diff --git a/lib/canvas/agent-explicit-operations.ts b/lib/canvas/agent-explicit-operations.ts index 993676e8b..4395b379d 100644 --- a/lib/canvas/agent-explicit-operations.ts +++ b/lib/canvas/agent-explicit-operations.ts @@ -11,6 +11,7 @@ type ExplicitOperationKind = | "add_annotation" | "create_agent_slot" | "create_text_node" + | "link_nodes" | "rename_node" | "update_text" | "update_note"; @@ -254,6 +255,38 @@ function buildUpdateTextOperation(params: { }; } +function buildLinkNodesOperation(params: { + context: CanvasAgentContext; + projectId: string; + messageId: string; + chatId: string; + userText: string; + requestedNodeIds?: string[]; +}): CanvasAgentExplicitOperationPlan | null { + const text = params.userText.trim(); + const hasLinkIntent = /连起来|连线|连接|关联|参考关系|版本关系|建立关系/.test(text); + if (!hasLinkIntent || hasMediaMutationIntent(text)) return null; + + const targetNodeIds = getTargetNodeIds({ + context: params.context, + requestedNodeIds: params.requestedNodeIds, + }).slice(0, 20); + if (targetNodeIds.length < 2) return null; + + return { + operationPlan: { + ...buildPlanBase({ ...params, operation: "link_nodes" }), + sourceNodeIds: targetNodeIds, + edgeType: "reference", + }, + reply: "已为选中的画布节点建立参考连线。", + progressTitle: "正在连接画布节点", + progressMessage: "我会给选中的画布节点建立参考关系,不修改节点内容。", + completedTitle: "画布节点已连接", + failedTitle: "画布节点连接失败", + }; +} + function getSlotTypeLabel(slotType: "image" | "video" | "ui-screen") { if (slotType === "video") return "视频"; if (slotType === "ui-screen") return "UI"; @@ -463,6 +496,7 @@ export function buildCanvasAgentExplicitOperationPlan(params: { buildCreateAgentSlotOperation(commonParams) || buildCreateTextOperation(commonParams) || buildUpdateTextOperation(commonParams) || + buildLinkNodesOperation(commonParams) || buildRenameOperation(commonParams) || buildUpdateNoteOperation(commonParams) || buildAddAnnotationOperation(commonParams) diff --git a/tests/routes/canvas-agent-operations.test.ts b/tests/routes/canvas-agent-operations.test.ts index da7680032..5c675471d 100644 --- a/tests/routes/canvas-agent-operations.test.ts +++ b/tests/routes/canvas-agent-operations.test.ts @@ -818,6 +818,60 @@ test.describe("canvas agent operation mutation contract", () => { expect(retryText.data.metadata.previousContent).toBe("旧文案"); }); + test("links selected canvas nodes without duplicating existing edges", () => { + const source = buildImageNode("image-link-source"); + const target = buildTextNode("text-link-target"); + const plan: CanvasOperationPlan = { + version: 1, + operationId: "op-link-nodes", + operation: "link_nodes", + projectId, + sourceNodeIds: ["image-link-source", "text-link-target"], + edgeType: "reference", + metadata: { + messageId: "assistant-message-link", + chatId: "chat-1", + }, + }; + + const first = applyCanvasOperationPlanToCanvasData({ + canvasData: getCanvasData([source, target]), + plan, + }); + + expect(first.createdNodeIds).toEqual([]); + expect(first.updatedNodeIds).toEqual([ + "image-link-source", + "text-link-target", + ]); + expect(first.createdEdgeIds).toHaveLength(1); + expect(getResultEdges(first.canvasData)).toEqual([ + expect.objectContaining({ + source: "image-link-source", + target: "text-link-target", + type: "reference", + }), + ]); + expect(getResultNodes(first.canvasData)).toEqual([ + expect.objectContaining({ + id: "image-link-source", + connections: ["text-link-target"], + }), + expect.objectContaining({ + id: "text-link-target", + connections: ["image-link-source"], + }), + ]); + + const retry = applyCanvasOperationPlanToCanvasData({ + canvasData: first.canvasData, + plan, + }); + + expect(retry.createdEdgeIds).toEqual([]); + expect(getResultEdges(retry.canvasData)).toHaveLength(1); + }); + test("fills an agent slot and returns media item node mappings", () => { const slot = buildAgentSlotNode(); const result = applyCanvasOperationPlanToCanvasData({ diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index b25fd914b..3d8380db0 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -1455,6 +1455,29 @@ test.describe("canvas chat pure helpers", () => { expect(outcome?.reply).toContain("文本"); }); + test("builds deterministic link plans for selected canvas nodes", () => { + const outcome = buildCanvasAgentExplicitOperationPlan({ + context: plannerContext, + projectId: plannerContext.projectId || "", + messageId: "message-1", + chatId: "chat-1", + userText: "把选中的两个节点连成参考关系", + }); + + expect(outcome?.operationPlan).toMatchObject({ + operation: "link_nodes", + sourceNodeIds: ["image-1", "text-1"], + edgeType: "reference", + preserveOriginal: true, + metadata: { + messageId: "message-1", + chatId: "chat-1", + planner: "deterministic", + }, + }); + expect(outcome?.reply).toContain("参考连线"); + }); + test("builds deterministic agent slot creation plans from explicit canvas prompts", () => { const context: CanvasAgentContext = { ...plannerContext, @@ -1634,6 +1657,16 @@ test.describe("canvas chat pure helpers", () => { userText: "把图片里的文字改成新品上市", }) ).toBeNull(); + + expect( + buildCanvasAgentExplicitOperationPlan({ + context: plannerContext, + projectId: plannerContext.projectId || "", + messageId: "message-1", + chatId: "chat-1", + userText: "生成一张图并和选中图连接", + }) + ).toBeNull(); }); test("normalizes model-planned annotations to selected media nodes", () => {