diff --git a/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md b/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md index df59712c0..35cc19f28 100644 --- a/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md +++ b/docs/无限画布对话框Agent模式升级开发文档-借鉴Cowart-2026-06-22.md @@ -615,6 +615,7 @@ export type CanvasOperationKind = | "add_annotation" | "link_nodes" | "move_nodes" + | "rename_node" | "update_text"; export interface CanvasMediaItem { @@ -738,6 +739,7 @@ Agent 首期只能请求以下操作,后端 mutation 层也只接受这些操 | `insert_beside` | 在锚点旁边插入新结果 | 低 | 否 | | `append_revision` | 基于源节点追加一个新版本 | 低 | 否 | | `link_nodes` | 给两个节点建立参考/版本关系 | 低 | 否 | +| `rename_node` | 修改图片/视频/slot/节点标题 | 低 | 否 | | `update_text` | 修改选中文本节点内容 | 中 | 视情况 | | `replace_node_media` | 替换已有图片/视频内容 | 高 | 是 | | `move_nodes` | 批量移动或整理节点 | 中 | 视情况 | @@ -748,6 +750,7 @@ Agent 首期只能请求以下操作,后端 mutation 层也只接受这些操 - `insert_beside` - `append_revision` - `link_nodes` +- `rename_node` 这样可以先把“对话框驱动画布”跑通,同时把误操作风险降到最低。 @@ -1062,6 +1065,7 @@ autosave 冲突要求: - `fill_slot`,仅限空 slot 或 append-version slot - `append_revision` - `link_nodes` +- `rename_node` 首期 revision 不一致时必须拒绝或要求确认的操作: @@ -1164,6 +1168,7 @@ hasSelectedAgentSlot?: boolean; | “再出三版放右边” | `insert_beside` | selected image 或 latest result | | “按批注修改” | `append_revision` | selected image + annotations | | “把这两张连起来” | `link_nodes` | selected nodes | +| “把选中图片改名为白底主图 A” | `rename_node` | selected image/video/slot node | | “把这段文字改成更高级” | `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 dc0b43efb..d1da481b4 100644 --- a/lib/canvas/agent-model-planner.ts +++ b/lib/canvas/agent-model-planner.ts @@ -17,6 +17,7 @@ const plannerDecisionSchema = z.object({ "zoom_out", "link_nodes", "update_text", + "rename_node", "add_annotation", "create_agent_slot", "confirmation_required", @@ -419,6 +420,7 @@ export function shouldConsiderCanvasAgentModelPlanner(params: { /文本|文字|文案|标题|标签|改写|润色|优化|改成|改为|设置为|设为|替换为/.test( text ); + const hasRenameIntent = /改名|重命名|命名|标题/.test(text); const hasAnnotationIntent = /批注|标注|备注|note|annotation/.test(text); const hasSlotIntent = hasCanvasAgentSlotCreationIntent(text); const hasHighRiskIntent = Boolean(inferHighRiskOperation(text)); @@ -432,6 +434,7 @@ export function shouldConsiderCanvasAgentModelPlanner(params: { !hasViewIntent && !hasLinkIntent && !hasTextIntent && + !hasRenameIntent && !hasAnnotationIntent && !hasSlotIntent && !hasHighRiskIntent @@ -443,6 +446,7 @@ export function shouldConsiderCanvasAgentModelPlanner(params: { hasViewIntent || hasLinkIntent || hasTextIntent || + hasRenameIntent || hasAnnotationIntent || hasSlotIntent || hasHighRiskIntent @@ -763,6 +767,50 @@ export function normalizeCanvasAgentModelPlannerDecision(params: { }; } + if (decision.action === "rename_node") { + const targetNodeIds = getDecisionNodeIds({ + decision, + context: params.context, + allowSelectedFallback: true, + maxItems: 10, + }); + const title = trimString(decision.textContent, 120); + if (targetNodeIds.length === 0 || !title) { + return { kind: "none", reason: "rename_node_missing_target_or_title" }; + } + const operationPlan: CanvasOperationPlan = { + version: 1, + operationId, + operation: "rename_node", + projectId: params.projectId, + baseCanvasRevision: params.context.canvasRevision, + baseCanvasUpdatedAt: params.context.canvasUpdatedAt, + targetNodeIds, + textContent: title, + preserveOriginal: true, + metadata: { + messageId: params.messageId, + chatId: params.chatId, + planner: "model", + prompt: params.userText, + }, + }; + return { + kind: "operation", + operationPlan, + viewPlan: buildViewPlan({ + action: "focus_nodes", + context: params.context, + projectId: params.projectId, + operationId, + nodeIds: targetNodeIds, + reason: "focus_model_renamed_nodes", + }) || undefined, + reply: trimString(decision.reply, 280) || "已更新选中节点标题。", + reason, + }; + } + return { kind: "none", reason: "unsupported_model_planner_action" }; } @@ -791,9 +839,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。", + "当用户要求给图片/视频/slot/节点改名、重命名或修改标题时,返回 action=rename_node;不要把图片节点标题改名误判为 update_text。", "删除、覆盖、替换现有图片、移动/重排节点等高风险动作必须返回 action=confirmation_required。", "只能使用上下文中出现的 nodeId。不要编造节点 ID。", "如果用户只是聊天或目标不明确,返回 action=none。", @@ -804,9 +853,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 | add_annotation | create_agent_slot | confirmation_required", + "none | focus_nodes | fit_all | zoom_in | zoom_out | link_nodes | update_text | rename_node | add_annotation | create_agent_slot | confirmation_required", nodeIds: "只填上下文存在的 nodeId", - textContent: "update_text 时填写最终文本;add_annotation 时填写批注内容", + textContent: + "update_text 时填写最终文本;rename_node 时填写新标题;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 adfc93700..174d941f8 100644 --- a/lib/canvas/agent-mutations.ts +++ b/lib/canvas/agent-mutations.ts @@ -938,6 +938,7 @@ function applyOperationToCanvasData(params: { params.plan.operation !== "move_nodes" && params.plan.operation !== "replace_node_media" && params.plan.operation !== "delete_nodes" && + params.plan.operation !== "rename_node" && params.plan.operation !== "update_text" ) { throw new CanvasAgentMutationError("当前仅支持非破坏式画布写回操作"); @@ -1047,6 +1048,63 @@ function applyOperationToCanvasData(params: { }); } + if (params.plan.operation === "rename_node") { + const nextTitle = String(params.plan.textContent || "").trim().slice(0, 120); + if (!nextTitle) { + throw new CanvasAgentMutationError("缺少节点标题"); + } + + const targetNodeIds = getOperationTargetNodeIds(params.plan); + if (targetNodeIds.length === 0) { + throw new CanvasAgentMutationError("缺少要改名的节点"); + } + + const now = new Date().toISOString(); + const updatedNodeIds: string[] = []; + targetNodeIds.forEach((nodeId) => { + const node = findNode(nodes, nodeId); + if (!node) { + throw new CanvasAgentMutationError(`改名节点不存在:${nodeId}`); + } + + const data = (node.data || {}) as Record; + const previousTitle = + typeof data.title === "string" + ? data.title + : typeof data.label === "string" + ? data.label + : undefined; + node.data = { + ...data, + title: nextTitle, + label: node.type === "agent-slot" ? nextTitle : data.label, + metadata: { + ...(data.metadata || {}), + renamedByAgent: true, + canvasAgentOperationId: params.plan.operationId, + canvasAgentOperation: params.plan.operation, + previousTitle, + agentRenamedAt: now, + }, + }; + node.updatedAt = now; + updatedNodeIds.push(node.id); + }); + + return { + canvasData: { + ...params.canvasData, + nodes, + edges, + selectedNodeIds: updatedNodeIds, + }, + createdNodeIds: [], + createdNodeIdsByMediaItemId: {}, + updatedNodeIds, + createdEdgeIds: [], + }; + } + if (params.plan.operation === "update_text") { const textContent = String(params.plan.textContent || "").trim(); if (!textContent) { diff --git a/lib/canvas/agent-operation-request.ts b/lib/canvas/agent-operation-request.ts index f8e3eb501..c1b71042c 100644 --- a/lib/canvas/agent-operation-request.ts +++ b/lib/canvas/agent-operation-request.ts @@ -15,6 +15,7 @@ export const canvasAgentOperationPlanSchema = z "link_nodes", "move_nodes", "delete_nodes", + "rename_node", "update_text", ]), projectId: z.string().uuid().optional(), @@ -123,6 +124,7 @@ export const canvasAgentOperationPlanSchema = z value.operation === "add_annotation" || value.operation === "move_nodes" || value.operation === "delete_nodes" || + value.operation === "rename_node" || value.media || (value.mediaItems?.length ?? 0) > 0, { @@ -148,6 +150,15 @@ export const canvasAgentOperationPlanSchema = z message: "缺少文本内容", path: ["textContent"], } + ) + .refine( + (value) => + value.operation !== "rename_node" || + Boolean(String(value.textContent || "").trim()), + { + message: "缺少节点标题", + path: ["textContent"], + } ); export const canvasAgentOperationRequestSchema = z.object({ diff --git a/tests/routes/canvas-agent-operations.test.ts b/tests/routes/canvas-agent-operations.test.ts index 702378994..74db41c33 100644 --- a/tests/routes/canvas-agent-operations.test.ts +++ b/tests/routes/canvas-agent-operations.test.ts @@ -281,6 +281,13 @@ test.describe("canvas agent operation request schema", () => { targetNodeIds: ["text-1"], }) ).toContain("operationPlan.textContent"); + + expect( + issuePathsFor({ + operation: "rename_node", + targetNodeIds: ["image-1"], + }) + ).toContain("operationPlan.textContent"); }); test("rejects invalid media urls", () => { @@ -752,6 +759,44 @@ test.describe("canvas agent operation mutation contract", () => { expect(retryNode.data.metadata.annotations).toHaveLength(1); }); + test("renames canvas nodes with trace metadata", () => { + const target = buildImageNode("image-rename"); + target.data.title = "旧主图"; + const result = applyCanvasOperationPlanToCanvasData({ + canvasData: getCanvasData([target]), + plan: { + version: 1, + operationId: "op-rename-node", + operation: "rename_node", + projectId, + targetNodeIds: ["image-rename"], + textContent: "白底主图 A", + metadata: { + messageId: "assistant-message-rename", + chatId: "chat-1", + }, + }, + }); + + const renamedNode = getResultNodes(result.canvasData).find( + (node) => node.id === "image-rename" + ); + + expect(result.createdNodeIds).toEqual([]); + expect(result.updatedNodeIds).toEqual(["image-rename"]); + expect(renamedNode).toMatchObject({ + data: { + title: "白底主图 A", + metadata: { + renamedByAgent: true, + canvasAgentOperationId: "op-rename-node", + canvasAgentOperation: "rename_node", + previousTitle: "旧主图", + }, + }, + }); + }); + test("keeps batch media writes idempotent by operation id", () => { const anchor = buildImageNode("image-anchor"); const plan = { diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 3384071f7..2a28d3ba5 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -734,6 +734,33 @@ test.describe("canvas chat pure helpers", () => { }); }); + test("normalizes model-planned node renames without requiring text nodes", () => { + const outcome = normalizeCanvasAgentModelPlannerDecision({ + decision: { + action: "rename_node", + nodeIds: ["image-1"], + textContent: "白底主图 A", + }, + context: plannerContext, + projectId: plannerContext.projectId || "", + messageId: "message-1", + chatId: "chat-1", + userText: "把选中图片标题改成白底主图 A", + }); + + expect(outcome.kind).toBe("operation"); + expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({ + operation: "rename_node", + targetNodeIds: ["image-1"], + textContent: "白底主图 A", + metadata: { + messageId: "message-1", + chatId: "chat-1", + planner: "model", + }, + }); + }); + test("normalizes model-planned annotations to selected media nodes", () => { const outcome = normalizeCanvasAgentModelPlannerDecision({ decision: { diff --git a/types/canvas/agent-protocol.ts b/types/canvas/agent-protocol.ts index 344f28552..91379c166 100644 --- a/types/canvas/agent-protocol.ts +++ b/types/canvas/agent-protocol.ts @@ -119,6 +119,7 @@ export type CanvasOperationKind = | "link_nodes" | "move_nodes" | "delete_nodes" + | "rename_node" | "update_text"; export interface CanvasMediaItem {