fix: support confirmed slot replacement fills

This commit is contained in:
134 2026-06-23 21:33:30 +08:00
parent 652bc75ed0
commit 020a74a6a8
2 changed files with 190 additions and 11 deletions

View File

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

View File

@ -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({