From 3a2a7f2490702f9e6d827426fa9c454e3fe97556 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 23 Jun 2026 16:54:18 +0800 Subject: [PATCH] feat: let canvas agent create text nodes --- ...框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md | 4 + lib/canvas/agent-model-planner.ts | 92 +++++++++++++++++- lib/canvas/agent-mutations.ts | 96 +++++++++++++++++++ lib/canvas/agent-operation-request.ts | 11 +++ tests/routes/canvas-agent-operations.test.ts | 73 ++++++++++++++ tests/routes/canvas-chat-helpers.test.ts | 67 +++++++++++++ types/canvas/agent-protocol.ts | 1 + 7 files changed, 341 insertions(+), 3 deletions(-) diff --git a/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md b/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md index 42f65a1a0..101c26c67 100644 --- a/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md +++ b/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md @@ -608,6 +608,7 @@ export interface CanvasAgentAnnotationRef { ```ts export type CanvasOperationKind = + | "create_text_node" | "fill_slot" | "insert_beside" | "append_revision" @@ -740,6 +741,7 @@ Agent 首期只能请求以下操作,后端 mutation 层也只接受这些操 | `insert_beside` | 在锚点旁边插入新结果 | 低 | 否 | | `append_revision` | 基于源节点追加一个新版本 | 低 | 否 | | `link_nodes` | 给两个节点建立参考/版本关系 | 低 | 否 | +| `create_text_node` | 在当前视口或指定位置创建文本节点 | 低 | 否 | | `rename_node` | 修改图片/视频/slot/节点标题 | 低 | 否 | | `update_note` | 更新图片/视频节点备注并同步结构化备注标注 | 低 | 否 | | `update_text` | 修改选中文本节点内容 | 中 | 视情况 | @@ -752,6 +754,7 @@ Agent 首期只能请求以下操作,后端 mutation 层也只接受这些操 - `insert_beside` - `append_revision` - `link_nodes` +- `create_text_node` - `rename_node` - `update_note` @@ -1174,6 +1177,7 @@ hasSelectedAgentSlot?: boolean; | “把这两张连起来” | `link_nodes` | selected nodes | | “把选中图片改名为白底主图 A” | `rename_node` | selected image/video/slot node | | “给选中图片加备注:保留主体轮廓” | `update_note` | selected image/video node | +| “在画布上添加文字:新品上市” | `create_text_node` | current viewport center 或 targetPosition | | “把这段文字改成更高级” | `update_text` | selected text node | | “替换掉原图” | `replace_node_media` | selected image,需要确认 | | “把这些整理成三列” | `move_nodes` | selected nodes,建议确认 | diff --git a/lib/canvas/agent-model-planner.ts b/lib/canvas/agent-model-planner.ts index a32e27df4..3c796ffd3 100644 --- a/lib/canvas/agent-model-planner.ts +++ b/lib/canvas/agent-model-planner.ts @@ -16,6 +16,7 @@ const plannerDecisionSchema = z.object({ "zoom_in", "zoom_out", "link_nodes", + "create_text_node", "update_text", "rename_node", "update_note", @@ -314,6 +315,17 @@ function hasCanvasAgentSlotCreationIntent(userText: string) { return resolveCanvasAgentSlotCreationIntent({ userText }).shouldCreate; } +function hasCanvasAgentTextCreationIntent(userText: string) { + const text = userText.trim().toLowerCase(); + if (!text) return false; + const hasCreateVerb = + /创建|新增|新建|添加|加一段|加一句|写一段|写一句|放一段|放一句|create|add|new/.test( + text + ) || /在(?:画布|这里|当前|旁边).*写|写到画布|写在画布|写上/.test(text); + const hasTextWord = /文本|文字|文案|标题|标语|说明|caption|copy|text/.test(text); + return hasCreateVerb && hasTextWord; +} + function buildViewPlan(params: { action: PlannerDecision["action"]; context: CanvasAgentContext; @@ -421,6 +433,7 @@ export function shouldConsiderCanvasAgentModelPlanner(params: { /文本|文字|文案|标题|标签|改写|润色|优化|改成|改为|设置为|设为|替换为/.test( text ); + const hasCreateTextIntent = hasCanvasAgentTextCreationIntent(text); const hasRenameIntent = /改名|重命名|命名|标题/.test(text); const hasNoteIntent = /备注|note/.test(text); const hasAnnotationIntent = /批注|标注|备注|note|annotation/.test(text); @@ -436,6 +449,7 @@ export function shouldConsiderCanvasAgentModelPlanner(params: { !hasViewIntent && !hasLinkIntent && !hasTextIntent && + !hasCreateTextIntent && !hasRenameIntent && !hasNoteIntent && !hasAnnotationIntent && @@ -449,6 +463,7 @@ export function shouldConsiderCanvasAgentModelPlanner(params: { hasViewIntent || hasLinkIntent || hasTextIntent || + hasCreateTextIntent || hasRenameIntent || hasNoteIntent || hasAnnotationIntent || @@ -772,6 +787,41 @@ export function normalizeCanvasAgentModelPlannerDecision(params: { }; } + if (decision.action === "create_text_node") { + const textContent = trimString(decision.textContent, 4000); + if (!textContent) { + return { kind: "none", reason: "create_text_node_missing_content" }; + } + const textSize = { width: 320, height: 120 }; + const operationPlan: CanvasOperationPlan = { + version: 1, + operationId, + operation: "create_text_node", + projectId: params.projectId, + baseCanvasRevision: params.context.canvasRevision, + baseCanvasUpdatedAt: params.context.canvasUpdatedAt, + targetPosition: getViewportCenteredSlotPosition({ + context: params.context, + size: textSize, + }), + textContent, + preserveOriginal: true, + metadata: { + messageId: params.messageId, + chatId: params.chatId, + planner: "model", + prompt: params.userText, + canvasAgentViewport: params.context.viewport, + }, + }; + return { + kind: "operation", + operationPlan, + reply: trimString(decision.reply, 280) || "已在当前画布添加文本节点。", + reason, + }; + } + if (decision.action === "update_text") { const knownNodes = buildKnownNodeMap(params.context); const requestedIds = getDecisionNodeIds({ @@ -784,6 +834,41 @@ export function normalizeCanvasAgentModelPlannerDecision(params: { (nodeId) => knownNodes.get(nodeId)?.type === "text" ); const textContent = trimString(decision.textContent, 4000); + if ( + targetNodeIds.length === 0 && + textContent && + hasCanvasAgentTextCreationIntent(params.userText) + ) { + const textSize = { width: 320, height: 120 }; + const operationPlan: CanvasOperationPlan = { + version: 1, + operationId: operationId.replace("canvas-model-update_text", "canvas-model-create_text_node"), + operation: "create_text_node", + projectId: params.projectId, + baseCanvasRevision: params.context.canvasRevision, + baseCanvasUpdatedAt: params.context.canvasUpdatedAt, + targetPosition: getViewportCenteredSlotPosition({ + context: params.context, + size: textSize, + }), + textContent, + preserveOriginal: true, + metadata: { + messageId: params.messageId, + chatId: params.chatId, + planner: "model", + prompt: params.userText, + normalizedFromAction: "update_text", + canvasAgentViewport: params.context.viewport, + }, + }; + return { + kind: "operation", + operationPlan, + reply: trimString(decision.reply, 280) || "已在当前画布添加文本节点。", + reason, + }; + } if (targetNodeIds.length === 0 || !textContent) { return { kind: "none", reason: "update_text_missing_target_or_content" }; } @@ -892,9 +977,10 @@ export async function planCanvasAgentOperationWithModel(params: { abortSignal: params.abortSignal || AbortSignal.timeout(18_000), system: [ "你是无限画布 Agent 的受控操作规划器。", - "你只能规划低风险画布操作:视角聚焦/缩放/看全局、连接节点、更新文本节点、修改节点标题、更新图片或视频备注、给图片或视频节点添加批注、创建空 Agent 槽位。", + "你只能规划低风险画布操作:视角聚焦/缩放/看全局、连接节点、新建文本节点、更新文本节点、修改节点标题、更新图片或视频备注、给图片或视频节点添加批注、创建空 Agent 槽位。", "图片/视频生成、换背景、抠图、高清、扩图、重绘等媒体任务必须返回 action=none,交给其他执行器。", "当用户明确要求新建/添加空图片槽位、视频槽位、UI槽位、空框或占位时,可以返回 action=create_agent_slot;如果同时要求生成或填充媒体,返回 action=none。", + "当用户明确要求在画布中新建/添加/写入文本节点、文案、说明、标题或标语时,返回 action=create_text_node,并把最终文本写入 textContent。", "当用户要求给图片/视频/slot/节点改名、重命名或修改标题时,返回 action=rename_node;不要把图片节点标题改名误判为 update_text。", "当用户要求给图片或视频添加/更新备注时,返回 action=update_note;批注、标注、箭头、框选说明才返回 action=add_annotation。", "删除、覆盖、替换现有图片、移动/重排节点等高风险动作必须返回 action=confirmation_required。", @@ -907,10 +993,10 @@ export async function planCanvasAgentOperationWithModel(params: { context: buildContextSummary(params.context), outputContract: { action: - "none | focus_nodes | fit_all | zoom_in | zoom_out | link_nodes | update_text | rename_node | update_note | add_annotation | create_agent_slot | confirmation_required", + "none | focus_nodes | fit_all | zoom_in | zoom_out | link_nodes | create_text_node | update_text | rename_node | update_note | add_annotation | create_agent_slot | confirmation_required", nodeIds: "只填上下文存在的 nodeId", textContent: - "update_text 时填写最终文本;rename_node 时填写新标题;update_note 时填写备注内容;add_annotation 时填写批注内容", + "create_text_node/update_text 时填写最终文本;rename_node 时填写新标题;update_note 时填写备注内容;add_annotation 时填写批注内容", slotType: "create_agent_slot 时填写 image/video/ui-screen", slotLabel: "create_agent_slot 时可填写槽位名称", aspectRatio: "create_agent_slot 时可填写 1:1/16:9 等比例", diff --git a/lib/canvas/agent-mutations.ts b/lib/canvas/agent-mutations.ts index e5b35f165..bd654e239 100644 --- a/lib/canvas/agent-mutations.ts +++ b/lib/canvas/agent-mutations.ts @@ -921,6 +921,82 @@ function applyCreateAgentSlotOperation(params: { }; } +function getTextNodeSize(textContent: string) { + const lines = textContent.split(/\r?\n/); + const longestLineLength = Math.max( + 1, + ...lines.map((line) => Array.from(line).length) + ); + return { + width: Math.min(520, Math.max(260, longestLineLength * 10 + 48)), + height: Math.min(280, Math.max(96, lines.length * 30 + 56)), + }; +} + +function applyCreateTextNodeOperation(params: { + canvasData: CanvasDataLike; + nodes: any[]; + edges: any[]; + plan: CanvasOperationPlan; +}) { + const textContent = String(params.plan.textContent || "").trim().slice(0, 4000); + if (!textContent) { + throw new CanvasAgentMutationError("缺少文本内容"); + } + + const now = new Date().toISOString(); + const size = getTextNodeSize(textContent); + const targetPosition = + params.plan.targetPosition && + Number.isFinite(Number(params.plan.targetPosition.x)) && + Number.isFinite(Number(params.plan.targetPosition.y)) + ? { + x: Number(params.plan.targetPosition.x), + y: Number(params.plan.targetPosition.y), + } + : getCanvasViewportCenterPosition({ + canvasData: params.canvasData, + size, + }); + const node = { + id: generateId(), + type: "text", + position: targetPosition, + size, + data: { + content: textContent, + fontSize: 18, + color: "#111827", + lineHeight: 1.4, + metadata: { + ...(params.plan.metadata || {}), + createdByAgent: true, + canvasAgentOperationId: params.plan.operationId, + canvasAgentOperation: params.plan.operation, + agentCreatedAt: now, + }, + }, + connections: [], + createdAt: now, + updatedAt: now, + }; + + params.nodes.push(node); + + return { + canvasData: { + ...params.canvasData, + nodes: params.nodes, + edges: params.edges, + selectedNodeIds: [node.id], + }, + createdNodeIds: [node.id], + createdNodeIdsByMediaItemId: {}, + updatedNodeIds: [], + createdEdgeIds: [], + }; +} + function applyOperationToCanvasData(params: { canvasData: CanvasDataLike; plan: CanvasOperationPlan; @@ -931,6 +1007,7 @@ function applyOperationToCanvasData(params: { } if ( params.plan.operation !== "create_agent_slot" && + params.plan.operation !== "create_text_node" && params.plan.operation !== "insert_beside" && params.plan.operation !== "append_revision" && params.plan.operation !== "fill_slot" && @@ -1050,6 +1127,25 @@ function applyOperationToCanvasData(params: { }); } + if (params.plan.operation === "create_text_node") { + if (existingOperationNodes.length > 0) { + return { + canvasData: params.canvasData, + createdNodeIds: existingOperationNodes.map((node) => node.id), + createdNodeIdsByMediaItemId: {}, + updatedNodeIds: [], + createdEdgeIds: [], + }; + } + + return applyCreateTextNodeOperation({ + canvasData: params.canvasData, + nodes, + edges, + plan: params.plan, + }); + } + if (params.plan.operation === "rename_node") { const nextTitle = String(params.plan.textContent || "").trim().slice(0, 120); if (!nextTitle) { diff --git a/lib/canvas/agent-operation-request.ts b/lib/canvas/agent-operation-request.ts index 485c3f61d..7fc8abd10 100644 --- a/lib/canvas/agent-operation-request.ts +++ b/lib/canvas/agent-operation-request.ts @@ -7,6 +7,7 @@ export const canvasAgentOperationPlanSchema = z operationId: z.string().min(1).max(160), operation: z.enum([ "create_agent_slot", + "create_text_node", "fill_slot", "insert_beside", "append_revision", @@ -120,6 +121,7 @@ export const canvasAgentOperationPlanSchema = z .refine( (value) => value.operation === "create_agent_slot" || + value.operation === "create_text_node" || value.operation === "link_nodes" || value.operation === "update_text" || value.operation === "add_annotation" || @@ -153,6 +155,15 @@ export const canvasAgentOperationPlanSchema = z path: ["textContent"], } ) + .refine( + (value) => + value.operation !== "create_text_node" || + Boolean(String(value.textContent || "").trim()), + { + message: "缺少文本内容", + path: ["textContent"], + } + ) .refine( (value) => value.operation !== "rename_node" || diff --git a/tests/routes/canvas-agent-operations.test.ts b/tests/routes/canvas-agent-operations.test.ts index f0faa9bcf..cf227d1a2 100644 --- a/tests/routes/canvas-agent-operations.test.ts +++ b/tests/routes/canvas-agent-operations.test.ts @@ -237,6 +237,14 @@ test.describe("canvas agent operation request schema", () => { textContent: "New canvas title", }).operationPlan.operation ).toBe("update_text"); + + expect( + parsePlan({ + operation: "create_text_node", + targetPosition: { x: 120, y: 160 }, + textContent: "New canvas copy", + }).operationPlan.operation + ).toBe("create_text_node"); }); test("caps batch media at twenty items", () => { @@ -295,6 +303,12 @@ test.describe("canvas agent operation request schema", () => { targetNodeIds: ["image-1"], }) ).toContain("operationPlan.textContent"); + + expect( + issuePathsFor({ + operation: "create_text_node", + }) + ).toContain("operationPlan.textContent"); }); test("rejects invalid media urls", () => { @@ -599,6 +613,65 @@ test.describe("canvas agent operation mutation contract", () => { ); }); + test("creates text nodes from agent plans with trace metadata and idempotency", () => { + const canvasData = { + ...getCanvasData([]), + viewport: { x: -200, y: -100, zoom: 1, width: 1000, height: 700 }, + }; + const plan: CanvasOperationPlan = { + version: 1, + operationId: "op-create-text-node", + operation: "create_text_node", + projectId, + textContent: "618 限时优惠\n新品主推", + metadata: { + messageId: "assistant-message-text", + chatId: "chat-1", + prompt: "在画布上添加一段促销文案", + }, + }; + + const first = applyCanvasOperationPlanToCanvasData({ + canvasData, + plan, + }); + const createdTextId = first.createdNodeIds[0]; + const createdText = getResultNodes(first.canvasData).find( + (node) => node.id === createdTextId + ); + + expect(createdText).toMatchObject({ + type: "text", + position: { x: 570, y: 392 }, + data: { + content: "618 限时优惠\n新品主推", + fontSize: 18, + color: "#111827", + metadata: { + createdByAgent: true, + canvasAgentOperationId: "op-create-text-node", + canvasAgentOperation: "create_text_node", + messageId: "assistant-message-text", + chatId: "chat-1", + prompt: "在画布上添加一段促销文案", + }, + }, + }); + expect((first.canvasData as { selectedNodeIds?: string[] }).selectedNodeIds).toEqual([ + createdTextId, + ]); + + const retry = applyCanvasOperationPlanToCanvasData({ + canvasData: first.canvasData, + plan, + }); + + expect(retry.createdNodeIds).toEqual(first.createdNodeIds); + expect(getResultNodes(retry.canvasData)).toHaveLength( + getResultNodes(first.canvasData).length + ); + }); + 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 aedd66e20..32dcfb866 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -781,6 +781,73 @@ test.describe("canvas chat pure helpers", () => { }); }); + test("normalizes model-planned text creation into new canvas text nodes", () => { + const context: CanvasAgentContext = { + ...plannerContext, + selectedNodeIds: [], + selectedNodes: [], + viewport: { x: 0, y: 0, zoom: 1, width: 1200, height: 800 }, + focusPolicy: "auto", + }; + const outcome = normalizeCanvasAgentModelPlannerDecision({ + decision: { + action: "create_text_node", + textContent: "618 限时优惠,新品主推", + }, + context, + projectId: plannerContext.projectId || "", + messageId: "message-1", + chatId: "chat-1", + userText: "在画布上添加一段促销文案:618 限时优惠,新品主推", + }); + + expect(outcome.kind).toBe("operation"); + expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({ + operation: "create_text_node", + targetPosition: { x: 440, y: 340 }, + textContent: "618 限时优惠,新品主推", + preserveOriginal: true, + metadata: { + messageId: "message-1", + chatId: "chat-1", + planner: "model", + canvasAgentViewport: { x: 0, y: 0, zoom: 1, width: 1200, height: 800 }, + }, + }); + }); + + test("turns new-text update decisions into text creation when no text node is targeted", () => { + const context: CanvasAgentContext = { + ...plannerContext, + selectedNodeIds: ["image-1"], + selectedNodes: [plannerContext.selectedNodes[0]], + viewport: { x: -200, y: -100, zoom: 1, width: 1000, height: 700 }, + focusPolicy: "selected", + }; + const outcome = normalizeCanvasAgentModelPlannerDecision({ + decision: { + action: "update_text", + nodeIds: ["image-1"], + textContent: "新品上市", + }, + context, + projectId: plannerContext.projectId || "", + messageId: "message-2", + chatId: "chat-1", + userText: "在画布上添加文字:新品上市", + }); + + expect(outcome.kind).toBe("operation"); + expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({ + operation: "create_text_node", + targetPosition: { x: 540, y: 390 }, + textContent: "新品上市", + metadata: { + normalizedFromAction: "update_text", + }, + }); + }); + test("normalizes model-planned node renames without requiring text nodes", () => { const outcome = normalizeCanvasAgentModelPlannerDecision({ decision: { diff --git a/types/canvas/agent-protocol.ts b/types/canvas/agent-protocol.ts index fca6b11c8..5af17939f 100644 --- a/types/canvas/agent-protocol.ts +++ b/types/canvas/agent-protocol.ts @@ -111,6 +111,7 @@ export interface CanvasAgentContext { export type CanvasOperationKind = | "create_agent_slot" + | "create_text_node" | "fill_slot" | "insert_beside" | "append_revision"