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..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,17 +67,25 @@ 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 { + error_field(body, "code") +} + 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 +97,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 +107,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/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 2e2d583e9..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 @@ -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,17 @@ 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` 的调试测试线程栈; + // 凭据作用域由外层 `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, + }, + )) .await - .map_err(|cause| RemoveBackgroundError::BackgroundRemovalFailed { cause })? + .map_err(RemoveBackgroundError::from_resource_edit_error)? } else { // id 按回合身份与请求指纹确定性派生,同指纹重试与 pending 对账语义不变。 let turn_id = state @@ -2596,9 +2609,11 @@ async fn remove_background_payload( background_mode: Some(input.background_mode.clone()), screen_color: input.screen_color.clone(), }; - derive_local_project_resource_at(request) + // 资源编辑的 future 很大,装箱避免顶穿 `handle_direct_tool_bridge` 的调试测试线程栈; + // 凭据作用域由外层 `with_direct_editor_api_credentials` 覆盖,这里不再套一层。 + Box::pin(derive_local_project_resource_typed(request)) .await - .map_err(|cause| RemoveBackgroundError::BackgroundRemovalFailed { cause })? + .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/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 661ae2ad2..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,8 +1274,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/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..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 @@ -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,56 @@ impl ToolFailure for CreateOrDeriveResourceError { Self::ProjectRevisionUnavailable { cause } => { format!("生成或派生资源失败:读取项目 revision 失败:{cause}") } + Self::RemoteGenerationFailed(failure) => { + format!("生成或派生资源失败:{}", 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, + phase_detail, + } => Self::RemoteGenerationFailed(RemoteResourceEditFailure { + server_message, + phase_detail, + }), + error => Self::ResourceGenerationFailed { + cause: error.to_user_msg(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + 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()), + }, + ); + + let serialized = serde_json::to_value(&error).expect("serialize typed failure"); + assert_eq!( + serialized["RemoteGenerationFailed"]["server_message"], + serde_json::json!("provider detail") + ); + assert_eq!( + serialized["RemoteGenerationFailed"]["phase_detail"], + serde_json::json!("生成失败。") + ); + } +} 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..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 @@ -179,6 +179,31 @@ impl ToolFailure for ResourceCompletionRejection { } } +/// 平台把远端资源编辑判成终态 `failed` 时给出的原因。 +/// +/// `agc_create_or_derive_resource` 与 `agc_remove_background` 走的是同一条远端资源编辑 +/// 通道,平台原文必须作为数据原样带出(不是在产生点拼进前缀)。这里只负责记录平台给的事实, +/// 前缀由使用它的工具在自己的变体里加,两个工具共用这一份载体。 +/// +/// `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 { + fn to_user_msg(&self) -> String { + match &self.server_message { + Some(server_message) => server_message.clone(), + 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..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 @@ -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,54 @@ impl ToolFailure for RemoveBackgroundError { Self::ProjectRevisionUnavailable { cause } => { format!("抠图失败:读取项目 revision 失败:{cause}") } + Self::RemoteGenerationFailed(failure) => { + format!("抠图失败:{}", 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, + phase_detail, + } => Self::RemoteGenerationFailed(RemoteResourceEditFailure { + server_message, + phase_detail, + }), + error => Self::BackgroundRemovalFailed { + cause: error.to_user_msg(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + 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()), + }, + ); + + let serialized = serde_json::to_value(&error).expect("serialize typed failure"); + assert_eq!( + serialized["RemoteGenerationFailed"]["server_message"], + serde_json::json!("provider detail") + ); + assert_eq!( + serialized["RemoteGenerationFailed"]["phase_detail"], + serde_json::json!("生成失败。") + ); + } +} 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/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..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 @@ -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 错误只带平台给的 `error` 原文(或「服务器未返回 +/// 错误信息」),前缀是使用者的事——工具面用的是各工具自己的前缀,这里是桌面命令。 +fn resource_edit_command_error(cause: String) -> String { + format!("资源编辑生成失败:{cause}") } #[tauri::command] 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..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::{ @@ -193,8 +194,10 @@ 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(server_error_detail(code, message, || { + "服务器未返回错误信息".to_string() + })); } Ok(value.get("data").cloned().unwrap_or(value)) } @@ -208,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 - .or(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}") } @@ -309,7 +309,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 +341,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.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 259757a5e..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 @@ -67,12 +67,82 @@ 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()) + } + } +} + +/// 上传编排对外只回一句 `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, version_id: &str, access_token: &str, -) -> Result { +) -> Result { let response = platform_request( client, access_token, @@ -81,21 +151,28 @@ 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}"))?; + .map_err(|error| GamePackageUploadError::read_upload_state(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); + 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| 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}")) + }) } /// 上传一个分片;返回服务端确认后的已收字节。 @@ -120,48 +197,63 @@ 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 = server_error_detail(code, message, || 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 (_, message) = parse_server_error(status, &text); + let (code, message) = parse_server_error(status, &text); + let detail = server_error_detail(code, message, || format!("HTTP {status}")); return Err(ChunkUploadError::Retryable( - message.unwrap_or_else(|| format!("发行包分片上传失败(HTTP {status})")), + GamePackageUploadError::upload_chunk(detail), )); } if status >= 400 { - let (_, message) = parse_server_error(status, &text); + let (code, message) = parse_server_error(status, &text); + let detail = server_error_detail(code, message, || format!("HTTP {status}")); return Err(ChunkUploadError::Fatal( - message.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( @@ -170,7 +262,7 @@ async fn complete_upload( version_id: &str, access_token: &str, idempotency_key: &str, -) -> Result { +) -> Result { let response = platform_request( client, access_token, @@ -180,18 +272,20 @@ 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}"))?; + .map_err(|error| GamePackageUploadError::complete_upload(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); + 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| 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 @@ -286,10 +380,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.into()), 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; 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..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 @@ -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,32 @@ 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"); + 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)?; - 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, + phase_detail, + }); } 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 +3403,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 +3428,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 +3444,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 +3471,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 +3485,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 +3498,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 +3515,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 +3533,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 +3556,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 +3573,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 +3597,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 +5383,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 +5473,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 +5586,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 +5669,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 +5681,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 +5761,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 +5779,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 +5803,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 +5827,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 +5842,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 +5879,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 +5903,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 +5954,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 +5963,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 +7848,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 +8055,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 +8089,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 +8691,7 @@ mod tests { ( "400 Bad Request", ResourceEditLedgerPhase::RemoteFailed, - "remote-terminal-failed", + "资源编辑请求被拒绝", ), ( "502 Bad Gateway", @@ -8590,6 +8740,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 +9083,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..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": "生成失败。" }}), ); } @@ -702,12 +704,20 @@ 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` 原文时退回同一个响应里的 `phaseDetail`(带 `error` 原文的路径见 + // resource_editor.rs 的远端终态测试)。 + assert_eq!( + error, + ResourceEditError::RemoteGenerationFailed { + server_message: None, + phase_detail: Some("生成失败。".to_string()), + } + ); 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..fc66e2b8a --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs @@ -0,0 +1,44 @@ +//! `resource_editor` 的错误定义。 +//! +//! 这一层整体还是 `Result<_, String>`(见 `Other` 上的 TODO),这里先把**远端资源编辑终态 +//! 失败**这条穿出来:平台给的原因必须作为数据往上走,而不是在产生点就拼成一句带前缀的话—— +//! 否则工具层再也分不出「远端失败」和「本地失败」,只能把两句前缀叠在一起。 + +/// 资源编辑失败。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ResourceEditError { + /// 平台轮询到终态 `status=failed`。`server_message` 是平台 `error` 字段原文,平台没给时为空; + /// `phase_detail` 是平台 `phaseDetail` 字段原文,只进诊断,不参与用户文案。 + /// + /// 原文只进当次错误文案,不进账本(账本只存 `terminal_failure_code` 分类码)。 + RemoteGenerationFailed { + server_message: Option, + phase_detail: Option, + }, + /// 尚未分类的失败:`message` 是产生点写好的、可直接展示的文案或稳定码。 + // TODO refactor string-typed + Other(String), +} + +impl ResourceEditError { + /// 面向用户与模型的文案。 + /// + /// 只给平台 `error` 原文,平台没给就回「服务器未返回错误信息」。「谁失败了」这类前缀由使用者 + /// 自己加(工具面用各工具自己的前缀,桌面命令面用「资源编辑生成失败」),这一层不替它们定调; + /// `phase_detail` 只进诊断,不进用户文案。 + 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::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()) + } +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b8b4ebc1a..d41142369 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,4 +1,22 @@ # 决策记录 +## 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`,不再往主文件里塞类型定义。 +- 决策(工具层承载):`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` 原文;平台没给就回「服务器未返回错误信息」,不再说「资源编辑生成失败」这种没有信息量的总结。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` 不再参与用户文案(只作结构化字段)。 +- 改动范围:`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-02 DirectProject 对话:更早历史自动加载、前插锚定与回到底部胶囊 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 876438088..f209c9f42 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),工具层经 `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`。 ## Tauri `--no-sign` 会连带跳过 updater 签名