diff --git a/apps/ai-game-creator-shell/src/services/errorReporting.ts b/apps/ai-game-creator-shell/src/services/errorReporting.ts index 9b9af0027..cc82eb8f9 100644 --- a/apps/ai-game-creator-shell/src/services/errorReporting.ts +++ b/apps/ai-game-creator-shell/src/services/errorReporting.ts @@ -55,7 +55,13 @@ export async function invokeDiagnostic( } } -function normalizeDiagnosticText(value: string) { +/** + * 诊断文本脱敏:抹掉授权头、Bearer、api key / token、URL 与本地绝对路径、长十六进制 id。 + * + * 交互层的失败提示要保留原因时就复用它,别在业务文件里另写一套正则 —— + * 脱敏口径必须只有一份,否则"某条路径漏了"会随调用点漂移。 + */ +export function normalizeDiagnosticText(value: string) { return value .replace( /authorization\s*:\s*(?:bearer\s+)?\S+/giu, diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index 7e641046a..becf44ea8 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -10,6 +10,7 @@ import { isSafeProjectResourceCanvasCoordinate, isSafeProjectResourceCanvasLayoutRevision, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { normalizeDiagnosticText } from '../../services/errorReporting'; import { createEmptyResourceCanvasLayout, moveResourceCanvasPosition, @@ -19,6 +20,23 @@ import { } from './resourceCanvasLayoutModel'; import { normalizeResourceCanvasPosition } from './resourceCanvasSectionMapping'; +/** + * 布局落盘 / 读盘失败时保留一份**脱敏后的原因**。 + * + * 这两个 `.catch` 此前写成 `() => { ... }` —— 没有 message 参数,失败原因被整个吞掉: + * 用户只看到"布局保存失败",排障端也拿不到为什么(revision 越界?IPC 拒绝?权限?)。 + * 这里把 `error.message` 压成一行并复用既有的 `normalizeDiagnosticText`(路径 / token / + * URL / 长 id 一律打码),再截断,避免多行堆栈糊进日志;日志由客户端已安装的 + * webview→application.log 桥带进诊断日志,遇到"某个窗口保存一直失败"能直接查。 + */ +function describeResourceCanvasLayoutFailure(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return normalizeDiagnosticText(message.replace(/\s+/gu, ' ').trim()).slice( + 0, + 400, + ); +} + type LayoutNotice = | '' | '布局已保存' @@ -696,11 +714,16 @@ export function useProjectResourceCanvasLayout({ } rebuildOptimisticLayout(currentScope.epoch, writePolicy); }) - .catch(() => { + .catch((error: unknown) => { const currentScope = scopeRef.current; if (currentScope.epoch !== intent.scopeEpoch) { return; } + // 原因先落一行脱敏日志:下面那条提示在部分分支里是刻意不显示的, + // 若不记日志,这种"用户看不到、排障也看不到"的失败就完全消失。 + console.warn( + `[resource-canvas-layout] 布局保存失败:${describeResourceCanvasLayoutFailure(error)}`, + ); removeWriteIntent(intent); rebuildOptimisticLayout(currentScope.epoch, writePolicy); if (intent.kind === 'manual') { @@ -843,10 +866,14 @@ export function useProjectResourceCanvasLayout({ ); pumpWritesRef.current(); }) - .catch(() => { + .catch((error: unknown) => { if (cancelled || scopeRef.current.epoch !== epoch) { return; } + // 读盘失败同样是"只报一句、不报原因",这里保留脱敏后的一行原因。 + console.warn( + `[resource-canvas-layout] 布局读取失败:${describeResourceCanvasLayoutFailure(error)}`, + ); persistedLayoutRef.current = initialFallback; initializedScopeEpochRef.current = epoch; setReadyScopeKey(scopeKey); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index 5615c7a82..d0eb28f1a 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -1414,3 +1414,101 @@ describe('resource canvas layout read report', () => { ).toBe('打开项目时有 2 条坐标无法对齐已跳过(2 条分区与资源分类不匹配)'); }); }); + +describe('布局落盘 / 读盘失败的原因不再被吞掉', () => { + function warnMessages(warn: ReturnType) { + return warn.mock.calls.map((call) => String(call[0])); + } + + it('keeps the update failure reason in a sanitized log line', async () => { + // 写回失败此前是 `.catch(() => {})`:没有 message 参数,原因被整个吞掉。 + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const resourceA = resource('resource-a'); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('dependency', 1, [ + position('resource-a', 10, 20), + ]); + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: persistedLayout( + 'dependency', + GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1, + structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ), + ), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources: [resourceA], + }), + ); + await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10)); + + act(() => result.current.commitPosition('resource-a', 'document', 100, 30)); + await waitFor(() => + expect(result.current.notice).toBe('布局保存失败,已恢复上次布局'), + ); + + // 用户可见文案保持原样(不把内部原因塞进面板),原因落到日志里可查。 + const logged = warnMessages(warn).filter((message) => + message.includes('[resource-canvas-layout] 布局保存失败'), + ); + expect(logged).toHaveLength(1); + expect(logged[0]).toContain( + 'layout response revision or coordinates are invalid', + ); + }); + + it('sanitizes an IPC failure reason before logging it', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + // 原始错误里带本机绝对路径与 token:日志可以留原因,但不能把这两样带出去。 + const rawPath = 'C:\\Users\\someone\\workspace\\secret-project'; + const invoke = vi.fn(async (command: string) => { + if (command === 'read_local_project_resource_canvas_layout') { + // 带换行是为了证明日志被压成一行,不会把多行堆栈糊进去。 + throw new Error( + `读取布局失败:\n${rawPath}\\.agent\\layout.json token=abcdef123456`, + ); + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources: [resource('resource-a')], + }), + ); + await waitFor(() => + expect(result.current.notice).toBe('布局读取失败,已使用当前会话布局'), + ); + + const logged = warnMessages(warn).filter((message) => + message.includes('[resource-canvas-layout] 布局读取失败'), + ); + expect(logged).toHaveLength(1); + expect(logged[0]).toContain('读取布局失败'); + expect(logged[0]).not.toContain('someone'); + expect(logged[0]).not.toContain('abcdef123456'); + expect(logged[0]).toContain(''); + // 压成一行:不许把多行堆栈糊进日志。 + expect(logged[0]).not.toContain('\n'); + }); +});