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({ > 使用指南 + + +
+ {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.alt} -
-
- {qrCode.label} -
-
- ))} -
- ); - } else if (panel === 'redeem') { - content = ( - - } - isEmpty={Boolean(center?.hasRedeemedCode)} - emptyState={ - - 已填写邀请码 - - } - > -
{ - event.preventDefault(); - onSubmitRedeemCode(); - }} - > - onRedeemCodeChange(event.target.value)} - size="lg" - density="roomy" - tone="rose" - className="rounded-xl text-center font-black uppercase tracking-[0.16em]" - placeholder="邀请码" - aria-label="邀请码" - autoComplete="off" - autoFocus - /> - {canPasteRedeemCode ? ( - - - 粘贴 - - ) : null} - - {isSubmittingRedeem ? '提交中' : '提交'} - - -
- ); - } 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";