From 0956ab0213b96d08bb2a784e37c7d089efcd06a9 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:44:39 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E6=94=B6=E6=95=9B=E9=9D=9E?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E9=94=99=E8=AF=AF=E5=85=B7=E4=BD=93=E6=96=87?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留策划、External Editor、账户和发布错误正文 补充模型目录、工具桥、素材上传与错误报告网络因链 修复客户端登录和 UI 保存的具体错误丢失 更新错误展示口径与回归测试 --- .../src-tauri/src/account_api.rs | 72 +++++++++++++++---- .../src-tauri/src/agent/design_runtime.rs | 20 ++++-- .../src-tauri/src/agent/direct_tools_mcp.rs | 8 +-- .../src/agent/generation/canvas_generation.rs | 40 ++++++++++- .../agent/generation/loop_orchestration.rs | 21 +++++- .../src-tauri/src/agent/tool/error.rs | 12 ++-- .../src-tauri/src/assets.rs | 2 +- .../src-tauri/src/config.rs | 26 ++++--- .../src-tauri/src/error_report/submit.rs | 6 +- .../src/game_distribution_publish.rs | 68 ++++++++++++++---- .../src-tauri/src/game_package_upload.rs | 10 ++- .../src-tauri/src/llm_catalog.rs | 16 ++--- .../src-tauri/src/platform_asset_upload.rs | 12 ++-- .../src/project/resource_editor/error.rs | 20 +++--- .../src/app/AuthenticatedClient.tsx | 49 ++++++++++--- .../chat/conversation/directTurnFailure.ts | 2 +- .../src/view/ui-editor/useUiEditorPage.ts | 14 ++-- ...GC命令错误结构化与错误报告口径-2026-10-01.md | 1 + ...实施计划】AGC错误具体文本展示-2026-10-04.md | 1 + 19 files changed, 295 insertions(+), 105 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 9c2caee3e..f9204a992 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 @@ -15,7 +15,7 @@ use shared_contracts::runtime::{ ProfileRechargeCenterResponse, ProfileReferralInviteCenterResponse, ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse, }; -use std::time::Duration; +use std::{path::Path, time::Duration}; use url::Url; const HTTP_TIMEOUT: Duration = Duration::from_secs(30); @@ -36,7 +36,35 @@ fn build_client() -> Result { .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| "创建账户网络客户端失败".to_string()) + .map_err(|error| format!("创建账户网络客户端失败:{error}")) +} + +fn describe_account_request_failure(error: &reqwest::Error) -> String { + let kind = if error.is_timeout() { + "请求超时" + } else if error.is_connect() { + "连接失败" + } else if error.is_body() { + "响应正文读取失败" + } else if error.is_request() { + "请求发送失败" + } else { + "网络请求失败" + }; + let mut causes = Vec::new(); + let mut source = std::error::Error::source(error); + while let Some(current) = source { + let text = current.to_string(); + if !text.is_empty() && !causes.contains(&text) { + causes.push(text); + } + source = current.source(); + } + if causes.is_empty() { + kind.to_string() + } else { + format!("{kind}:{}", causes.join(" → ")) + } } fn endpoint(snapshot: &PlatformSessionSnapshot, segments: &[&str]) -> Result { @@ -86,6 +114,21 @@ fn error_code(body: &str) -> Option { error_field(body, "code") } +fn safe_error_body_detail(body: &str) -> Option { + let value = serde_json::from_str::(body).ok(); + let detail = value + .as_ref() + .and_then(|value| value.get("error").or(Some(value))) + .and_then(|value| serde_json::to_string(value).ok()) + .or_else(|| (!body.trim().is_empty()).then(|| body.trim().to_string()))?; + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 600, + ); + (!detail.trim().is_empty()).then_some(detail) +} + 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 { @@ -94,22 +137,26 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { if status == StatusCode::FORBIDDEN { return format!( "permission-denied: {}", - error_message(body).unwrap_or_else(|| "当前账号无权执行此操作".to_string()) + error_message(body) + .or_else(|| safe_error_body_detail(body)) + .unwrap_or_else(|| "当前账号无权执行此操作".to_string()) ); } let detail = error_message(body) .or_else(|| error_code(body)) + .or_else(|| safe_error_body_detail(body)) .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); format!("{fallback}:{detail}") } fn unwrap_envelope(body: &str, fallback: &str) -> Result { - let value: Value = - serde_json::from_str(body).map_err(|_| format!("{fallback}:服务端响应不是合法 JSON"))?; + let value: Value = serde_json::from_str(body) + .map_err(|error| format!("{fallback}:服务端响应不是合法 JSON:{error}"))?; if value.get("ok").and_then(Value::as_bool) == Some(false) { let detail = error_message(body) .or_else(|| error_code(body)) - .unwrap_or_else(|| "服务器未返回错误信息".to_string()); + .or_else(|| safe_error_body_detail(body)) + .unwrap_or_else(|| "服务端返回 ok=false,但未提供 error/code/message 详情".to_string()); return Err(format!("{fallback}:{detail}")); } Ok(value.get("data").cloned().unwrap_or(value)) @@ -157,22 +204,21 @@ async fn request_json( request = request.json(&body); } let response = request.send().await.map_err(|error| { - if error.is_timeout() { - format!("{fallback}:请求超时,请稍后重试") - } else { - format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") - } + format!( + "{fallback}:{};请确认配套后端或 API 代理已启动后重试", + describe_account_request_failure(&error) + ) })?; let status = response.status(); let text = response .text() .await - .map_err(|_| format!("{fallback}:读取响应失败"))?; + .map_err(|error| format!("{fallback}:读取响应失败:{error}"))?; if !status.is_success() { return Err(map_http_error(status, &text, fallback)); } let data = unwrap_envelope(&text, fallback)?; - serde_json::from_value(data).map_err(|_| format!("{fallback}:响应格式无效")) + serde_json::from_value(data).map_err(|error| format!("{fallback}:响应格式无效:{error}")) } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 3f8c99995..53441925d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -1247,8 +1247,17 @@ async fn finish_design_command( // panic 负载可能包含路径或内容片段,公开文案固定;位置和负载由 panic hook 写进私有 // design_debug(task-local 提供项目根),不进入用户可见消息。 -fn design_panic_error(_payload: Box) -> String { - DESIGN_PANIC_PUBLIC_ERROR.to_string() +fn design_panic_error(payload: Box) -> String { + let raw = payload + .downcast_ref::<&str>() + .map(|value| (*value).to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_default(); + let detail = redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &raw, 600); + if detail.trim().is_empty() { + return DESIGN_PANIC_PUBLIC_ERROR.to_string(); + } + format!("策划运行发生内部错误:{detail};本轮已中断,可直接重试;若反复出现请反馈。") } #[cfg(test)] @@ -2808,10 +2817,9 @@ mod tests { .expect("panic 必须转成可恢复视图而不是向上传播"); assert!(!view.running); assert!(view.can_retry); - assert_eq!( - view.session.last_error.as_deref(), - Some(DESIGN_PANIC_PUBLIC_ERROR) - ); + let last_error = view.session.last_error.as_deref().expect("panic detail"); + assert!(last_error.contains("注入的策划工具 panic"), "{last_error}"); + assert!(last_error.contains("本轮已中断"), "{last_error}"); let session = read_design_session(&root) .expect("read session") .expect("session exists"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 28aded7d3..51c33c5f2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -910,7 +910,7 @@ fn build_direct_tool_bridge_client() -> Result { .timeout(std::time::Duration::from_secs(1_800)) .redirect(reqwest::redirect::Policy::none()) .build() - .map_err(|_| "创建客户端工具桥连接失败".to_string()) + .map_err(|error| format!("创建客户端工具桥连接失败:{error}")) } async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { @@ -922,7 +922,7 @@ async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { .json(&json!({ "tool": tool, "arguments": arguments })) .send() .await - .map_err(|_| "连接客户端受控工具桥失败".to_string())?; + .map_err(|error| format!("连接客户端受控工具桥失败:{error}"))?; if !response.status().is_success() { return Err(format!( "客户端受控工具桥返回 HTTP {}", @@ -938,12 +938,12 @@ async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { let bytes = response .bytes() .await - .map_err(|_| "读取客户端受控工具桥响应失败".to_string())?; + .map_err(|error| format!("读取客户端受控工具桥响应失败:{error}"))?; if bytes.len() > DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES { return Err("客户端受控工具桥响应超过大小上限".to_string()); } let value = serde_json::from_slice::(&bytes) - .map_err(|_| "客户端受控工具桥响应格式无效".to_string())?; + .map_err(|error| format!("客户端受控工具桥响应格式无效:{error}"))?; if !value.is_object() || !value.get("content").is_some_and(Value::is_array) || !value.get("isError").is_some_and(Value::is_boolean) 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 245491074..96181bc03 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 @@ -1144,6 +1144,27 @@ fn collect_external_http_error_fields(value: &serde_json::Value, output: &mut Ve } } +fn summarize_external_generation_failure(generation: &serde_json::Value) -> String { + let source = generation.get("error").unwrap_or(generation); + let mut fields = Vec::new(); + collect_external_http_error_fields(source, &mut fields); + let detail = if fields.is_empty() { + match source { + serde_json::Value::String(value) => value.trim().to_string(), + _ => serde_json::to_string(source).unwrap_or_default(), + } + } else { + fields.join(";") + }; + let detail = redact_secret_tokens(&redact_absolute_path_tokens(&detail)); + let detail = detail.trim().chars().take(600).collect::(); + if detail.is_empty() { + "平台图片生成任务返回 failed,但响应缺少 error 详情".to_string() + } else { + detail + } +} + pub(crate) fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 { external_editor_response_data(payload) .get("pollAfterMs") @@ -1273,8 +1294,7 @@ async fn wait_for_external_generation_result_inner( return Ok(result); } Some("failed") => { - let error = json_string_field(generation, "error") - .unwrap_or_else(|| "服务器未返回错误信息".to_string()); + let error = summarize_external_generation_failure(generation); return Err(format!( "平台图片生成任务失败:{error};operationId={operation_id}" )); @@ -8523,6 +8543,21 @@ mod canvas_generation_tests { ColorType, ImageEncoder, }; + #[test] + fn failed_generation_without_error_field_keeps_response_envelope_detail() { + let detail = summarize_external_generation_failure(&serde_json::json!({ + "status": "failed", + "code": "provider-rejected", + "message": "上游拒绝请求", + "operationId": "private-operation-id", + "api_key": "private-key" + })); + assert!(detail.contains("provider-rejected"), "{detail}"); + assert!(detail.contains("上游拒绝请求"), "{detail}"); + assert!(!detail.contains("private-operation-id"), "{detail}"); + assert!(!detail.contains("private-key"), "{detail}"); + } + #[test] fn generation_kind_catalog_accepts_only_canonical_manifest_kinds() { assert_eq!( @@ -12751,6 +12786,7 @@ mod canvas_generation_tests { .await; server.join().expect("join accepted failure fixture"); assert!(error.contains("平台图片生成任务失败"), "{error}"); + assert!(error.contains("provider rejected request"), "{error}"); assert!(request_receiver .recv_timeout(Duration::from_secs(1)) .expect("failed operation request") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs index de0390d4f..c8612377b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs @@ -1,4 +1,5 @@ use super::*; +use std::path::Path; pub(crate) struct AgentProgressEmitter<'a> { app: &'a tauri::AppHandle, @@ -104,12 +105,30 @@ pub(crate) fn game_creator_agent_llm_error_public_summary( platform_llm::LlmError::Deserialize(_) => ("deserialize".to_string(), None), }; let raw = error.to_string(); + let detail = redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &raw, 1200); let http_status = http_status .map(|status| format!(" httpStatus={status}")) .unwrap_or_default(); format!( - "kind={kind}{http_status} fingerprint={:x} chars={}", + "kind={kind}{http_status} detail={detail} fingerprint={:x} chars={}", Sha256::digest(raw.as_bytes()), raw.chars().count() ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_summary_keeps_safe_error_detail() { + let summary = + game_creator_agent_llm_error_public_summary(&platform_llm::LlmError::Upstream { + status_code: 502, + message: "upstream overloaded; operationId=private-op; api_key=private-key".into(), + }); + assert!(summary.contains("kind=upstream-502"), "{summary}"); + assert!(summary.contains("upstream overloaded"), "{summary}"); + assert!(!summary.contains("private-key"), "{summary}"); + } +} 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 2606a2a68..1a45e4235 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 @@ -185,8 +185,8 @@ impl ToolFailure for ResourceCompletionRejection { /// 通道,平台原文必须作为数据原样带出(不是在产生点拼进前缀)。这里只负责记录平台给的事实, /// 前缀由使用它的工具在自己的变体里加,两个工具共用这一份载体。 /// -/// `to_user_msg` 只给平台 `error` 原文,平台没给就回「服务器未返回错误信息」。`phase_detail` -/// 是结构化字段,只进诊断 sidecar 给开发者/LLM 看,不参与用户文案。 +/// `to_user_msg` 优先给平台 `error` 原文,缺失时展示同一终态的 `phase_detail`;两者都没有 +/// 才说明响应没有提供失败正文。结构化字段也会留在诊断 sidecar。 #[derive(serde::Serialize, Debug)] pub(crate) struct RemoteResourceEditFailure { /// 平台 `error` 字段原文;平台没给时为空。 @@ -197,10 +197,10 @@ pub(crate) struct RemoteResourceEditFailure { impl ToolFailure for RemoteResourceEditFailure { fn to_user_msg(&self) -> String { - match &self.server_message { - Some(server_message) => server_message.clone(), - None => "服务器未返回错误信息".to_string(), - } + self.server_message + .clone() + .or_else(|| self.phase_detail.clone()) + .unwrap_or_else(|| "平台已返回失败终态,但没有提供 error 或 phaseDetail".to_string()) } } 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 c45f849b8..5a3724c90 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -419,7 +419,7 @@ async fn create_private_external_editor_api_credentials_from_platform_session( let payload = response .json::() .await - .map_err(|_| "创建本机陶泥儿开发者 Key 响应格式无效".to_string())?; + .map_err(|error| format!("创建本机陶泥儿开发者 Key 响应格式无效:{error}"))?; let api_key = payload .get("apiKey") .or_else(|| payload.pointer("/data/apiKey")) diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 05f66dd3b..3e224b6ca 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -4432,30 +4432,28 @@ pub(crate) async fn fetch_custom_llm_models( .redirect(reqwest::redirect::Policy::none()) .timeout(std::time::Duration::from_secs(15)) .build() - .map_err(|_| "初始化模型列表请求失败".to_string())?; + .map_err(|error| format!("初始化模型列表请求失败:{error}"))?; let mut response = client .get(url) .bearer_auth(llm.api_key.trim()) .send() .await - .map_err(|error| { - if error.is_timeout() { - "模型列表请求超时,请重试".to_string() - } else { - "无法连接模型端点,请检查 API 地址和网络".to_string() - } - })?; + .map_err(|error| format!("无法连接模型端点:{error};请检查 API 地址和网络"))?; if !response.status().is_success() { - return Err(format!( - "模型列表读取失败(HTTP {})", - response.status().as_u16() - )); + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + let detail = body.trim().chars().take(600).collect::(); + return Err(if detail.is_empty() { + format!("模型列表读取失败(HTTP {})", status.as_u16()) + } else { + format!("模型列表读取失败(HTTP {}):{detail}", status.as_u16()) + }); } let mut body = Vec::new(); while let Some(chunk) = response .chunk() .await - .map_err(|_| "读取模型列表响应失败或超时".to_string())? + .map_err(|error| format!("读取模型列表响应失败或超时:{error}"))? { if body.len() + chunk.len() > 1024 * 1024 { return Err("模型列表响应超过 1 MiB 上限".to_string()); @@ -4471,7 +4469,7 @@ pub(crate) async fn fetch_custom_llm_models( data: Vec, } let models: Models = serde_json::from_slice(&body) - .map_err(|_| "模型端点需返回 OpenAI 兼容的 data[].id 列表".to_string())?; + .map_err(|error| format!("模型端点需返回 OpenAI 兼容的 data[].id 列表:{error}"))?; let ids = models .data .into_iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs index 54698314f..33f6cb46f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs @@ -134,7 +134,7 @@ pub async fn submit_error_report( .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| "创建错误报告客户端失败".to_string())?; + .map_err(|error| format!("创建错误报告客户端失败:{error}"))?; validate_session(&snapshot)?; let response = client .post(endpoint(&snapshot)?) @@ -144,12 +144,12 @@ pub async fn submit_error_report( .json(&payload) .send() .await - .map_err(|_| "错误报告提交失败:无法连接登录服务,请稍后重试".to_string())?; + .map_err(|error| format!("错误报告提交失败:无法连接登录服务:{error}"))?; let status = response.status(); let body = response .text() .await - .map_err(|_| "错误报告提交失败:读取响应失败".to_string())?; + .map_err(|error| format!("错误报告提交失败:读取响应失败:{error}"))?; validate_session(&snapshot)?; if !status.is_success() { return Err(response_error(status, &body)); 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 b3ee96410..d3b2c9436 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 @@ -315,6 +315,14 @@ fn parse_error_payload(body: &str) -> (Option, Option) { .get("message") .and_then(Value::as_str) .map(str::to_string); + let message = message.or_else(|| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!detail.trim().is_empty()).then_some(detail) + }); (code, message) } @@ -324,7 +332,7 @@ 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(server_error_detail(code, message, || { - "服务器未返回错误信息".to_string() + "服务端响应 ok=false,但未提供 error/code/message 详情".to_string() })); } Ok(value.get("data").cloned().unwrap_or(value)) @@ -397,11 +405,10 @@ async fn request_json( request = request.header(CONTENT_TYPE, "application/json").json(&body); } let response = request.send().await.map_err(|error| { - if error.is_timeout() { - format!("{fallback}:请求超时,请稍后重试") - } else { - format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") - } + format!( + "{fallback}:{};请确认配套后端或 API 代理已启动后重试", + describe_distribution_request_failure(&error) + ) })?; let status = response.status(); let text = response @@ -422,6 +429,34 @@ fn build_client() -> Result { .map_err(|error| format!("创建陶泥儿网络客户端失败:{error}")) } +fn describe_distribution_request_failure(error: &reqwest::Error) -> String { + let kind = if error.is_timeout() { + "请求超时" + } else if error.is_connect() { + "连接失败" + } else if error.is_body() { + "响应正文读取失败" + } else if error.is_request() { + "请求发送失败" + } else { + "网络请求失败" + }; + let mut causes = Vec::new(); + let mut source = std::error::Error::source(error); + while let Some(current) = source { + let text = current.to_string(); + if !text.is_empty() && !causes.contains(&text) { + causes.push(text); + } + source = current.source(); + } + if causes.is_empty() { + kind.to_string() + } else { + format!("{kind}:{}", causes.join(" → ")) + } +} + fn normalized_origin(value: &str) -> String { value.trim().trim_end_matches('/').to_string() } @@ -467,11 +502,10 @@ async fn request_json_for_read( ) .timeout(HTTP_TIMEOUT); let response = request.send().await.map_err(|error| { - GameDistributionReadError::Unavailable(if error.is_timeout() { - format!("{fallback}:请求超时,请稍后重试") - } else { - format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") - }) + GameDistributionReadError::Unavailable(format!( + "{fallback}:{};请确认配套后端或 API 代理已启动后重试", + describe_distribution_request_failure(&error) + )) })?; let status = response.status(); let text = response.text().await.map_err(|error| { @@ -1008,7 +1042,11 @@ async fn resolve_cover_queue( .error .clone() .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "服务器未返回错误信息".to_string())); + .unwrap_or_else(|| { + format!( + "封面生成任务返回 failed,但没有提供 error 详情;operationId={operation_id}" + ) + })); } return Ok(initial); } @@ -1041,7 +1079,11 @@ async fn resolve_cover_queue( return Err(status .error .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "服务器未返回错误信息".to_string())); + .unwrap_or_else(|| { + format!( + "封面生成任务返回 failed,但没有提供 error 详情;operationId={operation_id}" + ) + })); } 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.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs index d90d18c3d..679ed41f5 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 @@ -129,7 +129,15 @@ fn parse_server_error(status: u16, body: &str) -> (Option, Option LlmModelsResponse { fn parse_catalog_payload(bytes: &[u8]) -> Result { let value: Value = serde_json::from_slice(bytes) - .map_err(|_| "模型列表响应不是有效 JSON,请稍后重试".to_string())?; + .map_err(|error| format!("模型列表响应不是有效 JSON:{error}"))?; // The platform API normally returns the versioned response envelope. Keep // accepting the raw payload for older dev proxies and local fixtures, but // never accept an error envelope as a successful catalog. if value.get("ok").is_some() { let envelope: ApiEnvelope = serde_json::from_value(value) - .map_err(|_| "模型列表响应格式无效,请稍后重试".to_string())?; + .map_err(|error| format!("模型列表响应格式无效:{error}"))?; if !envelope.ok { let message = envelope .error @@ -104,7 +104,7 @@ fn parse_catalog_payload(bytes: &[u8]) -> Result { serde_json::from_value::(value) .map(map_catalog) - .map_err(|_| "模型列表响应格式无效,请稍后重试".to_string()) + .map_err(|error| format!("模型列表响应格式无效:{error}")) } async fn read_bounded_body( @@ -123,7 +123,7 @@ async fn read_bounded_body( .next() .await .transpose() - .map_err(|_| "读取模型列表响应失败或超时,请重试".to_string())? + .map_err(|error| format!("读取模型列表响应失败或超时:{error}"))? { if body.len().saturating_add(chunk.len()) > max_bytes { return Err("模型列表响应超过 1 MiB 上限".to_string()); @@ -152,13 +152,7 @@ async fn fetch_game_creator_llm_models( ) .send() .await - .map_err(|error| { - if error.is_timeout() { - "模型列表请求超时,请重试".to_string() - } else { - "无法连接模型服务,请检查网络后重试".to_string() - } - })?; + .map_err(|error| format!("无法连接模型服务:{error};请检查网络后重试"))?; let status = response.status(); let body = read_bounded_body(response, MODEL_CATALOG_MAX_BYTES).await?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs index 0fa86e867..ad2034739 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs @@ -217,19 +217,19 @@ async fn request_ticket( })) .send() .await - .map_err(|_| "创建素材上传凭证失败:无法连接登录服务,请稍后重试".to_string())?; + .map_err(|error| format!("创建素材上传凭证失败:无法连接登录服务:{error}"))?; let status = response.status(); let body = response .text() .await - .map_err(|_| "创建素材上传凭证失败:读取响应失败".to_string())?; + .map_err(|error| format!("创建素材上传凭证失败:读取响应失败:{error}"))?; validate_session(snapshot)?; if !status.is_success() { return Err(response_error(status, &body, "创建素材上传凭证")); } let payload = serde_json::from_str::(&body) .map(response_data) - .map_err(|_| "创建素材上传凭证失败:响应格式无效".to_string())?; + .map_err(|error| format!("创建素材上传凭证失败:响应格式无效:{error}"))?; let upload = payload .get("upload") .ok_or_else(|| "创建素材上传凭证失败:响应缺少 upload".to_string())?; @@ -356,19 +356,19 @@ async fn confirm_object( })) .send() .await - .map_err(|_| "确认素材资产失败:无法连接登录服务,请稍后重试".to_string())?; + .map_err(|error| format!("确认素材资产失败:无法连接登录服务:{error}"))?; let status = response.status(); let body = response .text() .await - .map_err(|_| "确认素材资产失败:读取响应失败".to_string())?; + .map_err(|error| format!("确认素材资产失败:读取响应失败:{error}"))?; validate_session(snapshot)?; if !status.is_success() { return Err(response_error(status, &body, "确认素材资产")); } let payload = serde_json::from_str::(&body) .map(response_data) - .map_err(|_| "确认素材资产失败:响应格式无效".to_string())?; + .map_err(|error| format!("确认素材资产失败:响应格式无效:{error}"))?; let asset = payload .get("assetObject") .ok_or_else(|| "确认素材资产失败:响应缺少 assetObject".to_string())?; 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 fc66e2b8a..fb90b7ca0 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 @@ -21,17 +21,19 @@ pub(crate) enum ResourceEditError { } impl ResourceEditError { - /// 面向用户与模型的文案。 - /// - /// 只给平台 `error` 原文,平台没给就回「服务器未返回错误信息」。「谁失败了」这类前缀由使用者 - /// 自己加(工具面用各工具自己的前缀,桌面命令面用「资源编辑生成失败」),这一层不替它们定调; - /// `phase_detail` 只进诊断,不进用户文案。 + /// 面向用户与模型的文案:优先给平台 `error` 原文,缺失时展示同一终态里的 + /// `phaseDetail`,两者都缺失才说明响应没有提供失败正文。前缀由使用者自己加。 pub(crate) fn to_user_msg(&self) -> String { match self { - Self::RemoteGenerationFailed { server_message, .. } => match server_message { - Some(server_message) => server_message.clone(), - None => "服务器未返回错误信息".to_string(), - }, + Self::RemoteGenerationFailed { + server_message, + phase_detail, + } => server_message + .clone() + .or_else(|| phase_detail.clone()) + .unwrap_or_else(|| { + "平台已返回失败终态,但没有提供 error 或 phaseDetail".to_string() + }), Self::Other(message) => message.clone(), } } diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 0d6220044..205b6cc2c 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -371,8 +371,14 @@ export function AuthenticatedClient({ const payload = failure as AuthNetworkFailure; const text = payload.reason === 'timeout' - ? '登录服务响应超时,请检查服务器地址和网络后重试' - : '无法连接登录服务,请确认配套后端或 API 代理已启动后重试'; + ? visibleClientErrorMessage( + error, + '登录服务响应超时,请检查服务器地址和网络后重试', + ) + : visibleClientErrorMessage( + error, + '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ); setAuthCheckError(text); setLoginStatus(text); throw error; @@ -444,7 +450,10 @@ export function AuthenticatedClient({ default: { expectNever(failure); // 结构化映射外(Tauri 级失败等):仍然原样抛出上报,但界面给一行泛化提示,别停在空白状态。 - const text = '登录服务返回了未识别的错误,请重试或提交错误报告'; + const text = visibleClientErrorMessage( + error, + '登录服务返回了未识别的错误,请重试或提交错误报告', + ); setAuthCheckError(text); setLoginStatus(text); throw error; @@ -637,8 +646,14 @@ export function AuthenticatedClient({ const payload = failure as AuthNetworkFailure; setLoginStatus( payload.reason === 'timeout' - ? '发送验证码失败:登录服务响应超时,请检查服务器地址和网络后重试' - : '发送验证码失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ? `发送验证码失败:${visibleClientErrorMessage( + error, + '登录服务响应超时,请检查服务器地址和网络后重试', + )}` + : `发送验证码失败:${visibleClientErrorMessage( + error, + '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + )}`, ); throw error; } @@ -701,7 +716,12 @@ export function AuthenticatedClient({ } default: { expectNever(failure); - setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告'); + setLoginStatus( + visibleClientErrorMessage( + error, + '登录服务返回了未识别的错误,请重试或提交错误报告', + ), + ); throw error; } } @@ -864,8 +884,14 @@ export function AuthenticatedClient({ const payload = failure as AuthNetworkFailure; setLoginStatus( payload.reason === 'timeout' - ? '登录失败:登录服务响应超时,请检查服务器地址和网络后重试' - : '登录失败:无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + ? `登录失败:${visibleClientErrorMessage( + error, + '登录服务响应超时,请检查服务器地址和网络后重试', + )}` + : `登录失败:${visibleClientErrorMessage( + error, + '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + )}`, ); throw error; } @@ -928,7 +954,12 @@ export function AuthenticatedClient({ } default: { expectNever(failure); - setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告'); + setLoginStatus( + visibleClientErrorMessage( + error, + '登录服务返回了未识别的错误,请重试或提交错误报告', + ), + ); throw error; } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts index 261cbda9d..9fb1ad3ca 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts @@ -216,7 +216,7 @@ function directModelCallText( ); default: { expectNever(kind); - return '智能服务执行失败,请稍后重试'; + return withVisibleFailureDetail('智能服务执行失败,请稍后重试', detail); } } } diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index c88d3e111..88de5c4b1 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -51,6 +51,7 @@ import { useUiEditorState, } from '../../features/ui-editor/useUiEditorState'; import { beginUiSaveAnalytics } from '../../services/clientAnalytics'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { cancelLocalProjectResourcePreviewScope, createProjectResourcePreviewRequestId, @@ -82,8 +83,7 @@ import { useUiEditorNodeFocus } from './useUiEditorNodeFocus'; const SEPARATION_IMPORT_BATCH_SIZE = 100; export type UiEditorSaveResult = - | { status: 'saved' } - | { status: 'failed'; message: string }; + { status: 'saved' } | { status: 'failed'; message: string }; export type UiEditorSaveAndGenerateResult = | { status: 'generated'; result: UiDesignCodeGenerationResult } @@ -1359,9 +1359,13 @@ export function useUiEditorSession( analytics?.record(result.status === 'saved'); return { status: 'saved' as const }; }); - } catch { - setSaveError('保存失败,请稍后重试。'); - return { status: 'failed' as const, message: '保存失败,请稍后重试。' }; + } catch (cause) { + const message = visibleClientErrorMessage( + cause, + '保存失败,请稍后重试。', + ); + setSaveError(message); + return { status: 'failed' as const, message }; } finally { setIsSaving(false); } diff --git a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md index dcb1a5d35..bdd038d4a 100644 --- a/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md +++ b/docs/adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md @@ -108,6 +108,7 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接 - 上游 app-server 映射保留 `message` / `additionalDetails` 的脱敏摘要;因此 401/403/408/429/413/5xx 等状态不会因为分类为 transport 或 native 而丢失正文。 - Claude Code(cc)侧车的非零退出、RPC `error`、stdout JSON/UTF-8 解析失败、stderr 和静默超时也走同一映射;侧车 stderr 只在有界收口窗口内读取并进入同一脱敏 detail,不再只写裸 `eprintln!`。 - Codex CLI / Claude sidecar 的 JSONL/JSON 解析失败、失败终态、缺失终态、超时和空回执都保留解析错误、失败事件正文与有界安全片段;`LlmError` 没有 detail 字段的超时/空回执由 Direct `ModelCallFailed.detail` 补回,不再把这些协议事实统一压成单一类别。 +- 账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥共用同一原则:网络错误保留底层因链,JSON/协议解析保留 serde 原因,HTTP 错误保留状态码与安全正文;只有响应完全没有正文时才说明“未提供 error/code/message”,不得退成“无法连接/格式无效/服务器未返回错误信息”。 - HTTP 409 只有明确包含泥点不足事实时才映射为 `paidCreditsInsufficient`;Claude Code 的普通 409 冲突保留为 `upstreamFailed`。 - `hostDropped` 携带可选的脱敏 `detail`:panic hook 能取得的负载和位置随原回合占用进入失败事件;普通 Drop 仅记录“退出时未写终态、未观察到 panic”,不推断为网络中断。旧版本无 `detail` 的事件继续可读,界面明确标注旧事件未记录原因。 - DirectProject 控制器的取消待发、终止、附件上传、项目历史读取和发送前置异常也复用同一份前端精确脱敏出口;这些非回合错误不得直接把 `Error.message` 原文写入状态栏、composer 或历史错误行。 diff --git a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md index 48c76d67a..aa68e85f3 100644 --- a/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md +++ b/docs/project-memory/plans/【实施计划】AGC错误具体文本展示-2026-10-04.md @@ -18,6 +18,7 @@ Parent Milestone: `【里程碑】AGC错误具体文本展示-2026-10-04.md` 9. 应用壳、运行配置、认证状态、插件启动、运行预览和策划会话的非结构化错误统一使用 `visibleClientErrorMessage`。 10. 资产导入、邀请码、策划工作区、发布封面/截图和素材命令的 UI 错误沿用同一共享出口。 11. Codex CLI / Claude sidecar 的解析失败、失败终态、缺失终态、超时和空回执保留有界脱敏正文;即使平台 `LlmError` 变体没有 detail 字段,也由统一 Direct `ModelCallFailed.detail` 继续携带原始原因。 +12. 扩展统一正文口径到账户、模型目录、External Editor/资源编辑、发布、素材上传、错误报告与客户端受控工具桥,保留 HTTP 状态、响应安全片段、JSON 解析原因、网络因链和 IPC/桥接原因。 ## 实现顺序