diff --git a/lib/canvas/agent-explicit-operations.ts b/lib/canvas/agent-explicit-operations.ts index 03c51e669..af97867d1 100644 --- a/lib/canvas/agent-explicit-operations.ts +++ b/lib/canvas/agent-explicit-operations.ts @@ -8,6 +8,7 @@ import { } from "@/lib/canvas/agent-model-planner"; type ExplicitOperationKind = + | "add_annotation" | "create_agent_slot" | "create_text_node" | "rename_node" @@ -332,6 +333,59 @@ function buildUpdateNoteOperation(params: { }; } +function buildAddAnnotationOperation(params: { + context: CanvasAgentContext; + projectId: string; + messageId: string; + chatId: string; + userText: string; + requestedNodeIds?: string[]; +}): CanvasAgentExplicitOperationPlan | null { + const text = params.userText.trim(); + if (!/批注|标注|箭头|框选/i.test(text)) return null; + if (/按.*(?:批注|标注|箭头|框选).*(?:修改|编辑|改图|改主图|生成|出图|修图)/i.test(text)) { + return null; + } + if (hasMediaMutationIntent(text)) return null; + + const annotationText = extractContentFromPatterns( + text, + [ + /(?:添加批注|加批注|写批注|批注为|批注成|批注|添加标注|加标注|写标注|标注为|标注成|标注|箭头说明|框选说明)\s*[::]?\s*([\s\S]+)$/i, + /[::]\s*([\s\S]+)$/i, + ], + 1000 + ); + if (!annotationText) return null; + + const targetNodeIds = getTargetNodeIds({ + context: params.context, + requestedNodeIds: params.requestedNodeIds, + allowedTypes: ["image", "video"], + }).slice(0, 10); + if (targetNodeIds.length === 0) return null; + + return { + operationPlan: { + ...buildPlanBase({ ...params, operation: "add_annotation" }), + targetNodeIds, + textContent: annotationText, + annotations: [ + { + type: "text-note", + text: annotationText, + color: "#f59e0b", + }, + ], + }, + reply: "已给选中图片添加批注。", + progressTitle: "正在添加画布批注", + progressMessage: "我会把批注保存到选中的图片或视频节点。", + completedTitle: "画布批注已添加", + failedTitle: "画布批注添加失败", + }; +} + export function buildCanvasAgentExplicitOperationPlan(params: { context: CanvasAgentContext; projectId: string; @@ -354,6 +408,7 @@ export function buildCanvasAgentExplicitOperationPlan(params: { buildCreateAgentSlotOperation(commonParams) || buildCreateTextOperation(commonParams) || buildRenameOperation(commonParams) || - buildUpdateNoteOperation(commonParams) + buildUpdateNoteOperation(commonParams) || + buildAddAnnotationOperation(commonParams) ); } diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 1699b4488..da3030343 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -1020,6 +1020,34 @@ test.describe("canvas chat pure helpers", () => { }); }); + test("builds deterministic annotation additions for selected media nodes only", () => { + const outcome = buildCanvasAgentExplicitOperationPlan({ + context: plannerContext, + projectId: plannerContext.projectId || "", + messageId: "message-1", + chatId: "chat-1", + userText: "给选中图片加批注:这里需要重点保留产品轮廓", + }); + + expect(outcome?.operationPlan).toMatchObject({ + operation: "add_annotation", + targetNodeIds: ["image-1"], + textContent: "这里需要重点保留产品轮廓", + annotations: [ + { + type: "text-note", + text: "这里需要重点保留产品轮廓", + color: "#f59e0b", + }, + ], + metadata: { + messageId: "message-1", + chatId: "chat-1", + planner: "deterministic", + }, + }); + }); + test("does not route media generation or note-driven image edits as explicit canvas operations", () => { expect( buildCanvasAgentExplicitOperationPlan({ @@ -1050,6 +1078,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", () => {