diff --git a/lib/canvas/agent-mutations.ts b/lib/canvas/agent-mutations.ts index 15fe50083..22f731827 100644 --- a/lib/canvas/agent-mutations.ts +++ b/lib/canvas/agent-mutations.ts @@ -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; 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; 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; + (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), + ...(replacedSlotNodeIds.length > 0 + ? { + replacedSlotNodeIds, + replacedByCanvasAgentOperationId: params.plan.operationId, + replacedAt: new Date().toISOString(), + } + : {}), + }; targetSlot.data = data; if (!updatedNodeIds.includes(targetSlot.id)) { updatedNodeIds.push(targetSlot.id); diff --git a/tests/routes/canvas-agent-operations.test.ts b/tests/routes/canvas-agent-operations.test.ts index af0551ee4..5d6f23715 100644 --- a/tests/routes/canvas-agent-operations.test.ts +++ b/tests/routes/canvas-agent-operations.test.ts @@ -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({