From fab0e4034c5613068934c1606feb7734338673be Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Mon, 3 Aug 2026 15:48:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=A1=AE=E8=AE=A4=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=90=8E=E7=9A=84=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=AF=B9=E8=B4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:客户端确认 Quote 后只刷新一次任务,遇到短暂投影延迟时任务列表为空且流程静默成功。 修复:按 quoteId 有界重试任务列表,合并 REST 与实时推送快照,缺任务时显示明确错误;补充 Store 与页面回归测试。 --- .../image-workspace/works-square-workspace.ts | 3 ++ src/stores/image-workspace.ts | 41 +++++++++++++++- tests/unit/image-canvas-page.test.tsx | 2 + tests/unit/image-workspace-store.test.ts | 48 +++++++++++++++++++ 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/electron/image-workspace/works-square-workspace.ts b/electron/image-workspace/works-square-workspace.ts index 46f91b1..ce16d2d 100644 --- a/electron/image-workspace/works-square-workspace.ts +++ b/electron/image-workspace/works-square-workspace.ts @@ -580,6 +580,9 @@ function asErrorDetail(payload: unknown): ServerErrorDetail { } function userFacingErrorMessage(code: string, fallback: string): string { + if (code === 'generation_task_not_created') { + return '生成方案已确认,但任务创建失败,请刷新后重试'; + } const messages: Record = { workspace_not_found: '设计项目不存在或无权访问', workspace_revision_conflict: '设计项目已更新,请刷新后重试', diff --git a/src/stores/image-workspace.ts b/src/stores/image-workspace.ts index eac011c..f48387a 100644 --- a/src/stores/image-workspace.ts +++ b/src/stores/image-workspace.ts @@ -67,6 +67,8 @@ type ImageWorkspaceState = { let inFlightLoad: Promise | null = null; const TASK_FALLBACK_POLL_INTERVAL_MS = 15_000; +const CONFIRMED_TASK_RECONCILE_ATTEMPTS = 5; +const CONFIRMED_TASK_RECONCILE_INTERVAL_MS = 250; let activeTaskEventSource: EventSource | null = null; let activeTaskEventWorkspaceId: string | null = null; let taskStreamGeneration = 0; @@ -170,6 +172,18 @@ function sortTasks(tasks: DesignGenerationTask[]): DesignGenerationTask[] { )); } +function mergeTasks( + current: DesignGenerationTask[], + incoming: DesignGenerationTask[], +): DesignGenerationTask[] { + const tasksById = new Map(current.map((task) => [task.taskId, task])); + for (const task of incoming) { + const existing = tasksById.get(task.taskId); + if (!existing || existing.updatedAt <= task.updatedAt) tasksById.set(task.taskId, task); + } + return sortTasks([...tasksById.values()]); +} + function unavailable(error: unknown): boolean { return error instanceof ImageWorkspaceApiError && (error.status === 501 || error.code === IMAGE_WORKSPACE_UNAVAILABLE_CODE); @@ -598,7 +612,7 @@ export const useImageWorkspaceStore = create((set, get) => if (get().activeWorkspaceId === workspaceId && currentWorkspace?.workspaceId === workspaceId && currentWorkspace.viewRevision === requestedViewRevision) { - set({ tasks: sortTasks(tasks) }); + set((state) => ({ tasks: mergeTasks(state.tasks, tasks) })); for (const task of tasks) { const key = taskRevisionKey(workspaceId, task.taskId); taskEventRevisions.set( @@ -669,7 +683,30 @@ export const useImageWorkspaceStore = create((set, get) => ); if (get().activeWorkspaceId === workspace.workspaceId) { applyWorkspace(updated); - await get().refreshTasks().catch(() => []); + let confirmedTaskVisible = get().tasks.some((task) => task.quoteId === quoteId); + for ( + let attempt = 0; + !confirmedTaskVisible && attempt < CONFIRMED_TASK_RECONCILE_ATTEMPTS; + attempt += 1 + ) { + if (get().activeWorkspaceId !== workspace.workspaceId) return updated; + const refreshed = await get().refreshTasks(); + if (get().activeWorkspaceId !== workspace.workspaceId) return updated; + confirmedTaskVisible = refreshed.some((task) => task.quoteId === quoteId) + || get().tasks.some((task) => task.quoteId === quoteId); + if (!confirmedTaskVisible && attempt + 1 < CONFIRMED_TASK_RECONCILE_ATTEMPTS) { + await new Promise((resolve) => { + setTimeout(resolve, CONFIRMED_TASK_RECONCILE_INTERVAL_MS); + }); + } + } + if (!confirmedTaskVisible) { + throw new ImageWorkspaceApiError( + 502, + 'generation_task_not_visible', + '生成已确认,但任务列表同步超时。请刷新项目查看,不要重复确认。', + ); + } } return updated; } catch (error) { diff --git a/tests/unit/image-canvas-page.test.tsx b/tests/unit/image-canvas-page.test.tsx index f9acff5..dd58451 100644 --- a/tests/unit/image-canvas-page.test.tsx +++ b/tests/unit/image-canvas-page.test.tsx @@ -405,6 +405,7 @@ describe('ImageCanvas Workspace-first design experience', () => { .mockResolvedValueOnce([{ ...taskFixture, taskId: 'task-two', + quoteId: 'quote-one', status: 'queued', resultAssets: [], }]); @@ -421,6 +422,7 @@ describe('ImageCanvas Workspace-first design experience', () => { expect.stringMatching(/^turn-/), )); await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2)); + expect(await screen.findByTestId('design-task-task-two')).toBeInTheDocument(); }); it('renders a new task pushed by the design event stream without repeated polling', async () => { diff --git a/tests/unit/image-workspace-store.test.ts b/tests/unit/image-workspace-store.test.ts index a524bc8..6671223 100644 --- a/tests/unit/image-workspace-store.test.ts +++ b/tests/unit/image-workspace-store.test.ts @@ -14,6 +14,7 @@ const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn()); const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn()); const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn()); const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn()); +const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn()); vi.mock('@/lib/image-workspace', async (importOriginal) => { const actual = await importOriginal(); @@ -24,6 +25,9 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => { fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args), openImageWorkspaceTaskEvents: (...args: unknown[]) => openImageWorkspaceTaskEventsMock(...args), sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args), + confirmImageWorkspaceGeneration: (...args: unknown[]) => ( + confirmImageWorkspaceGenerationMock(...args) + ), }; }); @@ -144,6 +148,10 @@ describe('AI design task event store', () => { fetchImageWorkspaceProjectMock.mockResolvedValue(workspace()); fetchImageWorkspaceTasksMock.mockResolvedValue([task]); sendImageWorkspaceMessageMock.mockResolvedValue(workspace()); + confirmImageWorkspaceGenerationMock.mockResolvedValue({ + ...workspace('workspace-one', 2), + turnRevision: 2, + }); }); afterEach(() => { @@ -236,6 +244,46 @@ describe('AI design task event store', () => { await sending; }); + it('reconciles the confirmed Quote until its generation task is visible', async () => { + const source = new MockEventSource(); + openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource); + fetchImageWorkspaceTasksMock + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([task]); + + await useImageWorkspaceStore.getState().load(); + await useImageWorkspaceStore.getState().confirmGeneration('quote-one'); + + expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledWith( + 'workspace-one', + 1, + 'quote-one', + expect.stringMatching(/^turn-/), + ); + expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(3); + expect(useImageWorkspaceStore.getState().tasks).toEqual([task]); + }); + + it('does not silently complete confirmation when the quoted task stays missing', async () => { + const source = new MockEventSource(); + openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource); + fetchImageWorkspaceTasksMock.mockResolvedValue([]); + + await useImageWorkspaceStore.getState().load(); + vi.useFakeTimers(); + const outcome = useImageWorkspaceStore.getState().confirmGeneration('quote-one').then( + () => null, + (error: unknown) => error, + ); + await vi.runAllTimersAsync(); + + await expect(outcome).resolves.toMatchObject({ code: 'generation_task_not_visible' }); + expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(6); + expect(useImageWorkspaceStore.getState().error) + .toContain('任务列表同步超时'); + }); + it('closes the previous stream when switching workspaces and on reset', async () => { const first = new MockEventSource(); const second = new MockEventSource();