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); });