Merge pull request 'fix/资源编辑错误通道补 typed 错误并把平台失败原文带出' (#589) from fix/res-edit-error-handling into master
Project CI / AI game creator shell Rust crates (push) Successful in 1m56s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 4m14s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 4m40s
Project CI / Backend tests (push) Successful in 4m37s
Project CI / Frontend tests (push) Successful in 2m27s
Project CI / AI game creator shell web tests (push) Successful in 2m19s
Project CI / Native shell tests (push) Successful in 5m30s
Project CI / Repository checks (push) Successful in 2m52s

Reviewed-on: #589
This commit was merged in pull request #589.
This commit is contained in:
2026-10-03 11:32:40 +08:00
16 changed files with 766 additions and 215 deletions
@@ -67,17 +67,25 @@ fn bounded_business_id(value: &str, label: &str) -> Result<String, String> {
Ok(value.to_string())
}
fn error_message(body: &str) -> Option<String> {
fn error_field(body: &str, field: &str) -> Option<String> {
let value = serde_json::from_str::<Value>(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<String> {
error_field(body, "message")
}
fn error_code(body: &str) -> Option<String> {
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<Value, String> {
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))
}
@@ -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)
@@ -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}"
));
@@ -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!("生成失败。")
);
}
}
@@ -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<String>,
/// 平台 `phaseDetail` 字段原文;只进诊断,不进用户文案。
pub(crate) phase_detail: Option<String>,
}
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 回合的执行账本,不能借用其它回合的预算,所以这里直接拒绝,
@@ -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<ToolArgumentsRejection> 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!("生成失败。")
);
}
}
@@ -56,10 +56,22 @@ pub(crate) async fn with_external_editor_api_credentials<T>(
pub(crate) async fn with_direct_editor_api_credentials<T>(
operation: impl Future<Output = Result<T, String>>,
) -> Result<T, String> {
with_direct_editor_api_credentials_as(operation, std::convert::identity).await
}
/// 与 [`with_direct_editor_api_credentials`] 同一段作用域语义,但保留调用方自己的错误类型:
/// 凭据解析失败由调用方用 `credentials_error` 显式翻译,不引入 `From<String>` 这种隐式转换。
pub(crate) async fn with_direct_editor_api_credentials_as<T, E>(
operation: impl Future<Output = Result<T, E>>,
credentials_error: impl FnOnce(String) -> E,
) -> Result<T, E> {
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
}
@@ -562,7 +562,9 @@ pub(crate) async fn derive_local_project_resource(
) -> Result<DeriveLocalProjectResourceResult, String> {
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<DeriveLocalProjectResourceResult, String> {
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]
@@ -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<Value, String> {
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);
@@ -134,6 +134,19 @@ fn parse_server_error(status: u16, body: &str) -> (Option<String>, Option<String
(code, message)
}
/// 服务端错误信封里「给用户看的那句」的统一取法:优先非空 `message`,退非空 `code`,
/// 都没有才用调用点的 `fallback`。所有 HTTP 错误分支共用这一处顺序,避免各自漂移。
pub(crate) fn server_error_detail(
code: Option<String>,
message: Option<String>,
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<u64> {
let parsed = serde_json::from_str::<Value>(body).ok()?;
@@ -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<String>) -> Self {
Self {
step: GamePackageUploadStep::ReadUploadState,
detail: detail.into(),
}
}
fn upload_chunk(detail: impl Into<String>) -> Self {
Self {
step: GamePackageUploadStep::UploadChunk,
detail: detail.into(),
}
}
fn complete_upload(detail: impl Into<String>) -> 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<GamePackageUploadError> 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<PackageUploadState, String> {
) -> Result<PackageUploadState, GamePackageUploadError> {
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<GamePackageUploadOutcome, String> {
) -> Result<GamePackageUploadOutcome, GamePackageUploadError> {
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;
File diff suppressed because it is too large Load Diff
@@ -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);
@@ -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<String>,
phase_detail: Option<String>,
},
/// 尚未分类的失败:`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())
}
}
@@ -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>`;每处 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<ResourceEditError>`,远端终态进 `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 对话:更早历史自动加载、前插锚定与回到底部胶囊
@@ -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 签名