fix: support confirmed slot replacement fills
This commit is contained in:
parent
652bc75ed0
commit
020a74a6a8
@ -433,6 +433,34 @@ function getNodePosition(node: any) {
|
||||
};
|
||||
}
|
||||
|
||||
function removeCanvasNodesAndConnectedEdges(params: {
|
||||
nodes: any[];
|
||||
edges: any[];
|
||||
nodeIds: string[];
|
||||
}) {
|
||||
const deleteSet = new Set(params.nodeIds.filter(Boolean));
|
||||
if (deleteSet.size === 0) return [];
|
||||
|
||||
const removedNodeIds = params.nodes
|
||||
.filter((node) => deleteSet.has(node?.id))
|
||||
.map((node) => node.id);
|
||||
if (removedNodeIds.length === 0) return [];
|
||||
|
||||
for (let index = params.nodes.length - 1; index >= 0; index -= 1) {
|
||||
if (deleteSet.has(params.nodes[index]?.id)) {
|
||||
params.nodes.splice(index, 1);
|
||||
}
|
||||
}
|
||||
for (let index = params.edges.length - 1; index >= 0; index -= 1) {
|
||||
const edge = params.edges[index];
|
||||
if (deleteSet.has(edge?.source) || deleteSet.has(edge?.target)) {
|
||||
params.edges.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return removedNodeIds;
|
||||
}
|
||||
|
||||
function isConfirmedHighRiskPlan(plan: CanvasOperationPlan) {
|
||||
const metadata = (plan.metadata || {}) as Record<string, unknown>;
|
||||
return plan.confirmed === true || metadata.confirmedByUser === true;
|
||||
@ -1458,6 +1486,7 @@ function applyOperationToCanvasData(params: {
|
||||
if (params.plan.operation === "fill_slot" && !targetSlot) {
|
||||
throw new CanvasAgentMutationError("目标 slot 不存在");
|
||||
}
|
||||
let replacedSlotNodeIds: string[] = [];
|
||||
if (targetSlot && params.plan.operation === "fill_slot") {
|
||||
const targetSlotData = (targetSlot.data || {}) as Record<string, unknown>;
|
||||
const fillPolicy = normalizeSlotFillPolicy(targetSlotData.fillPolicy);
|
||||
@ -1470,13 +1499,28 @@ function applyOperationToCanvasData(params: {
|
||||
mediaItems,
|
||||
});
|
||||
|
||||
if (fillPolicy === "replace-existing" && filledNodeIds.length > 0) {
|
||||
if (params.baseCanvasWasStale && fillPolicy === "replace-existing") {
|
||||
throw new CanvasAgentMutationError(
|
||||
"当前 slot 已有结果,替换已有结果需要用户确认",
|
||||
"画布已更新,替换 slot 前需要刷新并确认",
|
||||
409,
|
||||
"CANVAS_AGENT_CONFIRMATION_REQUIRED"
|
||||
"STALE_CANVAS_REVISION"
|
||||
);
|
||||
}
|
||||
|
||||
if (fillPolicy === "replace-existing" && filledNodeIds.length > 0) {
|
||||
if (!isConfirmedHighRiskPlan(params.plan)) {
|
||||
throw new CanvasAgentMutationError(
|
||||
"当前 slot 已有结果,替换已有结果需要用户确认",
|
||||
409,
|
||||
"CANVAS_AGENT_CONFIRMATION_REQUIRED"
|
||||
);
|
||||
}
|
||||
replacedSlotNodeIds = removeCanvasNodesAndConnectedEdges({
|
||||
nodes,
|
||||
edges,
|
||||
nodeIds: filledNodeIds,
|
||||
});
|
||||
}
|
||||
if (fillPolicy === "replace-empty" && filledNodeIds.length > 0) {
|
||||
throw new CanvasAgentMutationError(
|
||||
"当前 slot 已被填充,请新建 slot 或使用追加版本",
|
||||
@ -1484,13 +1528,6 @@ function applyOperationToCanvasData(params: {
|
||||
"CANVAS_AGENT_CONFLICT"
|
||||
);
|
||||
}
|
||||
if (params.baseCanvasWasStale && fillPolicy === "replace-existing") {
|
||||
throw new CanvasAgentMutationError(
|
||||
"画布已更新,替换 slot 前需要刷新并确认",
|
||||
409,
|
||||
"STALE_CANVAS_REVISION"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const createdNodeIds: string[] = [];
|
||||
@ -1513,6 +1550,17 @@ function applyOperationToCanvasData(params: {
|
||||
size: placement.size,
|
||||
sourceNodeIds: placement.sourceNodeIds,
|
||||
});
|
||||
if (replacedSlotNodeIds.length > 0) {
|
||||
const nodeData = (node.data || {}) as Record<string, any>;
|
||||
(node as any).data = {
|
||||
...nodeData,
|
||||
metadata: {
|
||||
...(nodeData.metadata || {}),
|
||||
replacedSlotNodeIds,
|
||||
replacedSlotId: targetSlot?.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
nodes.push(node);
|
||||
createdNodeIds.push(node.id);
|
||||
createdNodeIdsByMediaItemId[placement.item.mediaItemId] = node.id;
|
||||
@ -1534,7 +1582,20 @@ function applyOperationToCanvasData(params: {
|
||||
? data.filledNodeIds
|
||||
: [];
|
||||
data.status = "completed";
|
||||
data.filledNodeIds = Array.from(new Set([...filledNodeIds, node.id]));
|
||||
data.filledNodeIds =
|
||||
replacedSlotNodeIds.length > 0
|
||||
? Array.from(new Set(createdNodeIds))
|
||||
: Array.from(new Set([...filledNodeIds, node.id]));
|
||||
data.metadata = {
|
||||
...((data.metadata || {}) as Record<string, unknown>),
|
||||
...(replacedSlotNodeIds.length > 0
|
||||
? {
|
||||
replacedSlotNodeIds,
|
||||
replacedByCanvasAgentOperationId: params.plan.operationId,
|
||||
replacedAt: new Date().toISOString(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
targetSlot.data = data;
|
||||
if (!updatedNodeIds.includes(targetSlot.id)) {
|
||||
updatedNodeIds.push(targetSlot.id);
|
||||
|
||||
@ -775,6 +775,124 @@ test.describe("canvas agent operation mutation contract", () => {
|
||||
).toThrow("目标 slot 是图片槽位,不能填充视频结果");
|
||||
});
|
||||
|
||||
test("confirms replace-existing slot fills and keeps retries idempotent", () => {
|
||||
const slot = buildAgentSlotNode("slot-replace-existing") as any;
|
||||
slot.data = {
|
||||
...slot.data,
|
||||
fillPolicy: "replace-existing",
|
||||
filledNodeIds: ["old-slot-result"],
|
||||
};
|
||||
const oldResult = {
|
||||
...buildImageNode("old-slot-result"),
|
||||
position: { x: 500, y: 120 },
|
||||
};
|
||||
const unrelated = buildImageNode("unrelated-image");
|
||||
const canvasData = {
|
||||
...getCanvasData([slot, oldResult, unrelated]),
|
||||
edges: [
|
||||
{
|
||||
id: "edge-slot-old-result",
|
||||
source: "slot-replace-existing",
|
||||
target: "old-slot-result",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
selectedNodeIds: ["slot-replace-existing"],
|
||||
};
|
||||
const plan: CanvasOperationPlan = {
|
||||
version: 1,
|
||||
operationId: "op-replace-existing-slot-fill",
|
||||
operation: "fill_slot",
|
||||
projectId,
|
||||
targetSlotId: "slot-replace-existing",
|
||||
mediaItems: [buildMutationMediaItem("media-new-slot-result")],
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
applyCanvasOperationPlanToCanvasData({
|
||||
canvasData,
|
||||
plan,
|
||||
})
|
||||
).toThrow(/需要用户确认/);
|
||||
|
||||
let staleError: unknown;
|
||||
try {
|
||||
applyCanvasOperationPlanToCanvasData({
|
||||
canvasData,
|
||||
baseCanvasWasStale: true,
|
||||
plan: {
|
||||
...plan,
|
||||
confirmed: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
staleError = error;
|
||||
}
|
||||
|
||||
expect(staleError).toBeInstanceOf(CanvasAgentMutationError);
|
||||
expect((staleError as CanvasAgentMutationError).code).toBe(
|
||||
"STALE_CANVAS_REVISION"
|
||||
);
|
||||
|
||||
const replaced = applyCanvasOperationPlanToCanvasData({
|
||||
canvasData,
|
||||
plan: {
|
||||
...plan,
|
||||
confirmed: true,
|
||||
metadata: {
|
||||
confirmedByUser: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const replacedNodes = getResultNodes(replaced.canvasData);
|
||||
const newNodeId = replaced.createdNodeIds[0];
|
||||
|
||||
expect(replaced.createdNodeIds).toHaveLength(1);
|
||||
expect(replaced.createdNodeIdsByMediaItemId).toEqual({
|
||||
"media-new-slot-result": newNodeId,
|
||||
});
|
||||
expect(replacedNodes.some((node) => node.id === "old-slot-result")).toBe(false);
|
||||
expect(replacedNodes.some((node) => node.id === "unrelated-image")).toBe(true);
|
||||
expect(replacedNodes.find((node) => node.id === "slot-replace-existing"))
|
||||
.toMatchObject({
|
||||
data: {
|
||||
filledNodeIds: [newNodeId],
|
||||
metadata: {
|
||||
replacedSlotNodeIds: ["old-slot-result"],
|
||||
replacedByCanvasAgentOperationId: "op-replace-existing-slot-fill",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(replacedNodes.find((node) => node.id === newNodeId)).toMatchObject({
|
||||
data: {
|
||||
metadata: {
|
||||
replacedSlotNodeIds: ["old-slot-result"],
|
||||
replacedSlotId: "slot-replace-existing",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(getResultEdges(replaced.canvasData)).toEqual([
|
||||
expect.objectContaining({
|
||||
source: "slot-replace-existing",
|
||||
target: newNodeId,
|
||||
}),
|
||||
]);
|
||||
|
||||
const retry = applyCanvasOperationPlanToCanvasData({
|
||||
canvasData: replaced.canvasData,
|
||||
plan: {
|
||||
...plan,
|
||||
confirmed: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(retry.createdNodeIds).toEqual(replaced.createdNodeIds);
|
||||
expect(getResultNodes(retry.canvasData)).toHaveLength(replacedNodes.length);
|
||||
expect(
|
||||
getResultNodes(retry.canvasData).filter((node) => node.id === newNodeId)
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("creates append revision nodes without overwriting the source", () => {
|
||||
const source = buildImageNode("image-source");
|
||||
const result = applyCanvasOperationPlanToCanvasData({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user