From 652bc75ed0e879b858111320af9d89821dd91bd1 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 23 Jun 2026 21:26:02 +0800 Subject: [PATCH] fix: enforce canvas agent slot media types --- lib/canvas/agent-direct-writeback.ts | 17 +++++- lib/canvas/agent-mutations.ts | 37 ++++++++++++ tests/routes/canvas-agent-operations.test.ts | 44 +++++++++++++- tests/routes/canvas-chat-helpers.test.ts | 62 ++++++++++++++++++++ 4 files changed, 157 insertions(+), 3 deletions(-) diff --git a/lib/canvas/agent-direct-writeback.ts b/lib/canvas/agent-direct-writeback.ts index 3226561ef..3bedd4055 100644 --- a/lib/canvas/agent-direct-writeback.ts +++ b/lib/canvas/agent-direct-writeback.ts @@ -8,6 +8,7 @@ import type { CanvasAgentAnnotationRef, CanvasAgentContext, CanvasAgentNodeRef, + CanvasAgentSlotRef, CanvasMediaItem, CanvasOperationKind, CanvasOperationPlan, @@ -58,6 +59,14 @@ function buildKnownNodeMap(context: CanvasAgentContext) { ); } +function isSlotCompatibleWithResultType( + slot: CanvasAgentSlotRef, + resultType: CanvasAgentDirectWritebackResultType +) { + if (slot.slotType === "video") return resultType === "video"; + return resultType === "image"; +} + function resolveTargetMediaNodes(params: { context: CanvasAgentContext; targetNodeIds: string[]; @@ -120,7 +129,12 @@ export function buildCanvasAgentDirectWritebackPlan( if (resultUrls.length === 0) return null; const preferredTargetSlotId = String(params.targetSlotId || "").trim(); - const selectedSlot = getSelectedSlots(params.context)[0] || null; + const selectedSlots = getSelectedSlots(params.context); + const selectedSlot = + selectedSlots.find((slot) => + isSlotCompatibleWithResultType(slot, params.resultType) + ) || null; + const selectedSlotAnchor = selectedSlots[0] || null; const targetSlotId = preferredTargetSlotId || selectedSlot?.nodeId || ""; const shouldFillSlot = Boolean(targetSlotId); const selectedMediaNodes = getSelectedMediaNodes(params.context); @@ -140,6 +154,7 @@ export function buildCanvasAgentDirectWritebackPlan( targetMediaNodes.length > 0 ? targetMediaNodes : selectedMediaNodes; const anchorNode = selectedSlot || + selectedSlotAnchor || targetMediaNodes[0] || selectedMediaNodes[0] || getBestAnchorNode(params.context); diff --git a/lib/canvas/agent-mutations.ts b/lib/canvas/agent-mutations.ts index 1dbd469c7..15fe50083 100644 --- a/lib/canvas/agent-mutations.ts +++ b/lib/canvas/agent-mutations.ts @@ -273,6 +273,38 @@ function getAgentSlotDefaults(slotType: "image" | "video" | "ui-screen") { }; } +function isMediaItemCompatibleWithAgentSlot(params: { + slotType: "image" | "video" | "ui-screen"; + mediaType: CanvasMediaItem["type"]; +}) { + if (params.slotType === "video") return params.mediaType === "video"; + return params.mediaType === "image"; +} + +function assertMediaItemsCompatibleWithAgentSlot(params: { + targetSlot: any; + mediaItems: CanvasMediaItem[]; +}) { + const targetSlotData = (params.targetSlot?.data || {}) as Record; + const slotType = normalizeAgentSlotType(targetSlotData.slotType); + const mismatchedItem = params.mediaItems.find( + (item) => + !isMediaItemCompatibleWithAgentSlot({ + slotType, + mediaType: item.type, + }) + ); + if (!mismatchedItem) return; + + const expectedLabel = slotType === "video" ? "视频" : "图片"; + const actualLabel = mismatchedItem.type === "video" ? "视频" : "图片"; + throw new CanvasAgentMutationError( + `目标 slot 是${expectedLabel}槽位,不能填充${actualLabel}结果`, + 400, + "CANVAS_AGENT_SLOT_MEDIA_TYPE_MISMATCH" + ); +} + function getCanvasViewportCenterPosition(params: { canvasData: CanvasDataLike; size: { width: number; height: number }; @@ -1433,6 +1465,11 @@ function applyOperationToCanvasData(params: { ? targetSlotData.filledNodeIds.filter((id) => typeof id === "string") : []; + assertMediaItemsCompatibleWithAgentSlot({ + targetSlot, + mediaItems, + }); + if (fillPolicy === "replace-existing" && filledNodeIds.length > 0) { throw new CanvasAgentMutationError( "当前 slot 已有结果,替换已有结果需要用户确认", diff --git a/tests/routes/canvas-agent-operations.test.ts b/tests/routes/canvas-agent-operations.test.ts index 031bbb4d8..af0551ee4 100644 --- a/tests/routes/canvas-agent-operations.test.ts +++ b/tests/routes/canvas-agent-operations.test.ts @@ -83,14 +83,14 @@ function buildImageNode(id = "image-1") { }; } -function buildAgentSlotNode(id = "slot-1") { +function buildAgentSlotNode(id = "slot-1", slotType: "image" | "video" = "image") { return { id, type: "agent-slot", position: { x: 500, y: 120 }, size: { width: 300, height: 300 }, data: { - slotType: "image", + slotType, status: "empty", fillPolicy: "replace-empty", filledNodeIds: [], @@ -735,6 +735,46 @@ test.describe("canvas agent operation mutation contract", () => { ]); }); + test("rejects fill-slot media when it does not match the slot type", () => { + const videoSlot = buildAgentSlotNode("video-slot-1", "video"); + + expect(() => + applyCanvasOperationPlanToCanvasData({ + canvasData: getCanvasData([videoSlot]), + plan: { + version: 1, + operationId: "op-fill-video-slot-with-image", + operation: "fill_slot", + projectId, + targetSlotId: "video-slot-1", + mediaItems: [buildMutationMediaItem("media-image-result")], + }, + }) + ).toThrow("目标 slot 是视频槽位,不能填充图片结果"); + + const imageSlot = buildAgentSlotNode("image-slot-1", "image"); + + expect(() => + applyCanvasOperationPlanToCanvasData({ + canvasData: getCanvasData([imageSlot]), + plan: { + version: 1, + operationId: "op-fill-image-slot-with-video", + operation: "fill_slot", + projectId, + targetSlotId: "image-slot-1", + mediaItems: [ + { + mediaItemId: "media-video-result", + type: "video", + url: "https://cdn.example.com/media-video-result.mp4", + }, + ], + }, + }) + ).toThrow("目标 slot 是图片槽位,不能填充视频结果"); + }); + test("creates append revision nodes without overwriting the source", () => { const source = buildImageNode("image-source"); const result = applyCanvasOperationPlanToCanvasData({ diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 66605df32..25304fb66 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -723,6 +723,68 @@ test.describe("canvas chat pure helpers", () => { }); }); + 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 || "",