快速编辑浮层接入提示词润色,提示词变了就重铸请求身份
- 选中工具条的快速编辑面板通过共享 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:
@@ -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 />);
|
||||
|
||||
Reference in New Issue
Block a user