From d7b6dce39352d2cc9477103f23aea5d426d968cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 13:02:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E6=94=B6=E6=95=9B=E5=90=88=E5=B9=B6=E5=BC=95?= =?UTF-8?q?=E5=85=A5=E7=9A=84=E4=B8=A4=E5=A4=84=E5=A4=B1=E8=B4=A5=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 模型目录 ok=true 但 data 为空时恢复服务端协议 error 明细,补回合并时丢失的分支实现 自定义模型端点失败时不再回显上游正文,仅返回 HTTP 状态码,避免泄漏密钥 使 catalog_missing_data_preserves_protocol_detail 与 custom_llm_discovery_reports_safe_errors_and_bounds_response 恢复通过 --- .../src-tauri/src/config.rs | 21 ++++++------------- .../src-tauri/src/llm_catalog.rs | 14 ++++++++++--- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index e22b22158..629eb9a4f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -4487,21 +4487,12 @@ pub(crate) async fn fetch_custom_llm_models( .await .map_err(|error| format!("无法连接模型端点:{error};请检查 API 地址和网络"))?; if !response.status().is_success() { - let status = response.status(); - let body = response - .text() - .await - .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); - let detail = crate::agent::redact_agent_runtime_error( - Path::new("__agc_no_project_root__"), - body.trim(), - 600, - ); - return Err(if detail.is_empty() { - format!("模型列表读取失败(HTTP {})", status.as_u16()) - } else { - format!("模型列表读取失败(HTTP {}):{detail}", status.as_u16()) - }); + // 自定义模型端点由用户提供、响应不可信:只回报状态码,绝不回显上游正文, + // 否则会把密钥或私有数据带进错误文案。 + return Err(format!( + "模型列表读取失败(HTTP {})", + response.status().as_u16() + )); } let mut body = Vec::new(); while let Some(chunk) = response diff --git a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs index 7c233a2e9..ac09f9fcd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs @@ -53,9 +53,17 @@ fn parse_catalog_payload(bytes: &[u8]) -> Result { .unwrap_or("模型列表读取失败"); return Err(message.to_string()); } - return envelope - .data - .ok_or_else(|| "模型列表响应缺少 data,请稍后重试".to_string()); + return envelope.data.ok_or_else(|| { + // `ok=true` 但 `data` 为空时,把服务端协议里的 error 明细带出来, + // 否则排障只能看到笼统的「缺少 data」。 + let error_detail = envelope + .error + .as_ref() + .and_then(|error| error.message.as_deref().or(error.code.as_deref())) + .filter(|detail| !detail.trim().is_empty()) + .unwrap_or("error 字段为空"); + format!("模型列表响应缺少 data:ok=true,error={error_detail}") + }); } serde_json::from_value::(value) From 2daf2c8a7ff68b952165d2ba132214fdfa608473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 7 Oct 2026 13:29:47 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E6=94=B6=E6=95=9B=E5=90=88=E5=B9=B6=E5=90=8E?= =?UTF-8?q?=E9=81=97=E7=95=99=E7=9A=84=E5=89=8D=E7=AB=AF=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 恢复合并时丢失的 homeCreationFailure 安全正文实现:按工作区是否已建好拼接脱敏后的 IPC / Provider / 宿主失败原因 - errorReporting:映射外 Tauri 失败被 ClientAuthErrorWrapper 序列化成 `{}` 时落回兜底文案,不再把 `{}` 当正文展示 - useRecentProjects:提权失败分类改看原始正文,避免脱敏把 `(DACL …)` 连同路径吞成 `` 后误判可重试、再弹一次 UAC - PluginPanelHost:插件面板是不可信内容,原始错误只进错误池,用户可见文案保持「插件面板加载失败」 - platformSession / platformSessionRetry:订阅替身返回 Promise,对齐真实 subscribeClientAuthState 契约 - 润色失败、资源版本替换、首页创建失败、启动浮层的测试期望对齐脱敏后的详情文案 --- .../features/app-shell/homeCreationFailure.ts | 26 ++++++++++++++++--- .../features/app-shell/useRecentProjects.ts | 10 ++++--- .../src/features/plugins/PluginPanelHost.tsx | 10 ++++--- .../src/services/errorReporting.ts | 9 ++++++- .../tests/appSurface/home.suite.ts | 8 +++--- .../tests/launcherNoticeShellWiring.test.tsx | 1 + .../tests/platformSession.test.ts | 2 +- .../tests/platformSessionRetry.test.ts | 2 +- .../projectResourceLiveIntegration.test.tsx | 4 ++- .../resourceCanvasGenerationEntry.test.tsx | 4 ++- .../tests/resourceVersionReplacement.test.tsx | 6 ++--- 11 files changed, 62 insertions(+), 20 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/app-shell/homeCreationFailure.ts b/apps/ai-game-creator-shell/src/features/app-shell/homeCreationFailure.ts index 2ac9e3fbe..f8e7bca7a 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/homeCreationFailure.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/homeCreationFailure.ts @@ -11,6 +11,8 @@ * 落回本模块的专用兜底,并按「工作区是否已经建好」分成两条,避免用户重做一遍。 */ +import { visibleClientErrorMessage } from '../../services/errorReporting'; + /** 未分类失败的专用兜底文案。 */ export const HOME_CREATION_UNCLASSIFIED_FAILURE = '创建未完成,请重试'; @@ -64,6 +66,19 @@ function isHomeCreationTransportText(text: string): boolean { ); } +function safeHomeCreationDetail(error: unknown, code?: string) { + const detail = visibleClientErrorMessage(error, '').trim(); + if (!detail) return ''; + if (!code) return detail; + const withoutCode = detail + .replace(code, '') + .replace(/^\s*[::;;,,-]\s*/u, '') + .trim(); + return withoutCode === code || /[::;;,,-]\s*$/u.test(withoutCode) + ? '' + : withoutCode; +} + /** * 由宿主错误算出用户可见文案。 * @@ -76,20 +91,25 @@ export function homeCreationFailureMessage( ): string { const projectCreated = options.projectCreated === true; const text = homeCreationDiagnosticText(error); + const safeDetail = safeHomeCreationDetail(error); if (isHomeCreationTransportText(text)) { - return projectCreated + const base = projectCreated ? HOME_CREATION_AFTER_PROJECT_IPC_FAILURE : HOME_CREATION_IPC_FAILURE; + return safeDetail ? `${base}(${safeDetail})` : base; } const code = HOME_CREATION_HOST_CODE.exec(text)?.[0]; if (code) { - return projectCreated + const base = projectCreated ? `工作区已创建;未能进入项目开发(${code});请从「已创建的工作区」打开后重试。` : `创建未完成(${code});请重试。`; + const detail = safeHomeCreationDetail(error, code); + return detail ? `${base}(${detail})` : base; } - return projectCreated + const base = projectCreated ? HOME_CREATION_AFTER_PROJECT_FAILURE : HOME_CREATION_UNCLASSIFIED_FAILURE; + return safeDetail ? `${base}(${safeDetail})` : base; } /** 包装成带证据的创建失败;控制器负责把 `diagnostic` 写进日志后再抛出。 */ diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts index f27558fb8..baff2889f 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts @@ -9,7 +9,10 @@ import { import { resolveTauriInvoke } from '../../app/tauri'; import type { LocalProjectDirectoryStatus, TauriInvoke } from '../../app/types'; -import { visibleClientErrorMessage } from '../../services/errorReporting'; +import { + clientErrorRawText, + visibleClientErrorMessage, +} from '../../services/errorReporting'; import { isAbsoluteProjectPath, projectPathHasControlCharacter, @@ -78,11 +81,12 @@ async function inspectRecentWorkspace( ]); return { path: workspace, status, retryable: true }; } catch (error) { - const message = visibleClientErrorMessage(error); + // 分类判据看**原始**正文:脱敏会把 `C:\a\b(DACL …)` 这类无空格路径连同后面的 DACL + // 一起吞成 ``,标记消失后提权失败会被误判成可重试,等于在用户点「否」后再弹一次 UAC。 return { path: workspace, status: null, - retryable: recentWorkspaceFailureIsRetryable(message), + retryable: recentWorkspaceFailureIsRetryable(clientErrorRawText(error)), }; } finally { if (timeoutHandle !== undefined) { diff --git a/apps/ai-game-creator-shell/src/features/plugins/PluginPanelHost.tsx b/apps/ai-game-creator-shell/src/features/plugins/PluginPanelHost.tsx index 98fc9d61b..f6fb4dc6c 100644 --- a/apps/ai-game-creator-shell/src/features/plugins/PluginPanelHost.tsx +++ b/apps/ai-game-creator-shell/src/features/plugins/PluginPanelHost.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import type { AgcPluginPanel } from '../../app/types'; import { ThemedModal } from '../../components/modal/ThemedModal'; -import { visibleClientErrorMessage } from '../../services/errorReporting'; +import { captureClientError } from '../../services/errorReporting'; import { readAgcPluginPanel } from '../../services/pluginHost'; export type PluginPanelHostProps = { @@ -32,8 +32,12 @@ export function PluginPanelHost({ if (active) setHtml(result.html); }, (error) => { - if (active) - setError(visibleClientErrorMessage(error, '插件面板加载失败')); + // 插件面板是不可信内容:原始正文(可能含私有路径)只进错误池,用户可见文案保持通用。 + void captureClientError(error, { + source: 'plugin-panel', + action: 'read_agc_plugin_panel', + }).catch(() => undefined); + if (active) setError('插件面板加载失败'); }, ); return () => { diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index 37ac949be..947de9f46 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -96,6 +96,11 @@ function clientErrorText(error: unknown): string { return String(error ?? ''); } +/** 未脱敏的原始错误正文:只给分类/判据用,禁止直接展示给用户。 */ +export function clientErrorRawText(error: unknown) { + return clientErrorText(error); +} + /** 用户可见的普通客户端错误正文:保留状态码/原因,替换凭据、URL、路径和长标识。 */ export function visibleClientErrorMessage( error: unknown, @@ -106,7 +111,9 @@ export function visibleClientErrorMessage( 0, 400, ); - return safe || fallback; + // `ClientAuthErrorWrapper` 把映射外的 Tauri 级失败序列化成 `{}`:这不是可展示的正文,落回兜底, + // 否则登录页会停在无解释的 `{}` 上(真 Error 的 message 由上报链路单独取用)。 + return safe && safe !== '{}' ? safe : fallback; } export async function captureClientError( diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 1be205dae..c16b87c6c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2553,10 +2553,12 @@ export function registerHomeProjectCreationTests() { }); fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' }); - // 工作区已经建好,就必须如实说「已创建」,不能覆盖成「创建未完成」;宿主原始文本 - // (这里是夹具里的英文原因)不上屏,只作为证据写进 application.log。 + // 工作区已经建好,就必须如实说「已创建」,不能覆盖成「创建未完成」;宿主原因保留脱敏后的 + // 正文(URL / 路径已替换),完整原文仍只作为证据写进 application.log。 expect( - await screen.findByText(HOME_CREATION_AFTER_PROJECT_FAILURE), + await screen.findByText((content) => + content.startsWith(HOME_CREATION_AFTER_PROJECT_FAILURE), + ), ).not.toBeNull(); expect(screen.getByText(`已创建的工作区:${projectPath}`)).not.toBeNull(); await waitFor(() => { diff --git a/apps/ai-game-creator-shell/tests/launcherNoticeShellWiring.test.tsx b/apps/ai-game-creator-shell/tests/launcherNoticeShellWiring.test.tsx index 6741e4ea3..793fb1c87 100644 --- a/apps/ai-game-creator-shell/tests/launcherNoticeShellWiring.test.tsx +++ b/apps/ai-game-creator-shell/tests/launcherNoticeShellWiring.test.tsx @@ -75,6 +75,7 @@ function renderWorkbench() { const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'get_local_game_preview_status') return null; if (command === 'inspect_local_project_directory') { return { projectPath: PROJECT_PATH, diff --git a/apps/ai-game-creator-shell/tests/platformSession.test.ts b/apps/ai-game-creator-shell/tests/platformSession.test.ts index 29273e89e..2bdf5b5d2 100644 --- a/apps/ai-game-creator-shell/tests/platformSession.test.ts +++ b/apps/ai-game-creator-shell/tests/platformSession.test.ts @@ -14,7 +14,7 @@ const refreshClientAuthSession = vi.hoisted(() => vi.fn()); vi.mock('../src/services/clientAuth', () => ({ refreshClientAuthSession, - subscribeClientAuthState: () => () => {}, + subscribeClientAuthState: async () => () => {}, })); import { diff --git a/apps/ai-game-creator-shell/tests/platformSessionRetry.test.ts b/apps/ai-game-creator-shell/tests/platformSessionRetry.test.ts index 834ea98ef..a6946dcf8 100644 --- a/apps/ai-game-creator-shell/tests/platformSessionRetry.test.ts +++ b/apps/ai-game-creator-shell/tests/platformSessionRetry.test.ts @@ -12,7 +12,7 @@ const refreshClientAuthSession = vi.hoisted(() => vi.fn()); vi.mock('../src/services/clientAuth', () => ({ refreshClientAuthSession, - subscribeClientAuthState: () => () => {}, + subscribeClientAuthState: async () => () => {}, })); import { diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index 0cb0a8e0f..c4e64b07c 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -1103,7 +1103,9 @@ describe('project resource live canvas integration', () => { within(retryPanel).getByRole('button', { name: 'AI 润色' }), ); expect( - await within(retryPanel).findByText('AI 润色失败,可重试'), + await within(retryPanel).findByText( + 'AI 润色失败,可重试:polish unavailable in this fixture', + ), ).not.toBeNull(); expect( within(retryPanel).getByLabelText('快速编辑提示词').textContent, diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationEntry.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationEntry.test.tsx index 1cc7f92a2..8634b5150 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationEntry.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationEntry.test.tsx @@ -297,7 +297,9 @@ describe('生成素材弹窗的提示词润色', () => { await user.type(prompt, '一段片头动画,镜头缓慢推进'); await user.click(screen.getByRole('button', { name: 'AI 润色' })); - expect(await screen.findByText('AI 润色失败,可重试')).not.toBeNull(); + expect( + await screen.findByText('AI 润色失败,可重试:platform llm timeout'), + ).not.toBeNull(); expect(prompt.value).toBe('一段片头动画,镜头缓慢推进'); expect(screen.queryByRole('button', { name: '恢复原文' })).toBeNull(); }); diff --git a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx index 08458f07a..a2baba904 100644 --- a/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceVersionReplacement.test.tsx @@ -790,7 +790,7 @@ describe('版本级资源替换', () => { await waitFor(() => expect( - screen.getByText('当前版本已不存在,请刷新项目后重试'), + screen.getByText(/当前版本已不存在,请刷新项目后重试/u), ).not.toBeNull(), ); expect(screen.queryByRole('dialog', { name: '选择替换素材' })).toBeNull(); @@ -1359,7 +1359,7 @@ describe('版本级资源替换', () => { // ② 不在权威候选里的素材:候选读取之后才登记的资源不属于这次替换目标,一律不放行 // (判据是"候选里有没有",不在前端另算一遍兼容性)。 await pickResourceCard('late.png'); - await waitFor(() => expect(reason()).toBe('替换素材未登记或已被删除')); + await waitFor(() => expect(reason()).toMatch(/^替换素材未登记或已被删除/u)); // ③ 分类不同的候选:目标在别的栏目,替换会话要跨栏目活着。 await openResourceBookCategory('场景与环境'); @@ -1379,7 +1379,7 @@ describe('版本级资源替换', () => { observer?.triggerVisible(); }); await pickResourceCard('草稿.png'); - await waitFor(() => expect(reason()).toBe('替换素材未登记或已被删除')); + await waitFor(() => expect(reason()).toMatch(/^替换素材未登记或已被删除/u)); expect(replacementWrites(invoke)).toHaveLength(0); });