ai-chatbot/tests/routes/canvas-chat-helpers.test.ts

3124 lines
93 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test } from "@playwright/test";
import {
collectMessageImageUrls,
extractAssistantImageUrls,
extractVideoUrls,
normalizeMediaUrl,
stripMediaMarkup,
} from "@/components/canvas/chat-panel/canvas-chat-media";
import {
getCanvasMediaPayloadHandledItemIds,
getCanvasOperationHandledMediaItemIds,
shouldRetryCanvasViewOperationAfterRefresh,
shouldSkipCanvasMediaPayloadForHandledItems,
} from "@/components/canvas/chat-panel/canvas-agent-stream";
import {
mergePendingCanvasAgentTargetNodeIds,
resolvePendingCanvasAgentTargetNode,
} from "@/components/canvas/chat-panel/canvas-agent-targets";
import {
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 {
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 { buildCanvasAgentDirectWritebackPlan } from "@/lib/canvas/agent-direct-writeback";
import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement";
import { buildCanvasAgentResultViewOperation } from "@/lib/canvas/agent-result-view";
import {
buildCanvasAgentComparisonViewPlan,
hasCanvasAgentComparisonViewIntent,
resolveCanvasAgentComparisonNodeIds,
} from "@/lib/canvas/agent-view-intent";
import {
computeCanvasAgentPanViewport,
resolveCanvasViewOperation,
resolveCanvasViewOperationTargetNodeIds,
} from "@/lib/canvas/agent-view-operations";
import {
getCanvasAgentOperationErrorMessage,
normalizeCanvasAgentOperationErrorCode,
} from "@/lib/canvas/agent-operation-errors";
import {
buildStaleCanvasSaveConflictPayload,
isStaleCanvasSaveBaseRevision,
parseCanvasRevisionDate,
} from "@/lib/canvas/canvas-save-conflict";
import {
applyCanvasOperationPlanToCanvasData,
CanvasAgentMutationError,
} from "@/lib/canvas/agent-mutations";
import {
appendCanvasAgentOperationAuditIfMissing,
buildIdempotentCanvasAgentAuditResult,
findCanvasAgentOperationAudit,
} from "@/lib/canvas/agent-operation-audit";
import {
canApplyCanvasAgentMutations,
getCanvasAgentFeatureFlags,
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 {
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";
test.describe("canvas chat pure helpers", () => {
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("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",
});
});
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(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("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("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("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 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"],
});
});
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("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);
});
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: "换成白底",
},
],
},
});
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",
},
},
});
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<string>();
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);
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("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("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-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("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("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);
});
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 },
]);
});
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 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(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("");
});
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("extracts and strips video markup without removing surrounding text", () => {
const text = [
"生成完成",
'<video src="https://cdn.example.com/video-a.mp4?sig=1"></video>',
"[下载视频](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("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",
});
});
});