Merge remote-tracking branch 'origin/master' into fix/test-concurrent-problem
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m41s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m14s
Project CI / Backend tests (pull_request) Successful in 4m28s
Project CI / Native shell tests (pull_request) Successful in 6m4s
Project CI / Frontend tests (pull_request) Successful in 2m13s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m53s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 10m23s
Project CI / Repository checks (pull_request) Successful in 2m24s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m57s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m41s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m14s
Project CI / Backend tests (pull_request) Successful in 4m28s
Project CI / Native shell tests (pull_request) Successful in 6m4s
Project CI / Frontend tests (pull_request) Successful in 2m13s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m53s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 10m23s
Project CI / Repository checks (pull_request) Successful in 2m24s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m57s
# Conflicts: # docs/project-memory/shared-memory/pitfalls.md
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -226,6 +226,7 @@ export function AdminAgcModelsPage({
|
||||
>
|
||||
<option value="codex">Codex</option>
|
||||
<option value="cc">Claude Code</option>
|
||||
<option value="anthropic">Anthropic 协议</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 交付最终回复。",
|
||||
|
||||
@@ -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<Value, String> {
|
||||
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<T: DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
snapshot: &PlatformSessionSnapshot,
|
||||
@@ -171,6 +196,24 @@ pub(crate) async fn read_profile_wallet_ledger() -> Result<ProfileWalletLedgerRe
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn read_profile_referral_invite_center(
|
||||
) -> Result<ClientProfileReferralInviteCenter, String> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +352,21 @@ fn copy_env(command: &mut tokio::process::Command, name: &str) {
|
||||
fn claude_base_url(llm: Option<&GameCreatorLlmConfig>) -> Option<String> {
|
||||
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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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<String, String>,
|
||||
>| {
|
||||
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() {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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<u8>)>,
|
||||
write_permit: Option<&crate::agent::WritePermit>,
|
||||
) -> Result<RemoteImportResult, String> {
|
||||
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(),
|
||||
|
||||
@@ -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/<op>`。
|
||||
let proxy_base_url = format!("{api_base_url}/api/llm/v1");
|
||||
LlmConfig::new(
|
||||
LlmProvider::OpenAiCompatible,
|
||||
proxy_base_url,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { PlatformProfileReferralModal } from '../../../../../packages/shared/src/components/PlatformProfileReferralModal';
|
||||
import type {
|
||||
AccountReferralController,
|
||||
AccountReferralPanel,
|
||||
} from './useAccountReferral';
|
||||
|
||||
/** 客户端账号菜单的入口文案是「邀请码 / 玩家社区」,弹层标题跟着入口走。 */
|
||||
const CLIENT_REFERRAL_PANEL_TITLE: Record<AccountReferralPanel, string> = {
|
||||
invite: '邀请码',
|
||||
community: '玩家社区',
|
||||
};
|
||||
|
||||
/**
|
||||
* 账号菜单的邀请码 / 玩家社区弹层。
|
||||
*
|
||||
* 弹层实现与主站「我的」页签共用 `packages/shared` 里的同一份
|
||||
* `PlatformProfileReferralModal`(与充值、泥点账单同口径);这里只做宿主适配:
|
||||
* 把 Tauri typed command 读到的邀请中心、会话 origin 补全后的邀请链接、宿主剪贴板
|
||||
* 复制回显和面板开关接到共享组件上,不再另写一份客户端专属弹层。
|
||||
*/
|
||||
export function AccountReferralDialogs({
|
||||
controller,
|
||||
}: {
|
||||
controller: AccountReferralController;
|
||||
}) {
|
||||
if (!controller.panel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformProfileReferralModal
|
||||
panel={controller.panel}
|
||||
title={CLIENT_REFERRAL_PANEL_TITLE[controller.panel]}
|
||||
center={controller.center}
|
||||
inviteLinkUrl={controller.inviteUrl}
|
||||
isLoading={controller.loading}
|
||||
isSubmittingRedeem={false}
|
||||
redeemCode=""
|
||||
copyInviteLinkState={controller.copyInviteLinkState}
|
||||
copyInviteCodeState={controller.copyInviteCodeState}
|
||||
error={controller.error}
|
||||
success={null}
|
||||
onClose={controller.closePanel}
|
||||
onCopyInviteLink={controller.copyInviteLink}
|
||||
onCopyInviteCode={controller.copyInviteCode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
<AccountWalletDialogs controller={accountWallet} />
|
||||
<AccountReferralDialogs controller={accountReferral} />
|
||||
{walletSlot ? (
|
||||
createPortal(
|
||||
<AccountWalletBar controller={accountWallet} />,
|
||||
|
||||
@@ -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<AccountReferralPanel | null>(null);
|
||||
const [center, setCenter] =
|
||||
useState<ProfileReferralInviteCenterResponse | null>(null);
|
||||
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copyInviteLinkState, setCopyInviteLinkState] =
|
||||
useState<PlatformProfileReferralCopyState>('idle');
|
||||
const [copyInviteCodeState, setCopyInviteCodeState] =
|
||||
useState<PlatformProfileReferralCopyState>('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<typeof useAccountReferral>;
|
||||
@@ -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<ClientProfileReferralInviteCenterResponse>(
|
||||
'read_profile_referral_invite_center',
|
||||
);
|
||||
}
|
||||
|
||||
export function createClientProfileRechargeOrder(productId: string) {
|
||||
return invokeAccount<CreateProfileRechargeOrderResponse>(
|
||||
'create_profile_recharge_order',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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({
|
||||
>
|
||||
使用指南
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={sidebarMenuItemClass}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onReferralRequest('invite');
|
||||
}}
|
||||
>
|
||||
邀请码
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={sidebarMenuItemClass}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onReferralRequest('community');
|
||||
}}
|
||||
>
|
||||
玩家社区
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -198,6 +223,7 @@ export function Sidebar({
|
||||
onViewChange,
|
||||
onNoticeRequest,
|
||||
onRechargeRequest,
|
||||
onReferralRequest,
|
||||
onRuntimeConfigOpen,
|
||||
onLogout,
|
||||
}: LauncherSidebarProps) {
|
||||
@@ -324,6 +350,7 @@ export function Sidebar({
|
||||
onLogout={onLogout}
|
||||
onNoticeRequest={onNoticeRequest}
|
||||
onRechargeRequest={onRechargeRequest}
|
||||
onReferralRequest={onReferralRequest}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user