快速编辑浮层接入提示词润色,提示词变了就重铸请求身份

- 选中工具条的快速编辑面板通过共享 composer 的注入位渲染同一个 ResourcePromptPolishSlot,场景约束为「图片素材的快速编辑提示词」
- 润色回填走 applyResourceQuickEditPrompt:与共享 composer 的 resetFailedDialogStatus 同口径,提示词变了就把上一次的失败状态清掉
- 提交侧请求身份按提示词收口:同 sourceLayerId 且提示词未变时复用(失败重试仍命中同一 operation 账本),提示词变了则重铸 operationId / 幂等键——Rust 的 request_fingerprint 含 prompt,复用旧身份会被判「已绑定到不同资源编辑请求」;正规化结果与 revision 属于别的事实,换身份时原样保留
- 打开面板时的初始身份改用统一的 createResourceEditRequestIdentity(绑定面板草稿提示词)
- 测试:live integration 补两条——润色成功回填并换身份(断言 prompt 与 operationId/幂等键同时变化)、润色失败保留原文且身份不变
- 变异验证:提交侧去掉按提示词重铸(同 sourceLayerId 直接复用旧身份)→ 「润色后换身份」用例红灯(两次 operationId 相同,实测)
This commit is contained in:
2026-09-11 17:02:17 +08:00
parent d44e1ae8ea
commit c2d3292769
2 changed files with 154 additions and 17 deletions
@@ -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<QuickEditPanelState | null>(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={
<ResourcePromptPolishSlot
subject="图片素材的快速编辑提示词"
editKind="image-reference"
prompt={quickEditPanel.prompt}
disabled={
quickEditPanel.status === 'generating'
}
applyPrompt={applyResourceQuickEditPrompt}
/>
}
onSubmit={() => {
void submitResourceQuickEdit();
}}
@@ -250,11 +250,14 @@ describe('project resource live canvas integration', () => {
confirmResourceEditServiceIdentity?: (
input: Record<string, unknown>,
) => Promise<Record<string, unknown>>;
/** 配了才应答 `polish_local_project_prompt`;不配时该命令直接抛错。 */
polishResult?: string;
} = {},
) {
const layoutWrites: Array<Record<string, unknown>> = [];
const graphReads: Array<Record<string, unknown>> = [];
const deriveCalls: Array<Record<string, unknown>> = [];
const polishCalls: Array<Record<string, unknown>> = [];
const resumeCalls: Array<Record<string, unknown>> = [];
const serviceIdentityRequestCalls: Array<Record<string, unknown>> = [];
const serviceIdentityConfirmCalls: Array<Record<string, unknown>> = [];
@@ -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(<DerivedWorkbench includeArt />);
@@ -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(<DerivedWorkbench includeArt />);
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(<DerivedWorkbench includeArt />);
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(<DerivedWorkbench />);