fix: enforce canvas agent slot media types
This commit is contained in:
parent
5d5e10c83e
commit
652bc75ed0
@ -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);
|
||||
|
||||
@ -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 已有结果,替换已有结果需要用户确认",
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 || "",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user