From 8a2483ad7d6145a8abc515317f21cf5a2a869cc2 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 23 Jun 2026 23:04:43 +0800 Subject: [PATCH] feat: route canvas agent text updates --- lib/canvas/agent-explicit-operations.ts | 56 ++++++++++++++++ lib/canvas/agent-mutations.ts | 6 +- tests/routes/canvas-agent-operations.test.ts | 67 ++++++++++++++++++++ tests/routes/canvas-chat-helpers.test.ts | 33 ++++++++++ 4 files changed, 161 insertions(+), 1 deletion(-) diff --git a/lib/canvas/agent-explicit-operations.ts b/lib/canvas/agent-explicit-operations.ts index af97867d1..993676e8b 100644 --- a/lib/canvas/agent-explicit-operations.ts +++ b/lib/canvas/agent-explicit-operations.ts @@ -12,6 +12,7 @@ type ExplicitOperationKind = | "create_agent_slot" | "create_text_node" | "rename_node" + | "update_text" | "update_note"; export type CanvasAgentExplicitOperationPlan = { @@ -199,6 +200,60 @@ function buildCreateTextOperation(params: { }; } +function hasImageTextEditIntent(userText: string) { + return /(?:图片|图像|照片|主图|海报|画面|图中|图里|图片里|图片上|海报上).*(?:文字|文本|文案|字).*(?:改|换|替换|编辑|修改|去掉|删除)|(?:文字|文本|文案|字).*(?:图片|图像|照片|主图|海报|画面|图中|图里|图片里|图片上|海报上).*(?:改|换|替换|编辑|修改|去掉|删除)/.test( + userText + ); +} + +function buildUpdateTextOperation(params: { + context: CanvasAgentContext; + projectId: string; + messageId: string; + chatId: string; + userText: string; + requestedNodeIds?: string[]; +}): CanvasAgentExplicitOperationPlan | null { + const text = params.userText.trim(); + const hasUpdateTextIntent = + /(?:选中|当前|这个|这段|该).*(?:文本|文字|文案|标语|说明|copy|text).*(?:改成|改为|修改为|更新为|设为|设置为|换成)|(?:文本|文字|文案|标语|说明|copy|text).*(?:改成|改为|修改为|更新为|设为|设置为|换成)/i.test( + text + ); + if (!hasUpdateTextIntent || hasMediaMutationIntent(text)) return null; + if (hasImageTextEditIntent(text)) return null; + + const textContent = extractContentFromPatterns( + text, + [ + /(?:改成|改为|修改为|更新为|设为|设置为|换成)\s*[::]?\s*([\s\S]+)$/i, + /(?:文本|文字|文案|标语|说明|copy|text)[^::]*(?:改成|改为|修改为|更新为|设为|设置为|换成)\s*[::]?\s*([\s\S]+)$/i, + /[::]\s*([\s\S]+)$/i, + ], + 4000 + ); + if (!textContent) return null; + + const targetNodeIds = getTargetNodeIds({ + context: params.context, + requestedNodeIds: params.requestedNodeIds, + allowedTypes: ["text"], + }).slice(0, 10); + if (targetNodeIds.length === 0) return null; + + return { + operationPlan: { + ...buildPlanBase({ ...params, operation: "update_text" }), + targetNodeIds, + textContent, + }, + reply: "已更新选中文本内容。", + progressTitle: "正在更新画布文本", + progressMessage: "我会只修改选中文本节点的内容,不移动画布节点。", + completedTitle: "画布文本已更新", + failedTitle: "画布文本更新失败", + }; +} + function getSlotTypeLabel(slotType: "image" | "video" | "ui-screen") { if (slotType === "video") return "视频"; if (slotType === "ui-screen") return "UI"; @@ -407,6 +462,7 @@ export function buildCanvasAgentExplicitOperationPlan(params: { return ( buildCreateAgentSlotOperation(commonParams) || buildCreateTextOperation(commonParams) || + buildUpdateTextOperation(commonParams) || buildRenameOperation(commonParams) || buildUpdateNoteOperation(commonParams) || buildAddAnnotationOperation(commonParams) diff --git a/lib/canvas/agent-mutations.ts b/lib/canvas/agent-mutations.ts index 22f731827..24a927b5c 100644 --- a/lib/canvas/agent-mutations.ts +++ b/lib/canvas/agent-mutations.ts @@ -1132,7 +1132,10 @@ function applyOperationToCanvasData(params: { if ( existingOperationNodes.length > 0 && (params.plan.operation === "replace_node_media" || - params.plan.operation === "move_nodes") + params.plan.operation === "move_nodes" || + params.plan.operation === "rename_node" || + params.plan.operation === "update_note" || + params.plan.operation === "update_text") ) { return { canvasData: params.canvasData, @@ -1385,6 +1388,7 @@ function applyOperationToCanvasData(params: { content: textContent, metadata: { ...(data.metadata || {}), + ...(params.plan.metadata || {}), updatedByAgent: true, canvasAgentOperationId: params.plan.operationId, canvasAgentOperation: params.plan.operation, diff --git a/tests/routes/canvas-agent-operations.test.ts b/tests/routes/canvas-agent-operations.test.ts index 128c14fc6..da7680032 100644 --- a/tests/routes/canvas-agent-operations.test.ts +++ b/tests/routes/canvas-agent-operations.test.ts @@ -83,6 +83,23 @@ function buildImageNode(id = "image-1") { }; } +function buildTextNode(id = "text-1") { + return { + id, + type: "text", + position: { x: 160, y: 180 }, + size: { width: 280, height: 120 }, + data: { + content: "旧文案", + fontSize: 18, + color: "#111827", + }, + connections: [], + createdAt: "2026-06-22T00:00:00.000Z", + updatedAt: "2026-06-22T00:00:00.000Z", + }; +} + function buildAgentSlotNode(id = "slot-1", slotType: "image" | "video" = "image") { return { id, @@ -751,6 +768,56 @@ test.describe("canvas agent operation mutation contract", () => { ); }); + test("updates selected text nodes with trace metadata and idempotency", () => { + const plan: CanvasOperationPlan = { + version: 1, + operationId: "op-update-text-node", + operation: "update_text", + projectId, + targetNodeIds: ["text-update"], + textContent: "轻盈强吸力,家务更省心", + metadata: { + messageId: "assistant-message-update-text", + chatId: "chat-1", + }, + }; + + const first = applyCanvasOperationPlanToCanvasData({ + canvasData: getCanvasData([buildTextNode("text-update")]), + plan, + }); + const updatedText = getResultNodes(first.canvasData).find( + (node) => node.id === "text-update" + ); + + expect(first.createdNodeIds).toEqual([]); + expect(first.updatedNodeIds).toEqual(["text-update"]); + expect(updatedText).toMatchObject({ + data: { + content: "轻盈强吸力,家务更省心", + metadata: { + updatedByAgent: true, + canvasAgentOperationId: "op-update-text-node", + canvasAgentOperation: "update_text", + previousContent: "旧文案", + messageId: "assistant-message-update-text", + chatId: "chat-1", + }, + }, + }); + + const retry = applyCanvasOperationPlanToCanvasData({ + canvasData: first.canvasData, + plan, + }); + const retryText = getResultNodes(retry.canvasData).find( + (node) => node.id === "text-update" + ); + + expect(retry.updatedNodeIds).toEqual(["text-update"]); + expect(retryText.data.metadata.previousContent).toBe("旧文案"); + }); + 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 64b01834a..b25fd914b 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -1432,6 +1432,29 @@ test.describe("canvas chat pure helpers", () => { }); }); + test("builds deterministic text update plans for selected text nodes", () => { + const outcome = buildCanvasAgentExplicitOperationPlan({ + context: plannerContext, + projectId: plannerContext.projectId || "", + messageId: "message-1", + chatId: "chat-1", + userText: "把选中文案改成:轻盈强吸力,家务更省心", + }); + + expect(outcome?.operationPlan).toMatchObject({ + operation: "update_text", + targetNodeIds: ["text-1"], + textContent: "轻盈强吸力,家务更省心", + 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, @@ -1601,6 +1624,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", () => {