From 2f47540eebeb1935e44da35394c9e2c8cf64eb6d Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 14 Sep 2026 18:20:50 +0800 Subject: [PATCH] =?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',