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();