diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts
index 637ecc213..c3a9e7256 100644
--- a/apps/admin-web/src/api/adminApiTypes.ts
+++ b/apps/admin-web/src/api/adminApiTypes.ts
@@ -1075,7 +1075,7 @@ export interface AdminRechargeRefundActionResponse {
export interface AdminWalletRestrictionResponse {
wallet: AdminProfileWalletPayload;
}
-export type AdminAgcAgentMode = 'codex' | 'cc';
+export type AdminAgcAgentMode = 'codex' | 'cc' | 'anthropic';
export interface AdminAgcModel {
id: string;
diff --git a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx
index c64efc67d..cab805cf7 100644
--- a/apps/admin-web/src/pages/AdminAgcModelsPage.tsx
+++ b/apps/admin-web/src/pages/AdminAgcModelsPage.tsx
@@ -226,6 +226,7 @@ export function AdminAgcModelsPage({
>
+
diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs
index 1b964573f..a7dda892c 100644
--- a/apps/ai-game-creator-shell/scripts/check-config.mjs
+++ b/apps/ai-game-creator-shell/scripts/check-config.mjs
@@ -140,6 +140,7 @@ const allowedUncalledTauriCommands = [
// 里以字面量出现,静态扫描仍按共享注册表核验。
'read_profile_recharge_center',
'read_profile_wallet_ledger',
+ 'read_profile_referral_invite_center',
'create_profile_recharge_order',
'confirm_wechat_profile_recharge_order',
'redeem_profile_reward_code',
diff --git a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs
index a4a4cde9e..5733c36c8 100644
--- a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs
+++ b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.mjs
@@ -156,7 +156,8 @@ function resolveTestExecutable() {
kind: options.targetKind,
name: options.binName,
});
- cargoArguments.push('--no-run', '--message-format=json');
+ // 保留 stdout 的 artifact JSON,并由 Cargo 将编译诊断输出到继承的 stderr。
+ cargoArguments.push('--no-run', '--message-format=json-render-diagnostics');
console.log(`[rust-shards] cargo ${cargoArguments.join(' ')}`);
const child = spawn('cargo', cargoArguments, {
diff --git a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.test.mjs b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.test.mjs
index 9eec73d7d..ce4c01c49 100644
--- a/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.test.mjs
+++ b/apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.test.mjs
@@ -62,6 +62,18 @@ fn failing_case() {
assert.match(result.output, /1 passed; 1 failed/);
});
+test('compile failure retains the diagnostic code, source location and cause', (t) => {
+ const result = runFixture(
+ t,
+ '#[test]\nfn compile_failure() { let _: u32 = "not a number"; }\n',
+ );
+ assert.equal(result.status, 1, result.output);
+ assert.match(result.output, /error\[E0308\]/);
+ assert.match(result.output, /src[\\/]lib\.rs:2:/);
+ assert.match(result.output, /mismatched types/);
+ assert.match(result.output, /expected `u32`, found `&str`/);
+});
+
test('successful shard keeps its compact summary', (t) => {
const result = runFixture(t, '#[test]\nfn passing_case() {}\n');
assert.equal(result.status, 0, result.output);
diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json
index d259be149..043ab9557 100644
--- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json
+++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/direct-tools.json
@@ -33,7 +33,7 @@
"agc_list_project_files.description": "列出当前 AGC 项目文件,包括尚未登记的本地资源。结果返回项目相对路径、大小、文件类别、是否已登记及 assetImportable。仅把 assetImportable=true 的项目相对路径交给 agc_import_account_assets.localPaths。",
"agc_list_project_files.parameters.path": "可选的项目相对目录前缀,例如 assets 或 game",
"agc_list_project_files.parameters.kind": "image/font/audio/video/document/code 是通用类别;model 是 Cocos 等引擎的三维模型数据,binary 是只能发现、当前无法在客户端预览的二进制资源",
- "agc_list_account_assets.description": "查询当前登录账户网页/云端素材库,以及当前项目已绑定网页画布 project.resources 中的静态图片。结果中的 assetId/resourceId 可交给 agc_import_account_assets;项目内本地资源用 agc_list_project_files 发现。",
+ "agc_list_account_assets.description": "查询当前登录账户网页/云端素材库,以及当前项目已绑定网页画布 project.resources 中的静态图片。可按 folderId 与 query(素材名或文件夹名)筛选;offset 为非负整数,limit 为 1–100(默认 100),按 nextOffset 继续翻页。账户素材总量不影响查询。结果中的 assetId/resourceId 可交给 agc_import_account_assets;项目内本地资源用 agc_list_project_files 发现。",
"agc_import_account_assets.description": "导入账户图片、已绑定网页项目画布图片或项目内本地资源。assetIds 必须使用 agc_list_account_assets 返回的账户 assetId 或 project-canvas resourceId;本地资源使用 agc_list_project_files 返回且 assetImportable=true 的项目根相对 localPaths。assetIds 与 localPaths 可混合提交。",
"agc_create_or_derive_resource.description": "按用户当前意图创建或派生视频、角色动画、音效或背景音乐。相同未完成请求会优先恢复;付费请求结果不确定时先核对状态,禁止重发。",
"agc_create_or_derive_resource.parameters.sourceLocalAssetId": "derive 时必须使用 agc_list_registered_assets 返回的当前项目 localAssetId",
diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/runtime.json b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/runtime.json
index f01bcdaae..2299b3df6 100644
--- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/runtime.json
+++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/texts/runtime.json
@@ -72,7 +72,7 @@
"file_tools_contract": "file.list 使用 {{\"path\":\"\"}},path 为空字符串时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}};file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}};file.delete 使用 {{\"path\":\"项目内相对路径\"}},只删除项目内普通文件,不删除目录或任何 .agent 控制面文件。\n",
"task_tools_contract": "task.create 使用 {{\"taskId\":null,\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[],\"artifacts\":[],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},需要自定义 taskId 时把 null 替换为合法 ID;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};{limited_command_contract}\n",
"canvas_generate_contract": "canvas.asset_generate 使用 {{\"prompt\":\"图片描述\",\"outputPath\":null,\"aspectRatio\":null,\"imageSize\":null,\"assetKind\":null,\"assetLabel\":null,\"replaceExisting\":false}};需要指定时,aspectRatio 只允许 1:1|2:3|3:2|9:16|16:9,imageSize 只允许 0.5K|1K|2K,assetKind 只允许 {canvas_asset_kind_catalog}。replaceExisting 只能在带 repairOfDelegationId 的唯一返工委派中设为 true,普通生成必须为 false,并通过配置的 External Editor API 同时写入画布、同名素材库目录和本地 assets。\n",
- "asset_library_contract": "asset.library.list 使用 {{\"folderId\":null,\"query\":null,\"offset\":0,\"limit\":100}} 查询当前登录账户的网页/云端静态图片,以及当前项目已绑定网页画布的 project.resources 图片;结果只含 assetId/resourceId 与安全展示元数据,不含 URL、objectKey、签名地址或凭据。账户或画布图片必须先查询再导入。file.list/asset.list 仍用于发现项目内尚未登记的本地资源。\n",
+ "asset_library_contract": "asset.library.list 使用 {{\"folderId\":null,\"query\":null,\"offset\":0,\"limit\":100}} 查询当前登录账户的网页/云端静态图片,以及当前项目已绑定网页画布的 project.resources 图片;offset 为非负整数,limit 为 1–100(默认 100),按 nextOffset 继续翻页,账户素材总量不影响查询。结果只含当前页 assetId/resourceId 与安全展示元数据,不含 URL、objectKey、签名地址或凭据。账户或画布图片必须先查询再导入。file.list/asset.list 仍用于发现项目内尚未登记的本地资源。\n",
"asset_import_contract": "canvas.asset_import 使用 {{\"assetIds\":[],\"localPaths\":[]}};assetIds 必须来自最近一次 asset.library.list(账户素材或 project-canvas 资源均可),localPaths 必须是 file.list 返回的项目根内相对已识别资源路径(包括 assets/ 与 game/),不能提交 objectKey、URL、绝对路径或凭据。两类数组可以混合提交;导入成功后资源即可用于正式产物。\n",
"collaboration_tools_contract": "blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"acceptanceCriteria\":[\"可核对的语义验收条件\"],\"expectedArtifacts\":[],\"repairOfDelegationId\":null,\"runId\":null}},expectedArtifacts 无产物时传空数组且不接受 glob;返工时 repairOfDelegationId 指向已认领原 delivery 且 runId 必须为 null;agent.schedule_ready 使用 {{\"limit\":1}};agent.run_status 使用 {{\"agentId\":null,\"scope\":\"all\",\"delegationId\":null}},指定目标 Agent 或已认领 delegation 时把对应 null 替换为实际 ID;当前可信父 Run 传 delegationId 时读取自己已认领的未截断权威返工合同。\n",
"empty_tool_inputs_contract": "只有 conversation.read、asset.list、project.index、project.checkpoint、task.list、preview.start 的 arguments.input 使用空对象 {{}};asset.library.list 也允许使用其广告 schema 中的全 null/分页默认值;其他函数必须提交实际广告 schema 的全部 required 字段。如果已有观察足够,必须调用 respond_to_user 交付最终回复。",
diff --git a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs
index 3f882e163..2cdc1b40d 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs
@@ -8,10 +8,12 @@ use crate::http_client::agc_main_site_client_builder;
use crate::platform_session::{current_platform_session, PlatformSessionSnapshot};
use reqwest::{Method, StatusCode};
use serde::de::DeserializeOwned;
+use serde::Serialize;
use serde_json::Value;
use shared_contracts::runtime::{
ConfirmWechatProfileRechargeOrderResponse, CreateProfileRechargeOrderResponse,
- ProfileRechargeCenterResponse, ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse,
+ ProfileRechargeCenterResponse, ProfileReferralInviteCenterResponse,
+ ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse,
};
use std::time::Duration;
use url::Url;
@@ -103,6 +105,29 @@ fn unwrap_envelope(body: &str, fallback: &str) -> Result {
Ok(value.get("data").cloned().unwrap_or(value))
}
+/// 客户端邀请码弹层需要的完整数据。
+///
+/// 后端只返回站内相对路径(`inviteLinkPath`),客户端要拿它当分享链接用,
+/// 所以这里用当前登录站点 origin 补全成完整 URL 再交给渲染层。
+#[derive(Clone, Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct ClientProfileReferralInviteCenter {
+ pub(crate) center: ProfileReferralInviteCenterResponse,
+ pub(crate) invite_url: String,
+}
+
+fn resolve_invite_url(api_base_url: &str, invite_link_path: &str) -> String {
+ if invite_link_path.trim().is_empty() {
+ return api_base_url.trim_end_matches('/').to_string();
+ }
+ match Url::parse(&format!("{}/", api_base_url.trim_end_matches('/')))
+ .and_then(|base| base.join(invite_link_path))
+ {
+ Ok(url) => url.to_string(),
+ Err(_) => invite_link_path.to_string(),
+ }
+}
+
async fn request_json(
client: &reqwest::Client,
snapshot: &PlatformSessionSnapshot,
@@ -171,6 +196,24 @@ pub(crate) async fn read_profile_wallet_ledger() -> Result Result {
+ let snapshot = require_session()?;
+ let client = build_client()?;
+ let center: ProfileReferralInviteCenterResponse = request_json(
+ &client,
+ &snapshot,
+ Method::GET,
+ &["api", "profile", "referrals", "invite-center"],
+ None,
+ "读取邀请码失败",
+ )
+ .await?;
+ let invite_url = resolve_invite_url(&snapshot.api_base_url, ¢er.invite_link_path);
+ Ok(ClientProfileReferralInviteCenter { center, invite_url })
+}
+
#[tauri::command]
pub(crate) async fn create_profile_recharge_order(
product_id: String,
@@ -314,4 +357,20 @@ mod tests {
assert!(bounded_business_id(&"x".repeat(MAX_BUSINESS_ID_CHARS + 1), "订单").is_err());
assert!(bounded_business_id("bad\nid", "订单").is_err());
}
+
+ #[test]
+ fn invite_link_resolves_against_login_origin() {
+ assert_eq!(
+ resolve_invite_url("https://dev.genarrative.world", "/?inviteCode=SY12345678"),
+ "https://dev.genarrative.world/?inviteCode=SY12345678"
+ );
+ assert_eq!(
+ resolve_invite_url("https://dev.genarrative.world/", "invite/SY12345678"),
+ "https://dev.genarrative.world/invite/SY12345678"
+ );
+ assert_eq!(
+ resolve_invite_url("https://dev.genarrative.world", ""),
+ "https://dev.genarrative.world"
+ );
+ }
}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs
index 641ace7bb..263b314da 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs
@@ -352,7 +352,21 @@ fn copy_env(command: &mut tokio::process::Command, name: &str) {
fn claude_base_url(llm: Option<&GameCreatorLlmConfig>) -> Option {
std::env::var("ANTHROPIC_BASE_URL")
.ok()
- .or_else(|| llm.map(|value| value.base_url.trim_end_matches('/').to_string()))
+ .or_else(|| {
+ let llm = llm?;
+ // Official models never talk to the Router directly: the account
+ // Router credential lives server-side, so the Claude sidecar goes
+ // through the platform gateway and appends `/v1/messages` itself.
+ if !llm.custom_enabled {
+ if let Some(session) = crate::platform_session::current_platform_session() {
+ return Some(format!(
+ "{}/api/llm/anthropic",
+ session.api_base_url.trim_end_matches('/')
+ ));
+ }
+ }
+ Some(llm.base_url.trim_end_matches('/').to_string())
+ })
.map(|value| {
value
.trim_end_matches('/')
@@ -898,7 +912,6 @@ mod tests {
let error = parse_direct_stream_result(
br#"{"type":"result","is_error":true,"result":"Authentication failed"}
"#,
- "turn-1",
None,
)
.expect_err("failed terminal must be rejected");
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs
index 0b0cbb827..d81e102aa 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs
@@ -2351,7 +2351,7 @@ impl CodexAppServerConnection {
)
})?;
effective_llm.base_url =
- format!("{}/api/llm", session.api_base_url.trim_end_matches('/'));
+ format!("{}/api/llm/v1", session.api_base_url.trim_end_matches('/'));
effective_llm.api_key.clear();
// Only the platform catalog identifier reaches Codex. api-server
// validates it and resolves the actual upstream model.
@@ -2619,7 +2619,7 @@ impl CodexAppServerConnection {
..
} => (
Some((
- format!("{}/api/llm", api_base_url.trim_end_matches('/')),
+ format!("{}/api/llm/v1", api_base_url.trim_end_matches('/')),
access_token.clone(),
)),
true,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs
index 1300be1b5..4940fbfec 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs
@@ -668,9 +668,6 @@ fn bridge_account_asset_page(arguments: &Value) -> Result<(usize, usize), String
})
.transpose()?
.unwrap_or(0);
- if offset > 500 {
- return Err("工具参数 offset 不能超过 500".to_string());
- }
let limit = arguments
.get("limit")
.filter(|value| !value.is_null())
@@ -1232,6 +1229,9 @@ fn bridge_list_project_files(root: &Path, arguments: &Value) -> Value {
return Err("工具参数 kind 不是受支持的项目文件类别".to_string());
}
let (offset, limit) = bridge_account_asset_page(arguments)?;
+ if offset > 500 {
+ return Err("工具参数 offset 不能超过 500".to_string());
+ }
let manifest = read_existing_manifest_for_project(root)?;
let registered_ids = manifest
.assets
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs
index 35c166d53..95feedd00 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs
@@ -443,7 +443,7 @@ fn direct_tools_mcp_specs_for_plugins(
"properties": {
"folderId": { "type": "string", "minLength": 1, "maxLength": 512 },
"query": { "type": "string", "minLength": 1, "maxLength": 120 },
- "offset": { "type": "integer", "minimum": 0, "maximum": 500 },
+ "offset": { "type": "integer", "minimum": 0 },
"limit": { "type": "integer", "minimum": 1, "maximum": 100 }
},
"additionalProperties": false
@@ -981,15 +981,6 @@ fn validate_account_asset_library_arguments(arguments: &Value) -> Result<(), Str
{
return Err("工具参数 offset 必须是非负整数".to_string());
}
- if let Some(offset) = arguments
- .get("offset")
- .filter(|value| !value.is_null())
- .and_then(Value::as_u64)
- {
- if offset > 500 {
- return Err("工具参数 offset 不能超过 500".to_string());
- }
- }
if let Some(limit) = arguments.get("limit").filter(|value| !value.is_null()) {
let limit = limit
.as_u64()
@@ -3283,6 +3274,16 @@ mod tests {
#[test]
fn semantic_resource_tools_reject_unreviewed_or_inconsistent_arguments() {
+ let specs = direct_tools_mcp_specs_for(false, false);
+ let account_tool = specs["tools"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .find(|tool| tool["name"] == "agc_list_account_assets")
+ .unwrap();
+ let validator = jsonschema::validator_for(&account_tool["inputSchema"]).unwrap();
+ assert!(validator.is_valid(&json!({"offset": 501, "limit": 1})));
+ assert!(!validator.is_valid(&json!({"offset": 501, "limit": 101})));
assert!(validate_registered_assets_arguments(&json!({
"kind": "character-animation",
"includeSequenceFrames": true,
@@ -3320,7 +3321,10 @@ mod tests {
"limit": 100
}))
.is_ok());
- assert!(validate_account_asset_library_arguments(&json!({ "offset": 501 })).is_err());
+ assert!(validate_account_asset_library_arguments(&json!({ "offset": 501 })).is_ok());
+ assert!(validate_account_asset_library_arguments(&json!({ "offset": -1 })).is_err());
+ assert!(validate_account_asset_library_arguments(&json!({ "offset": 1.5 })).is_err());
+ assert!(validate_account_asset_library_arguments(&json!({ "limit": 101 })).is_err());
assert!(validate_account_asset_library_arguments(&json!({ "unknown": true })).is_err());
assert!(validate_account_asset_import_arguments(&json!({
"assetIds": ["asset-1"],
@@ -3903,6 +3907,233 @@ mod tests {
.unwrap_or_else(|error| panic!("工具返回不是 JSON:{error};{result}"))
}
+ /// 大库仍通过真实工具桥分页和按 ID 导入,平台私有字段不进入工具返回。
+ #[tokio::test]
+ async fn account_asset_large_library_tool_chain_pages_filters_and_imports() {
+ let temporary = crate::tests::canonical_test_tempdir("direct-tools-account-large-library-");
+ let root = temporary.path();
+ init_local_game_project_at(root, "account-large-library", "账户大库工具链")
+ .expect("init project");
+ let mut assets = (0..503)
+ .map(|index| {
+ json!({
+ "assetId": format!("asset-{index:04}"),
+ "folderId": if index < 501 { "folder-main" } else { "folder-target" },
+ "label": format!("素材-{index:04}"),
+ "objectKey": format!("private/account-{index:04}.png"),
+ "imageSrc": format!("/private/account-{index:04}.png"),
+ "assetObjectId": format!("private-object-{index:04}"),
+ "assetKind": "image",
+ "width": 2,
+ "height": 2
+ })
+ })
+ .collect::>();
+ assets.push(json!({
+ "assetId": "unsupported-video",
+ "folderId": "folder-main",
+ "label": "不支持的视频",
+ "objectKey": "private/video.mp4",
+ "assetKind": "video"
+ }));
+ let library = json!({"data": {"library": {
+ "folders": [
+ {"folderId": "folder-main", "label": "图标目录"},
+ {"folderId": "folder-target", "label": "背景目录"}
+ ],
+ "assets": assets
+ }}});
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+ .await
+ .expect("bind account fixture");
+ let base_url = format!("http://{}", listener.local_addr().expect("fixture address"));
+ let media_url = format!("{base_url}/account-image.png");
+ let png = tool_chain_png();
+ let app = Router::new()
+ .route(
+ "/api/editor/assets/library",
+ axum::routing::get(move |headers: HeaderMap| {
+ let library = library.clone();
+ async move {
+ assert_eq!(
+ headers
+ .get(axum::http::header::AUTHORIZATION)
+ .and_then(|value| value.to_str().ok()),
+ Some("Bearer tool-chain-token")
+ );
+ Json(library)
+ }
+ }),
+ )
+ .route(
+ "/api/assets/read-url",
+ axum::routing::get(
+ move |axum::extract::Query(query): axum::extract::Query<
+ std::collections::HashMap,
+ >| {
+ let media_url = media_url.clone();
+ async move {
+ assert_eq!(
+ query.get("objectKey").map(String::as_str),
+ Some("private/account-0502.png")
+ );
+ Json(json!({"data": {"read": {"signedUrl": media_url}}}))
+ }
+ },
+ ),
+ )
+ .route(
+ "/account-image.png",
+ axum::routing::get(move || {
+ let png = png.clone();
+ async move { ([(axum::http::header::CONTENT_TYPE, "image/png")], png) }
+ }),
+ );
+ let server = tokio::spawn(async move { axum::serve(listener, app).await });
+ let _session = crate::platform_session::install_test_platform_session(
+ "account-large-library-owner",
+ TOOL_CHAIN_ACCESS_TOKEN,
+ &base_url,
+ );
+ let (bridge, _turn_arm, _execution) = tool_chain_start(root).await;
+ let _turn = bridge.begin_user_turn().expect("begin client turn");
+
+ for (arguments, expected_total, expected_id, expected_next) in [
+ (
+ json!({"offset": 501, "limit": 1}),
+ 503,
+ "asset-0501",
+ json!(502),
+ ),
+ (
+ json!({"offset": 502, "limit": 1}),
+ 503,
+ "asset-0502",
+ Value::Null,
+ ),
+ (
+ json!({"folderId": "folder-target", "query": "素材-0502", "limit": 1}),
+ 1,
+ "asset-0502",
+ Value::Null,
+ ),
+ (
+ json!({"query": "背景目录", "limit": 1}),
+ 2,
+ "asset-0501",
+ json!(1),
+ ),
+ ] {
+ let result = tool_chain_call(&bridge, root, "agc_list_account_assets", arguments).await;
+ assert_eq!(result["result"]["isError"], false, "{result}");
+ assert_eq!(result["result"]["content"].as_array().unwrap().len(), 1);
+ let payload = tool_chain_payload(&result);
+ assert_eq!(payload["total"], expected_total, "{payload}");
+ assert_eq!(payload["limit"], 1);
+ assert_eq!(payload["assets"].as_array().unwrap().len(), 1);
+ assert_eq!(payload["assets"][0]["assetId"], expected_id);
+ assert_eq!(payload["nextOffset"], expected_next);
+ let text = result.to_string();
+ for private in [
+ "private/account-",
+ "private-object-",
+ TOOL_CHAIN_ACCESS_TOKEN,
+ &base_url,
+ ] {
+ assert!(!text.contains(private), "私有字段进入工具返回:{private}");
+ }
+ }
+ for offset in [503, usize::MAX] {
+ let result = tool_chain_call(
+ &bridge,
+ root,
+ "agc_list_account_assets",
+ json!({"offset": offset}),
+ )
+ .await;
+ assert_eq!(result["result"]["isError"], false, "{result}");
+ let payload = tool_chain_payload(&result);
+ assert_eq!(payload["total"], 503);
+ assert_eq!(payload["offset"], json!(offset));
+ assert_eq!(payload["limit"], 100);
+ assert_eq!(payload["assets"], json!([]));
+ assert_eq!(payload["nextOffset"], Value::Null);
+ }
+ let runtime = crate::agent::observe_agent_runtime_account_asset_library(
+ root,
+ &json!({"offset": 501, "limit": 1}),
+ )
+ .await;
+ assert_eq!(runtime.status, "ok", "{}", runtime.summary);
+ let runtime_payload: Value =
+ serde_json::from_str(runtime.detail.as_deref().unwrap()).unwrap();
+ assert_eq!(runtime_payload["assets"][0]["assetId"], "asset-0501");
+ assert_eq!(runtime_payload["nextOffset"], 502);
+
+ let snapshot = crate::commands::read_editor_asset_library()
+ .await
+ .expect("read large account library for UI");
+ assert_eq!(snapshot.assets.len(), 503);
+ assert!(snapshot
+ .assets
+ .iter()
+ .any(|asset| asset.asset_id == "asset-0502" && asset.preview_available));
+ let preview = crate::commands::read_editor_asset_preview("asset-0502".to_string())
+ .await
+ .expect("preview image from large account library");
+ assert!(preview.preview_url.starts_with("data:image/png;base64,"));
+
+ let imported = tool_chain_call(
+ &bridge,
+ root,
+ "agc_import_account_assets",
+ json!({"assetIds": ["asset-0502"]}),
+ )
+ .await;
+ assert_eq!(imported["result"]["isError"], false, "{imported}");
+ let imported_payload = tool_chain_payload(&imported);
+ assert_eq!(imported_payload["status"], "completed");
+ assert_eq!(imported_payload["revisionAdvanceCount"], 1);
+ let imported_asset = &imported_payload["imported"][0];
+ let local_path = imported_asset["localPath"].as_str().expect("imported path");
+ assert_eq!(
+ std::fs::read(root.join(local_path)).unwrap(),
+ tool_chain_png()
+ );
+ let manifest_before = std::fs::read(root.join(".agent/manifest.json")).unwrap();
+
+ let repeated = tool_chain_call(
+ &bridge,
+ root,
+ "agc_import_account_assets",
+ json!({"assetIds": ["asset-0502"]}),
+ )
+ .await;
+ assert_eq!(repeated["result"]["isError"], false, "{repeated}");
+ let repeated_payload = tool_chain_payload(&repeated);
+ assert_eq!(repeated_payload["imported"][0]["id"], imported_asset["id"]);
+ assert_eq!(repeated_payload["revisionAdvanceCount"], 0);
+ for id in ["missing-asset", "unsupported-video"] {
+ let rejected = tool_chain_call(
+ &bridge,
+ root,
+ "agc_import_account_assets",
+ json!({"assetIds": [id]}),
+ )
+ .await;
+ assert_eq!(rejected["result"]["isError"], true, "{rejected}");
+ let rejected_payload = tool_chain_payload(&rejected);
+ assert_eq!(rejected_payload["status"], "failed");
+ assert_eq!(rejected_payload["imported"], json!([]));
+ assert_eq!(rejected_payload["revisionAdvanceCount"], 0);
+ }
+ assert_eq!(
+ std::fs::read(root.join(".agent/manifest.json")).unwrap(),
+ manifest_before
+ );
+ server.abort();
+ }
+
/// P1 门禁:工具 schema 的 per-kind 上限、MCP 校验、工具桥校验与客户端权威口径必须是同一个数字。
#[test]
fn tool_prompt_limits_agree_with_the_client_authority() {
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs
index cbcc08d97..f888c3f61 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/context.rs
@@ -326,12 +326,16 @@ pub(in crate::agent) async fn observe_agent_runtime_account_asset_library(
}
}
if input.get("offset").is_some_and(|value| {
- !value.is_null() && (value.as_u64().is_none() || value.as_u64() > Some(500))
+ !value.is_null()
+ && value
+ .as_u64()
+ .and_then(|offset| usize::try_from(offset).ok())
+ .is_none()
}) {
return AgentRuntimeToolObservation {
tool: "asset.library.list".to_string(),
status: "rejected".to_string(),
- summary: "工具参数 offset 必须是 0 到 500 的非负整数".to_string(),
+ summary: "工具参数 offset 必须是非负整数".to_string(),
detail: None,
};
}
diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs
index 8262ccb0c..7fa421b6a 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs
@@ -1137,7 +1137,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
"properties": {
"folderId": { "type": ["string", "null"], "maxLength": 512 },
"query": { "type": ["string", "null"], "maxLength": 120 },
- "offset": { "type": ["integer", "null"], "minimum": 0, "maximum": 500 },
+ "offset": { "type": ["integer", "null"], "minimum": 0 },
"limit": { "type": ["integer", "null"], "minimum": 1, "maximum": 100 }
}
}),
@@ -2006,6 +2006,40 @@ mod tests {
assert!(issues.is_empty(), "{}", issues.join("\n"));
}
+ #[test]
+ fn account_asset_native_function_schema_accepts_later_pages() {
+ let functions = build_agent_runtime_native_function_tools().unwrap();
+ let function = functions
+ .iter()
+ .find(|function| {
+ function.name == native_runtime_function_name_for_tool("asset.library.list")
+ })
+ .expect("account asset native function");
+ assert!(function.strict);
+ let validator = jsonschema::validator_for(&function.parameters).unwrap();
+ let mut arguments = json!({
+ "reason": "读取账户素材后续页",
+ "input": { "folderId": null, "query": null, "offset": null, "limit": null }
+ });
+ assert!(validator.is_valid(&arguments));
+ for offset in [0, 500, 501, 600, 10_000] {
+ for limit in [1, 100] {
+ arguments["input"]["offset"] = json!(offset);
+ arguments["input"]["limit"] = json!(limit);
+ assert!(validator.is_valid(&arguments), "{arguments}");
+ }
+ }
+ for offset in [json!(-1), json!(501.5)] {
+ arguments["input"]["offset"] = offset;
+ assert!(!validator.is_valid(&arguments), "{arguments}");
+ }
+ arguments["input"]["offset"] = json!(600);
+ for limit in [0, 101] {
+ arguments["input"]["limit"] = json!(limit);
+ assert!(!validator.is_valid(&arguments), "{arguments}");
+ }
+ }
+
#[test]
fn canvas_asset_generate_schema_uses_shared_asset_kind_catalog() {
let schema = runtime_tool_input_schema("canvas.asset_generate");
diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs
index d77fc38b5..b5c910a84 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs
@@ -15,9 +15,9 @@ const UI_EDITOR_FONT_MAX_COUNT: usize = 64;
const UI_EDITOR_IMAGE_MAX_FILE_SIZE: u64 = 20 * 1024 * 1024;
const UI_EDITOR_IMAGE_MAX_TOTAL_SIZE: u64 = 256 * 1024 * 1024;
const UI_EDITOR_IMAGE_MAX_COUNT: usize = 100;
-// Agent 查询/导入账户素材使用比 UI 更窄的投影和同一批次上限。原始 objectKey、
+// Agent 查询/导入账户素材只接收安全投影,账户总量不作为拒绝条件。原始 objectKey、
// imageSrc 与签名地址只在本文件的客户端下载阶段存在,绝不进入 Agent observation。
-const AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS: usize = 500;
+const AGENT_EDITOR_PROJECT_RESOURCE_MAX_ITEMS: usize = 500;
const AGENT_EDITOR_ASSET_ID_MAX_CHARS: usize = 512;
const EDITOR_ASSET_PREVIEW_MAX_BYTES: usize = 8 * 1024 * 1024;
const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000;
@@ -1098,7 +1098,8 @@ fn select_game_creator_model_inner(
if let Some(agent_mode) = catalog_agent_mode {
config.agent_mode = match agent_mode.trim() {
"codex" => GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string(),
- "cc" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(),
+ // `cc` 是 Claude 执行器的历史别名,`anthropic` 是同一执行器的显式协议值。
+ "cc" | "anthropic" => GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI.to_string(),
value => return Err(format!("模型 Agent 模式无效:{value}")),
};
}
@@ -1730,6 +1731,33 @@ mod agent_asset_import_tests {
assert_eq!(records[0].object_key.as_deref(), Some("private/button.png"));
}
+ #[test]
+ fn account_library_parser_keeps_large_account_snapshots_available() {
+ for count in [500, 501] {
+ let assets = (0..count)
+ .map(|index| {
+ serde_json::json!({
+ "assetId": format!("image-{index:04}"),
+ "label": format!("图片-{index:04}"),
+ "objectKey": format!("private/{index}.png"),
+ "assetKind": "image"
+ })
+ })
+ .collect::>();
+ let payload = serde_json::json!({
+ "data": { "library": { "folders": [], "assets": assets } }
+ });
+ let records = parse_agent_editor_asset_library(&payload).expect("parse large library");
+ assert_eq!(records.len(), count);
+ assert_eq!(
+ records.last().unwrap().asset_id,
+ format!("image-{:04}", count - 1)
+ );
+ let snapshot = editor_asset_library_snapshot_from_records(&records);
+ assert_eq!(snapshot.assets.len(), count);
+ }
+ }
+
#[test]
fn project_resource_parser_requires_matching_project_and_filters_sequences() {
let payload = serde_json::json!({
@@ -2376,13 +2404,6 @@ fn parse_agent_editor_asset_library(
.and_then(serde_json::Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
- if assets.len() > AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS {
- return Err(format!(
- "账户素材库数量超过 {} 项安全上限",
- AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS
- ));
- }
-
let mut records = Vec::with_capacity(assets.len());
let mut seen_ids = std::collections::BTreeSet::new();
for asset in assets {
@@ -2473,10 +2494,10 @@ fn parse_agent_editor_project_resources(
.get("resources")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| "网页项目响应缺少 resources".to_string())?;
- if resources.len() > AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS {
+ if resources.len() > AGENT_EDITOR_PROJECT_RESOURCE_MAX_ITEMS {
return Err(format!(
"网页项目画布资源数量超过 {} 项安全上限",
- AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS
+ AGENT_EDITOR_PROJECT_RESOURCE_MAX_ITEMS
));
}
@@ -3480,18 +3501,17 @@ pub(crate) async fn import_account_editor_assets_for_agent_with_write_permit(
.as_ref()
.map(|session| acquire_platform_session_identity_lease(&session.identity()))
.transpose()?;
- commit_account_editor_asset_downloads(root, &access, downloads, write_permit)
+ // 身份租约覆盖本地提交;持租约期间不能再次读取登录态互斥锁。
+ commit_account_editor_asset_downloads(root, downloads, write_permit)
}
fn commit_account_editor_asset_downloads(
root: &Path,
- access: &ExternalEditorBindingAccess<'_>,
downloads: Vec<(AgentEditorAssetRecord, String, String, Vec)>,
write_permit: Option<&crate::agent::WritePermit>,
) -> Result {
let _lock = acquire_project_write_lock(root, "canvas.asset_import")?;
let commit = || {
- access.validate_frozen_session()?;
let manifest = read_existing_manifest_for_project(root)?;
let mut imported = Vec::with_capacity(downloads.len());
for (record, media_type, local_path, bytes) in downloads {
@@ -3610,11 +3630,8 @@ pub(crate) fn commit_account_asset_for_write_permit_test(
height: Some(1),
size_bytes: Some(bytes.len() as u64),
};
- let access =
- ExternalEditorBindingAccess::for_developer("https://fixture.invalid", "fixture-key")?;
commit_account_editor_asset_downloads(
root,
- &access,
vec![(
record,
"image/png".into(),
diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs
index 8a16b71a8..6eb153861 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/config.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs
@@ -222,7 +222,8 @@ fn build_game_creator_official_platform_llm_config(
if api_base_url.is_empty() {
return Err("登录态缺少 API Server 地址".to_string());
}
- let proxy_base_url = format!("{api_base_url}/api/llm");
+ // baseUrl 不带路由与版本段,OpenAI 协议由 platform-llm 自己拼 `v1/`。
+ let proxy_base_url = format!("{api_base_url}/api/llm/v1");
LlmConfig::new(
LlmProvider::OpenAiCompatible,
proxy_base_url,
diff --git a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs
index 29268e088..0c479c168 100644
--- a/apps/ai-game-creator-shell/src-tauri/src/desktop.rs
+++ b/apps/ai-game-creator-shell/src-tauri/src/desktop.rs
@@ -638,6 +638,7 @@ pub(super) fn run() {
set_client_server_selection,
read_profile_recharge_center,
read_profile_wallet_ledger,
+ read_profile_referral_invite_center,
create_profile_recharge_order,
confirm_wechat_profile_recharge_order,
redeem_profile_reward_code,
diff --git a/apps/ai-game-creator-shell/src/features/app-shell/AccountReferral.tsx b/apps/ai-game-creator-shell/src/features/app-shell/AccountReferral.tsx
new file mode 100644
index 000000000..1ab5f56b7
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/features/app-shell/AccountReferral.tsx
@@ -0,0 +1,48 @@
+import { PlatformProfileReferralModal } from '../../../../../packages/shared/src/components/PlatformProfileReferralModal';
+import type {
+ AccountReferralController,
+ AccountReferralPanel,
+} from './useAccountReferral';
+
+/** 客户端账号菜单的入口文案是「邀请码 / 玩家社区」,弹层标题跟着入口走。 */
+const CLIENT_REFERRAL_PANEL_TITLE: Record = {
+ invite: '邀请码',
+ community: '玩家社区',
+};
+
+/**
+ * 账号菜单的邀请码 / 玩家社区弹层。
+ *
+ * 弹层实现与主站「我的」页签共用 `packages/shared` 里的同一份
+ * `PlatformProfileReferralModal`(与充值、泥点账单同口径);这里只做宿主适配:
+ * 把 Tauri typed command 读到的邀请中心、会话 origin 补全后的邀请链接、宿主剪贴板
+ * 复制回显和面板开关接到共享组件上,不再另写一份客户端专属弹层。
+ */
+export function AccountReferralDialogs({
+ controller,
+}: {
+ controller: AccountReferralController;
+}) {
+ if (!controller.panel) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
index 145e33445..3af7db68f 100644
--- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
+++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx
@@ -41,6 +41,7 @@ import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
import { projectPathsMatchForInvalidation } from '../project-summary/projectPath';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
import { useTemplateLibrary } from '../template-library/useTemplateLibrary';
+import { AccountReferralDialogs } from './AccountReferral';
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
import {
type LauncherNotice,
@@ -52,6 +53,7 @@ import type {
WorkspaceLauncherShellProps,
} from './model';
import { NonEmptyProjectDialog, ProjectsPage } from './ProjectCreation';
+import { useAccountReferral } from './useAccountReferral';
import { useAccountWallet } from './useAccountWallet';
import {
DESIGN_ARTIFACTS_BUILD_PROMPT,
@@ -73,6 +75,7 @@ export function WorkspaceLauncherShell({
setActiveProjectRuns,
} = useWindowChrome();
const accountWallet = useAccountWallet(currentUser.id);
+ const accountReferral = useAccountReferral(currentUser.id);
const [status, setStatus] = useState('');
const recentProjects = useRecentProjects(setStatus);
const { recentProjectRows, rememberRecentWorkspace } = recentProjects;
@@ -741,6 +744,11 @@ export function WorkspaceLauncherShell({
}}
onNoticeRequest={showLauncherNotice}
onRechargeRequest={accountWallet.openRecharge}
+ onReferralRequest={(panel) =>
+ panel === 'invite'
+ ? accountReferral.openInvite()
+ : accountReferral.openCommunity()
+ }
onRuntimeConfigOpen={() => setRuntimeConfigOpen(true)}
onViewChange={navigateLauncher}
/>
@@ -963,6 +971,7 @@ export function WorkspaceLauncherShell({
/>
) : null}
+
{walletSlot ? (
createPortal(
,
diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts
new file mode 100644
index 000000000..76c99d35c
--- /dev/null
+++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountReferral.ts
@@ -0,0 +1,143 @@
+import { writeText } from '@tauri-apps/plugin-clipboard-manager';
+import { useEffect, useLayoutEffect, useRef, useState } from 'react';
+
+import type { PlatformProfileReferralCopyState } from '../../../../../packages/shared/src/components/PlatformProfileReferralModal';
+import type { ProfileReferralInviteCenterResponse } from '../../../../../packages/shared/src/contracts/runtime';
+import { getClientProfileReferralInviteCenter } from '../../services/accountHost';
+
+export type AccountReferralPanel = 'invite' | 'community';
+
+/**
+ * 客户端账号菜单的邀请码 / 玩家社区弹层控制器。
+ *
+ * 邀请码是账号级数据,和泥点余额一样按登录账号隔离:切换账号就丢弃上一位用户的
+ * 邀请中心和复制回显;读取过程中换账号时,晚到的响应直接作废。
+ */
+export function useAccountReferral(currentUserId: string) {
+ const [panel, setPanel] = useState(null);
+ const [center, setCenter] =
+ useState(null);
+ const [inviteUrl, setInviteUrl] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [copyInviteLinkState, setCopyInviteLinkState] =
+ useState('idle');
+ const [copyInviteCodeState, setCopyInviteCodeState] =
+ useState('idle');
+ const lifecycleRef = useRef(0);
+ const currentUserIdRef = useRef(currentUserId);
+
+ useLayoutEffect(() => {
+ currentUserIdRef.current = currentUserId;
+ lifecycleRef.current += 1;
+ }, [currentUserId]);
+
+ useEffect(() => {
+ lifecycleRef.current += 1;
+ setPanel(null);
+ setCenter(null);
+ setInviteUrl(null);
+ setLoading(false);
+ setError(null);
+ setCopyInviteLinkState('idle');
+ setCopyInviteCodeState('idle');
+ }, [currentUserId]);
+
+ async function loadInviteCenter(lifecycle: number, owner: string) {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await getClientProfileReferralInviteCenter();
+ if (
+ lifecycleRef.current !== lifecycle ||
+ currentUserIdRef.current !== owner
+ ) {
+ return;
+ }
+ setCenter(response.center);
+ setInviteUrl(response.inviteUrl);
+ } catch (loadError) {
+ if (
+ lifecycleRef.current !== lifecycle ||
+ currentUserIdRef.current !== owner
+ ) {
+ return;
+ }
+ setCenter(null);
+ setInviteUrl(null);
+ setError(
+ loadError instanceof Error ? loadError.message : '读取邀请码失败',
+ );
+ } finally {
+ if (
+ lifecycleRef.current === lifecycle &&
+ currentUserIdRef.current === owner
+ ) {
+ setLoading(false);
+ }
+ }
+ }
+
+ function openPanel(nextPanel: AccountReferralPanel) {
+ const lifecycle = ++lifecycleRef.current;
+ setPanel(nextPanel);
+ setError(null);
+ setCopyInviteLinkState('idle');
+ setCopyInviteCodeState('idle');
+ if (nextPanel !== 'invite') {
+ return;
+ }
+ void loadInviteCenter(lifecycle, currentUserId);
+ }
+
+ function closePanel() {
+ lifecycleRef.current += 1;
+ setPanel(null);
+ setLoading(false);
+ setCopyInviteLinkState('idle');
+ setCopyInviteCodeState('idle');
+ }
+
+ async function copyValue(
+ applyState: (state: PlatformProfileReferralCopyState) => void,
+ value: string,
+ ) {
+ try {
+ await writeText(value);
+ applyState('copied');
+ } catch {
+ applyState('failed');
+ }
+ }
+
+ function copyInviteCode() {
+ if (!center?.inviteCode) {
+ return;
+ }
+ void copyValue(setCopyInviteCodeState, center.inviteCode);
+ }
+
+ function copyInviteLink() {
+ if (!inviteUrl) {
+ return;
+ }
+ void copyValue(setCopyInviteLinkState, inviteUrl);
+ }
+
+ return {
+ center,
+ copyInviteCodeState,
+ copyInviteCode,
+ copyInviteLink,
+ copyInviteLinkState,
+ error,
+ inviteUrl,
+ loading,
+ panel,
+ openInvite: () => openPanel('invite'),
+ openCommunity: () => openPanel('community'),
+ closePanel,
+ };
+}
+
+export type AccountReferralController = ReturnType;
diff --git a/apps/ai-game-creator-shell/src/services/accountHost.ts b/apps/ai-game-creator-shell/src/services/accountHost.ts
index 479874be9..da82f4168 100644
--- a/apps/ai-game-creator-shell/src/services/accountHost.ts
+++ b/apps/ai-game-creator-shell/src/services/accountHost.ts
@@ -2,6 +2,7 @@ import type {
ConfirmWechatProfileRechargeOrderResponse,
CreateProfileRechargeOrderResponse,
ProfileRechargeCenterResponse,
+ ProfileReferralInviteCenterResponse,
ProfileWalletLedgerResponse,
RedeemProfileRewardCodeResponse,
} from '../../../../packages/shared/src/contracts/runtime';
@@ -40,6 +41,17 @@ export function getClientProfileWalletLedger() {
);
}
+export type ClientProfileReferralInviteCenterResponse = {
+ center: ProfileReferralInviteCenterResponse;
+ inviteUrl: string;
+};
+
+export function getClientProfileReferralInviteCenter() {
+ return invokeAccount(
+ 'read_profile_referral_invite_center',
+ );
+}
+
export function createClientProfileRechargeOrder(productId: string) {
return invokeAccount(
'create_profile_recharge_order',
diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts
index 2f4de6483..2221e5cfd 100644
--- a/apps/ai-game-creator-shell/src/services/clientApi.ts
+++ b/apps/ai-game-creator-shell/src/services/clientApi.ts
@@ -3,7 +3,7 @@ import { resolveTauriInvoke } from '../app/tauri';
export type ClientLlmModel = {
displayName: string;
id: string;
- agentMode: 'codex' | 'cc';
+ agentMode: 'codex' | 'cc' | 'anthropic';
};
export type ClientLlmModelCatalog = {
diff --git a/apps/ai-game-creator-shell/src/view/layout.tsx b/apps/ai-game-creator-shell/src/view/layout.tsx
index 642cd7a87..621c98708 100644
--- a/apps/ai-game-creator-shell/src/view/layout.tsx
+++ b/apps/ai-game-creator-shell/src/view/layout.tsx
@@ -46,6 +46,7 @@ type LauncherSidebarProps = {
onViewChange: (view: LauncherView) => void;
onNoticeRequest: (title: string) => void;
onRechargeRequest: () => void;
+ onReferralRequest: (panel: 'invite' | 'community') => void;
onRuntimeConfigOpen: () => void;
onLogout: () => void;
};
@@ -56,6 +57,7 @@ type SidebarAccountMenuProps = {
onLogout: () => void;
onNoticeRequest: (title: string) => void;
onRechargeRequest: () => void;
+ onReferralRequest: (panel: 'invite' | 'community') => void;
};
function formatMudPoints(value: number | null | undefined) {
@@ -71,6 +73,7 @@ function SidebarAccountMenu({
onLogout,
onNoticeRequest,
onRechargeRequest,
+ onReferralRequest,
}: SidebarAccountMenuProps) {
const walletBalance = useWalletStore(
(state) => state.mudPointBalance?.totalPoints ?? null,
@@ -164,6 +167,28 @@ function SidebarAccountMenu({
>
使用指南
+
+
) : null}
diff --git a/apps/ai-game-creator-shell/tests/accountHost.test.ts b/apps/ai-game-creator-shell/tests/accountHost.test.ts
index 42f92ba60..7912089d3 100644
--- a/apps/ai-game-creator-shell/tests/accountHost.test.ts
+++ b/apps/ai-game-creator-shell/tests/accountHost.test.ts
@@ -27,6 +27,7 @@ import {
confirmClientWechatProfileRechargeOrder,
createClientProfileRechargeOrder,
getClientProfileRechargeCenter,
+ getClientProfileReferralInviteCenter,
getClientProfileWalletLedger,
redeemClientProfileRewardCode,
} from '../src/services/accountHost';
@@ -47,6 +48,7 @@ test('账户读取只提交结构化参数,不携带 token 或远端地址', a
await getClientProfileRechargeCenter();
await getClientProfileWalletLedger();
+ await getClientProfileReferralInviteCenter();
await createClientProfileRechargeOrder('product-1');
await confirmClientWechatProfileRechargeOrder('order-1');
await redeemClientProfileRewardCode('CODE-1');
@@ -54,6 +56,7 @@ test('账户读取只提交结构化参数,不携带 token 或远端地址', a
expect(invoke.mock.calls).toEqual([
['read_profile_recharge_center', undefined],
['read_profile_wallet_ledger', undefined],
+ ['read_profile_referral_invite_center', undefined],
['create_profile_recharge_order', { productId: 'product-1' }],
['confirm_wechat_profile_recharge_order', { orderId: 'order-1' }],
['redeem_profile_reward_code', { code: 'CODE-1' }],
diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts
index b304035f9..974693229 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts
@@ -120,6 +120,9 @@ vi.mock('@tauri-apps/plugin-clipboard-manager', () => ({
throw new Error('no native clipboard image');
}),
readText: vi.fn(async () => nativeClipboardMock.text),
+ writeText: vi.fn(async (text: string) => {
+ nativeClipboardMock.text = text;
+ }),
}));
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
index fd9364151..912be89a7 100644
--- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
+++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts
@@ -1076,6 +1076,8 @@ export function registerClientHomeTests() {
fireEvent.click(screen.getByRole('button', { name: '我的' }));
expect(screen.getByRole('menuitem', { name: '使用指南' })).not.toBeNull();
+ expect(screen.getByRole('menuitem', { name: '邀请码' })).not.toBeNull();
+ expect(screen.getByRole('menuitem', { name: '玩家社区' })).not.toBeNull();
expect(screen.getByRole('menuitem', { name: '反馈联系' })).not.toBeNull();
expect(screen.getByRole('menuitem', { name: '退出登录' })).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '帮助' }));
@@ -1090,6 +1092,74 @@ export function registerClientHomeTests() {
fireEvent.click(screen.getByRole('menuitem', { name: '使用指南' }));
expect(screen.getByRole('dialog', { name: '使用指南' })).not.toBeNull();
});
+
+ it('opens the invite code and community panels from the account menu', async () => {
+ const inviteCenter = {
+ inviteCode: 'SY12345678',
+ inviteLinkPath: '/?inviteCode=SY12345678',
+ invitedCount: 2,
+ rewardedInviteCount: 2,
+ todayInviterRewardCount: 1,
+ todayInviterRewardRemaining: 9,
+ rewardPoints: 30,
+ invitedUsers: [],
+ hasRedeemedCode: false,
+ boundInviterUserId: null,
+ boundAt: null,
+ updatedAt: '2026-10-01T00:00:00.000Z',
+ };
+ const inviteLink = 'https://dev.genarrative.world/?inviteCode=SY12345678';
+ const invoke = vi.fn(async (command: string) => {
+ if (command === 'read_profile_referral_invite_center') {
+ return { center: inviteCenter, inviteUrl: inviteLink };
+ }
+ return undefined;
+ });
+ window.__TAURI__ = { core: { invoke } };
+ renderLauncherAt('/?launcher');
+
+ fireEvent.click(screen.getByRole('button', { name: '我的' }));
+ fireEvent.click(screen.getByRole('menuitem', { name: '邀请码' }));
+
+ const inviteDialog = await screen.findByRole('dialog', { name: '邀请码' });
+ expect(invoke).toHaveBeenCalledWith(
+ 'read_profile_referral_invite_center',
+ undefined,
+ );
+ expect(within(inviteDialog).getByText('SY12345678')).not.toBeNull();
+ expect(within(inviteDialog).getByText(inviteLink)).not.toBeNull();
+
+ fireEvent.click(
+ within(inviteDialog).getByRole('button', { name: '复制邀请链接' }),
+ );
+ await waitFor(() => {
+ expect(nativeClipboardMock.text).toBe(inviteLink);
+ });
+
+ fireEvent.click(
+ within(inviteDialog).getByRole('button', { name: '复制邀请码' }),
+ );
+ await waitFor(() => {
+ expect(nativeClipboardMock.text).toBe('SY12345678');
+ });
+
+ fireEvent.click(
+ within(inviteDialog).getByRole('button', { name: '关闭邀请码' }),
+ );
+ expect(screen.queryByRole('dialog', { name: '邀请码' })).toBeNull();
+
+ fireEvent.click(screen.getByRole('button', { name: '我的' }));
+ fireEvent.click(screen.getByRole('menuitem', { name: '玩家社区' }));
+ const communityDialog = await screen.findByRole('dialog', {
+ name: '玩家社区',
+ });
+ expect(
+ within(communityDialog).getByAltText('玩家社区微信群二维码'),
+ ).not.toBeNull();
+ expect(
+ within(communityDialog).getByAltText('玩家社区 QQ 群二维码'),
+ ).not.toBeNull();
+ });
}
export function registerHomeProjectCreationTests() {
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index f942f60e2..bd675dbb0 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -59,6 +59,18 @@
> 维护:阶段过程、分支合并和当轮测试数字由 Git 追溯;实现依据以当前代码和最新专题文档为准。
> 格式:参见[决策记录格式](../README.md#决策记录格式),按需填写。
+## 2026-10-01 邀请好友入口回到“我的”页,客户端账号菜单接入邀请码与玩家社区
+
+- 背景:主站“我的”页签的常用功能宫格只剩四项,`邀请好友` 入口在平台个人页恢复时漏了;同一份邀请码在 AGC 客户端里没有任何入口,玩家社区二维码也只存在主站弹层里。
+- 决策(主站):宫格按文档口径恢复五项——泥点充值、邀请好友、兑换码、玩家社区、反馈与建议,`grid-cols-5` 与 `.platform-profile-shortcut-grid` 的五列规则保持一致;邀请好友复用邀请弹层的 `invite` 面板,弹层展示邀请码与完整邀请链接,并拆成“复制邀请码 / 复制邀请链接”两个动作。邀请链接仍由后端 `inviteLinkPath` 加宿主 origin 补全,不在共享组件里拼参数。
+- 决策(跨端复用,重要):邀请弹层下沉到 `packages/shared/src/components/PlatformProfileReferralModal/`(含 `model.ts`、`index.css`、`index.test.tsx` 与 `assets/` 微信群 / QQ 群二维码),与 `PlatformProfileRechargeModal`、`PlatformProfileWalletLedgerModal` 同口径,由主站 `PlatformEntryActiveFlowShell` 和 AGC 客户端 `AccountReferralDialogs` 共用一份实现。共享组件只吃 props(邀请中心事实、补全后的邀请链接、两个复制回显、宿主剪贴板粘贴能力),自己不发请求、不写剪贴板;宿主能力(网页 HostBridge / 客户端 Tauri clipboard-manager)留在各自宿主。客户端弹层标题由宿主覆盖为“邀请码 / 玩家社区”,与账号菜单入口文案保持一致;主站沿用“邀请好友 / 填邀请码 / 玩家社区”。原地删除 `src/components/platform-entry/PlatformProfileReferralModal.tsx` 与客户端专用弹层实现(含 `launcher-referral-*` 样式)。
+- 决策(客户端取数):AGC 侧栏账号菜单在“使用指南”之后新增“邀请码”和“玩家社区”两项。Tauri 侧新增 `read_profile_referral_invite_center`,用当前登录 origin 把 `inviteLinkPath` 补全成完整链接后交给渲染层;客户端只展示邀请码 / 邀请链接和复制动作,不本地另存邀请码、不发放奖励。命令名同时登记进 `scripts/check-config.mjs` 的 native-only 白名单(该门禁要求 `generate_handler!` 里的命令必须被前端调用或显式白名单)。客户端邀请中心按登录账号隔离(`useAccountReferral` 按 `currentUserId` 重置并作废在飞响应)。
+- 决策(奖励说明收进右上角帮助按钮):邀请面板正文不再常驻那段黄色说明,改由标题右侧的帮助按钮(`?`,仅 `invite` 面板)承载悬浮说明;鼠标 `pointerenter` 展开、离开收起,触屏 / 键盘用点击切换(按 `pointerType` 区分,两套触发互不干扰),文案随 `role="note"` 可被无障碍读取。面板正文因此只保留邀请码、邀请链接和成功邀请三段内容。
+- 决策(每日上限文案改为数据驱动):说明文案不再写死“每日最多获得十次”(旧文案只在网页弹层里硬编码,客户端完全没有这句)。共享弹层用后端字段推导:每日上限 = `todayInviterRewardCount + todayInviterRewardRemaining`(当前 10),说明为“邀请一位好友注册,好友每次都能获得 N 泥点 / 好友完成注册后你也可以获得 N 泥点;每天最多 L 次,今日还剩 R 次”,两端自动一致。后端事实:`PROFILE_REFERRAL_REWARD_POINTS = 30`、`PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT = 10` 在 `module-runtime` 域内强制;被邀请人每次都拿满 30,上限只压邀请人一侧,超限后邀请关系仍成立。
+- 已知口径问题(未改,待产品确认):邀请上限的“当日”用的是 `runtime_profile_day_start_micros`(UTC 00:00 边界,即北京时间 08:00 重置),而每日任务 / 每日免费泥点用的是 `runtime_profile_beijing_day_key`(北京时间 00:00)。同一个仓库里两套“每日”口径并存,跨零点前后的奖励计数会与用户直觉不一致。
+- 影响范围:`packages/shared/src/components/PlatformProfileReferralModal/**`、`src/components/platform-entry/PlatformActiveProfileView.tsx`、`PlatformEntryActiveFlowShell.tsx`、`usePlatformProfileCenterController.ts`、`vite.config.ts`(移除已删除文件的 Tailwind `@source`)、`apps/ai-game-creator-shell/src/view/layout.tsx`、`src/features/app-shell/AccountReferral.tsx`、`useAccountReferral.ts`、`WorkspaceLauncher.tsx`、`src/styles.css`、`src-tauri/src/account_api.rs`、`scripts/check-config.mjs`、`src/services/accountHost.ts`、`media/social-media-group/*`(移动到共享组件 `assets/`)、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
+- 验证方式:`npx vitest run packages/shared/src/components/PlatformProfileReferralModal/index.test.tsx src/components/platform-entry apps/ai-game-creator-shell/tests/appSurface.test.ts apps/ai-game-creator-shell/tests/accountHost.test.ts`、`npm run typecheck`(主站,既有 6 处历史报错不变)、`apps/ai-game-creator-shell` 内 `npm run typecheck`(含 check-config 门禁)、`cargo check --tests` 与 `cargo test --bin … account_api::tests`、`cargo fmt --check`、ESLint / Prettier 改动文件、`npm run check:encoding`、`git diff --check`;并在真实 dev 栈上用浏览器与 CDP 附着客户端 WebView2 复截两端弹层,确认同一份实现渲染一致。
+
## 2026-09-29 外壳状态栏退役:`status` 一行改由浮层承载
- 背景:首页输入框下方、项目组页面头部那一行由 `WorkspaceLauncher` 的 `status` 承接,写入的内容很杂——进行中进度(`正在创建工作区`、`正在选择项目`)、“已取消 / 已创建项目 / 已打开项目目录”这类回显、失败结论(`创建未完成,请重试`、工作区看门狗文案、Provider 报错)以及项目组页面的 `正在检查项目状态`。这一行常驻占页面,用户明确要求整行改成浮层提示,而不是只把「已取消」挑出来。
diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md
index 78ed07e28..cb554f6da 100644
--- a/docs/project-memory/shared-memory/pitfalls.md
+++ b/docs/project-memory/shared-memory/pitfalls.md
@@ -2,6 +2,12 @@
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
+## 2026-10-01 Rust 分片编译失败只剩汇总错误
+
+- **原因**:`--message-format=json` 把编译诊断写到 stdout;只读取 `compiler-artifact` 的运行器会丢弃 `compiler-message`,CI 只能看到「due to 1 previous error」。
+- **处理**:使用 `json-render-diagnostics` 保留 JSON artifact,同时让 Cargo 将诊断渲染到 stderr;运行器继承 stderr。既有分片 fixture 同时覆盖编译失败详情、测试 panic 和成功摘要,见开发运维文档的 Rust lane 口径。
+- **排查边界**:函数签名变更与另一分支新增测试可能在无文本冲突的合并后产生参数不匹配;合并后必须检查测试目标,不能只验证普通二进制。
+
## 2026-10-01 线上 Nginx 手工维护漂移:`/profile` 刷新 404、发行包请求体仍限 64m
- **现象**:登录后刷新 `https://www.genarrative.world/profile` 直接 404,返回 Nginx 默认 404 页(162 字节);应用内点击进入正常。`curl` 复现:`/`、`/project`、`/creation`、`/components`、`/design-system`、`/games*`、`/editor/canvas` 全部 200 且正文与 `/` 同一份 SPA 外壳,只有 `/profile`(含 `/PROFILE`、`/profile/`)404。
@@ -9,6 +15,14 @@
- **处理**:线上只收敛 SPA allowlist(12 条,含 `/profile`)并补 `error_page 404 /404.html`;host-only 块收进 `deploy/nginx/snippets/genarrative-host-extras.conf`,由主模板 include、由 `Genarrative-Server-Provision` 安装。退役路由不进白名单,也不需要显式 404 配置,落 `location /` 即得品牌 404(非 HTML 客户端保持纯 404)。
- **判据/取证**:改前 `grep -c profile /etc/nginx/conf.d/genarrative.conf` 为 0;改后 `curl -s -o /dev/null -w '%{http_code}' https://www.genarrative.world/profile` 为 200(正文 sha 与 `/` 一致),`sudo nginx -T | grep -c 'editor/canvas|profile|project'` 为 1,`match3d` 计数为 0。
- **长期口径**:任何 SPA/路由白名单改动都要同时核对**生效配置**(`sudo nginx -T`)与仓库模板;线上主站 vhost 只由模板 + `genarrative-host-extras.conf` 生成,不再手工追加路由。snippet 内的 location 不在 Pingora 路由矩阵覆盖范围,Pingora 接公网 443 前要单独确认这些 host-only 路径。
+
+## 2026-10-01 AGC 账户总量门槛会阻断单项查询与导入
+
+- 账户素材库接口当前返回全量快照;客户端不能以全库条数代替单次查询、返回或导入的资源边界。原先在解析阶段拒绝超过 500 项,会让 `limit=1` 和指定 ID 导入一起失效,且检查发生在下载和 JSON 解析之后,不能保护这两步开销。
+- 账户查询允许偏移超过 500,仍按单页最多 100 项返回安全元数据。修改时同步 MCP schema/校验、Direct 工具桥、Runtime 执行层及 `agent_native_tools.rs` 下发的 strict 原生函数 schema;仅直接调用 observation 会绕过模型侧参数约束,无法证明后续页可达,必须验证实际生成的函数参数 schema。共享分页函数的项目文件调用方仍保留自己的范围,画布资源数量边界独立维护。
+- 实际平台会话导入还需避免在身份租约内再次调用会话读取/校验:租约持有非重入互斥锁,提交 helper 重复校验会等待自身释放。下载后校验并取得租约,持有至本地提交完成即可;不能用无平台会话的提交夹具替代完整导入链路验证。
+- 后端有界分页与按 ID 读取由 #574 跟进,不作为 #549 客户端恢复可用的前置条件。当前合同与验证入口见 [AGC 实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的“账户 / 项目画布 / 本地素材导入”。
+
## 2026-09-30 构建期 staging 撞上不装 npm 依赖的 Linux 门禁:AGC 壳 Rust lane 全红
- **现象**:`Project CI` 的 AGC 壳 Rust 三条 lane(`npm run check:native-shells:agc-rust-shard-*`)在 `fb130d184` 之后全部失败,日志只有 `error: failed to run custom build command for genarrative-ai-game-creator-shell` 与 `thread 'main' panicked at build.rs:65:28: Claude Agent SDK 缺失;请先执行 npm ci`(run 3083 / job 17521 实测,1 分钟即失败)。
@@ -6191,6 +6205,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **验证**:真机尺寸下用 `document.elementFromPoint(按钮中心)` 断言命中的是按钮自身而不是底部菜单(`overlay.parentElement === document.body`);顶栏断点矩阵(320–768)断言单行且无横向溢出。
- **关联**:`src/components/common/SquareImageCropModal.tsx`、`src/components/platform-entry/PlatformProfileModalShell.tsx`、`src/components/platform-entry/PlatformEntryActiveFlowShell.tsx`、`src/index.css`。
+## 2026-10-01 cc/Anthropic 必须走平台网关,客户端不得直连 Router
+
+- **现象**:目录里把 `claude-opus-5-5` 标成 `agentMode=cc` 后,选它发消息必然失败:先是 Claude CLI 自己打印 `Not logged in · Please run `,后来卡满 `requestTimeoutMs=180000`。
+- **根因**:`claude_code_cli.rs` 用配置里残留的 `llm.baseUrl`(`https://router.genarrative.world/v1`)当 `ANTHROPIC_BASE_URL`,再把平台会话 token 塞成 `ANTHROPIC_AUTH_TOKEN`。平台凭据边界是「客户端只出示平台 access token,账号的 Router key 由 api-server 解析、绝不下发」(见 `api-server/src/llm/mod.rs`)。实测 `POST https://router.genarrative.world/v1/messages` 用平台 token 返回 `401 Invalid token (new_api_error)`。
+- **现行口径**:baseUrl 不带路由与版本段,统一由协议自己拼 `v1/`(服务端 `router_protocol_url` 会先把历史凭据末尾的 `/v1` 归一化掉)。平台侧 Anthropic 是独立路由 `/api/llm/anthropic/{*path}`,客户端 `ANTHROPIC_BASE_URL` 设成 `{apiBaseUrl}/api/llm/anthropic`;OpenAI 侧是 `/api/llm/v1/responses` 与 `/api/llm/v1/chat/completions`,旧的无 `v1` 路径保留为已发布客户端的兼容别名。
+- **注意**:Claude Agent SDK 固定请求 `{ANTHROPIC_BASE_URL}/v1/messages?beta=true`(外加一次 `HEAD /api/hello` 探测),网关不要自己再补 `v1`,用通配段承接客户端协议路径。
+- **后台协议选项**:`AgcAgentMode` 增加 `anthropic`(显式 Anthropic 协议),`cc` 保留为同一执行器的历史别名;新增目录项应直接写 `anthropic`。
+
## 2026-10-01 AGC 壳 Rust 单测片内 `--test-threads=1` 不等于没有并发:宿主后台回合会跨用例发通知
- **现象**:`agent::thread_manager::tests::active_turn_changes_publish_one_notification_per_real_change` 在 CI 偶发红,`进度内容变化必须通知一次` 拿到 6 而非 5(也见过入队断言就多 1);本地要跑 3–5 次才复现。这条用例靠一个进程级计数器数「运行中的项目」通知。
diff --git a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
index 2ce2cb439..347575fbf 100644
--- a/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
+++ b/docs/technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md
@@ -55,7 +55,8 @@
## 模型绑定 Agent 执行模式
-- 后台 AGC 模型目录每项新增 `agentMode`,只接受 `codex` 与 `cc`;缺少该字段的历史目录按 `codex` 解释,后台新增模型默认 `codex`。
+- 后台 AGC 模型目录每项新增 `agentMode`,接受 `codex`、`cc` 与 `anthropic`;缺少该字段的历史目录按 `codex` 解释,后台新增模型默认 `codex`。`anthropic` 是 2026-10-01 新增的显式 Anthropic Messages 协议值,`cc` 保留为同一 Claude 执行器的历史别名(两者都映射到 `claude_code_cli`),新目录项应直接写 `anthropic`。
+- 2026-10-01 补充:`cc`/`anthropic` 一律经平台 LLM 网关的独立 Anthropic 路由 `POST /api/llm/anthropic/{*path}`,客户端 `ANTHROPIC_BASE_URL` 为 `{apiBaseUrl}/api/llm/anthropic`,不得再用配置里的 `llm.baseUrl`(那是 Router 地址,平台不会把账号 Router key 下发给客户端)。Claude Agent SDK 自己拼 `{base}/v1/messages` 并带 `authorization: bearer` 与 `anthropic-version`,网关据此转发上游 `{routerBase}/v1/messages`。
- `codex` 仍表示现有 AGC Codex app-server 执行链路;`cc` 表示 AGC 客户端启动随包的 Claude Agent SDK sidecar,模型的 `modelId` 原样作为 Claude Agent SDK 的模型标识,不把 Claude 协议伪装成 OpenAI Responses。sidecar 随 AGC 安装包携带 SDK 及匹配平台的 Claude Code runtime,用户不需要预先安装 `claude` 命令。
- `/api/llm/models` 的启用模型摘要返回 `agentMode`,后台完整目录、管理 DTO 和客户端目录保持同一绑定快照。客户端在选择模型时同时持久化模型 ID 与执行模式;默认模型变化、模型被停用或目录刷新回退时一并更新执行模式。
- 客户端设置保存不得把后台模型绑定覆盖回 Codex。旧客户端配置缺少执行模式时继续按 Codex 运行;旧后台响应缺少 `agentMode` 时客户端按 Codex 兼容。
diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
index 860ef3572..d2ab76784 100644
--- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
+++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
@@ -505,6 +505,11 @@ npm 游戏的可预览产物固定为对应 package 目录下的 `dist/index.htm
## 2026-08-25 账户 / 项目画布 / 本地素材导入
+- 账户素材库总量不作为客户端查询、预览或按 ID 导入的拒绝条件。账户查询继续先读取后端快照,再按静态图片、文件夹与关键词筛选并分页;`offset` 接受可表示的非负整数,超过结果总数返回空页,`limit` 保持 1–100(默认 100),结果保留 `total / offset / limit / nextOffset`。Direct MCP 的关键词匹配素材名或文件夹名,Runtime 保留现有素材名匹配语义。
+- Runtime 下发给模型的 `asset.library.list` strict 原生函数 schema 必须与执行层分页边界一致;验收须校验 `build_agent_runtime_native_function_tools` 生成的完整函数参数,确认 `offset=501/600` 可用、负数和非整数偏移及 `limit>100` 被拒绝,同时保留可选字段的 `null` 语义。直接调用 observation 函数不能替代模型可见 schema 的验证;定向用例为 `account_asset_native_function_schema_accepts_later_pages`。
+- 账户查询只把当前页安全元数据作为工具文本返回,不把全库作为附件交给 Agent。指定 ID 导入仍先读取账号快照再选取目标;单批数量、下载体积、账号/项目归属、会话、媒体类型和原子登记校验保持有效。绑定画布资源数量与项目文件发现的既有边界不随账户总量限制解除而放宽。无需后端 API、DTO、数据库或持久化迁移;全库传输与解析开销由后续 #574 的服务端分页及按 ID 读取处理,不阻塞 #549 的客户端修复。
+- 平台账号导入在下载后重新校验会话并取得身份租约,租约覆盖本地写入、manifest 登记与 revision 推进;提交过程不得再次读取同一登录态互斥锁,避免在身份保护内自锁。身份变更仍须在取得租约时拒绝,取消写入许可仍不得写入。
+- 验收覆盖 500/501 项账户快照、按文件夹/关键词查询、偏移超过 500 的后续页、越界空页和大库指定 ID 导入,并复核无效 ID/不支持媒体拒绝、安全投影、取消写入和账号切换。使用 `agent_asset_import_tests`、`account_asset`、`semantic_resource_tools_reject_unreviewed_or_inconsistent_arguments`、`cancelled_import_write_permit`、`platform_session::tests` 定向 Rust 用例及提示词合同测试;模拟平台证据与真实账号验证分别记录。
- 素材读取区分三类来源:`asset.list` / `agc_list_registered_assets` 是当前项目本地 manifest,`agc_list_project_files` / `file.list` 只发现项目目录中实际存在但可能未登记的文件,`asset.library.list` 是当前登录账号素材库,项目画布资源读取是当前网页项目/画布的完整图片清单;账户素材库不能替代项目画布清单。
- Agent 只接收稳定素材 ID、类型、尺寸和项目相对路径等安全投影。客户端负责重新校验账号/项目归属、换签下载、媒体校验,以及 manifest/画布原子登记;不得向 Agent 暴露绝对路径、签名 URL、objectKey、token 或 Cookie。
- `canvas.asset_import` 支持账户/画布资源 ID 和项目内本地相对路径。项目文件发现结果以 `assetImportable` 明确区分当前可登记的已识别图片、字体、音频、视频、文档、代码与**引擎资源**(Cocos Creator 的模型、动画、场景/预制体、材质/特效、图集与压缩纹理容器)与其它文件;Agent 只能提交前者。引擎资源在资源画布上是**只读预览**:模型出缩略图、序列化资源出结构摘要、客户端解不了的容器出类型卡,不承接编辑与派生;`.meta`、`library/`、`temp/` 等引擎生成物仍然只可发现、不可登记。导入拒绝路径穿越、`.agent`、符号链接/reparse point 及敏感配置文件;外部宿主文件须由 UI 原生文件选择器授权后导入,不开放任意绝对路径。
diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
index 5c629527f..fb31bb682 100644
--- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
+++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
@@ -78,6 +78,7 @@ npm run check:server-rs-ddd
- 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
- 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
+- 2026-10-01 路由约定:LLM 网关 baseUrl 不带路由与版本段,路径由协议自己拼。OpenAI 侧是 `POST /api/llm/v1/responses` 与 `POST /api/llm/v1/chat/completions`;Anthropic 是独立路由 `POST /api/llm/anthropic/{*path}`(Claude Agent SDK 固定请求 `{ANTHROPIC_BASE_URL}/v1/messages`,网关不自己补 `v1`)。旧的无 `v1` 路径 `/api/llm/responses`、`/api/llm/chat/completions` 保留为已发布客户端的兼容别名。三条路径共用同一凭据边界:客户端只出示平台 access token,账号 Router key 由 api-server 解析且不下发,模型名由目录解析后替换。
- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-url`、`/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并通过受 runtime service identity 限制的 procedure 在同一事务快照内按配置 bucket 与精确 key 权威查询 `asset_object`、计算现役编辑器精选素材派生授权;不得把任意连接的订阅 cache miss 或命中解释为当前授权真相。一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead`、当前登录 owner,或同 owner 且已通过、已展示、返还完成的 `editor_showcase_asset` 顶层媒体 / 冻结角色动作帧精确授权读取;动作帧只按快照中的 `assetObjectId` / `objectKey` 逐对象授权,不从 `imageSrc` 或 `generated-*` 前缀推导宽泛权限;动作快照损坏、隐藏、拒绝或不再满足返还条件时不形成帧授权。只有同 bucket / key 的权威查询确认未登记时,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。已登记资产继续保持 `private`,公开精选只获得与正式展示快照生命周期一致的精确派生读授权,不得批量改为 `PublicRead` 或放开 `generated-*` 前缀。任意未登记 `objectKey`、跨 owner 和未获授权的匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理;精选派生授权、`PublicRead` 和 legacy 兼容读取签发的 URL 统一限制为最长 600 秒,owner / admin 读取保持原有有效期口径。
- 外部 OpenAPI:`/api/external/v1/openapi.json`、`/api/external/v1/assets/direct-upload-tickets`、`/api/external/v1/assets/objects/confirm`、`/api/external/v1/assets/read-url`、`/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。主站和 External 的 asset object confirm 都必须从已认证主体派生 owner,不能信任请求体 owner;同 bucket / key 已登记后不得改变 owner。
- 编辑器与素材生成:`/api/editor/projects*`、`/api/editor/assets*`、`/api/editor/showcase/*`、`/api/editor/*/generations`、`/api/editor/images/*`、`/api/editor/icon-spritesheets/*`、`/api/editor/ui-designs/*`,以及编辑器 Agent 会话路由。通用任务与素材支撑另保留 `/api/ai/tasks*`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*` 和 `/api/assets/hyper3d/*`。
diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
index 4f73e66ef..d3272305e 100644
--- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
+++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
@@ -359,6 +359,8 @@ npm run check
### Gitea Actions PR 门禁
+AGC 分片运行器编译测试二进制时使用 `--message-format=json-render-diagnostics`:stdout 的 JSON artifact 用于定位可执行文件,编译错误与 warning 由 Cargo 渲染到继承的 stderr,CI 必须保留错误编号、源码位置与具体原因。回归验证运行 `node --test apps/ai-game-creator-shell/scripts/run-rust-shell-test-shards.test.mjs`。函数签名变更合并后,还要编译 `cfg(test)` 调用方;普通 `cargo check` 不能替代测试目标检查。
+
Linux process-session 的 owner SIGKILL 用例必须在启动 owner 后立即建立测试清理 guard:正常退出或断言 panic 时终止、回收 owner,并在有界时间内清理其独立临时项目目录中的残留进程。原有「owner 退出后子进程自行消失」断言在兜底清理之前执行,不能由 guard 代替生产生命周期验证。清理覆盖 panic 路径及临时项目间隔离,且不得因清理失败再次 panic。
仓库级 Gitea Actions 工作流固定为 `.gitea/workflows/project-ci.yml`,在向 `master` 推送、创建或更新 PR,以及手工触发时运行。工作流拆成九个必须通过的 job。job 声明顺序就是 runner 领取顺序,因此把两条 AGC 壳 Rust lane 排在最前:并发槽位不足时它们必须最先开始,AGC 侧的关键路径才由自己而不是由排队决定。
diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md
index f9157429a..20435edeb 100644
--- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md
+++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md
@@ -98,7 +98,7 @@ server-rs + Axum + SpacetimeDB
7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
9. 现役项目、素材等列表的筛选必须打开独立 dialog / drawer / modal;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。
-10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、三张统计卡、五项常用功能宫格、通用设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位。页面不再提供会员购买 / 升级横幅、每日任务卡片或任务中心入口,也不提供独立存档按钮入口,不在底部保留旧的填邀请码次级入口;主题设置、账号与安全只作为通用设置弹窗下一级入口,不在“我的”页外层单独占行。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
+10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、三张统计卡、五项常用功能宫格、通用设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议五项,按五列铺满整行;入口收缩到四项时按四列铺满整行,不保留五列网格导致左对齐空位。邀请弹层(主站「我的」页签与 AGC 客户端账号菜单共用 `packages/shared` 的 `PlatformProfileReferralModal`)展示邀请码和完整邀请链接,提供“复制邀请码 / 复制邀请链接”两个动作;奖励说明必须从后端 `rewardPoints` 与 `todayInviterRewardCount + todayInviterRewardRemaining` 推导(当前为 30 泥点、每日 10 次、今日剩余 N 次),不在前端写死次数或金额。同一份邀请数据在网页和客户端显示一致,客户端只允许覆盖弹层标题以匹配各自入口文案。页面不再提供会员购买 / 升级横幅、每日任务卡片或任务中心入口,也不提供独立存档按钮入口,不在底部保留旧的填邀请码次级入口;主题设置、账号与安全只作为通用设置弹窗下一级入口,不在“我的”页外层单独占行。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
11. 每日免费泥点由后端独立余额桶承载,基础额度固定为 `20`,按北京时间每日 `00:00` 重置。跨业务日退款时,原消费中的每日免费泥点部分叠加到退款当日每日免费桶,当日余额允许超过 `20`;到下一业务日仍统一失效并重置为 `20`。主站不得把已隐藏的每日任务入口或 `daily_task_reward` 文案继续当作每日免费泥点入口。
12. “我的”页泥点余额、累计游玩、已玩游戏三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。
13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。
diff --git a/media/social-media-group/qq.png b/packages/shared/src/components/PlatformProfileReferralModal/assets/community-qq.png
similarity index 100%
rename from media/social-media-group/qq.png
rename to packages/shared/src/components/PlatformProfileReferralModal/assets/community-qq.png
diff --git a/media/social-media-group/wechat.png b/packages/shared/src/components/PlatformProfileReferralModal/assets/community-wechat.png
similarity index 100%
rename from media/social-media-group/wechat.png
rename to packages/shared/src/components/PlatformProfileReferralModal/assets/community-wechat.png
diff --git a/packages/shared/src/components/PlatformProfileReferralModal/index.css b/packages/shared/src/components/PlatformProfileReferralModal/index.css
new file mode 100644
index 000000000..16829a976
--- /dev/null
+++ b/packages/shared/src/components/PlatformProfileReferralModal/index.css
@@ -0,0 +1,55 @@
+.platform-profile-referral-modal-backdrop {
+ background: rgba(0, 0, 0, 0.42);
+}
+
+.platform-profile-referral-modal {
+ border: 1px solid var(--platform-modal-border);
+ background: #fff;
+ color: #18181b;
+ box-shadow: 0 24px 80px rgba(0, 0, 0, 0.24);
+}
+
+.platform-profile-referral-modal__close {
+ border: 0;
+ background: transparent;
+ color: #ff4056;
+ font-size: 20px;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.platform-profile-referral-modal__close:hover {
+ background: rgba(255, 64, 86, 0.08);
+}
+
+/*
+ * 帮助按钮复用平台通用图标按钮 `.platform-icon-button` 的配色(--platform-icon-*),
+ * 这里只把它收到和关闭按钮同高,避免弹层标题栏出现两种尺寸。
+ */
+.platform-icon-button.platform-profile-referral-modal__help {
+ width: 2rem;
+ height: 2rem;
+}
+
+.platform-profile-referral-modal__help-panel {
+ /* 悬浮层必须不透明:PlatformStatusMessage 的 warning 底是半透明色,
+ 直接悬浮会透出下面的邀请码卡片。底色沿用弹层主体的白底,amber 边框 / 文字
+ 继续走平台 warning token。 */
+ border-radius: 0.75rem;
+ background: #fff;
+ box-shadow: 0 12px 30px rgba(112, 57, 30, 0.16);
+}
+
+.platform-profile-referral-modal__avatar {
+ background: #ff4056;
+ color: #fff;
+}
+
+.platform-profile-referral-modal__link {
+ overflow-wrap: anywhere;
+}
+
+.platform-profile-referral-modal__skeleton-item {
+ border-radius: 0.75rem;
+ background: rgba(228, 228, 231, 0.6);
+}
diff --git a/packages/shared/src/components/PlatformProfileReferralModal/index.test.tsx b/packages/shared/src/components/PlatformProfileReferralModal/index.test.tsx
new file mode 100644
index 000000000..c82c6da24
--- /dev/null
+++ b/packages/shared/src/components/PlatformProfileReferralModal/index.test.tsx
@@ -0,0 +1,205 @@
+/* @vitest-environment jsdom */
+
+import { fireEvent, render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type { ComponentProps } from 'react';
+import { describe, expect, test, vi } from 'vitest';
+
+import type { ProfileReferralInviteCenterResponse } from '../../contracts/runtime';
+import { PlatformProfileReferralModal } from './index';
+
+function buildCenter(
+ overrides: Partial = {},
+): ProfileReferralInviteCenterResponse {
+ return {
+ inviteCode: 'ABCD1234',
+ inviteLinkPath: '/invite/ABCD1234',
+ invitedCount: 1,
+ rewardedInviteCount: 1,
+ todayInviterRewardCount: 1,
+ todayInviterRewardRemaining: 9,
+ rewardPoints: 30,
+ invitedUsers: [
+ {
+ userId: 'user-2',
+ displayName: '海盐',
+ avatarUrl: null,
+ boundAt: '2026-06-10T08:00:00.000Z',
+ },
+ ],
+ hasRedeemedCode: false,
+ boundInviterUserId: null,
+ boundAt: null,
+ updatedAt: '2026-06-10T08:00:00.000Z',
+ ...overrides,
+ };
+}
+
+function renderPanel(
+ panel: 'invite' | 'redeem' | 'community',
+ overrides: Partial> = {},
+) {
+ return render(
+ ,
+ );
+}
+
+describe('PlatformProfileReferralModal', () => {
+ test('renders invite panel with invite code, invite link and both copy actions', () => {
+ const onCopyInviteLink = vi.fn();
+ const onCopyInviteCode = vi.fn();
+ renderPanel('invite', { onCopyInviteLink, onCopyInviteCode });
+
+ const dialog = screen.getByRole('dialog', { name: '邀请好友' });
+
+ expect(within(dialog).getByText('邀请码')).toBeTruthy();
+ expect(within(dialog).getByText('ABCD1234')).toBeTruthy();
+ expect(within(dialog).getByText('海盐')).toBeTruthy();
+ expect(within(dialog).getByText('成功邀请')).toBeTruthy();
+ expect(
+ within(dialog).getByText('https://www.genarrative.world/invite/ABCD1234'),
+ ).toBeTruthy();
+
+ fireEvent.click(
+ within(dialog).getByRole('button', { name: '复制邀请链接' }),
+ );
+ expect(onCopyInviteLink).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(within(dialog).getByRole('button', { name: '复制邀请码' }));
+ expect(onCopyInviteCode).toHaveBeenCalledTimes(1);
+ });
+
+ test('奖励说明收进右上角帮助按钮,hover 展开并派生每日上限', async () => {
+ const user = userEvent.setup();
+ renderPanel('invite', {
+ center: buildCenter({
+ todayInviterRewardCount: 4,
+ todayInviterRewardRemaining: 6,
+ rewardPoints: 66,
+ }),
+ });
+
+ const dialog = screen.getByRole('dialog', { name: '邀请好友' });
+ const inviteeLine = '邀请一位好友注册,好友每次都能获得 66 泥点。';
+ const inviterLine =
+ '好友完成注册后你也可以获得 66 泥点;每天最多 10 次,今日还剩 6 次。';
+
+ // 说明不再常驻面板正文,只在悬浮 / 展开时出现。
+ expect(within(dialog).queryByText(inviteeLine)).toBeNull();
+ expect(within(dialog).queryByText(inviterLine)).toBeNull();
+
+ const helpButton = within(dialog).getByRole('button', {
+ name: '邀请奖励说明',
+ });
+ await user.hover(helpButton);
+
+ const helpPanel = within(dialog).getByRole('note', {
+ name: '邀请奖励说明',
+ });
+ expect(within(helpPanel).getByText(inviteeLine)).toBeTruthy();
+ expect(within(helpPanel).getByText(inviterLine)).toBeTruthy();
+ });
+
+ test('触屏 / 键盘可以点开帮助说明(无 hover 事件)', () => {
+ renderPanel('invite');
+
+ const dialog = screen.getByRole('dialog', { name: '邀请好友' });
+ const helpButton = within(dialog).getByRole('button', {
+ name: '邀请奖励说明',
+ });
+
+ fireEvent.click(helpButton);
+ expect(
+ within(dialog).getByRole('note', { name: '邀请奖励说明' }),
+ ).toBeTruthy();
+
+ fireEvent.click(helpButton);
+ expect(
+ within(dialog).queryByRole('note', { name: '邀请奖励说明' }),
+ ).toBeNull();
+ });
+
+ test('玩家社区面板不显示邀请奖励帮助', () => {
+ renderPanel('community');
+
+ const dialog = screen.getByRole('dialog', { name: '玩家社区' });
+ expect(
+ within(dialog).queryByRole('button', { name: '邀请奖励说明' }),
+ ).toBeNull();
+ });
+
+ test('复制状态按动作各自回显', () => {
+ renderPanel('invite', {
+ copyInviteCodeState: 'copied',
+ copyInviteLinkState: 'failed',
+ });
+
+ const dialog = screen.getByRole('dialog', { name: '邀请好友' });
+
+ expect(within(dialog).getByText('已复制')).toBeTruthy();
+ expect(within(dialog).getByText('复制失败')).toBeTruthy();
+ });
+
+ test('submits redeem panel with the shared form shell', async () => {
+ const user = userEvent.setup();
+ const onRedeemCodeChange = vi.fn();
+ const onSubmitRedeemCode = vi.fn();
+
+ renderPanel('redeem', {
+ redeemCode: 'ab12',
+ onRedeemCodeChange,
+ onSubmitRedeemCode,
+ });
+
+ const dialog = screen.getByRole('dialog', { name: '填邀请码' });
+ const input = within(dialog).getByRole('textbox', { name: '邀请码' });
+
+ await user.type(input, ' c');
+ expect(onRedeemCodeChange).toHaveBeenCalled();
+
+ await user.click(within(dialog).getByRole('button', { name: '提交' }));
+ expect(onSubmitRedeemCode).toHaveBeenCalledTimes(1);
+ });
+
+ test('redeem panel offers paste when the host declares the capability', async () => {
+ const user = userEvent.setup();
+ const onPasteRedeemCode = vi.fn();
+
+ renderPanel('redeem', { canPasteRedeemCode: true, onPasteRedeemCode });
+ await user.click(screen.getByRole('button', { name: '粘贴' }));
+ expect(onPasteRedeemCode).toHaveBeenCalledTimes(1);
+ });
+
+ test('redeem panel hides paste when the host cannot read the clipboard', () => {
+ renderPanel('redeem', { canPasteRedeemCode: false });
+ expect(screen.queryByRole('button', { name: '粘贴' })).toBeNull();
+ });
+
+ test('renders community QR panels', () => {
+ renderPanel('community');
+
+ const dialog = screen.getByRole('dialog', { name: '玩家社区' });
+
+ expect(within(dialog).getByAltText('玩家社区微信群二维码')).toBeTruthy();
+ expect(within(dialog).getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
+ expect(within(dialog).getByText('微信群')).toBeTruthy();
+ expect(within(dialog).getByText('QQ群')).toBeTruthy();
+ });
+});
diff --git a/packages/shared/src/components/PlatformProfileReferralModal/index.tsx b/packages/shared/src/components/PlatformProfileReferralModal/index.tsx
new file mode 100644
index 000000000..8b0e62b80
--- /dev/null
+++ b/packages/shared/src/components/PlatformProfileReferralModal/index.tsx
@@ -0,0 +1,529 @@
+import './index.css';
+
+import { CircleHelp, ClipboardPaste, Copy } from 'lucide-react';
+import {
+ type PointerEvent,
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useId,
+ useState,
+} from 'react';
+
+import type { ProfileReferralInviteCenterResponse } from '../../contracts/runtime';
+import { PlatformActionButton } from '../PlatformActionButton';
+import { PlatformAsyncStatePanel } from '../PlatformAsyncStatePanel';
+import { PlatformEmptyState } from '../PlatformEmptyState';
+import { PlatformFieldLabel } from '../PlatformFieldLabel';
+import { PlatformIconButton } from '../PlatformIconButton';
+import { PlatformStatusMessage } from '../PlatformStatusMessage';
+import { PlatformSubpanel } from '../PlatformSubpanel';
+import { PlatformTextField } from '../PlatformTextField';
+import communityQqQrImage from './assets/community-qq.png';
+import communityWechatQrImage from './assets/community-wechat.png';
+import {
+ buildPlatformProfileReferralRewardCopy,
+ normalizePlatformProfileReferralRedeemCode,
+ type PlatformProfileReferralCopyState,
+ type PlatformProfileReferralPanel,
+} from './model';
+
+export type {
+ PlatformProfileReferralCopyState,
+ PlatformProfileReferralPanel,
+} from './model';
+
+export type PlatformProfileReferralModalProps = {
+ panel: PlatformProfileReferralPanel;
+ /**
+ * 弹层标题。默认按 panel 取「邀请好友 / 填邀请码 / 玩家社区」;
+ * 入口文案不同的宿主(例如客户端菜单叫「邀请码」)可以覆盖,保持入口与弹层自洽。
+ */
+ title?: string;
+ center: ProfileReferralInviteCenterResponse | null;
+ /** 宿主补全站点 origin 后的完整邀请链接;后端只返回站内相对路径。 */
+ inviteLinkUrl: string | null;
+ isLoading: boolean;
+ isSubmittingRedeem: boolean;
+ redeemCode: string;
+ copyInviteLinkState: PlatformProfileReferralCopyState;
+ copyInviteCodeState: PlatformProfileReferralCopyState;
+ error: string | null;
+ success: string | null;
+ /** 宿主能力:声明可读取宿主剪贴板时才显示「粘贴」。 */
+ canPasteRedeemCode?: boolean;
+ onPasteRedeemCode?: () => void;
+ onClose: () => void;
+ onCopyInviteLink: () => void;
+ onCopyInviteCode: () => void;
+ /** 不支持「填邀请码」的宿主可以整体省略;省略时不渲染填码表单。 */
+ onRedeemCodeChange?: (value: string) => void;
+ onSubmitRedeemCode?: () => void;
+};
+
+const COMMUNITY_QR_CODES = [
+ { label: '微信群', src: communityWechatQrImage, alt: '玩家社区微信群二维码' },
+ { label: 'QQ群', src: communityQqQrImage, alt: '玩家社区 QQ 群二维码' },
+] as const;
+
+function resolvePanelTitle(panel: PlatformProfileReferralPanel) {
+ if (panel === 'invite') {
+ return '邀请好友';
+ }
+ if (panel === 'redeem') {
+ return '填邀请码';
+ }
+ return '玩家社区';
+}
+
+/**
+ * 复制回显按钮。
+ *
+ * 复制实现和状态由宿主持有(网页走 HostBridge / navigator.clipboard,客户端走
+ * Tauri clipboard-manager),这里只按状态渲染文案。
+ */
+function ReferralCopyButton({
+ state,
+ idleLabel,
+ copiedLabel = '已复制',
+ disabled,
+ className,
+ buttonProps,
+ idleIcon,
+ tone = 'primary',
+ size = 'md',
+ shape = 'default',
+ fullWidth = true,
+}: {
+ state: PlatformProfileReferralCopyState;
+ idleLabel: string;
+ copiedLabel?: string;
+ disabled?: boolean;
+ className: string;
+ buttonProps?: Record;
+ idleIcon: ReactNode;
+ tone?: 'primary' | 'ghost';
+ size?: 'xs' | 'md';
+ shape?: 'default' | 'pill';
+ fullWidth?: boolean;
+}) {
+ const label =
+ state === 'copied'
+ ? copiedLabel
+ : state === 'failed'
+ ? '复制失败'
+ : idleLabel;
+
+ return (
+
+ {state === 'copied' ? null : idleIcon}
+ {label}
+
+ );
+}
+
+function ReferralSkeleton({ variant }: { variant: 'invite' | 'redeem' }) {
+ return (
+
+ );
+}
+
+function ProfileReferralInvitedUserRow({
+ name,
+ avatarUrl,
+}: {
+ name: string;
+ avatarUrl: string | null;
+}) {
+ const avatarLabel = (name.trim() || '玩').slice(0, 1).toUpperCase();
+
+ return (
+
+
+ {avatarUrl ? (
+
+ ) : (
+ avatarLabel
+ )}
+
+
+
+ );
+}
+
+/**
+ * 个人中心邀请能力统一弹层:邀请码、填邀请码和玩家社区三种 panel。
+ *
+ * 主站「我的」页签和 AGC 客户端账号菜单共用这一份实现(与充值 / 泥点账单弹层同口径),
+ * 弹层只渲染宿主传入的事实,不自己读邀请中心、不自己写剪贴板。
+ */
+export function PlatformProfileReferralModal({
+ panel,
+ title: titleOverride,
+ center,
+ inviteLinkUrl,
+ isLoading,
+ isSubmittingRedeem,
+ redeemCode,
+ copyInviteLinkState,
+ copyInviteCodeState,
+ error,
+ success,
+ canPasteRedeemCode = false,
+ onPasteRedeemCode,
+ onClose,
+ onCopyInviteLink,
+ onCopyInviteCode,
+ onRedeemCodeChange,
+ onSubmitRedeemCode,
+}: PlatformProfileReferralModalProps) {
+ const titleId = useId();
+ const title = titleOverride ?? resolvePanelTitle(panel);
+ const normalizedRedeemCode =
+ normalizePlatformProfileReferralRedeemCode(redeemCode);
+ const rewardCopy = buildPlatformProfileReferralRewardCopy(center);
+ // 鼠标走 hover,触屏 / 键盘走点击,两套触发互不干扰。
+ const [isRewardHelpHovered, setIsRewardHelpHovered] = useState(false);
+ const [isRewardHelpPinned, setIsRewardHelpPinned] = useState(false);
+ const isRewardHelpOpen = isRewardHelpHovered || isRewardHelpPinned;
+ const handleRewardHelpPointerEnter = useCallback(
+ (event: PointerEvent) => {
+ if (event.pointerType === 'mouse') {
+ setIsRewardHelpHovered(true);
+ }
+ },
+ [],
+ );
+ const handleRewardHelpPointerLeave = useCallback(
+ (event: PointerEvent) => {
+ if (event.pointerType === 'mouse') {
+ setIsRewardHelpHovered(false);
+ setIsRewardHelpPinned(false);
+ }
+ },
+ [],
+ );
+ const handleRewardHelpBlur = useCallback(() => {
+ setIsRewardHelpHovered(false);
+ setIsRewardHelpPinned(false);
+ }, []);
+ const handleRewardHelpFocus = useCallback(
+ () => setIsRewardHelpHovered(true),
+ [],
+ );
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape' && !event.defaultPrevented) {
+ onClose();
+ }
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [onClose]);
+
+ let content: ReactNode;
+
+ if (panel === 'community') {
+ content = (
+
+ {COMMUNITY_QR_CODES.map((qrCode) => (
+
+
+ 
+
+
+ {qrCode.label}
+
+
+ ))}
+
+ );
+ } else if (panel === 'redeem' && onRedeemCodeChange && onSubmitRedeemCode) {
+ content = (
+ }
+ isEmpty={Boolean(center?.hasRedeemedCode)}
+ emptyState={
+
+ 已填写邀请码
+
+ }
+ >
+
+
+ );
+ } else {
+ content = (
+ }
+ >
+
+
+
+ 邀请码
+
+
+ {center?.inviteCode ?? '--------'}
+
+ }
+ buttonProps={{ onClick: onCopyInviteCode }}
+ />
+
+
+
+ 邀请链接
+
+
+ {inviteLinkUrl ?? '正在生成邀请链接'}
+
+
+ }
+ buttonProps={{ onClick: onCopyInviteLink }}
+ />
+
+
+ 成功邀请
+
+ {center?.invitedUsers?.length ? (
+
+ {center.invitedUsers.map((user) => (
+
+ ))}
+
+ ) : (
+
+ 暂无成功邀请
+
+ )}
+
+
+
+ );
+ }
+
+ return (
+ {
+ if (event.target === event.currentTarget) {
+ onClose();
+ }
+ }}
+ >
+
+ {/*
+ 奖励说明不再占用面板正文,收进右上角的帮助按钮:桌面 hover 即出,
+ 触屏 / 键盘用点击展开,所以按钮同时是 hover 与点击两种触发。
+ */}
+ {panel === 'invite' && rewardCopy ? (
+
+ }
+ onFocus={handleRewardHelpFocus}
+ onBlur={handleRewardHelpBlur}
+ onClick={() => setIsRewardHelpPinned((pinned) => !pinned)}
+ />
+ {isRewardHelpOpen ? (
+
+
+ {rewardCopy.inviteeRewardLine}
+ {rewardCopy.inviterRewardLine}
+
+
+ ) : null}
+
+ ) : null}
+
+
+ {title}
+
+ {content}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {success ? (
+
+ {success}
+
+ ) : null}
+
+
+ );
+}
+
+export default PlatformProfileReferralModal;
diff --git a/packages/shared/src/components/PlatformProfileReferralModal/model.ts b/packages/shared/src/components/PlatformProfileReferralModal/model.ts
new file mode 100644
index 000000000..dfa3b47f2
--- /dev/null
+++ b/packages/shared/src/components/PlatformProfileReferralModal/model.ts
@@ -0,0 +1,53 @@
+import type { ProfileReferralInviteCenterResponse } from '../../contracts/runtime';
+
+export type PlatformProfileReferralPanel = 'invite' | 'redeem' | 'community';
+export type PlatformProfileReferralCopyState = 'idle' | 'copied' | 'failed';
+
+export function normalizePlatformProfileReferralRedeemCode(value: string) {
+ return value
+ .trim()
+ .replace(/[^0-9a-z]/gi, '')
+ .toUpperCase();
+}
+
+/**
+ * 邀请奖励的当日额度由后端字段推导。
+ *
+ * 后端只返回「今日已获得次数」和「今日剩余次数」,两者相加就是当前生效的每日上限
+ * (当前 `PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT = 10`)。前端不再另存一份写死的
+ * `10`:上限调整时两端文案自动跟着走。
+ */
+export function resolvePlatformProfileReferralDailyLimit(
+ center: ProfileReferralInviteCenterResponse | null,
+) {
+ if (!center) {
+ return null;
+ }
+ return center.todayInviterRewardCount + center.todayInviterRewardRemaining;
+}
+
+/**
+ * 邀请面板的奖励说明。
+ *
+ * 语义边界(后端 `redeem_profile_referral_invite_code`):被邀请人每次都拿满
+ * `rewardPoints`;每日上限只压邀请人一侧,超过上限后邀请人当日不再获得奖励,
+ * 邀请关系本身仍然成立。
+ */
+export function buildPlatformProfileReferralRewardCopy(
+ center: ProfileReferralInviteCenterResponse | null,
+) {
+ if (!center) {
+ return null;
+ }
+ const dailyLimit = resolvePlatformProfileReferralDailyLimit(center);
+ return {
+ rewardPoints: center.rewardPoints,
+ dailyLimit,
+ remainingToday: center.todayInviterRewardRemaining,
+ inviteeRewardLine: `邀请一位好友注册,好友每次都能获得 ${center.rewardPoints} 泥点。`,
+ inviterRewardLine:
+ dailyLimit === null
+ ? `好友完成注册后你也可以获得 ${center.rewardPoints} 泥点。`
+ : `好友完成注册后你也可以获得 ${center.rewardPoints} 泥点;每天最多 ${dailyLimit} 次,今日还剩 ${center.todayInviterRewardRemaining} 次。`,
+ };
+}
diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs
index 541e0c06e..ab074c958 100644
--- a/scripts/check-native-shells.mjs
+++ b/scripts/check-native-shells.mjs
@@ -292,8 +292,8 @@ const h5HostBridgeCallChainWrapperFiles = [
const h5HostBridgeRequiredCallChainFiles = [
'src/ActiveApp.tsx',
'src/components/auth/AuthGate.tsx',
+ 'src/components/platform-entry/PlatformEntryActiveFlowShell.tsx',
'src/components/platform-entry/PlatformFeedbackView.tsx',
- 'src/components/platform-entry/PlatformProfileReferralModal.tsx',
'src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx',
'src/components/platform-entry/usePlatformProfileCenterController.ts',
'src/hooks/useHostNavigationCanGoBack.ts',
diff --git a/server-rs/crates/api-server/src/llm/mod.rs b/server-rs/crates/api-server/src/llm/mod.rs
index 439ddbd7e..3ff4dc75c 100644
--- a/server-rs/crates/api-server/src/llm/mod.rs
+++ b/server-rs/crates/api-server/src/llm/mod.rs
@@ -365,7 +365,7 @@ pub async fn proxy_llm_responses(
.with_message(format!("创建 LLM Router 请求客户端失败:{error}")),
)
})?;
- let upstream_url = format!("{}/responses", base_url.trim_end_matches('/'));
+ let upstream_url = router_protocol_url(&base_url, "responses");
let mut request = client
.post(upstream_url)
.bearer_auth(api_key)
@@ -470,6 +470,227 @@ pub async fn proxy_llm_responses(
)
}
+/// Proxies the Anthropic Messages protocol for the LLM Router.
+///
+/// Credential boundary is identical to [`proxy_llm_responses`]: the caller only
+/// presents the platform access token, and the account-owned Router credential
+/// is resolved inside api-server and never returned to the client. The Claude
+/// Agent SDK appends `/v1/messages` to `ANTHROPIC_BASE_URL`, so this route is
+/// registered at that exact path and only rewrites `model`.
+pub async fn proxy_llm_messages(
+ State(state): State,
+ Extension(request_context): Extension,
+ Extension(authenticated): Extension,
+ headers: HeaderMap,
+ body: Bytes,
+) -> Result {
+ if body.len() > LLM_REQUEST_MAX_BODY_BYTES {
+ return Err(llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE)
+ .with_message("LLM Messages 请求体超过大小限制"),
+ ));
+ }
+
+ if let Err(error) =
+ ensure_llm_router_user_can_start_conversation(&state, authenticated.claims().user_id())
+ .await
+ {
+ return Err(llm_error_response(&request_context, error));
+ }
+
+ let mut payload = serde_json::from_slice::(&body).map_err(|_| {
+ llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::BAD_REQUEST)
+ .with_message("LLM Messages 请求体必须是合法 JSON"),
+ )
+ })?;
+ let object = payload.as_object_mut().ok_or_else(|| {
+ llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::BAD_REQUEST)
+ .with_message("LLM Messages 请求体必须是 JSON 对象"),
+ )
+ })?;
+ let requested_model = object
+ .get("model")
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .map(str::to_string);
+ // Same boundary as Responses: provider/credential controls never reach the
+ // Router, even when an older desktop build still sends them.
+ for field in [
+ "apiKey",
+ "api_key",
+ "baseUrl",
+ "base_url",
+ "provider",
+ "apiKind",
+ "api_kind",
+ "agentLlm",
+ "agent_llm",
+ "agentMode",
+ "agent_mode",
+ ] {
+ object.remove(field);
+ }
+ let catalog = load_llm_catalog(&state, authenticated.claims().user_id())
+ .await
+ .map_err(|error| llm_error_response(&request_context, error))?;
+ // 这条路由只服务 AGC 的 Anthropic 执行器。Claude Agent SDK 直连网关、不会补
+ // `x-genarrative-client` 标记,所以这里直接按目录解析客户端选的稳定标识;
+ // 目录外取值仍由 `resolve_requested` 拒绝。
+ let requested_model = requested_model.as_deref();
+ let selected_model = catalog
+ .resolve_requested(requested_model)
+ .map_err(|message| {
+ llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_message(message),
+ )
+ })?
+ .to_string();
+ object.insert("model".to_string(), Value::String(selected_model));
+
+ let (base_url, api_key, key_id) =
+ resolve_llm_router_credentials(&state, authenticated.claims().user_id())
+ .await
+ .map_err(|error| {
+ llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(error),
+ )
+ })?;
+
+ prepare_llm_router_billing(&state, authenticated.claims().user_id())
+ .await
+ .map_err(|error| llm_error_response(&request_context, error))?;
+
+ let client = reqwest::Client::builder()
+ .connect_timeout(std::time::Duration::from_secs(15))
+ .read_timeout(std::time::Duration::from_secs(180))
+ .redirect(reqwest::redirect::Policy::none())
+ .build()
+ .map_err(|error| {
+ llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
+ .with_message(format!("创建 LLM Router 请求客户端失败:{error}")),
+ )
+ })?;
+ let upstream_url = router_protocol_url(&base_url, "messages");
+ // The Router speaks Anthropic Messages on this path; accept both credential
+ // headers because the SDK uses `x-api-key` while the gateway also allows
+ // `Authorization: Bearer`.
+ let mut request = client
+ .post(upstream_url)
+ .header("x-api-key", api_key.clone())
+ .bearer_auth(api_key)
+ .header("content-type", "application/json");
+ for name in ["anthropic-version", "anthropic-beta", "accept"] {
+ if let Some(value) = headers.get(name) {
+ request = request.header(name, value);
+ }
+ }
+ let upstream = request
+ .body(serde_json::to_vec(&payload).map_err(|error| {
+ llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
+ .with_message(format!("序列化 LLM Messages 请求失败:{error}")),
+ )
+ })?)
+ .send()
+ .await
+ .map_err(|error| {
+ llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::BAD_GATEWAY)
+ .with_message(format!("LLM Router 暂时不可用:{error}")),
+ )
+ })?;
+
+ let status = upstream.status();
+ if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {
+ if let Err(error) = crate::external_api_keys::revoke_llm_router_account(
+ &state,
+ authenticated.claims().user_id(),
+ &key_id,
+ )
+ .await
+ {
+ tracing::warn!(
+ request_id = request_context.request_id(),
+ user_id = %authenticated.claims().user_id(),
+ key_id = %key_id,
+ error = %error,
+ "LLM Router 返回确定鉴权失败,但本地账号 Key 失效标记未完成"
+ );
+ }
+ }
+ let upstream_headers = upstream.headers().clone();
+ let is_stream = payload
+ .get("stream")
+ .and_then(Value::as_bool)
+ .unwrap_or(false);
+ if !is_stream {
+ let body = upstream.bytes().await.map_err(|error| {
+ llm_error_response(
+ &request_context,
+ AppError::from_status(StatusCode::BAD_GATEWAY)
+ .with_message(format!("读取 LLM Router 响应失败:{error}")),
+ )
+ })?;
+ if status.is_success() {
+ if let Err(error) =
+ settle_llm_router_usage(&state, authenticated.claims().user_id()).await
+ {
+ tracing::error!(
+ request_id = request_context.request_id(),
+ user_id = %authenticated.claims().user_id(),
+ error = %error,
+ "LLM Router Messages 成功但累计额度同步未完成"
+ );
+ }
+ }
+ return build_upstream_response(
+ status,
+ &upstream_headers,
+ Body::from(body),
+ &request_context,
+ );
+ }
+
+ if !status.is_success() {
+ let stream = upstream.bytes_stream().map(|chunk| {
+ chunk.map_err(|error| {
+ std::io::Error::other(format!("LLM Router 响应流读取失败:{error}"))
+ })
+ });
+ return build_upstream_response(
+ status,
+ &upstream_headers,
+ Body::from_stream(stream),
+ &request_context,
+ );
+ }
+
+ let stream = stream_messages_with_billing(
+ upstream.bytes_stream(),
+ state.clone(),
+ authenticated.claims().user_id().to_string(),
+ request_context.request_id().to_string(),
+ );
+ build_upstream_response(
+ status,
+ &upstream_headers,
+ Body::from_stream(stream),
+ &request_context,
+ )
+}
+
fn build_upstream_response(
status: StatusCode,
upstream_headers: &HeaderMap,
@@ -666,6 +887,17 @@ fn is_responses_done_sse_event(event: &str) -> bool {
.any(|line| matches!(line.trim(), "data: [DONE]" | "[DONE]"))
}
+/// Anthropic Messages ends a stream with `message_stop` and has no `[DONE]`
+/// sentinel, so the terminal event is the only settle point.
+fn is_messages_terminal_sse_event(event: &str) -> bool {
+ event.lines().any(|line| {
+ let line = line.trim();
+ line == "event: message_stop"
+ || line.contains("\"type\":\"message_stop\"")
+ || line.contains("\"type\": \"message_stop\"")
+ })
+}
+
fn finalize_llm_router_terminal_event(
terminal_event: &mut Option,
billing_result: Option<&Result<(), AppError>>,
@@ -816,6 +1048,77 @@ fn stream_responses_with_billing(
}
}
+/// Passes an Anthropic Messages SSE stream through unchanged and settles the
+/// account quota once the upstream signals `message_stop`.
+///
+/// The bytes are forwarded verbatim so the Claude Agent SDK keeps parsing the
+/// same event shapes it received before the platform gateway was introduced.
+fn stream_messages_with_billing(
+ mut upstream: impl futures_util::Stream- > + Unpin,
+ state: AppState,
+ owner_user_id: String,
+ request_id: String,
+) -> impl futures_util::Stream
- > {
+ async_stream::stream! {
+ let mut pending = String::new();
+ let mut utf8_pending = Vec::new();
+ let mut billing_result = None;
+
+ while let Some(chunk) = upstream.next().await {
+ match chunk {
+ Ok(bytes) => {
+ append_utf8_chunk(&mut pending, &mut utf8_pending, bytes.as_ref());
+ pending = pending.replace("\r\n", "\n");
+ while let Some(separator) = pending.find("\n\n") {
+ let raw_event = pending[..separator + 2].to_string();
+ let event = pending[..separator].to_string();
+ pending.drain(..separator + 2);
+ if is_messages_terminal_sse_event(&event) && billing_result.is_none() {
+ billing_result = Some(settle_llm_router_usage(
+ &state,
+ owner_user_id.as_str(),
+ ).await);
+ if let Some(Err(error)) = billing_result.as_ref() {
+ tracing::error!(
+ request_id = %request_id,
+ user_id = %owner_user_id,
+ error = %error,
+ "LLM Router 流式响应已完成但累计额度同步未完成"
+ );
+ }
+ }
+ yield Ok(Bytes::from(raw_event));
+ }
+ }
+ Err(error) => {
+ if billing_result.is_none() {
+ if let Err(billing_error) = settle_llm_router_usage(
+ &state,
+ owner_user_id.as_str(),
+ )
+ .await
+ {
+ tracing::error!(
+ request_id = %request_id,
+ user_id = %owner_user_id,
+ error = %billing_error,
+ "LLM Router 流式响应中断且累计额度同步未完成"
+ );
+ }
+ }
+ yield Err(std::io::Error::other(format!(
+ "LLM Router 响应流读取失败:{error}"
+ )));
+ return;
+ }
+ }
+ }
+ if !pending.is_empty() {
+ yield Ok(Bytes::from(pending));
+ }
+ }
+}
+
fn append_utf8_chunk(pending: &mut String, carry: &mut Vec, bytes: &[u8]) {
carry.extend_from_slice(bytes);
loop {
@@ -1082,6 +1385,21 @@ fn map_chat_message(message: LlmChatMessagePayload) -> LlmMessage {
LlmMessage::new(role, message.content)
}
+/// Composes the Router endpoint for one protocol operation.
+///
+/// The account credential stored for the AGC official route is a bare gateway
+/// base: it must not carry the version segment or the operation path, so the
+/// protocol composes `v1/` itself. Historical rows still store a
+/// trailing `/v1`, so it is normalised away first instead of being duplicated.
+fn router_protocol_url(base_url: &str, operation: &str) -> String {
+ let base = base_url.trim().trim_end_matches('/');
+ let base = base
+ .strip_suffix("/v1")
+ .unwrap_or(base)
+ .trim_end_matches('/');
+ format!("{base}/v1/{operation}")
+}
+
fn llm_error_response(request_context: &RequestContext, error: AppError) -> Response {
error.into_response_with_context(Some(request_context))
}
@@ -1387,7 +1705,8 @@ mod tests {
.expect("captured request lock")
.clone()
.expect("mock server should capture upstream request");
- assert!(upstream_request.starts_with("POST /responses HTTP/1.1"));
+ // baseUrl 不带路由与版本段,Responses 由协议自己拼 `v1/responses`。
+ assert!(upstream_request.starts_with("POST /v1/responses HTTP/1.1"));
assert!(
upstream_request
.lines()
@@ -1657,6 +1976,145 @@ mod tests {
assert!(carry.is_empty());
}
+ #[test]
+ fn router_protocol_url_composes_v1_without_duplicating_it() {
+ // baseUrl 不带路由与版本段,协议自己拼。
+ assert_eq!(
+ router_protocol_url("https://router.example", "messages"),
+ "https://router.example/v1/messages"
+ );
+ assert_eq!(
+ router_protocol_url("https://router.example", "responses"),
+ "https://router.example/v1/responses"
+ );
+ // 历史凭据里带着 `/v1`,归一化掉而不是拼成 `/v1/v1/...`。
+ assert_eq!(
+ router_protocol_url("https://router.example/v1", "messages"),
+ "https://router.example/v1/messages"
+ );
+ assert_eq!(
+ router_protocol_url("https://router.example/v1/", "chat/completions"),
+ "https://router.example/v1/chat/completions"
+ );
+ }
+
+ #[tokio::test]
+ async fn llm_v1_responses_route_reaches_the_router_v1_path() {
+ let (server_url, captured) = spawn_capturing_mock_server(MockResponse {
+ status_line: "200 OK",
+ content_type: "application/json; charset=utf-8",
+ body: r#"{"id":"resp_v1_01","status":"completed","output":[]}"#.to_string(),
+ extra_headers: Vec::new(),
+ });
+ let (state, user_id) = seed_authenticated_state(AppConfig {
+ llm_router_base_url: server_url.clone(),
+ ..AppConfig::default()
+ })
+ .await;
+ install_test_provisioned_router_credential(&user_id, server_url, "router-key");
+ let token = issue_access_token(&state, &user_id);
+ let app = build_router(state);
+
+ let response = app
+ .oneshot(
+ Request::builder()
+ .method("POST")
+ .uri("/api/llm/v1/responses")
+ .header("authorization", format!("Bearer {token}"))
+ .header("content-type", "application/json")
+ .header("x-genarrative-response-envelope", "v1")
+ .body(Body::from(
+ json!({ "input": [{ "role": "user", "content": "ping" }] }).to_string(),
+ ))
+ .expect("request should build"),
+ )
+ .await
+ .expect("request should succeed");
+
+ assert_eq!(response.status(), StatusCode::OK);
+ let captured_request = captured
+ .lock()
+ .expect("captured request lock should not poison")
+ .clone()
+ .expect("upstream request should be captured");
+ assert!(
+ captured_request
+ .to_ascii_lowercase()
+ .starts_with("post /v1/responses"),
+ "canonical v1 route must reach the Router v1 path: {captured_request}"
+ );
+ }
+
+ #[tokio::test]
+ async fn llm_anthropic_messages_uses_account_router_credential() {
+ let (server_url, captured) = spawn_capturing_mock_server(MockResponse {
+ status_line: "200 OK",
+ content_type: "application/json; charset=utf-8",
+ body: r#"{"id":"msg_api_server_01","type":"message","role":"assistant","content":[{"type":"text","text":"pong"}]}"#.to_string(),
+ extra_headers: Vec::new(),
+ });
+ let (state, user_id) = seed_authenticated_state(AppConfig {
+ llm_router_base_url: server_url.clone(),
+ ..AppConfig::default()
+ })
+ .await;
+ install_test_provisioned_router_credential(&user_id, server_url, "router-key");
+ let token = issue_access_token(&state, &user_id);
+ let app = build_router(state);
+
+ let response = app
+ .oneshot(
+ Request::builder()
+ .method("POST")
+ .uri("/api/llm/anthropic/v1/messages")
+ .header("authorization", format!("Bearer {token}"))
+ .header("content-type", "application/json")
+ .header("anthropic-version", "2023-06-01")
+ .header("x-genarrative-client", "agc")
+ .body(Body::from(
+ json!({
+ "max_tokens": 16,
+ "messages": [{ "role": "user", "content": "ping" }]
+ })
+ .to_string(),
+ ))
+ .expect("request should build"),
+ )
+ .await
+ .expect("request should succeed");
+
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response
+ .into_body()
+ .collect()
+ .await
+ .expect("body should collect")
+ .to_bytes();
+ assert!(
+ String::from_utf8_lossy(&body).contains("msg_api_server_01"),
+ "anthropic payload should pass through"
+ );
+
+ let captured_request = captured
+ .lock()
+ .expect("captured request lock should not poison")
+ .clone()
+ .expect("upstream request should be captured");
+ let head = captured_request.to_ascii_lowercase();
+ assert!(
+ head.starts_with("post /v1/messages"),
+ "upstream must receive the Anthropic path: {captured_request}"
+ );
+ assert!(
+ head.contains("x-api-key: router-key"),
+ "upstream must receive the account Router key: {captured_request}"
+ );
+ assert!(
+ !head.contains(&token.to_ascii_lowercase()),
+ "platform access token must never reach the Router"
+ );
+ }
+
fn spawn_mock_server(responses: Vec) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
diff --git a/server-rs/crates/api-server/src/modules/platform.rs b/server-rs/crates/api-server/src/modules/platform.rs
index d457b795e..4cfbda348 100644
--- a/server-rs/crates/api-server/src/modules/platform.rs
+++ b/server-rs/crates/api-server/src/modules/platform.rs
@@ -9,7 +9,7 @@ use crate::{
auth::require_bearer_auth,
llm::{
LLM_REQUEST_MAX_BODY_BYTES, list_llm_models, proxy_llm_chat_completions,
- proxy_llm_responses,
+ proxy_llm_messages, proxy_llm_responses,
},
state::AppState,
volcengine_speech::{
@@ -36,6 +36,15 @@ pub fn router(state: AppState) -> Router {
))
.layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)),
)
+ .route(
+ "/api/llm/v1/chat/completions",
+ post(proxy_llm_chat_completions)
+ .route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ ))
+ .layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)),
+ )
.route(
"/api/llm/responses",
post(proxy_llm_responses)
@@ -45,6 +54,27 @@ pub fn router(state: AppState) -> Router {
))
.layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)),
)
+ .route(
+ "/api/llm/v1/responses",
+ post(proxy_llm_responses)
+ .route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ ))
+ .layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)),
+ )
+ // Anthropic 客户端(Claude Agent SDK / Claude Code CLI)固定请求
+ // `{ANTHROPIC_BASE_URL}/v1/messages`,所以这里只声明 Anthropic 自己的
+ // 路由前缀,尾段交给上游协议本身,网关不自己发明版本段。
+ .route(
+ "/api/llm/anthropic/{*path}",
+ post(proxy_llm_messages)
+ .route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ ))
+ .layer(DefaultBodyLimit::max(LLM_REQUEST_MAX_BODY_BYTES)),
+ )
.route(
"/api/speech/volcengine/config",
get(get_volcengine_speech_config).route_layer(middleware::from_fn_with_state(
diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs
index 9a02ec3ea..ee48803b9 100644
--- a/server-rs/crates/api-server/src/tracking.rs
+++ b/server-rs/crates/api-server/src/tracking.rs
@@ -602,6 +602,15 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option Some(route_spec("llm_request", "llm", User, "anonymous")),
+ ("POST", "/api/llm/v1/responses") => {
+ Some(route_spec("llm_request", "llm", User, "anonymous"))
+ }
+ ("POST", "/api/llm/v1/chat/completions") => {
+ Some(route_spec("llm_request", "llm", User, "anonymous"))
+ }
+ ("POST", "/api/llm/anthropic/v1/messages") => {
+ Some(route_spec("llm_request", "llm", User, "anonymous"))
+ }
("GET", "/api/speech/volcengine/config") => Some(route_spec(
"speech_config_view",
"speech",
@@ -946,6 +955,7 @@ fn is_known_static_route_segment(segment: &str) -> bool {
matches!(
segment,
"ai" | "analytics"
+ | "anthropic"
| "api"
| "api-keys"
| "asr"
@@ -988,11 +998,13 @@ fn is_known_static_route_segment(segment: &str) -> bool {
| "library"
| "llm"
| "login"
+ | "messages"
| "optimizations"
| "login-options"
| "logout"
| "logout-all"
| "me"
+ | "responses"
| "metric"
| "objects"
| "orders"
@@ -1616,6 +1628,35 @@ mod tests {
);
}
+ #[test]
+ fn llm_gateway_protocol_paths_keep_their_tracking_specs() {
+ // 协议路径段必须保持静态,否则归一化会把 Anthropic 路由打成
+ // `/api/llm/{id}/v1/{id}`,埋点永远匹配不上。
+ assert_eq!(
+ normalize_route_path("/api/llm/anthropic/v1/messages"),
+ "/api/llm/anthropic/v1/messages"
+ );
+ assert_eq!(
+ normalize_route_path("/api/llm/v1/responses"),
+ "/api/llm/v1/responses"
+ );
+ assert_eq!(
+ normalize_route_path("/api/llm/v1/chat/completions"),
+ "/api/llm/v1/chat/completions"
+ );
+
+ for path in [
+ "/api/llm/v1/responses",
+ "/api/llm/v1/chat/completions",
+ "/api/llm/anthropic/v1/messages",
+ ] {
+ let spec = resolve_route_tracking_spec(&Method::POST, path)
+ .unwrap_or_else(|| panic!("{path} should keep the llm_request spec"));
+ assert_eq!(spec.event_key, "llm_request");
+ assert_eq!(spec.module_key, "llm");
+ }
+ }
+
#[test]
fn active_runtime_settings_routes_keep_tracking_specs() {
for (method, event_key) in [
diff --git a/server-rs/crates/module-runtime/src/agc_models.rs b/server-rs/crates/module-runtime/src/agc_models.rs
index 16cb030a8..e4ff1c5fb 100644
--- a/server-rs/crates/module-runtime/src/agc_models.rs
+++ b/server-rs/crates/module-runtime/src/agc_models.rs
@@ -17,6 +17,9 @@ pub const AGC_MODEL_CATALOG_MAX_MODELS: usize = 32;
pub enum AgcAgentMode {
Codex,
Cc,
+ /// 显式 Anthropic Messages 协议。`cc` 是同一执行器的历史别名,
+ /// 新目录项应直接写 `anthropic`。
+ Anthropic,
}
impl Default for AgcAgentMode {
@@ -30,6 +33,7 @@ impl AgcAgentMode {
match self {
Self::Codex => "codex",
Self::Cc => "cc",
+ Self::Anthropic => "anthropic",
}
}
@@ -37,7 +41,8 @@ impl AgcAgentMode {
match value {
"codex" => Ok(Self::Codex),
"cc" => Ok(Self::Cc),
- _ => Err("Agent 模式只允许 codex 或 cc".into()),
+ "anthropic" => Ok(Self::Anthropic),
+ _ => Err("Agent 模式只允许 codex、cc 或 anthropic".into()),
}
}
}
@@ -340,13 +345,21 @@ mod tests {
}
#[test]
- fn catalog_agent_mode_accepts_codex_and_cc_only() {
+ fn catalog_agent_mode_accepts_codex_cc_and_anthropic_only() {
let mut catalog =
AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap();
catalog.models[0].agent_mode = AgcAgentMode::Cc;
assert!(catalog.validate().is_ok());
assert_eq!(catalog.models[0].agent_mode.as_str(), "cc");
+ catalog.models[0].agent_mode = AgcAgentMode::Anthropic;
+ assert!(catalog.validate().is_ok());
+ assert_eq!(catalog.models[0].agent_mode.as_str(), "anthropic");
+ assert_eq!(
+ AgcAgentMode::parse("anthropic").unwrap(),
+ AgcAgentMode::Anthropic
+ );
+
let invalid: Result = serde_json::from_str(
r#"{"revision":0,"defaultModelId":"model-a","models":[{"id":"model-a","alias":"model-a","modelId":"model-a","enabled":true,"agentMode":"provider"}]}"#,
);
diff --git a/src/components/platform-entry/PlatformActiveProfileView.test.tsx b/src/components/platform-entry/PlatformActiveProfileView.test.tsx
index 080996f2d..acf9d4934 100644
--- a/src/components/platform-entry/PlatformActiveProfileView.test.tsx
+++ b/src/components/platform-entry/PlatformActiveProfileView.test.tsx
@@ -18,6 +18,7 @@ const callbacks = {
onOpenApiKeys: vi.fn(),
onOpenCommunity: vi.fn(),
onOpenFeedback: vi.fn(),
+ onOpenInvite: vi.fn(),
onOpenRecharge: vi.fn(),
onOpenRewardCode: vi.fn(),
onOpenSettings: vi.fn(),
@@ -90,6 +91,7 @@ describe('PlatformActiveProfileView', () => {
expect(screen.getByText(/陶泥号:\s*100001/u)).toBeTruthy();
expect(screen.getByRole('button', { name: '泥点余额 108' })).toBeTruthy();
expect(screen.getByRole('button', { name: /泥点充值/u })).toBeTruthy();
+ expect(screen.getByRole('button', { name: /邀请好友/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /兑换码/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /玩家社区/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /反馈与建议/u })).toBeTruthy();
@@ -98,6 +100,9 @@ describe('PlatformActiveProfileView', () => {
screen.getByRole('button', { name: /开发者 API Key/u }),
).toBeTruthy();
+ fireEvent.click(screen.getByRole('button', { name: /邀请好友/u }));
+ expect(callbacks.onOpenInvite).toHaveBeenCalledTimes(1);
+
fireEvent.click(screen.getByRole('button', { name: '用户协议' }));
const legalDialog = screen.getByRole('dialog', { name: '用户协议' });
expect(legalDialog.parentElement?.className).toContain(
diff --git a/src/components/platform-entry/PlatformActiveProfileView.tsx b/src/components/platform-entry/PlatformActiveProfileView.tsx
index 8c5c39e74..8b431a2aa 100644
--- a/src/components/platform-entry/PlatformActiveProfileView.tsx
+++ b/src/components/platform-entry/PlatformActiveProfileView.tsx
@@ -7,6 +7,7 @@ import {
Pencil,
Settings,
Ticket,
+ UserPlus,
UserRound,
} from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
@@ -21,6 +22,7 @@ import profileClockImage from '../../../media/profile/_Image (1).png';
import profileGamepadImage from '../../../media/profile/_Image (2).png';
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
import profileCoinsImage from '../../../media/profile/_Image (4).png';
+import profileInviteImage from '../../../media/profile/_Image (5).png';
import profileGiftImage from '../../../media/profile/_Image (6).png';
import profileCommunityImage from '../../../media/profile/_Image (7).png';
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
@@ -69,6 +71,7 @@ type PlatformActiveProfileViewProps = {
onOpenApiKeys: () => void;
onOpenCommunity: () => void;
onOpenFeedback: () => void;
+ onOpenInvite: () => void;
onOpenRecharge: () => void;
onOpenRewardCode: () => void;
onOpenSettings: () => void;
@@ -278,6 +281,7 @@ export function PlatformActiveProfileView({
onOpenApiKeys,
onOpenCommunity,
onOpenFeedback,
+ onOpenInvite,
onOpenRecharge,
onOpenRewardCode,
onOpenSettings,
@@ -606,7 +610,7 @@ export function PlatformActiveProfileView({
className="platform-profile-shortcut-panel"
aria-label="常用功能"
>
-
+
+
{
+ void readNativeHostClipboardText().then((text) => {
+ if (text) {
+ profileCenter.setReferralRedeemCode(text);
+ }
+ });
+ }, [profileCenter]);
+
const isMobileToolStage =
!isDesktopLayout &&
(selectionStage === 'creation-home' ||
@@ -766,6 +784,9 @@ export function PlatformEntryFlowShellImpl({
profileCenter.openProfilePopupPanel('community')
}
onOpenFeedback={openFeedback}
+ onOpenInvite={() =>
+ profileCenter.openProfilePopupPanel('invite')
+ }
onOpenRecharge={openRecharge}
onOpenRewardCode={profileCenter.openRewardCodeModal}
onOpenSettings={() => authUi?.openSettingsModal()}
@@ -870,14 +891,19 @@ export function PlatformEntryFlowShellImpl({
diff --git a/src/components/platform-entry/PlatformProfileReferralModal.test.tsx b/src/components/platform-entry/PlatformProfileReferralModal.test.tsx
deleted file mode 100644
index d674c2b88..000000000
--- a/src/components/platform-entry/PlatformProfileReferralModal.test.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-/* @vitest-environment jsdom */
-
-import { render, screen, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { afterEach, describe, expect, test, vi } from 'vitest';
-
-import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
-import {
- canPasteFromNativeHostClipboard,
- readNativeHostClipboardText,
-} from './platformProfileHostClipboard';
-import { PlatformProfileReferralModal } from './PlatformProfileReferralModal';
-
-vi.mock('./platformProfileHostClipboard', () => ({
- canPasteFromNativeHostClipboard: vi.fn(() => false),
- readNativeHostClipboardText: vi.fn(),
-}));
-
-const canPasteFromNativeHostClipboardMock = vi.mocked(
- canPasteFromNativeHostClipboard,
-);
-const readNativeHostClipboardTextMock = vi.mocked(readNativeHostClipboardText);
-
-function buildCenter(
- overrides: Partial = {},
-): ProfileReferralInviteCenterResponse {
- return {
- inviteCode: 'ABCD1234',
- inviteLinkPath: '/invite/ABCD1234',
- invitedCount: 1,
- rewardedInviteCount: 1,
- todayInviterRewardCount: 1,
- todayInviterRewardRemaining: 9,
- rewardPoints: 66,
- invitedUsers: [
- {
- userId: 'user-2',
- displayName: '海盐',
- avatarUrl: null,
- boundAt: '2026-06-10T08:00:00.000Z',
- },
- ],
- hasRedeemedCode: false,
- boundInviterUserId: null,
- boundAt: null,
- updatedAt: '2026-06-10T08:00:00.000Z',
- ...overrides,
- };
-}
-
-describe('PlatformProfileReferralModal', () => {
- afterEach(() => {
- vi.clearAllMocks();
- canPasteFromNativeHostClipboardMock.mockReturnValue(false);
- });
-
- test('renders invite panel with shared profile content', () => {
- render(
- ,
- );
-
- const dialog = screen.getByRole('dialog', { name: '邀请好友' });
-
- expect(within(dialog).getByText('邀请码')).toBeTruthy();
- expect(within(dialog).getByText('ABCD1234')).toBeTruthy();
- expect(within(dialog).getByText('海盐')).toBeTruthy();
- expect(within(dialog).getByText('成功邀请')).toBeTruthy();
- expect(
- within(dialog).getByRole('button', { name: /复制邀请/u }),
- ).toBeTruthy();
- });
-
- test('submits redeem panel with the shared form shell', async () => {
- const user = userEvent.setup();
- const onRedeemCodeChange = vi.fn();
- const onSubmitRedeemCode = vi.fn();
-
- render(
- ,
- );
-
- const dialog = screen.getByRole('dialog', { name: '填邀请码' });
- const input = within(dialog).getByRole('textbox', { name: '邀请码' });
-
- await user.type(input, ' c');
- expect(onRedeemCodeChange).toHaveBeenCalled();
-
- await user.click(within(dialog).getByRole('button', { name: '提交' }));
- expect(onSubmitRedeemCode).toHaveBeenCalledTimes(1);
- });
-
- test('redeem panel can paste invite code from native host clipboard', async () => {
- const user = userEvent.setup();
- const onRedeemCodeChange = vi.fn();
- canPasteFromNativeHostClipboardMock.mockReturnValue(true);
- readNativeHostClipboardTextMock.mockResolvedValue('spring-2026');
-
- render(
- ,
- );
-
- await user.click(screen.getByRole('button', { name: '粘贴' }));
-
- expect(readNativeHostClipboardTextMock).toHaveBeenCalledTimes(1);
- expect(onRedeemCodeChange).toHaveBeenCalledWith('spring-2026');
- });
-
- test('renders community QR panels', () => {
- render(
- ,
- );
-
- const dialog = screen.getByRole('dialog', { name: '玩家社区' });
-
- expect(within(dialog).getByAltText('玩家社区微信群二维码')).toBeTruthy();
- expect(within(dialog).getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
- expect(within(dialog).getByText('微信群')).toBeTruthy();
- expect(within(dialog).getByText('QQ群')).toBeTruthy();
- });
-});
diff --git a/src/components/platform-entry/PlatformProfileReferralModal.tsx b/src/components/platform-entry/PlatformProfileReferralModal.tsx
deleted file mode 100644
index 0f5151148..000000000
--- a/src/components/platform-entry/PlatformProfileReferralModal.tsx
+++ /dev/null
@@ -1,343 +0,0 @@
-import { ClipboardPaste, Copy } from 'lucide-react';
-import type { ReactNode } from 'react';
-
-import communityQqQrImage from '../../../media/social-media-group/qq.png';
-import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
-import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
-import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
-import { PlatformActionButton } from '../common/PlatformActionButton';
-import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
-import { PlatformEmptyState } from '../common/PlatformEmptyState';
-import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
-import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
-import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
-import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
-import { PlatformSubpanel } from '../common/PlatformSubpanel';
-import { PlatformTextField } from '../common/PlatformTextField';
-import type { CopyFeedbackState } from '../common/useCopyFeedback';
-import {
- canPasteFromNativeHostClipboard,
- readNativeHostClipboardText,
-} from './platformProfileHostClipboard';
-import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
-import type { ProfileReferralPanel } from './usePlatformProfileCenterController';
-
-type PlatformProfileReferralModalProps = {
- panel: ProfileReferralPanel;
- center: ProfileReferralInviteCenterResponse | null;
- isLoading: boolean;
- isSubmittingRedeem: boolean;
- redeemCode: string;
- copyInviteState: CopyFeedbackState;
- error: string | null;
- success: string | null;
- onClose: () => void;
- onCopyInvite: () => void;
- onRedeemCodeChange: (value: string) => void;
- onSubmitRedeemCode: () => void;
-};
-
-const COMMUNITY_QR_CODES = [
- {
- label: '微信群',
- src: communityWechatQrImage,
- alt: '玩家社区微信群二维码',
- },
- {
- label: 'QQ群',
- src: communityQqQrImage,
- alt: '玩家社区 QQ 群二维码',
- },
-] as const;
-
-function ProfileReferralUserAvatar({
- name,
- avatarUrl,
-}: {
- name: string;
- avatarUrl: string | null;
-}) {
- const avatarLabel = (name.trim() || '玩').slice(0, 1).toUpperCase();
-
- return (
-
- {avatarUrl ? (
-
- ) : (
- avatarLabel
- )}
-
- );
-}
-
-function resolvePanelTitle(panel: ProfileReferralPanel) {
- if (panel === 'invite') {
- return '邀请好友';
- }
- if (panel === 'redeem') {
- return '填邀请码';
- }
- return '玩家社区';
-}
-
-/**
- * 个人中心邀请能力统一弹层。
- * 承接邀请码、填码和社区二维码三种 profile panel,避免首页继续内联重复白底浮层实现。
- */
-export function PlatformProfileReferralModal({
- panel,
- center,
- isLoading,
- isSubmittingRedeem,
- redeemCode,
- copyInviteState,
- error,
- success,
- onClose,
- onCopyInvite,
- onRedeemCodeChange,
- onSubmitRedeemCode,
-}: PlatformProfileReferralModalProps) {
- const title = resolvePanelTitle(panel);
- const normalizedRedeemCode = redeemCode
- .trim()
- .replace(/[^0-9a-z]/gi, '')
- .toUpperCase();
- const canPasteRedeemCode = canPasteFromNativeHostClipboard();
- const pasteRedeemCode = () => {
- void readNativeHostClipboardText().then((text) => {
- if (text) {
- onRedeemCodeChange(text);
- }
- });
- };
-
- let content: ReactNode;
-
- if (panel === 'community') {
- content = (
-
- {COMMUNITY_QR_CODES.map((qrCode) => (
-
-
- 
-
-
- {qrCode.label}
-
-
- ))}
-
- );
- } else if (panel === 'redeem') {
- content = (
-
- }
- isEmpty={Boolean(center?.hasRedeemedCode)}
- emptyState={
-
- 已填写邀请码
-
- }
- >
-
-
- );
- } else {
- content = (
-
- }
- >
-
-
-
- 邀请码
-
-
- {center?.inviteCode ?? '--------'}
-
-
-
-
- {`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`}
-
- 每日最多获得十次邀请奖励。
-
- }
- actionSurface="profile"
- actionSize="md"
- actionFullWidth
- className="gap-2 rounded-xl"
- />
-
-
- 成功邀请
-
- {center?.invitedUsers?.length ? (
-
- {center.invitedUsers.map((user) => (
-
-
-
-
- {user.displayName || '玩家'}
-
-
-
- ))}
-
- ) : (
-
- 暂无成功邀请
-
- )}
-
-
-
- );
- }
-
- return (
-
- {title}
- {content}
-
- {error ? (
-
- {error}
-
- ) : null}
- {success ? (
-
- {success}
-
- ) : null}
-
- );
-}
diff --git a/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx b/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx
index dbbb10029..2ec27ed59 100644
--- a/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx
+++ b/src/components/platform-entry/usePlatformProfileCenterController.redemption.test.tsx
@@ -11,11 +11,62 @@ import {
userB,
} from './usePlatformProfileCenterController.testSupport';
+const clipboardMock = vi.hoisted(() => ({
+ copyTextToClipboard: vi.fn(async () => true),
+}));
+
+vi.mock('../../services/clipboard', () => clipboardMock);
+
describe('usePlatformProfileCenterController redemption lifecycle', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/profile');
usePlatformWalletStore.getState().resetWalletBalance();
vi.clearAllMocks();
+ clipboardMock.copyTextToClipboard.mockResolvedValue(true);
+ });
+
+ test('copies the invite link and the invite code as separate actions', async () => {
+ profileClientMocks.getPlatformProfileReferralInviteCenter.mockResolvedValue(
+ {
+ inviteCode: 'USER-A',
+ inviteLinkPath: '/?inviteCode=USER-A',
+ invitedCount: 0,
+ rewardedInviteCount: 0,
+ todayInviterRewardCount: 0,
+ todayInviterRewardRemaining: 10,
+ rewardPoints: 30,
+ invitedUsers: [],
+ hasRedeemedCode: false,
+ boundInviterUserId: null,
+ boundAt: null,
+ updatedAt: '2026-10-01T00:00:00.000Z',
+ },
+ );
+ const expectedInviteLink = `${window.location.origin}/?inviteCode=USER-A`;
+ const { result } = renderController(userA);
+
+ await act(async () => {
+ result.current.openProfilePopupPanel('invite');
+ });
+
+ expect(result.current.profilePopupPanel).toBe('invite');
+ expect(result.current.inviteLinkUrl).toBe(expectedInviteLink);
+
+ await act(async () => {
+ result.current.copyInviteLink();
+ });
+ expect(clipboardMock.copyTextToClipboard).toHaveBeenLastCalledWith(
+ expectedInviteLink,
+ );
+ expect(result.current.referralSuccess).toBe('邀请链接已复制');
+
+ await act(async () => {
+ result.current.copyInviteCode();
+ });
+ expect(clipboardMock.copyTextToClipboard).toHaveBeenLastCalledWith(
+ 'USER-A',
+ );
+ expect(result.current.referralSuccess).toBe('邀请码已复制');
});
test('ignores a reward-code response after the account changes', async () => {
diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts
index 03a5f423d..208eaccd7 100644
--- a/src/components/platform-entry/usePlatformProfileCenterController.ts
+++ b/src/components/platform-entry/usePlatformProfileCenterController.ts
@@ -422,7 +422,9 @@ export function usePlatformProfileCenterController({
useState(false);
const [referralError, setReferralError] = useState(null);
const [referralSuccess, setReferralSuccess] = useState(null);
- const { copyState: inviteCopyState, copyText: copyInviteText } =
+ const { copyState: inviteLinkCopyState, copyText: copyInviteLinkText } =
+ useCopyFeedback();
+ const { copyState: inviteCodeCopyState, copyText: copyInviteCodeText } =
useCopyFeedback();
const pendingWechatRechargeOrderRef =
useRef(null);
@@ -1701,21 +1703,43 @@ export function usePlatformProfileCenterController({
setProfilePopupPanel(null);
}, []);
- const copyInviteInfo = useCallback(() => {
- if (!referralCenter?.inviteCode) {
+ /**
+ * 邀请链接是分享给好友的正式入口(好友打开后自动带上邀请码),
+ * 因此这里只用站点 origin 补全后端返回的相对路径。
+ */
+ const inviteLinkUrl = useMemo(() => {
+ const inviteLinkPath = referralCenter?.inviteLinkPath;
+ if (!inviteLinkPath) {
+ return null;
+ }
+ if (typeof window === 'undefined') {
+ return inviteLinkPath;
+ }
+ try {
+ return new URL(inviteLinkPath, window.location.origin).href;
+ } catch {
+ return inviteLinkPath;
+ }
+ }, [referralCenter]);
+
+ const copyInviteLink = useCallback(() => {
+ if (!inviteLinkUrl) {
return;
}
+ void copyInviteLinkText(inviteLinkUrl).then((copied) => {
+ setReferralSuccess(copied ? '邀请链接已复制' : '复制失败');
+ });
+ }, [copyInviteLinkText, inviteLinkUrl]);
- const inviteUrl =
- typeof window === 'undefined'
- ? referralCenter.inviteLinkPath
- : new URL(referralCenter.inviteLinkPath, window.location.origin).href;
- void copyInviteText(`${referralCenter.inviteCode} ${inviteUrl}`).then(
- (copied) => {
- setReferralSuccess(copied ? '已复制' : '复制失败');
- },
- );
- }, [copyInviteText, referralCenter]);
+ const copyInviteCode = useCallback(() => {
+ const inviteCode = referralCenter?.inviteCode;
+ if (!inviteCode) {
+ return;
+ }
+ void copyInviteCodeText(inviteCode).then((copied) => {
+ setReferralSuccess(copied ? '邀请码已复制' : '复制失败');
+ });
+ }, [copyInviteCodeText, referralCenter]);
const submitReferralRedeemCode = useCallback(() => {
const inviteCode = referralRedeemCode
@@ -1850,7 +1874,10 @@ export function usePlatformProfileCenterController({
closeNativeWechatPayment,
closeProfilePopupPanel,
confirmNativeWechatPayment,
- inviteCopyState: inviteCopyState as CopyFeedbackState,
+ copyInviteCode,
+ inviteCodeCopyState: inviteCodeCopyState as CopyFeedbackState,
+ inviteLinkCopyState: inviteLinkCopyState as CopyFeedbackState,
+ inviteLinkUrl,
isLoadingRechargeCenter,
isLoadingReferral,
isLoadingWalletLedger,
@@ -1892,6 +1919,6 @@ export function usePlatformProfileCenterController({
walletLedgerError,
wechatRechargeOrderConfirmationState,
buyRechargeProduct,
- copyInviteInfo,
+ copyInviteLink,
};
}
diff --git a/vite.config.ts b/vite.config.ts
index 1210009a3..142f05851 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -190,7 +190,6 @@ const ACTIVE_TAILWIND_SOURCES = `@import 'tailwindcss' source(none);
@source "./components/platform-entry/PlatformProfileApiKeysModal.tsx";
@source "./components/platform-entry/PlatformProfileModalShell.tsx";
@source "./components/platform-entry/PlatformProfileRechargeModal.tsx";
-@source "./components/platform-entry/PlatformProfileReferralModal.tsx";
@source "./components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx";
@source "./components/platform-entry/PlatformProfileWalletLedgerModal.tsx";
@source "./components/platform-entry/PlatformRechargePaymentStatusDialogs.tsx";
|