import { expect, test } from "@playwright/test"; import { collectMessageImageUrls, extractAssistantImageUrls, extractAudioUrls, extractVideoUrls, filterStandaloneAssistantImageUrls, getMarkdownTableImageUrlKeys, normalizeMediaUrl, sortCanvasMessagesByCreatedAt, stripMediaMarkup, } from "@/components/canvas/chat-panel/canvas-chat-media"; import { filterCanvasMediaPayloadForUnhandledItems, buildAgentProgressNodeSteps, getCanvasMediaPayloadHandledItemIds, getCanvasOperationHandledMediaItemIds, getAgentProgressStepLabel, isCanvasAgentViewOperationClientEnabled, normalizeCanvasAgentClientBoolean, shouldRetryCanvasViewOperationAfterRefresh, shouldSkipCanvasMediaPayloadForHandledItems, } from "@/components/canvas/chat-panel/canvas-agent-stream"; import { mergePendingCanvasAgentTargetNodeIds, resolvePendingCanvasAgentTargetNode, } from "@/components/canvas/chat-panel/canvas-agent-targets"; import { consumeCanvasAgentAnnotationEditRequest, consumeCanvasAgentSlotFillRequest, getPendingCanvasAgentAnnotationEditRequest, getPendingCanvasAgentSlotFillRequest, storeCanvasAgentAnnotationEditRequest, storeCanvasAgentSlotFillRequest, type CanvasAgentPendingRequestWindow, } from "@/lib/canvas/agent-pending-requests"; import { normalizeCanvasMediaStreamPayload, writeCanvasMediaChunk, writeCanvasOperationChunk, } from "@/app/(chat)/api/chat/_lib/stream"; import { normalizeCanvasAgentContext } from "@/lib/canvas/agent-context"; import { buildCanvasAgentAnnotationEditPrompt, hasCanvasAgentAnnotationEditIntent, } from "@/lib/canvas/agent-annotation-prompt"; import { defaultCanvasToolbarConfig, getCanvasToolbarItem, } from "@/lib/canvas-toolbar-config"; import { shouldBypassCanvasAgentMissingTargetClarification } from "@/lib/ai/runtime/canvas-agent-clarification"; import { hasCanvasAgentRoutingContext } from "@/lib/ai/canvas-agent-routing"; import { inferCanvasAgentImageEditCapability, resolveCanvasAgentRequestPolicy, resolveCanvasAgentTargetPreference, } from "@/lib/ai/runtime/canvas-agent-kernel"; import { isExplicitImageGenerationRequest } from "@/lib/ai/smart-analyzer"; import { inferAgentImageCountFromText } from "@/lib/ai/runtime/image-count-intent"; import { buildAgentPlanningBriefMarkdown, extractAgentTaskGroups, isAgentPlanningBriefMarkdown, } from "@/lib/ai/runtime/agent-planning-brief"; import { buildRecentDialogueMemory, isRecentDialogueRecallRequest, } from "@/lib/ai/runtime/recent-dialogue-memory"; import { isCapabilityQuestionTurnRequest } from "@/lib/ai/runtime/task-boundary-text"; import { isAgentModeScopedChatId, isScopedEmptyHistoryRequest, isValidMessagesChatId, normalizeMessagesChatId, } from "@/lib/chat/messages-route-access"; import { CANVAS_LOCAL_EDIT_REGION_ANNOTATION_SOURCE, CANVAS_NODE_NOTE_ANNOTATION_SOURCE, CANVAS_VISUAL_ANNOTATION_SOURCE, appendCanvasVisualAnnotationsMetadata, syncCanvasLocalEditRegionAnnotationsMetadata, syncCanvasNodeNoteAnnotationMetadata, } from "@/lib/canvas/agent-annotation-metadata"; import { buildPendingCanvasOperation, extractPendingCanvasOperationFromWorkingState, isCanvasOperationCancelText, isCanvasOperationConfirmText, withConfirmedCanvasOperationPlan, } from "@/lib/canvas/agent-confirmation"; import { shouldRejectStaleCanvasAgentHighRiskMutation } from "@/lib/canvas/agent-risk"; import { buildCanvasAgentSlotCreationPlan, normalizeCanvasAgentModelPlannerDecision, resolveCanvasAgentSlotCreationIntent, shouldConsiderCanvasAgentModelPlanner, } from "@/lib/canvas/agent-model-planner"; import { buildCanvasAgentExplicitOperationPlan } from "@/lib/canvas/agent-explicit-operations"; import { buildCanvasAgentDirectWritebackPlan } from "@/lib/canvas/agent-direct-writeback"; import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement"; import { buildCanvasAgentResultViewOperation } from "@/lib/canvas/agent-result-view"; import { buildCanvasAgentComparisonViewPlan, buildCanvasAgentLatestResultViewPlan, hasCanvasAgentComparisonViewIntent, hasCanvasAgentLatestResultViewIntent, resolveCanvasAgentComparisonNodeIds, } from "@/lib/canvas/agent-view-intent"; import { buildCanvasAgentPanViewPlan, hasCanvasAgentPanViewIntent, } from "@/lib/canvas/agent-view-pan"; import { computeCanvasAgentPanViewport, resolveCanvasViewOperation, resolveCanvasViewOperationTargetNodeIds, } from "@/lib/canvas/agent-view-operations"; import { easeOutCanvasAgentViewProgress, interpolateCanvasAgentViewport, normalizeCanvasAgentViewAnimationDuration, } from "@/lib/canvas/agent-view-animation"; import { CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS, shouldDeferCanvasAgentViewOperation, } from "@/lib/canvas/agent-view-interaction-guard"; import { getCanvasAgentOperationErrorMessage, normalizeCanvasAgentOperationErrorCode, } from "@/lib/canvas/agent-operation-errors"; import { buildStaleCanvasSaveConflictPayload, isStaleCanvasSaveBaseRevision, parseCanvasRevisionDate, } from "@/lib/canvas/canvas-save-conflict"; import { buildCanvasSaveRequestBody, mergeCanvasSaveRevisionOptions, } from "@/lib/canvas/canvas-save-request"; import { canvasService } from "@/services/canvas"; import { hasCanvasAgentSlotFillContextIntent, hasCanvasAgentNonGenerativeControlIntent, shouldPreserveCanvasAgentSlotOnlyContext, shouldBypassCanvasImagePointsPrecheckForAgentControl, } from "@/lib/canvas/agent-client-intent"; import { getDirectAgentToolDisplayName, getDirectAgentToolLabel, getDirectAgentToolPrefaceFallback, getDirectAgentToolSuccessMessage, hasExplicitDirectAgentBatchImageEditIntent, isDirectAgentBatchImageEditCapability, resolveDirectAgentToolContext, resolveDirectAgentToolExecution, shouldRunDirectAgentBatchImageEdit, } from "@/lib/ai/runtime/direct-agent-execution"; import { buildDirectAgentImageEditAssistantText, buildDirectAgentImageGenerationAssistantText, } from "@/lib/ai/runtime/direct-agent-response-builders"; import { inferAngleEditOptionsFromText } from "@/lib/ai/runtime/angle-edit-options"; import { hasDirectAgentLocalEditIntent, normalizeDirectAgentLocalEditRect, resolveDirectAgentLocalEditRegions, } from "@/lib/ai/runtime/local-edit-options"; import { applyCanvasOperationPlanToCanvasData, CanvasAgentMutationError, } from "@/lib/canvas/agent-mutations"; import { appendCanvasAgentOperationAuditIfMissing, buildIdempotentCanvasAgentAuditResult, findCanvasAgentOperationAudit, } from "@/lib/canvas/agent-operation-audit"; import { CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY, CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY, CANVAS_AGENT_SLOT_ENABLED_KEY, CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY, CANVAS_AGENT_WRITEBACK_MODE_KEY, canvasAgentFeatureFlagsToSettings, canvasAgentFeatureSettingsToSystemSettings, canApplyCanvasAgentMutations, getCanvasAgentFeatureFlags, normalizeCanvasAgentFeatureSettings, normalizeCanvasAgentBoolean, normalizeCanvasAgentWritebackMode, shouldStreamCanvasAgentShadowOperations, } from "@/lib/canvas/agent-feature-flags"; import { useCanvasStore } from "@/store/canvas"; import { buildDetailResultMarkdown, buildDetailReviewAppendMarkdown, buildDetailReviewPlans, } from "@/components/canvas/chat-panel/canvas-detail-markdown"; import { buildOpenClawDisplayMessage, buildOpenClawMessageWithSpreadsheetMarkdown, isOpenClawSpreadsheetFileName, rowsToOpenClawMarkdownTable, } from "@/lib/openclaw-chat/spreadsheet-markdown"; import { DETAIL_BUNDLE_SLOT_INDEXES, DETAIL_GENERATION_MODE_OPTIONS, DETAIL_MODEL_SLOT_INDEX, DETAIL_PRODUCT_SLOT_INDEXES, DETAIL_SLOT_CONFIGS, DETAIL_TARGET_HEIGHT, DETAIL_TARGET_WIDTH, extractDetailFrameCount, extractFirstNumber, extractFontWeight, normalizeDetailGenerationMode, sanitizeDetailImageModelPrompt, splitDetailKeywords, } from "@/components/canvas/chat-panel/canvas-detail-config"; import { createDetailArchiveSnapshot, patchDetailArchiveList, prependDetailArchive, replaceSavedDetailArchive, sanitizeDetailArchiveList, } from "@/components/canvas/chat-panel/canvas-detail-archive"; import { applyDetailSlotAttachment, fillDetailSlotAttachments, getDetailSlotTargetIndexes, } from "@/components/canvas/chat-panel/canvas-detail-slots"; import { buildCanvasUploadedImageAttachment, CANVAS_IMAGE_UPLOAD_MAX_BYTES, getCanvasUploadImageTitle, normalizeUploadedAssetId, validateCanvasImageUploadFile, } from "@/components/canvas/chat-panel/canvas-upload-helpers"; import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session"; import type { CanvasAgentContext } from "@/types/canvas/agent-protocol"; import type { ChatMessage } from "@/lib/types"; import type { DBMessage } from "@/lib/db/schema"; function dbTextMessage( id: string, role: "user" | "assistant", text: string, ): DBMessage { return { id, role, parts: [{ type: "text", text }], } as unknown as DBMessage; } test.describe("canvas chat pure helpers", () => { test("keeps optimistic messages without timestamps after persisted history", () => { const messages = [ { id: "optimistic-latest-user", role: "user", parts: [{ type: "text", text: "你能为我做什么?列出表给我" }], }, { id: "persisted-first-user", role: "user", createdAt: "2026-07-07T01:00:00.000Z", parts: [{ type: "text", text: "你好" }], }, { id: "persisted-first-assistant", role: "assistant", createdAt: "2026-07-07T01:00:01.000Z", parts: [{ type: "text", text: "你好,我在。" }], }, ] as ChatMessage[]; expect(sortCanvasMessagesByCreatedAt(messages).map((message) => message.id)).toEqual([ "persisted-first-user", "persisted-first-assistant", "optimistic-latest-user", ]); }); test("preserves relative order among messages without timestamps", () => { const messages = [ { id: "pending-user", role: "user", parts: [{ type: "text", text: "生成一张图" }], }, { id: "pending-assistant", role: "assistant", parts: [{ type: "text", text: "分析中" }], }, ] as ChatMessage[]; expect(sortCanvasMessagesByCreatedAt(messages).map((message) => message.id)).toEqual([ "pending-user", "pending-assistant", ]); }); test("formats uploaded spreadsheet rows as safe markdown tables for OpenClaw", () => { const markdown = rowsToOpenClawMarkdownTable([ ["名称", "备注"], ["A|B", "第一行\n第二行"], ["空值列", ""], ]); expect(markdown).toContain("| 名称 | 备注 |"); expect(markdown).toContain("A\\|B"); expect(markdown).toContain("第一行
第二行"); expect(isOpenClawSpreadsheetFileName("sample.xlsx")).toBe(true); expect(isOpenClawSpreadsheetFileName("sample.xls")).toBe(false); }); test("keeps OpenClaw spreadsheet display text compact while upstream message includes markdown", () => { const files = [ { fileName: "sales.csv", rowCount: 12, sheetCount: 1, truncated: false, }, ]; const display = buildOpenClawDisplayMessage({ message: "", files }); expect(display).toContain("请分析上传的表格内容"); expect(display).toContain("sales.csv"); expect(display).not.toContain("| SKU |"); const upstream = buildOpenClawMessageWithSpreadsheetMarkdown({ message: "分析销量", spreadsheetMarkdown: "| SKU | 销量 |\n| --- | --- |\n| A01 | 20 |", }); expect(upstream).toContain("分析销量"); expect(upstream).toContain("以下是用户上传表格转换后的 Markdown 内容"); expect(upstream).toContain("| SKU | 销量 |"); }); test("recognizes canvas product capability questions with typo tolerance", () => { expect(isCapabilityQuestionTurnRequest("你能为我做什么?")).toBe(true); expect(isCapabilityQuestionTurnRequest("你嫩为我做什么?")).toBe(true); expect(isCapabilityQuestionTurnRequest("你有哪些功能")).toBe(true); expect(isCapabilityQuestionTurnRequest("帮我把这张图换成白底")).toBe(false); }); test("builds bounded recent dialogue memory for user recall requests", () => { const memory = buildRecentDialogueMemory({ messages: [ dbTextMessage("m1", "user", "你好"), dbTextMessage("m2", "assistant", "你好,我在。"), dbTextMessage("m3", "user", "你是一个小可爱"), dbTextMessage("m4", "assistant", "嘿嘿,谢谢你的夸奖。"), ], currentText: "我前面说了什么?", }); expect(isRecentDialogueRecallRequest("我前面说了什么?")).toBe(true); expect(memory.isRecallRequest).toBe(true); expect(memory.entries.map((entry) => entry.role)).toEqual(["user", "user"]); expect(memory.entries.at(-1)?.content).toBe("你是一个小可爱"); expect(memory.contextSummary).toContain("用户: 你是一个小可爱"); expect(memory.memoryHints.join(" ")).toContain("不要声称没有上下文"); }); test("sanitizes media markup from recent dialogue memory", () => { const memory = buildRecentDialogueMemory({ messages: [ dbTextMessage("m1", "user", "生成一只小猫"), dbTextMessage( "m2", "assistant", "\n![原图](https://example.com/cat.png)\n已完成本次生成" ), ], currentText: "你刚才回复了什么?", }); expect(memory.entries).toHaveLength(1); expect(memory.entries[0].role).toBe("assistant"); expect(memory.entries[0].content).toBe("已完成本次生成"); expect(memory.contextSummary).not.toContain("https://example.com"); expect(memory.contextSummary).not.toContain("IMAGE_GENERATION_START"); }); test("infers explicit agent image counts from user text", () => { expect(inferAgentImageCountFromText("再出三版")).toBe(3); expect(inferAgentImageCountFromText("基于这张图再来 3 张")).toBe(3); expect(inferAgentImageCountFromText("生成四张主图")).toBe(4); expect(inferAgentImageCountFromText("多来两张")).toBe(2); expect(inferAgentImageCountFromText("生成16:9横版海报")).toBeNull(); expect(inferAgentImageCountFromText("提升到2K清晰度")).toBeNull(); expect(inferAgentImageCountFromText("再出30张", 12)).toBe(12); }); test("builds market-grade planning brief for grouped image generation requests", () => { const request = "基于上传的潮玩角色图片,生成3组共12张图片,要求保持角色核心特征一致。第一组为4张动作表情夸张的半身表情包;第二组为4张不同场景的角色活动照;第三组为4张服装发型差异巨大的造型照,所有图片比例保持3:4。"; const groups = extractAgentTaskGroups(request); expect(groups).toHaveLength(3); expect(groups.map((group) => group.count)).toEqual([4, 4, 4]); expect(groups[0].description).toContain("半身表情包"); expect(groups[1].description).toContain("不同场景"); expect(groups[2].description).toContain("服装发型"); const markdown = buildAgentPlanningBriefMarkdown({ userText: request, intent: "image_gen", nextAction: "generate_image", preferredCapability: "generate_image", targetSource: "current_upload", hasImage: true, imageCount: 12, aspectRatio: "3:4", imageSize: "2K", }); expect(isAgentPlanningBriefMarkdown(markdown)).toBe(true); expect(markdown).toContain("### 意图分析"); expect(markdown).toContain("拆成 3 组,共 12 张"); expect(markdown).toContain("### 任务规划"); expect(markdown).toContain("第1组:生成4张"); expect(markdown).toContain("第2组:生成4张"); expect(markdown).toContain("第3组:生成4张"); expect(markdown).toContain("图片比例:3:4"); }); test("keeps canvas message empty-history access scoped and explicit", () => { const canvasChatId = "11111111-1111-4111-8111-111111111111"; const agentScopedChatId = "11111111-1111-4111-8111-a93e17a60001"; expect(normalizeMessagesChatId(`canvas-${canvasChatId}`)).toBe( canvasChatId ); expect(normalizeMessagesChatId("canvas-not-a-uuid")).toBe( "canvas-not-a-uuid" ); expect(isValidMessagesChatId(canvasChatId)).toBe(true); expect(isValidMessagesChatId("canvas-not-a-uuid")).toBe(false); expect(isScopedEmptyHistoryRequest({ chatId: canvasChatId })).toBe(false); expect( isScopedEmptyHistoryRequest({ chatId: canvasChatId, scope: "canvas" }) ).toBe(true); expect( isScopedEmptyHistoryRequest({ chatId: canvasChatId, scope: " AGENT " }) ).toBe(true); expect( isScopedEmptyHistoryRequest({ chatId: "canvas-not-a-uuid", scope: "canvas", }) ).toBe(false); expect(isAgentModeScopedChatId(agentScopedChatId)).toBe(true); expect(isAgentModeScopedChatId(canvasChatId)).toBe(false); }); test("normalizes client-side canvas agent feature flags", () => { expect(normalizeCanvasAgentClientBoolean(undefined, true)).toBe(true); expect(normalizeCanvasAgentClientBoolean("", false)).toBe(false); expect(normalizeCanvasAgentClientBoolean("enabled", false)).toBe(true); expect(normalizeCanvasAgentClientBoolean("off", true)).toBe(false); expect(normalizeCanvasAgentClientBoolean("disabled", true)).toBe(false); expect(normalizeCanvasAgentClientBoolean("unexpected", true)).toBe(true); expect(isCanvasAgentViewOperationClientEnabled(undefined)).toBe(true); expect(isCanvasAgentViewOperationClientEnabled("false")).toBe(false); expect(isCanvasAgentViewOperationClientEnabled("1")).toBe(true); }); test("bypasses image point precheck only for non-generative canvas agent controls", () => { expect(hasCanvasAgentNonGenerativeControlIntent("把画布放大一点")).toBe(true); expect(hasCanvasAgentNonGenerativeControlIntent("缩小看全局")).toBe(true); expect(hasCanvasAgentNonGenerativeControlIntent("聚焦刚生成结果")).toBe(true); expect(hasCanvasAgentNonGenerativeControlIntent("对比原图和新图")).toBe(true); expect(hasCanvasAgentNonGenerativeControlIntent("新建一个图片槽位")).toBe(true); expect(hasCanvasAgentNonGenerativeControlIntent("创建一个图片槽位")).toBe(true); expect(hasCanvasAgentNonGenerativeControlIntent("删除选中的节点")).toBe(true); expect(hasCanvasAgentNonGenerativeControlIntent("把选中图片背景换白")).toBe(false); expect(hasCanvasAgentNonGenerativeControlIntent("生成一张产品主图")).toBe(false); expect(hasCanvasAgentNonGenerativeControlIntent("抠图去背景")).toBe(false); expect( shouldBypassCanvasImagePointsPrecheckForAgentControl({ text: "把画布放大一点", activeComposerMode: "image", agentEnabled: true, }) ).toBe(true); expect( shouldBypassCanvasImagePointsPrecheckForAgentControl({ text: "聚焦刚生成结果", activeComposerMode: "image", agentEnabled: true, }) ).toBe(true); expect( shouldBypassCanvasImagePointsPrecheckForAgentControl({ text: "对比原图和新图", activeComposerMode: "image", agentEnabled: true, }) ).toBe(true); expect( shouldBypassCanvasImagePointsPrecheckForAgentControl({ text: "把画布放大一点", activeComposerMode: "image", attachmentCount: 1, agentEnabled: true, }) ).toBe(false); expect( shouldBypassCanvasImagePointsPrecheckForAgentControl({ text: "把画布放大一点", activeComposerMode: "image", hasWorkflowContext: true, agentEnabled: true, }) ).toBe(false); expect( shouldBypassCanvasImagePointsPrecheckForAgentControl({ text: "把画布放大一点", activeComposerMode: "image", agentEnabled: false, }) ).toBe(false); expect( shouldBypassCanvasImagePointsPrecheckForAgentControl({ text: "生成一张产品主图", activeComposerMode: "image", agentEnabled: true, }) ).toBe(false); expect( shouldBypassCanvasImagePointsPrecheckForAgentControl({ text: "任意 agent 模式文本", activeComposerMode: "agent", agentEnabled: true, }) ).toBe(true); }); test("preserves selected slot-only canvas context for fill and generation intents", () => { expect(hasCanvasAgentSlotFillContextIntent("在这里生成一张主图")).toBe(true); expect(hasCanvasAgentSlotFillContextIntent("填充这个槽位")).toBe(true); expect(hasCanvasAgentSlotFillContextIntent("普通聊天")).toBe(false); expect( shouldPreserveCanvasAgentSlotOnlyContext({ text: "在这里生成一张主图", activeComposerMode: "image", selectedAgentSlotCount: 1, agentEnabled: true, }) ).toBe(true); expect( shouldPreserveCanvasAgentSlotOnlyContext({ text: "生成一张主图", activeComposerMode: "image", selectedAgentSlotCount: 0, agentEnabled: true, }) ).toBe(false); expect( shouldPreserveCanvasAgentSlotOnlyContext({ text: "在这里生成一张主图", activeComposerMode: "image", selectedAgentSlotCount: 1, attachmentCount: 1, agentEnabled: true, }) ).toBe(false); expect( shouldPreserveCanvasAgentSlotOnlyContext({ text: "任意 agent 模式文本", activeComposerMode: "agent", selectedAgentSlotCount: 1, agentEnabled: false, }) ).toBe(true); }); test("rejects stale autosave bases without blocking fresh or missing revisions", () => { const currentUpdatedAt = new Date("2026-06-22T00:00:05.500Z"); expect(parseCanvasRevisionDate("not-a-date")).toBeNull(); expect( isStaleCanvasSaveBaseRevision({ baseCanvasRevision: "2026-06-22T00:00:00.000Z", currentUpdatedAt, }) ).toBe(true); expect( isStaleCanvasSaveBaseRevision({ baseCanvasUpdatedAt: "2026-06-22T00:00:05.000Z", currentUpdatedAt, }) ).toBe(true); expect( isStaleCanvasSaveBaseRevision({ baseCanvasUpdatedAt: "2026-06-22T00:00:05.500Z", currentUpdatedAt, }) ).toBe(false); expect( isStaleCanvasSaveBaseRevision({ currentUpdatedAt, }) ).toBe(false); expect(buildStaleCanvasSaveConflictPayload(currentUpdatedAt)).toMatchObject({ success: false, code: "STALE_CANVAS_REVISION", canvasUpdatedAt: "2026-06-22T00:00:05.500Z", }); }); test("builds canvas save requests with fallback revision guards", () => { const canvasData = { nodes: [], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select" as const, }; expect( mergeCanvasSaveRevisionOptions({ fallbackOptions: { baseCanvasRevision: "2026-06-22T00:00:00.000Z", baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z", }, }) ).toEqual({ baseCanvasRevision: "2026-06-22T00:00:00.000Z", baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z", }); expect( mergeCanvasSaveRevisionOptions({ options: { baseCanvasRevision: "2026-06-22T00:00:02.000Z", }, fallbackOptions: { baseCanvasRevision: "2026-06-22T00:00:00.000Z", baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z", }, }) ).toEqual({ baseCanvasRevision: "2026-06-22T00:00:02.000Z", baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z", }); expect( buildCanvasSaveRequestBody({ canvasData, workspaceType: "video", fallbackOptions: { baseCanvasRevision: "2026-06-22T00:00:00.000Z", baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z", }, }) ).toMatchObject({ canvasData, workspaceType: "video", baseCanvasRevision: "2026-06-22T00:00:00.000Z", baseCanvasUpdatedAt: "2026-06-22T00:00:00.000Z", }); }); test("canvas service save uses store revision fallback when options are omitted", async () => { const canvasData = { nodes: [], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select" as const, }; const originalFetch = globalThis.fetch; const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window"); const originalWindow = (globalThis as typeof globalThis & { window?: unknown }) .window; const bodies: Array> = []; useCanvasStore .getState() .setCanvasRevision( "2026-06-22T00:00:10.000Z", "2026-06-22T00:00:10.000Z" ); Object.defineProperty(globalThis, "window", { configurable: true, value: {}, writable: true, }); globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body || "{}"))); return { ok: true, status: 200, json: async () => ({ success: true, data: { updatedAt: "2026-06-22T00:00:11.000Z" }, }), } as Response; }) as typeof fetch; try { await canvasService.saveCanvasState( "11111111-1111-4111-8111-111111111111", canvasData ); await canvasService.saveCanvasState( "11111111-1111-4111-8111-111111111111", canvasData, "canvas", { baseCanvasRevision: "2026-06-22T00:00:12.000Z", } ); } finally { globalThis.fetch = originalFetch; if (hadWindow) { Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow, writable: true, }); } else { Reflect.deleteProperty(globalThis, "window"); } useCanvasStore.getState().setCanvasRevision(null, null); } expect(bodies).toHaveLength(2); expect(bodies[0]).toMatchObject({ canvasData, baseCanvasRevision: "2026-06-22T00:00:10.000Z", baseCanvasUpdatedAt: "2026-06-22T00:00:10.000Z", }); expect(bodies[1]).toMatchObject({ canvasData, workspaceType: "canvas", baseCanvasRevision: "2026-06-22T00:00:12.000Z", baseCanvasUpdatedAt: "2026-06-22T00:00:11.000Z", }); }); test("canvas service keeps store revision aligned after project load and save", async () => { const canvasData = { nodes: [], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select" as const, }; const originalFetch = globalThis.fetch; const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window"); const originalWindow = (globalThis as typeof globalThis & { window?: unknown }) .window; const responses = [ { success: true, data: { id: "11111111-1111-4111-8111-111111111111", title: "测试画布", canvasData, updatedAt: "2026-06-22T00:01:00.000Z", }, }, { success: true, data: { updatedAt: "2026-06-22T00:01:05.000Z", }, }, ]; useCanvasStore.getState().setCanvasRevision(null, null); Object.defineProperty(globalThis, "window", { configurable: true, value: {}, writable: true, }); globalThis.fetch = (async () => ({ ok: true, status: 200, json: async () => responses.shift()!, }) as Response) as typeof fetch; try { await canvasService.getProject("11111111-1111-4111-8111-111111111111"); expect(useCanvasStore.getState().canvasRevision).toBe( "2026-06-22T00:01:00.000Z" ); await canvasService.saveCanvasState( "11111111-1111-4111-8111-111111111111", canvasData ); expect(useCanvasStore.getState().canvasRevision).toBe( "2026-06-22T00:01:05.000Z" ); expect(useCanvasStore.getState().canvasUpdatedAt).toBe( "2026-06-22T00:01:05.000Z" ); } finally { globalThis.fetch = originalFetch; if (hadWindow) { Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow, writable: true, }); } else { Reflect.deleteProperty(globalThis, "window"); } useCanvasStore.getState().setCanvasRevision(null, null); } }); test("canvas service automatically retries stale save conflicts", async () => { const originalFetch = globalThis.fetch; const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, "window"); const originalWindow = (globalThis as typeof globalThis & { window?: unknown }) .window; const bodies: Array> = []; const responses = [ { ok: false, status: 409, json: async () => ({ success: false, error: "画布已更新,请刷新后再保存", code: "STALE_CANVAS_REVISION", canvasUpdatedAt: "2026-06-22T00:02:00.000Z", }), }, { ok: true, status: 200, json: async () => ({ success: true, data: { updatedAt: "2026-06-22T00:02:01.000Z" }, }), }, ]; Object.defineProperty(globalThis, "window", { configurable: true, value: {}, writable: true, }); globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body || "{}"))); return responses.shift() as Response; }) as typeof fetch; try { const result = await canvasService.saveCanvasState( "11111111-1111-4111-8111-111111111111", { nodes: [], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select", } ); expect(result).toMatchObject({ success: true, data: { updatedAt: "2026-06-22T00:02:01.000Z" }, }); expect(bodies).toHaveLength(2); expect(bodies[1]).toMatchObject({ baseCanvasRevision: "2026-06-22T00:02:00.000Z", baseCanvasUpdatedAt: "2026-06-22T00:02:00.000Z", }); expect(useCanvasStore.getState().canvasRevision).toBe( "2026-06-22T00:02:01.000Z" ); } finally { globalThis.fetch = originalFetch; if (hadWindow) { Object.defineProperty(globalThis, "window", { configurable: true, value: originalWindow, writable: true, }); } else { Reflect.deleteProperty(globalThis, "window"); } } }); const plannerContext: CanvasAgentContext = { version: 1, source: "canvas", projectId: "11111111-1111-4111-8111-111111111111", canvasRevision: "2026-06-22T00:00:00.000Z", canvasUpdatedAt: "2026-06-22T00:00:00.000Z", viewport: { x: 0, y: 0, zoom: 1 }, selectedNodeIds: ["image-1", "text-1"], selectedNodes: [ { nodeId: "image-1", type: "image", title: "主图", x: 10, y: 20, width: 300, height: 240, isSelected: true, }, { nodeId: "text-1", type: "text", title: "旧标题", x: 40, y: 80, width: 240, height: 80, isSelected: true, }, ], candidateNodes: [], slots: [], annotations: [], focusPolicy: "selected", }; test("keeps canvas model planner scoped to low-risk canvas operations", () => { expect( shouldConsiderCanvasAgentModelPlanner({ userText: "把选中的文案润色得更高级", context: plannerContext, }) ).toBe(true); expect( shouldConsiderCanvasAgentModelPlanner({ userText: "给这张图换成夏天户外背景,新图放右边", context: plannerContext, }) ).toBe(false); expect( shouldConsiderCanvasAgentModelPlanner({ userText: "删除背景,结果放右边", context: plannerContext, }) ).toBe(false); expect( shouldConsiderCanvasAgentModelPlanner({ userText: "新建一个视频槽位", context: plannerContext, }) ).toBe(true); expect( shouldConsiderCanvasAgentModelPlanner({ userText: "对比原图和新图", context: plannerContext, }) ).toBe(true); expect( shouldConsiderCanvasAgentModelPlanner({ userText: "清空选中的节点", context: plannerContext, }) ).toBe(true); expect( shouldConsiderCanvasAgentModelPlanner({ userText: "向右移动视角一点", context: plannerContext, }) ).toBe(true); }); test("treats selected agent slots as canvas routing context", () => { expect( hasCanvasAgentRoutingContext({ hasCanvasSelection: false, targetResolutionMode: "none", mediaSource: "none", selectedImageCount: 0, selectedSlotCount: 1, hasSelectedAgentSlot: true, }) ).toBe(true); expect( hasCanvasAgentRoutingContext({ hasCanvasSelection: false, targetResolutionMode: "none", mediaSource: "none", selectedImageCount: 0, selectedSlotCount: 0, hasSelectedAgentSlot: false, }) ).toBe(false); }); test("normalizes model-planned empty slot creation", () => { const outcome = normalizeCanvasAgentModelPlannerDecision({ decision: { action: "create_agent_slot", slotType: "video", slotLabel: "片头视频槽位", }, context: { ...plannerContext, viewport: { x: -100, y: -50, zoom: 2, width: 1000, height: 800 }, }, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "新建一个视频槽位", }); expect(outcome.kind).toBe("operation"); expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({ operation: "create_agent_slot", targetPosition: { x: 120, y: 115 }, slot: { slotType: "video", label: "片头视频槽位", aspectRatio: "16:9", fillPolicy: "replace-empty", }, }); }); test("builds direct-media slot creation plans for combined slot requests", () => { const intent = resolveCanvasAgentSlotCreationIntent({ userText: "新建一个 9:16 视频槽位并生成片头", }); expect(intent).toMatchObject({ shouldCreate: true, combinedWithMediaExecution: true, slotType: "video", aspectRatio: "9:16", }); const plan = buildCanvasAgentSlotCreationPlan({ context: { ...plannerContext, viewport: { x: 0, y: 0, zoom: 1, width: 1000, height: 800 }, }, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "新建一个 9:16 视频槽位并生成片头", operationId: "message-1:agent-generate-video:create-slot", }); expect(plan).toMatchObject({ operationId: "message-1:agent-generate-video:create-slot", operation: "create_agent_slot", targetPosition: { x: 320, y: 290 }, slot: { slotType: "video", aspectRatio: "9:16", fillPolicy: "replace-empty", }, }); }); test("leaves combined slot creation and media generation to media executors", () => { const outcome = normalizeCanvasAgentModelPlannerDecision({ decision: { action: "create_agent_slot", slotType: "video", }, context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "创建一个视频槽位并生成结果", }); expect(outcome).toMatchObject({ kind: "none", reason: "slot_creation_combined_with_media_execution", }); }); test("plans direct writeback as a slot fill when an agent slot is selected", () => { const context: CanvasAgentContext = { ...plannerContext, selectedNodeIds: ["slot-1"], selectedNodes: [ { nodeId: "slot-1", type: "agent-slot", title: "主图槽位", x: 100, y: 120, width: 320, height: 240, isSelected: true, }, ], slots: [ { nodeId: "slot-1", slotType: "image", label: "主图槽位", x: 100, y: 120, width: 320, height: 240, aspectRatio: "4:3", isSelected: true, fillPolicy: "replace-empty", }, ], }; const result = buildCanvasAgentDirectWritebackPlan({ projectId: plannerContext.projectId || "", chatId: "chat-1", assistantMessageId: "assistant-1", operationLabel: "agent-generate-image", resultType: "image", resultUrls: ["https://cdn.example.com/slot.png"], prompt: "生成主图", mode: "generate", context, model: "image-model", }); expect(result).toMatchObject({ operationId: "assistant-1:agent-generate-image", operation: "fill_slot", targetSlotId: "slot-1", shouldFillSlot: true, mediaItemIds: ["assistant-1:agent-generate-image:1"], plan: { operation: "fill_slot", targetSlotId: "slot-1", anchorNodeId: undefined, placementStrategy: "single", sourceNodeIds: ["slot-1"], }, }); expect(result?.mediaItems[0]).toMatchObject({ mediaItemId: "assistant-1:agent-generate-image:1", type: "image", url: "https://cdn.example.com/slot.png", prompt: "生成主图", sourceNodeIds: ["slot-1"], metadata: { messageId: "assistant-1", chatId: "chat-1", model: "image-model", }, }); }); test("does not fill a selected agent slot when the result type mismatches", () => { const context: CanvasAgentContext = { ...plannerContext, selectedNodeIds: ["slot-video-1"], selectedNodes: [ { nodeId: "slot-video-1", type: "agent-slot", title: "视频槽位", x: 100, y: 120, width: 320, height: 180, isSelected: true, }, ], slots: [ { nodeId: "slot-video-1", slotType: "video", label: "视频槽位", x: 100, y: 120, width: 320, height: 180, aspectRatio: "16:9", isSelected: true, fillPolicy: "replace-empty", }, ], }; const result = buildCanvasAgentDirectWritebackPlan({ projectId: plannerContext.projectId || "", chatId: "chat-1", assistantMessageId: "assistant-mismatch", operationLabel: "agent-generate-image", resultType: "image", resultUrls: ["https://cdn.example.com/not-video-slot.png"], prompt: "生成主图", mode: "generate", context, model: "image-model", }); expect(result).toMatchObject({ operation: "insert_beside", targetSlotId: "", shouldFillSlot: false, plan: { operation: "insert_beside", targetSlotId: undefined, anchorNodeId: "slot-video-1", placement: "right", }, }); expect(result?.mediaItems[0]).toMatchObject({ type: "image", sourceNodeIds: ["slot-video-1"], }); }); test("plans direct image edits as append revisions anchored to selected media", () => { const result = buildCanvasAgentDirectWritebackPlan({ projectId: plannerContext.projectId || "", chatId: "chat-1", assistantMessageId: "assistant-2", operationLabel: "agent-edit-image", resultType: "image", resultUrls: [ "relative.png", "https://cdn.example.com/revision.png", ], prompt: "", requestTextContent: "把选中图片改成黄昏光线", mode: "edit", context: plannerContext, targetRefs: [{ nodeId: "image-1" }], sourceImageUrls: ["https://cdn.example.com/source.png"], assetIdsByUrl: { "https://cdn.example.com/revision.png": "asset-1", }, }); expect(result).toMatchObject({ operation: "append_revision", shouldFillSlot: false, mediaItemIds: ["assistant-2:agent-edit-image:1"], plan: { operation: "append_revision", anchorNodeId: "image-1", placementStrategy: "single", metadata: { prompt: "把选中图片改成黄昏光线", }, }, }); expect(result?.mediaItems[0]).toMatchObject({ assetId: "asset-1", sourceNodeIds: ["image-1"], metadata: { sourceImageUrls: ["https://cdn.example.com/source.png"], }, }); }); test("routes watermark removal through the dedicated direct image tool", () => { const result = resolveDirectAgentToolExecution({ mode: "edit_image", routeIntent: "image_edit", routeSubIntent: "remove_watermark", targetSource: "explicit_selection", preferredCapability: "remove_watermark", shouldUseStructuredReply: true, shouldOptimizePrompt: true, shouldApplyImagePipeline: true, shouldApplyVideoPipeline: false, shouldStayConversational: false, shouldPublishToCanvas: false, toolCalls: [ { name: "resolveTargetImage", args: { source: "selected_canvas" }, }, { name: "editTargetImage", reason: "planner only provided the generic edit tool", }, ], availableTools: ["resolveTargetImage", "editTargetImage", "removeWatermark"], }); expect(result).toMatchObject({ toolName: "removeWatermark", capability: "remove_watermark", sourcePreferences: ["selected_canvas"], }); expect(getDirectAgentToolLabel("remove_watermark")).toBe("Agent工具:去水印"); expect(getDirectAgentToolSuccessMessage("remove_watermark")).toBe( "已完成去水印处理,结果如下:" ); }); test("formats direct agent image results as structured markdown cards", () => { const generatedText = buildDirectAgentImageGenerationAssistantText({ imageMarkdown: "![生成结果](https://cdn.example.com/generated.png)", modelDisplayName: "Test Image Model", }); expect(generatedText).toContain("### 执行结果"); expect(generatedText).toContain("- 已完成图片生成,结果如下。"); expect(generatedText).toContain("![生成结果](https://cdn.example.com/generated.png)"); expect(generatedText).toContain("### 后续操作"); const editText = buildDirectAgentImageEditAssistantText({ capability: "remove_background", originalUrl: "https://cdn.example.com/cutout.png", }); expect(editText).toContain("### 执行结果"); expect(editText).toContain("- 已完成抠图处理,结果如下。"); expect(editText).toContain("https://cdn.example.com/cutout.png"); expect(editText).toContain("◈ 抠图"); expect(editText).not.toContain("Agent工具:"); expect(editText).toContain("替换背景"); }); test("uses user-facing direct agent tool labels in fallbacks", () => { expect(getDirectAgentToolDisplayName("generic_edit")).toBe("图片编辑"); expect(getDirectAgentToolDisplayName("local_edit")).toBe("局部编辑"); expect(getDirectAgentToolPrefaceFallback("generic_edit")).toBe( "好的,我来帮你修改这张图,稍等我一下。" ); expect(getDirectAgentToolPrefaceFallback("generic_edit")).not.toContain("Agent工具:"); }); test("builds capability-aware agent progress node labels", () => { const selectingSteps = buildAgentProgressNodeSteps({ stage: "planning", currentStep: "select_tool", capability: "remove_background", }); expect(selectingSteps.map((step) => step.label)).toEqual([ "理解用户需求", "分析使用工具", "确认抠图工具", "抠图中", "整理结果", ]); expect(selectingSteps.map((step) => step.state)).toEqual([ "completed", "completed", "current", "upcoming", "upcoming", ]); const executingSteps = buildAgentProgressNodeSteps({ stage: "executing", currentStep: "execute", capability: "remove_background", }); expect(executingSteps[2]?.state).toBe("completed"); expect(executingSteps[3]).toMatchObject({ label: "抠图中", state: "current", }); expect(getAgentProgressStepLabel("execute", { capability: "generate_video" })).toBe( "视频生成中" ); }); test("resolves deictic canvas edit requests to latest results and direct tools", () => { const latestResultUrl = "https://cdn.example.com/latest-cat-result.png"; const requestPolicy = resolveCanvasAgentRequestPolicy({ composerMode: "agent", selectedChatModel: "conversation-model-id", }); expect(requestPolicy.effectiveSelectedChatModel).toBe("auto"); expect(requestPolicy.shouldForceToolOrchestration).toBe(true); const targetPreference = resolveCanvasAgentTargetPreference({ text: "将这只猫从图中扣图扣出来", selectedImageCount: 0, latestResultImageCount: 1, workingMemoryTargetImageCount: 1, }); expect(targetPreference.shouldPreferLatestResult).toBe(true); const mediaInput = { imageRefs: [], currentMessageImageRefs: [], selectedImageRefs: [], canvasImageRefs: [], historyUserImageRefs: [], historyAssistantImageRefs: [ { url: latestResultUrl, normalizedUrl: latestResultUrl, source: "history_assistant_result", role: "history_result", }, ], resolvedImageRefs: [], currentMessageImageUrls: [], selectedImageUrls: [], historyUserImageUrls: [], historyAssistantImageUrls: [latestResultUrl], resolvedImageUrls: [latestResultUrl], selectedAssetIds: [], candidateAssetIds: [], uploadedAssetIds: [], hasImage: true, source: "history", fromHistory: true, } as any; const workingState = { lastGeneratedImageUrls: [latestResultUrl], lastResolvedTargetImageUrls: [latestResultUrl], }; const targetResolution = { targetRefs: [], candidateRefs: [], targetImageUrls: [latestResultUrl], targetAssetIds: [], candidateImageUrls: [], candidateAssetIds: [], resolutionMode: "working_memory", confidence: targetPreference.confidence, needsClarification: false, reason: targetPreference.reason, fromHistory: true, } as any; const directContext = resolveDirectAgentToolContext({ execution: { mode: "edit_image", routeIntent: "image_edit", routeSubIntent: "remove_background", targetSource: targetResolution.resolutionMode, preferredCapability: "remove_background", shouldUseStructuredReply: true, shouldOptimizePrompt: true, shouldApplyImagePipeline: true, shouldApplyVideoPipeline: false, shouldStayConversational: false, shouldPublishToCanvas: false, toolCalls: [], availableTools: ["removeBackground"], } as any, requestResolvedMediaInput: mediaInput, requestTargetResolution: targetResolution, requestWorkingState: workingState, }); expect(directContext?.execution.toolName).toBe("removeBackground"); expect(directContext?.targetImageUrl).toBe(latestResultUrl); }); test("falls back to canvas images for direct agent edit tools", () => { const canvasImageUrl = "https://cdn.example.com/canvas-cat.png"; const mediaInput = { imageRefs: [], currentMessageImageRefs: [], selectedImageRefs: [], canvasImageRefs: [ { url: canvasImageUrl, normalizedUrl: canvasImageUrl, nodeId: "cat-node", source: "canvas_context", role: "reference", x: 120, y: 80, width: 640, height: 640, isSelected: false, }, ], historyUserImageRefs: [], historyAssistantImageRefs: [], resolvedImageRefs: [], currentMessageImageUrls: [], selectedImageUrls: [], canvasImageUrls: [canvasImageUrl], historyUserImageUrls: [], historyAssistantImageUrls: [], resolvedImageUrls: [], selectedAssetIds: [], candidateAssetIds: [], uploadedAssetIds: [], hasImage: false, source: "none", fromHistory: false, } as any; const directContext = resolveDirectAgentToolContext({ execution: { mode: "edit_image", routeIntent: "image_edit", routeSubIntent: "remove_background", targetSource: "text_reference", preferredCapability: "remove_background", shouldUseStructuredReply: true, shouldOptimizePrompt: true, shouldApplyImagePipeline: true, shouldApplyVideoPipeline: false, shouldStayConversational: false, shouldPublishToCanvas: false, toolCalls: [], availableTools: ["removeBackground"], } as any, requestResolvedMediaInput: mediaInput, }); expect(directContext?.execution.toolName).toBe("removeBackground"); expect(directContext?.targetImageUrl).toBe(canvasImageUrl); }); test("routes canvas subject cutout wording to remove background instead of image generation", () => { const canvasImageUrl = "https://cdn.example.com/canvas-dog.png"; const userText = "帮我将画布中的狗的图扣出来"; expect(isExplicitImageGenerationRequest(userText)).toBe(false); expect(inferCanvasAgentImageEditCapability(userText)).toBe("remove_background"); const directContext = resolveDirectAgentToolContext({ execution: { mode: "edit_image", routeIntent: "image_edit", routeSubIntent: "remove_background", targetSource: "text_reference", preferredCapability: "remove_background", shouldUseStructuredReply: true, shouldOptimizePrompt: true, shouldApplyImagePipeline: true, shouldApplyVideoPipeline: false, shouldStayConversational: false, shouldPublishToCanvas: false, toolCalls: [], availableTools: ["resolveTargetImage", "removeBackground"], } as any, requestResolvedMediaInput: { imageRefs: [], currentMessageImageRefs: [], selectedImageRefs: [], canvasImageRefs: [ { url: canvasImageUrl, normalizedUrl: canvasImageUrl, nodeId: "dog-node", source: "canvas_context", role: "reference", }, ], historyUserImageRefs: [], historyAssistantImageRefs: [], resolvedImageRefs: [], currentMessageImageUrls: [], selectedImageUrls: [], canvasImageUrls: [canvasImageUrl], historyUserImageUrls: [], historyAssistantImageUrls: [], resolvedImageUrls: [], selectedAssetIds: [], candidateAssetIds: [], uploadedAssetIds: [], hasImage: true, source: "canvas_context", fromHistory: false, } as any, }); expect(directContext?.execution.toolName).toBe("removeBackground"); expect(directContext?.targetImageUrl).toBe(canvasImageUrl); }); test("only runs direct image edit batches for explicit safe batch requests", () => { expect( shouldRunDirectAgentBatchImageEdit({ capability: "remove_watermark", sourceImageUrls: [ "https://cdn.example.com/a.png", "https://cdn.example.com/b.png", ], userText: "把这几张都去水印", }) ).toBe(true); expect( shouldRunDirectAgentBatchImageEdit({ capability: "remove_background", sourceImageUrls: [ "https://cdn.example.com/a.png", "https://cdn.example.com/b.png", ], userText: "每张分别抠图", }) ).toBe(true); expect( shouldRunDirectAgentBatchImageEdit({ capability: "remove_watermark", sourceImageUrls: ["https://cdn.example.com/a.png"], userText: "全部去水印", }) ).toBe(false); expect( shouldRunDirectAgentBatchImageEdit({ capability: "generic_edit", sourceImageUrls: [ "https://cdn.example.com/a.png", "https://cdn.example.com/b.png", ], userText: "把这几张都改成黄昏", }) ).toBe(false); expect( shouldRunDirectAgentBatchImageEdit({ capability: "angle_variant", sourceImageUrls: [ "https://cdn.example.com/a.png", "https://cdn.example.com/b.png", ], userText: "这两张都生成右侧面视角", }) ).toBe(true); expect(hasExplicitDirectAgentBatchImageEditIntent("只处理第一张")).toBe(false); expect(isDirectAgentBatchImageEditCapability("box_cutout")).toBe(false); }); test("routes angle variant requests through the dedicated direct image tool", () => { const result = resolveDirectAgentToolExecution({ mode: "edit_image", routeIntent: "image_edit", routeSubIntent: "angle_variant", targetSource: "explicit_selection", preferredCapability: "angle_variant", shouldUseStructuredReply: true, shouldOptimizePrompt: true, shouldApplyImagePipeline: true, shouldApplyVideoPipeline: false, shouldStayConversational: false, shouldPublishToCanvas: false, toolCalls: [ { name: "resolveTargetImage", args: { source: "selected_canvas" }, }, { name: "editTargetImage", reason: "planner only provided the generic edit tool", }, ], availableTools: ["resolveTargetImage", "editTargetImage", "angleEditImage"], }); expect(result).toMatchObject({ toolName: "angleEditImage", capability: "angle_variant", sourcePreferences: ["selected_canvas"], }); expect(getDirectAgentToolLabel("angle_variant")).toBe("Agent工具:多视角"); expect(getDirectAgentToolSuccessMessage("angle_variant")).toBe( "已完成多视角生成,结果如下:" ); }); test("routes local edit requests through the dedicated direct image tool", () => { const result = resolveDirectAgentToolExecution({ mode: "edit_image", routeIntent: "image_edit", routeSubIntent: "local_edit", targetSource: "explicit_selection", preferredCapability: "local_edit", shouldUseStructuredReply: true, shouldOptimizePrompt: true, shouldApplyImagePipeline: true, shouldApplyVideoPipeline: false, shouldStayConversational: false, shouldPublishToCanvas: false, toolCalls: [ { name: "resolveTargetImage", args: { source: "selected_canvas" }, }, { name: "editTargetImage", reason: "planner only provided the generic edit tool", }, ], availableTools: ["resolveTargetImage", "editTargetImage", "localEditImage"], }); expect(result).toMatchObject({ toolName: "localEditImage", capability: "local_edit", sourcePreferences: ["selected_canvas"], }); expect(getDirectAgentToolLabel("local_edit")).toBe("Agent工具:局部编辑"); expect(getDirectAgentToolSuccessMessage("local_edit")).toBe( "已完成局部编辑,结果如下:" ); expect(isDirectAgentBatchImageEditCapability("local_edit")).toBe(false); }); test("resolves direct local edit regions from selected canvas annotations", () => { const context: CanvasAgentContext = { version: 1, source: "canvas", projectId: "project-1", selectedNodeIds: ["image-1"], selectedNodes: [ { nodeId: "image-1", type: "image", url: "https://cdn.example.com/source.png", x: 0, y: 0, isSelected: true, }, ], candidateNodes: [], slots: [], annotations: [ { id: "annotation-1", sourceNodeId: "image-1", type: "crop-box", text: "把杯子换成蓝色陶瓷杯", bounds: { x: 0.2, y: 0.3, width: 0.25, height: 0.2 }, color: "#1677ff", }, { id: "annotation-other", sourceNodeId: "image-2", type: "crop-box", text: "不应带入", bounds: { x: 0.1, y: 0.1, width: 0.1, height: 0.1 }, }, ], focusPolicy: "selected", }; const result = resolveDirectAgentLocalEditRegions({ canvasAgentContext: context, requestTextContent: "按批注修改", sourceImageUrl: "https://cdn.example.com/source.png", targetNodeIds: ["image-1"], }); expect(result.sourceNodeId).toBe("image-1"); expect(result.regions).toEqual([ { id: "annotation-1", color: "#1677ff", prompt: "把杯子换成蓝色陶瓷杯", rect: { x: 0.2, y: 0.3, width: 0.25, height: 0.2 }, }, ]); expect(hasDirectAgentLocalEditIntent("按照批注修改这张图")).toBe(true); expect(hasDirectAgentLocalEditIntent("给这张图添加批注")).toBe(false); expect( normalizeDirectAgentLocalEditRect({ x: 0.8, y: 0.8, width: 0.4, height: 0.3, }) ).toEqual({ x: 0.8, y: 0.8, width: 0.2, height: 0.2 }); }); test("infers deterministic angle edit options from canvas agent text", () => { expect(inferAngleEditOptionsFromText("生成右侧面近景视角")).toMatchObject({ horizontalAngle: 90, verticalAngle: 0, zoom: 7, }); expect(inferAngleEditOptionsFromText("改成背面俯视远景")).toMatchObject({ horizontalAngle: 180, verticalAngle: 45, zoom: 3, }); expect(inferAngleEditOptionsFromText("水平角120度,垂直角-10度,zoom8")).toMatchObject({ horizontalAngle: 120, verticalAngle: -10, zoom: 8, }); }); test("plans watermark removal results as non-destructive canvas revisions", () => { const result = buildCanvasAgentDirectWritebackPlan({ projectId: plannerContext.projectId || "", chatId: "chat-1", assistantMessageId: "assistant-watermark", operationLabel: "agent-remove_watermark", resultType: "image", resultUrls: ["https://cdn.example.com/watermark-removed.png"], prompt: "把选中图片上的水印去掉", requestTextContent: "把选中图片上的水印去掉,结果放右边", mode: "edit", context: plannerContext, targetRefs: [{ nodeId: "image-1" }], sourceImageUrls: ["https://cdn.example.com/source-watermarked.png"], }); expect(result).toMatchObject({ operation: "append_revision", shouldFillSlot: false, mediaItemIds: ["assistant-watermark:agent-remove_watermark:1"], plan: { operation: "append_revision", anchorNodeId: "image-1", placement: "right", preserveOriginal: true, }, }); expect(result?.mediaItems[0]).toMatchObject({ type: "image", url: "https://cdn.example.com/watermark-removed.png", sourceNodeIds: ["image-1"], metadata: { sourceImageUrls: ["https://cdn.example.com/source-watermarked.png"], }, }); }); test("anchors direct image edits to resolved target refs before fallback selection", () => { const context: CanvasAgentContext = { ...plannerContext, selectedNodeIds: [], selectedNodes: [], candidateNodes: [ { nodeId: "image-left", type: "image", title: "左图", x: 0, y: 0, width: 240, height: 180, }, { nodeId: "image-right", type: "image", title: "右图", x: 420, y: 0, width: 240, height: 180, }, ], focusPolicy: "auto", }; const result = buildCanvasAgentDirectWritebackPlan({ projectId: plannerContext.projectId || "", chatId: "chat-1", assistantMessageId: "assistant-targeted", operationLabel: "agent-edit-image", resultType: "image", resultUrls: ["https://cdn.example.com/right-revision.png"], prompt: "把右边那张背景换成白色", mode: "edit", context, targetRefs: [{ nodeId: " image-right " }], sourceImageUrls: ["https://cdn.example.com/right-source.png"], }); expect(result).toMatchObject({ operation: "append_revision", plan: { operation: "append_revision", anchorNodeId: "image-right", sourceNodeIds: ["image-right"], }, }); expect(result?.selectedMediaNodes.map((node) => node.nodeId)).toEqual([ "image-right", ]); expect(result?.mediaItems[0]).toMatchObject({ sourceNodeIds: ["image-right"], metadata: { sourceImageUrls: ["https://cdn.example.com/right-source.png"], }, }); }); test("records annotation trace metadata for direct annotation edits", () => { const context: CanvasAgentContext = { ...plannerContext, annotations: [ { id: "annotation-keep-bg", sourceNodeId: "image-1", type: "text-note", text: "背景换成浅灰", bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 }, }, { id: "annotation-other", sourceNodeId: "image-other", type: "text-note", text: "不应进入 image-1 的结果 metadata", }, ], }; const result = buildCanvasAgentDirectWritebackPlan({ projectId: plannerContext.projectId || "", chatId: "chat-1", assistantMessageId: "assistant-annotation-edit", operationLabel: "agent-edit-image", resultType: "image", resultUrls: ["https://cdn.example.com/annotation-edit.png"], prompt: "按批注修改这张图", requestTextContent: "按批注修改这张图", mode: "edit", context, targetRefs: [{ nodeId: "image-1" }], }); expect(result?.mediaItems[0]?.metadata).toMatchObject({ canvasAgentAnnotationIds: ["annotation-keep-bg"], canvasAgentAnnotationCount: 1, }); expect( String(result?.mediaItems[0]?.metadata?.canvasAgentAnnotationBrief || "") ).toContain("背景换成浅灰"); expect( String(result?.mediaItems[0]?.metadata?.canvasAgentAnnotationBrief || "") ).not.toContain("annotation-other"); }); test("plans direct batch image edits beside each matched source", () => { const context: CanvasAgentContext = { ...plannerContext, selectedNodeIds: ["image-a", "image-b"], selectedNodes: [ { nodeId: "image-a", type: "image", title: "A", x: 0, y: 0, width: 200, height: 160, isSelected: true, }, { nodeId: "image-b", type: "image", title: "B", x: 260, y: 0, width: 200, height: 160, isSelected: true, }, ], }; const result = buildCanvasAgentDirectWritebackPlan({ projectId: plannerContext.projectId || "", chatId: "chat-1", assistantMessageId: "assistant-batch-edit", operationLabel: "agent-remove_watermark", resultType: "image", resultUrls: [ "https://cdn.example.com/a-clean.png", "https://cdn.example.com/b-clean.png", ], prompt: "这两张都去水印", requestTextContent: "这两张都去水印", mode: "edit", context, targetRefs: [{ nodeId: "image-a" }, { nodeId: "image-b" }], sourceImageUrls: [ "https://cdn.example.com/a.png", "https://cdn.example.com/b.png", ], }); expect(result).toMatchObject({ operation: "append_revision", shouldFillSlot: false, plan: { operation: "append_revision", anchorNodeId: "image-a", placementStrategy: "beside_each_source", sourceNodeIds: ["image-a", "image-b"], }, }); expect(result?.mediaItems.map((item) => item.sourceNodeIds)).toEqual([ ["image-a"], ["image-b"], ]); }); test("plans direct batch generations beside each matched source", () => { const context: CanvasAgentContext = { ...plannerContext, selectedNodeIds: ["image-a", "image-b"], selectedNodes: [ { nodeId: "image-a", type: "image", title: "A", x: 0, y: 0, width: 200, height: 160, isSelected: true, }, { nodeId: "image-b", type: "image", title: "B", x: 260, y: 0, width: 200, height: 160, isSelected: true, }, ], }; const result = buildCanvasAgentDirectWritebackPlan({ projectId: plannerContext.projectId || "", chatId: "chat-1", assistantMessageId: "assistant-3", operationLabel: "agent-generate-image", resultType: "image", resultUrls: [ "https://cdn.example.com/a.png", "https://cdn.example.com/b.png", ], prompt: "分别生成两个变体", mode: "generate", context, targetRefs: [{ nodeId: "image-a" }, { nodeId: "image-b" }], }); expect(result).toMatchObject({ operation: "insert_beside", shouldFillSlot: false, plan: { operation: "insert_beside", anchorNodeId: "image-a", placementStrategy: "beside_each_source", sourceNodeIds: ["image-a", "image-b"], }, }); expect(result?.mediaItems.map((item) => item.sourceNodeIds)).toEqual([ ["image-a"], ["image-b"], ]); }); test("plans direct generations into the current viewport when no anchor exists", () => { const context: CanvasAgentContext = { ...plannerContext, selectedNodeIds: [], selectedNodes: [], candidateNodes: [], slots: [], annotations: [], viewport: { x: -200, y: -100, zoom: 1, width: 1000, height: 800 }, focusPolicy: "auto", }; const result = buildCanvasAgentDirectWritebackPlan({ projectId: plannerContext.projectId || "", chatId: "chat-1", assistantMessageId: "assistant-no-anchor", operationLabel: "agent-generate-image", resultType: "image", resultUrls: ["https://cdn.example.com/no-anchor.png"], prompt: "生成一张主图", mode: "generate", context, }); expect(result).toMatchObject({ operation: "insert_beside", shouldFillSlot: false, plan: { operation: "insert_beside", anchorNodeId: undefined, placement: "viewport-center", placementStrategy: "single", sourceNodeIds: [], metadata: { canvasAgentViewport: { x: -200, y: -100, zoom: 1, width: 1000, height: 800, }, }, }, }); expect(result?.mediaItems[0]?.sourceNodeIds).toEqual([]); }); test("does not build direct writeback plans without valid result urls", () => { expect( buildCanvasAgentDirectWritebackPlan({ projectId: plannerContext.projectId || "", chatId: "chat-1", assistantMessageId: "assistant-4", operationLabel: "agent-generate-image", resultType: "image", resultUrls: ["", "blob:local", "/relative.png"], prompt: "生成图片", mode: "generate", context: plannerContext, }) ).toBeNull(); }); test("normalizes model-planned focus to known canvas nodes only", () => { const outcome = normalizeCanvasAgentModelPlannerDecision({ decision: { action: "focus_nodes", nodeIds: ["missing-node", "image-1"], reply: "已聚焦主图。", }, context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "帮我定位到主图", }); expect(outcome.kind).toBe("view"); expect(outcome.kind === "view" ? outcome.viewPlan.nodeIds : []).toEqual([ "image-1", ]); }); test("normalizes model-planned text updates to selected text nodes", () => { const outcome = normalizeCanvasAgentModelPlannerDecision({ decision: { action: "update_text", nodeIds: ["image-1", "text-1"], textContent: "轻盈强吸力,家务更省心", }, context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "把选中文案改得更高级", }); expect(outcome.kind).toBe("operation"); expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({ operation: "update_text", targetNodeIds: ["text-1"], textContent: "轻盈强吸力,家务更省心", }); }); 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: { 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 image notes as note updates", () => { const outcome = normalizeCanvasAgentModelPlannerDecision({ decision: { action: "update_note", nodeIds: ["image-1", "text-1"], textContent: "保留主体轮廓,背景可以更干净", }, context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "给选中图片备注:保留主体轮廓,背景可以更干净", }); expect(outcome.kind).toBe("operation"); expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({ operation: "update_note", targetNodeIds: ["image-1"], textContent: "保留主体轮廓,背景可以更干净", metadata: { messageId: "message-1", chatId: "chat-1", planner: "model", }, }); }); test("builds deterministic text creation plans from explicit canvas prompts", () => { const context: CanvasAgentContext = { ...plannerContext, selectedNodeIds: [], selectedNodes: [], viewport: { x: 0, y: 0, zoom: 1, width: 1200, height: 800 }, focusPolicy: "auto", }; const outcome = buildCanvasAgentExplicitOperationPlan({ context, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "在画布上添加文字:新品上市", }); expect(outcome?.operationPlan).toMatchObject({ operation: "create_text_node", targetPosition: { x: 440, y: 340 }, textContent: "新品上市", preserveOriginal: true, metadata: { messageId: "message-1", chatId: "chat-1", planner: "deterministic", canvasAgentViewport: { x: 0, y: 0, zoom: 1, width: 1200, height: 800 }, }, }); }); 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 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, viewport: { x: 0, y: 0, zoom: 1, width: 1000, height: 800 }, focusPolicy: "auto", }; const outcome = buildCanvasAgentExplicitOperationPlan({ context, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "新建一个 9:16 视频槽位", }); expect(outcome?.operationPlan).toMatchObject({ operation: "create_agent_slot", targetPosition: { x: 320, y: 290 }, slot: { slotType: "video", label: "视频槽位", aspectRatio: "9:16", fillPolicy: "replace-empty", acceptedSources: ["text", "image", "selection", "annotation"], }, preserveOriginal: true, metadata: { messageId: "message-1", chatId: "chat-1", planner: "deterministic", }, }); expect(outcome?.reply).toContain("视频槽位"); const imageOutcome = buildCanvasAgentExplicitOperationPlan({ context, projectId: plannerContext.projectId || "", messageId: "message-2", chatId: "chat-1", userText: "创建一个图片槽位", }); expect(imageOutcome?.operationPlan).toMatchObject({ operation: "create_agent_slot", targetPosition: { x: 340, y: 240 }, slot: { slotType: "image", label: "图片槽位", aspectRatio: "1:1", fillPolicy: "replace-empty", }, metadata: { messageId: "message-2", chatId: "chat-1", planner: "deterministic", }, }); expect(imageOutcome?.reply).toContain("图片槽位"); }); test("builds deterministic node rename plans without treating titles as image edits", () => { const outcome = buildCanvasAgentExplicitOperationPlan({ context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "把选中图片改名为白底主图 A", }); expect(outcome?.operationPlan).toMatchObject({ operation: "rename_node", targetNodeIds: ["image-1"], textContent: "白底主图 A", metadata: { messageId: "message-1", chatId: "chat-1", planner: "deterministic", }, }); }); test("builds deterministic note updates 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: "update_note", targetNodeIds: ["image-1"], textContent: "保留主体轮廓", metadata: { messageId: "message-1", chatId: "chat-1", planner: "deterministic", }, }); }); 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({ context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "按这个备注修改主图", }) ).toBeNull(); expect( buildCanvasAgentExplicitOperationPlan({ context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "生成一张图,标题文字:新品上市", }) ).toBeNull(); expect( buildCanvasAgentExplicitOperationPlan({ context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "新建一个视频槽位并生成片头", }) ).toBeNull(); expect( buildCanvasAgentExplicitOperationPlan({ context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "按这张图上的批注修改", }) ).toBeNull(); expect( buildCanvasAgentExplicitOperationPlan({ context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", 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", () => { const outcome = normalizeCanvasAgentModelPlannerDecision({ decision: { action: "add_annotation", nodeIds: ["image-1", "text-1"], textContent: "这里需要重点保留产品轮廓", }, context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "给选中图片加备注:这里需要重点保留产品轮廓", }); expect(outcome.kind).toBe("operation"); expect(outcome.kind === "operation" ? outcome.operationPlan : null).toMatchObject({ operation: "add_annotation", targetNodeIds: ["image-1"], textContent: "这里需要重点保留产品轮廓", annotations: [ { type: "text-note", text: "这里需要重点保留产品轮廓", }, ], }); }); test("turns destructive model-planned canvas requests into confirmation", () => { const outcome = normalizeCanvasAgentModelPlannerDecision({ decision: { action: "none", }, context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "删除选中的这两项", }); expect(outcome).toMatchObject({ kind: "confirmation_required", riskOperation: "delete_nodes", targetNodeIds: ["image-1", "text-1"], }); const clearOutcome = normalizeCanvasAgentModelPlannerDecision({ decision: { action: "none", }, context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "清除选中的对象", }); expect(clearOutcome).toMatchObject({ kind: "confirmation_required", riskOperation: "delete_nodes", targetNodeIds: ["image-1", "text-1"], }); const removeBackgroundOutcome = normalizeCanvasAgentModelPlannerDecision({ decision: { action: "none", }, context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "删除背景,结果放右边", }); expect(removeBackgroundOutcome).toEqual({ kind: "none", reason: undefined, }); }); test("builds pending move-node operation from model-planned high-risk layout", () => { const outcome = normalizeCanvasAgentModelPlannerDecision({ decision: { action: "confirmation_required", riskOperation: "move_nodes", nodeIds: ["image-1", "text-1"], }, context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "把选中的两项整理成一行", }); expect(outcome.kind).toBe("confirmation_required"); expect( outcome.kind === "confirmation_required" ? outcome.operationPlan : null ).toMatchObject({ operation: "move_nodes", targetNodeIds: ["image-1", "text-1"], layoutStrategy: "row", }); expect( outcome.kind === "confirmation_required" ? outcome.operationPlan?.confirmed : true ).toBeUndefined(); }); test("keeps viewport-centering move requests as view operations", () => { const outcome = normalizeCanvasAgentModelPlannerDecision({ decision: { action: "focus_nodes", nodeIds: ["image-1"], }, context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "把选中的图移动到视野中央", }); expect(outcome.kind).toBe("view"); expect(outcome.kind === "view" ? outcome.viewPlan : null).toMatchObject({ operation: "focus_selection", nodeIds: ["image-1"], anchorNodeId: "image-1", persist: false, }); }); test("builds pending delete-node operation from model-planned destructive request", () => { const outcome = normalizeCanvasAgentModelPlannerDecision({ decision: { action: "confirmation_required", riskOperation: "delete_nodes", nodeIds: ["image-1", "text-1"], }, context: plannerContext, projectId: plannerContext.projectId || "", messageId: "message-1", chatId: "chat-1", userText: "删除选中的两项", }); expect(outcome.kind).toBe("confirmation_required"); expect( outcome.kind === "confirmation_required" ? outcome.operationPlan : null ).toMatchObject({ operation: "delete_nodes", targetNodeIds: ["image-1", "text-1"], metadata: { pendingConfirmation: true, riskOperation: "delete_nodes", }, }); expect( outcome.kind === "confirmation_required" ? outcome.operationPlan?.confirmed : true ).toBeUndefined(); }); test("extracts and confirms pending canvas operations from working state", () => { const pending = buildPendingCanvasOperation({ projectId: plannerContext.projectId || "", messageId: "message-1", userText: "把选中的两项整理成一行", riskOperation: "move_nodes", targetNodeIds: ["image-1", "text-1"], reply: "移动节点需要确认。", operationPlan: { version: 1, operationId: "message-1:canvas-move", operation: "move_nodes", projectId: plannerContext.projectId || "", targetNodeIds: ["image-1", "text-1"], layoutStrategy: "row", metadata: { pendingConfirmation: true, }, }, }); const extracted = extractPendingCanvasOperationFromWorkingState({ lastTurnSummary: { pendingCanvasOperation: pending, }, }); expect(extracted?.operationPlan?.operation).toBe("move_nodes"); expect(isCanvasOperationConfirmText("确认执行")).toBe(true); expect(isCanvasOperationCancelText("先不执行了")).toBe(true); const confirmedPlan = extracted ? withConfirmedCanvasOperationPlan(extracted, "message-2") : null; expect(confirmedPlan).toMatchObject({ operation: "move_nodes", confirmed: true, metadata: { confirmedByUser: true, confirmedByMessageId: "message-2", }, }); }); test("confirms pending replace-node-media operations without executing directly", () => { const pending = buildPendingCanvasOperation({ projectId: plannerContext.projectId || "", messageId: "message-1", userText: "直接替换原图", riskOperation: "replace_node_media", targetNodeIds: ["image-1"], reply: "替换原图需要确认。", operationPlan: { version: 1, operationId: "op-replace-confirmed", operation: "replace_node_media", projectId: plannerContext.projectId || "", targetNodeIds: ["image-1"], mediaItems: [ { mediaItemId: "media-1", type: "image", url: "https://cdn.example.com/new.png", assetId: "asset-new", title: "新图", prompt: "new prompt", }, ], }, }); expect(pending.operationPlan?.confirmed).toBeUndefined(); const confirmedPlan = withConfirmedCanvasOperationPlan(pending, "message-2"); expect(confirmedPlan).toMatchObject({ operation: "replace_node_media", confirmed: true, targetNodeIds: ["image-1"], mediaItems: [ { mediaItemId: "media-1", url: "https://cdn.example.com/new.png", }, ], metadata: { confirmedByUser: true, confirmedByMessageId: "message-2", }, }); }); test("confirms pending delete-node operations without executing directly", () => { const pending = buildPendingCanvasOperation({ projectId: plannerContext.projectId || "", messageId: "message-1", userText: "删除选中的两项", riskOperation: "delete_nodes", targetNodeIds: ["image-1", "text-1"], reply: "删除节点需要确认。", operationPlan: { version: 1, operationId: "message-1:canvas-delete", operation: "delete_nodes", projectId: plannerContext.projectId || "", targetNodeIds: ["image-1", "text-1"], metadata: { pendingConfirmation: true, }, }, }); const confirmedPlan = withConfirmedCanvasOperationPlan(pending, "message-2"); expect(confirmedPlan).toMatchObject({ operation: "delete_nodes", confirmed: true, targetNodeIds: ["image-1", "text-1"], metadata: { confirmedByUser: true, confirmedByMessageId: "message-2", }, }); }); test("rejects confirmed stale high-risk canvas mutations", () => { expect( shouldRejectStaleCanvasAgentHighRiskMutation({ operation: "replace_node_media", confirmed: false, baseCanvasWasStale: true, }) ).toBe(false); expect( shouldRejectStaleCanvasAgentHighRiskMutation({ operation: "replace_node_media", confirmed: true, baseCanvasWasStale: true, }) ).toBe(true); expect( shouldRejectStaleCanvasAgentHighRiskMutation({ operation: "delete_nodes", metadata: { confirmedByUser: true }, baseCanvasWasStale: true, }) ).toBe(true); expect( shouldRejectStaleCanvasAgentHighRiskMutation({ operation: "insert_beside", confirmed: true, baseCanvasWasStale: true, }) ).toBe(false); expect( shouldRejectStaleCanvasAgentHighRiskMutation({ operation: "move_nodes", confirmed: true, baseCanvasWasStale: false, }) ).toBe(false); }); test("normalizes canvas operation error messages for stream feedback", () => { expect(normalizeCanvasAgentOperationErrorCode("STALE_CANVAS_REVISION")).toBe( "STALE_CANVAS_REVISION" ); expect(normalizeCanvasAgentOperationErrorCode("unknown")).toBe( "CANVAS_AGENT_WRITEBACK_FAILED" ); expect( normalizeCanvasAgentOperationErrorCode("CANVAS_AGENT_WRITEBACK_DISABLED") ).toBe("CANVAS_AGENT_WRITEBACK_DISABLED"); expect( getCanvasAgentOperationErrorMessage({ code: "STALE_CANVAS_REVISION", }) ).toContain("刷新画布后重新确认"); expect( getCanvasAgentOperationErrorMessage({ code: "CANVAS_AGENT_CONFIRMATION_REQUIRED", }) ).toContain("需要确认"); expect( getCanvasAgentOperationErrorMessage({ code: "CANVAS_AGENT_CONFLICT", }) ).toContain("目标状态有冲突"); expect( getCanvasAgentOperationErrorMessage({ code: "CANVAS_AGENT_WRITEBACK_DISABLED", }) ).toContain("灰度保护"); expect( getCanvasAgentOperationErrorMessage({ code: "CANVAS_AGENT_WRITEBACK_FAILED", fallbackMessage: "写回接口失败", }) ).toBe("写回接口失败"); }); test("normalizes canvas agent rollout flags and writeback modes", () => { expect(normalizeCanvasAgentBoolean("true", false)).toBe(true); expect(normalizeCanvasAgentBoolean("off", true)).toBe(false); expect(normalizeCanvasAgentBoolean("surprise", true)).toBe(true); expect(normalizeCanvasAgentWritebackMode("operation_shadow")).toBe( "operation_shadow" ); expect(normalizeCanvasAgentWritebackMode("nope")).toBe( "operation_authoritative" ); const defaults = getCanvasAgentFeatureFlags({}); expect(defaults).toMatchObject({ operationProtocolEnabled: true, writebackMode: "operation_authoritative", slotEnabled: true, viewOperationEnabled: true, annotationEditEnabled: true, }); expect(canApplyCanvasAgentMutations(defaults)).toBe(true); const shadow = getCanvasAgentFeatureFlags({ CANVAS_AGENT_WRITEBACK_MODE: "operation_shadow", CANVAS_AGENT_SLOT_ENABLED: "0", CANVAS_AGENT_VIEW_OPERATION_ENABLED: "false", CANVAS_AGENT_ANNOTATION_EDIT_ENABLED: "off", }); expect(shadow).toMatchObject({ writebackMode: "operation_shadow", slotEnabled: false, viewOperationEnabled: false, annotationEditEnabled: false, }); expect(canApplyCanvasAgentMutations(shadow)).toBe(false); expect(shouldStreamCanvasAgentShadowOperations(shadow)).toBe(true); const disabledProtocol = getCanvasAgentFeatureFlags({ CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED: "disabled", CANVAS_AGENT_WRITEBACK_MODE: "operation_authoritative", }); expect(canApplyCanvasAgentMutations(disabledProtocol)).toBe(false); expect(shouldStreamCanvasAgentShadowOperations(disabledProtocol)).toBe(false); const settingsOverride = getCanvasAgentFeatureFlags( { CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED: "disabled", CANVAS_AGENT_WRITEBACK_MODE: "legacy_canvas_media", CANVAS_AGENT_SLOT_ENABLED: "0", }, { [CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY]: true, [CANVAS_AGENT_WRITEBACK_MODE_KEY]: "operation_authoritative", [CANVAS_AGENT_SLOT_ENABLED_KEY]: true, [CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY]: false, [CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY]: "off", } ); expect(settingsOverride).toMatchObject({ operationProtocolEnabled: true, writebackMode: "operation_authoritative", slotEnabled: true, viewOperationEnabled: false, annotationEditEnabled: false, }); expect(canApplyCanvasAgentMutations(settingsOverride)).toBe(true); expect(canvasAgentFeatureFlagsToSettings(settingsOverride)).toEqual({ operationProtocolEnabled: true, writebackMode: "operation_authoritative", slotEnabled: true, viewOperationEnabled: false, annotationEditEnabled: false, }); expect( canvasAgentFeatureSettingsToSystemSettings({ operationProtocolEnabled: false, writebackMode: "operation_shadow", slotEnabled: true, viewOperationEnabled: true, annotationEditEnabled: false, }) ).toEqual({ [CANVAS_AGENT_OPERATION_PROTOCOL_ENABLED_KEY]: false, [CANVAS_AGENT_WRITEBACK_MODE_KEY]: "operation_shadow", [CANVAS_AGENT_SLOT_ENABLED_KEY]: true, [CANVAS_AGENT_VIEW_OPERATION_ENABLED_KEY]: true, [CANVAS_AGENT_ANNOTATION_EDIT_ENABLED_KEY]: false, }); expect( normalizeCanvasAgentFeatureSettings({ operationProtocolEnabled: "off", writebackMode: "invalid-mode", slotEnabled: 0, viewOperationEnabled: "on", annotationEditEnabled: false, }) ).toEqual({ operationProtocolEnabled: false, writebackMode: "operation_authoritative", slotEnabled: false, viewOperationEnabled: true, annotationEditEnabled: false, }); const invalidSettingsFallback = getCanvasAgentFeatureFlags( { CANVAS_AGENT_WRITEBACK_MODE: "operation_shadow" }, { [CANVAS_AGENT_WRITEBACK_MODE_KEY]: "not-a-mode", } ); expect(invalidSettingsFallback.writebackMode).toBe("operation_shadow"); }); test("builds idempotent canvas operation audit results", () => { const canvasData = { metadata: { canvasAgentOperationAudit: [ { operationId: " op-1 ", operation: "link_nodes", createdNodeIds: [" node-1 ", ""], createdNodeIdsByMediaItemId: { " media-1 ": " node-1 ", "": "ignored", "media-2": "", }, updatedNodeIds: [" node-a ", "node-b"], createdEdgeIds: [" edge-1 "], }, ], }, }; const audit = findCanvasAgentOperationAudit(canvasData, "op-1"); expect(audit).toBeTruthy(); expect( buildIdempotentCanvasAgentAuditResult({ canvasData, audit: audit || {}, }) ).toMatchObject({ canvasData, createdNodeIds: ["node-1"], createdNodeIdsByMediaItemId: { "media-1": "node-1" }, updatedNodeIds: ["node-a", "node-b"], createdEdgeIds: ["edge-1"], }); const unchanged = appendCanvasAgentOperationAuditIfMissing({ canvasData, plan: { version: 1, operationId: "op-1", operation: "link_nodes", projectId: "11111111-1111-4111-8111-111111111111", sourceNodeIds: ["node-a", "node-b"], }, result: { createdNodeIds: [], createdNodeIdsByMediaItemId: {}, updatedNodeIds: ["different"], createdEdgeIds: ["different"], }, }); expect(unchanged).toBe(canvasData); const appended = appendCanvasAgentOperationAuditIfMissing({ canvasData, plan: { version: 1, operationId: "op-2", operation: "add_annotation", projectId: "11111111-1111-4111-8111-111111111111", targetNodeIds: ["node-a"], }, result: { createdNodeIds: [], createdNodeIdsByMediaItemId: {}, updatedNodeIds: ["node-a"], createdEdgeIds: [], }, }); expect( findCanvasAgentOperationAudit(appended, "op-2") ).toMatchObject({ operationId: "op-2", operation: "add_annotation", updatedNodeIds: ["node-a"], }); }); test("applies append revision canvas mutations without overwriting source nodes", () => { const sourceNode = { id: "image-1", type: "image", position: { x: 100, y: 120 }, size: { width: 320, height: 240 }, data: { src: "https://cdn.example.com/source.png", status: "completed", title: "原图", }, connections: [], createdAt: "2026-06-22T00:00:00.000Z", updatedAt: "2026-06-22T00:00:00.000Z", }; const result = applyCanvasOperationPlanToCanvasData({ canvasData: { nodes: [sourceNode], edges: [], selectedNodeIds: ["image-1"], viewport: { x: 0, y: 0, zoom: 1 }, }, plan: { version: 1, operationId: "op-append-1", operation: "append_revision", projectId: plannerContext.projectId || "", sourceNodeIds: ["image-1"], anchorNodeId: "image-1", placement: "right", mediaItems: [ { mediaItemId: "media-append-1", type: "image", url: "https://cdn.example.com/revision.png", title: "白底版本", prompt: "换成白底", metadata: { messageId: "assistant-append-1", model: "test-image-model", }, }, ], }, }); expect(result.createdNodeIds).toHaveLength(1); const resultCanvasData = result.canvasData as { nodes?: any[]; edges?: any[] }; expect(result.createdNodeIdsByMediaItemId["media-append-1"]).toBe( result.createdNodeIds[0] ); const createdNode = resultCanvasData.nodes?.find( (node) => node.id === result.createdNodeIds[0] ); expect(createdNode).toMatchObject({ type: "image", data: { src: "https://cdn.example.com/revision.png", metadata: { generatedByAgent: true, canvasAgentOperationId: "op-append-1", canvasAgentMediaItemId: "media-append-1", revisionOf: "image-1", sourceNodeId: "image-1", sourceNodeIds: ["image-1"], prompt: "换成白底", model: "test-image-model", messageId: "assistant-append-1", sourceMessageId: "assistant-append-1", sourceImageKey: "https://cdn.example.com/revision.png", }, }, }); expect(createdNode?.position.x).toBeGreaterThan(sourceNode.position.x); expect(resultCanvasData.nodes?.find((node) => node.id === "image-1")).toMatchObject({ data: { src: "https://cdn.example.com/source.png" }, }); expect(resultCanvasData.edges).toEqual([ expect.objectContaining({ source: "image-1", target: result.createdNodeIds[0], type: "reference", }), ]); }); test("fills agent slots and keeps operation retries idempotent", () => { const slotNode = { id: "slot-1", type: "agent-slot", position: { x: 400, y: 80 }, size: { width: 300, height: 300 }, data: { slotType: "image", status: "empty", fillPolicy: "replace-empty", filledNodeIds: [], }, connections: [], createdAt: "2026-06-22T00:00:00.000Z", updatedAt: "2026-06-22T00:00:00.000Z", }; const plan = { version: 1 as const, operationId: "op-fill-slot-1", operation: "fill_slot" as const, projectId: plannerContext.projectId || "", targetSlotId: "slot-1", mediaItems: [ { mediaItemId: "media-slot-1", type: "image" as const, url: "https://cdn.example.com/slot-result.png", }, ], }; const first = applyCanvasOperationPlanToCanvasData({ canvasData: { nodes: [slotNode], edges: [], selectedNodeIds: ["slot-1"], viewport: { x: 0, y: 0, zoom: 1 }, }, plan, }); expect(first.createdNodeIds).toHaveLength(1); const firstCanvasData = first.canvasData as { nodes?: any[]; edges?: any[] }; expect(first.updatedNodeIds).toEqual(["slot-1"]); expect(first.createdNodeIdsByMediaItemId).toEqual({ "media-slot-1": first.createdNodeIds[0], }); expect(firstCanvasData.nodes?.find((node) => node.id === "slot-1")).toMatchObject({ data: { status: "completed", filledNodeIds: [first.createdNodeIds[0]], }, }); expect(firstCanvasData.edges).toEqual([ expect.objectContaining({ source: "slot-1", target: first.createdNodeIds[0], }), ]); const retry = applyCanvasOperationPlanToCanvasData({ canvasData: first.canvasData, plan, }); expect(retry.createdNodeIds).toEqual(first.createdNodeIds); expect(retry.createdNodeIdsByMediaItemId).toEqual( first.createdNodeIdsByMediaItemId ); expect((retry.canvasData as { nodes?: any[] }).nodes).toHaveLength( firstCanvasData.nodes?.length || 0 ); }); test("applies batch media mutations with stable media item mappings", () => { const anchorNode = { id: "image-batch-source", type: "image", position: { x: 0, y: 0 }, size: { width: 200, height: 200 }, data: { src: "https://cdn.example.com/source.png", status: "completed" }, connections: [], createdAt: "2026-06-22T00:00:00.000Z", updatedAt: "2026-06-22T00:00:00.000Z", }; const result = applyCanvasOperationPlanToCanvasData({ canvasData: { nodes: [anchorNode], edges: [], selectedNodeIds: ["image-batch-source"], viewport: { x: 0, y: 0, zoom: 1 }, }, plan: { version: 1, operationId: "op-batch-1", operation: "insert_beside", projectId: plannerContext.projectId || "", anchorNodeId: "image-batch-source", placementStrategy: "grid", mediaItems: [1, 2, 3].map((index) => ({ mediaItemId: `media-batch-${index}`, type: "image" as const, url: `https://cdn.example.com/result-${index}.png`, })), }, }); expect(result.createdNodeIds).toHaveLength(3); const resultCanvasData = result.canvasData as { nodes?: any[]; edges?: any[] }; expect(Object.keys(result.createdNodeIdsByMediaItemId).sort()).toEqual([ "media-batch-1", "media-batch-2", "media-batch-3", ]); expect(resultCanvasData.nodes).toHaveLength(4); expect(resultCanvasData.edges).toHaveLength(3); }); test("rejects stale confirmed high-risk media replacement mutations", () => { const targetNode = { id: "image-replace-1", type: "image", position: { x: 0, y: 0 }, size: { width: 200, height: 200 }, data: { src: "https://cdn.example.com/source.png", status: "completed" }, connections: [], createdAt: "2026-06-22T00:00:00.000Z", updatedAt: "2026-06-22T00:00:00.000Z", }; expect(() => applyCanvasOperationPlanToCanvasData({ canvasData: { nodes: [targetNode], edges: [], selectedNodeIds: ["image-replace-1"], viewport: { x: 0, y: 0, zoom: 1 }, }, baseCanvasWasStale: true, plan: { version: 1, operationId: "op-replace-1", operation: "replace_node_media", projectId: plannerContext.projectId || "", targetNodeIds: ["image-replace-1"], confirmed: true, mediaItems: [ { mediaItemId: "media-replace-1", type: "image", url: "https://cdn.example.com/replacement.png", }, ], }, }) ).toThrow(CanvasAgentMutationError); }); test("keeps failed canvas operations eligible for canvasMedia fallback", () => { const handled = new Set(); const failedHandledIds = getCanvasOperationHandledMediaItemIds({ status: "failed", operationId: "op-1", operation: "insert_beside", handledCanvasMediaItemIds: ["media-1"], mediaItemIds: ["media-1"], fallbackToCanvasMedia: true, }); failedHandledIds.forEach((id) => handled.add(id)); expect(failedHandledIds).toEqual([]); expect( shouldSkipCanvasMediaPayloadForHandledItems({ payload: { mediaItemIds: ["media-1"], imageUrls: ["https://cdn.example.com/a.png"], }, handledCanvasMediaItemIds: handled, }) ).toBe(false); const appliedHandledIds = getCanvasOperationHandledMediaItemIds({ status: "applied", operationId: "op-2", operation: "insert_beside", handledCanvasMediaItemIds: ["media-2"], }); appliedHandledIds.forEach((id) => handled.add(id)); expect(appliedHandledIds).toEqual(["media-2"]); expect( shouldSkipCanvasMediaPayloadForHandledItems({ payload: { mediaItemIds: ["media-2"], imageUrls: ["https://cdn.example.com/b.png"], }, handledCanvasMediaItemIds: handled, }) ).toBe(true); expect( shouldSkipCanvasMediaPayloadForHandledItems({ payload: { mediaItemIds: ["media-2", "media-3"], imageUrls: ["https://cdn.example.com/b.png", "https://cdn.example.com/c.png"], }, handledCanvasMediaItemIds: handled, }) ).toBe(false); expect( filterCanvasMediaPayloadForUnhandledItems({ payload: { mediaItemIds: ["media-2", "media-3"], imageUrls: ["https://cdn.example.com/b.png", "https://cdn.example.com/c.png"], }, handledCanvasMediaItemIds: handled, }) ).toMatchObject({ mediaItemIds: ["media-3"], imageUrls: ["https://cdn.example.com/c.png"], videoUrls: [], }); const shadowHandledIds = getCanvasOperationHandledMediaItemIds({ status: "shadowed", operationId: "op-shadow", operation: "insert_beside", handledCanvasMediaItemIds: ["media-shadow"], mediaItemIds: ["media-shadow"], fallbackToCanvasMedia: true, }); shadowHandledIds.forEach((id) => handled.add(id)); expect(shadowHandledIds).toEqual([]); expect( shouldSkipCanvasMediaPayloadForHandledItems({ payload: { mediaItemIds: ["media-shadow"], imageUrls: ["https://cdn.example.com/shadow.png"], }, handledCanvasMediaItemIds: handled, }) ).toBe(false); expect( shouldSkipCanvasMediaPayloadForHandledItems({ payload: { mediaItemIds: ["media-pending-replace"], imageUrls: ["https://cdn.example.com/pending-replace.png"], }, handledCanvasMediaItemIds: handled, }) ).toBe(false); expect( getCanvasMediaPayloadHandledItemIds({ handledCanvasMediaItemIds: [" media-4 ", "", "media-5"], }) ).toEqual(["media-4", "media-5"]); }); test("filters handled items out of partially applied canvasMedia batches", () => { const handled = new Set(["media-2"]); expect( filterCanvasMediaPayloadForUnhandledItems({ payload: { mediaItemIds: ["media-1", "media-2", "media-3"], imageUrls: [ "https://cdn.example.com/a.png", "https://cdn.example.com/b.png", "https://cdn.example.com/c.png", ], prompt: "keep prompt", }, handledCanvasMediaItemIds: handled, }) ).toMatchObject({ mediaItemIds: ["media-1", "media-3"], imageUrls: [ "https://cdn.example.com/a.png", "https://cdn.example.com/c.png", ], videoUrls: [], prompt: "keep prompt", }); expect( filterCanvasMediaPayloadForUnhandledItems({ payload: { mediaItemIds: ["media-2"], imageUrls: ["https://cdn.example.com/b.png"], }, handledCanvasMediaItemIds: handled, }) ).toMatchObject({ mediaItemIds: [], imageUrls: [], videoUrls: [], }); expect( filterCanvasMediaPayloadForUnhandledItems({ payload: { mediaItemIds: ["media-image", "media-video"], imageUrls: ["https://cdn.example.com/image.png"], videoUrls: ["https://cdn.example.com/video.mp4"], }, handledCanvasMediaItemIds: new Set(["media-image"]), }) ).toMatchObject({ mediaItemIds: ["media-video"], imageUrls: [], videoUrls: ["https://cdn.example.com/video.mp4"], }); expect( filterCanvasMediaPayloadForUnhandledItems({ payload: { mediaItemIds: ["media-1", "media-2"], imageUrls: ["https://cdn.example.com/a.png"], }, handledCanvasMediaItemIds: handled, }) ).toMatchObject({ mediaItemIds: ["media-1", "media-2"], imageUrls: ["https://cdn.example.com/a.png"], }); }); test("keeps canvasMedia ids aligned with normalized batch media urls", () => { expect( normalizeCanvasMediaStreamPayload({ imageUrls: [ "not-a-url", "https://cdn.example.com/a.png", "https://cdn.example.com/a.png", "https://cdn.example.com/b.png", ], videoUrls: ["https://cdn.example.com/c.mp4"], mediaItemIds: [ "media-invalid", "media-a", "media-a-duplicate", "media-b", "media-c", ], handledCanvasMediaItemIds: [" handled-1 ", ""], }) ).toEqual({ imageUrls: [ "https://cdn.example.com/a.png", "https://cdn.example.com/b.png", ], videoUrls: ["https://cdn.example.com/c.mp4"], mediaItemIds: ["media-a", "media-b", "media-c"], handledCanvasMediaItemIds: ["handled-1"], }); expect( normalizeCanvasMediaStreamPayload({ imageUrls: [ "https://cdn.example.com/a.png", "https://cdn.example.com/b.png", ], mediaItemIds: ["only-one-id"], }) ).toEqual({ imageUrls: [ "https://cdn.example.com/a.png", "https://cdn.example.com/b.png", ], videoUrls: [], mediaItemIds: [], handledCanvasMediaItemIds: [], }); }); test("writes applied, failed, and shadowed canvas operation stream chunks", () => { const chunks: Array<{ type: string; data: string }> = []; const writer = { write(chunk: { type: string; data: string }) { chunks.push(chunk); }, }; writeCanvasOperationChunk( writer, { projectId: "11111111-1111-4111-8111-111111111111", operationId: "op-applied", operation: "insert_beside", createdNodeIds: ["node-1"], createdNodeIdsByMediaItemId: { "media-1": "node-1" }, updatedNodeIds: [], createdEdgeIds: ["edge-1"], canvasRevision: "2026-06-22T00:00:00.000Z", anchorNodeId: "anchor-1", handledCanvasMediaItemIds: [" media-1 ", ""], mediaItemIds: ["media-1"], message: "已写回画布", }, "test-applied" ); writeCanvasOperationChunk( writer, { projectId: "11111111-1111-4111-8111-111111111111", operationId: "op-failed", operation: "insert_beside", status: "failed", createdNodeIds: [], updatedNodeIds: [], createdEdgeIds: [], handledCanvasMediaItemIds: ["should-not-be-used"], mediaItemIds: [" media-2 ", ""], errorCode: "STALE_CANVAS_REVISION", errorMessage: "画布已更新", fallbackToCanvasMedia: true, }, "test-failed" ); writeCanvasOperationChunk( writer, { projectId: "11111111-1111-4111-8111-111111111111", operationId: "op-shadowed", operation: "insert_beside", status: "shadowed", createdNodeIds: [], updatedNodeIds: [], createdEdgeIds: [], handledCanvasMediaItemIds: ["should-not-be-used"], mediaItemIds: [" media-3 ", ""], fallbackToCanvasMedia: true, }, "test-shadowed" ); expect(chunks).toHaveLength(3); expect(chunks[0].type).toBe("data-canvasOperation"); expect(JSON.parse(chunks[0].data)).toMatchObject({ status: "applied", operationId: "op-applied", createdNodeIds: ["node-1"], handledCanvasMediaItemIds: ["media-1"], mediaItemIds: ["media-1"], }); expect(JSON.parse(chunks[1].data)).toMatchObject({ status: "failed", operationId: "op-failed", handledCanvasMediaItemIds: [], mediaItemIds: ["media-2"], errorCode: "STALE_CANVAS_REVISION", errorMessage: "画布已更新", fallbackToCanvasMedia: true, }); expect(JSON.parse(chunks[2].data)).toMatchObject({ status: "shadowed", operationId: "op-shadowed", handledCanvasMediaItemIds: [], mediaItemIds: ["media-3"], fallbackToCanvasMedia: true, }); }); test("writes normalized canvasMedia stream chunks with shared media item ids", () => { const chunks: Array<{ type: string; data: string }> = []; const writer = { write(chunk: { type: string; data: string }) { chunks.push(chunk); }, }; writeCanvasMediaChunk( writer, { messageId: "message-1", imageUrls: [ " https://cdn.example.com/a.png ", "https://cdn.example.com/a.png", "not-a-url", ], videoUrls: ["https://cdn.example.com/a.mp4", ""], prompt: " prompt ", sourceImageUrls: [" https://cdn.example.com/source.png ", "bad-url"], mediaItemIds: [" media-1 ", "", "media-2"], handledCanvasMediaItemIds: [" handled-1 ", ""], }, "test-canvas-media" ); expect(chunks).toHaveLength(1); expect(chunks[0].type).toBe("data-canvasMedia"); expect(JSON.parse(chunks[0].data)).toMatchObject({ messageId: "message-1", imageUrls: ["https://cdn.example.com/a.png"], videoUrls: ["https://cdn.example.com/a.mp4"], prompt: "prompt", sourceImageUrls: ["https://cdn.example.com/source.png"], mediaItemIds: ["media-1", "media-2"], handledCanvasMediaItemIds: ["handled-1"], }); }); test("keeps agent transient viewport out of persisted canvas state", () => { const store = useCanvasStore.getState(); store.clearCanvas(); store.setViewport({ x: 10, y: 20, zoom: 2 }); store.setTransientViewport({ x: 100, y: 200, zoom: 3 }); expect(useCanvasStore.getState().viewport).toEqual({ x: 100, y: 200, zoom: 3 }); expect(useCanvasStore.getState().getCanvasState().viewport).toEqual({ x: 10, y: 20, zoom: 2, }); store.clearCanvas(); }); test("normalizes and interpolates canvas agent view animations", () => { expect(normalizeCanvasAgentViewAnimationDuration(undefined)).toBe(280); expect(normalizeCanvasAgentViewAnimationDuration(360)).toBe(360); expect(normalizeCanvasAgentViewAnimationDuration(-20)).toBe(0); expect(normalizeCanvasAgentViewAnimationDuration(1200)).toBe(900); expect(easeOutCanvasAgentViewProgress(-1)).toBe(0); expect(easeOutCanvasAgentViewProgress(1.5)).toBe(1); expect(easeOutCanvasAgentViewProgress(0.5)).toBeCloseTo(0.875); expect( interpolateCanvasAgentViewport( { x: 0, y: 20, zoom: 1 }, { x: 100, y: 220, zoom: 2 }, 0 ) ).toEqual({ x: 0, y: 20, zoom: 1 }); expect( interpolateCanvasAgentViewport( { x: 0, y: 20, zoom: 1 }, { x: 100, y: 220, zoom: 2 }, 1 ) ).toEqual({ x: 100, y: 220, zoom: 2 }); expect( interpolateCanvasAgentViewport( { x: 0, y: 20, zoom: 1 }, { x: 100, y: 220, zoom: 2 }, 0.5 ) ).toEqual({ x: 87.5, y: 195, zoom: 1.875 }); }); test("defers canvas agent view operations during active canvas interactions", () => { expect(shouldDeferCanvasAgentViewOperation({ pointerDown: true })).toBe(true); expect(shouldDeferCanvasAgentViewOperation({ activeEditable: true })).toBe(true); expect( shouldDeferCanvasAgentViewOperation({ lastPointerInteractionAt: 1000, now: 1000 + CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS - 1, }) ).toBe(true); expect( shouldDeferCanvasAgentViewOperation({ lastPointerInteractionAt: 1000, now: 1000 + CANVAS_AGENT_VIEW_INTERACTION_DEFER_MS, }) ).toBe(false); expect(shouldDeferCanvasAgentViewOperation({ now: 2000 })).toBe(false); }); test("marks agent operation refresh windows for autosave guards", () => { const store = useCanvasStore.getState(); store.clearAgentOperationRefresh(); expect(store.isAgentOperationRefreshActive()).toBe(false); store.markAgentOperationRefresh(1000); expect(useCanvasStore.getState().isAgentOperationRefreshActive()).toBe(true); useCanvasStore.getState().clearAgentOperationRefresh(); expect(useCanvasStore.getState().isAgentOperationRefreshActive()).toBe(false); }); test("queues target-based canvas view operations for post-refresh retry", () => { expect( shouldRetryCanvasViewOperationAfterRefresh({ version: 1, viewOperationId: "view-result", operation: "focus_latest_result", nodeIds: ["new-image-1"], }) ).toBe(true); expect( shouldRetryCanvasViewOperationAfterRefresh({ version: 1, viewOperationId: "view-fit-all", operation: "fit_all", }) ).toBe(true); expect( shouldRetryCanvasViewOperationAfterRefresh({ version: 1, viewOperationId: "view-zoom", operation: "zoom_in", }) ).toBe(false); expect( shouldRetryCanvasViewOperationAfterRefresh( { version: 1, viewOperationId: "view-other-project", operation: "focus_latest_result", projectId: "project-other", nodeIds: ["new-image-1"], }, "project-current" ) ).toBe(false); }); test("builds transient view operations for agent writeback results", () => { expect( buildCanvasAgentResultViewOperation({ projectId: plannerContext.projectId, operationId: "operation-1", mutationOperation: "insert_beside", createdNodeIds: ["new-image-1"], }) ).toMatchObject({ version: 1, viewOperationId: "operation-1:view", operation: "focus_latest_result", nodeIds: ["new-image-1"], anchorNodeId: "new-image-1", persist: false, reason: "focus_canvas_agent_writeback_result", }); expect( buildCanvasAgentResultViewOperation({ projectId: plannerContext.projectId, operationId: "operation-2", mutationOperation: "append_revision", createdNodeIds: ["new-image-1", "new-image-2", "new-image-1"], updatedNodeIds: ["slot-1"], }) ).toMatchObject({ operation: "focus_nodes", nodeIds: ["new-image-1", "new-image-2"], anchorNodeId: "new-image-1", }); expect( buildCanvasAgentResultViewOperation({ projectId: plannerContext.projectId, operationId: "operation-compare", mutationOperation: "append_revision", sourceNodeIds: ["image-1", " image-1 "], createdNodeIds: ["new-image-1"], anchorNodeId: "new-image-1", }) ).toMatchObject({ operation: "focus_nodes", nodeIds: ["image-1", "new-image-1"], anchorNodeId: "image-1", reason: "focus_canvas_agent_writeback_comparison", }); expect( buildCanvasAgentResultViewOperation({ projectId: plannerContext.projectId, operationId: "operation-3", mutationOperation: "replace_node_media", updatedNodeIds: ["image-1"], }) ).toMatchObject({ operation: "focus_node", nodeIds: ["image-1"], reason: "focus_canvas_agent_replaced_nodes", }); expect( buildCanvasAgentResultViewOperation({ projectId: plannerContext.projectId, operationId: "operation-4", }) ).toBeNull(); }); test("resolves comparison view targets from selection and revision metadata", () => { const comparisonContext: CanvasAgentContext = { ...plannerContext, selectedNodeIds: ["agent-result-1"], selectedNodes: [ { nodeId: "agent-result-1", type: "image", title: "白底版本", x: 460, y: 20, width: 300, height: 240, isSelected: true, metadata: { generatedByAgent: true, revisionOf: "image-1", sourceNodeIds: ["image-1"], }, }, ], candidateNodes: [ { nodeId: "image-1", type: "image", title: "原图", x: 10, y: 20, width: 300, height: 240, }, ], }; expect(hasCanvasAgentComparisonViewIntent("对比原图和新图")).toBe(true); expect( resolveCanvasAgentComparisonNodeIds({ context: comparisonContext, }) ).toEqual(["image-1", "agent-result-1"]); expect( buildCanvasAgentComparisonViewPlan({ context: comparisonContext, projectId: plannerContext.projectId || "", operationId: "operation-compare", }) ).toMatchObject({ operation: "focus_nodes", nodeIds: ["image-1", "agent-result-1"], anchorNodeId: "image-1", persist: false, reason: "focus_canvas_comparison_targets", }); expect( resolveCanvasAgentComparisonNodeIds({ context: { ...comparisonContext, selectedNodeIds: ["image-1", "agent-result-1"], selectedNodes: [ { nodeId: "image-1", type: "image", x: 10, y: 20, isSelected: true, }, { nodeId: "agent-result-1", type: "image", x: 460, y: 20, isSelected: true, metadata: { generatedByAgent: true, revisionOf: "image-1" }, }, ], candidateNodes: [], }, }) ).toEqual(["image-1", "agent-result-1"]); }); test("builds latest-result view plans for follow-up focus requests", () => { expect(hasCanvasAgentLatestResultViewIntent("聚焦刚生成结果")).toBe(true); expect(hasCanvasAgentLatestResultViewIntent("放大看看最新结果")).toBe(true); expect(hasCanvasAgentLatestResultViewIntent("生成一张新图")).toBe(false); expect( buildCanvasAgentLatestResultViewPlan({ userText: "帮我定位到刚生成的图", projectId: plannerContext.projectId || "", operationId: "operation-latest", }) ).toMatchObject({ viewOperationId: "operation-latest:view", operation: "focus_latest_result", projectId: plannerContext.projectId, persist: false, reason: "focus_latest_canvas_agent_result", }); }); test("resolves latest-result and pan canvas view operations", () => { expect( resolveCanvasViewOperationTargetNodeIds({ plan: { operation: "focus_latest_result", }, selectedNodeIds: ["selected-1"], nodes: [ { id: "old-agent-result", data: { metadata: { generatedByAgent: true, }, }, createdAt: "2026-06-22T00:00:00.000Z", }, { id: "manual-node", data: { metadata: {}, }, createdAt: "2026-06-22T00:02:00.000Z", }, { id: "new-agent-result", data: { metadata: { canvasAgentOperationId: "op-1", }, }, updatedAt: "2026-06-22T00:03:00.000Z", }, ], }) ).toEqual(["new-agent-result", "old-agent-result"]); expect( resolveCanvasViewOperationTargetNodeIds({ plan: { operation: "focus_selection", }, selectedNodeIds: ["selected-1"], nodes: [], }) ).toEqual(["selected-1"]); expect( computeCanvasAgentPanViewport({ currentViewport: { x: 10, y: 20, zoom: 1 }, center: { x: 100, y: 50 }, stage: { width: 1000, height: 800 }, zoom: 2, }) ).toEqual({ x: 320, y: 268, zoom: 2, }); }); test("builds relative pan view plans from canvas viewport context", () => { expect(hasCanvasAgentPanViewIntent("右边那张图")).toBe(false); expect(hasCanvasAgentPanViewIntent("向右移动视角一点")).toBe(true); const plan = buildCanvasAgentPanViewPlan({ userText: "向右移动视角一点", context: { ...plannerContext, viewport: { x: -200, y: -100, zoom: 2, width: 1000, height: 800 }, }, projectId: plannerContext.projectId || "", operationId: "view-pan", }); expect(plan).toMatchObject({ version: 1, viewOperationId: "view-pan:view", operation: "pan_to", projectId: plannerContext.projectId, center: { x: 490, y: 234, }, zoom: 2, persist: false, reason: "pan_canvas_view", }); const diagonalPlan = buildCanvasAgentPanViewPlan({ userText: "把画布往左上拖一点", context: { ...plannerContext, viewport: { x: -200, y: -100, zoom: 2, width: 1000, height: 800 }, }, projectId: plannerContext.projectId || "", operationId: "view-pan-diagonal", }); expect(diagonalPlan?.center?.x).toBeCloseTo(230); expect(diagonalPlan?.center?.y).toBeCloseTo(130); }); test("resolves canvas view operation plans into transient viewport targets", () => { const zoomIn = resolveCanvasViewOperation({ plan: { version: 1, viewOperationId: "view-zoom-in", operation: "zoom_in", }, nodes: [], currentViewport: { x: 10, y: 20, zoom: 2 }, stage: { width: 1000, height: 800 }, }); expect(zoomIn?.targetNodeIds).toEqual([]); expect(zoomIn?.viewport.zoom).toBeCloseTo(2.44); expect(zoomIn?.viewport.x).toBeCloseTo(-102.2); expect(zoomIn?.viewport.y).toBeCloseTo(-56.56); const focusSelection = resolveCanvasViewOperation({ plan: { version: 1, viewOperationId: "view-focus-selection", operation: "focus_selection", }, nodes: [ { id: "image-1", type: "image", position: { x: 100, y: 120 }, size: { width: 320, height: 240 }, }, ], selectedNodeIds: ["image-1"], currentViewport: { x: 0, y: 0, zoom: 1 }, stage: { width: 1000, height: 800 }, }); expect(focusSelection?.targetNodeIds).toEqual(["image-1"]); expect(focusSelection?.viewport.x).toBeCloseTo(218.4); expect(focusSelection?.viewport.y).toBeCloseTo(89.6); expect(focusSelection?.viewport.zoom).toBeCloseTo(1.16); const fitAll = resolveCanvasViewOperation({ plan: { version: 1, viewOperationId: "view-fit-all", operation: "fit_all", padding: 100, }, nodes: [ { id: "image-1", type: "image", position: { x: 0, y: 0 }, size: { width: 200, height: 100 }, }, { id: "text-1", type: "text", position: { x: 500, y: 200 }, size: { width: 100, height: 100 }, }, { id: "workflow-1", type: "workflow", position: { x: 2000, y: 2000 }, size: { width: 400, height: 400 }, }, ], currentViewport: { x: 0, y: 0, zoom: 1 }, stage: { width: 1000, height: 800 }, }); expect(fitAll?.targetNodeIds).toEqual([]); expect(fitAll?.viewport.zoom).toBeCloseTo(0.1784615385); expect(fitAll?.viewport.x).toBeCloseTo(305.8461538); expect(fitAll?.viewport.y).toBeCloseTo(153.8461538); const panTo = resolveCanvasViewOperation({ plan: { version: 1, viewOperationId: "view-pan-to", operation: "pan_to", center: { x: 490, y: 234 }, zoom: 2, }, nodes: [], currentViewport: { x: -200, y: -100, zoom: 2 }, stage: { width: 1000, height: 800 }, }); expect(panTo?.targetNodeIds).toEqual([]); expect(panTo?.viewport).toEqual({ x: -460, y: -100, zoom: 2, }); }); test("resolves grid media placements to the right of an anchor node", () => { const placements = resolveCanvasAgentMediaPlacements({ plan: { version: 1, operationId: "op-grid", operation: "insert_beside", projectId: "project-1", anchorNodeId: "anchor-1", placement: "right", placementStrategy: "grid", }, mediaItems: [ { mediaItemId: "media-1", type: "image", url: "https://cdn.example.com/1.png", width: 100, height: 80 }, { mediaItemId: "media-2", type: "image", url: "https://cdn.example.com/2.png", width: 100, height: 80 }, { mediaItemId: "media-3", type: "image", url: "https://cdn.example.com/3.png", width: 100, height: 80 }, ], nodes: [ { id: "anchor-1", position: { x: 10, y: 20 }, size: { width: 300, height: 200 }, }, ], }); expect(placements.map((placement) => placement.position)).toEqual([ { x: 350, y: 20 }, { x: 474, y: 20 }, { x: 350, y: 124 }, ]); expect(placements.map((placement) => placement.edgeSourceId)).toEqual([ "anchor-1", "anchor-1", "anchor-1", ]); }); test("resolves batch media beside each item source node", () => { const placements = resolveCanvasAgentMediaPlacements({ plan: { version: 1, operationId: "op-source-batch", operation: "insert_beside", projectId: "project-1", placement: "right", placementStrategy: "beside_each_source", }, mediaItems: [ { mediaItemId: "media-a-1", type: "image", url: "https://cdn.example.com/a1.png", width: 20, height: 20, sourceNodeIds: ["source-a"], }, { mediaItemId: "media-b-1", type: "image", url: "https://cdn.example.com/b1.png", width: 20, height: 20, sourceNodeIds: ["source-b"], }, { mediaItemId: "media-a-2", type: "image", url: "https://cdn.example.com/a2.png", width: 20, height: 20, sourceNodeIds: ["source-a"], }, ], nodes: [ { id: "source-a", position: { x: 0, y: 0 }, size: { width: 50, height: 50 }, }, { id: "source-b", position: { x: 1000, y: 10 }, size: { width: 50, height: 50 }, }, ], }); expect(placements.map((placement) => placement.position)).toEqual([ { x: 90, y: 0 }, { x: 1090, y: 10 }, { x: 134, y: 0 }, ]); expect(placements.map((placement) => placement.edgeSourceId)).toEqual([ "source-a", "source-b", "source-a", ]); expect(placements[0]?.sourceNodeIds).toEqual(["source-a"]); }); test("resolves overlay and viewport-centered media placements", () => { const [overlay] = resolveCanvasAgentMediaPlacements({ plan: { version: 1, operationId: "op-overlay", operation: "append_revision", projectId: "project-1", anchorNodeId: "source-1", placementStrategy: "overlay", }, mediaItems: [ { mediaItemId: "media-overlay", type: "image", url: "https://cdn.example.com/overlay.png", }, ], nodes: [ { id: "source-1", position: { x: 120, y: 80 }, size: { width: 160, height: 90 }, }, ], }); expect(overlay?.position).toEqual({ x: 120, y: 80 }); expect(overlay?.size).toEqual({ width: 160, height: 90 }); const centeredPlacements = resolveCanvasAgentMediaPlacements({ plan: { version: 1, operationId: "op-center", operation: "insert_beside", projectId: "project-1", anchorNodeId: "source-1", placement: "viewport-center", placementStrategy: "grid", }, mediaItems: [ { mediaItemId: "media-1", type: "image", url: "https://cdn.example.com/1.png", width: 100, height: 80 }, { mediaItemId: "media-2", type: "image", url: "https://cdn.example.com/2.png", width: 100, height: 80 }, ], nodes: [ { id: "source-1", position: { x: 1000, y: 1000 }, size: { width: 200, height: 200 }, }, ], viewport: { x: -200, y: -100, zoom: 1 }, }); expect(centeredPlacements.map((placement) => placement.position)).toEqual([ { x: 88, y: 60 }, { x: 212, y: 60 }, ]); const viewportAwarePlacements = resolveCanvasAgentMediaPlacements({ plan: { version: 1, operationId: "op-center-with-stage", operation: "insert_beside", projectId: "project-1", placement: "viewport-center", placementStrategy: "grid", metadata: { canvasAgentViewport: { x: -200, y: -100, zoom: 1, width: 1000, height: 800, }, }, }, mediaItems: [ { mediaItemId: "media-1", type: "image", url: "https://cdn.example.com/1.png", width: 100, height: 80 }, { mediaItemId: "media-2", type: "image", url: "https://cdn.example.com/2.png", width: 100, height: 80 }, ], nodes: [], viewport: { x: 0, y: 0, zoom: 1 }, }); expect(viewportAwarePlacements.map((placement) => placement.position)).toEqual([ { x: 588, y: 460 }, { x: 712, y: 460 }, ]); }); test("normalizes slot-only canvas agent context without image nodes", () => { const context = normalizeCanvasAgentContext({ source: "canvas", projectId: "11111111-1111-4111-8111-111111111111", selectedNodeIds: ["slot-1"], canvasImageNodes: [], canvasAgentContext: { version: 1, source: "canvas", projectId: "11111111-1111-4111-8111-111111111111", selectedNodeIds: ["slot-1"], selectedNodes: [ { nodeId: "slot-1", type: "agent-slot", x: 120, y: 80, width: 320, height: 240, isSelected: true, }, ], candidateNodes: [], slots: [ { nodeId: "slot-1", slotType: "image", x: 120, y: 80, width: 320, height: 240, aspectRatio: "4:3", isSelected: true, fillPolicy: "replace-empty", }, ], annotations: [], viewport: { x: 0, y: 0, zoom: 1 }, focusPolicy: "slot", }, }); expect(context?.selectedNodeIds).toEqual(["slot-1"]); expect(context?.slots).toHaveLength(1); expect(context?.candidateNodes).toEqual([]); expect(context?.focusPolicy).toBe("slot"); }); test("bypasses missing-target clarification for selected agent slot generation", () => { const slotOnlyContext = { source: "canvas" as const, projectId: "11111111-1111-4111-8111-111111111111", selectedNodeIds: ["slot-1"], canvasImageNodes: [], canvasAgentContext: { version: 1 as const, source: "canvas" as const, projectId: "11111111-1111-4111-8111-111111111111", selectedNodeIds: ["slot-1"], selectedNodes: [ { nodeId: "slot-1", type: "agent-slot" as const, x: 120, y: 80, width: 320, height: 240, isSelected: true, }, ], candidateNodes: [], slots: [ { nodeId: "slot-1", slotType: "image" as const, x: 120, y: 80, width: 320, height: 240, aspectRatio: "4:3", isSelected: true, fillPolicy: "replace-empty" as const, }, ], annotations: [], viewport: { x: 0, y: 0, zoom: 1 }, focusPolicy: "slot" as const, }, }; expect( shouldBypassCanvasAgentMissingTargetClarification({ text: "在这里生成一张电商主图", contextPayload: slotOnlyContext, executionMode: "generate_image", }) ).toMatchObject({ shouldBypass: true, reason: "selected_canvas_agent_slot_generation_intent", }); expect( shouldBypassCanvasAgentMissingTargetClarification({ text: "把这张图背景换白", contextPayload: slotOnlyContext, executionMode: "edit_image", }).shouldBypass ).toBe(false); expect( shouldBypassCanvasAgentMissingTargetClarification({ text: "在这里生成一张电商主图", contextPayload: { ...slotOnlyContext, selectedNodeIds: [], canvasAgentContext: { ...slotOnlyContext.canvasAgentContext, selectedNodeIds: [], slots: [{ ...slotOnlyContext.canvasAgentContext.slots[0], isSelected: false }], }, }, executionMode: "generate_image", }).shouldBypass ).toBe(false); }); test("keeps view-only canvas agent context without nodes or media", () => { const context = normalizeCanvasAgentContext({ source: "canvas", projectId: "11111111-1111-4111-8111-111111111111", selectedNodeIds: [], canvasImageNodes: [], canvasAgentContext: { version: 1, source: "canvas", projectId: "11111111-1111-4111-8111-111111111111", selectedNodeIds: [], selectedNodes: [], candidateNodes: [], slots: [], annotations: [], viewport: { x: -240, y: -120, zoom: 0.8, width: 1440, height: 900 }, focusPolicy: "auto", }, }); expect(context).toMatchObject({ projectId: "11111111-1111-4111-8111-111111111111", selectedNodes: [], candidateNodes: [], slots: [], viewport: { x: -240, y: -120, zoom: 0.8, width: 1440, height: 900 }, focusPolicy: "auto", }); }); test("clips oversized canvas agent context lists to protocol limits", () => { const candidateNodes = Array.from({ length: 42 }, (_, index) => ({ nodeId: `image-${index + 1}`, type: "image", url: `https://cdn.example.com/${index + 1}.png`, x: index * 10, y: index * 5, isSelected: index < 25, })); const slots = Array.from({ length: 30 }, (_, index) => ({ nodeId: `slot-${index + 1}`, slotType: "image", x: index, y: index, width: 320, height: 320, aspectRatio: "1:1", fillPolicy: "replace-empty", })); const annotations = Array.from({ length: 30 }, (_, index) => ({ id: `annotation-${index + 1}`, sourceNodeId: "image-1", type: "text-note", text: `批注 ${index + 1}`, })); const context = normalizeCanvasAgentContext({ source: "canvas", projectId: "11111111-1111-4111-8111-111111111111", selectedNodeIds: candidateNodes.map((node) => node.nodeId), canvasAgentContext: { version: 1, source: "canvas", projectId: "11111111-1111-4111-8111-111111111111", selectedNodeIds: candidateNodes.map((node) => node.nodeId), selectedNodes: candidateNodes, candidateNodes, slots, annotations, viewport: { x: 0, y: 0, zoom: 1 }, focusPolicy: "selected", }, }); expect(context?.selectedNodeIds).toHaveLength(20); expect(context?.selectedNodes).toHaveLength(20); expect(context?.candidateNodes).toHaveLength(30); expect(context?.slots).toHaveLength(20); expect(context?.annotations).toHaveLength(20); }); test("normalizes canvas agent annotations from node metadata", () => { const context = normalizeCanvasAgentContext({ source: "canvas", projectId: "11111111-1111-4111-8111-111111111111", selectedNodeIds: ["image-1"], canvasAgentContext: { version: 1, source: "canvas", projectId: "11111111-1111-4111-8111-111111111111", selectedNodeIds: ["image-1"], selectedNodes: [ { nodeId: "image-1", type: "image", x: 0, y: 0, isSelected: true, }, ], candidateNodes: [], slots: [], annotations: [ { id: "annotation-1", sourceNodeId: "image-1", type: "text-note", text: "保留产品轮廓", bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 }, }, ], viewport: { x: 0, y: 0, zoom: 1 }, focusPolicy: "selected", }, }); expect(context?.annotations).toEqual([ { id: "annotation-1", sourceNodeId: "image-1", type: "text-note", text: "保留产品轮廓", bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 }, targetPoint: undefined, color: undefined, createdAt: undefined, }, ]); }); test("keeps pending canvas agent UI requests until the matching panel event consumes them", () => { const targetWindow = {} as CanvasAgentPendingRequestWindow; const slotRequest = { nodeId: "slot-1", label: "主图槽", }; const annotationRequest = { nodeId: "image-annotated", title: "白底主图", annotationCount: 2, }; storeCanvasAgentSlotFillRequest(targetWindow, slotRequest); storeCanvasAgentAnnotationEditRequest(targetWindow, annotationRequest); expect(getPendingCanvasAgentSlotFillRequest(targetWindow)).toEqual(slotRequest); expect(getPendingCanvasAgentAnnotationEditRequest(targetWindow)).toEqual( annotationRequest ); expect( consumeCanvasAgentSlotFillRequest(targetWindow, { nodeId: "other-slot" }) ).toEqual(slotRequest); expect( consumeCanvasAgentAnnotationEditRequest(targetWindow, { nodeId: "other-image", }) ).toEqual(annotationRequest); expect(getPendingCanvasAgentSlotFillRequest(targetWindow)).toEqual(slotRequest); expect(getPendingCanvasAgentAnnotationEditRequest(targetWindow)).toEqual( annotationRequest ); expect( consumeCanvasAgentSlotFillRequest(targetWindow, { nodeId: "slot-1" }) ).toEqual(slotRequest); expect( consumeCanvasAgentAnnotationEditRequest(targetWindow, { nodeId: "image-annotated", }) ).toEqual(annotationRequest); expect(getPendingCanvasAgentSlotFillRequest(targetWindow)).toBeNull(); expect(getPendingCanvasAgentAnnotationEditRequest(targetWindow)).toBeNull(); }); test("consumes pending canvas agent UI requests when no event detail is provided", () => { const targetWindow = {} as CanvasAgentPendingRequestWindow; const slotRequest = { nodeId: "slot-1", label: "主图槽", }; const annotationRequest = { nodeId: "image-annotated", title: "白底主图", annotationCount: 2, }; storeCanvasAgentSlotFillRequest(targetWindow, slotRequest); storeCanvasAgentAnnotationEditRequest(targetWindow, annotationRequest); expect(consumeCanvasAgentSlotFillRequest(targetWindow)).toEqual(slotRequest); expect(consumeCanvasAgentAnnotationEditRequest(targetWindow)).toEqual( annotationRequest ); expect(getPendingCanvasAgentSlotFillRequest(targetWindow)).toBeNull(); expect(getPendingCanvasAgentAnnotationEditRequest(targetWindow)).toBeNull(); }); test("keeps pending annotation edit targets eligible for the next canvas context", () => { const nodes = [ { id: "image-annotated", type: "image", position: { x: 10, y: 20 }, size: { width: 300, height: 240 }, data: { src: "https://cdn.example.com/annotated.png", metadata: { annotations: [ { id: "annotation-1", type: "text-note", text: "背景换浅灰", }, ], }, }, }, { id: "text-1", type: "text", position: { x: 500, y: 20 }, size: { width: 220, height: 80 }, data: { content: "说明" }, }, ] as any[]; const pendingNode = resolvePendingCanvasAgentTargetNode({ nodes, pendingTarget: { nodeId: "image-annotated", requestedAt: 1000, }, now: 1500, isEligibleNode: (node) => node.type === "image", }); expect(pendingNode?.id).toBe("image-annotated"); expect( resolvePendingCanvasAgentTargetNode({ nodes, pendingTarget: { nodeId: "image-annotated", requestedAt: 1000, }, now: 1000 + 10 * 60 * 1000 + 1, isEligibleNode: (node) => node.type === "image", }) ).toBeNull(); expect( resolvePendingCanvasAgentTargetNode({ nodes, pendingTarget: { nodeId: "text-1", requestedAt: 1000, }, now: 1500, isEligibleNode: (node) => node.type === "image", }) ).toBeNull(); }); test("merges pending annotation edit targets into selected node ids without duplicates", () => { expect( mergePendingCanvasAgentTargetNodeIds({ selectedNodeIds: ["image-1", "image-2"], pendingTargetNodeId: "image-1", }) ).toEqual(["image-1", "image-2"]); expect( mergePendingCanvasAgentTargetNodeIds({ selectedNodeIds: ["image-1"], pendingTargetNodeId: "image-annotated", }) ).toEqual(["image-1", "image-annotated"]); expect( mergePendingCanvasAgentTargetNodeIds({ selectedNodeIds: Array.from({ length: 20 }, (_, index) => `node-${index + 1}`), pendingTargetNodeId: "node-21", maxItems: 20, }) ).toHaveLength(20); }); test("builds annotation-aware edit prompts only for annotation requests", () => { const context: CanvasAgentContext = { ...plannerContext, selectedNodeIds: ["image-1"], selectedNodes: [ { nodeId: "image-1", type: "image", title: "主图", x: 10, y: 20, width: 300, height: 240, isSelected: true, }, ], annotations: [ { id: "annotation-1", sourceNodeId: "image-1", type: "box", text: "把这里的水印去掉", bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 }, }, { id: "annotation-2", sourceNodeId: "image-2", type: "text-note", text: "不应进入 image-1 的提示", }, ], }; expect(hasCanvasAgentAnnotationEditIntent("按批注修改这张图")).toBe(true); expect(hasCanvasAgentAnnotationEditIntent("批注里的水印去掉")).toBe(true); expect(hasCanvasAgentAnnotationEditIntent("把背景换成白色")).toBe(false); expect(hasCanvasAgentAnnotationEditIntent("给选中图片加批注:这里保留")).toBe( false ); const prompt = buildCanvasAgentAnnotationEditPrompt({ context, targetNodeIds: ["image-1"], userText: "按批注修改这张图", }); expect(prompt).toContain("【画布批注编辑要求】"); expect(prompt).toContain("把这里的水印去掉"); expect(prompt).toContain("框选区域:x=0.1, y=0.2, width=0.3, height=0.4"); expect(prompt).not.toContain("不应进入 image-1 的提示"); expect( buildCanvasAgentAnnotationEditPrompt({ context, targetNodeIds: ["image-1"], userText: "把背景换成白色", }) ).toBe(""); expect( buildCanvasAgentAnnotationEditPrompt({ context, targetNodeIds: ["image-1"], userText: "给选中图片加批注:这里保留", }) ).toBe(""); }); test("labels image note action as an agent annotation entry", () => { expect( getCanvasToolbarItem(defaultCanvasToolbarConfig, "imageNote") ).toMatchObject({ label: "添加批注", enabled: true, }); }); test("syncs image notes into structured canvas annotations", () => { const first = syncCanvasNodeNoteAnnotationMetadata({ metadata: { keep: true, annotations: [ { id: "agent-annotation-1", sourceNodeId: "image-1", type: "box", text: "保留这个框选区域", createdByAgent: true, }, ], }, nodeId: "image-1", note: " 让 Agent 按这个备注改主图 ", now: "2026-06-22T00:00:00.000Z", }); expect(first.noteAnnotationId).toBe("image-node-note:image-1"); expect(first.hasStructuredAnnotations).toBe(true); expect(first.metadata.keep).toBe(true); expect(first.annotations).toHaveLength(2); expect(first.annotations.at(-1)).toMatchObject({ id: "image-node-note:image-1", source: CANVAS_NODE_NOTE_ANNOTATION_SOURCE, sourceNodeId: "image-1", type: "text-note", text: "让 Agent 按这个备注改主图", createdAt: "2026-06-22T00:00:00.000Z", updatedAt: "2026-06-22T00:00:00.000Z", createdByUser: true, }); const updated = syncCanvasNodeNoteAnnotationMetadata({ metadata: first.metadata, nodeId: "image-1", note: "换成蓝色背景", now: "2026-06-22T00:01:00.000Z", }); expect(updated.annotations).toHaveLength(2); expect(updated.annotations.at(-1)).toMatchObject({ id: "image-node-note:image-1", text: "换成蓝色背景", createdAt: "2026-06-22T00:00:00.000Z", updatedAt: "2026-06-22T00:01:00.000Z", }); const cleared = syncCanvasNodeNoteAnnotationMetadata({ metadata: updated.metadata, nodeId: "image-1", note: "", now: "2026-06-22T00:02:00.000Z", }); expect(cleared.hasStructuredAnnotations).toBe(true); expect(cleared.annotations).toEqual([ { id: "agent-annotation-1", sourceNodeId: "image-1", type: "box", text: "保留这个框选区域", createdByAgent: true, }, ]); expect(cleared.metadata.annotationsUpdatedAt).toBe("2026-06-22T00:02:00.000Z"); }); test("maps visual image annotations into structured canvas metadata", () => { const result = appendCanvasVisualAnnotationsMetadata({ metadata: { annotations: [ { id: "note-1", sourceNodeId: "image-1", type: "text-note", text: "已有备注", }, ], }, nodeId: "image-1", imageWidth: 200, imageHeight: 100, now: "2026-06-22T00:03:00.000Z", actions: [ { type: "circle", color: "#ff0000", startX: 20, startY: 30, radius: 10, }, { type: "arrow", color: "#00ff00", startX: 0, startY: 0, endX: 100, endY: 50, }, { type: "text", color: "#0000ff", startX: 10, startY: 20, text: "去掉这里", }, ], }); expect(result.appendedAnnotations).toHaveLength(3); expect(result.annotations).toHaveLength(4); expect(result.appendedAnnotations[0]).toMatchObject({ source: CANVAS_VISUAL_ANNOTATION_SOURCE, sourceNodeId: "image-1", type: "box", text: "用户圈选了需要关注或修改的区域", bounds: { x: 0.05, y: 0.2, width: 0.1, height: 0.2 }, visualActionType: "circle", createdByUser: true, }); expect(result.appendedAnnotations[1]).toMatchObject({ type: "arrow-note", targetPoint: { x: 0.5, y: 0.5 }, visualActionType: "arrow", }); expect(result.appendedAnnotations[2]).toMatchObject({ type: "text-note", text: "去掉这里", targetPoint: { x: 0.05, y: 0.2 }, visualActionType: "text", }); expect(result.metadata.annotationsUpdatedAt).toBe("2026-06-22T00:03:00.000Z"); }); test("syncs local edit regions into crop-box canvas annotations", () => { const first = syncCanvasLocalEditRegionAnnotationsMetadata({ metadata: { annotations: [ { id: "note-1", sourceNodeId: "image-1", type: "text-note", text: "保留的备注", }, ], }, nodeId: "image-1", now: "2026-06-22T00:04:00.000Z", regions: [ { id: "region-1", index: 1, color: "#ef4444", prompt: "把框内 logo 去掉", rect: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 }, }, { id: "region-2", index: 2, color: "#06b6d4", prompt: "比例调整", rect: { x: 0.2, y: 0.3, width: 0.2, height: 0.2 }, targetRect: { x: 0.25, y: 0.35, width: 0.3, height: 0.25 }, }, ], }); expect(first.regionAnnotations).toHaveLength(2); expect(first.annotations).toHaveLength(3); expect(first.regionAnnotations[0]).toMatchObject({ id: "image-local-edit-region:image-1:region-1", source: CANVAS_LOCAL_EDIT_REGION_ANNOTATION_SOURCE, sourceNodeId: "image-1", type: "crop-box", text: "把框内 logo 去掉", bounds: { x: 0.1, y: 0.2, width: 0.3, height: 0.4 }, color: "#ef4444", createdByUser: true, localEditRegionId: "region-1", }); expect(first.regionAnnotations[1].text).toContain("目标区域:x=0.25"); const cleared = syncCanvasLocalEditRegionAnnotationsMetadata({ metadata: first.metadata, nodeId: "image-1", now: "2026-06-22T00:05:00.000Z", regions: [], }); expect(cleared.regionAnnotations).toHaveLength(0); expect(cleared.annotations).toEqual([ { id: "note-1", sourceNodeId: "image-1", type: "text-note", text: "保留的备注", }, ]); }); test("normalizes selected text-only canvas agent context", () => { const context = normalizeCanvasAgentContext({ source: "canvas", projectId: "11111111-1111-4111-8111-111111111111", selectedNodeIds: ["text-1"], canvasImageNodes: [], canvasAgentContext: { version: 1, source: "canvas", projectId: "11111111-1111-4111-8111-111111111111", selectedNodeIds: ["text-1"], selectedNodes: [ { nodeId: "text-1", type: "text", title: "旧标题", x: 40, y: 80, width: 240, height: 80, isSelected: true, }, ], candidateNodes: [], slots: [], annotations: [], viewport: { x: 0, y: 0, zoom: 1 }, focusPolicy: "selected", }, }); expect(context?.selectedNodeIds).toEqual(["text-1"]); expect(context?.selectedNodes[0]).toMatchObject({ nodeId: "text-1", type: "text", title: "旧标题", }); expect(context?.focusPolicy).toBe("selected"); }); test("builds canvas agent context from legacy canvas image nodes", () => { const context = normalizeCanvasAgentContext({ source: "canvas", selectedNodeIds: ["image-1"], canvasImageNodes: [ { nodeId: "image-1", url: "https://cdn.example.com/image.png", title: "主图", x: 10, y: 20, width: 300, height: 300, isSelected: true, }, ], }); expect(context?.selectedNodes).toHaveLength(1); expect(context?.candidateNodes[0]).toMatchObject({ nodeId: "image-1", type: "image", url: "https://cdn.example.com/image.png", isSelected: true, }); }); test("normalizes media urls without query strings or trailing slashes", () => { expect(normalizeMediaUrl(" HTTPS://EXAMPLE.COM/assets/image.png?sig=secret#preview ")).toBe( "https://example.com/assets/image.png" ); expect(normalizeMediaUrl("not-a-url?cache=1#hash/")).toBe("not-a-url"); }); test("collects message image urls from parts, attachments, and legacy content", () => { const message = { id: "message-1", role: "user", parts: [ { type: "file", url: "https://cdn.example.com/upload-a.png?x=1", mediaType: "image/png", }, { type: "text", text: "参考 ![a](https://cdn.example.com/markdown-a.webp) 和 https://cdn.example.com/plain-a.jpg?token=1", }, ], attachments: [ { name: "attachment", url: "https://cdn.example.com/attachment-a.jpeg?download=1", contentType: "image/jpeg", }, ], content: "legacy https://cdn.example.com/legacy-a.png", } as unknown as ChatMessage; expect(collectMessageImageUrls(message)).toEqual([ "https://cdn.example.com/upload-a.png?x=1", "https://cdn.example.com/markdown-a.webp", "https://cdn.example.com/plain-a.jpg?token=1", "https://cdn.example.com/attachment-a.jpeg?download=1", "https://cdn.example.com/legacy-a.png", ]); }); test("filters assistant result images that match nearest user references", () => { const referenceKeys = new Set(["https://cdn.example.com/reference.png"]); const urls = extractAssistantImageUrls( [ "![ref](https://cdn.example.com/reference.png?token=secret)", "![result](https://cdn.example.com/result.png?token=secret)", ].join("\n"), referenceKeys ); expect(urls).toEqual(["https://cdn.example.com/result.png?token=secret"]); }); test("does not append Claw images that already render inside markdown tables", () => { const markdown = [ "| 商品 | 主图 |", "| --- | --- |", "| A | ![主图](https://cdn.example.com/table-image.png?token=secret) |", "| B | [查看](https://cdn.example.com/table-link.webp?token=secret) |", "", "补充图片:", "![补充](https://cdn.example.com/standalone-image.jpg?token=secret)", ].join("\n"); const imageUrls = extractAssistantImageUrls(markdown); expect(getMarkdownTableImageUrlKeys(markdown)).toEqual( new Set([ "https://cdn.example.com/table-image.png", "https://cdn.example.com/table-link.webp", ]) ); expect(filterStandaloneAssistantImageUrls(markdown, imageUrls)).toEqual([ "https://cdn.example.com/standalone-image.jpg?token=secret", ]); }); test("extracts and strips video markup without removing surrounding text", () => { const text = [ "生成完成", '', "[下载视频](https://cdn.example.com/video-b.mp4)", "继续优化", ].join("\n"); expect(extractVideoUrls(text)).toEqual([ "https://cdn.example.com/video-a.mp4?sig=1", "https://cdn.example.com/video-b.mp4", ]); expect(stripMediaMarkup(text)).toBe("生成完成\n\n\n继续优化"); }); test("extracts playable audio and common video formats from Claw markdown", () => { const text = [ "任务完成", "[播放音频](https://cdn.example.com/result?id=1&format=mp3)", '', "[播放视频](https://cdn.example.com/result-video?filename=demo.mp4)", "https://cdn.example.com/preview.webm?token=3", "查看说明:https://cdn.example.com/readme.html", ].join("\n"); expect(extractAudioUrls(text)).toEqual([ "https://cdn.example.com/result?id=1&format=mp3", "https://cdn.example.com/voice.m4a?sig=2", ]); expect(extractVideoUrls(text)).toEqual([ "https://cdn.example.com/result-video?filename=demo.mp4", "https://cdn.example.com/preview.webm?token=3", ]); expect(stripMediaMarkup(text)).toContain( "查看说明:https://cdn.example.com/readme.html" ); expect(stripMediaMarkup(text)).not.toContain("播放音频"); expect(stripMediaMarkup(text)).not.toContain("preview.webm"); }); test("builds detail review plans with clamped count and localized fallbacks", () => { const plans = buildDetailReviewPlans({ language: "zh", count: 9, summary: " 已验证卖点 ", brief: { productSpu: "", demandType: "", productName: "无线吸尘器", targetAudience: "养宠家庭", mainUser: "", usageScenario: "客厅清洁", triggerMoment: "", coreNeed: "快速清理毛发", riskConcern: "吸力不足", expectedResult: "地面干净", coreSellingPoint: "强吸力,轻量,低噪音", positioningStatement: "", userVoice: "", competitorReference: "", priceBand: "", brandTone: "", bannedWords: "", }, }); expect(plans).toHaveLength(5); expect(plans[0]).toMatchObject({ index: 1, title: "真实需求反馈1", }); expect(plans[0]?.reviewText).toContain("养宠家庭"); expect(plans[0]?.reviewText).toContain("已验证卖点"); expect(plans[0]?.imagePrompt).toContain("无线吸尘器"); }); test("keeps detail slot indexes and generation mode options stable", () => { expect(DETAIL_TARGET_WIDTH).toBe(790); expect(DETAIL_TARGET_HEIGHT).toBe(1300); expect(DETAIL_SLOT_CONFIGS.map((slot) => slot.role)).toEqual([ "product", "product", "product", "product", "product", "model", "bundle", "bundle", "bundle", ]); expect(DETAIL_PRODUCT_SLOT_INDEXES).toEqual([0, 1, 2, 3, 4]); expect(DETAIL_MODEL_SLOT_INDEX).toBe(5); expect(DETAIL_BUNDLE_SLOT_INDEXES).toEqual([6, 7, 8]); expect(DETAIL_GENERATION_MODE_OPTIONS.map((option) => option.value)).toEqual([ "classic", "agent_layout", ]); }); test("normalizes detail config parser helpers defensively", () => { expect(normalizeDetailGenerationMode("agent_layout")).toBe("agent_layout"); expect(normalizeDetailGenerationMode("unknown")).toBe("classic"); expect(extractDetailFrameCount("需要 23 张详情页")).toBe(20); expect(extractDetailFrameCount("需要 2 屏")).toBe(5); expect(extractDetailFrameCount("没有明确数量")).toBe(15); expect(extractFirstNumber("标题 64px / 700", 32)).toBe(64); expect(extractFirstNumber("无数字", 32)).toBe(32); expect(extractFontWeight("主标题 64px / 700 / 行高1.15", "600")).toBe("700"); expect(extractFontWeight("weight: 500", "600")).toBe("500"); expect(extractFontWeight("无权重", "600")).toBe("600"); expect(splitDetailKeywords("强吸力,轻量; 低噪音 | 易清洁", 3)).toEqual([ "强吸力", "轻量", "低噪音", ]); }); test("sanitizes detail image model prompts away from pixel-locked layout instructions", () => { const sanitized = sanitizeDetailImageModelPrompt( [ "字体规范:主标题 64px / 700 / 行高1.15", "输出为竖版电商详情图,尺寸固定 790x1300 像素。", "安全区 y=64~360,左右边距56px,模块间距24px。", "商业摄影质感,留出清晰文案排版区,不要水印。", "突出产品真实材质与使用场景。", ].join("\n") ); expect(sanitized).not.toContain("790x1300"); expect(sanitized).not.toContain("64px"); expect(sanitized).not.toContain("56px"); expect(sanitized).toContain("输出为竖版电商详情图。"); expect(sanitized).toContain("顶部标题安全区"); expect(sanitized).toContain("左右留白充足"); expect(sanitized).toContain("突出产品真实材质与使用场景。"); }); test("builds stable detail result and appended review markdown", () => { const markdown = buildDetailResultMarkdown({ generationMode: "agent_layout", summary: "详情页生成成功", images: [ { originalUrl: "https://cdn.example.com/detail-1.png", thumbnailUrl: "https://cdn.example.com/detail-1-thumb.webp", }, ], prompts: ["商业摄影质感"], skillFlow: { name: "详情页生成", version: "1.0", steps: ["拆解卖点", "生成分镜"], }, qualityChecks: { passed: true, }, screens: [ { screenType: "A", title: "核心卖点", useModel: true, fabe: { b: "收益", }, }, ], reviewGroups: [ { reviewText: "真实好用", images: [ { originalUrl: "https://cdn.example.com/review-1.png", thumbnailUrl: "https://cdn.example.com/review-1-thumb.webp", }, ], }, ], }); expect(markdown).toContain("生成路线:AGENT排版(画面+可编辑文字层)"); expect(markdown).toContain("质检结果:通过"); expect(markdown).toContain("![thumb|||https://cdn.example.com/detail-1.png|||2K]"); expect(markdown).toContain("评价 1:真实好用"); expect( buildDetailReviewAppendMarkdown({ failedCount: 1, groups: [ { reviewText: "追加评价", images: [ { originalUrl: "https://cdn.example.com/review-2.png", thumbnailUrl: "https://cdn.example.com/review-2-thumb.webp", }, ], }, ], }) ).toContain("失败 1 条,已跳过。"); }); test("sanitizes and sorts detail archives from persisted payloads", () => { const archives = sanitizeDetailArchiveList([ { id: "older", title: "", prompt: " old prompt ", generationMode: "invalid", brief: { productName: "旧产品", targetAudience: "人群", }, slots: [ { url: "https://cdn.example.com/product.png", name: "", contentType: "", }, ], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z", uiSurfaceLatestReview: { screenIndex: 2, action: "unsafe", passed: true, issues: [" issue-a ", "", "issue-b"], }, }, { id: "newer", title: "新项目", prompt: "new prompt", generationMode: "agent_layout", brief: { coreNeed: "核心需求", }, slots: [], createdAt: "2026-01-03T00:00:00.000Z", }, { id: "", createdAt: "2026-01-04T00:00:00.000Z", }, ]); expect(archives.map((archive) => archive.id)).toEqual(["newer", "older"]); expect(archives[0]?.generationMode).toBe("agent_layout"); expect(archives[1]).toMatchObject({ title: "未命名详情项目", generationMode: "classic", brief: { productName: "旧产品", coreNeed: "", }, }); expect(archives[1]?.slots[0]).toMatchObject({ url: "https://cdn.example.com/product.png", name: "产品图1", contentType: "image/jpeg", }); expect(archives[1]?.uiSurfaceLatestReview).toMatchObject({ screenIndex: 2, action: undefined, passed: true, issues: ["issue-a", "issue-b"], }); }); test("creates and updates detail archive lists with stable limits", () => { const archive = createDetailArchiveSnapshot({ archiveId: "draft", createdAt: 1000, prompt: " 第一行\n第二行 ", generationMode: "classic", summary: "摘要", imageCount: 2, coverUrl: "https://cdn.example.com/cover.png", brief: { productSpu: " SPU-001 ", demandType: "", productName: "产品名", targetAudience: "", mainUser: "", usageScenario: " 客厅清洁 ", triggerMoment: "", coreNeed: "", riskConcern: "", expectedResult: "", coreSellingPoint: "", positioningStatement: "", userVoice: "", competitorReference: "", priceBand: "", brandTone: "", bannedWords: "", }, slots: [ { name: "产品", url: "https://cdn.example.com/product.png", contentType: "image/png", }, null, ], }); expect(archive).toMatchObject({ id: "draft", title: "SPU-001+客厅清洁", prompt: "第一行 第二行", createdAt: 1000, updatedAt: 1000, summary: "摘要", imageCount: 2, coverUrl: "https://cdn.example.com/cover.png", }); expect(archive.slots[0]).toMatchObject({ name: "产品", url: "https://cdn.example.com/product.png", contentType: "image/png", }); const patched = patchDetailArchiveList([archive], "draft", { summary: "新摘要" }, 2000); expect(patched[0]).toMatchObject({ summary: "新摘要", updatedAt: 2000 }); const saved = { ...archive, id: "saved", updatedAt: 3000 }; expect(replaceSavedDetailArchive([archive], "draft", saved).map((item) => item.id)).toEqual([ "saved", ]); const manyArchives = Array.from({ length: 35 }, (_, index) => ({ ...archive, id: `archive-${index}`, createdAt: index, updatedAt: index, })); expect(prependDetailArchive(manyArchives, saved)).toHaveLength(30); }); test("fills detail slots by current role group without crossing groups", () => { expect(getDetailSlotTargetIndexes(0)).toEqual([0, 1, 2, 3, 4]); expect(getDetailSlotTargetIndexes(3)).toEqual([3, 4]); expect(getDetailSlotTargetIndexes(5)).toEqual([5]); expect(getDetailSlotTargetIndexes(7)).toEqual([7, 8]); expect(getDetailSlotTargetIndexes(-1)).toEqual([]); expect(getDetailSlotTargetIndexes(99)).toEqual([]); const current = Array.from({ length: DETAIL_SLOT_CONFIGS.length }, () => null); const productA: PickedObjectAttachment = { name: "product-a", url: "https://cdn.example.com/product-a.png", contentType: "image/png", }; const productB: PickedObjectAttachment = { name: "product-b", url: "https://cdn.example.com/product-b.png", contentType: "image/png", }; const updatedSingle = applyDetailSlotAttachment(current, 5, productA); expect(updatedSingle[5]).toBe(productA); expect(current[5]).toBeNull(); const filled = fillDetailSlotAttachments(current, getDetailSlotTargetIndexes(3), [ productA, productB, ]); expect(filled.filledCount).toBe(2); expect(filled.slots[3]).toBe(productA); expect(filled.slots[4]).toBe(productB); expect(filled.slots[5]).toBeNull(); }); test("validates and normalizes canvas image upload helper payloads", () => { expect(validateCanvasImageUploadFile(null)).toEqual({ ok: false, message: "请选择图片文件", }); expect( validateCanvasImageUploadFile({ name: "demo.txt", type: "text/plain", size: 10, }) ).toEqual({ ok: false, message: "请上传图片文件", }); expect( validateCanvasImageUploadFile({ name: "huge.png", type: "image/png", size: CANVAS_IMAGE_UPLOAD_MAX_BYTES + 1, }) ).toEqual({ ok: false, message: "图片大小不能超过 50MB", }); expect( validateCanvasImageUploadFile({ name: "valid.webp", type: "image/webp", size: CANVAS_IMAGE_UPLOAD_MAX_BYTES, }) ).toEqual({ ok: true }); expect(getCanvasUploadImageTitle(" product.hero.final.png ")).toBe("product.hero.final"); expect(getCanvasUploadImageTitle(".png")).toBe("图片"); expect(normalizeUploadedAssetId(" asset-123 ")).toBe("asset-123"); expect(normalizeUploadedAssetId(" ")).toBeUndefined(); expect( buildCanvasUploadedImageAttachment({ file: { name: "photo.png", type: "", size: 100, }, imageUrl: "https://cdn.example.com/photo.png", assetId: "asset-1", }) ).toEqual({ name: "photo.png", url: "https://cdn.example.com/photo.png", contentType: "image/jpeg", assetId: "asset-1", }); }); });