diff --git a/apps/ai-game-creator-shell/src/app/tauri.ts b/apps/ai-game-creator-shell/src/app/tauri.ts index b1e3d4346..8a8cbd62d 100644 --- a/apps/ai-game-creator-shell/src/app/tauri.ts +++ b/apps/ai-game-creator-shell/src/app/tauri.ts @@ -1,3 +1,12 @@ -export function resolveTauriInvoke() { +/** + * AGC 调用 Rust 命令的唯一入口类型。全局 `window.__TAURI__.core.invoke` 由 Tauri 注入, + * 消费方一律从这个名字取类型,不要再写 `ReturnType`。 + */ +export type TauriInvoke = ( + command: string, + args?: Record, +) => Promise; + +export function resolveTauriInvoke(): TauriInvoke | undefined { return window.__TAURI__?.core?.invoke; } 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 b8f141ad3..93698a88e 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 @@ -7,7 +7,7 @@ import { useState, } from 'react'; -import { resolveTauriInvoke } from '../../app/tauri'; +import { resolveTauriInvoke, type TauriInvoke } from '../../app/tauri'; import type { LocalProjectDirectoryStatus } from '../../app/types'; import { isAbsoluteProjectPath, @@ -21,6 +21,17 @@ import { } from './model'; const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000; +/** + * 单次目录检查失败后的就地重试退避。Tauri IPC 或磁盘的瞬时抖动不该把整行钉成 + * 「检查失败」;只有持续失败才落成失败状态。 + */ +const RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS = [300]; +/** + * 一轮检查结束仍有失败行时,按退避重跑整张列表(重跑次数上限 = 数组长度)。 + * 失败不进终态:例如 IPC 响应链路卡住十几秒之后,列表必须能自己恢复, + * 而不是保持「检查失败」直到用户重启客户端。 + */ +const RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS = [15_000, 45_000, 120_000]; export function useRecentProjects(setStatus: Dispatch>) { const [recentWorkspaces, setRecentWorkspaces] = @@ -35,9 +46,11 @@ export function useRecentProjects(setStatus: Dispatch>) { const recentWorkspacesRef = useRef(recentWorkspaces); recentWorkspacesRef.current = recentWorkspaces; const inspectionGenerationRef = useRef(0); + const failureRecheckTimerRef = useRef(undefined); + const failureStreakRef = useRef(0); async function inspectRecentWorkspace( - invoke: NonNullable>, + invoke: TauriInvoke, workspace: string, ): Promise<[string, LocalProjectDirectoryStatus | null]> { let timeoutHandle: number | undefined; @@ -63,6 +76,28 @@ export function useRecentProjects(setStatus: Dispatch>) { } } + /** + * 一轮检查结束仍有失败行时,按退避重跑整张列表;连续无失败的一轮会把计数清零。 + * 这是「一次抖动不能把列表钉死」的兜底:即使无人操作,列表也会自己恢复。 + */ + function scheduleFailureRecheck(failedCount: number) { + window.clearTimeout(failureRecheckTimerRef.current); + if (failedCount === 0) { + failureStreakRef.current = 0; + return; + } + const recheckDelayMs = + RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS[failureStreakRef.current]; + if (recheckDelayMs === undefined) { + return; + } + failureStreakRef.current += 1; + failureRecheckTimerRef.current = window.setTimeout(() => { + failureRecheckTimerRef.current = undefined; + setRecentWorkspaceRefreshKey((current) => current + 1); + }, recheckDelayMs); + } + useEffect(() => { const invoke = resolveTauriInvoke(); if (!invoke || recentWorkspaces.length === 0) { @@ -72,16 +107,34 @@ export function useRecentProjects(setStatus: Dispatch>) { return; } const inspectionGeneration = ++inspectionGenerationRef.current; + // 单次目录检查失败就地重试:瞬时抖动不该把整行钉成「检查失败」。 + const inspectRecentWorkspaceWithRetry = async ( + workspace: string, + ): Promise<[string, LocalProjectDirectoryStatus | null]> => { + for (let attempt = 0; ; attempt += 1) { + const result = await inspectRecentWorkspace(invoke, workspace); + const retryDelayMs = RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS[attempt]; + if (result[1] || retryDelayMs === undefined) { + return result; + } + await new Promise((resolve) => { + window.setTimeout(resolve, retryDelayMs); + }); + } + }; let disposed = false; let pendingCount = recentWorkspaces.length; - // 保留已完成项目的最后一个独立结果。刷新是增量投影:只有新项目或 - // 尚未完成检查的项目显示“检查中”,不能因为另一个坏目录而把整张列表 - // 清空成同一个异常状态。 + let failedCount = 0; + // 刷新是增量投影:已完成项目的成功结果保留下来(不能因为另一个坏目录把整张 + // 列表清空成同一个异常状态),但上一轮的失败结果**不进新投影**——失败项回到 + // 「检查中」并重新检查。否则一次瞬时失败会把结果原样搬到下一轮,整行永久钉在 + // 「检查失败」上。 setRecentWorkspaceStatuses((current) => { const next: Record = {}; for (const workspace of recentWorkspaces) { - if (Object.prototype.hasOwnProperty.call(current, workspace)) { - next[workspace] = current[workspace] ?? null; + const status = current[workspace]; + if (status) { + next[workspace] = status; } } return next; @@ -89,7 +142,7 @@ export function useRecentProjects(setStatus: Dispatch>) { setRecentWorkspaceRefreshing(true); for (const workspace of recentWorkspaces) { - void inspectRecentWorkspace(invoke, workspace).then( + void inspectRecentWorkspaceWithRetry(workspace).then( ([projectPath, status]) => { if ( disposed || @@ -98,6 +151,9 @@ export function useRecentProjects(setStatus: Dispatch>) { ) { return; } + if (!status) { + failedCount += 1; + } setRecentWorkspaceStatuses((current) => ({ ...current, [projectPath]: status, @@ -105,12 +161,15 @@ export function useRecentProjects(setStatus: Dispatch>) { pendingCount -= 1; if (pendingCount === 0) { setRecentWorkspaceRefreshing(false); + scheduleFailureRecheck(failedCount); } }, ); } return () => { disposed = true; + window.clearTimeout(failureRecheckTimerRef.current); + failureRecheckTimerRef.current = undefined; }; }, [recentWorkspaces, recentWorkspaceRefreshKey]); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 57356ad13..3d7a55c46 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -4,7 +4,7 @@ import { CONVERSATION_INITIAL_VISIBLE_COUNT, CONVERSATION_VISIBLE_STEP, } from '../../../../app/constants'; -import { resolveTauriInvoke } from '../../../../app/tauri'; +import { resolveTauriInvoke, type TauriInvoke } from '../../../../app/tauri'; import type { ChatMessage, DirectTurnCancelView } from '../../../../app/types'; import { projectRuntimeVisibleError } from '../../../../features/agent-runtime'; import { uploadLocalFilesAsAttachments } from '../../../../features/app-shell/useHomeProjectCreation'; @@ -556,7 +556,7 @@ export function useDirectProjectChatController({ } async function runTurn( - invoke: NonNullable>, + invoke: TauriInvoke, nextProjectPath: string, input: DirectProjectTurnInput, ) { 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 e4607c0d0..e081afb8b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2915,7 +2915,8 @@ export function registerRecentProjectsTests() { expect(screen.getByText('不是文件夹')).not.toBeNull(); expect(screen.getAllByText('未初始化').length).toBeGreaterThan(0); expect(screen.getByText('无法读取')).not.toBeNull(); - expect(screen.getByText('检查失败')).not.toBeNull(); + // 目录检查失败会先就地重试一次(失败不进终态),因此这里等它落成最终的失败状态。 + expect(await screen.findByText('检查失败')).not.toBeNull(); expect(screen.getByText('厨房突围')).not.toBeNull(); expect(screen.getByText('已完成 · 预览运行中')).not.toBeNull(); expect( diff --git a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx index e564a6fca..e351f650a 100644 --- a/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx +++ b/apps/ai-game-creator-shell/tests/recentProjectsHook.test.tsx @@ -91,3 +91,75 @@ test('刷新坏项目时保留其它项目已确认的正常状态', async () => await pendingInspection; }); }); + +test('单次目录检查失败会就地重试,不会把整行钉成「检查失败」', async () => { + let attempts = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command !== 'inspect_local_project_directory') { + throw new Error(`unexpected invoke ${command}`); + } + attempts += 1; + if (attempts === 1) { + throw new Error('Tauri IPC 瞬时失败'); + } + return READY_PROJECT; + }, + ); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify(['/tmp/ready-project']), + ); + + const { result } = renderHook(() => useRecentProjects(vi.fn())); + + await waitFor(() => { + expect(result.current.projectRows[0]).toMatchObject({ + status: '本地项目', + canOpen: true, + }); + }); + expect(attempts).toBe(2); + expect(result.current.projectRows[0]?.status).not.toBe('检查失败'); +}); + +test('刷新时重新检查上一轮失败的项目,不沿用失败结果', async () => { + const inspectCounts: Record = {}; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command !== 'inspect_local_project_directory') { + throw new Error(`unexpected invoke ${command}`); + } + const projectPath = String(args?.projectPath ?? ''); + inspectCounts[projectPath] = (inspectCounts[projectPath] ?? 0) + 1; + if (projectPath === '/tmp/broken-project') { + throw new Error('Tauri IPC 持续失败'); + } + return { ...READY_PROJECT, projectPath }; + }, + ); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify(['/tmp/broken-project']), + ); + + const { result } = renderHook(() => useRecentProjects(vi.fn())); + + await waitFor(() => { + expect(result.current.projectRows[0]?.status).toBe('检查失败'); + }); + const attemptsAfterFirstRun = inspectCounts['/tmp/broken-project'] ?? 0; + expect(attemptsAfterFirstRun).toBeGreaterThan(1); + + act(() => { + result.current.rememberRecentWorkspace('/tmp/ready-project'); + }); + + await waitFor(() => { + expect(inspectCounts['/tmp/broken-project']).toBeGreaterThan( + attemptsAfterFirstRun, + ); + }); +});