From f2a3ba7aca5c4022ca1427faea3edf26a4caac52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 21 Sep 2026 00:17:05 +0800 Subject: [PATCH] =?UTF-8?q?=E9=9F=B3=E9=A2=91=E6=8F=90=E4=BA=A4=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E6=8C=89=E4=BB=BB=E5=8A=A1=20id=20=E5=90=84?= =?UTF-8?q?=E8=87=AA=E7=95=99=E5=AD=98=EF=BC=8C=E9=87=8D=E5=8F=A0=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E4=B8=8D=E5=86=8D=E4=BA=92=E7=9B=B8=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `resourceGenerationAudioSubmissionRef` 从单槽改成按任务 id 索引的 Map(键是 task id,值里 不再重复存 taskId) - 提交写入改成 `set(task.taskId, …)`,收口读取改成 `get(settlement.taskId)` + `delete`,切项目 改成 `clear()` - 原先两条音频提交重叠时,后一次提交会覆盖前一条的提交上下文,前一条「后端从未受理」的即时 失败因此找不到自己的恢复上下文,只走通用失败提示、丢掉「面板连原草稿带回来」 - 新增宿主用例「两条音频提交重叠:第一条未被受理时,它的面板仍会被带回来」:给测试宿主加 `holdFirstAssetGenerationStart` 开关,把第一次派发挂在受理前,复现两条提交重叠 --- .../src/view/project-development/index.tsx | 37 +++++++----- ...urceCanvasGenerationHostLifecycle.test.tsx | 56 +++++++++++++++++++ 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 030ea1f01..29414c29e 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -2080,18 +2080,26 @@ export default function ProjectDevelopmentView({ new Map(), ); /** - * 音频提交上下文:这次提交属于哪张占位。 + * 音频提交上下文:这次提交属于哪张占位。按**任务 id** 各自一条。 * * 只有一件事要用它——**后端从未受理**的即时失败要把面板连原草稿、原请求身份带回来;受理 * 之后才失败的收口只看账本,不需要这条本地上下文。`dispatchedImmediately` 沿用图片类口径: * 排在队列后面才派发的任务即使失败也不弹面板打断用户。 + * + * 用 Map 而不是单槽:队列串行派发,第二张占位可以在第一条还在途时提交(它排在本地队列里), + * 单槽会被后一次提交覆盖,于是前一条「从未受理」的即时失败找不到自己的恢复上下文,只剩通用 + * 失败提示、丢掉把面板连草稿带回来这一步。 */ - const resourceGenerationAudioSubmissionRef = useRef<{ - taskId: string; - draftId: string; - kind: ResourceCanvasGenerationKind; - dispatchedImmediately: boolean; - } | null>(null); + const resourceGenerationAudioSubmissionRef = useRef( + new Map< + string, + { + draftId: string; + kind: ResourceCanvasGenerationKind; + dispatchedImmediately: boolean; + } + >(), + ); /** 用户主动收起浮层时留下的可再编辑草稿(按草稿 ID 记,提交成功后清掉)。 */ const resourceGenerationDraftRef = useRef( new Map< @@ -7860,12 +7868,11 @@ export default function ProjectDevelopmentView({ idempotencyKey: input.idempotencyKey, prompt: input.prompt, }); - resourceGenerationAudioSubmissionRef.current = { - taskId: task.taskId, + resourceGenerationAudioSubmissionRef.current.set(task.taskId, { draftId, kind: input.kind, dispatchedImmediately, - }; + }); resourceGenerationDraftRef.current.set(draftId, { kind: input.kind, prompt: input.prompt, @@ -7959,9 +7966,11 @@ export default function ProjectDevelopmentView({ // 账本已经把结果写在它自己的项目里,这里不再动当前项目的状态。 return; } - const audioSubmission = resourceGenerationAudioSubmissionRef.current; - if (audioSubmission?.taskId === settlement.taskId) { - resourceGenerationAudioSubmissionRef.current = null; + const audioSubmission = resourceGenerationAudioSubmissionRef.current.get( + settlement.taskId, + ); + if (audioSubmission) { + resourceGenerationAudioSubmissionRef.current.delete(settlement.taskId); if ( settlement.status === 'failed' && settlement.record === null && @@ -8249,7 +8258,7 @@ export default function ProjectDevelopmentView({ setResourceAssetGenerationPanel(null); // 收起时的草稿与音频提交身份同样是本项目内的记忆,换项目一并作废。 resourceGenerationAudioRequestRef.current.clear(); - resourceGenerationAudioSubmissionRef.current = null; + resourceGenerationAudioSubmissionRef.current.clear(); resourceGenerationDraftRef.current.clear(); resourceAssetGenerationDraftRef.current.clear(); void (async () => { diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx index 734def7ba..351f79175 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationHostLifecycle.test.tsx @@ -102,6 +102,13 @@ function installHostTauri(options: { * 所以「未受理」与「已受理」两条路能在同一条用例里对照。 */ assetGenerationStartError?: string; + /** + * 让**第一次** `start_local_project_asset_generation` 挂在半空(受理前)。 + * + * 用来复现「两条音频提交重叠」:第一条还在等受理时提交第二条,第二条会排在本地队列里。 + * 用例用返回的 `releaseHeldAssetGenerationStart()` 决定第一条什么时候收到结果。 + */ + holdFirstAssetGenerationStart?: boolean; }) { const layoutWrites: LayoutWrite[] = []; const deriveCalls: Array> = []; @@ -114,6 +121,7 @@ function installHostTauri(options: { const assetGenerationStartError = { current: options.assetGenerationStartError, }; + let releaseHeldStart: (() => void) | null = null; const positions: ProjectResourceCanvasPosition[] = []; const invoke = vi.fn( @@ -192,7 +200,13 @@ function installHostTauri(options: { } if (command === 'start_local_project_asset_generation') { const startArgs = args ?? {}; + const isFirstStart = assetGenerationStarts.length === 0; assetGenerationStarts.push(structuredClone(startArgs)); + if (options.holdFirstAssetGenerationStart && isFirstStart) { + await new Promise((resolve) => { + releaseHeldStart = resolve; + }); + } if (assetGenerationStartError.current) { throw new Error(assetGenerationStartError.current); } @@ -257,6 +271,7 @@ function installHostTauri(options: { deriveCalls, assetGenerationStarts, assetGenerationStartError, + releaseHeldAssetGenerationStart: () => releaseHeldStart?.(), unexpectedCommands, /** * 后台任务收口为完成:账本里那条记录变成 `completed` + `assetId`,素材同时进清单。 @@ -672,6 +687,47 @@ describe('画布生成入口的宿主生命周期', () => { await waitFor(() => expect(floatingPanel()).toBeNull()); }, 20_000); + test('两条音频提交重叠:第一条未被受理时,它的面板仍会被带回来', async () => { + const tauri = installHostTauri({ + assets: [seedBgmAsset('seed-bgm')], + assetGenerationStartError: '项目权限策略拒绝执行:asset.register', + holdFirstAssetGenerationStart: true, + }); + render(); + await settle(); + + // 第一条:背景音乐。派发挂在「受理」上,队列还没拿到它的终局。 + const { draftId } = await openBgmEntry(); + fireEvent.click(panelSubmitButton('生成背景音乐')); + await settle(); + expect(tauri.assetGenerationStarts).toHaveLength(1); + + // 第二条:音效(另一张占位)。第一条还在途,所以它排在本地队列里,还没有发 IPC。 + await openAudioTool('生成音效'); + fireEvent.change( + floatingPanel()?.querySelector('textarea') as HTMLTextAreaElement, + { target: { value: '木门缓慢推开的吱呀声' } }, + ); + fireEvent.click(panelSubmitButton('生成音效')); + await settle(); + expect(tauri.assetGenerationStarts).toHaveLength(1); + + /* + 放开第一条:它这次从未被受理。提交上下文按**任务 id** 各自留存,所以后来的第二条不许把 + 第一条的恢复上下文挤掉——面板必须连原草稿回到第一张占位。 + */ + await act(async () => { + tauri.releaseHeldAssetGenerationStart(); + await Promise.resolve(); + }); + await waitFor(() => expect(floatingPanel()).not.toBeNull()); + expect(floatingPanelPrompt()?.value ?? '').toBe('一段平静的夜晚钢琴曲'); + expect( + placeholderElement(draftId)?.dataset + .resourceCanvasGenerationPlaceholderStatus, + ).toBe('failed'); + }, 20_000); + test('失败后用同一份请求重试:复用同一个 operationId 与幂等键', async () => { const tauri = installHostTauri({ assets: [seedBgmAsset('seed-bgm')],