From ccff6cdceb659eea36f3b2250b717136c7125297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 17:50:00 +0800 Subject: [PATCH 01/18] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E8=BF=9C=E7=AB=AF=E5=A4=B1=E8=B4=A5=E8=A6=81?= =?UTF-8?q?=E5=B8=A6=E5=87=BA=E5=B9=B3=E5=8F=B0=E5=8E=9F=E5=9B=A0=E7=9A=84?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=86=B3=E7=AD=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - decision-log 新增 2026-10-02 条目:ResourceEditError 造型、不用 blanket From、不按 terminal_failure_code 分支、前缀去留、原文边界、Other 命名与 error.rs 落点 - 条目里写清 terminal_failure_code 的清理前置条件:字段无 skip_serializing_if 且账本是 deny_unknown_fields,清掉会影响每个已落盘账本 - pitfalls「远端资源编辑终态必须指出唯一出口」补上处理与验证口径:轮询终态把平台 error 原文装进 typed 错误带出、不再压成一句,断言同时要求原文进文案且不进账本 --- docs/project-memory/shared-memory/decision-log.md | 12 ++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 4 ++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 01fd611cd..558cf4e12 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,4 +1,16 @@ # 决策记录 +## 2026-10-02 资源编辑远端失败的原始原因穿出到工具错误,资源编辑错误通道补一层 typed + +- 背景:轮询到 `status=failed` 时客户端只读 `status`,丢掉平台在同一个响应里给的 `error`(契约 `ExternalEditorGenerationJobResponse.error`),统一写 `terminal_failure_code = remote-generation-failed` 并返回「remote-terminal-failed: 资源编辑生成失败」。平台的可行动原因就此消失:模型与用户卡片只看到一句「失败了」,重试路径(`ensure_resource_edit_phase_resumable`)也只有分类码。这违反 `pitfalls.md`「远端资源编辑终态必须指出唯一出口」里已写下的口径——「首次失败的原始拒绝说明继续由当次错误文案承担」;提交期 HTTP 400 分支(`editor_api_rejection_reason`)兑现了,轮询分支没有。另外 `remote-terminal-failed:` 只是文案前缀(全仓没有 `starts_with` 解析它),在第一句失败文案里与「失败」重复。 +- 决策(typed 承载):新增 `ResourceEditError`(`project/resource_editor/error.rs`),只两个变体:`RemoteGenerationFailed { serverMessage }` 承载平台 `error` 原文,`Other(String)` 收尚未分类的失败(`// TODO refactor string-typed`)。两个入口 `derive_local_project_resource`、`resume_local_project_resource_edit` 返回 typed;旧名 `derive_local_project_resource_at` / `resume_local_project_resource_edit_at` 保留为 `Result<_, String>` 外观(映射 `to_user_msg()`),因此 33 个既有测试调用点与两个 Tauri 命令零改动。 +- 决策(不用 blanket From):不提供 `impl From`;每处 String 错误显式 `.map_err(ResourceEditError::Other)`,让「还没 typed 化」的边界处处可见,而不是被一次隐式转换吞掉。 +- 决策(不按 code 分支):不按 `terminal_failure_code` 分支。它两个写入点最终落到同一个 `phase`、唯一读者只做插值不比较,值域撑不起 policy;字段上加 `// TODO clean unnecessary`。清理前置条件已核实:该字段无 `skip_serializing_if`,`.agent/resource-edits/operations/*.json` 每个文件都带这个 key,而 `ResourceEditLedger` 是 `deny_unknown_fields`、扫描循环里一个文件解析失败会让整个「待恢复资源编辑」列表报错返回。 +- 决策(前缀去留):删掉第一句失败文案里的 `remote-terminal-failed:`(轮询与提交期 400 两处)。`ensure_resource_edit_phase_resumable` 里那三个 token 保留:它们与三个 phase 一一对应,是那句重试文案里区分「确定失败 / 已归档 / 待对账」的唯一手段。 +- 决策(原文边界):平台原文只进当次错误文案,仍不进账本(`terminal_failure_code` 的写入边界与既有断言不变)。平台 `user_visible_external_generation_error` 已对四种 kind 做 sanitize,图片/视频两种原样透出——与同 wire 的 `canvas_generation.rs` 口径一致,要收边界应改服务端。 +- 决策(命名与落点):尚未 typed 化的变体叫 `Other`,不叫 `Message`(后者分不清是「已渲染文案」还是「原始消息」);新错误单独放 `project/resource_editor/error.rs`,不再往主文件里塞类型定义。 +- 改动范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`project/resource_editor/error.rs`(新)、`agent/tool/create_or_derive_resource/error.rs`、`agent/tool/remove_background/error.rs`、`agent/direct_tool_bridge.rs`、`docs/project-memory/shared-memory/pitfalls.md`。 +- 验证:待补(`cargo test --bin genarrative-ai-game-creator-shell -- project::resource_editor` 与 `-- agent::direct_tool_bridge`;`npm run check:encoding`;`git diff --check`)。 +- 关联:`pitfalls.md`「远端资源编辑终态必须指出唯一出口」。 ## 2026-10-01 Web、后台与 AGC 一键联调 - 背景:Web、管理后台和 AGC 同时开发时,分别启动入口容易产生两套 API/worker/SpacetimeDB,以及重复后台 Vite。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 901bff371..8bc2f2cc3 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -340,8 +340,8 @@ Copy Artifact 插件在**非 SYSTEM 认证**下按「认证用户」判权:只 - 现象:「生成背景音乐」再次提交 0.1 秒就失败,卡片只有 `remote-terminal-failed: 远端资源编辑已明确失败,不允许再次请求`,既没有原因也没有下一步。 - 原因:上一次同 `operationId` 的请求被平台确定性拒绝(HTTP 400 或任务 `failed`)后,账本落到 `remote-failed`,之后所有重试都在 `ensure_resource_edit_phase_resumable` 失败关闭;唯一出口是「待恢复资源编辑」里的移出恢复队列,但终态文案没有指向它。 -- 处理:终态文案带出稳定失败码,并明确「先在待恢复资源编辑中把它移出恢复队列」;上游失败原文仍不写入账本(只存分类码),首次失败的原始拒绝说明继续由当次错误文案承担。 -- 验证:`remote_failed_status_is_terminal_and_can_only_be_archived`、`submission_bad_request_is_terminal_while_gateway_failure_requires_reconciliation` 等资源编辑用例继续通过,账本序列化不含上游失败原文。 +- 处理:终态文案带出稳定失败码,并明确「先在待恢复资源编辑中把它移出恢复队列」;上游失败原文仍不写入账本(只存分类码),首次失败的原始拒绝说明继续由当次错误文案承担——轮询终态这条路由此改成把平台 `error` 原文装进 `ResourceEditError::RemoteGenerationFailed` 原样带出(2026-10-02),不再压成「资源编辑生成失败」一句;第一句失败文案不再带 `remote-terminal-failed:` 前缀。 +- 验证:`remote_failed_status_is_terminal_and_can_only_be_archived` 断言失败文案带出平台 `error` 原文、同时账本序列化不含原文;`background_removal_remote_failure_keeps_manifest_without_result` 覆盖平台没给 `error` 时的兜底文案;`submission_bad_request_is_terminal_while_gateway_failure_requires_reconciliation` 等资源编辑用例继续通过。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`。 ## Tauri `--no-sign` 会连带跳过 updater 签名 From f68773db9af02ee64eaa3da6cf5e263b05e20693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 18:42:41 +0800 Subject: [PATCH 02/18] =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E9=80=9A=E9=81=93=E8=A1=A5=20typed=20?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=B9=B6=E6=8A=8A=E5=B9=B3=E5=8F=B0=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=8E=9F=E6=96=87=E5=B8=A6=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 project/resource_editor/error.rs:ResourceEditError 只有 RemoteGenerationFailed { server_message } 与 Other(String) 两个变体,to_user_msg 是唯一写「资源编辑生成失败:…」的地方,Other 上留 // TODO refactor string-typed - 轮询到 status=failed 时读平台 error 原文装进 RemoteGenerationFailed 带出,账本仍只写 terminal_failure_code,原文不进账本 - 删掉轮询与提交期 HTTP 400 两处首句失败文案的 remote-terminal-failed 前缀;ensure_resource_edit_phase_resumable 里的三个 token 保留 - 两个入口拆成 typed 实现与 Result<_, String> 外观:derive_local_project_resource_typed / resume_local_project_resource_edit_typed,旧名映射 to_user_msg 供既有调用点与 Tauri 命令使用 - 不提供 impl From,每处 String 错误显式 .map_err(ResourceEditError::Other) - terminal_failure_code 加 // TODO clean unnecessary 并写明清理前置条件(字段无 skip_serializing_if、账本带 deny_unknown_fields) - 测试:远端终态断言平台原文进文案且不进账本,抠图远端失败改用 typed 入口覆盖无 error 时的兜底 --- .../src-tauri/src/project/resource_editor.rs | 391 ++++++++++++------ .../background_removal_tests.rs | 11 +- .../src/project/resource_editor/error.rs | 37 ++ 3 files changed, 320 insertions(+), 119 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index a3195c7fa..2b91952dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -6,6 +6,10 @@ use std::path::PathBuf; use std::sync::{Arc, Weak}; use uuid::Uuid; +mod error; + +pub(crate) use error::ResourceEditError; + const RESOURCE_EDIT_SCHEMA_VERSION: &str = "game-creator-resource-edit.v1"; const RESOURCE_EDIT_LEDGER_MAX_BYTES: usize = 512 * 1024; const RESOURCE_EDIT_TEXT_MAX_BYTES: usize = 2 * 1024 * 1024; @@ -484,6 +488,8 @@ struct ResourceEditLedger { remote_slice_warnings: Vec, remote_asset_object_id: Option, remote_model: Option, + // TODO clean unnecessary:字段无 `skip_serializing_if`,每个已落盘账本都带这个 key, + // 而 `ResourceEditLedger` 是 `deny_unknown_fields`;直接删会让扫描时旧账本解析失败。 #[serde(default)] terminal_failure_code: Option, #[serde(default)] @@ -2801,10 +2807,8 @@ async fn submit_resource_edit_remote( return Err(error.clone()); } return Err(match editor_api_rejection_reason(response).await { - Some(reason) => { - format!("remote-terminal-failed: 资源编辑请求被拒绝:HTTP 400({reason})") - } - None => "remote-terminal-failed: 资源编辑请求被拒绝:HTTP 400".to_string(), + Some(reason) => format!("资源编辑请求被拒绝:HTTP 400({reason})"), + None => "资源编辑请求被拒绝:HTTP 400".to_string(), }); } if !matches!( @@ -2875,11 +2879,12 @@ async fn wait_for_resource_edit_remote( client: &reqwest::Client, access: &ExternalEditorBindingAccess<'_>, ledger: &mut ResourceEditLedger, -) -> Result { +) -> Result { let operation_id = ledger .remote_operation_id .as_deref() - .ok_or_else(|| "资源编辑账本缺少远端 operationId".to_string())?; + .ok_or_else(|| "资源编辑账本缺少远端 operationId".to_string()) + .map_err(ResourceEditError::Other)?; let status_url = format!( "{}{}", access.api_base_url(), @@ -2889,39 +2894,50 @@ async fn wait_for_resource_edit_remote( let mut poll_after_ms = 1_000; loop { if started_at.elapsed() >= Duration::from_secs(35 * 60) { - return Err("result-unknown: 资源编辑任务仍在执行,已停止本地等待".to_string()); + return Err(ResourceEditError::Other( + "result-unknown: 资源编辑任务仍在执行,已停止本地等待".to_string(), + )); } - access.validate_frozen_session()?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; tokio::time::sleep(Duration::from_millis(poll_after_ms)).await; - access.validate_frozen_session()?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; let response = crate::http_client::with_agc_main_site_marker( client.get(&status_url).bearer_auth(access.bearer_token()), ) .send() .await - .map_err(|_| "result-unknown: 查询资源编辑任务失败".to_string())?; - access.validate_frozen_session()?; + .map_err(|_| { + ResourceEditError::Other("result-unknown: 查询资源编辑任务失败".to_string()) + })?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; if response.status() == reqwest::StatusCode::UNAUTHORIZED { - return Err(editor_api_authentication_error()); + return Err(ResourceEditError::Other(editor_api_authentication_error())); } if response.status() == reqwest::StatusCode::FORBIDDEN { - return Err(editor_api_authorization_error()); + return Err(ResourceEditError::Other(editor_api_authorization_error())); } if [429, 502, 503, 504].contains(&response.status().as_u16()) { poll_after_ms = 2_000; continue; } if !response.status().is_success() { - return Err(format!( + return Err(ResourceEditError::Other(format!( "result-unknown: 查询资源编辑任务返回 HTTP {}", response.status().as_u16() - )); + ))); } - let payload = response - .json::() - .await - .map_err(|_| "result-unknown: 资源编辑任务响应无法解析".to_string())?; - access.validate_frozen_session()?; + let payload = response.json::().await.map_err(|_| { + ResourceEditError::Other("result-unknown: 资源编辑任务响应无法解析".to_string()) + })?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; let job = platform_generation_status_data(&payload); match json_string_field(job, "status").as_deref() { Some("completed") => { @@ -2929,7 +2945,11 @@ async fn wait_for_resource_edit_remote( .get("result") .filter(|value| !value.is_null()) .cloned() - .ok_or_else(|| "result-unknown: 资源编辑任务完成但缺少 result".to_string())?; + .ok_or_else(|| { + ResourceEditError::Other( + "result-unknown: 资源编辑任务完成但缺少 result".to_string(), + ) + })?; if let (Some(result), Some(warning)) = ( result.as_object_mut(), job.get("warning").filter(|value| !value.is_null()), @@ -2937,20 +2957,28 @@ async fn wait_for_resource_edit_remote( result.insert("_queryWarning".to_string(), warning.clone()); } if !resource_edit_result_has_download(&result) { - return Err("result-unknown: 资源编辑结果缺少可下载媒体".to_string()); + return Err(ResourceEditError::Other( + "result-unknown: 资源编辑结果缺少可下载媒体".to_string(), + )); } return Ok(result); } Some("failed") => { + let server_message = json_string_field(job, "error"); ledger.terminal_failure_code = Some("remote-generation-failed".to_string()); ledger.terminal_failed_at = Some(unix_timestamp()); - update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::RemoteFailed)?; - return Err("remote-terminal-failed: 资源编辑生成失败".to_string()); + update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::RemoteFailed) + .map_err(ResourceEditError::Other)?; + return Err(ResourceEditError::RemoteGenerationFailed { server_message }); } Some("queued" | "running") => { poll_after_ms = external_generation_poll_after_ms(job); } - _ => return Err("result-unknown: 资源编辑任务状态无效".to_string()), + _ => { + return Err(ResourceEditError::Other( + "result-unknown: 资源编辑任务状态无效".to_string(), + )) + } } } } @@ -3371,19 +3399,24 @@ async fn prepare_remote_resource_edit( ledger: &mut ResourceEditLedger, access: &ExternalEditorBindingAccess<'_>, principal: &ExternalEditorPrincipalIdentity, -) -> Result<(), String> { - bind_resource_edit_platform_owner(ledger, access.frozen_platform_session())?; - access.validate_frozen_session()?; +) -> Result<(), ResourceEditError> { + bind_resource_edit_platform_owner(ledger, access.frozen_platform_session()) + .map_err(ResourceEditError::Other)?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; match prepare_resource_edit_service_identity( root, ledger, access.api_base_url(), access.bearer_token(), access.frozen_platform_session(), - )? { + ) + .map_err(ResourceEditError::Other)? + { ResourceEditServiceIdentityDecision::Ready => {} ResourceEditServiceIdentityDecision::ConfirmationRequired(_) => { - return Err("service-identity-confirmation-required: 当前服务地址需要用户确认后才能恢复原资源编辑 operation".to_string()) + return Err(ResourceEditError::Other("service-identity-confirmation-required: 当前服务地址需要用户确认后才能恢复原资源编辑 operation".to_string())) } } if ledger @@ -3391,14 +3424,14 @@ async fn prepare_remote_resource_edit( .as_deref() .is_some_and(|endpoint| !is_external_resource_edit_endpoint(endpoint)) { - return Err( + return Err(ResourceEditError::Other( "result-unknown: 历史站内资源编辑 operation 不能由 External v1 自动重放".to_string(), - ); + )); } let client = crate::http_client::agc_main_site_client_builder() .timeout(Duration::from_secs(35 * 60)) .build() - .map_err(|_| "无法创建资源编辑 HTTP 客户端".to_string())?; + .map_err(|_| ResourceEditError::Other("无法创建资源编辑 HTTP 客户端".to_string()))?; let download_source = if ledger.phase == ResourceEditLedgerPhase::RemoteCompleted { if let Some(legacy_public_path) = ledger.remote_legacy_public_path.clone() { serde_json::json!({ "imageSrc": legacy_public_path }) @@ -3407,13 +3440,19 @@ async fn prepare_remote_resource_edit( "objectKey": ledger .remote_object_key .clone() - .ok_or_else(|| "远端已完成的资源编辑缺少稳定 objectKey".to_string())? + .ok_or_else(|| { + ResourceEditError::Other( + "远端已完成的资源编辑缺少稳定 objectKey".to_string(), + ) + })? }) } } else { let generated = if ledger.remote_operation_id.is_some() { let result = wait_for_resource_edit_remote(root, &client, access, ledger).await?; - access.validate_frozen_session()?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; result } else { let prepared_source = if matches!( @@ -3428,7 +3467,8 @@ async fn prepare_remote_resource_edit( ensure_resource_edit_source_reference( root, &client, access, principal, input, source, ledger, ) - .await?, + .await + .map_err(ResourceEditError::Other)?, ) } else { None @@ -3441,7 +3481,7 @@ async fn prepare_remote_resource_edit( // never carry another owner's resourceId, objectKey, or projectId. ledger.endpoint = None; ledger.request_body_json = None; - write_resource_edit_ledger(root, ledger)?; + write_resource_edit_ledger(root, ledger).map_err(ResourceEditError::Other)?; } if ledger.endpoint.is_none() || ledger.request_body_json.is_none() { let canvas_context = if let Some(prepared) = prepared_source.as_ref() { @@ -3454,7 +3494,11 @@ async fn prepare_remote_resource_edit( | LocalProjectResourceEditKind::SoundEffect | LocalProjectResourceEditKind::BackgroundMusic ) { - Some(prepare_external_canvas_generation_context(root, &client, access).await?) + Some( + prepare_external_canvas_generation_context(root, &client, access) + .await + .map_err(ResourceEditError::Other)?, + ) } else { None }; @@ -3467,12 +3511,13 @@ async fn prepare_remote_resource_edit( .as_ref() .map(|prepared| prepared.stable_reference.as_str()), canvas_context.as_ref(), - )?; + ) + .map_err(ResourceEditError::Other)?; let mut body = body; if let Some(prepared) = prepared_source.as_ref() { - let body_object = body - .as_object_mut() - .ok_or_else(|| "资源编辑生成请求正文必须是 object".to_string())?; + let body_object = body.as_object_mut().ok_or_else(|| { + ResourceEditError::Other("资源编辑生成请求正文必须是 object".to_string()) + })?; body_object.insert( "projectId".to_string(), serde_json::Value::String(prepared.canvas_context.project_id.clone()), @@ -3484,18 +3529,22 @@ async fn prepare_remote_resource_edit( ledger.remote_project_id = Some(prepared.canvas_context.project_id.clone()); } ledger.endpoint = Some(endpoint.to_string()); - ledger.request_body_json = Some( - serde_json::to_string(&body) - .map_err(|error| format!("序列化资源编辑生成请求失败:{error}"))?, - ); - write_resource_edit_ledger(root, ledger)?; + ledger.request_body_json = Some(serde_json::to_string(&body).map_err(|error| { + ResourceEditError::Other(format!("序列化资源编辑生成请求失败:{error}")) + })?); + write_resource_edit_ledger(root, ledger).map_err(ResourceEditError::Other)?; } - submit_and_persist_resource_edit_remote(root, &client, access, ledger).await?; + submit_and_persist_resource_edit_remote(root, &client, access, ledger) + .await + .map_err(ResourceEditError::Other)?; let result = wait_for_resource_edit_remote(root, &client, access, ledger).await?; - access.validate_frozen_session()?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; result }; - let identity = extract_resource_edit_remote_identity(&generated, input.edit_kind)?; + let identity = extract_resource_edit_remote_identity(&generated, input.edit_kind) + .map_err(ResourceEditError::Other)?; ledger.remote_resource_id = identity.resource_id; ledger.remote_object_key = identity.object_key.clone(); ledger.remote_legacy_public_path = identity.legacy_public_path.clone(); @@ -3503,8 +3552,9 @@ async fn prepare_remote_resource_edit( .sequence_frames .as_ref() .map(|frames| { - serde_json::to_string(frames) - .map_err(|error| format!("序列化角色动画序列帧失败:{error}")) + serde_json::to_string(frames).map_err(|error| { + ResourceEditError::Other(format!("序列化角色动画序列帧失败:{error}")) + }) }) .transpose()?; ledger.remote_sequence_duration_ms = identity.sequence_duration_ms; @@ -3519,18 +3569,23 @@ async fn prepare_remote_resource_edit( ledger.remote_slice_warnings = identity.slice_warnings; ledger.remote_asset_object_id = identity.asset_object_id; ledger.remote_model = identity.model; - update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::RemoteCompleted)?; + update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::RemoteCompleted) + .map_err(ResourceEditError::Other)?; if let Some(legacy_public_path) = identity.legacy_public_path { serde_json::json!({ "imageSrc": legacy_public_path }) } else { serde_json::json!({ "objectKey": identity .object_key - .ok_or_else(|| "资源编辑结果缺少稳定 objectKey".to_string())? + .ok_or_else(|| { + ResourceEditError::Other("资源编辑结果缺少稳定 objectKey".to_string()) + })? }) } }; - access.validate_frozen_session()?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; let download = resolve_canvas_resource_download_with_access( &client, access, @@ -3538,15 +3593,22 @@ async fn prepare_remote_resource_edit( media_read_limit(&input.edit_kind), ) .await - .map_err(|_| "result-unknown: 远端资源编辑结果下载或换签失败".to_string())? - .ok_or_else(|| "远端资源编辑结果缺少可下载媒体".to_string())?; - access.validate_frozen_session()?; + .map_err(|_| { + ResourceEditError::Other("result-unknown: 远端资源编辑结果下载或换签失败".to_string()) + })? + .ok_or_else(|| ResourceEditError::Other("远端资源编辑结果缺少可下载媒体".to_string()))?; + access + .validate_frozen_session() + .map_err(ResourceEditError::Other)?; let (media_type, extension) = - validate_downloaded_media(&input.edit_kind, &download.media_type, &download.bytes)?; - write_resource_edit_staging(root, &input.operation_id, &download.bytes)?; + validate_downloaded_media(&input.edit_kind, &download.media_type, &download.bytes) + .map_err(ResourceEditError::Other)?; + write_resource_edit_staging(root, &input.operation_id, &download.bytes) + .map_err(ResourceEditError::Other)?; ledger.staged_media_type = Some(media_type); ledger.staged_extension = Some(extension); update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::MediaDownloaded) + .map_err(ResourceEditError::Other) } fn remove_resource_edit_staging(root: &Path, operation_id: &str) -> Result<(), String> { @@ -5317,25 +5379,43 @@ pub(crate) async fn archive_failed_local_project_resource_edit_at( }) } +/// 未 typed 化入口:Tauri 命令与既有调用点仍按 `String` 取文案。 +// TODO refactor string-typed pub(crate) async fn resume_local_project_resource_edit_at( input: ResumeLocalProjectResourceEditInput, ) -> Result { - validate_resource_edit_uuid(&input.operation_id, "operationId")?; + resume_local_project_resource_edit_typed(input) + .await + .map_err(|error| error.to_user_msg()) +} + +/// 「待恢复资源编辑」的恢复实现:错误带 typed 分类,远端终止失败不在这一层压成文案。 +pub(crate) async fn resume_local_project_resource_edit_typed( + input: ResumeLocalProjectResourceEditInput, +) -> Result { + validate_resource_edit_uuid(&input.operation_id, "operationId") + .map_err(ResourceEditError::Other)?; let root = Path::new(input.project_path.trim()); - validate_project_root(root)?; - let manifest = read_existing_manifest_for_project(root)?; + validate_project_root(root).map_err(ResourceEditError::Other)?; + let manifest = read_existing_manifest_for_project(root).map_err(ResourceEditError::Other)?; if manifest.project_id != input.expected_project_id { - return Err("project-identity-conflict".to_string()); + return Err(ResourceEditError::Other( + "project-identity-conflict".to_string(), + )); } - let ledger = read_resource_edit_ledger(root, &input.operation_id)? - .ok_or_else(|| "待恢复的资源编辑账本不存在".to_string())?; + let ledger = read_resource_edit_ledger(root, &input.operation_id) + .map_err(ResourceEditError::Other)? + .ok_or_else(|| "待恢复的资源编辑账本不存在".to_string()) + .map_err(ResourceEditError::Other)?; if ledger.schema_version != RESOURCE_EDIT_SCHEMA_VERSION || ledger.project_id != input.expected_project_id || ledger.operation_id != input.operation_id { - return Err("待恢复的资源编辑账本身份无效".to_string()); + return Err(ResourceEditError::Other( + "待恢复的资源编辑账本身份无效".to_string(), + )); } - ensure_resource_edit_phase_resumable(&ledger)?; + ensure_resource_edit_phase_resumable(&ledger).map_err(ResourceEditError::Other)?; let source_asset = ledger .source_asset_id .as_deref() @@ -5389,7 +5469,7 @@ pub(crate) async fn resume_local_project_resource_edit_at( .to_string(), ) }); - derive_local_project_resource_at(DeriveLocalProjectResourceInput { + derive_local_project_resource_typed(DeriveLocalProjectResourceInput { project_path: input.project_path, expected_project_id: input.expected_project_id, expected_project_revision: ledger.expected_project_revision, @@ -5502,47 +5582,79 @@ pub(crate) async fn run_local_project_audio_generation_at( Ok(result.asset.map(|asset| asset.id)) } +/// 未 typed 化入口:Tauri 命令与既有调用点仍按 `String` 取文案。 +// TODO refactor string-typed pub(crate) async fn derive_local_project_resource_at( input: DeriveLocalProjectResourceInput, ) -> Result { - validate_resource_edit_uuid(&input.operation_id, "operationId")?; - validate_resource_edit_uuid(&input.idempotency_key, "idempotencyKey")?; + derive_local_project_resource_typed(input) + .await + .map_err(|error| error.to_user_msg()) +} + +/// 资源编辑派生的实现:错误带 typed 分类。 +/// +/// 这一层是「远端原因」与「本地失败」的分界:`ResourceEditError` 只在这里产生, +/// 上层(工具桥)据此决定是原样透传平台原文,还是按本地失败包装。 +pub(crate) async fn derive_local_project_resource_typed( + input: DeriveLocalProjectResourceInput, +) -> Result { + validate_resource_edit_uuid(&input.operation_id, "operationId") + .map_err(ResourceEditError::Other)?; + validate_resource_edit_uuid(&input.idempotency_key, "idempotencyKey") + .map_err(ResourceEditError::Other)?; if input.edit_kind == LocalProjectResourceEditKind::BackgroundRemoval { validate_background_removal_options( input.background_mode.as_deref().unwrap_or("complex"), input.screen_color.as_deref(), - )?; + ) + .map_err(ResourceEditError::Other)?; } if input.expected_project_revision > 9_007_199_254_740_991 { - return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string()); + return Err(ResourceEditError::Other( + "expectedProjectRevision 超出 JavaScript 安全整数范围".to_string(), + )); } - let prompt = normalize_resource_edit_prompt(&input.edit_kind, &input.prompt)?; - let asset_name = normalize_resource_edit_name(&input.asset_name)?; + let prompt = normalize_resource_edit_prompt(&input.edit_kind, &input.prompt) + .map_err(ResourceEditError::Other)?; + let asset_name = + normalize_resource_edit_name(&input.asset_name).map_err(ResourceEditError::Other)?; let root = Path::new(input.project_path.trim()); - validate_project_root(root)?; - let operation_lock = resource_edit_operation_lock(root, &input.operation_id)?; + validate_project_root(root).map_err(ResourceEditError::Other)?; + let operation_lock = resource_edit_operation_lock(root, &input.operation_id) + .map_err(ResourceEditError::Other)?; let operation_guard = operation_lock.lock_owned().await; - let manifest = read_existing_manifest_for_project(root)?; + let manifest = read_existing_manifest_for_project(root).map_err(ResourceEditError::Other)?; if manifest.project_id != input.expected_project_id { - return Err("project-identity-conflict".to_string()); + return Err(ResourceEditError::Other( + "project-identity-conflict".to_string(), + )); } - let source = resolve_resource_edit_source(root, &manifest, &input)?; + let source = + resolve_resource_edit_source(root, &manifest, &input).map_err(ResourceEditError::Other)?; let request_fingerprint = - resource_edit_request_fingerprint(&input, &source, &prompt, &asset_name)?; + resource_edit_request_fingerprint(&input, &source, &prompt, &asset_name) + .map_err(ResourceEditError::Other)?; let legacy_request_fingerprint = - legacy_resource_edit_request_fingerprint(&input, &source, &prompt, &asset_name)?; + legacy_resource_edit_request_fingerprint(&input, &source, &prompt, &asset_name) + .map_err(ResourceEditError::Other)?; let now = unix_timestamp(); - let existing_ledger = read_resource_edit_ledger(root, &input.operation_id)?; + let existing_ledger = + read_resource_edit_ledger(root, &input.operation_id).map_err(ResourceEditError::Other)?; let new_remote_credentials = if existing_ledger.is_none() && input.edit_kind.is_remote_media() { - Some(resolve_canvas_sync_api_credentials(None, None)?) + Some(resolve_canvas_sync_api_credentials(None, None).map_err(ResourceEditError::Other)?) } else { None }; if existing_ledger.is_none() - && read_game_creator_agent_runtime_project_revision(root)?.revision + && read_game_creator_agent_runtime_project_revision(root) + .map_err(ResourceEditError::Other)? + .revision != input.expected_project_revision { - return Err("project-revision-conflict".to_string()); + return Err(ResourceEditError::Other( + "project-revision-conflict".to_string(), + )); } let mut ledger = match existing_ledger { Some(ledger) => { @@ -5553,7 +5665,8 @@ pub(crate) async fn derive_local_project_resource_at( &prompt, &asset_name, &ledger, - )?; + ) + .map_err(ResourceEditError::Other)?; let fingerprint_matches = matches!( ledger.request_fingerprint.as_str(), value if value == request_fingerprint || value == legacy_request_fingerprint @@ -5564,9 +5677,11 @@ pub(crate) async fn derive_local_project_resource_at( || ledger.idempotency_key != input.idempotency_key || ledger.project_id != input.expected_project_id { - return Err("operationId 或幂等键已绑定到不同资源编辑请求".to_string()); + return Err(ResourceEditError::Other( + "operationId 或幂等键已绑定到不同资源编辑请求".to_string(), + )); } - ensure_resource_edit_phase_resumable(&ledger)?; + ensure_resource_edit_phase_resumable(&ledger).map_err(ResourceEditError::Other)?; ledger } None => { @@ -5642,15 +5757,17 @@ pub(crate) async fn derive_local_project_resource_at( if input.edit_kind.is_remote_media() { let (api_base_url, _, platform_session) = new_remote_credentials .as_ref() - .ok_or_else(|| "资源编辑缺少首次访问主体".to_string())?; + .ok_or_else(|| "资源编辑缺少首次访问主体".to_string()) + .map_err(ResourceEditError::Other)?; initialize_resource_edit_access_identity( root, &mut ledger, api_base_url, platform_session.as_ref(), - )?; + ) + .map_err(ResourceEditError::Other)?; } else { - write_resource_edit_ledger(root, &ledger)?; + write_resource_edit_ledger(root, &ledger).map_err(ResourceEditError::Other)?; } ledger } @@ -5658,10 +5775,11 @@ pub(crate) async fn derive_local_project_resource_at( if ledger.phase == ResourceEditLedgerPhase::Committed || input.edit_kind == LocalProjectResourceEditKind::Version { - let project_lock = resource_edit_project_mutation_lock(root)?; + let project_lock = + resource_edit_project_mutation_lock(root).map_err(ResourceEditError::Other)?; let project_guard = project_lock.lock_owned().await; let root = root.to_path_buf(); - return tokio::task::spawn_blocking(move || { + let blocked = tokio::task::spawn_blocking(move || { // 调用方取消等待时,互斥仍由正在运行的提交持有。 let _operation_guard = operation_guard; let _project_guard = project_guard; @@ -5681,14 +5799,17 @@ pub(crate) async fn derive_local_project_resource_at( } }) .await - .map_err(|error| format!("资源编辑提交任务未返回:{error}"))?; + .map_err(|error| ResourceEditError::Other(format!("资源编辑提交任务未返回:{error}")))?; + return blocked.map_err(ResourceEditError::Other); } let remote_credentials = if input.edit_kind.is_remote_media() && ledger.phase != ResourceEditLedgerPhase::MediaDownloaded { Some(match new_remote_credentials { Some(credentials) => credentials, - None => resolve_canvas_sync_api_credentials(None, None)?, + None => { + resolve_canvas_sync_api_credentials(None, None).map_err(ResourceEditError::Other)? + } }) } else { None @@ -5702,10 +5823,10 @@ pub(crate) async fn derive_local_project_resource_at( && ledger.platform_owner_user_id.is_some() && frozen_platform_session.is_none() { - return Err( + return Err(ResourceEditError::Other( "authentication-required: 此资源编辑属于已登录账号,请恢复原账号后提交本地结果" .to_string(), - ); + )); } let binding_principal = remote_credentials .as_ref() @@ -5717,28 +5838,36 @@ pub(crate) async fn derive_local_project_resource_at( )?; external_editor_binding_principal(&access) }) - .transpose()?; + .transpose() + .map_err(ResourceEditError::Other)?; if input.edit_kind.is_remote_media() { - bind_resource_edit_platform_owner(&ledger, frozen_platform_session.as_ref())?; + bind_resource_edit_platform_owner(&ledger, frozen_platform_session.as_ref()) + .map_err(ResourceEditError::Other)?; } if ledger.phase != ResourceEditLedgerPhase::MediaDownloaded { let generation_result = if input.edit_kind.is_text() { - let staged = read_optional_resource_edit_staging(root, &input.operation_id)?; + let staged = read_optional_resource_edit_staging(root, &input.operation_id) + .map_err(ResourceEditError::Other)?; let generated = match staged { Some(bytes) => Ok(bytes), - None => generate_resource_edit_text(root, &source, &input, &prompt, &ledger).await, + None => generate_resource_edit_text(root, &source, &input, &prompt, &ledger) + .await + .map_err(ResourceEditError::Other), }; match generated { Err(error) => Err(error), Ok(bytes) => (|| { let content = std::str::from_utf8(&bytes) - .map_err(|_| "派生文本不是 UTF-8".to_string())?; + .map_err(|_| "派生文本不是 UTF-8".to_string()) + .map_err(ResourceEditError::Other)?; let (media_type, extension) = validate_text_derivative( &input.edit_kind, source.source_path.as_deref(), content, - )?; - write_resource_edit_staging(root, &input.operation_id, &bytes)?; + ) + .map_err(ResourceEditError::Other)?; + write_resource_edit_staging(root, &input.operation_id, &bytes) + .map_err(ResourceEditError::Other)?; ledger.staged_media_type = Some(media_type); ledger.staged_extension = Some(extension); update_resource_edit_phase( @@ -5746,17 +5875,20 @@ pub(crate) async fn derive_local_project_resource_at( &mut ledger, ResourceEditLedgerPhase::MediaDownloaded, ) + .map_err(ResourceEditError::Other) })(), } } else if input.edit_kind.is_remote_media() { let (api_base_url, api_key, _) = remote_credentials .as_ref() - .ok_or_else(|| "资源编辑缺少 External Editor 凭据".to_string())?; + .ok_or_else(|| "资源编辑缺少 External Editor 凭据".to_string()) + .map_err(ResourceEditError::Other)?; let access = ExternalEditorBindingAccess::new( api_base_url, api_key, frozen_platform_session.as_ref(), - )?; + ) + .map_err(ResourceEditError::Other)?; prepare_remote_resource_edit( root, &input, @@ -5767,27 +5899,36 @@ pub(crate) async fn derive_local_project_resource_at( &access, binding_principal .as_ref() - .ok_or_else(|| "资源编辑缺少 External Editor principal".to_string())?, + .ok_or_else(|| "资源编辑缺少 External Editor principal".to_string()) + .map_err(ResourceEditError::Other)?, ) .await } else { - Err("当前资源类型没有编辑实现".to_string()) + Err(ResourceEditError::Other( + "当前资源类型没有编辑实现".to_string(), + )) }; if let Err(error) = generation_result { + // TODO refactor string-typed:`result-unknown` 还没 typed 化,先在未分类分支里按前缀判。 if ledger.remote_operation_id.is_none() && ledger.phase != ResourceEditLedgerPhase::RemoteCompleted - && error.contains("result-unknown") + && matches!( + &error, + ResourceEditError::Other(message) if message.contains("result-unknown") + ) { update_resource_edit_phase( root, &mut ledger, ResourceEditLedgerPhase::ReconciliationRequired, - )?; + ) + .map_err(ResourceEditError::Other)?; } return Err(error); } } - let project_lock = resource_edit_project_mutation_lock(root)?; + let project_lock = + resource_edit_project_mutation_lock(root).map_err(ResourceEditError::Other)?; let project_guard = project_lock.lock_owned().await; let commit_root = root.to_path_buf(); let commit_input = input.clone(); @@ -5809,7 +5950,8 @@ pub(crate) async fn derive_local_project_resource_at( Ok::<_, String>((result, ledger, operation_guard, project_guard)) }) .await - .map_err(|error| format!("资源编辑提交任务未返回:{error}"))??; + .map_err(|error| ResourceEditError::Other(format!("资源编辑提交任务未返回:{error}")))? + .map_err(ResourceEditError::Other)?; if let (Some((api_base_url, api_key, _)), Some(principal)) = (remote_credentials.as_ref(), binding_principal.as_ref()) { @@ -5817,7 +5959,8 @@ pub(crate) async fn derive_local_project_resource_at( api_base_url, api_key, frozen_platform_session.as_ref(), - )?; + ) + .map_err(ResourceEditError::Other)?; // The local asset transaction is already committed. A binding-sidecar failure must not // roll back or hide that durable local result; a future edit will safely re-upload bytes. let _ = @@ -7701,7 +7844,8 @@ mod tests { .build() .unwrap(); submit_and_persist_resource_edit_remote(root, &client, &access, &mut ledger) - .await?; + .await + .map_err(ResourceEditError::Other)?; assert_eq!(ledger.phase, ResourceEditLedgerPhase::Accepted); assert_eq!( ledger.remote_operation_id.as_deref(), @@ -7907,6 +8051,7 @@ mod tests { }) .await .expect_err("poll authentication status must fail"); + let error = error.to_user_msg(); server.join().expect("join poll auth fixture"); let expected_code = if status.starts_with("401") { "authentication-required" @@ -7940,6 +8085,7 @@ mod tests { }) .await .expect_err("missing remote status must stay unknown"); + let error = error.to_user_msg(); server.join().expect("join missing status fixture"); assert!(error.contains("result-unknown")); assert_eq!(ledger.phase, ResourceEditLedgerPhase::Accepted); @@ -8541,7 +8687,7 @@ mod tests { ( "400 Bad Request", ResourceEditLedgerPhase::RemoteFailed, - "remote-terminal-failed", + "资源编辑请求被拒绝", ), ( "502 Bad Gateway", @@ -8590,6 +8736,12 @@ mod tests { .expect("submission status must fail"); server.join().expect("join submission fixture"); assert!(error.contains(expected_error), "{status}: {error}"); + if status.starts_with("400") { + assert!( + error.contains("provider detail must not persist"), + "平台拒绝原因必须带出到文案:{error}" + ); + } let persisted = read_resource_edit_ledger(root, &request.operation_id) .expect("read submission ledger") @@ -8927,8 +9079,13 @@ mod tests { }) .await .expect_err("failed status must become terminal"); + let error = error.to_user_msg(); server.join().expect("join failed status fixture"); - assert!(error.contains("remote-terminal-failed")); + assert!( + error.contains("secret provider detail"), + "平台原文必须带出到文案:{error}" + ); + assert!(!error.contains("remote-terminal-failed"), "{error}"); let persisted = read_resource_edit_ledger(root, &request.operation_id) .expect("read terminal ledger") .expect("terminal ledger"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs index 3428ad8cb..3f1c306cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs @@ -702,12 +702,19 @@ async fn background_removal_remote_failure_keeps_manifest_without_result() { let error = with_test_credentials( &base_url, - derive_local_project_resource_at(fixture.request.clone()), + derive_local_project_resource_typed(fixture.request.clone()), ) .await .expect_err("remote failure must be returned"); server.join().expect("join failure server"); - assert!(error.contains("remote-terminal-failed")); + // 平台没给 `error` 原文时走兜底文案(带原文的路径见 resource_editor.rs 的远端终态测试)。 + assert_eq!( + error, + ResourceEditError::RemoteGenerationFailed { + server_message: None + } + ); + assert_eq!(error.to_user_msg(), "资源编辑生成失败"); let manifest = read_existing_manifest_for_project(fixture.root()).expect("read unchanged manifest"); assert_eq!(manifest.assets.len(), 1); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs new file mode 100644 index 000000000..ce7b61668 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs @@ -0,0 +1,37 @@ +//! `resource_editor` 的错误定义。 +//! +//! 这一层整体还是 `Result<_, String>`(见 `Other` 上的 TODO),这里先把**远端资源编辑终态 +//! 失败**这条穿出来:平台给的原因必须作为数据往上走,而不是在产生点就拼成一句带前缀的话—— +//! 否则工具层再也分不出「远端失败」和「本地失败」,只能把两句前缀叠在一起。 + +/// 资源编辑失败。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ResourceEditError { + /// 平台轮询到终态 `status=failed`。`server_message` 是平台 `error` 字段原文,平台没给时为空。 + /// + /// 原文只进当次错误文案,不进账本(账本只存 `terminal_failure_code` 分类码)。 + RemoteGenerationFailed { server_message: Option }, + /// 尚未分类的失败:`message` 是产生点写好的、可直接展示的文案或稳定码。 + // TODO refactor string-typed + Other(String), +} + +impl ResourceEditError { + /// 面向用户与模型的文案。只有这一层知道「远端生成失败」该怎么说, + /// 上层(工具层)原样透传,不再叠自己的前缀。 + pub(crate) fn to_user_msg(&self) -> String { + match self { + Self::RemoteGenerationFailed { server_message } => match server_message { + Some(server_message) => format!("资源编辑生成失败:{server_message}"), + None => "资源编辑生成失败".to_string(), + }, + Self::Other(message) => message.clone(), + } + } +} + +impl std::fmt::Display for ResourceEditError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.to_user_msg()) + } +} From d2a5bceabae337a40af77c482fceabf0ba25ccfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 18:43:07 +0800 Subject: [PATCH 03/18] =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E8=BF=9C=E7=AB=AF=E5=A4=B1=E8=B4=A5=E5=8E=9F=E6=96=87=E9=80=8F?= =?UTF-8?q?=E4=BC=A0=E5=88=B0=E5=B7=A5=E5=85=B7=E9=94=99=E8=AF=AF=E4=B8=8E?= =?UTF-8?q?=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent/tool/error.rs 新增共用载体 RemoteResourceEditFailure { serverMessage },两个工具的平台失败文案只写一份 - CreateOrDeriveResourceError 与 RemoveBackgroundError 各加 RemoteGenerationFailed(RemoteResourceEditFailure) 变体,远端终态失败不再压回字符串 - 两个工具各加显式 from_resource_edit_error 翻译,不用 impl From;Other 仍落回各工具原有的「失败:<文案>」变体并留 TODO - 桥里四个资源编辑调用点改用 typed 入口,并 Box::pin 后再 await:资源编辑 future 内联会顶穿 handle_direct_tool_bridge 状态机的调试测试线程栈 - assets.rs 新增 with_direct_editor_api_credentials_as,保留调用方 error 类型,凭据解析失败由调用方显式翻译成 ResourceEditError::Other,旧名与其余调用点零改动 - 工具错误模块补单测:平台原文进 message、进序列化的 typed 错误,无原文与未分类失败各自的文案 - decision-log 补工具层承载、凭据作用域错误类型、future 装箱三条决策与真实验证结果;pitfalls 补工具层原样透传口径 --- .../src-tauri/src/agent/direct_tool_bridge.rs | 51 ++++--- .../tool/create_or_derive_resource/error.rs | 65 ++++++++- .../src-tauri/src/agent/tool/error.rs | 20 +++ .../src/agent/tool/remove_background/error.rs | 131 +++++++++++++++--- .../src-tauri/src/assets.rs | 14 +- .../shared-memory/decision-log.md | 7 +- docs/project-memory/shared-memory/pitfalls.md | 2 +- 7 files changed, 250 insertions(+), 40 deletions(-) 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 2e2d583e9..1755adff8 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 @@ -39,6 +39,7 @@ use crate::agent::tool::run_validation::error::RunValidationError; use crate::agent::tool::update_plan::error::UpdatePlanError; use crate::agent::tool::web_search::error::WebSearchError; use crate::agent::tool::write_file::error::WriteFileError; +use crate::project::ResourceEditError; #[cfg(test)] use axum::extract::Query; use axum::extract::{DefaultBodyLimit, State}; @@ -2272,15 +2273,18 @@ async fn bridge_create_or_derive_resource( return Err(CreateOrDeriveResourceError::DuplicatePendingOperations); } let completed = if let Some(pending) = matching_pending.into_iter().next() { - with_direct_editor_api_credentials(resume_local_project_resource_edit_at( - ResumeLocalProjectResourceEditInput { + // 资源编辑的 future 很大;装箱后只留一个指针进 `handle_direct_tool_bridge` 的状态机, + // 避免把调试测试线程的默认栈顶穿。 + Box::pin(with_direct_editor_api_credentials_as( + resume_local_project_resource_edit_typed(ResumeLocalProjectResourceEditInput { project_path: state.root.to_string_lossy().into_owned(), expected_project_id: manifest.project_id, operation_id: pending.operation_id, - }, + }), + ResourceEditError::Other, )) .await - .map_err(|cause| CreateOrDeriveResourceError::ResourceGenerationFailed { cause })? + .map_err(CreateOrDeriveResourceError::from_resource_edit_error)? } else { let turn_id = state .active_resource_turn_id() @@ -2318,9 +2322,14 @@ async fn bridge_create_or_derive_resource( background_mode: None, screen_color: None, }; - with_direct_editor_api_credentials(derive_local_project_resource_at(request)) - .await - .map_err(|cause| CreateOrDeriveResourceError::ResourceGenerationFailed { cause })? + // 资源编辑的 future 很大;装箱后只留一个指针进 `handle_direct_tool_bridge` 的状态机, + // 避免把调试测试线程的默认栈顶穿。 + Box::pin(with_direct_editor_api_credentials_as( + derive_local_project_resource_typed(request), + ResourceEditError::Other, + )) + .await + .map_err(CreateOrDeriveResourceError::from_resource_edit_error)? }; let result = bridge_completed_resource_result( &state.root, @@ -2559,13 +2568,18 @@ async fn remove_background_payload( return Err(RemoveBackgroundError::DuplicatePendingOperations); } let completed = if let Some(pending) = matching_pending.into_iter().next() { - resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { - project_path: state.root.to_string_lossy().into_owned(), - expected_project_id: manifest.project_id.clone(), - operation_id: pending.operation_id, - }) + // 资源编辑的 future 很大;装箱后只留一个指针进 `handle_direct_tool_bridge` 的状态机, + // 避免把调试测试线程的默认栈顶穿。 + Box::pin(with_direct_editor_api_credentials_as( + resume_local_project_resource_edit_typed(ResumeLocalProjectResourceEditInput { + project_path: state.root.to_string_lossy().into_owned(), + expected_project_id: manifest.project_id.clone(), + operation_id: pending.operation_id, + }), + ResourceEditError::Other, + )) .await - .map_err(|cause| RemoveBackgroundError::BackgroundRemovalFailed { cause })? + .map_err(RemoveBackgroundError::from_resource_edit_error)? } else { // id 按回合身份与请求指纹确定性派生,同指纹重试与 pending 对账语义不变。 let turn_id = state @@ -2596,9 +2610,14 @@ async fn remove_background_payload( background_mode: Some(input.background_mode.clone()), screen_color: input.screen_color.clone(), }; - derive_local_project_resource_at(request) - .await - .map_err(|cause| RemoveBackgroundError::BackgroundRemovalFailed { cause })? + // 资源编辑的 future 很大;装箱后只留一个指针进 `handle_direct_tool_bridge` 的状态机, + // 避免把调试测试线程的默认栈顶穿。 + Box::pin(with_direct_editor_api_credentials_as( + derive_local_project_resource_typed(request), + ResourceEditError::Other, + )) + .await + .map_err(RemoveBackgroundError::from_resource_edit_error)? }; emit_game_creator_manifest_invalidated(&state.root, "direct-background-removal"); bridge_completed_resource_result(&state.root, "background-removal", "derive", completed) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs index 8b4e27c87..b735eb645 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs @@ -4,9 +4,10 @@ use crate::agent::direct_tool_bridge::DirectResourceGenerationKind; use crate::agent::tool::error::{ - ProjectManifestRejection, ProjectPermissionRejection, ResourceCompletionRejection, - ToolArgumentsRejection, ToolFailure, UNREGISTERED_SOURCE_HINT, + ProjectManifestRejection, ProjectPermissionRejection, RemoteResourceEditFailure, + ResourceCompletionRejection, ToolArgumentsRejection, ToolFailure, UNREGISTERED_SOURCE_HINT, }; +use crate::project::ResourceEditError; /// `agc_create_or_derive_resource` 的每一种失败各占一个变体;文案写在这里,捕获处只调 /// `to_user_msg`。 @@ -94,6 +95,9 @@ pub(crate) enum CreateOrDeriveResourceError { ProjectRevisionUnavailable { cause: String, }, + RemoteGenerationFailed(RemoteResourceEditFailure), + /// 尚未分类的本地失败(对应 `ResourceEditError::Other`)。 + // TODO refactor string-typed ResourceGenerationFailed { cause: String, }, @@ -250,9 +254,66 @@ impl ToolFailure for CreateOrDeriveResourceError { Self::ProjectRevisionUnavailable { cause } => { format!("生成或派生资源失败:读取项目 revision 失败:{cause}") } + Self::RemoteGenerationFailed(failure) => failure.to_user_msg(), Self::ResourceGenerationFailed { cause } => { format!("生成或派生资源失败:{cause}") } } } } + +impl CreateOrDeriveResourceError { + /// 把资源编辑层的 typed 失败翻成这个工具自己的失败:远端终态失败保留平台原文, + /// 其余尚未 typed 化的本地失败仍落到 `ResourceGenerationFailed`。 + pub(crate) fn from_resource_edit_error(error: ResourceEditError) -> Self { + match error { + ResourceEditError::RemoteGenerationFailed { server_message } => { + Self::RemoteGenerationFailed(RemoteResourceEditFailure { server_message }) + } + error => Self::ResourceGenerationFailed { + cause: error.to_user_msg(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_generation_failure_carries_platform_message() { + let error = CreateOrDeriveResourceError::from_resource_edit_error( + ResourceEditError::RemoteGenerationFailed { + server_message: Some("provider detail".to_string()), + }, + ); + + assert_eq!(error.to_user_msg(), "资源编辑生成失败:provider detail"); + assert_eq!( + serde_json::to_value(&error).expect("serialize typed failure") + ["RemoteGenerationFailed"]["server_message"], + serde_json::json!("provider detail") + ); + } + + #[test] + fn remote_generation_failure_without_message_falls_back() { + let error = CreateOrDeriveResourceError::from_resource_edit_error( + ResourceEditError::RemoteGenerationFailed { + server_message: None, + }, + ); + + assert_eq!(error.to_user_msg(), "资源编辑生成失败"); + } + + #[test] + fn unclassified_resource_edit_failure_keeps_tool_prefix() { + let error = CreateOrDeriveResourceError::from_resource_edit_error( + ResourceEditError::Other("local-boom".to_string()), + ); + + assert_eq!(error.to_user_msg(), "生成或派生资源失败:local-boom"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs index 53b8ad613..f6eab4fc6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs @@ -179,6 +179,26 @@ impl ToolFailure for ResourceCompletionRejection { } } +/// 平台把远端资源编辑判成终态 `failed` 时给出的原因。 +/// +/// `agc_create_or_derive_resource` 与 `agc_remove_background` 走的是同一条远端资源编辑 +/// 通道,平台 `error` 原文必须作为数据原样带出(不是在产生点拼进前缀),两个工具共用 +/// 这一层文案。 +#[derive(serde::Serialize, Debug)] +pub(crate) struct RemoteResourceEditFailure { + /// 平台 `error` 字段原文;平台没给时为空。 + pub(crate) server_message: Option, +} + +impl ToolFailure for RemoteResourceEditFailure { + fn to_user_msg(&self) -> String { + match &self.server_message { + Some(server_message) => format!("资源编辑生成失败:{server_message}"), + None => "资源编辑生成失败".to_string(), + } + } +} + /// 只有客户端 Direct 回合才会放行的工具被独立客户端 MCP 调用。 /// /// 独立客户端 MCP 没有 Direct 回合的执行账本,不能借用其它回合的预算,所以这里直接拒绝, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs index 1c903e51a..1dd849593 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs @@ -3,9 +3,10 @@ //! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge`。 use crate::agent::tool::error::{ - ProjectManifestRejection, ProjectPermissionRejection, ResourceCompletionRejection, - ToolArgumentsRejection, ToolFailure, UNREGISTERED_SOURCE_HINT, + ProjectManifestRejection, ProjectPermissionRejection, RemoteResourceEditFailure, + ResourceCompletionRejection, ToolArgumentsRejection, ToolFailure, UNREGISTERED_SOURCE_HINT, }; +use crate::project::ResourceEditError; /// `agc_remove_background` 的每一种失败各占一个变体;文案写在这里,捕获处只调 /// `to_user_msg`。 @@ -16,28 +17,65 @@ pub(crate) enum RemoveBackgroundError { Manifest(ProjectManifestRejection), Completion(ResourceCompletionRejection), SourceLocalAssetIdEmpty, - SourceLocalAssetIdTooLong { got_chars: usize, max_chars: usize }, + SourceLocalAssetIdTooLong { + got_chars: usize, + max_chars: usize, + }, SourceLocalAssetIdHasControlCharacters, - SourceLocalAssetIdNotText { got: String }, + SourceLocalAssetIdNotText { + got: String, + }, AssetNameEmpty, - AssetNameTooLong { got_chars: usize, max_chars: usize }, + AssetNameTooLong { + got_chars: usize, + max_chars: usize, + }, AssetNameHasControlCharacters, - AssetNameNotText { got: String }, - BackgroundModeNotText { got: String }, - BackgroundModeUnsupported { got: String }, - ScreenColorNotText { got: String }, - ScreenColorMalformed { got: String }, + AssetNameNotText { + got: String, + }, + BackgroundModeNotText { + got: String, + }, + BackgroundModeUnsupported { + got: String, + }, + ScreenColorNotText { + got: String, + }, + ScreenColorMalformed { + got: String, + }, ScreenColorRequiresFlatMode, SourceAssetNotRegistered, - SourceNotImage { media_type: String }, - CredentialsUnavailable { cause: String }, - GenerationQueueUnavailable { cause: String }, - PaidDispatchRefused { cause: String }, - PendingEditsUnreadable { cause: String }, + SourceNotImage { + media_type: String, + }, + CredentialsUnavailable { + cause: String, + }, + GenerationQueueUnavailable { + cause: String, + }, + PaidDispatchRefused { + cause: String, + }, + PendingEditsUnreadable { + cause: String, + }, DuplicatePendingOperations, - TurnIdUnavailable { cause: String }, - ProjectRevisionUnavailable { cause: String }, - BackgroundRemovalFailed { cause: String }, + TurnIdUnavailable { + cause: String, + }, + ProjectRevisionUnavailable { + cause: String, + }, + RemoteGenerationFailed(RemoteResourceEditFailure), + /// 尚未分类的本地失败(对应 `ResourceEditError::Other`)。 + // TODO refactor string-typed + BackgroundRemovalFailed { + cause: String, + }, } impl From for RemoveBackgroundError { @@ -137,7 +175,64 @@ impl ToolFailure for RemoveBackgroundError { Self::ProjectRevisionUnavailable { cause } => { format!("抠图失败:读取项目 revision 失败:{cause}") } + Self::RemoteGenerationFailed(failure) => failure.to_user_msg(), Self::BackgroundRemovalFailed { cause } => format!("抠图失败:{cause}"), } } } + +impl RemoveBackgroundError { + /// 把资源编辑层的 typed 失败翻成这个工具自己的失败:远端终态失败保留平台原文, + /// 其余尚未 typed 化的本地失败仍落到 `BackgroundRemovalFailed`。 + pub(crate) fn from_resource_edit_error(error: ResourceEditError) -> Self { + match error { + ResourceEditError::RemoteGenerationFailed { server_message } => { + Self::RemoteGenerationFailed(RemoteResourceEditFailure { server_message }) + } + error => Self::BackgroundRemovalFailed { + cause: error.to_user_msg(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_generation_failure_carries_platform_message() { + let error = RemoveBackgroundError::from_resource_edit_error( + ResourceEditError::RemoteGenerationFailed { + server_message: Some("provider detail".to_string()), + }, + ); + + assert_eq!(error.to_user_msg(), "资源编辑生成失败:provider detail"); + assert_eq!( + serde_json::to_value(&error).expect("serialize typed failure") + ["RemoteGenerationFailed"]["server_message"], + serde_json::json!("provider detail") + ); + } + + #[test] + fn remote_generation_failure_without_message_falls_back() { + let error = RemoveBackgroundError::from_resource_edit_error( + ResourceEditError::RemoteGenerationFailed { + server_message: None, + }, + ); + + assert_eq!(error.to_user_msg(), "资源编辑生成失败"); + } + + #[test] + fn unclassified_resource_edit_failure_keeps_tool_prefix() { + let error = RemoveBackgroundError::from_resource_edit_error(ResourceEditError::Other( + "local-boom".to_string(), + )); + + assert_eq!(error.to_user_msg(), "抠图失败:local-boom"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 01e556e37..214a360e5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -56,10 +56,22 @@ pub(crate) async fn with_external_editor_api_credentials( pub(crate) async fn with_direct_editor_api_credentials( operation: impl Future>, ) -> Result { + with_direct_editor_api_credentials_as(operation, std::convert::identity).await +} + +/// 与 [`with_direct_editor_api_credentials`] 同一段作用域语义,但保留调用方自己的错误类型: +/// 凭据解析失败由调用方用 `credentials_error` 显式翻译,不引入 `From` 这种隐式转换。 +pub(crate) async fn with_direct_editor_api_credentials_as( + operation: impl Future>, + credentials_error: impl FnOnce(String) -> E, +) -> Result { if editor_api_mode() == EditorApiMode::PlatformAccount { return operation.await; } - let credentials = ensure_private_external_editor_api_credentials().await?; + let credentials = match ensure_private_external_editor_api_credentials().await { + Ok(credentials) => credentials, + Err(cause) => return Err(credentials_error(cause)), + }; with_external_editor_api_credentials(credentials, operation).await } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 558cf4e12..35df7a8bd 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -8,8 +8,11 @@ - 决策(前缀去留):删掉第一句失败文案里的 `remote-terminal-failed:`(轮询与提交期 400 两处)。`ensure_resource_edit_phase_resumable` 里那三个 token 保留:它们与三个 phase 一一对应,是那句重试文案里区分「确定失败 / 已归档 / 待对账」的唯一手段。 - 决策(原文边界):平台原文只进当次错误文案,仍不进账本(`terminal_failure_code` 的写入边界与既有断言不变)。平台 `user_visible_external_generation_error` 已对四种 kind 做 sanitize,图片/视频两种原样透出——与同 wire 的 `canvas_generation.rs` 口径一致,要收边界应改服务端。 - 决策(命名与落点):尚未 typed 化的变体叫 `Other`,不叫 `Message`(后者分不清是「已渲染文案」还是「原始消息」);新错误单独放 `project/resource_editor/error.rs`,不再往主文件里塞类型定义。 -- 改动范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`project/resource_editor/error.rs`(新)、`agent/tool/create_or_derive_resource/error.rs`、`agent/tool/remove_background/error.rs`、`agent/direct_tool_bridge.rs`、`docs/project-memory/shared-memory/pitfalls.md`。 -- 验证:待补(`cargo test --bin genarrative-ai-game-creator-shell -- project::resource_editor` 与 `-- agent::direct_tool_bridge`;`npm run check:encoding`;`git diff --check`)。 +- 决策(工具层承载):`RemoteGenerationFailed` 不能到工具层又被压回一句字符串。共用载体放 `agent/tool/error.rs` 的 `RemoteResourceEditFailure { serverMessage }`(两个工具共用的文案只写一份),`CreateOrDeriveResourceError` / `RemoveBackgroundError` 各加 `RemoteGenerationFailed(RemoteResourceEditFailure)` 变体;翻译用显式 `from_resource_edit_error`,不用 `impl From`,远端终态进 `RemoteGenerationFailed`、其余 `Other` 仍落回各工具原有的「失败:<文案>」变体。这样诊断 sidecar 的 `error` 字段(typed enum 整体序列化)天然带上平台原文,LLM 侧拿到的 `message` 也带上。 +- 决策(凭据作用域的 error 类型):`with_direct_editor_api_credentials` 原本把操作限定成 `Result<_, String>`,会把 typed 错误提前压掉。新增 `with_direct_editor_api_credentials_as(operation, credentials_error)` 保留调用方 error 类型,凭据解析失败由调用方显式翻译(这里传 `ResourceEditError::Other`),旧名保持 `String` 语义、零改动。 +- 决策(future 装箱):`handle_direct_tool_bridge` 的状态机在调试测试线程的默认栈上已经贴着上限,资源编辑 arm 直接内联会顶穿(`bridge_write_file_waits_on_the_blocking_pool_instead_of_a_runtime_worker` 栈溢出)。桥里四个资源编辑 await 点用 `Box::pin` 只留指针进外层状态机;这是体积问题,不是错误用 `Box`。 +- 改动范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`project/resource_editor/error.rs`(新)、`agent/tool/error.rs`、`agent/tool/create_or_derive_resource/error.rs`、`agent/tool/remove_background/error.rs`、`agent/direct_tool_bridge.rs`、`assets.rs`、`docs/project-memory/shared-memory/pitfalls.md`。 +- 验证:`cargo check --bin genarrative-ai-game-creator-shell --tests` 通过;`cargo test --bin genarrative-ai-game-creator-shell -- project::resource_editor --test-threads=1` 66 passed(并行跑会有一批 TCP fixture 用例因争用超时,串行全绿,与本次改动无关);`-- agent::tool:: agent::direct_tool_bridge` 47 passed;`npm run check:encoding` 5111 files;`git diff --check` 干净;`cargo fmt` 已跑。 - 关联:`pitfalls.md`「远端资源编辑终态必须指出唯一出口」。 ## 2026-10-01 Web、后台与 AGC 一键联调 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 8bc2f2cc3..8ff81704d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -340,7 +340,7 @@ Copy Artifact 插件在**非 SYSTEM 认证**下按「认证用户」判权:只 - 现象:「生成背景音乐」再次提交 0.1 秒就失败,卡片只有 `remote-terminal-failed: 远端资源编辑已明确失败,不允许再次请求`,既没有原因也没有下一步。 - 原因:上一次同 `operationId` 的请求被平台确定性拒绝(HTTP 400 或任务 `failed`)后,账本落到 `remote-failed`,之后所有重试都在 `ensure_resource_edit_phase_resumable` 失败关闭;唯一出口是「待恢复资源编辑」里的移出恢复队列,但终态文案没有指向它。 -- 处理:终态文案带出稳定失败码,并明确「先在待恢复资源编辑中把它移出恢复队列」;上游失败原文仍不写入账本(只存分类码),首次失败的原始拒绝说明继续由当次错误文案承担——轮询终态这条路由此改成把平台 `error` 原文装进 `ResourceEditError::RemoteGenerationFailed` 原样带出(2026-10-02),不再压成「资源编辑生成失败」一句;第一句失败文案不再带 `remote-terminal-failed:` 前缀。 +- 处理:终态文案带出稳定失败码,并明确「先在待恢复资源编辑中把它移出恢复队列」;上游失败原文仍不写入账本(只存分类码),首次失败的原始拒绝说明继续由当次错误文案承担——轮询终态这条路由此改成把平台 `error` 原文装进 `ResourceEditError::RemoteGenerationFailed` 原样带出(2026-10-02),工具层再用 `RemoteResourceEditFailure` 原样传给模型与用户卡片,不再压成「资源编辑生成失败」一句;第一句失败文案不再带 `remote-terminal-failed:` 前缀。 - 验证:`remote_failed_status_is_terminal_and_can_only_be_archived` 断言失败文案带出平台 `error` 原文、同时账本序列化不含原文;`background_removal_remote_failure_keeps_manifest_without_result` 覆盖平台没给 `error` 时的兜底文案;`submission_bad_request_is_terminal_while_gateway_failure_requires_reconciliation` 等资源编辑用例继续通过。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`。 From cdccb7a06a2b8ce98e3853135428552c7749da67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 19:14:57 +0800 Subject: [PATCH 04/18] =?UTF-8?q?=E5=8E=BB=E6=8E=89=E6=8A=A0=E5=9B=BE?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=BC=96=E8=BE=91=E8=B0=83=E7=94=A8=E4=B8=8A?= =?UTF-8?q?=E5=86=97=E4=BD=99=E7=9A=84=E5=87=AD=E6=8D=AE=20wrapper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remove_background_payload 的恢复与派生两处不再套 with_direct_editor_api_credentials_as:整个 payload 已在 bridge_remove_background 的 with_direct_editor_api_credentials 作用域内,内层凭据解析只会命中 override - 因此 ResourceEditError::Other 作为 credentials_error 的分支不可达,删掉以免误导(凭据失败仍按原路径落到 CredentialsUnavailable) - 保留 Box::pin(资源编辑 future 体积问题不变),注释改为同时说明装箱原因与凭据作用域来源 --- .../src-tauri/src/agent/direct_tool_bridge.rs | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) 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 1755adff8..31cd98b59 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 @@ -2568,15 +2568,14 @@ async fn remove_background_payload( return Err(RemoveBackgroundError::DuplicatePendingOperations); } let completed = if let Some(pending) = matching_pending.into_iter().next() { - // 资源编辑的 future 很大;装箱后只留一个指针进 `handle_direct_tool_bridge` 的状态机, - // 避免把调试测试线程的默认栈顶穿。 - Box::pin(with_direct_editor_api_credentials_as( - resume_local_project_resource_edit_typed(ResumeLocalProjectResourceEditInput { + // 资源编辑的 future 很大,装箱避免顶穿 `handle_direct_tool_bridge` 的调试测试线程栈; + // 凭据作用域由外层 `with_direct_editor_api_credentials` 覆盖,这里不再套一层。 + Box::pin(resume_local_project_resource_edit_typed( + ResumeLocalProjectResourceEditInput { project_path: state.root.to_string_lossy().into_owned(), expected_project_id: manifest.project_id.clone(), operation_id: pending.operation_id, - }), - ResourceEditError::Other, + }, )) .await .map_err(RemoveBackgroundError::from_resource_edit_error)? @@ -2610,14 +2609,11 @@ async fn remove_background_payload( background_mode: Some(input.background_mode.clone()), screen_color: input.screen_color.clone(), }; - // 资源编辑的 future 很大;装箱后只留一个指针进 `handle_direct_tool_bridge` 的状态机, - // 避免把调试测试线程的默认栈顶穿。 - Box::pin(with_direct_editor_api_credentials_as( - derive_local_project_resource_typed(request), - ResourceEditError::Other, - )) - .await - .map_err(RemoveBackgroundError::from_resource_edit_error)? + // 资源编辑的 future 很大,装箱避免顶穿 `handle_direct_tool_bridge` 的调试测试线程栈; + // 凭据作用域由外层 `with_direct_editor_api_credentials` 覆盖,这里不再套一层。 + Box::pin(derive_local_project_resource_typed(request)) + .await + .map_err(RemoveBackgroundError::from_resource_edit_error)? }; emit_game_creator_manifest_invalidated(&state.root, "direct-background-removal"); bridge_completed_resource_result(&state.root, "background-removal", "derive", completed) From 44dd809e9a5fb1ab81be64ee1e4c19e0615a9b11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 19:29:33 +0800 Subject: [PATCH 05/18] =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=86=99=E6=AD=BB=E3=80=8C=E8=B5=84=E6=BA=90?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E7=94=9F=E6=88=90=E5=A4=B1=E8=B4=A5=E3=80=8D?= =?UTF-8?q?=E5=89=8D=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RemoteResourceEditFailure::to_user_msg 只回平台 error 原文,平台没给时统一说「服务器未返回错误信息」,不再拼一句没有信息量的总结 - 前缀交给使用它的工具自己加:生成/派生侧「生成或派生资源失败:」、抠图侧「抠图失败:」,两侧文案回到各自工具的既有口径 - 载体仍是两个工具共用的 RemoteResourceEditFailure,平台原文照旧整段进诊断的 typed error - 补单测:载体层断言原文逐字透传与缺省兜底,工具层断言各自前缀下的文案 --- .../tool/create_or_derive_resource/error.rs | 11 +++++-- .../src-tauri/src/agent/tool/error.rs | 31 ++++++++++++++++--- .../src/agent/tool/remove_background/error.rs | 8 +++-- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs index b735eb645..202e1440a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs @@ -254,7 +254,9 @@ impl ToolFailure for CreateOrDeriveResourceError { Self::ProjectRevisionUnavailable { cause } => { format!("生成或派生资源失败:读取项目 revision 失败:{cause}") } - Self::RemoteGenerationFailed(failure) => failure.to_user_msg(), + Self::RemoteGenerationFailed(failure) => { + format!("生成或派生资源失败:{}", failure.to_user_msg()) + } Self::ResourceGenerationFailed { cause } => { format!("生成或派生资源失败:{cause}") } @@ -289,7 +291,7 @@ mod tests { }, ); - assert_eq!(error.to_user_msg(), "资源编辑生成失败:provider detail"); + assert_eq!(error.to_user_msg(), "生成或派生资源失败:provider detail"); assert_eq!( serde_json::to_value(&error).expect("serialize typed failure") ["RemoteGenerationFailed"]["server_message"], @@ -305,7 +307,10 @@ mod tests { }, ); - assert_eq!(error.to_user_msg(), "资源编辑生成失败"); + assert_eq!( + error.to_user_msg(), + "生成或派生资源失败:服务器未返回错误信息" + ); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs index f6eab4fc6..734f2b335 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs @@ -182,8 +182,8 @@ impl ToolFailure for ResourceCompletionRejection { /// 平台把远端资源编辑判成终态 `failed` 时给出的原因。 /// /// `agc_create_or_derive_resource` 与 `agc_remove_background` 走的是同一条远端资源编辑 -/// 通道,平台 `error` 原文必须作为数据原样带出(不是在产生点拼进前缀),两个工具共用 -/// 这一层文案。 +/// 通道,平台 `error` 原文必须作为数据原样带出(不是在产生点拼进前缀)。这里只负责 +/// 原文与「平台没给」的兜底,前缀由使用它的工具在自己的变体里加,两个工具共用这一份载体。 #[derive(serde::Serialize, Debug)] pub(crate) struct RemoteResourceEditFailure { /// 平台 `error` 字段原文;平台没给时为空。 @@ -193,8 +193,8 @@ pub(crate) struct RemoteResourceEditFailure { impl ToolFailure for RemoteResourceEditFailure { fn to_user_msg(&self) -> String { match &self.server_message { - Some(server_message) => format!("资源编辑生成失败:{server_message}"), - None => "资源编辑生成失败".to_string(), + Some(server_message) => server_message.clone(), + None => "服务器未返回错误信息".to_string(), } } } @@ -274,3 +274,26 @@ impl ToolFailure for DirectExecutionGateRejection { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_resource_edit_failure_keeps_platform_text_verbatim() { + let failure = RemoteResourceEditFailure { + server_message: Some("provider detail".to_string()), + }; + + assert_eq!(failure.to_user_msg(), "provider detail"); + } + + #[test] + fn remote_resource_edit_failure_without_message_blames_the_server() { + let failure = RemoteResourceEditFailure { + server_message: None, + }; + + assert_eq!(failure.to_user_msg(), "服务器未返回错误信息"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs index 1dd849593..f34b8c131 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs @@ -175,7 +175,9 @@ impl ToolFailure for RemoveBackgroundError { Self::ProjectRevisionUnavailable { cause } => { format!("抠图失败:读取项目 revision 失败:{cause}") } - Self::RemoteGenerationFailed(failure) => failure.to_user_msg(), + Self::RemoteGenerationFailed(failure) => { + format!("抠图失败:{}", failure.to_user_msg()) + } Self::BackgroundRemovalFailed { cause } => format!("抠图失败:{cause}"), } } @@ -208,7 +210,7 @@ mod tests { }, ); - assert_eq!(error.to_user_msg(), "资源编辑生成失败:provider detail"); + assert_eq!(error.to_user_msg(), "抠图失败:provider detail"); assert_eq!( serde_json::to_value(&error).expect("serialize typed failure") ["RemoteGenerationFailed"]["server_message"], @@ -224,7 +226,7 @@ mod tests { }, ); - assert_eq!(error.to_user_msg(), "资源编辑生成失败"); + assert_eq!(error.to_user_msg(), "抠图失败:服务器未返回错误信息"); } #[test] From a932c9635ef0e39503d7523e54a69f0905552e66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 19:29:49 +0800 Subject: [PATCH 06/18] =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=BC=96=E8=BE=91=20ty?= =?UTF-8?q?ped=20=E9=94=99=E8=AF=AF=E5=8F=AA=E7=BB=99=E4=BA=8B=E5=AE=9E?= =?UTF-8?q?=EF=BC=8C=E5=89=8D=E7=BC=80=E4=BA=A4=E7=BB=99=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=96=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ResourceEditError::to_user_msg 不再拼「资源编辑生成失败」:远端终态只回平台 error 原文,平台没给时统一说「服务器未返回错误信息」 - 桌面命令面 derive/resume_local_project_resource 自己补「资源编辑生成失败:」前缀,桌面 UI 文案不变,前缀落在它真正的使用者这一层 - 单测:typed 错误断言原文逐字透传与缺省兜底;抠图远端失败测试同口径更新 - decision-log 追加「前缀归属」决策(工具面各用各的前缀、桌面面用资源编辑生成失败、叶子错误不写死总结),pitfalls 同步口径 --- .../src-tauri/src/commands/desktop.rs | 14 ++++++-- .../background_removal_tests.rs | 2 +- .../src/project/resource_editor/error.rs | 34 ++++++++++++++++--- .../shared-memory/decision-log.md | 1 + docs/project-memory/shared-memory/pitfalls.md | 2 +- 5 files changed, 45 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs index 22f2a3a5d..634f3b7f3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs @@ -562,7 +562,9 @@ pub(crate) async fn derive_local_project_resource( ) -> Result { let root = Path::new(input.project_path.trim()); enforce_project_permission_policy(root, "asset.register")?; - derive_local_project_resource_at(input).await + derive_local_project_resource_at(input) + .await + .map_err(resource_edit_command_error) } #[tauri::command] @@ -580,7 +582,15 @@ pub(crate) async fn resume_local_project_resource_edit( ) -> Result { let root = Path::new(input.project_path.trim()); enforce_project_permission_policy(root, "asset.register")?; - resume_local_project_resource_edit_at(input).await + resume_local_project_resource_edit_at(input) + .await + .map_err(resource_edit_command_error) +} + +/// 桌面命令面给自己的资源编辑失败加前缀:typed 错误只带平台原文(或「服务器未返回错误信息」), +/// 前缀是使用者的事——工具面用的是各工具自己的前缀,这里是桌面命令。 +fn resource_edit_command_error(cause: String) -> String { + format!("资源编辑生成失败:{cause}") } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs index 3f1c306cf..5f96cf170 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs @@ -714,7 +714,7 @@ async fn background_removal_remote_failure_keeps_manifest_without_result() { server_message: None } ); - assert_eq!(error.to_user_msg(), "资源编辑生成失败"); + assert_eq!(error.to_user_msg(), "服务器未返回错误信息"); let manifest = read_existing_manifest_for_project(fixture.root()).expect("read unchanged manifest"); assert_eq!(manifest.assets.len(), 1); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs index ce7b61668..208bc8feb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs @@ -17,13 +17,16 @@ pub(crate) enum ResourceEditError { } impl ResourceEditError { - /// 面向用户与模型的文案。只有这一层知道「远端生成失败」该怎么说, - /// 上层(工具层)原样透传,不再叠自己的前缀。 + /// 面向用户与模型的文案。 + /// + /// 这里只给事实:平台原文,或平台没给时的「服务器未返回错误信息」。「谁失败了」这类前缀由使用者 + /// 自己加(工具面用各工具自己的前缀,桌面命令面用「资源编辑生成失败」),这一层不替它们定调—— + /// 前缀写死在这里,非工具侧的调用方就只能看到一句没有信息量的总结。 pub(crate) fn to_user_msg(&self) -> String { match self { Self::RemoteGenerationFailed { server_message } => match server_message { - Some(server_message) => format!("资源编辑生成失败:{server_message}"), - None => "资源编辑生成失败".to_string(), + Some(server_message) => server_message.clone(), + None => "服务器未返回错误信息".to_string(), }, Self::Other(message) => message.clone(), } @@ -35,3 +38,26 @@ impl std::fmt::Display for ResourceEditError { formatter.write_str(&self.to_user_msg()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn remote_generation_failure_keeps_platform_text_verbatim() { + let error = ResourceEditError::RemoteGenerationFailed { + server_message: Some("provider detail".to_string()), + }; + + assert_eq!(error.to_user_msg(), "provider detail"); + } + + #[test] + fn remote_generation_failure_without_message_blames_the_server() { + let error = ResourceEditError::RemoteGenerationFailed { + server_message: None, + }; + + assert_eq!(error.to_user_msg(), "服务器未返回错误信息"); + } +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 35df7a8bd..d0b2f7494 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9,6 +9,7 @@ - 决策(原文边界):平台原文只进当次错误文案,仍不进账本(`terminal_failure_code` 的写入边界与既有断言不变)。平台 `user_visible_external_generation_error` 已对四种 kind 做 sanitize,图片/视频两种原样透出——与同 wire 的 `canvas_generation.rs` 口径一致,要收边界应改服务端。 - 决策(命名与落点):尚未 typed 化的变体叫 `Other`,不叫 `Message`(后者分不清是「已渲染文案」还是「原始消息」);新错误单独放 `project/resource_editor/error.rs`,不再往主文件里塞类型定义。 - 决策(工具层承载):`RemoteGenerationFailed` 不能到工具层又被压回一句字符串。共用载体放 `agent/tool/error.rs` 的 `RemoteResourceEditFailure { serverMessage }`(两个工具共用的文案只写一份),`CreateOrDeriveResourceError` / `RemoveBackgroundError` 各加 `RemoteGenerationFailed(RemoteResourceEditFailure)` 变体;翻译用显式 `from_resource_edit_error`,不用 `impl From`,远端终态进 `RemoteGenerationFailed`、其余 `Other` 仍落回各工具原有的「失败:<文案>」变体。这样诊断 sidecar 的 `error` 字段(typed enum 整体序列化)天然带上平台原文,LLM 侧拿到的 `message` 也带上。 +- 决策(前缀归属,2026-10-02 追加):叶子错误只给事实,不给「谁失败了」的总结前缀。`RemoteResourceEditFailure::to_user_msg` 与 `ResourceEditError::to_user_msg` 都只返回平台 `error` 原文;平台没给时统一说「服务器未返回错误信息」,不再说「资源编辑生成失败」这种没有信息量的总结。前缀由使用者自己加:`agc_create_or_derive_resource` 用「生成或派生资源失败:」、`agc_remove_background` 用「抠图失败:」、桌面命令面用「资源编辑生成失败:」。同一份 typed 错误因此可以同时服务工具面(前缀各随其工具)与桌面面(保留原有文案)。 - 决策(凭据作用域的 error 类型):`with_direct_editor_api_credentials` 原本把操作限定成 `Result<_, String>`,会把 typed 错误提前压掉。新增 `with_direct_editor_api_credentials_as(operation, credentials_error)` 保留调用方 error 类型,凭据解析失败由调用方显式翻译(这里传 `ResourceEditError::Other`),旧名保持 `String` 语义、零改动。 - 决策(future 装箱):`handle_direct_tool_bridge` 的状态机在调试测试线程的默认栈上已经贴着上限,资源编辑 arm 直接内联会顶穿(`bridge_write_file_waits_on_the_blocking_pool_instead_of_a_runtime_worker` 栈溢出)。桥里四个资源编辑 await 点用 `Box::pin` 只留指针进外层状态机;这是体积问题,不是错误用 `Box`。 - 改动范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`project/resource_editor/error.rs`(新)、`agent/tool/error.rs`、`agent/tool/create_or_derive_resource/error.rs`、`agent/tool/remove_background/error.rs`、`agent/direct_tool_bridge.rs`、`assets.rs`、`docs/project-memory/shared-memory/pitfalls.md`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 8ff81704d..58ce5cff1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -340,7 +340,7 @@ Copy Artifact 插件在**非 SYSTEM 认证**下按「认证用户」判权:只 - 现象:「生成背景音乐」再次提交 0.1 秒就失败,卡片只有 `remote-terminal-failed: 远端资源编辑已明确失败,不允许再次请求`,既没有原因也没有下一步。 - 原因:上一次同 `operationId` 的请求被平台确定性拒绝(HTTP 400 或任务 `failed`)后,账本落到 `remote-failed`,之后所有重试都在 `ensure_resource_edit_phase_resumable` 失败关闭;唯一出口是「待恢复资源编辑」里的移出恢复队列,但终态文案没有指向它。 -- 处理:终态文案带出稳定失败码,并明确「先在待恢复资源编辑中把它移出恢复队列」;上游失败原文仍不写入账本(只存分类码),首次失败的原始拒绝说明继续由当次错误文案承担——轮询终态这条路由此改成把平台 `error` 原文装进 `ResourceEditError::RemoteGenerationFailed` 原样带出(2026-10-02),工具层再用 `RemoteResourceEditFailure` 原样传给模型与用户卡片,不再压成「资源编辑生成失败」一句;第一句失败文案不再带 `remote-terminal-failed:` 前缀。 +- 处理:终态文案带出稳定失败码,并明确「先在待恢复资源编辑中把它移出恢复队列」;上游失败原文仍不写入账本(只存分类码),首次失败的原始拒绝说明继续由当次错误文案承担——轮询终态这条路由此改成把平台 `error` 原文装进 `ResourceEditError::RemoteGenerationFailed` 原样带出(2026-10-02),工具层经 `RemoteResourceEditFailure` 转发时只保留原文、平台没给就说「服务器未返回错误信息」,前缀由使用它的工具/命令自己加(不再统一压成「资源编辑生成失败」一句,也不再多一层无信息前缀);第一句失败文案不再带 `remote-terminal-failed:` 前缀。 - 验证:`remote_failed_status_is_terminal_and_can_only_be_archived` 断言失败文案带出平台 `error` 原文、同时账本序列化不含原文;`background_removal_remote_failure_keeps_manifest_without_result` 覆盖平台没给 `error` 时的兜底文案;`submission_bad_request_is_terminal_while_gateway_failure_requires_reconciliation` 等资源编辑用例继续通过。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`。 From d62afe3275b75ae3bb23304a6bfbc8e775545c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 19:30:03 +0800 Subject: [PATCH 07/18] =?UTF-8?q?=E5=9B=BE=E7=89=87=E7=94=9F=E6=88=90?= =?UTF-8?q?=E8=BF=9C=E7=AB=AF=E5=A4=B1=E8=B4=A5=E4=B8=8D=E5=86=8D=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E4=B8=80=E5=8F=A5=E9=87=8D=E5=A4=8D=E7=9A=84=E3=80=8C?= =?UTF-8?q?=E7=94=9F=E6=88=90=E4=BB=BB=E5=8A=A1=E5=A4=B1=E8=B4=A5=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - canvas_generation 轮询到 status=failed 时,平台既没给 error 也没给 phaseDetail 的兜底由「生成任务失败」改为「服务器未返回错误信息」,不再和句首「平台图片生成任务失败:」重复 - decision-log 追加同类兜底决策,说明这条与资源编辑同属「缺省时要说清是服务器没给信息,而不是再喊一次失败」 --- .../src-tauri/src/agent/generation/canvas_generation.rs | 2 +- docs/project-memory/shared-memory/decision-log.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 661ae2ad2..c976dc57d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1275,7 +1275,7 @@ async fn wait_for_external_generation_result_inner( Some("failed") => { let error = json_string_field(generation, "error") .or_else(|| json_string_field(generation, "phaseDetail")) - .unwrap_or_else(|| "生成任务失败".to_string()); + .unwrap_or_else(|| "服务器未返回错误信息".to_string()); return Err(format!( "平台图片生成任务失败:{error};operationId={operation_id}" )); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d0b2f7494..0270bd490 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -12,6 +12,7 @@ - 决策(前缀归属,2026-10-02 追加):叶子错误只给事实,不给「谁失败了」的总结前缀。`RemoteResourceEditFailure::to_user_msg` 与 `ResourceEditError::to_user_msg` 都只返回平台 `error` 原文;平台没给时统一说「服务器未返回错误信息」,不再说「资源编辑生成失败」这种没有信息量的总结。前缀由使用者自己加:`agc_create_or_derive_resource` 用「生成或派生资源失败:」、`agc_remove_background` 用「抠图失败:」、桌面命令面用「资源编辑生成失败:」。同一份 typed 错误因此可以同时服务工具面(前缀各随其工具)与桌面面(保留原有文案)。 - 决策(凭据作用域的 error 类型):`with_direct_editor_api_credentials` 原本把操作限定成 `Result<_, String>`,会把 typed 错误提前压掉。新增 `with_direct_editor_api_credentials_as(operation, credentials_error)` 保留调用方 error 类型,凭据解析失败由调用方显式翻译(这里传 `ResourceEditError::Other`),旧名保持 `String` 语义、零改动。 - 决策(future 装箱):`handle_direct_tool_bridge` 的状态机在调试测试线程的默认栈上已经贴着上限,资源编辑 arm 直接内联会顶穿(`bridge_write_file_waits_on_the_blocking_pool_instead_of_a_runtime_worker` 栈溢出)。桥里四个资源编辑 await 点用 `Box::pin` 只留指针进外层状态机;这是体积问题,不是错误用 `Box`。 +- 决策(同类兜底,2026-10-02 追加):`agent/generation/canvas_generation.rs` 的远端 `failed` 分支原来在平台没给 `error`/`phaseDetail` 时兜底成「生成任务失败」,与句首的「平台图片生成任务失败:」重复,改成「服务器未返回错误信息」。 - 改动范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`project/resource_editor/error.rs`(新)、`agent/tool/error.rs`、`agent/tool/create_or_derive_resource/error.rs`、`agent/tool/remove_background/error.rs`、`agent/direct_tool_bridge.rs`、`assets.rs`、`docs/project-memory/shared-memory/pitfalls.md`。 - 验证:`cargo check --bin genarrative-ai-game-creator-shell --tests` 通过;`cargo test --bin genarrative-ai-game-creator-shell -- project::resource_editor --test-threads=1` 66 passed(并行跑会有一批 TCP fixture 用例因争用超时,串行全绿,与本次改动无关);`-- agent::tool:: agent::direct_tool_bridge` 47 passed;`npm run check:encoding` 5111 files;`git diff --check` 干净;`cargo fmt` 已跑。 - 关联:`pitfalls.md`「远端资源编辑终态必须指出唯一出口」。 From 14afd21d7ec7937fbc2816ac01d68311c668ec21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 20:16:22 +0800 Subject: [PATCH 08/18] =?UTF-8?q?=E8=B5=84=E6=BA=90=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=96=87=E6=A1=88=E5=8F=AA=E8=AE=A4=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=20error=20=E5=8E=9F=E6=96=87=EF=BC=8CphaseDetail=20?= =?UTF-8?q?=E5=8F=AA=E8=BF=9B=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RemoteResourceEditFailure / ResourceEditError 的 to_user_msg 只回平台 error 原文,平台没给时回「服务器未返回错误信息」 typed 错误新增 phaseDetail 字段,只随诊断 sidecar 序列化,不参与用户文案 远端轮询 failed 分支同时读取 error 与 phaseDetail 删除断言 to_user_msg 字面量的单测,只保留 typed 字段原样序列化进诊断的结构断言 canvas_generation 远端 failed 分支不再拿 phaseDetail 当用户文案 同步 decision-log 与 pitfalls --- .../src/agent/generation/canvas_generation.rs | 1 - .../tool/create_or_derive_resource/error.rs | 41 ++++++------------- .../src-tauri/src/agent/tool/error.rs | 32 ++++----------- .../src/agent/tool/remove_background/error.rs | 40 +++++++----------- .../src-tauri/src/commands/desktop.rs | 4 +- .../src-tauri/src/project/resource_editor.rs | 6 ++- .../background_removal_tests.rs | 11 +++-- .../src/project/resource_editor/error.rs | 39 +++++------------- .../shared-memory/decision-log.md | 5 ++- docs/project-memory/shared-memory/pitfalls.md | 2 +- 10 files changed, 62 insertions(+), 119 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index c976dc57d..dc68f3f86 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -1274,7 +1274,6 @@ async fn wait_for_external_generation_result_inner( } Some("failed") => { let error = json_string_field(generation, "error") - .or_else(|| json_string_field(generation, "phaseDetail")) .unwrap_or_else(|| "服务器未返回错误信息".to_string()); return Err(format!( "平台图片生成任务失败:{error};operationId={operation_id}" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs index 202e1440a..97e062db5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/create_or_derive_resource/error.rs @@ -269,9 +269,13 @@ impl CreateOrDeriveResourceError { /// 其余尚未 typed 化的本地失败仍落到 `ResourceGenerationFailed`。 pub(crate) fn from_resource_edit_error(error: ResourceEditError) -> Self { match error { - ResourceEditError::RemoteGenerationFailed { server_message } => { - Self::RemoteGenerationFailed(RemoteResourceEditFailure { server_message }) - } + ResourceEditError::RemoteGenerationFailed { + server_message, + phase_detail, + } => Self::RemoteGenerationFailed(RemoteResourceEditFailure { + server_message, + phase_detail, + }), error => Self::ResourceGenerationFailed { cause: error.to_user_msg(), }, @@ -284,41 +288,22 @@ mod tests { use super::*; #[test] - fn remote_generation_failure_carries_platform_message() { + fn remote_generation_failure_serializes_platform_fields_verbatim() { let error = CreateOrDeriveResourceError::from_resource_edit_error( ResourceEditError::RemoteGenerationFailed { server_message: Some("provider detail".to_string()), + phase_detail: Some("生成失败。".to_string()), }, ); - assert_eq!(error.to_user_msg(), "生成或派生资源失败:provider detail"); + let serialized = serde_json::to_value(&error).expect("serialize typed failure"); assert_eq!( - serde_json::to_value(&error).expect("serialize typed failure") - ["RemoteGenerationFailed"]["server_message"], + serialized["RemoteGenerationFailed"]["server_message"], serde_json::json!("provider detail") ); - } - - #[test] - fn remote_generation_failure_without_message_falls_back() { - let error = CreateOrDeriveResourceError::from_resource_edit_error( - ResourceEditError::RemoteGenerationFailed { - server_message: None, - }, - ); - assert_eq!( - error.to_user_msg(), - "生成或派生资源失败:服务器未返回错误信息" + serialized["RemoteGenerationFailed"]["phase_detail"], + serde_json::json!("生成失败。") ); } - - #[test] - fn unclassified_resource_edit_failure_keeps_tool_prefix() { - let error = CreateOrDeriveResourceError::from_resource_edit_error( - ResourceEditError::Other("local-boom".to_string()), - ); - - assert_eq!(error.to_user_msg(), "生成或派生资源失败:local-boom"); - } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs index 734f2b335..2606a2a68 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs @@ -182,12 +182,17 @@ impl ToolFailure for ResourceCompletionRejection { /// 平台把远端资源编辑判成终态 `failed` 时给出的原因。 /// /// `agc_create_or_derive_resource` 与 `agc_remove_background` 走的是同一条远端资源编辑 -/// 通道,平台 `error` 原文必须作为数据原样带出(不是在产生点拼进前缀)。这里只负责 -/// 原文与「平台没给」的兜底,前缀由使用它的工具在自己的变体里加,两个工具共用这一份载体。 +/// 通道,平台原文必须作为数据原样带出(不是在产生点拼进前缀)。这里只负责记录平台给的事实, +/// 前缀由使用它的工具在自己的变体里加,两个工具共用这一份载体。 +/// +/// `to_user_msg` 只给平台 `error` 原文,平台没给就回「服务器未返回错误信息」。`phase_detail` +/// 是结构化字段,只进诊断 sidecar 给开发者/LLM 看,不参与用户文案。 #[derive(serde::Serialize, Debug)] pub(crate) struct RemoteResourceEditFailure { /// 平台 `error` 字段原文;平台没给时为空。 pub(crate) server_message: Option, + /// 平台 `phaseDetail` 字段原文;只进诊断,不进用户文案。 + pub(crate) phase_detail: Option, } impl ToolFailure for RemoteResourceEditFailure { @@ -274,26 +279,3 @@ impl ToolFailure for DirectExecutionGateRejection { } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn remote_resource_edit_failure_keeps_platform_text_verbatim() { - let failure = RemoteResourceEditFailure { - server_message: Some("provider detail".to_string()), - }; - - assert_eq!(failure.to_user_msg(), "provider detail"); - } - - #[test] - fn remote_resource_edit_failure_without_message_blames_the_server() { - let failure = RemoteResourceEditFailure { - server_message: None, - }; - - assert_eq!(failure.to_user_msg(), "服务器未返回错误信息"); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs index f34b8c131..c7d63446c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/remove_background/error.rs @@ -188,9 +188,13 @@ impl RemoveBackgroundError { /// 其余尚未 typed 化的本地失败仍落到 `BackgroundRemovalFailed`。 pub(crate) fn from_resource_edit_error(error: ResourceEditError) -> Self { match error { - ResourceEditError::RemoteGenerationFailed { server_message } => { - Self::RemoteGenerationFailed(RemoteResourceEditFailure { server_message }) - } + ResourceEditError::RemoteGenerationFailed { + server_message, + phase_detail, + } => Self::RemoteGenerationFailed(RemoteResourceEditFailure { + server_message, + phase_detail, + }), error => Self::BackgroundRemovalFailed { cause: error.to_user_msg(), }, @@ -203,38 +207,22 @@ mod tests { use super::*; #[test] - fn remote_generation_failure_carries_platform_message() { + fn remote_generation_failure_serializes_platform_fields_verbatim() { let error = RemoveBackgroundError::from_resource_edit_error( ResourceEditError::RemoteGenerationFailed { server_message: Some("provider detail".to_string()), + phase_detail: Some("生成失败。".to_string()), }, ); - assert_eq!(error.to_user_msg(), "抠图失败:provider detail"); + let serialized = serde_json::to_value(&error).expect("serialize typed failure"); assert_eq!( - serde_json::to_value(&error).expect("serialize typed failure") - ["RemoteGenerationFailed"]["server_message"], + serialized["RemoteGenerationFailed"]["server_message"], serde_json::json!("provider detail") ); - } - - #[test] - fn remote_generation_failure_without_message_falls_back() { - let error = RemoveBackgroundError::from_resource_edit_error( - ResourceEditError::RemoteGenerationFailed { - server_message: None, - }, + assert_eq!( + serialized["RemoteGenerationFailed"]["phase_detail"], + serde_json::json!("生成失败。") ); - - assert_eq!(error.to_user_msg(), "抠图失败:服务器未返回错误信息"); - } - - #[test] - fn unclassified_resource_edit_failure_keeps_tool_prefix() { - let error = RemoveBackgroundError::from_resource_edit_error(ResourceEditError::Other( - "local-boom".to_string(), - )); - - assert_eq!(error.to_user_msg(), "抠图失败:local-boom"); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs index 634f3b7f3..794e50b73 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs @@ -587,8 +587,8 @@ pub(crate) async fn resume_local_project_resource_edit( .map_err(resource_edit_command_error) } -/// 桌面命令面给自己的资源编辑失败加前缀:typed 错误只带平台原文(或「服务器未返回错误信息」), -/// 前缀是使用者的事——工具面用的是各工具自己的前缀,这里是桌面命令。 +/// 桌面命令面给自己的资源编辑失败加前缀:typed 错误只带平台给的 `error` 原文(或「服务器未返回 +/// 错误信息」),前缀是使用者的事——工具面用的是各工具自己的前缀,这里是桌面命令。 fn resource_edit_command_error(cause: String) -> String { format!("资源编辑生成失败:{cause}") } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 2b91952dc..fd03fa539 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -2965,11 +2965,15 @@ async fn wait_for_resource_edit_remote( } Some("failed") => { let server_message = json_string_field(job, "error"); + let phase_detail = json_string_field(job, "phaseDetail"); ledger.terminal_failure_code = Some("remote-generation-failed".to_string()); ledger.terminal_failed_at = Some(unix_timestamp()); update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::RemoteFailed) .map_err(ResourceEditError::Other)?; - return Err(ResourceEditError::RemoteGenerationFailed { server_message }); + return Err(ResourceEditError::RemoteGenerationFailed { + server_message, + phase_detail, + }); } Some("queued" | "running") => { poll_after_ms = external_generation_poll_after_ms(job); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs index 5f96cf170..870b7773c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/background_removal_tests.rs @@ -693,7 +693,9 @@ async fn background_removal_remote_failure_keeps_manifest_without_result() { &mut stream, "200 OK", serde_json::json!({"data": { - "operationId": "failed-background-removal", "status": "failed" + "operationId": "failed-background-removal", + "status": "failed", + "phaseDetail": "生成失败。" }}), ); } @@ -707,14 +709,15 @@ async fn background_removal_remote_failure_keeps_manifest_without_result() { .await .expect_err("remote failure must be returned"); server.join().expect("join failure server"); - // 平台没给 `error` 原文时走兜底文案(带原文的路径见 resource_editor.rs 的远端终态测试)。 + // 平台没给 `error` 原文时退回同一个响应里的 `phaseDetail`(带 `error` 原文的路径见 + // resource_editor.rs 的远端终态测试)。 assert_eq!( error, ResourceEditError::RemoteGenerationFailed { - server_message: None + server_message: None, + phase_detail: Some("生成失败。".to_string()), } ); - assert_eq!(error.to_user_msg(), "服务器未返回错误信息"); let manifest = read_existing_manifest_for_project(fixture.root()).expect("read unchanged manifest"); assert_eq!(manifest.assets.len(), 1); diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs index 208bc8feb..fc66e2b8a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs @@ -7,10 +7,14 @@ /// 资源编辑失败。 #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum ResourceEditError { - /// 平台轮询到终态 `status=failed`。`server_message` 是平台 `error` 字段原文,平台没给时为空。 + /// 平台轮询到终态 `status=failed`。`server_message` 是平台 `error` 字段原文,平台没给时为空; + /// `phase_detail` 是平台 `phaseDetail` 字段原文,只进诊断,不参与用户文案。 /// /// 原文只进当次错误文案,不进账本(账本只存 `terminal_failure_code` 分类码)。 - RemoteGenerationFailed { server_message: Option }, + RemoteGenerationFailed { + server_message: Option, + phase_detail: Option, + }, /// 尚未分类的失败:`message` 是产生点写好的、可直接展示的文案或稳定码。 // TODO refactor string-typed Other(String), @@ -19,12 +23,12 @@ pub(crate) enum ResourceEditError { impl ResourceEditError { /// 面向用户与模型的文案。 /// - /// 这里只给事实:平台原文,或平台没给时的「服务器未返回错误信息」。「谁失败了」这类前缀由使用者 - /// 自己加(工具面用各工具自己的前缀,桌面命令面用「资源编辑生成失败」),这一层不替它们定调—— - /// 前缀写死在这里,非工具侧的调用方就只能看到一句没有信息量的总结。 + /// 只给平台 `error` 原文,平台没给就回「服务器未返回错误信息」。「谁失败了」这类前缀由使用者 + /// 自己加(工具面用各工具自己的前缀,桌面命令面用「资源编辑生成失败」),这一层不替它们定调; + /// `phase_detail` 只进诊断,不进用户文案。 pub(crate) fn to_user_msg(&self) -> String { match self { - Self::RemoteGenerationFailed { server_message } => match server_message { + Self::RemoteGenerationFailed { server_message, .. } => match server_message { Some(server_message) => server_message.clone(), None => "服务器未返回错误信息".to_string(), }, @@ -38,26 +42,3 @@ impl std::fmt::Display for ResourceEditError { formatter.write_str(&self.to_user_msg()) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn remote_generation_failure_keeps_platform_text_verbatim() { - let error = ResourceEditError::RemoteGenerationFailed { - server_message: Some("provider detail".to_string()), - }; - - assert_eq!(error.to_user_msg(), "provider detail"); - } - - #[test] - fn remote_generation_failure_without_message_blames_the_server() { - let error = ResourceEditError::RemoteGenerationFailed { - server_message: None, - }; - - assert_eq!(error.to_user_msg(), "服务器未返回错误信息"); - } -} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0270bd490..543a252a6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -9,10 +9,11 @@ - 决策(原文边界):平台原文只进当次错误文案,仍不进账本(`terminal_failure_code` 的写入边界与既有断言不变)。平台 `user_visible_external_generation_error` 已对四种 kind 做 sanitize,图片/视频两种原样透出——与同 wire 的 `canvas_generation.rs` 口径一致,要收边界应改服务端。 - 决策(命名与落点):尚未 typed 化的变体叫 `Other`,不叫 `Message`(后者分不清是「已渲染文案」还是「原始消息」);新错误单独放 `project/resource_editor/error.rs`,不再往主文件里塞类型定义。 - 决策(工具层承载):`RemoteGenerationFailed` 不能到工具层又被压回一句字符串。共用载体放 `agent/tool/error.rs` 的 `RemoteResourceEditFailure { serverMessage }`(两个工具共用的文案只写一份),`CreateOrDeriveResourceError` / `RemoveBackgroundError` 各加 `RemoteGenerationFailed(RemoteResourceEditFailure)` 变体;翻译用显式 `from_resource_edit_error`,不用 `impl From`,远端终态进 `RemoteGenerationFailed`、其余 `Other` 仍落回各工具原有的「失败:<文案>」变体。这样诊断 sidecar 的 `error` 字段(typed enum 整体序列化)天然带上平台原文,LLM 侧拿到的 `message` 也带上。 -- 决策(前缀归属,2026-10-02 追加):叶子错误只给事实,不给「谁失败了」的总结前缀。`RemoteResourceEditFailure::to_user_msg` 与 `ResourceEditError::to_user_msg` 都只返回平台 `error` 原文;平台没给时统一说「服务器未返回错误信息」,不再说「资源编辑生成失败」这种没有信息量的总结。前缀由使用者自己加:`agc_create_or_derive_resource` 用「生成或派生资源失败:」、`agc_remove_background` 用「抠图失败:」、桌面命令面用「资源编辑生成失败:」。同一份 typed 错误因此可以同时服务工具面(前缀各随其工具)与桌面面(保留原有文案)。 +- 决策(前缀归属,2026-10-02 追加):叶子错误只给事实,不给「谁失败了」的总结前缀。`RemoteResourceEditFailure::to_user_msg` 与 `ResourceEditError::to_user_msg` 都只返回平台 `error` 原文;平台没给就回「服务器未返回错误信息」,不再说「资源编辑生成失败」这种没有信息量的总结。typed 错误新增 `phaseDetail` 字段,但它只作为结构化字段进诊断 sidecar(开发者/LLM 侧看原始值),**不参与用户文案**。前缀由使用者自己加:`agc_create_or_derive_resource` 用「生成或派生资源失败:」、`agc_remove_background` 用「抠图失败:」、桌面命令面用「资源编辑生成失败:」。同一份 typed 错误因此可以同时服务工具面(前缀各随其工具)与桌面面(保留原有文案)。 +- 决策(HTTP 兜底与叶子前缀,2026-10-02 追加):叶子只给「服务端 message / code / 原始传输事实」这类事实,不把操作名写进叶子。`game_package_upload/runtime.rs` 三处 `let (_, message)` 把服务端 `code` 丢掉、再拼「读取上传状态失败(HTTP 503)」这类前缀,改成 `message` → `code` → `HTTP {status}`(操作名交给调用方的话术)。`game_distribution_publish.rs` 的 `response_data`(2xx + `ok:false`)同样用上被丢掉的 `error.code`,`account_api.rs` 的 envelope 分支补 `error.code`;服务端没给任何原因时统一回「服务器未返回错误信息」。错误类型自身的单测不再断言 `to_user_msg()` 的字面量(文案是给用户的话术,不是契约),只保留「typed 字段原样序列化进诊断」的结构断言。 - 决策(凭据作用域的 error 类型):`with_direct_editor_api_credentials` 原本把操作限定成 `Result<_, String>`,会把 typed 错误提前压掉。新增 `with_direct_editor_api_credentials_as(operation, credentials_error)` 保留调用方 error 类型,凭据解析失败由调用方显式翻译(这里传 `ResourceEditError::Other`),旧名保持 `String` 语义、零改动。 - 决策(future 装箱):`handle_direct_tool_bridge` 的状态机在调试测试线程的默认栈上已经贴着上限,资源编辑 arm 直接内联会顶穿(`bridge_write_file_waits_on_the_blocking_pool_instead_of_a_runtime_worker` 栈溢出)。桥里四个资源编辑 await 点用 `Box::pin` 只留指针进外层状态机;这是体积问题,不是错误用 `Box`。 -- 决策(同类兜底,2026-10-02 追加):`agent/generation/canvas_generation.rs` 的远端 `failed` 分支原来在平台没给 `error`/`phaseDetail` 时兜底成「生成任务失败」,与句首的「平台图片生成任务失败:」重复,改成「服务器未返回错误信息」。 +- 决策(同类兜底,2026-10-02 追加):`agent/generation/canvas_generation.rs` 的远端 `failed` 分支原来在平台没给 `error` 时兜底成「生成任务失败」,与句首的「平台图片生成任务失败:」重复,改成「服务器未返回错误信息」;`phaseDetail` 不再参与用户文案(只作结构化字段)。 - 改动范围:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`、`project/resource_editor/error.rs`(新)、`agent/tool/error.rs`、`agent/tool/create_or_derive_resource/error.rs`、`agent/tool/remove_background/error.rs`、`agent/direct_tool_bridge.rs`、`assets.rs`、`docs/project-memory/shared-memory/pitfalls.md`。 - 验证:`cargo check --bin genarrative-ai-game-creator-shell --tests` 通过;`cargo test --bin genarrative-ai-game-creator-shell -- project::resource_editor --test-threads=1` 66 passed(并行跑会有一批 TCP fixture 用例因争用超时,串行全绿,与本次改动无关);`-- agent::tool:: agent::direct_tool_bridge` 47 passed;`npm run check:encoding` 5111 files;`git diff --check` 干净;`cargo fmt` 已跑。 - 关联:`pitfalls.md`「远端资源编辑终态必须指出唯一出口」。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 58ce5cff1..13749b84a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -340,7 +340,7 @@ Copy Artifact 插件在**非 SYSTEM 认证**下按「认证用户」判权:只 - 现象:「生成背景音乐」再次提交 0.1 秒就失败,卡片只有 `remote-terminal-failed: 远端资源编辑已明确失败,不允许再次请求`,既没有原因也没有下一步。 - 原因:上一次同 `operationId` 的请求被平台确定性拒绝(HTTP 400 或任务 `failed`)后,账本落到 `remote-failed`,之后所有重试都在 `ensure_resource_edit_phase_resumable` 失败关闭;唯一出口是「待恢复资源编辑」里的移出恢复队列,但终态文案没有指向它。 -- 处理:终态文案带出稳定失败码,并明确「先在待恢复资源编辑中把它移出恢复队列」;上游失败原文仍不写入账本(只存分类码),首次失败的原始拒绝说明继续由当次错误文案承担——轮询终态这条路由此改成把平台 `error` 原文装进 `ResourceEditError::RemoteGenerationFailed` 原样带出(2026-10-02),工具层经 `RemoteResourceEditFailure` 转发时只保留原文、平台没给就说「服务器未返回错误信息」,前缀由使用它的工具/命令自己加(不再统一压成「资源编辑生成失败」一句,也不再多一层无信息前缀);第一句失败文案不再带 `remote-terminal-failed:` 前缀。 +- 处理:终态文案带出稳定失败码,并明确「先在待恢复资源编辑中把它移出恢复队列」;上游失败原文仍不写入账本(只存分类码),首次失败的原始拒绝说明继续由当次错误文案承担——轮询终态这条路由此改成把平台 `error` 原文装进 `ResourceEditError::RemoteGenerationFailed` 原样带出(2026-10-02),工具层经 `RemoteResourceEditFailure` 转发时保留 `error` 与 `phaseDetail` 两个原始字段(`phaseDetail` 只进诊断,不当用户文案);`to_user_msg` 只给 `error` 原文,平台没给就说「服务器未返回错误信息」。前缀由使用它的工具/命令自己加(不再统一压成「资源编辑生成失败」一句,也不再多一层无信息前缀);第一句失败文案不再带 `remote-terminal-failed:` 前缀。 - 验证:`remote_failed_status_is_terminal_and_can_only_be_archived` 断言失败文案带出平台 `error` 原文、同时账本序列化不含原文;`background_removal_remote_failure_keeps_manifest_without_result` 覆盖平台没给 `error` 时的兜底文案;`submission_bad_request_is_terminal_while_gateway_failure_requires_reconciliation` 等资源编辑用例继续通过。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`。 From 883bcb182db240a9eb1eb75aa0b2ebccdc24555e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 20:17:00 +0800 Subject: [PATCH 09/18] =?UTF-8?q?HTTP=20=E9=94=99=E8=AF=AF=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E4=BC=98=E5=85=88=E6=9C=8D=E5=8A=A1=E7=AB=AF=20code?= =?UTF-8?q?=EF=BC=8C=E5=8F=B6=E5=AD=90=E4=B8=8D=E5=86=8D=E6=8B=BC=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E5=90=8D=E5=89=8D=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit game_package_upload 三处不再丢弃服务端 code,改成 message → code → HTTP {status} 去掉「读取上传状态失败(HTTP 503)」这类叶子里的操作名前缀,操作名交给调用方话术 game_distribution_publish response_data 用上被丢掉的 error.code game_distribution_publish 封面任务终态兜底统一成「服务器未返回错误信息」 account_api envelope 分支补 error.code 兜底 --- .../src-tauri/src/account_api.rs | 23 +++++++++++---- .../src/game_distribution_publish.rs | 12 +++++--- .../src/game_package_upload/runtime.rs | 28 +++++++++++++------ 3 files changed, 46 insertions(+), 17 deletions(-) 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 2cdc1b40d..a8a05391d 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 @@ -78,6 +78,17 @@ fn error_message(body: &str) -> Option { .map(ToString::to_string) } +fn error_code(body: &str) -> Option { + let value = serde_json::from_str::(body).ok()?; + let error = value.get("error").unwrap_or(&value); + error + .get("code") + .and_then(Value::as_str) + .map(str::trim) + .filter(|code| !code.is_empty()) + .map(ToString::to_string) +} + fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); if status == StatusCode::UNAUTHORIZED { @@ -89,7 +100,9 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { error_message(body).unwrap_or_else(|| "当前账号无权执行此操作".to_string()) ); } - let detail = error_message(body).unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + let detail = error_message(body) + .or_else(|| error_code(body)) + .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); format!("{fallback}:{detail}") } @@ -97,10 +110,10 @@ fn unwrap_envelope(body: &str, fallback: &str) -> Result { let value: Value = serde_json::from_str(body).map_err(|_| format!("{fallback}:服务端响应不是合法 JSON"))?; if value.get("ok").and_then(Value::as_bool) == Some(false) { - return Err(format!( - "{fallback}:{}", - error_message(body).unwrap_or_else(|| "服务端请求失败".to_string()) - )); + let detail = error_message(body) + .or_else(|| error_code(body)) + .unwrap_or_else(|| "服务器未返回错误信息".to_string()); + return Err(format!("{fallback}:{detail}")); } Ok(value.get("data").cloned().unwrap_or(value)) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index 35acdde86..c3b2aabf9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -193,8 +193,11 @@ fn response_data(body: &str) -> Result { let value: Value = serde_json::from_str(body).map_err(|error| format!("服务端响应不是合法 JSON:{error}"))?; if value.get("ok").and_then(Value::as_bool) == Some(false) { - let (_, message) = parse_error_payload(body); - return Err(message.unwrap_or_else(|| "服务端请求失败".to_string())); + let (code, message) = parse_error_payload(body); + return Err(message + .or(code) + .filter(|detail| !detail.trim().is_empty()) + .unwrap_or_else(|| "服务器未返回错误信息".to_string())); } Ok(value.get("data").cloned().unwrap_or(value)) } @@ -309,7 +312,7 @@ async fn resolve_cover_queue( .error .clone() .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "生成游戏封面失败".to_string())); + .unwrap_or_else(|| "服务器未返回错误信息".to_string())); } return Ok(initial); } @@ -341,7 +344,8 @@ async fn resolve_cover_queue( if status.status == "failed" { return Err(status .error - .unwrap_or_else(|| "生成游戏封面失败".to_string())); + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "服务器未返回错误信息".to_string())); } if status.status == "completed" { let result = status.result.unwrap_or(Value::Null); diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs index 259757a5e..cc76558c1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs @@ -88,8 +88,11 @@ async fn read_upload_state( .await .map_err(|error| format!("读取上传状态失败:{error}"))?; if status >= 400 { - let (_, message) = parse_server_error(status, &body); - return Err(message.unwrap_or_else(|| format!("读取上传状态失败(HTTP {status})"))); + let (code, message) = parse_server_error(status, &body); + return Err(message + .or(code) + .filter(|detail| !detail.trim().is_empty()) + .unwrap_or_else(|| format!("HTTP {status}"))); } let parsed: Value = serde_json::from_str(&body) .map_err(|error| format!("上传状态响应不是合法 JSON:{error}"))?; @@ -139,15 +142,21 @@ async fn upload_chunk( )); } if status >= 500 || status == 408 || status == 429 { - let (_, message) = parse_server_error(status, &text); + let (code, message) = parse_server_error(status, &text); return Err(ChunkUploadError::Retryable( - message.unwrap_or_else(|| format!("发行包分片上传失败(HTTP {status})")), + message + .or(code) + .filter(|detail| !detail.trim().is_empty()) + .unwrap_or_else(|| format!("HTTP {status}")), )); } if status >= 400 { - let (_, message) = parse_server_error(status, &text); + let (code, message) = parse_server_error(status, &text); return Err(ChunkUploadError::Fatal( - message.unwrap_or_else(|| format!("发行包分片被拒绝(HTTP {status})")), + message + .or(code) + .filter(|detail| !detail.trim().is_empty()) + .unwrap_or_else(|| format!("HTTP {status}")), )); } let parsed: Value = serde_json::from_str(&text) @@ -187,8 +196,11 @@ async fn complete_upload( .await .map_err(|error| format!("读取完成响应失败:{error}"))?; if status >= 400 { - let (_, message) = parse_server_error(status, &text); - return Err(message.unwrap_or_else(|| format!("完成发行包上传失败(HTTP {status})"))); + let (code, message) = parse_server_error(status, &text); + return Err(message + .or(code) + .filter(|detail| !detail.trim().is_empty()) + .unwrap_or_else(|| format!("HTTP {status}"))); } let parsed: Value = serde_json::from_str(&text).map_err(|error| format!("完成响应不是合法 JSON:{error}"))?; From 675dfe0c8f22032e1e96cbe4177ee9b9f9fdda80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 22:08:44 +0800 Subject: [PATCH 10/18] =?UTF-8?q?account=5Fapi=20=E6=8A=BD=E5=87=BA=20erro?= =?UTF-8?q?r=5Ffield=20=E6=B6=88=E9=99=A4=20message/code=20=E7=9A=84?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit error_message / error_code 只差字段名,合并到 error_field(body, field) 调用点保留 error_message / error_code 两个薄封装 --- .../src-tauri/src/account_api.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) 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 a8a05391d..9c2caee3e 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 @@ -67,26 +67,23 @@ fn bounded_business_id(value: &str, label: &str) -> Result { Ok(value.to_string()) } -fn error_message(body: &str) -> Option { +fn error_field(body: &str, field: &str) -> Option { let value = serde_json::from_str::(body).ok()?; let error = value.get("error").unwrap_or(&value); error - .get("message") + .get(field) .and_then(Value::as_str) .map(str::trim) - .filter(|message| !message.is_empty()) + .filter(|detail| !detail.is_empty()) .map(ToString::to_string) } +fn error_message(body: &str) -> Option { + error_field(body, "message") +} + fn error_code(body: &str) -> Option { - let value = serde_json::from_str::(body).ok()?; - let error = value.get("error").unwrap_or(&value); - error - .get("code") - .and_then(Value::as_str) - .map(str::trim) - .filter(|code| !code.is_empty()) - .map(ToString::to_string) + error_field(body, "code") } fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { From a0428da5d183608661af8228633ef563f9b00ffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 22:08:53 +0800 Subject: [PATCH 11/18] =?UTF-8?q?HTTP=20=E9=94=99=E8=AF=AF=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E5=85=88=E8=BF=87=E6=BB=A4=20message=20=E5=86=8D?= =?UTF-8?q?=E9=80=80=20code=EF=BC=8C=E9=81=BF=E5=85=8D=E7=A9=BA=20message?= =?UTF-8?q?=20=E5=90=9E=E6=8E=89=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit game_distribution_publish response_data 的 message.or(code).filter 顺序反了,空 message 会连 code 一起丢掉 map_http_error 同类顺序问题一并修正(review 未列,属同一类 bug) --- .../src-tauri/src/game_distribution_publish.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index c3b2aabf9..b4bf84b03 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -195,8 +195,8 @@ fn response_data(body: &str) -> Result { if value.get("ok").and_then(Value::as_bool) == Some(false) { let (code, message) = parse_error_payload(body); return Err(message - .or(code) .filter(|detail| !detail.trim().is_empty()) + .or_else(|| code.filter(|detail| !detail.trim().is_empty())) .unwrap_or_else(|| "服务器未返回错误信息".to_string())); } Ok(value.get("data").cloned().unwrap_or(value)) @@ -215,8 +215,8 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { ); } let detail = message - .or(code) .filter(|value| !value.trim().is_empty()) + .or_else(|| code.filter(|value| !value.trim().is_empty())) .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); format!("{fallback}:{detail}") } From 1a99a2f26b6e911135d9c31a0f6440510b7fe4d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 22:09:02 +0800 Subject: [PATCH 12/18] =?UTF-8?q?=E5=8F=91=E8=A1=8C=E5=8C=85=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=20HTTP=20=E5=85=9C=E5=BA=95=E5=85=88=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=20message=20=E5=86=8D=E9=80=80=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_upload_state / upload_chunk(retryable、fatal) / complete_upload 四处 .or(code).filter 顺序反了 空 message 会把服务端 code 一起吞掉,统一改成 filter(message).or_else(|| code.filter(...)) --- .../src-tauri/src/game_package_upload/runtime.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs index cc76558c1..7e8920816 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs @@ -90,8 +90,8 @@ async fn read_upload_state( if status >= 400 { let (code, message) = parse_server_error(status, &body); return Err(message - .or(code) .filter(|detail| !detail.trim().is_empty()) + .or_else(|| code.filter(|detail| !detail.trim().is_empty())) .unwrap_or_else(|| format!("HTTP {status}"))); } let parsed: Value = serde_json::from_str(&body) @@ -145,8 +145,8 @@ async fn upload_chunk( let (code, message) = parse_server_error(status, &text); return Err(ChunkUploadError::Retryable( message - .or(code) .filter(|detail| !detail.trim().is_empty()) + .or_else(|| code.filter(|detail| !detail.trim().is_empty())) .unwrap_or_else(|| format!("HTTP {status}")), )); } @@ -154,8 +154,8 @@ async fn upload_chunk( let (code, message) = parse_server_error(status, &text); return Err(ChunkUploadError::Fatal( message - .or(code) .filter(|detail| !detail.trim().is_empty()) + .or_else(|| code.filter(|detail| !detail.trim().is_empty())) .unwrap_or_else(|| format!("HTTP {status}")), )); } @@ -198,8 +198,8 @@ async fn complete_upload( if status >= 400 { let (code, message) = parse_server_error(status, &text); return Err(message - .or(code) .filter(|detail| !detail.trim().is_empty()) + .or_else(|| code.filter(|detail| !detail.trim().is_empty())) .unwrap_or_else(|| format!("HTTP {status}"))); } let parsed: Value = From cdccfa6cd58cd4184b9c2b002d28a9fbd592637e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 22:19:27 +0800 Subject: [PATCH 13/18] =?UTF-8?q?=E5=8F=91=E8=A1=8C=E5=8C=85=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E5=A4=B1=E8=B4=A5=E5=B8=A6=E5=87=BA=E3=80=8C=E5=93=AA?= =?UTF-8?q?=E4=B8=80=E6=AD=A5=E3=80=8D=E5=AD=90=E6=9E=9A=E4=B8=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 GamePackageUploadStep(ReadUploadState / UploadChunk / CompleteUpload)与 GamePackageUploadError { step, detail } read_upload_state / upload_chunk / complete_upload 改返回 typed 错误,detail 只放服务端 message / code / HTTP {status} 步骤名由 step 枚举渲染,不再靠各调用点硬拼「读取上传状态失败」这类前缀 upload_staged_game_package 在三个远端调用点 .map_err(to_user_msg);本地错误与对外签名保持 String,两处调用方零改动 --- .../src/game_package_upload/runtime.rs | 190 +++++++++++++----- 1 file changed, 143 insertions(+), 47 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs index 7e8920816..b06eb543a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs @@ -67,12 +67,75 @@ fn platform_request( ) } +/// 发行包上传里的一个远端步骤。 +/// +/// 一次上传会跨多请求(读上传状态、每个分片 PUT、最后 complete),失败时只抛一句 +/// `HTTP {status}` 分不出是哪一步;`step` 把「哪一步」结构化带出来,用户文案与诊断各取所需。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum GamePackageUploadStep { + ReadUploadState, + UploadChunk, + CompleteUpload, +} + +impl GamePackageUploadStep { + fn label(self) -> &'static str { + match self { + Self::ReadUploadState => "读取上传状态失败", + Self::UploadChunk => "发行包分片上传失败", + Self::CompleteUpload => "完成发行包上传失败", + } + } +} + +/// 发行包上传失败:`step` 说明是哪一步,`detail` 是这一步的原始事实(服务端 message / code / +/// `HTTP {status}`)。 +#[derive(Clone, Debug, serde::Serialize)] +pub(crate) struct GamePackageUploadError { + pub(crate) step: GamePackageUploadStep, + pub(crate) detail: String, +} + +impl GamePackageUploadError { + fn read_upload_state(detail: impl Into) -> Self { + Self { + step: GamePackageUploadStep::ReadUploadState, + detail: detail.into(), + } + } + + fn upload_chunk(detail: impl Into) -> Self { + Self { + step: GamePackageUploadStep::UploadChunk, + detail: detail.into(), + } + } + + fn complete_upload(detail: impl Into) -> Self { + Self { + step: GamePackageUploadStep::CompleteUpload, + detail: detail.into(), + } + } + + /// 给用户/模型的那句:步骤 + 事实。步骤来自 `step` 枚举,不从调用点硬拼。 + pub(crate) fn to_user_msg(&self) -> String { + let detail = self.detail.trim(); + if detail.is_empty() { + self.step.label().to_string() + } else { + format!("{}:{detail}", self.step.label()) + } + } +} + async fn read_upload_state( client: &reqwest::Client, base_url: &str, version_id: &str, access_token: &str, -) -> Result { +) -> Result { let response = platform_request( client, access_token, @@ -81,24 +144,30 @@ async fn read_upload_state( ) .send() .await - .map_err(|error| format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"))?; + .map_err(|error| { + GamePackageUploadError::read_upload_state(format!( + "无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}" + )) + })?; let status = response.status().as_u16(); - let body = response - .text() - .await - .map_err(|error| format!("读取上传状态失败:{error}"))?; + let body = response.text().await.map_err(|error| { + GamePackageUploadError::read_upload_state(format!("读取响应失败:{error}")) + })?; if status >= 400 { let (code, message) = parse_server_error(status, &body); - return Err(message + let detail = message .filter(|detail| !detail.trim().is_empty()) .or_else(|| code.filter(|detail| !detail.trim().is_empty())) - .unwrap_or_else(|| format!("HTTP {status}"))); + .unwrap_or_else(|| format!("HTTP {status}")); + return Err(GamePackageUploadError::read_upload_state(detail)); } - let parsed: Value = serde_json::from_str(&body) - .map_err(|error| format!("上传状态响应不是合法 JSON:{error}"))?; + let parsed: Value = serde_json::from_str(&body).map_err(|error| { + GamePackageUploadError::read_upload_state(format!("上传状态响应不是合法 JSON:{error}")) + })?; let payload = parsed.get("data").unwrap_or(&parsed); - serde_json::from_value(payload.clone()) - .map_err(|error| format!("上传状态响应缺少字段:{error}")) + serde_json::from_value(payload.clone()).map_err(|error| { + GamePackageUploadError::read_upload_state(format!("上传状态响应缺少字段:{error}")) + }) } /// 上传一个分片;返回服务端确认后的已收字节。 @@ -123,54 +192,74 @@ async fn upload_chunk( .body(body) .send() .await - .map_err(|error| ChunkUploadError::Retryable(format!("发行包分片发送失败:{error}")))?; + .map_err(|error| { + ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!( + "请求未送达:{error}" + ))) + })?; let status = response.status().as_u16(); - let text = response - .text() - .await - .map_err(|error| ChunkUploadError::Retryable(format!("读取分片响应失败:{error}")))?; + let text = response.text().await.map_err(|error| { + ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!( + "读取分片响应失败:{error}" + ))) + })?; if status == 409 { let (code, message) = parse_server_error(status, &text); if code.as_deref() == Some("PACKAGE_UPLOAD_OFFSET_MISMATCH") { let received = parse_received_bytes(&text).ok_or_else(|| { - ChunkUploadError::Fatal("分片偏移不一致,但服务端未返回权威偏移".to_string()) + ChunkUploadError::Fatal(GamePackageUploadError::upload_chunk( + "分片偏移不一致,但服务端未返回权威偏移", + )) })?; return Ok(received); } + let detail = message + .filter(|detail| !detail.trim().is_empty()) + .or_else(|| code.filter(|detail| !detail.trim().is_empty())) + .unwrap_or_else(|| format!("HTTP {status}")); return Err(ChunkUploadError::Fatal( - message.unwrap_or_else(|| "发行包分片被拒绝".to_string()), + GamePackageUploadError::upload_chunk(detail), )); } if status >= 500 || status == 408 || status == 429 { let (code, message) = parse_server_error(status, &text); + let detail = message + .filter(|detail| !detail.trim().is_empty()) + .or_else(|| code.filter(|detail| !detail.trim().is_empty())) + .unwrap_or_else(|| format!("HTTP {status}")); return Err(ChunkUploadError::Retryable( - message - .filter(|detail| !detail.trim().is_empty()) - .or_else(|| code.filter(|detail| !detail.trim().is_empty())) - .unwrap_or_else(|| format!("HTTP {status}")), + GamePackageUploadError::upload_chunk(detail), )); } if status >= 400 { let (code, message) = parse_server_error(status, &text); + let detail = message + .filter(|detail| !detail.trim().is_empty()) + .or_else(|| code.filter(|detail| !detail.trim().is_empty())) + .unwrap_or_else(|| format!("HTTP {status}")); return Err(ChunkUploadError::Fatal( - message - .filter(|detail| !detail.trim().is_empty()) - .or_else(|| code.filter(|detail| !detail.trim().is_empty())) - .unwrap_or_else(|| format!("HTTP {status}")), + GamePackageUploadError::upload_chunk(detail), )); } - let parsed: Value = serde_json::from_str(&text) - .map_err(|error| ChunkUploadError::Retryable(format!("分片响应不是合法 JSON:{error}")))?; + let parsed: Value = serde_json::from_str(&text).map_err(|error| { + ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!( + "分片响应不是合法 JSON:{error}" + ))) + })?; let payload = parsed.get("data").unwrap_or(&parsed); payload .get("receivedBytes") .and_then(Value::as_u64) - .ok_or_else(|| ChunkUploadError::Fatal("分片响应缺少 receivedBytes".to_string())) + .ok_or_else(|| { + ChunkUploadError::Fatal(GamePackageUploadError::upload_chunk( + "分片响应缺少 receivedBytes", + )) + }) } enum ChunkUploadError { - Retryable(String), - Fatal(String), + Retryable(GamePackageUploadError), + Fatal(GamePackageUploadError), } async fn complete_upload( @@ -179,7 +268,7 @@ async fn complete_upload( version_id: &str, access_token: &str, idempotency_key: &str, -) -> Result { +) -> Result { let response = platform_request( client, access_token, @@ -189,21 +278,22 @@ async fn complete_upload( .header("Idempotency-Key", format!("{idempotency_key}:complete")) .send() .await - .map_err(|error| format!("完成发行包上传失败:{error}"))?; + .map_err(|error| GamePackageUploadError::complete_upload(format!("请求未送达:{error}")))?; let status = response.status().as_u16(); - let text = response - .text() - .await - .map_err(|error| format!("读取完成响应失败:{error}"))?; + let text = response.text().await.map_err(|error| { + GamePackageUploadError::complete_upload(format!("读取响应失败:{error}")) + })?; if status >= 400 { let (code, message) = parse_server_error(status, &text); - return Err(message + let detail = message .filter(|detail| !detail.trim().is_empty()) .or_else(|| code.filter(|detail| !detail.trim().is_empty())) - .unwrap_or_else(|| format!("HTTP {status}"))); + .unwrap_or_else(|| format!("HTTP {status}")); + return Err(GamePackageUploadError::complete_upload(detail)); } - let parsed: Value = - serde_json::from_str(&text).map_err(|error| format!("完成响应不是合法 JSON:{error}"))?; + let parsed: Value = serde_json::from_str(&text).map_err(|error| { + GamePackageUploadError::complete_upload(format!("完成响应不是合法 JSON:{error}")) + })?; let payload = parsed.get("data").unwrap_or(&parsed); Ok(GamePackageUploadOutcome { version_id: payload @@ -261,7 +351,8 @@ pub(crate) async fn upload_staged_game_package( request.version_id, &access_token, ) - .await?; + .await + .map_err(|error| error.to_user_msg())?; if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes { return Err(format!( "本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布", @@ -298,10 +389,13 @@ pub(crate) async fn upload_staged_game_package( on_progress(received, total_bytes); break; } - Err(ChunkUploadError::Fatal(error)) => return Err(error), + Err(ChunkUploadError::Fatal(error)) => return Err(error.to_user_msg()), Err(ChunkUploadError::Retryable(error)) => { if attempt >= CHUNK_MAX_ATTEMPTS { - return Err(format!("{error}(已尝试 {attempt} 次,可重新发布续传)")); + return Err(format!( + "{}(已尝试 {attempt} 次,可重新发布续传)", + error.to_user_msg() + )); } attempt += 1; tokio::time::sleep(CHUNK_RETRY_DELAY).await; @@ -316,7 +410,8 @@ pub(crate) async fn upload_staged_game_package( request.version_id, &access_token, ) - .await?; + .await + .map_err(|error| error.to_user_msg())?; received = authoritative.received_bytes.min(total_bytes); on_progress(received, total_bytes); } @@ -328,7 +423,8 @@ pub(crate) async fn upload_staged_game_package( &access_token, request.idempotency_key, ) - .await?; + .await + .map_err(|error| error.to_user_msg())?; outcome.uploaded_bytes = total_bytes; Ok(outcome) } From b3ed1c0f8098bbc8af13c29ff76ac82f6fd2474d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 10:51:29 +0800 Subject: [PATCH 14/18] =?UTF-8?q?=E5=8F=91=E8=A1=8C=E5=8C=85=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E6=8A=BD=20server=5Ferror=5Fdetail=EF=BC=8C=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=20message/code/=E5=85=9C=E5=BA=95=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - game_package_upload.rs 新增 pub(crate) server_error_detail,统一先取非空 message 再退非空 code 的顺序 - runtime.rs 五处 HTTP 错误分支改用该 helper,删除各自复制的过滤逻辑 - game_distribution_publish.rs 的 response_data 与 map_http_error 共用同一 helper - FORBIDDEN 分支改用 helper,补上此前缺失的空 message trim 过滤 --- .../src/game_distribution_publish.rs | 19 ++++++-------- .../src-tauri/src/game_package_upload.rs | 13 ++++++++++ .../src/game_package_upload/runtime.rs | 25 ++++--------------- 3 files changed, 26 insertions(+), 31 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index b4bf84b03..ca41e3fd8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -5,8 +5,9 @@ //! 其它素材上传链路。 use crate::game_package_upload::{ - game_package_upload_staging_dir, stage_game_package_bytes, upload_staged_game_package, - GamePackageUploadOutcome, GamePackageUploadRequest, StagedGamePackage, + game_package_upload_staging_dir, server_error_detail, stage_game_package_bytes, + upload_staged_game_package, GamePackageUploadOutcome, GamePackageUploadRequest, + StagedGamePackage, }; use crate::http_client::agc_main_site_client_builder; use crate::platform_session::{ @@ -194,10 +195,9 @@ fn response_data(body: &str) -> Result { serde_json::from_str(body).map_err(|error| format!("服务端响应不是合法 JSON:{error}"))?; if value.get("ok").and_then(Value::as_bool) == Some(false) { let (code, message) = parse_error_payload(body); - return Err(message - .filter(|detail| !detail.trim().is_empty()) - .or_else(|| code.filter(|detail| !detail.trim().is_empty())) - .unwrap_or_else(|| "服务器未返回错误信息".to_string())); + return Err(server_error_detail(code, message, || { + "服务器未返回错误信息".to_string() + })); } Ok(value.get("data").cloned().unwrap_or(value)) } @@ -211,13 +211,10 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { if status == StatusCode::FORBIDDEN { return format!( "permission-denied: {}", - message.unwrap_or_else(|| "当前账号无权执行此操作".to_string()) + server_error_detail(code, message, || "当前账号无权执行此操作".to_string()) ); } - let detail = message - .filter(|value| !value.trim().is_empty()) - .or_else(|| code.filter(|value| !value.trim().is_empty())) - .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + let detail = server_error_detail(code, message, || format!("HTTP {}", status.as_u16())); format!("{fallback}:{detail}") } diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs index 42ede48ea..d90d18c3d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs @@ -134,6 +134,19 @@ fn parse_server_error(status: u16, body: &str) -> (Option, Option, + message: Option, + fallback: impl FnOnce() -> String, +) -> String { + message + .filter(|detail| !detail.trim().is_empty()) + .or_else(|| code.filter(|detail| !detail.trim().is_empty())) + .unwrap_or_else(fallback) +} + /// 从 409 响应里取权威已收字节;取不到就返回 `None`,由调用方按失败处理。 fn parse_received_bytes(body: &str) -> Option { let parsed = serde_json::from_str::(body).ok()?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs index b06eb543a..9fc2f6ce9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs @@ -155,10 +155,7 @@ async fn read_upload_state( })?; if status >= 400 { let (code, message) = parse_server_error(status, &body); - let detail = message - .filter(|detail| !detail.trim().is_empty()) - .or_else(|| code.filter(|detail| !detail.trim().is_empty())) - .unwrap_or_else(|| format!("HTTP {status}")); + let detail = server_error_detail(code, message, || format!("HTTP {status}")); return Err(GamePackageUploadError::read_upload_state(detail)); } let parsed: Value = serde_json::from_str(&body).map_err(|error| { @@ -213,30 +210,21 @@ async fn upload_chunk( })?; return Ok(received); } - let detail = message - .filter(|detail| !detail.trim().is_empty()) - .or_else(|| code.filter(|detail| !detail.trim().is_empty())) - .unwrap_or_else(|| format!("HTTP {status}")); + let detail = server_error_detail(code, message, || format!("HTTP {status}")); return Err(ChunkUploadError::Fatal( GamePackageUploadError::upload_chunk(detail), )); } if status >= 500 || status == 408 || status == 429 { let (code, message) = parse_server_error(status, &text); - let detail = message - .filter(|detail| !detail.trim().is_empty()) - .or_else(|| code.filter(|detail| !detail.trim().is_empty())) - .unwrap_or_else(|| format!("HTTP {status}")); + let detail = server_error_detail(code, message, || format!("HTTP {status}")); return Err(ChunkUploadError::Retryable( GamePackageUploadError::upload_chunk(detail), )); } if status >= 400 { let (code, message) = parse_server_error(status, &text); - let detail = message - .filter(|detail| !detail.trim().is_empty()) - .or_else(|| code.filter(|detail| !detail.trim().is_empty())) - .unwrap_or_else(|| format!("HTTP {status}")); + let detail = server_error_detail(code, message, || format!("HTTP {status}")); return Err(ChunkUploadError::Fatal( GamePackageUploadError::upload_chunk(detail), )); @@ -285,10 +273,7 @@ async fn complete_upload( })?; if status >= 400 { let (code, message) = parse_server_error(status, &text); - let detail = message - .filter(|detail| !detail.trim().is_empty()) - .or_else(|| code.filter(|detail| !detail.trim().is_empty())) - .unwrap_or_else(|| format!("HTTP {status}")); + let detail = server_error_detail(code, message, || format!("HTTP {status}")); return Err(GamePackageUploadError::complete_upload(detail)); } let parsed: Value = serde_json::from_str(&text).map_err(|error| { From b4bbd084146dd2dd0dea94b57d908850e2395ce6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 10:52:32 +0800 Subject: [PATCH 15/18] =?UTF-8?q?=E5=8F=91=E8=A1=8C=E5=8C=85=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E9=94=99=E8=AF=AF=20detail=20=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E4=B8=8E=E6=AD=A5=E9=AA=A4=E6=A0=87=E7=AD=BE=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E7=9A=84=E5=89=8D=E7=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 读取响应/分片响应/完成响应失败改为直接给原始 error,步骤由 GamePackageUploadStep 标签提供 - JSON 解析与缺字段的 detail 去掉「上传状态响应」「分片响应」「完成响应」等重复主语 --- .../src/game_package_upload/runtime.rs | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs index 9fc2f6ce9..14049b120 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs @@ -150,20 +150,21 @@ async fn read_upload_state( )) })?; let status = response.status().as_u16(); - let body = response.text().await.map_err(|error| { - GamePackageUploadError::read_upload_state(format!("读取响应失败:{error}")) - })?; + let body = response + .text() + .await + .map_err(|error| GamePackageUploadError::read_upload_state(format!("{error}")))?; if status >= 400 { let (code, message) = parse_server_error(status, &body); let detail = server_error_detail(code, message, || format!("HTTP {status}")); return Err(GamePackageUploadError::read_upload_state(detail)); } let parsed: Value = serde_json::from_str(&body).map_err(|error| { - GamePackageUploadError::read_upload_state(format!("上传状态响应不是合法 JSON:{error}")) + GamePackageUploadError::read_upload_state(format!("响应不是合法 JSON:{error}")) })?; let payload = parsed.get("data").unwrap_or(&parsed); serde_json::from_value(payload.clone()).map_err(|error| { - GamePackageUploadError::read_upload_state(format!("上传状态响应缺少字段:{error}")) + GamePackageUploadError::read_upload_state(format!("响应缺少字段:{error}")) }) } @@ -196,9 +197,7 @@ async fn upload_chunk( })?; let status = response.status().as_u16(); let text = response.text().await.map_err(|error| { - ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!( - "读取分片响应失败:{error}" - ))) + ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!("{error}"))) })?; if status == 409 { let (code, message) = parse_server_error(status, &text); @@ -231,7 +230,7 @@ async fn upload_chunk( } let parsed: Value = serde_json::from_str(&text).map_err(|error| { ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!( - "分片响应不是合法 JSON:{error}" + "响应不是合法 JSON:{error}" ))) })?; let payload = parsed.get("data").unwrap_or(&parsed); @@ -240,7 +239,7 @@ async fn upload_chunk( .and_then(Value::as_u64) .ok_or_else(|| { ChunkUploadError::Fatal(GamePackageUploadError::upload_chunk( - "分片响应缺少 receivedBytes", + "响应缺少 receivedBytes", )) }) } @@ -268,16 +267,17 @@ async fn complete_upload( .await .map_err(|error| GamePackageUploadError::complete_upload(format!("请求未送达:{error}")))?; let status = response.status().as_u16(); - let text = response.text().await.map_err(|error| { - GamePackageUploadError::complete_upload(format!("读取响应失败:{error}")) - })?; + let text = response + .text() + .await + .map_err(|error| GamePackageUploadError::complete_upload(format!("{error}")))?; if status >= 400 { let (code, message) = parse_server_error(status, &text); let detail = server_error_detail(code, message, || format!("HTTP {status}")); return Err(GamePackageUploadError::complete_upload(detail)); } let parsed: Value = serde_json::from_str(&text).map_err(|error| { - GamePackageUploadError::complete_upload(format!("完成响应不是合法 JSON:{error}")) + GamePackageUploadError::complete_upload(format!("响应不是合法 JSON:{error}")) })?; let payload = parsed.get("data").unwrap_or(&parsed); Ok(GamePackageUploadOutcome { From fb80c7d5e4c82fdcc2a589c818b0cb32c01b7746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 3 Oct 2026 10:54:29 +0800 Subject: [PATCH 16/18] =?UTF-8?q?=E5=8F=91=E8=A1=8C=E5=8C=85=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E7=94=A8=20From=20=E6=94=B6=E6=95=9B=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E5=8C=96=E9=94=99=E8=AF=AF=EF=BC=8C=E6=9B=BF=E6=8D=A2?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E7=9A=84=20map=5Ferr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为 GamePackageUploadError 实现 From for String - upload_staged_game_package 中三处 .map_err(|e| e.to_user_msg()) 改为 ?,Fatal 分支改为 error.into() --- .../src/game_package_upload/runtime.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs index 14049b120..7fdc8c17b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs @@ -130,6 +130,13 @@ impl GamePackageUploadError { } } +/// 上传编排对外只回一句 `String`,让 `?` 直接完成结构化错误到用户文案的收敛。 +impl From for String { + fn from(error: GamePackageUploadError) -> Self { + error.to_user_msg() + } +} + async fn read_upload_state( client: &reqwest::Client, base_url: &str, @@ -336,8 +343,7 @@ pub(crate) async fn upload_staged_game_package( request.version_id, &access_token, ) - .await - .map_err(|error| error.to_user_msg())?; + .await?; if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes { return Err(format!( "本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布", @@ -374,7 +380,7 @@ pub(crate) async fn upload_staged_game_package( on_progress(received, total_bytes); break; } - Err(ChunkUploadError::Fatal(error)) => return Err(error.to_user_msg()), + Err(ChunkUploadError::Fatal(error)) => return Err(error.into()), Err(ChunkUploadError::Retryable(error)) => { if attempt >= CHUNK_MAX_ATTEMPTS { return Err(format!( @@ -395,8 +401,7 @@ pub(crate) async fn upload_staged_game_package( request.version_id, &access_token, ) - .await - .map_err(|error| error.to_user_msg())?; + .await?; received = authoritative.received_bytes.min(total_bytes); on_progress(received, total_bytes); } @@ -408,8 +413,7 @@ pub(crate) async fn upload_staged_game_package( &access_token, request.idempotency_key, ) - .await - .map_err(|error| error.to_user_msg())?; + .await?; outcome.uploaded_bytes = total_bytes; Ok(outcome) } From 3f0895fa410c90e1e0c72efcb483e0ce9fdef8af Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:27:41 +0800 Subject: [PATCH 17/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20cc=20=E5=9B=9E?= =?UTF-8?q?=E5=90=88"=E6=A8=A1=E5=9E=8B=E5=B7=B2=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E5=8D=B4=E6=8A=A5=E5=AE=BF=E4=B8=BB=E4=BB=BB=E5=8A=A1=E6=8F=90?= =?UTF-8?q?=E5=89=8D=E7=BB=93=E6=9D=9F"=EF=BC=8C=E5=B9=B6=E6=94=BE?= =?UTF-8?q?=E8=A1=8C=E5=AE=BF=E4=B8=BB=20MCP=20=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - thread_manager/dispatch.rs 在放行成功出口补写 DirectTurnTerminal::completed():cc 执行器没有 codex app-server 那条深层终态出口,否则占用对象兜底会把已拿到回复的回合收成 HostDropped - direct_turn_failure.rs 新增 completed() 终态(status=completed、无失败载荷)与对应单测 - claude_code_cli.rs 在 cc 解析成功后把回复按 response_item/assistant 落进 .agent/conversations/project.jsonl(id=direct-codex::assistant),落盘失败按回合失败收口 - agent-sidecar 按 mcp__ 前缀放行请求声明的 MCP 服务器,避免 dontAsk 模式下宿主工具被直接拒绝 - dispatch.rs 的 host_dropped / panic 诊断字段改用 tt=,不再被日志脱敏整行吃掉(原 turnToken= 命中凭据标记) - pitfalls.md 记录三条根因、现行口径、诊断口径与 CDP 注入验收证据 --- .../agent-sidecar/src/index.mjs | 6 +++++ .../src-tauri/src/agent/claude_code_cli.rs | 22 ++++++++++++++++++- .../src/agent/direct_turn_failure.rs | 22 +++++++++++++++++++ .../src/agent/thread_manager/dispatch.rs | 14 +++++++++--- docs/project-memory/shared-memory/pitfalls.md | 11 ++++++++++ 5 files changed, 71 insertions(+), 4 deletions(-) diff --git a/apps/ai-game-creator-shell/agent-sidecar/src/index.mjs b/apps/ai-game-creator-shell/agent-sidecar/src/index.mjs index 16f819b25..7f6c7baf5 100644 --- a/apps/ai-game-creator-shell/agent-sidecar/src/index.mjs +++ b/apps/ai-game-creator-shell/agent-sidecar/src/index.mjs @@ -56,6 +56,12 @@ function buildOptions(request, controller) { if (request.sessionId) options.sessionId = request.sessionId; if (request.mcpServers && typeof request.mcpServers === 'object') { options.mcpServers = request.mcpServers; + // `dontAsk` 模式下没有显式放行的工具会被直接拒绝(真实案例:2026-10-03 cc 回合里模型想读 + // 项目文件,`agc_read_project_context` 被拒,用户只拿到一句"没有权限")。宿主暴露的 MCP + // 本身就是 AGC 的工具边界(权限策略在宿主侧执行),这里按服务器前缀整体放行。 + options.allowedTools = Object.keys(request.mcpServers).map( + (name) => `mcp__${name}`, + ); } if (request.outputSchema) { options.outputFormat = { 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 7fd66c9fa..4709994ba 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 @@ -979,7 +979,27 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( direct_turn_trace("claude-parse-start"); let parsed = parse_direct_stream_result(stream.join("\n").as_bytes(), observer); match &parsed { - Ok(text) => direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())), + Ok(text) => { + direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())); + // 项目对话历史是这条对话的单一事实源:回复不落盘,UI 就看不到本轮结果。codex 路径由 + // app-server 的 collect-history 负责写 assistant 条目,cc 没有那一步——只补终态会让 + // 用户看到"回合结束但没有回复"。落盘失败按回合失败收口,不吞。 + let item_id = match client_turn_id { + Some(client_turn_id) => format!("direct-codex:{client_turn_id}:assistant"), + None => format!("direct-codex:{}:assistant", uuid::Uuid::new_v4()), + }; + let item = serde_json::json!({ + "type": "message", + "role": "assistant", + "id": item_id, + "content": [{ "type": "output_text", "text": text }], + }); + if let Err(error) = append_direct_project_history_item_at(root, &item) { + eprintln!("[agc-cc-direct] persist assistant failed: {error}"); + direct_turn_trace("claude-parse-error"); + return Err(format!("写入本项目对话历史失败:{error}")); + } + } Err(error) => { // 这一段必须走 stderr:AppData 日志的脱敏会把含 turnToken= 的整行替换掉, // 收尾失败时看不到任何原因(真实案例:2026-10-02 18:19 模型已回复但回合被丢弃)。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs index 1d3d004c9..61ca56880 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs @@ -35,6 +35,17 @@ pub(crate) struct DirectTurnTerminal { } impl DirectTurnTerminal { + /// 一次成功终态:只带 `status="completed"`,不带失败载荷。 + /// + /// 供给没有"深层终态出口"的执行器(cc / Claude Code sidecar)用:它们整轮成功返回后, + /// 线程仍被放行占用,必须由放行侧补写这条终态。 + pub(crate) fn completed() -> Self { + Self { + status: "completed".to_string(), + failure: None, + } + } + /// 终态事件:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> ThreadEvent { let event = match self.failure { @@ -258,4 +269,15 @@ stderrClass=nonempty;stderrBytes=1000"; assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped); assert!(!failure.message.trim().is_empty()); } + + #[test] + fn completed_terminal_carries_no_failure_payload() { + let terminal = DirectTurnTerminal::completed(); + assert_eq!(terminal.status, "completed"); + assert!(terminal.failure.is_none()); + assert!(matches!( + terminal.event(1_700_000_000_000, Some("item-1")), + ThreadEvent::TurnCompleted { ref status, .. } if status == "completed" + )); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index f8be8982e..cd700bfc9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -98,14 +98,17 @@ impl Drop for TurnReservation { // 不必只依赖 AppData 的应用日志。 let failure = DirectTurnError::TurnFailed { stage: DirectCodexFailureStage::CodeGeneration, + // 字段名用 `tt`(不是 `turnToken`):`turnToken=` 会命中应用日志的凭据标记, + // 整行被替换成 ``,离线就只剩一个 + // 说不出原因的 HostDropped。 detail: format!( - "DirectProject 宿主任务提前结束(panic、future 被丢弃或被取消),本轮未写下终态;threadId={} turnToken={}", + "DirectProject 宿主任务提前结束(panic、future 被丢弃或被取消),本轮未写下终态;threadId={} tt={}", self.thread_id, self.token ), }; let _ = record_direct_codex_failure(Path::new(&self.thread_id), &failure, None); app_log!( - "agent.direct_turn.host_dropped threadId={} turnToken={} userItemId={} panicking={}", + "agent.direct_turn.host_dropped threadId={} tt={} userItemId={} panicking={}", self.thread_id, self.token, self.user_item_id.as_deref().unwrap_or("-"), @@ -149,7 +152,7 @@ fn ensure_direct_turn_panic_hook() { .unwrap_or_else(|| "未知位置".to_string()); #[cfg(not(test))] app_log!( - "agent.direct_turn.panic threadId={} turnToken={} location={} payload={}", + "agent.direct_turn.panic threadId={} tt={} location={} payload={}", thread_id, token, location, @@ -270,6 +273,11 @@ async fn run_dispatched_direct_turn( Ok(_) => { // 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。 emitter.emit("completed", Some("none")); + // cc(Claude Code sidecar)执行器没有 app-server 那条"深层终态出口":整轮成功返回后 + // 线程仍被这条放行占用,不补写终态的话,占用对象 Drop 时的兜底会把一轮已经拿到回复的 + // 回合收成 `HostDropped`(真实案例:2026-10-03 `claude-parse-done chars=176` 之后立刻 + // `host_dropped panicking=false`)。codex 路径已写过终态,这里是空操作。 + reservation.finish_if_unfinished(DirectTurnTerminal::completed()); } Err(error) => { // 放行之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d0488a25a..876438088 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6269,3 +6269,14 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **注意**:日志脱敏标记包含 `credential`、`x-api-key`、`bearer `、`token=`、`api_key`,命中即整行替换成 ``。诊断行只能写 `source=`/`host=`/`auth=` 这类自查过的字段(第一版写成 `credential=bearer`,整行被吃掉过一次)。 - **验证**:`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell claude_code_cli::tests::` 11 passed,覆盖平台优先、自定义端点保留配置、未登录时不回落本机环境且报「先登录」、baseUrl `/v1` 归一化、主机名诊断、sidecar 环境隔离(`USERPROFILE`/`CLAUDE_CONFIG_DIR` 指向隔离目录且不带本机 `ANTHROPIC_*`);另用本地假 Anthropic 端点跑通真 sidecar:`REQ HEAD /api/hello` → `REQ POST /v1/messages?beta=true auth=bearer` → `result=PROBE_OK`。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`、[`【技术方案】AGC后台模型别名与对话选择-2026-09-05.md`](../../technical/【技术方案】AGC后台模型别名与对话选择-2026-09-05.md)。 + +## 2026-10-03 cc 回合"模型已回复却报宿主任务提前结束":缺终态 + 缺落盘 + 工具被拒 + +- **现象**:Router 渠道恢复后,cc 回合能拿到真实回复(日志 `stage=claude-parse-done chars=176/208/289`),但紧接着就是 `runtime-unclassified` +「DirectProject 宿主任务提前结束(panic、future 被丢弃或被取消)」,用户看到的仍是失败;项目对话历史里只有用户消息、没有助手消息;模型还会回一句「读项目文件的工具没有权限」。 +- **根因 1(缺终态)**:终态 `turn.completed` 的"深层出口"只在 codex app-server 那条路径里(`DirectTurnTerminalContext::write` → `complete_turn`)。cc 执行器(`direct_game_creator_claude_code_chat_at`)整轮成功返回后没有任何人写终态,于是 `thread_manager::dispatch` 里占用对象的兜底把一轮已经拿到回复的回合收成 `HostDropped`。诊断行 `agent.direct_turn.host_dropped … panicking=false` 且**没有** `agent.direct_turn.panic` 行,就是这条(不是 panic,是"没写终态就结束")。 +- **根因 2(缺落盘)**:assistant 回复只存在于 SDK 事件流里。codex 路径由 `finish_direct_project_collect_history` 写进 `.agent/conversations/project.jsonl`,cc 没有对应步骤,所以即使回合收口成功,UI 也读不到回复。 +- **根因 3(工具被拒)**:sidecar 用 `permissionMode: 'dontAsk'` 且没有 `allowedTools`,宿主 MCP 工具(`mcp__agc__*`)一律被直接拒绝,模型只能回"没有权限"。 +- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 解析成功后必须把回复按 `{"type":"message","role":"assistant","id":"direct-codex::assistant","content":[{"type":"output_text","text":…}]}` 落进项目历史,落盘失败按回合失败收口;sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 +- **诊断口径**:`agent.direct_turn.host_dropped` / `agent.direct_turn.panic` 里的令牌字段必须写 `tt=`,写 `turnToken=` 会命中脱敏标记,整行变成 ``,离线只剩"说不出原因"的 HostDropped。 +- **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs `):读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`` / ``)逐字一致;同一日志里不再出现新的 `host_dropped`。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`。 From 25beb3ad5973b73d8e7a7fee3458e3f34c210636 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:42:37 +0800 Subject: [PATCH 18/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20cc=20=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E5=8F=AA=E5=9C=A8=E9=87=8D=E8=BF=9B=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E6=97=B6=E6=89=8D=E5=8F=AF=E8=A7=81=EF=BC=9A=E8=A1=A5=E4=B8=8B?= =?UTF-8?q?=E5=8F=91=20item.completed=20=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - claude_code_cli.rs 在 cc 解析成功、回复落盘之后,用同一个条目 id 下发 ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"}),聊天区按事件流投影即可实时显示本轮回复 - 之前只落盘 project.jsonl、不下发条目事件,用户看到的是"用户气泡 + 本轮结束于 … · 耗时",回复要重进项目才从历史读出来 - pitfalls.md 补记第四条根因与 CDP + DOM 断言验收证据 --- .../src-tauri/src/agent/claude_code_cli.rs | 18 +++++++++++++++++- docs/project-memory/shared-memory/pitfalls.md | 5 +++-- 2 files changed, 20 insertions(+), 3 deletions(-) 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 4709994ba..483f42b8e 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 @@ -991,7 +991,7 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( let item = serde_json::json!({ "type": "message", "role": "assistant", - "id": item_id, + "id": item_id.clone(), "content": [{ "type": "output_text", "text": text }], }); if let Err(error) = append_direct_project_history_item_at(root, &item) { @@ -999,6 +999,22 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( direct_turn_trace("claude-parse-error"); return Err(format!("写入本项目对话历史失败:{error}")); } + // 聊天区是按 `item.completed` 事件流投影的(codex 路径在 rawResponseItem/completed + // 时下发同款事件),只落盘历史不会让本轮回复出现在界面上——重进项目才看得到。 + // 条目身份与落盘的历史条目保持同一个,重进项目按 id 去重。 + let at = crate::agent::direct_now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed( + ThreadItem::Message { + item_id, + role: "assistant".to_string(), + text: text.clone(), + at, + }, + at, + ), + ); } Err(error) => { // 这一段必须走 stderr:AppData 日志的脱敏会把含 turnToken= 的整行替换掉, diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index f209c9f42..b9b691b1f 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6276,7 +6276,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **根因 1(缺终态)**:终态 `turn.completed` 的"深层出口"只在 codex app-server 那条路径里(`DirectTurnTerminalContext::write` → `complete_turn`)。cc 执行器(`direct_game_creator_claude_code_chat_at`)整轮成功返回后没有任何人写终态,于是 `thread_manager::dispatch` 里占用对象的兜底把一轮已经拿到回复的回合收成 `HostDropped`。诊断行 `agent.direct_turn.host_dropped … panicking=false` 且**没有** `agent.direct_turn.panic` 行,就是这条(不是 panic,是"没写终态就结束")。 - **根因 2(缺落盘)**:assistant 回复只存在于 SDK 事件流里。codex 路径由 `finish_direct_project_collect_history` 写进 `.agent/conversations/project.jsonl`,cc 没有对应步骤,所以即使回合收口成功,UI 也读不到回复。 - **根因 3(工具被拒)**:sidecar 用 `permissionMode: 'dontAsk'` 且没有 `allowedTools`,宿主 MCP 工具(`mcp__agc__*`)一律被直接拒绝,模型只能回"没有权限"。 -- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 解析成功后必须把回复按 `{"type":"message","role":"assistant","id":"direct-codex::assistant","content":[{"type":"output_text","text":…}]}` 落进项目历史,落盘失败按回合失败收口;sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 +- **根因 4(界面看不到回复)**:聊天区是按 `item.completed` 事件流投影的(codex 路径在 `rawResponseItem/completed` 时下发 `ThreadItem::Message`),只把回复落进 `project.jsonl` 不会让本轮出现在界面上——用户看到"用户气泡 + 本轮结束于 … · 耗时",回复只在重进项目时从历史读出来。 +- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 解析成功后必须①把回复按 `{"type":"message","role":"assistant","id":"direct-codex::assistant","content":[{"type":"output_text","text":…}]}` 落进项目历史,②用**同一个 id** 下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`(落盘失败按回合失败收口);sidecar 按 `mcp__` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。 - **诊断口径**:`agent.direct_turn.host_dropped` / `agent.direct_turn.panic` 里的令牌字段必须写 `tt=`,写 `turnToken=` 会命中脱敏标记,整行变成 ``,离线只剩"说不出原因"的 HostDropped。 -- **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs `):读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`` / ``)逐字一致;同一日志里不再出现新的 `host_dropped`。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。 +- **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs `):①读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`` / ``)逐字一致(证明宿主工具真的执行了);②聊天视图打开时注入 `只回三个字:收到了`,DOM 断言(`document.body.innerText`)同时出现用户气泡 `11:40:05`、助手回复 `收到了` 与 `本轮结束于 11:40:16 · 耗时 10.7秒`(证明 `item.completed` 实时投影生效,不必重进项目);同一日志不再出现新的 `host_dropped`。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`。