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) {
|
function isConfirmedHighRiskPlan(plan: CanvasOperationPlan) {
|
||||||
const metadata = (plan.metadata || {}) as Record<string, unknown>;
|
const metadata = (plan.metadata || {}) as Record<string, unknown>;
|
||||||
return plan.confirmed === true || metadata.confirmedByUser === true;
|
return plan.confirmed === true || metadata.confirmedByUser === true;
|
||||||
@ -1458,6 +1486,7 @@ function applyOperationToCanvasData(params: {
|
|||||||
if (params.plan.operation === "fill_slot" && !targetSlot) {
|
if (params.plan.operation === "fill_slot" && !targetSlot) {
|
||||||
throw new CanvasAgentMutationError("目标 slot 不存在");
|
throw new CanvasAgentMutationError("目标 slot 不存在");
|
||||||
}
|
}
|
||||||
|
let replacedSlotNodeIds: string[] = [];
|
||||||
if (targetSlot && params.plan.operation === "fill_slot") {
|
if (targetSlot && params.plan.operation === "fill_slot") {
|
||||||
const targetSlotData = (targetSlot.data || {}) as Record<string, unknown>;
|
const targetSlotData = (targetSlot.data || {}) as Record<string, unknown>;
|
||||||
const fillPolicy = normalizeSlotFillPolicy(targetSlotData.fillPolicy);
|
const fillPolicy = normalizeSlotFillPolicy(targetSlotData.fillPolicy);
|
||||||
@ -1470,13 +1499,28 @@ function applyOperationToCanvasData(params: {
|
|||||||
mediaItems,
|
mediaItems,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (fillPolicy === "replace-existing" && filledNodeIds.length > 0) {
|
if (params.baseCanvasWasStale && fillPolicy === "replace-existing") {
|
||||||
throw new CanvasAgentMutationError(
|
throw new CanvasAgentMutationError(
|
||||||
"当前 slot 已有结果,替换已有结果需要用户确认",
|
"画布已更新,替换 slot 前需要刷新并确认",
|
||||||
409,
|
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) {
|
if (fillPolicy === "replace-empty" && filledNodeIds.length > 0) {
|
||||||
throw new CanvasAgentMutationError(
|
throw new CanvasAgentMutationError(
|
||||||
"当前 slot 已被填充,请新建 slot 或使用追加版本",
|
"当前 slot 已被填充,请新建 slot 或使用追加版本",
|
||||||
@ -1484,13 +1528,6 @@ function applyOperationToCanvasData(params: {
|
|||||||
"CANVAS_AGENT_CONFLICT"
|
"CANVAS_AGENT_CONFLICT"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (params.baseCanvasWasStale && fillPolicy === "replace-existing") {
|
|
||||||
throw new CanvasAgentMutationError(
|
|
||||||
"画布已更新,替换 slot 前需要刷新并确认",
|
|
||||||
409,
|
|
||||||
"STALE_CANVAS_REVISION"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdNodeIds: string[] = [];
|
const createdNodeIds: string[] = [];
|
||||||
@ -1513,6 +1550,17 @@ function applyOperationToCanvasData(params: {
|
|||||||
size: placement.size,
|
size: placement.size,
|
||||||
sourceNodeIds: placement.sourceNodeIds,
|
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);
|
nodes.push(node);
|
||||||
createdNodeIds.push(node.id);
|
createdNodeIds.push(node.id);
|
||||||
createdNodeIdsByMediaItemId[placement.item.mediaItemId] = node.id;
|
createdNodeIdsByMediaItemId[placement.item.mediaItemId] = node.id;
|
||||||
@ -1534,7 +1582,20 @@ function applyOperationToCanvasData(params: {
|
|||||||
? data.filledNodeIds
|
? data.filledNodeIds
|
||||||
: [];
|
: [];
|
||||||
data.status = "completed";
|
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;
|
targetSlot.data = data;
|
||||||
if (!updatedNodeIds.includes(targetSlot.id)) {
|
if (!updatedNodeIds.includes(targetSlot.id)) {
|
||||||
updatedNodeIds.push(targetSlot.id);
|
updatedNodeIds.push(targetSlot.id);
|
||||||
|
|||||||
@ -775,6 +775,124 @@ test.describe("canvas agent operation mutation contract", () => {
|
|||||||
).toThrow("目标 slot 是图片槽位,不能填充视频结果");
|
).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", () => {
|
test("creates append revision nodes without overwriting the source", () => {
|
||||||
const source = buildImageNode("image-source");
|
const source = buildImageNode("image-source");
|
||||||
const result = applyCanvasOperationPlanToCanvasData({
|
const result = applyCanvasOperationPlanToCanvasData({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user