From c2d32927695e56c030746987dc4a4cf088ad4d7f Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 11 Sep 2026 17:02:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BF=AB=E9=80=9F=E7=BC=96=E8=BE=91=E6=B5=AE?= =?UTF-8?q?=E5=B1=82=E6=8E=A5=E5=85=A5=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=B6=A6?= =?UTF-8?q?=E8=89=B2=EF=BC=8C=E6=8F=90=E7=A4=BA=E8=AF=8D=E5=8F=98=E4=BA=86?= =?UTF-8?q?=E5=B0=B1=E9=87=8D=E9=93=B8=E8=AF=B7=E6=B1=82=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 选中工具条的快速编辑面板通过共享 composer 的注入位渲染同一个 ResourcePromptPolishSlot,场景约束为「图片素材的快速编辑提示词」 - 润色回填走 applyResourceQuickEditPrompt:与共享 composer 的 resetFailedDialogStatus 同口径,提示词变了就把上一次的失败状态清掉 - 提交侧请求身份按提示词收口:同 sourceLayerId 且提示词未变时复用(失败重试仍命中同一 operation 账本),提示词变了则重铸 operationId / 幂等键——Rust 的 request_fingerprint 含 prompt,复用旧身份会被判「已绑定到不同资源编辑请求」;正规化结果与 revision 属于别的事实,换身份时原样保留 - 打开面板时的初始身份改用统一的 createResourceEditRequestIdentity(绑定面板草稿提示词) - 测试:live integration 补两条——润色成功回填并换身份(断言 prompt 与 operationId/幂等键同时变化)、润色失败保留原文且身份不变 - 变异验证:提交侧去掉按提示词重铸(同 sourceLayerId 直接复用旧身份)→ 「润色后换身份」用例红灯(两次 operationId 相同,实测) --- .../src/view/project-development/index.tsx | 77 +++++++++++---- .../projectResourceLiveIntegration.test.tsx | 94 ++++++++++++++++++- 2 files changed, 154 insertions(+), 17 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 1f4352ef4..370ff2827 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 @@ -132,6 +132,7 @@ import { currentVersionResourceBindingIds, isResourceUsedByCurrentVersion, } from '../../features/resource-canvas/resourceCanvasVersionBindingModel'; +import { ResourcePromptPolishSlot } from '../../features/resource-canvas/ResourcePromptPolishSlot'; import { ensureUiDesignResourceForPrototype } from '../../features/ui-editor/uiDesignResourceBridge'; import { currentPlatformSessionGeneration, @@ -214,7 +215,10 @@ import { } from './ResourceDependencyOverlay'; import { canonicalProjectedResourceMediaType, + createResourceEditRequestIdentity, defaultDerivedResourceName, + resolveResourceEditRequestIdentity, + type ResourceEditRequestIdentity, } from './resourceEditModel'; import { ResourceInfoFieldsView, @@ -1484,16 +1488,18 @@ export default function ProjectDevelopmentView({ /** 按住空格时左键平移,与美术画布的空格抓手一致。 */ const resourceCanvasSpacePanRef = useRef(false); /** - * 快速编辑的请求身份。失败重试必须复用同一个 operationId / idempotencyKey, - * 否则一次用户动作会在后端留下两条派生记录。 + * 快速编辑的请求身份。提示词没变时失败重试复用同一个 operationId / idempotencyKey, + * 否则一次用户动作会在后端留下两条派生记录;提示词变了(改写或润色回填)则重铸, + * 见 `resolveResourceEditRequestIdentity`。 */ - const resourceQuickEditRequestRef = useRef<{ - sourceLayerId: string; - operationId: string; - idempotencyKey: string; - normalizedAssetId: string | null; - expectedProjectRevision: number | null; - } | null>(null); + const resourceQuickEditRequestRef = useRef< + | (ResourceEditRequestIdentity & { + sourceLayerId: string; + normalizedAssetId: string | null; + expectedProjectRevision: number | null; + }) + | null + >(null); /** 快速编辑面板的最新状态:清焦点回调只在事件里读,不随面板状态反复重建。 */ const quickEditPanelRef = useRef(null); quickEditPanelRef.current = quickEditPanel; @@ -5164,11 +5170,11 @@ export default function ProjectDevelopmentView({ return; } setQuickEditSourceLayer(layer); - setQuickEditPanel(createResourceQuickEditPanelDraft(layer)); + const panelDraft = createResourceQuickEditPanelDraft(layer); + setQuickEditPanel(panelDraft); resourceQuickEditRequestRef.current = { + ...createResourceEditRequestIdentity(panelDraft.prompt), sourceLayerId: layer.id, - operationId: crypto.randomUUID(), - idempotencyKey: crypto.randomUUID(), normalizedAssetId: null, expectedProjectRevision: null, }; @@ -5177,6 +5183,26 @@ export default function ProjectDevelopmentView({ [canvasResources], ); + /** + * 润色回填(或用户改写)快速编辑提示词。 + * + * 与共享 composer 的 `resetFailedDialogStatus` 同口径:提示词变了就不再是上一次 + * 失败的那份请求,状态回到 idle、错误清空——同时提交侧会按新提示词重铸请求身份。 + */ + const applyResourceQuickEditPrompt = useCallback((text: string) => { + setQuickEditPanel((current) => + current + ? { + ...current, + prompt: text, + status: current.status === 'failed' ? 'idle' : current.status, + errorMessage: + current.status === 'failed' ? undefined : current.errorMessage, + } + : current, + ); + }, []); + /** * 快速编辑:先按后端真实门禁把任务产物正规化成正式素材,再走资源派生产出一张新素材。 * 源素材(文件与 manifest 条目)保持不变。 @@ -5206,13 +5232,21 @@ export default function ProjectDevelopmentView({ const actionProject = { projectPath, projectId: manifest.projectId }; const flowId = crypto.randomUUID(); const sourceLayerId = layer.id; + const previousRequest = resourceQuickEditRequestRef.current; + // 请求身份只对它铸造时的那句提示词有效:Rust 的 request_fingerprint 含 prompt + // (resource_editor.rs:807-827),改了提示词还拿旧 operationId 重试会被判 + // 「operationId 或幂等键已绑定到不同资源编辑请求」(同文件 :5216-5225)。 + // 提示词没变时仍复用,失败重试照旧命中同一 operation 账本; + // 正规化结果与 revision 是别的事实,换身份时原样保留。 const request = - resourceQuickEditRequestRef.current?.sourceLayerId === sourceLayerId - ? resourceQuickEditRequestRef.current + previousRequest?.sourceLayerId === sourceLayerId + ? { + ...previousRequest, + ...resolveResourceEditRequestIdentity(previousRequest, prompt), + } : { + ...createResourceEditRequestIdentity(prompt), sourceLayerId, - operationId: crypto.randomUUID(), - idempotencyKey: crypto.randomUUID(), normalizedAssetId: null, expectedProjectRevision: null, }; @@ -5849,6 +5883,17 @@ export default function ProjectDevelopmentView({ panel={quickEditPanel} style={quickEditPanelStyle} setQuickEditPanel={setQuickEditPanel} + promptActionSlot={ + + } onSubmit={() => { void submitResourceQuickEdit(); }} diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx index d198fed18..cad3f0d05 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveIntegration.test.tsx @@ -250,11 +250,14 @@ describe('project resource live canvas integration', () => { confirmResourceEditServiceIdentity?: ( input: Record, ) => Promise>; + /** 配了才应答 `polish_local_project_prompt`;不配时该命令直接抛错。 */ + polishResult?: string; } = {}, ) { const layoutWrites: Array> = []; const graphReads: Array> = []; const deriveCalls: Array> = []; + const polishCalls: Array> = []; const resumeCalls: Array> = []; const serviceIdentityRequestCalls: Array> = []; const serviceIdentityConfirmCalls: Array> = []; @@ -468,6 +471,13 @@ describe('project resource live canvas integration', () => { manifest: nextManifest, }; } + if (command === 'polish_local_project_prompt') { + polishCalls.push(structuredClone(args ?? {})); + if (options.polishResult) { + return options.polishResult; + } + throw new Error('polish unavailable in this fixture'); + } throw new Error(`unexpected invoke ${command}`); }, ); @@ -476,7 +486,9 @@ describe('project resource live canvas integration', () => { event: { listen: async () => () => undefined }, }; return { + invoke, deriveCalls, + polishCalls, graphReads, layoutWrites, pendingReadCalls, @@ -486,7 +498,6 @@ describe('project resource live canvas integration', () => { serviceIdentityConfirmCalls, }; } - it('derives a new art asset non-destructively from the quick edit panel and reuses the original operation identity', async () => { const { deriveCalls } = installTauri({ failFirstDerive: true }); render(); @@ -519,6 +530,87 @@ describe('project resource live canvas integration', () => { expect(deriveCalls[1]).not.toHaveProperty('apiKey'); }); + it('快速编辑里润色提示词:带场景约束回填,提示词变了就换请求身份', async () => { + const { deriveCalls, polishCalls } = installTauri({ + failFirstDerive: true, + polishResult: '把角色头发设定改为亮红色', + }); + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开待归类' })); + fireEvent.click(await findResourceSelectButton('source-art.png')); + const toolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' })); + const panel = await screen.findByRole('dialog', { + name: '快速编辑图片', + }); + await setComposerText( + within(panel).getByLabelText('快速编辑提示词'), + '把角色头发改成红色', + ); + fireEvent.click(within(panel).getByRole('button', { name: '修改' })); + expect(await screen.findByRole('alert')).not.toBeNull(); + + // 失败后提示词仍可改:润色一次,回填的是润色结果,而不是原文。 + fireEvent.click(within(panel).getByRole('button', { name: 'AI 润色' })); + await waitFor(() => { + expect( + within(panel).getByLabelText('快速编辑提示词').textContent, + ).toContain('亮红色'); + }); + expect(polishCalls[0]).toMatchObject({ prompt: '把角色头发改成红色' }); + expect(String(polishCalls[0]?.context)).toContain( + '图片素材的快速编辑提示词', + ); + + fireEvent.click(within(panel).getByRole('button', { name: '修改' })); + await waitFor(() => expect(deriveCalls).toHaveLength(2)); + // Rust 的 request_fingerprint 含 prompt:提示词换过就必须换 operationId / 幂等键, + // 否则后端会判「已绑定到不同资源编辑请求」。 + expect(deriveCalls[1]?.prompt).toBe('把角色头发设定改为亮红色'); + expect(deriveCalls[1]?.operationId).not.toBe(deriveCalls[0]?.operationId); + expect(deriveCalls[1]?.idempotencyKey).not.toBe( + deriveCalls[0]?.idempotencyKey, + ); + }); + + it('快速编辑里润色失败:保留原文、给出可重试提示,也不换请求身份', async () => { + const { deriveCalls } = installTauri({ failFirstDerive: true }); + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开待归类' })); + fireEvent.click(await findResourceSelectButton('source-art.png')); + const toolbar = await screen.findByRole('toolbar', { + name: '图片工具栏', + }); + fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' })); + const panel = await screen.findByRole('dialog', { + name: '快速编辑图片', + }); + await setComposerText( + within(panel).getByLabelText('快速编辑提示词'), + '把角色头发改成红色', + ); + fireEvent.click(within(panel).getByRole('button', { name: '修改' })); + expect(await screen.findByRole('alert')).not.toBeNull(); + + fireEvent.click(within(panel).getByRole('button', { name: 'AI 润色' })); + expect( + await within(panel).findByText('AI 润色失败,可重试'), + ).not.toBeNull(); + expect( + within(panel).getByLabelText('快速编辑提示词').textContent, + ).toContain('把角色头发改成红色'); + + fireEvent.click(within(panel).getByRole('button', { name: '修改' })); + await waitFor(() => expect(deriveCalls).toHaveLength(2)); + // 提示词没变,身份也不该变:重试仍然命中同一 operation 账本。 + expect(deriveCalls[1]?.operationId).toBe(deriveCalls[0]?.operationId); + expect(deriveCalls[1]?.idempotencyKey).toBe(deriveCalls[0]?.idempotencyKey); + }); + it('creates a brand new media asset from the canvas generation entry with a create-mode derive request', async () => { const { deriveCalls } = installTauri({ failFirstDerive: true }); render();