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