From 2f47540eebeb1935e44da35394c9e2c8cf64eb6d Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 14 Sep 2026 18:20:50 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(agc):=20=E3=80=8C=E5=AE=9A=E4=BD=8D?= =?UTF-8?q?=E5=88=B0=E7=B4=A0=E6=9D=90=E3=80=8D=E6=AF=8F=E6=AC=A1=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E9=83=BD=E7=BB=88=E5=B1=80=E5=8C=96=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=8D=A1=E5=9C=A8=E3=80=8C=E6=AD=A3=E5=9C=A8=E5=AE=9A?= =?UTF-8?q?=E4=BD=8D=E7=94=9F=E6=88=90=E7=9A=84=E7=B4=A0=E6=9D=90=E2=80=A6?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.tsx 新增 `resourceAssetGenerationFocusRequest` 序号并加进聚焦 effect 的依赖:手动点定位不改画布 任何状态,原依赖一个都不变 → effect 不重跑、intent 永远没人消费,这正是「点了没反应 + 提示条永久 停在中转文案」的根因 - index.tsx `focusResourceAssetGenerationTask` 重写:先按当前投影与清单判一次目标——不在投影里就当场给 结论(素材已不在项目里 / 已登记但尚未同步),不挂 intent 也不留中转提示;在别的栏目先切栏目再定位; 挂 intent 后推进聚焦请求序号,并起一个 3 秒有界兜底:仍停在中转文案就收口成可执行提示,intent 被判 invalid 被静默清掉时也给一条「定位请求已失效」的结论,绝不留下悬而未决的状态 - index.tsx 聚焦 effect 的 `focusedCommitIdsRef` 提前返回分支补 `setResourceWorkbenchNotice('')`: 已聚焦过时把中转提示一并收掉,不再永久留在提示条上(自动落卡那条链同源问题) - index.tsx `resourceAssetGenerationContextRef` 扩到带 `manifest` / `resources` / `activePageCategory` 并在每次渲染刷新:定位回调据此取权威目标与栏目,不再依赖可能过期的闭包值 - 新增 4 条 AppSurface 用例(同栏目直点定位并选中卡片、素材在另一个栏目先切栏目再定位、被搜索挡住时给出 既有「清除搜索并定位」、素材不在项目里时给明确结论且清掉中转提示) --- .../src/view/project-development/index.tsx | 99 +++++++- .../appSurface/project-development.suite.ts | 214 ++++++++++++++++++ 2 files changed, 305 insertions(+), 8 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 4a298bdb2..d72e6e3aa 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 @@ -1577,6 +1577,17 @@ export default function ProjectDevelopmentView({ const resourceAssetGenerationPanelTaskIdRef = useRef(null); const [resourceAssetGenerationTasksPanelOpen, setResourceAssetGenerationTasksPanelOpen] = useState(false); + /** + * 「定位到素材」的聚焦请求序号。 + * + * 聚焦 effect(`resolveResourceFocusIntent` 那条链)的依赖全是画布自身状态,手动点一次定位 + * 不改其中任何一项 → effect 不会重跑,intent 永远没人消费、提示条停在中转文案上。所以每次 + * 点击都要推进这个序号,让「这次请求」成为一个真实的依赖变化。 + */ + const [resourceAssetGenerationFocusRequest, setResourceAssetGenerationFocusRequest] = + useState(0); + /** 提示条文案的 ref 版:有界兜底要判断此刻是否还停在中转文案上。 */ + const resourceWorkbenchNoticeRef = useRef(''); const [resourceBottomToolbarUploading, setResourceBottomToolbarUploading] = useState(false); const [resourcePanelNotice, setResourcePanelNotice] = useState(''); @@ -5561,6 +5572,9 @@ export default function ProjectDevelopmentView({ } if (focusedCommitIdsRef.current.has(intent.commitId)) { pendingResourceFocusRef.current = null; + // 这条资源已经聚焦过了:把中转提示一并收掉,否则「生成资源已保存,正在同步资源与布局…」 + // 这类文字会永久留在提示条上。 + setResourceWorkbenchNotice(''); return; } intent.completed = true; @@ -5578,6 +5592,9 @@ export default function ProjectDevelopmentView({ manifest.projectId, projectPath, resources, + // 手动「定位到素材」不改画布任何状态,靠这个序号把「这次定位请求」变成真实的依赖变化; + // 少了它 effect 不会重跑,intent 永远没人消费。 + resourceAssetGenerationFocusRequest, selectResourceCanvasPage, typeLayout.layout.positions, typeLayout.settled, @@ -6671,13 +6688,20 @@ export default function ProjectDevelopmentView({ projectId: manifest.projectId, hasIconSpecReference, onManifestChange, + manifest, + resources, + activePageCategory, }); resourceAssetGenerationContextRef.current = { projectPath, projectId: manifest.projectId, hasIconSpecReference, onManifestChange, + manifest, + resources, + activePageCategory, }; + resourceWorkbenchNoticeRef.current = resourceWorkbenchNotice; const replaceResourceAssetGenerationTask = useCallback( (next: ResourceCanvasAssetGenerationTask) => { @@ -6908,32 +6932,91 @@ export default function ProjectDevelopmentView({ }; }, [manifest.projectId, projectPath]); - /** 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。 */ + /** + * 「生成任务」面板里点一条已完成任务:复用既有聚焦链定位到它的素材卡。 + * + * **每次点击都必须终局化**:素材不在投影里 / 不在当前栏目 / 被搜索挡住 / 画布还在布局,四种情况 + * 各有结论,不允许留下悬而未决的 intent 与中转提示。所以这里做三件事: + * + * 1. 先按当前投影与清单判一次「这次点击有没有可定位的目标」——没有就直接给可执行结论, + * 连 intent 都不挂(挂上去也没人消费); + * 2. 有目标就挂 intent,并推进 `resourceAssetGenerationFocusRequest`:聚焦 effect 的依赖全是 + * 画布自身状态,不推进这个序号时 effect 不会重跑,点了等于没点(这正是「点了没反应且提示条 + * 永久停在中转文案」的根因); + * 3. 起一个有界兜底:3 秒后仍停在中转文案就收口成可执行提示,绝不把中转态留给用户。 + */ const focusResourceAssetGenerationTask = useCallback( (task: ResourceCanvasAssetGenerationTask) => { - if (!task.assetId) { + const assetId = task.assetId; + if (!assetId) { return; } const context = resourceAssetGenerationContextRef.current; + const resourceId = `asset:${assetId}`; + const target = context.resources.find( + (resource) => resource.id === resourceId, + ); + const locateNotice = '正在定位生成的素材…'; + if (!target) { + // 不在投影里:还没同步到画布,或者素材已经不在项目里。两种都当场给结论, + // 不放 intent 也不留中转提示。 + pendingResourceFocusRef.current = null; + setResourceWorkbenchNotice( + (context.manifest.assets ?? []).some((asset) => asset.id === assetId) + ? '素材已登记但尚未同步到画布,请稍候重试' + : '素材已不在项目里(可能已被删除)', + ); + return; + } // 手动定位不能复用自动落卡那条 commitId:`focusedCommitIdsRef` 会把同一个 commitId 记为 // 「已聚焦」,重复点同一条任务就会静默失效,所以这里用一次一点击的 flowId。 const flowId = `asset-generation-focus:${task.taskId}:${Date.now()}`; activeFocusFlowIdRef.current = flowId; pendingResourceFocusRef.current = { flowId, - saveAttemptId: task.assetId, - sessionId: task.assetId, - draftId: task.assetId, + saveAttemptId: assetId, + sessionId: assetId, + draftId: assetId, commitId: flowId, projectPath: context.projectPath, projectId: context.projectId, focusGeneration: focusGenerationRef.current, - resourceId: `asset:${task.assetId}`, + resourceId, completed: false, }; - setResourceWorkbenchNotice('正在定位生成的素材…'); + if (target.category !== context.activePageCategory) { + // 素材在别的栏目:先切过去(切栏目本身就是 effect 的依赖变化),再让聚焦链在那边定位。 + selectResourceCanvasPage(target.category); + } + setResourceWorkbenchNotice(locateNotice); + setResourceAssetGenerationFocusRequest((current) => current + 1); + window.setTimeout(() => { + if (focusedCommitIdsRef.current.has(flowId)) { + // 真的聚焦过了。 + return; + } + const pending = pendingResourceFocusRef.current; + if (pending?.flowId === flowId) { + if (resourceWorkbenchNoticeRef.current !== locateNotice) { + // 聚焦链已经给出别的结论(例如「被当前搜索条件隐藏」+「清除搜索并定位」)。 + return; + } + pendingResourceFocusRef.current = null; + setResourceWorkbenchNotice( + '未能定位到素材:画布可能仍在布局或素材暂不可见,请稍后重试', + ); + return; + } + if (resourceWorkbenchNoticeRef.current === '') { + // intent 被判 invalid(项目 / 画布已切换)时聚焦链会清掉 intent 与提示:手动点击 + // 不能静默丢弃,给一条能解释「为什么没动」的结论。 + setResourceWorkbenchNotice( + '定位请求已失效(项目或画布已切换),请重新点击定位', + ); + } + }, 3_000); }, - [], + [selectResourceCanvasPage], ); /** 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。 */ diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index d95603460..68a2f43e9 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -10898,6 +10898,220 @@ export function registerProjectAgentStatusTests() { ).not.toBeNull(); }, 20_000); + /** + * 「定位到素材」的公共夹具:项目里有一个 character 资产 + 一条已完成、指向它的生成任务。 + * + * `openCategory` 决定先停在哪个栏目:停在别的栏目就能覆盖「素材在另一个栏目」这条分支。 + */ + async function renderGenerationLocateView(input: { + projectId: string; + projectPath: string; + openCategory: string; + assetId: string; + ledgerRecords: Record[]; + }) { + const manifest = createGameCreationAppManifest( + input.projectId, + '生成任务定位测试', + ); + manifest.assets = [ + { + id: input.assetId, + kind: 'character', + mediaType: 'image/png', + localPath: 'assets/locate-target.png', + source: { kind: 'canvas', resourceId: 'locate-target-resource' }, + }, + ]; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: input.projectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'list_local_project_asset_generations') { + return input.ledgerRecords; + } + if (command === 'get_local_game_project_revision') { + return { revision: 7 }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: input.projectPath, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + await openResourceBookCategory(input.openCategory); + fireEvent.click(screen.getByRole('button', { name: '生成任务' })); + return screen.findByRole('region', { name: '生成任务' }); + } + + function completedGenerationRecord(input: { + taskId: string; + assetId: string | null; + projectId: string; + }) { + return { + taskId: input.taskId, + projectId: input.projectId, + kind: 'character', + assetName: '定位目标素材', + status: 'completed', + phaseDetail: '生成已完成。', + createdAtMillis: 1, + startedAtMillis: 1, + finishedAtMillis: 2, + assetId: input.assetId, + error: null, + }; + } + + it('locates a generated asset that lives in another column instead of leaving the notice pending', async () => { + const panel = await renderGenerationLocateView({ + projectId: 'workbench-locate-other-column', + projectPath: '/tmp/workbench-locate-other-column', + // 停在 UI 交互栏目:目标素材在 character 栏目。 + openCategory: 'UI 交互', + assetId: 'locate-other-column-asset', + ledgerRecords: [ + completedGenerationRecord({ + taskId: 'task-locate-other-column', + assetId: 'locate-other-column-asset', + projectId: 'workbench-locate-other-column', + }), + ], + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '定位素材 定位目标素材' }), + ); + + // 悬而未决的中转提示必须消失,并且真的切到目标素材所在栏目。 + await waitFor(() => + expect(screen.queryByText('正在定位生成的素材…')).toBeNull(), + ); + await waitFor(() => + expect( + document.querySelector( + '.game-resource-book-scene-titlebar.is-active[data-resource-book-category="character"]', + ), + ).not.toBeNull(), + ); + }, 20_000); + + it('focuses a generated asset that is already in the current column', async () => { + // 这条是「点了没反应」的最小复现:不切栏目、不搜索,画布状态一个都不变, + // 只靠聚焦请求序号让 effect 重跑。 + const panel = await renderGenerationLocateView({ + projectId: 'workbench-locate-same-column', + projectPath: '/tmp/workbench-locate-same-column', + openCategory: '角色与对象', + assetId: 'locate-same-column-asset', + ledgerRecords: [ + completedGenerationRecord({ + taskId: 'task-locate-same-column', + assetId: 'locate-same-column-asset', + projectId: 'workbench-locate-same-column', + }), + ], + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '定位素材 定位目标素材' }), + ); + + await waitFor(() => + expect( + document.querySelector( + '.game-resource-card-select[data-resource-id="asset:locate-same-column-asset"][aria-pressed="true"]', + ), + ).not.toBeNull(), + ); + expect(screen.queryByText('正在定位生成的素材…')).toBeNull(); + }, 20_000); + + it('surfaces the existing clear-search action when the generated asset is filtered out', async () => { + const panel = await renderGenerationLocateView({ + projectId: 'workbench-locate-hidden', + projectPath: '/tmp/workbench-locate-hidden', + openCategory: '角色与对象', + assetId: 'locate-hidden-asset', + ledgerRecords: [ + completedGenerationRecord({ + taskId: 'task-locate-hidden', + assetId: 'locate-hidden-asset', + projectId: 'workbench-locate-hidden', + }), + ], + }); + + // 用搜索条件把目标素材挡掉:筛选面板的关键词就是画布唯一的搜索入口。 + fireEvent.keyDown(window, { key: 'f', ctrlKey: true }); + fireEvent.change(screen.getByLabelText('查找素材'), { + target: { value: 'zzz-no-such-resource' }, + }); + fireEvent.keyDown(document, { key: 'Escape' }); + + fireEvent.click( + within(panel).getByRole('button', { name: '定位素材 定位目标素材' }), + ); + + await waitFor(() => + expect(screen.queryByText('正在定位生成的素材…')).toBeNull(), + ); + expect(screen.getByRole('button', { name: '清除搜索并定位' })).not.toBeNull(); + }, 20_000); + + it('settles a locate request whose asset is not in the project at all', async () => { + const panel = await renderGenerationLocateView({ + projectId: 'workbench-locate-missing', + projectPath: '/tmp/workbench-locate-missing', + openCategory: '角色与对象', + assetId: 'locate-missing-asset', + ledgerRecords: [ + completedGenerationRecord({ + taskId: 'task-locate-missing', + assetId: 'asset-that-no-longer-exists', + projectId: 'workbench-locate-missing', + }), + ], + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '定位素材 定位目标素材' }), + ); + + expect( + await screen.findByText('素材已不在项目里(可能已被删除)'), + ).not.toBeNull(); + expect(screen.queryByText('正在定位生成的素材…')).toBeNull(); + }, 20_000); + it('routes the audio column entries to the existing audio generation chain', async () => { const manifest = createGameCreationAppManifest( 'workbench-bottom-toolbar-audio', From 14a0286f70303d08afac79b594e66db198c744f1 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 14 Sep 2026 18:35:11 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(agc):=20=E7=94=9F=E6=88=90=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E7=82=B9=E5=87=BB=E5=8D=B3=E5=85=B3=E9=97=AD=E3=80=81?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E5=86=85=E4=B8=8D=E5=86=8D=E5=87=BA=E7=8E=B0?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E6=96=87=E6=A1=88=EF=BC=8C=E5=8F=AA=E6=9C=89?= =?UTF-8?q?=E5=8D=B3=E6=97=B6=E5=A4=B1=E8=B4=A5=E6=89=8D=E6=8A=8A=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E5=B8=A6=E5=9B=9E=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ResourceCanvasAssetGenerationPanelView:删掉 `phaseDetail` 通道与整套在途状态(`submitting` / 禁用输入 / 「后台运行并关闭」),主按钮文案恒为动作名;提交时**同步**调用 onSubmit 并 onClose, 面板不等 IPC、不等排队、不等生成。新增 `draft` / `error` 两个入参:只有「后端从未受理」的即时 失败重开时,宿主才把草稿与原因带回来(`role="alert"` 可见、改完即可重试) - index.tsx:`submitResourceAssetGeneration` 改为同步返回(不再 await 终局),记录本次提交上下文 (taskId / action / 草稿 / 是否本来就该立刻派发),提交后自动开任务面板并给一次提示条; 收尾回调里区分两类失败:`record === null` 且本来就该立刻派发 → 重开提交面板并带回草稿; 受理之后才失败 → 不重开面板,只在任务面板收口为失败 + 提示条 - index.tsx:删掉只为旧「在途阶段文案」服务的 `resourceAssetGenerationPanelTaskIdRef` 与成功后的 自动收面板逻辑(面板此时已关闭) - 测试:`resourceCanvasAssetGenerationBackgroundClose.test.tsx` 的图片类用例改写为「点击即关闭 + 面板 DOM 里从不出现 排队中。/正在生成。/提交中…」+「即时失败重开带回草稿与原因」;音频面板用例不变 - 测试:`resourceCanvasBottomToolbar.test.tsx` 的失败重试用例按新语义改写(点击即关闭、重开时显示原因) - 测试:AppSurface 新增三条(同步关闭且面板无阶段文案、后端未受理时重开并保留草稿、受理后失败不重开 只在任务面板收口),并把既有生成任务 e2e 的提交步骤改为「点击即关闭」 - 顺带按仓库规范修正以上文件的 eslint import sort 与 prettier 格式(未触碰无关文件) --- ...ResourceCanvasAssetGenerationPanelView.tsx | 102 ++++---- ...rceCanvasAssetGenerationTasksPanelView.tsx | 8 +- .../resourceCanvasAssetGenerationQueue.ts | 11 +- .../resourceCanvasAssetGenerationTaskModel.ts | 7 +- .../src/view/project-development/index.tsx | 172 +++++++++---- .../appSurface/project-development.suite.ts | 238 ++++++++++++++++-- ...vasAssetGenerationBackgroundClose.test.tsx | 124 +++++---- ...resourceCanvasAssetGenerationQueue.test.ts | 25 +- ...ceCanvasAssetGenerationTasksPanel.test.tsx | 10 +- .../resourceCanvasBottomToolbar.test.tsx | 49 +++- 10 files changed, 530 insertions(+), 216 deletions(-) diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index d9443353b..5c36c3d56 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -22,34 +22,47 @@ export type ResourceCanvasAssetGenerationSubmitInput = { imageSize: string; }; +/** 提交面板的草稿:点击即关闭之后,只有「即时失败」重开时才需要把这份草稿带回来。 */ +export type ResourceCanvasAssetGenerationPanelDraft = { + prompt: string; + assetName: string; + aspectRatio: string; + imageSize: string; +}; + export type ResourceCanvasAssetGenerationPanelViewProps = { action: ResourceCanvasAssetToolAction; /** - * 在途阶段文案,**由后端任务账本提供**(`phaseDetail`)。 + * 上一次「点击瞬间就失败」带回来的草稿。 * - * 面板不自己编进度:派发前是本地排队的「排队中。」,派发后是后端的「正在生成。」之类。 + * 面板点击即关闭,草稿只活在组件里;重开时由宿主把它传回来,用户改完就能重试。 */ - phaseDetail?: string | null; - onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => Promise; + draft?: ResourceCanvasAssetGenerationPanelDraft; + /** 上一次即时失败的原因;重开时直接以 `role="alert"` 呈现。 */ + error?: string | null; + /** + * 提交回调:**同步返回**,面板不等它的结果。 + * + * 受理失败要不要把面板带回来由宿主决定(只有「从未被后端受理」的即时失败才重开), + * 面板自己不持有任何在途状态。 + */ + onSubmit: (input: ResourceCanvasAssetGenerationSubmitInput) => void; onClose: () => void; }; -function assetGenerationErrorMessage(error: unknown) { - if (typeof error === 'string' && error.trim()) return error; - if (error instanceof Error && error.message) return error.message; - return '生成素材失败'; -} - /** * 栏目画布底部工具栏的图片类生成浮层(生成图片 / 生成规范 / 生成角色形象 / 生成图标素材 / * 生成 UI 设计图共用)。 * * 形态是独立弹层(`ThemedModal`,与既有「生成素材」面板同一套宿主 chrome),不在任何现有 - * 面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责,面板只持有草稿与失败状态。 + * 面板下面追加内容;提交链路与结果定位由宿主 `index.tsx` 负责。 * - * 生成已经在后端后台跑(`start_local_project_asset_generation` + 项目内任务账本),所以 - * **提交期间面板必须能关**:× / 遮罩 / Esc / 「后台运行并关闭」四条路径都通,关闭只是把这一份 - * view 卸下来,请求继续在后台跑完并把结果写回项目——关闭**不等于**取消。 + * **点击「生成」即关闭面板**:不等 IPC、不等排队、不等生成结束,用户立刻回到画布。所以面板里 + * 不存在「排队中。」「正在生成。」「提交中…」这类阶段文案——阶段文案的唯一去处是画布上的 + * 「生成任务」侧栏与工具栏提示条。关闭**不等于**取消:任务照常在后台跑完并把结果写回项目。 + * + * 只有「点击瞬间就失败」(校验 / 权限拒绝 / IPC 立即报错,即后端从未受理)时,宿主才会带着 + * `draft` 与 `error` 把面板重新打开,用户可以直接改后重试。 * * 比例 / 尺寸选项来自网页端美术画布的纯模型(`ImageCanvasGenerationModel.ts`)经本地 IPC * 白名单收窄后的子集:网页端面板会渲染 `4:3`,而本地通道明确拒绝它,照搬就是一个点了必 @@ -58,45 +71,45 @@ function assetGenerationErrorMessage(error: unknown) { */ export function ResourceCanvasAssetGenerationPanelView({ action, - phaseDetail, + draft, + error: initialError, onSubmit, onClose, }: ResourceCanvasAssetGenerationPanelViewProps) { - const [prompt, setPrompt] = useState(''); - const [assetName, setAssetName] = useState(action.assetName); - const [aspectRatio, setAspectRatio] = useState(action.aspectRatio); - const [imageSize, setImageSize] = useState(action.imageSize); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); + const [prompt, setPrompt] = useState(draft?.prompt ?? ''); + const [assetName, setAssetName] = useState( + draft?.assetName ?? action.assetName, + ); + const [aspectRatio, setAspectRatio] = useState( + draft?.aspectRatio ?? action.aspectRatio, + ); + const [imageSize, setImageSize] = useState( + draft?.imageSize ?? action.imageSize, + ); + const [error, setError] = useState(initialError ?? null); // 提示词上限复用资源编辑模型的同一份口径:图片类入口默认 32000,与 Rust // `LOCAL_PROJECT_ASSET_MAX_PROMPT_CHARS` 一致,不在面板里另抄常量。 const promptMaxLength = resourceEditPromptMaxLength('image-reference'); - const canSubmit = - !submitting && prompt.trim().length > 0 && assetName.trim().length > 0; + const canSubmit = prompt.trim().length > 0 && assetName.trim().length > 0; - async function submit(event: FormEvent) { + function submit(event: FormEvent) { event.preventDefault(); const normalizedPrompt = prompt.trim(); const normalizedAssetName = assetName.trim(); - if (!normalizedPrompt || !normalizedAssetName || submitting) { + if (!normalizedPrompt || !normalizedAssetName) { return; } - setSubmitting(true); setError(null); - try { - await onSubmit({ - kind: action.assetKind, - prompt: normalizedPrompt, - assetName: normalizedAssetName, - aspectRatio, - imageSize, - }); - } catch (submitError) { - // 成功路径由宿主卸载面板;失败保留草稿,用户可直接用同一份输入重试。 - setError(assetGenerationErrorMessage(submitError)); - } finally { - setSubmitting(false); - } + // 点击即关闭:不等 IPC、不等排队、不等生成结束。失败要不要把面板带回来由宿主决定 + // (只有「从未被后端受理」的即时失败才重开并带回草稿),面板不持有在途状态。 + onSubmit({ + kind: action.assetKind, + prompt: normalizedPrompt, + assetName: normalizedAssetName, + aspectRatio, + imageSize, + }); + onClose(); } return ( @@ -124,7 +137,6 @@ export function ResourceCanvasAssetGenerationPanelView({ setAssetName(event.currentTarget.value)} /> @@ -136,7 +148,6 @@ export function ResourceCanvasAssetGenerationPanelView({ aria-label="生成提示词" rows={6} autoFocus - disabled={submitting} maxLength={promptMaxLength} placeholder={action.promptPlaceholder} value={prompt} @@ -156,7 +167,6 @@ export function ResourceCanvasAssetGenerationPanelView({ columns="threeToSix" gap="sm" size="compact" - disabled={submitting} onChange={setAspectRatio} /> @@ -186,7 +195,6 @@ export function ResourceCanvasAssetGenerationPanelView({ subject={`素材生成提示词(${action.label})`} editKind="image-reference" prompt={prompt} - disabled={submitting} applyPrompt={setPrompt} /> {error ? ( @@ -200,11 +208,11 @@ export function ResourceCanvasAssetGenerationPanelView({ tone="secondary" onClick={onClose} > - {submitting ? '后台运行并关闭' : '取消'} + 取消 diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx index d64d6515f..94d3b7456 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView.tsx @@ -3,8 +3,8 @@ import { useEffect, useMemo, useState } from 'react'; import { RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS, - type ResourceCanvasAssetGenerationTask, resourceCanvasAssetGenerationElapsedLabel, + type ResourceCanvasAssetGenerationTask, resourceCanvasAssetGenerationTaskElapsedMillis, resourceCanvasAssetGenerationTaskIsTerminal, sortResourceCanvasAssetGenerationTasks, @@ -95,7 +95,11 @@ export function ResourceCanvasAssetGenerationTasksPanelView({
- {RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[task.status]} + { + RESOURCE_CANVAS_ASSET_GENERATION_STATUS_LABELS[ + task.status + ] + } {task.phaseDetail} diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts index c2c17179a..87e724bd6 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationQueue.ts @@ -175,10 +175,9 @@ export function createResourceCanvasAssetGenerationQueue( for (;;) { let records: LocalProjectAssetGenerationTaskRecord[]; try { - records = (await deps.invoke( - 'list_local_project_asset_generations', - { projectPath }, - )) as LocalProjectAssetGenerationTaskRecord[]; + records = (await deps.invoke('list_local_project_asset_generations', { + projectPath, + })) as LocalProjectAssetGenerationTaskRecord[]; } catch (error) { // IPC 拒绝(未注册 / 权限拒绝 / 账本读坏):按「本轮读不到」处理,绝不把拒绝往上抛—— // 派发循环是 `void (async …)()`,抛出去就是未处理的 Promise 拒绝。 @@ -228,9 +227,7 @@ export function createResourceCanvasAssetGenerationQueue( } } - function markSettled( - settlement: ResourceCanvasAssetGenerationSettlement, - ) { + function markSettled(settlement: ResourceCanvasAssetGenerationSettlement) { const settled = deps .listTasks() .find((task) => task.taskId === settlement.taskId); diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts index 58a7000ab..150037530 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel.ts @@ -1,7 +1,7 @@ import { resolveResourceCanvasBottomTools, - resourceCanvasBottomToolActions, type ResourceCanvasAssetToolAction, + resourceCanvasBottomToolActions, } from './resourceCanvasBottomToolbarModel'; /** 一条生成任务在宿主里的状态。`queued` 包含「本地排队」和「后端排队」两种来源。 */ @@ -200,10 +200,7 @@ export function restoreResourceCanvasAssetGenerationTask( */ export function applyLocalProjectAssetGenerationRecords( tasks: readonly ResourceCanvasAssetGenerationTask[], - records: - | readonly LocalProjectAssetGenerationTaskRecord[] - | null - | undefined, + records: readonly LocalProjectAssetGenerationTaskRecord[] | null | undefined, ): ResourceCanvasAssetGenerationTask[] { const safeRecords = Array.isArray(records) ? records.filter( 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 d72e6e3aa..cae916de9 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 @@ -25,8 +25,8 @@ import { Info, Layers, LayoutGrid, - ListFilter, ListChecks, + ListFilter, Maximize2, Minus, Music2, @@ -110,9 +110,23 @@ import { } from '../../features/project-workspace/resourceReferences'; import { GameRunVersionPicker } from '../../features/resource-canvas/GameRunVersionPicker'; import { + type ResourceCanvasAssetGenerationPanelDraft, ResourceCanvasAssetGenerationPanelView, type ResourceCanvasAssetGenerationSubmitInput, } from '../../features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; +import { + createResourceCanvasAssetGenerationQueue, + mergeResourceCanvasAssetGenerationTasksWithRecords, + type ResourceCanvasAssetGenerationQueue, + type ResourceCanvasAssetGenerationSettlement, +} from '../../features/resource-canvas/resourceCanvasAssetGenerationQueue'; +import { + createResourceCanvasAssetGenerationTask, + type LocalProjectAssetGenerationTaskRecord, + type ResourceCanvasAssetGenerationTask, + resourceCanvasAssetGenerationTaskIsTerminal, +} from '../../features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; +import { ResourceCanvasAssetGenerationTasksPanelView } from '../../features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView'; import { defaultResourceExportFileName, isResourceCanvasExportable, @@ -329,18 +343,6 @@ import { } from './useProjectResourceCanvasLayout'; import { useProjectResourceCardPreviews } from './useProjectResourceCardPreviews'; import { useProjectResourceSectionHeights } from './useProjectResourceSectionHeights'; -import { - createResourceCanvasAssetGenerationQueue, - mergeResourceCanvasAssetGenerationTasksWithRecords, - type ResourceCanvasAssetGenerationQueue, - type ResourceCanvasAssetGenerationSettlement, -} from '../../features/resource-canvas/resourceCanvasAssetGenerationQueue'; -import { - createResourceCanvasAssetGenerationTask, - type LocalProjectAssetGenerationTaskRecord, - type ResourceCanvasAssetGenerationTask, -} from '../../features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; -import { ResourceCanvasAssetGenerationTasksPanelView } from '../../features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView'; import { useResourceAssetDeleteFlow } from './useResourceAssetDeleteFlow'; export type { @@ -1573,10 +1575,32 @@ export default function ProjectDevelopmentView({ const resourceAssetGenerationTasksRef = useRef< ResourceCanvasAssetGenerationTask[] >([]); - /** 提交表单当前这次提交对应的任务 id:面板的在途文案要读该任务的后端阶段。 */ - const resourceAssetGenerationPanelTaskIdRef = useRef(null); - const [resourceAssetGenerationTasksPanelOpen, setResourceAssetGenerationTasksPanelOpen] = - useState(false); + /** + * 提交面板上一次提交的上下文。 + * + * 面板点击即关闭,草稿只活在组件里,所以「点击瞬间就失败」要把面板带回来时,得从这里取回 + * 那份草稿;`dispatchedImmediately` 用来区分「这次点击本来就该立刻派发」与「排在队列后面 + * 才派发」——只有前者才值得重开面板。 + */ + const resourceAssetGenerationPanelSubmissionRef = useRef<{ + taskId: string; + action: ResourceCanvasAssetToolAction; + draft: ResourceCanvasAssetGenerationPanelDraft; + dispatchedImmediately: boolean; + } | null>(null); + /** 即时失败重开提交面板时带回去的草稿与原因;正常打开时为 null。 */ + const [ + resourceAssetGenerationPanelReopen, + setResourceAssetGenerationPanelReopen, + ] = useState<{ + actionId: string; + draft: ResourceCanvasAssetGenerationPanelDraft; + error: string; + } | null>(null); + const [ + resourceAssetGenerationTasksPanelOpen, + setResourceAssetGenerationTasksPanelOpen, + ] = useState(false); /** * 「定位到素材」的聚焦请求序号。 * @@ -1584,8 +1608,10 @@ export default function ProjectDevelopmentView({ * 不改其中任何一项 → effect 不会重跑,intent 永远没人消费、提示条停在中转文案上。所以每次 * 点击都要推进这个序号,让「这次请求」成为一个真实的依赖变化。 */ - const [resourceAssetGenerationFocusRequest, setResourceAssetGenerationFocusRequest] = - useState(0); + const [ + resourceAssetGenerationFocusRequest, + setResourceAssetGenerationFocusRequest, + ] = useState(0); /** 提示条文案的 ref 版:有界兜底要判断此刻是否还停在中转文案上。 */ const resourceWorkbenchNoticeRef = useRef(''); const [resourceBottomToolbarUploading, setResourceBottomToolbarUploading] = @@ -6716,11 +6742,15 @@ export default function ProjectDevelopmentView({ ); /** - * 一条生成任务收尾后的宿主动作:成功落卡 / 失败给提示。 + * 一条生成任务收尾后的宿主动作:成功落卡 / 失败给提示 / 即时失败把提交面板带回来。 * * 后端把生成结果与 manifest 登记都写完才把记录置为终态,所以这里只做「配对读 + 交给 * `onManifestChange`」这条既有链路,再用 `pendingResourceFocusRef` 定位新卡;不重算依赖图、 * 不另写布局逻辑。 + * + * 失败分两类:**后端从未受理**(`record === null`,且这次点击本来就该立刻派发)→ 把提交面板 + * 连草稿一起带回来,错误留在面板里;**受理之后才失败**(生成中失败 / 远端失败 / 轮询超时)→ + * 不重开面板,只在「生成任务」侧栏收口为失败并给一次提示条。 */ const handleResourceAssetGenerationSettlement = useCallback( async (settlement: ResourceCanvasAssetGenerationSettlement) => { @@ -6730,6 +6760,24 @@ export default function ProjectDevelopmentView({ // 账本已经把结果写在它自己的项目里,这里不再动当前项目的状态。 return; } + const submission = resourceAssetGenerationPanelSubmissionRef.current; + if (submission?.taskId === settlement.taskId) { + resourceAssetGenerationPanelSubmissionRef.current = null; + if ( + settlement.status === 'failed' && + settlement.record === null && + submission.dispatchedImmediately + ) { + setResourceAssetGenerationPanelReopen({ + actionId: submission.action.id, + draft: submission.draft, + error: settlement.error ?? '生成素材失败', + }); + setResourceAssetGenerationAction(submission.action); + setResourceWorkbenchNotice(''); + return; + } + } if (settlement.status !== 'completed' || !settlement.record?.assetId) { setResourceWorkbenchNotice( `生成素材失败:${settlement.error ?? '未知原因'}`, @@ -6795,11 +6843,6 @@ export default function ProjectDevelopmentView({ completed: false, }; setResourceWorkbenchNotice('生成资源已保存,正在同步资源与布局…'); - // 只在「这块表单还是这次提交的」时收面板:用户可能已经关掉它、又打开做第二次提交。 - if (resourceAssetGenerationPanelTaskIdRef.current === settlement.taskId) { - resourceAssetGenerationPanelTaskIdRef.current = null; - setResourceAssetGenerationAction(null); - } }, [], ); @@ -6839,19 +6882,30 @@ export default function ProjectDevelopmentView({ /** * 提交一条图片类生成任务。 * - * 只做「入队」,生成由队列在后台派发与轮询;面板的 `await` 落在该任务的终局上,所以提交 - * 期间关掉面板不等于取消请求,失败也能带着草稿重试。 + * 只做「入队」,生成由队列在后台派发与轮询。**同步返回**:提交面板在点击那一刻就自己关掉了, + * 不等受理、不等排队、不等生成;只有后端从未受理的即时失败才会由收尾回调把面板连草稿一起带回来。 */ const submitResourceAssetGeneration = useCallback( - async ( + ( action: ResourceCanvasAssetToolAction, input: ResourceCanvasAssetGenerationSubmitInput, ) => { const queue = resourceAssetGenerationQueueRef.current; - if (!queue) { - throw new Error('生成任务队列尚未就绪'); - } const context = resourceAssetGenerationContextRef.current; + if (!queue) { + setResourceWorkbenchNotice( + '生成任务队列尚未就绪,请重新打开项目后重试', + ); + return; + } + // 「这次点击本来就该立刻派发」:队列里没有在途任务时才是。排在队列后面才派发的任务即使 + // 提交失败,也不该把面板弹回来打断用户。 + const dispatchedImmediately = + !resourceAssetGenerationTasksRef.current.some( + (task) => + task.dispatched && + !resourceCanvasAssetGenerationTaskIsTerminal(task), + ); const task = createResourceCanvasAssetGenerationTask({ taskId: crypto.randomUUID(), action, @@ -6866,9 +6920,25 @@ export default function ProjectDevelopmentView({ projectId: context.projectId, nowMillis: Date.now(), }); - resourceAssetGenerationPanelTaskIdRef.current = task.taskId; + resourceAssetGenerationPanelSubmissionRef.current = { + taskId: task.taskId, + action, + draft: { + prompt: input.prompt, + assetName: input.assetName, + aspectRatio: input.aspectRatio, + imageSize: input.imageSize, + }, + dispatchedImmediately, + }; + setResourceAssetGenerationPanelReopen(null); setResourceAssetGenerationTasksPanelOpen(true); - await queue.submit(task); + setResourceWorkbenchNotice( + `已提交「${input.assetName}」,生成在后台继续,进度见「生成任务」`, + ); + // 终局由 `onSettled` 收口(成功落卡 / 失败收口 / 即时失败重开面板),这里只吞掉拒绝, + // 避免出现未处理的 Promise 拒绝。 + void queue.submit(task).catch(() => undefined); }, [], ); @@ -6891,7 +6961,8 @@ export default function ProjectDevelopmentView({ const projectId = manifest.projectId; let cancelled = false; setResourceAssetGenerationTasksPanelOpen(false); - resourceAssetGenerationPanelTaskIdRef.current = null; + resourceAssetGenerationPanelSubmissionRef.current = null; + setResourceAssetGenerationPanelReopen(null); void (async () => { const reportUnavailable = () => { if (!cancelled) { @@ -8340,20 +8411,33 @@ export default function ProjectDevelopmentView({ ) : null} {resourceAssetGenerationAction ? ( - task.taskId === resourceAssetGenerationPanelTaskIdRef.current, - )?.phaseDetail ?? null + draft={ + resourceAssetGenerationPanelReopen?.actionId === + resourceAssetGenerationAction.id + ? resourceAssetGenerationPanelReopen.draft + : undefined + } + error={ + resourceAssetGenerationPanelReopen?.actionId === + resourceAssetGenerationAction.id + ? resourceAssetGenerationPanelReopen.error + : null } onSubmit={(input) => - submitResourceAssetGeneration( - resourceAssetGenerationAction, - input, - ) + submitResourceAssetGeneration(resourceAssetGenerationAction, input) } - onClose={() => setResourceAssetGenerationAction(null)} + onClose={() => { + setResourceAssetGenerationPanelReopen(null); + setResourceAssetGenerationAction(null); + }} /> ) : null} {/* diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 68a2f43e9..c1c19e9e7 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -364,7 +364,9 @@ function generateCall(calls: readonly BottomToolbarInvokeCall[], kind: string) { invokeStringField(entry.args, 'kind') === kind, ); if (!call?.args) { - throw new Error(`missing start_local_project_asset_generation call for ${kind}`); + throw new Error( + `missing start_local_project_asset_generation call for ${kind}`, + ); } return call.args; } @@ -10661,7 +10663,8 @@ export function registerProjectAgentStatusTests() { } if (command === 'start_local_project_asset_generation') { const kind = invokeStringField(args, 'kind') ?? 'unknown'; - const taskId = invokeStringField(args, 'taskId') ?? `task-${tasks.size}`; + const taskId = + invokeStringField(args, 'taskId') ?? `task-${tasks.size}`; const task = { taskId, projectId: String(args?.projectId ?? manifest.projectId), @@ -10726,7 +10729,9 @@ export function registerProjectAgentStatusTests() { expect(screen.getByRole('button', { name: '生成任务' })).not.toBeNull(); // 1) 提交第一条;生成在途时面板能关(关闭 ≠ 取消请求)。 - fireEvent.click(await screen.findByRole('button', { name: '生成 UI 设计图' })); + fireEvent.click( + await screen.findByRole('button', { name: '生成 UI 设计图' }), + ); const firstPanel = await screen.findByRole('dialog', { name: '生成 UI 设计图', }); @@ -10736,15 +10741,12 @@ export function registerProjectAgentStatusTests() { fireEvent.change(within(firstPanel).getByLabelText('生成提示词'), { target: { value: '第一条界面' }, }); - fireEvent.click(within(firstPanel).getByRole('button', { name: '生成 UI 设计图' })); fireEvent.click( - await screen.findByRole('button', { name: '后台运行并关闭' }), - ); - await waitFor(() => - expect( - screen.queryByRole('dialog', { name: '生成 UI 设计图' }), - ).toBeNull(), + within(firstPanel).getByRole('button', { name: '生成 UI 设计图' }), ); + // 点击即关闭:同一个事件循环内提交面板就已卸载,画布立刻可用。 + expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull(); + expect(firstPanel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/); // 2) 面板关掉之后任务仍在「生成任务」面板里,阶段文案来自后端记录。 const taskPanel = await screen.findByRole('region', { name: '生成任务' }); @@ -10766,8 +10768,15 @@ export function registerProjectAgentStatusTests() { fireEvent.change(within(secondPanel).getByLabelText('生成提示词'), { target: { value: '第二条界面' }, }); - fireEvent.click(within(secondPanel).getByRole('button', { name: '生成 UI 设计图' })); - await within(secondPanel).findByText('排队中。'); + fireEvent.click( + within(secondPanel).getByRole('button', { name: '生成 UI 设计图' }), + ); + // 第二条提交面板同样点击即关闭;它停在本地队列里(阶段由任务面板呈现), + // 生成提交 IPC 仍然只有一次。 + expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull(); + await waitFor(() => + expect(within(taskPanel).getByText('排队中。')).not.toBeNull(), + ); expect( calls.filter( (call) => call.command === 'start_local_project_asset_generation', @@ -10776,12 +10785,6 @@ export function registerProjectAgentStatusTests() { await waitFor(() => expect(within(taskPanel).getByText('第二条设计图')).not.toBeNull(), ); - fireEvent.keyDown(window, { key: 'Escape' }); - await waitFor(() => - expect( - screen.queryByRole('dialog', { name: '生成 UI 设计图' }), - ).toBeNull(), - ); // 4) 第一条终态后自动补发第二条,两条都收口为已完成;每条完成各走一次「配对读 + 落卡」。 const manifestReadsBefore = calls.filter( @@ -11084,7 +11087,9 @@ export function registerProjectAgentStatusTests() { await waitFor(() => expect(screen.queryByText('正在定位生成的素材…')).toBeNull(), ); - expect(screen.getByRole('button', { name: '清除搜索并定位' })).not.toBeNull(); + expect( + screen.getByRole('button', { name: '清除搜索并定位' }), + ).not.toBeNull(); }, 20_000); it('settles a locate request whose asset is not in the project at all', async () => { @@ -11112,6 +11117,201 @@ export function registerProjectAgentStatusTests() { expect(screen.queryByText('正在定位生成的素材…')).toBeNull(); }, 20_000); + /** + * 提交面板的公共夹具:一个带权威规范图的 UI 交互栏目视图,`startLocalAsset` 决定 + * `start_local_project_asset_generation` 这一次调用的行为。 + */ + async function renderAssetGenerationSubmitView(input: { + projectId: string; + projectPath: string; + startLocalAsset: (args: { + taskId: string; + assetName: string; + }) => Promise; + listRecords?: (started: Map>) => unknown; + }) { + const manifest = createGameCreationAppManifest( + input.projectId, + '生成提交面板测试', + ); + manifest.assets = [ + { + id: 'submit-art-spec', + kind: 'icon-spec', + mediaType: 'image/png', + localPath: 'assets/art-spec.png', + source: { kind: 'canvas', resourceId: 'submit-art-spec-resource' }, + }, + ]; + const tasks = new Map>(); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_graph') { + return resourceGraphForInputs(args); + } + if (command === 'read_local_project_resource_canvas_layout') { + return { + schemaVersion: 'game-creator-resource-layout.v1', + projectId: input.projectId, + mode: args?.mode, + revision: 0, + positions: [], + updatedAt: 0, + }; + } + if (command === 'start_local_project_asset_generation') { + const started = await input.startLocalAsset({ + taskId: String(args?.taskId), + assetName: String(args?.assetName), + }); + if (started) { + tasks.set(String(args?.taskId), started as Record); + } + return started; + } + if (command === 'list_local_project_asset_generations') { + if (input.listRecords) { + return input.listRecords(tasks); + } + return [...tasks.values()]; + } + if (command === 'get_local_game_project_revision') { + return { revision: 7 }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + + render( + React.createElement(ProjectDevelopmentView, { + projectName: manifest.name, + projectPath: input.projectPath, + manifest, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + supervisor: React.createElement('div', null, '项目总控'), + onHomeOpen: vi.fn(), + onProjectsOpen: vi.fn(), + }), + ); + + await openResourceBookCategory('UI 交互'); + fireEvent.click( + await screen.findByRole('button', { name: '生成 UI 设计图' }), + ); + const panel = await screen.findByRole('dialog', { + name: '生成 UI 设计图', + }); + fireEvent.change(within(panel).getByLabelText('素材名称'), { + target: { value: '待提交设计图' }, + }); + fireEvent.change(within(panel).getByLabelText('生成提示词'), { + target: { value: '主界面与背包页' }, + }); + return panel; + } + + it('closes the submission panel synchronously on submit and keeps stage text out of it', async () => { + // 提交这一步挂住:面板仍然必须立刻消失(不等受理、不等排队、不等生成)。 + const panel = await renderAssetGenerationSubmitView({ + projectId: 'workbench-submit-sync-close', + projectPath: '/tmp/workbench-submit-sync-close', + startLocalAsset: () => new Promise(() => undefined), + }); + expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/); + + fireEvent.click( + within(panel).getByRole('button', { name: '生成 UI 设计图' }), + ); + + expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull(); + expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/); + // 阶段文案只出现在任务面板 / 提示条里,不在提交面板里。 + expect(screen.getByRole('region', { name: '生成任务' })).not.toBeNull(); + }, 20_000); + + it('brings the submission panel back with the draft when the backend never accepted the submit', async () => { + const panel = await renderAssetGenerationSubmitView({ + projectId: 'workbench-submit-instant-failure', + projectPath: '/tmp/workbench-submit-instant-failure', + startLocalAsset: () => + Promise.reject( + new Error('项目权限策略拒绝执行:canvas.asset_generate'), + ), + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '生成 UI 设计图' }), + ); + expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull(); + + // 后端从未受理 → 面板连草稿一起带回来,错误可见、可直接改后重试。 + const reopened = await screen.findByRole('dialog', { + name: '生成 UI 设计图', + }); + await waitFor(() => + expect(within(reopened).getByRole('alert').textContent).toContain( + '项目权限策略拒绝执行:canvas.asset_generate', + ), + ); + expect( + (within(reopened).getByLabelText('生成提示词') as HTMLTextAreaElement) + .value, + ).toBe('主界面与背包页'); + expect( + (within(reopened).getByLabelText('素材名称') as HTMLInputElement).value, + ).toBe('待提交设计图'); + }, 20_000); + + it('does not reopen the submission panel when an accepted task fails later', async () => { + const panel = await renderAssetGenerationSubmitView({ + projectId: 'workbench-submit-late-failure', + projectPath: '/tmp/workbench-submit-late-failure', + startLocalAsset: async ({ taskId }) => ({ + taskId, + projectId: 'workbench-submit-late-failure', + kind: 'ui-prototype', + assetName: '待提交设计图', + status: 'running', + phaseDetail: '正在生成。', + createdAtMillis: 1, + startedAtMillis: 1, + finishedAtMillis: null, + assetId: null, + error: null, + }), + // 后端已经受理(start 返回了记录),随后这次生成失败。 + listRecords: (started) => + [...started.values()].map((task) => ({ + ...task, + status: 'failed', + phaseDetail: '生成失败:远端拒绝', + finishedAtMillis: 2, + error: '远端拒绝', + })), + }); + + fireEvent.click( + within(panel).getByRole('button', { name: '生成 UI 设计图' }), + ); + + // 受理之后才失败:面板不回来,只在任务面板收口为失败 + 一次提示条。 + await waitFor(() => + expect(screen.getByText('生成素材失败:远端拒绝')).not.toBeNull(), + ); + expect(screen.queryByRole('dialog', { name: '生成 UI 设计图' })).toBeNull(); + expect( + within(screen.getByRole('region', { name: '生成任务' })).getByText( + '生成失败:远端拒绝', + ), + ).not.toBeNull(); + }, 20_000); + it('routes the audio column entries to the existing audio generation chain', async () => { const manifest = createGameCreationAppManifest( 'workbench-bottom-toolbar-audio', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx index 50b78e5a6..1b7c0a8d0 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationBackgroundClose.test.tsx @@ -1,5 +1,12 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; @@ -26,29 +33,15 @@ const uiPrototypeAction: ResourceCanvasAssetToolAction = { writesIconSpecReference: false, }; -/** 永不 resolve 的提交:模拟「生成还要跑 35 分钟」的在途状态。 */ +/** 永不 resolve 的提交:音频面板仍然等生成结束,用它模拟在途状态。 */ function pendingSubmit() { return new Promise(() => undefined); } -async function fillAndSubmitAssetPanel(onSubmit: () => Promise) { - const user = userEvent.setup(); - render( - undefined} - />, - ); - await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页'); - await user.click(screen.getByRole('button', { name: '生成 UI 设计图' })); - return user; -} - -describe('图片类生成面板在提交期间可关闭', () => { - test('提交在途时点 × 能关闭面板,且关闭不等于取消请求', async () => { +describe('图片类生成面板:点击即关闭,面板里不出现阶段文案', () => { + test('点击生成同步调用提交并关闭面板,面板 DOM 里从不出现阶段 / 排队文案', async () => { const onClose = vi.fn(); - const onSubmit = vi.fn(pendingSubmit); + const onSubmit = vi.fn(); const user = userEvent.setup(); render( { />, ); await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页'); - await user.click(screen.getByRole('button', { name: '生成 UI 设计图' })); - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + // 点击前主按钮文案就是动作名:不是阶段、也不是「已提交」。 + const panel = screen.getByRole('dialog', { name: '生成 UI 设计图' }); + expect( + ( + within(panel).getByRole('button', { + name: '生成 UI 设计图', + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/); - const closeButton = screen.getByRole('button', { - name: '关闭生成 UI 设计图', - }) as HTMLButtonElement; - expect(closeButton.disabled).toBe(false); - await user.click(closeButton); - expect(onClose).toHaveBeenCalledTimes(1); - // 关闭面板只是卸载 view:请求仍在飞,面板不该去「取消」它。 + await user.click( + within(panel).getByRole('button', { name: '生成 UI 设计图' }), + ); + + // 提交与关闭在同一个事件循环里发生:面板不等受理、不等排队、不等生成。 expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + expect(panel.textContent).not.toMatch(/排队中。|正在生成。|提交中…/); + expect(screen.queryByRole('button', { name: '后台运行并关闭' })).toBeNull(); }); - test('提交在途时 Esc 与点遮罩都能关面板', async () => { + test('× / Esc / 点遮罩仍能关面板,且关闭不等于取消', async () => { const onClose = vi.fn(); + const onSubmit = vi.fn(); + const user = userEvent.setup(); render( , ); - const user = userEvent.setup(); await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页'); - await user.click(screen.getByRole('button', { name: '生成 UI 设计图' })); - fireEvent.keyDown(window, { key: 'Escape' }); + await user.click( + screen.getByRole('button', { name: '关闭生成 UI 设计图' }), + ); expect(onClose).toHaveBeenCalledTimes(1); - + fireEvent.keyDown(window, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledTimes(2); const backdrop = document.querySelector('.fixed.inset-0') as HTMLElement; fireEvent.pointerDown(backdrop); fireEvent.pointerUp(backdrop); fireEvent.click(backdrop); - expect(onClose).toHaveBeenCalledTimes(2); + expect(onClose).toHaveBeenCalledTimes(3); + expect(onSubmit).not.toHaveBeenCalled(); }); - test('提交在途时提供「后台运行并关闭」,文案明说关闭不等于取消', async () => { + test('即时失败重开时带回草稿与失败原因,改完就能重试', async () => { const onClose = vi.fn(); - const onSubmit = vi.fn(pendingSubmit); + const onSubmit = vi.fn(); const user = userEvent.setup(); render( , ); - await user.type(screen.getByLabelText('生成提示词'), '主界面与背包页'); - await user.click(screen.getByRole('button', { name: '生成 UI 设计图' })); - const backgroundButton = screen.getByRole('button', { - name: '后台运行并关闭', - }); - await user.click(backgroundButton); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onSubmit).toHaveBeenCalledTimes(1); - }); - - test('提交失败后草稿仍留在面板里可以直接重试', async () => { - const onSubmit = vi - .fn<() => Promise>() - .mockRejectedValueOnce(new Error('生成素材失败:远端拒绝')); - const user = await fillAndSubmitAssetPanel(onSubmit); - - expect((await screen.findByRole('alert')).textContent).toContain( + expect(screen.getByRole('alert').textContent).toContain( '生成素材失败:远端拒绝', ); - expect((screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value).toBe( - '主界面与背包页', - ); expect( - (screen.getByLabelText('素材名称') as HTMLInputElement).disabled, - ).toBe(false); + (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value, + ).toBe('主界面与背包页'); + await user.click(screen.getByRole('button', { name: '生成 UI 设计图' })); - expect(onSubmit).toHaveBeenCalledTimes(2); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit.mock.calls[0]?.[0]).toMatchObject({ + kind: 'ui-prototype', + prompt: '主界面与背包页', + assetName: 'AI 生成 UI 设计图', + }); }); }); @@ -179,9 +179,7 @@ describe('音频生成面板在提交期间可关闭', () => { await user.type(screen.getByLabelText('生成提示词'), '木门推开的声音'); await user.click(screen.getByRole('button', { name: '生成音效' })); - await user.click( - screen.getByRole('button', { name: '后台运行并关闭' }), - ); + await user.click(screen.getByRole('button', { name: '后台运行并关闭' })); expect(onClose).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts index 0410d26e9..908aa2669 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationQueue.test.ts @@ -10,9 +10,9 @@ import { import { applyLocalProjectAssetGenerationRecords, createResourceCanvasAssetGenerationTask, + type LocalProjectAssetGenerationTaskRecord, nextResourceCanvasAssetGenerationDispatch, RESOURCE_CANVAS_ASSET_GENERATION_LOCAL_QUEUE_PHASE, - type LocalProjectAssetGenerationTaskRecord, resourceCanvasAssetGenerationElapsedLabel, resourceCanvasAssetGenerationKindLabel, type ResourceCanvasAssetGenerationTask, @@ -180,7 +180,9 @@ describe('生成任务模型', () => { test('已耗时文案按分秒呈现', () => { expect(resourceCanvasAssetGenerationElapsedLabel(12_000)).toBe('12 秒'); - expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe('1 分 12 秒'); + expect(resourceCanvasAssetGenerationElapsedLabel(72_000)).toBe( + '1 分 12 秒', + ); expect(resourceCanvasAssetGenerationElapsedLabel(-5)).toBe('0 秒'); }); }); @@ -284,9 +286,7 @@ describe('本地排队驱动器', () => { assertedMidFlight = true; // 第一条还在途:第二条必须停在本地队列里。 expect(api.startCallCount()).toBe(1); - const second = api - .tasks() - .find((task) => task.taskId === 'task-b'); + const second = api.tasks().find((task) => task.taskId === 'task-b'); expect(second?.status).toBe('queued'); expect(second?.dispatched).toBe(false); expect(second?.phaseDetail).toBe( @@ -308,18 +308,16 @@ describe('本地排队驱动器', () => { expect(assertedMidFlight).toBe(true); // 第一条终态后自动补发第二条:生成提交 IPC 次数 = 2。 expect(harness.startCallCount()).toBe(2); - expect(harness.settlements.map((item) => [item.taskId, item.status])).toEqual( - [ - ['task-a', 'completed'], - ['task-b', 'completed'], - ], - ); expect( - harness.tasks().map((task) => [task.taskId, task.status]), + harness.settlements.map((item) => [item.taskId, item.status]), ).toEqual([ ['task-a', 'completed'], ['task-b', 'completed'], ]); + expect(harness.tasks().map((task) => [task.taskId, task.status])).toEqual([ + ['task-a', 'completed'], + ['task-b', 'completed'], + ]); }); test('第一条失败也会补发第二条,失败原因按后端记录留在任务上', async () => { @@ -468,7 +466,8 @@ describe('非预期 IPC 形状 / 失败下的健壮性', () => { if (command === 'list_local_project_asset_generations') { listCalls += 1; if ( - listCalls <= RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT + listCalls <= + RESOURCE_CANVAS_ASSET_GENERATION_MISSING_RECORD_POLL_LIMIT ) { throw new Error('项目权限策略拒绝执行:asset.read'); } diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx index 7ef84bc4b..2e7506a2b 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasAssetGenerationTasksPanel.test.tsx @@ -3,11 +3,11 @@ import { cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, describe, expect, test, vi } from 'vitest'; -import { ResourceCanvasAssetGenerationTasksPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView'; import { createResourceCanvasAssetGenerationTask, type ResourceCanvasAssetGenerationTask, } from '../src/features/resource-canvas/resourceCanvasAssetGenerationTaskModel'; +import { ResourceCanvasAssetGenerationTasksPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationTasksPanelView'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; afterEach(() => { @@ -149,8 +149,12 @@ describe('「生成任务」面板', () => { />, ); - expect(screen.queryByRole('button', { name: '定位素材 背包界面设计图' })).toBeNull(); - await user.click(screen.getByRole('button', { name: '定位素材 主界面设计图' })); + expect( + screen.queryByRole('button', { name: '定位素材 背包界面设计图' }), + ).toBeNull(); + await user.click( + screen.getByRole('button', { name: '定位素材 主界面设计图' }), + ); expect(onFocusTask).toHaveBeenCalledTimes(1); expect(onFocusTask.mock.calls[0]?.[0]).toMatchObject({ taskId: 't1', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx index da9986e59..c228db240 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx @@ -417,16 +417,15 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { ); }); - test('空提示词不提交,失败保留草稿并可原样重试', async () => { - const onSubmit = vi - .fn<(input: unknown) => Promise>() - .mockRejectedValueOnce(new Error('图片比例不受支持:4:3')) - .mockResolvedValueOnce(undefined); - render( + test('空提示词不提交;点击即关闭,重开时带回草稿与失败原因', () => { + const onSubmit = vi.fn(); + const onClose = vi.fn(); + const action = assetActionOf('character', '生成角色形象'); + const { unmount } = render( undefined} + onClose={onClose} />, ); @@ -436,16 +435,40 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { target: { value: '披风猫骑士' }, }); fireEvent.click(submit); - expect(await screen.findByRole('alert')).not.toBeNull(); + // 点击即关闭:面板不等受理结果,失败由宿主决定要不要把它带回来。 + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + unmount(); + + // 即时失败重开:草稿与原因都带回来,用户改完就能重试(同一份草稿就是同一个请求)。 + const first = onSubmit.mock.calls[0]?.[0] as { + prompt: string; + assetName: string; + aspectRatio: string; + imageSize: string; + }; + render( + , + ); expect(screen.getByRole('alert').textContent).toContain( '图片比例不受支持:4:3', ); - // 失败不锁输入:同一份草稿再点一次就是同一个请求。 expect( - (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).disabled, - ).toBe(false); + (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value, + ).toBe('披风猫骑士'); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); - await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(2)); + expect(onSubmit).toHaveBeenCalledTimes(2); expect(onSubmit.mock.calls[1]?.[0]).toEqual(onSubmit.mock.calls[0]?.[0]); }); }); From ec02c39a5d7a48a6bd88ad9e16eabd1821f80cf9 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 14 Sep 2026 18:49:22 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(agc):=20=E3=80=8C=E7=94=9F=E6=88=90?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E3=80=8D=E6=94=B9=E9=80=A0=E6=88=90=E5=B8=B8?= =?UTF-8?q?=E9=A9=BB=E7=94=BB=E5=B8=83=E7=9A=84=E5=8F=AF=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=E4=BE=A7=E6=A0=8F=EF=BC=88=E5=88=86=E6=A0=8F=20/=20=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=20/=20=E8=87=AA=E5=8A=A8=E5=B1=95=E5=BC=80=20/=20?= =?UTF-8?q?=E6=8A=98=E5=8F=A0=E4=B8=8D=E5=BD=B1=E5=93=8D=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ResourceCanvasAssetGenerationTasksPanelView 由非模态浮层改造成任务侧栏:展开时是贴边 `