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`。