diff --git a/lib/canvas/agent-direct-writeback.ts b/lib/canvas/agent-direct-writeback.ts index d1e4a0000..ecf61d8a9 100644 --- a/lib/canvas/agent-direct-writeback.ts +++ b/lib/canvas/agent-direct-writeback.ts @@ -5,6 +5,7 @@ import { } from "@/lib/canvas/agent-context"; import type { CanvasAgentContext, + CanvasAgentNodeRef, CanvasMediaItem, CanvasOperationKind, CanvasOperationPlan, @@ -46,6 +47,28 @@ export interface CanvasAgentDirectWritebackPlanResult { selectedMediaNodes: ReturnType; } +function buildKnownNodeMap(context: CanvasAgentContext) { + return new Map( + [...context.candidateNodes, ...context.selectedNodes].map((node) => [ + node.nodeId, + node, + ]) + ); +} + +function resolveTargetMediaNodes(params: { + context: CanvasAgentContext; + targetNodeIds: string[]; +}) { + const knownNodes = buildKnownNodeMap(params.context); + return params.targetNodeIds + .map((nodeId) => knownNodes.get(nodeId)) + .filter( + (node): node is CanvasAgentNodeRef => + node !== undefined && (node.type === "image" || node.type === "video") + ); +} + export function buildCanvasAgentDirectWritebackPlan( params: BuildCanvasAgentDirectWritebackPlanParams ): CanvasAgentDirectWritebackPlanResult | null { @@ -62,17 +85,26 @@ export function buildCanvasAgentDirectWritebackPlan( const targetSlotId = preferredTargetSlotId || selectedSlot?.nodeId || ""; const shouldFillSlot = Boolean(targetSlotId); const selectedMediaNodes = getSelectedMediaNodes(params.context); - const anchorNode = - selectedSlot || selectedMediaNodes[0] || getBestAnchorNode(params.context); const targetRefs = params.targetRefs || []; const targetNodeIds = Array.from( new Set( targetRefs - .map((ref) => ref.nodeId) + .map((ref) => String(ref.nodeId || "").trim()) .filter((value): value is string => Boolean(value)) ) ); - const selectedMediaNodeIds = selectedMediaNodes + const targetMediaNodes = resolveTargetMediaNodes({ + context: params.context, + targetNodeIds, + }); + const writebackMediaNodes = + targetMediaNodes.length > 0 ? targetMediaNodes : selectedMediaNodes; + const anchorNode = + selectedSlot || + targetMediaNodes[0] || + selectedMediaNodes[0] || + getBestAnchorNode(params.context); + const selectedMediaNodeIds = writebackMediaNodes .map((node) => node.nodeId) .filter((value): value is string => Boolean(value)); const perResultSourceNodeIds = @@ -154,6 +186,6 @@ export function buildCanvasAgentDirectWritebackPlan( operation, targetSlotId, shouldFillSlot, - selectedMediaNodes, + selectedMediaNodes: writebackMediaNodes, }; } diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 8141232ab..c6e908d98 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -509,6 +509,67 @@ test.describe("canvas chat pure helpers", () => { }); }); + test("anchors direct image edits to resolved target refs before fallback selection", () => { + const context: CanvasAgentContext = { + ...plannerContext, + selectedNodeIds: [], + selectedNodes: [], + candidateNodes: [ + { + nodeId: "image-left", + type: "image", + title: "左图", + x: 0, + y: 0, + width: 240, + height: 180, + }, + { + nodeId: "image-right", + type: "image", + title: "右图", + x: 420, + y: 0, + width: 240, + height: 180, + }, + ], + focusPolicy: "auto", + }; + + const result = buildCanvasAgentDirectWritebackPlan({ + projectId: plannerContext.projectId || "", + chatId: "chat-1", + assistantMessageId: "assistant-targeted", + operationLabel: "agent-edit-image", + resultType: "image", + resultUrls: ["https://cdn.example.com/right-revision.png"], + prompt: "把右边那张背景换成白色", + mode: "edit", + context, + targetRefs: [{ nodeId: " image-right " }], + sourceImageUrls: ["https://cdn.example.com/right-source.png"], + }); + + expect(result).toMatchObject({ + operation: "append_revision", + plan: { + operation: "append_revision", + anchorNodeId: "image-right", + sourceNodeIds: ["image-right"], + }, + }); + expect(result?.selectedMediaNodes.map((node) => node.nodeId)).toEqual([ + "image-right", + ]); + expect(result?.mediaItems[0]).toMatchObject({ + sourceNodeIds: ["image-right"], + metadata: { + sourceImageUrls: ["https://cdn.example.com/right-source.png"], + }, + }); + }); + test("plans direct batch generations beside each matched source", () => { const context: CanvasAgentContext = { ...plannerContext,