fix: enforce canvas agent slot media types

This commit is contained in:
134 2026-06-23 21:26:02 +08:00
parent 5d5e10c83e
commit 652bc75ed0
4 changed files with 157 additions and 3 deletions

View File

@ -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);

View File

@ -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<string, unknown>;
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 已有结果,替换已有结果需要用户确认",

View File

@ -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({

View File

@ -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 || "",