ai-chatbot/lib/canvas/agent-mutations.ts

1309 lines
38 KiB
TypeScript

import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement";
import { shouldRejectStaleCanvasAgentHighRiskMutation } from "@/lib/canvas/agent-risk";
import {
appendCanvasAgentOperationAudit,
appendCanvasAgentOperationAuditIfMissing,
buildIdempotentCanvasAgentAuditResult,
findCanvasAgentOperationAudit,
normalizeCanvasAgentAuditStringList,
} from "@/lib/canvas/agent-operation-audit";
import type {
CanvasMediaItem,
CanvasOperationPlan,
} from "@/types/canvas/agent-protocol";
export type CanvasDataLike = {
nodes?: any[];
edges?: any[];
selectedNodeIds?: string[];
viewport?: { x: number; y: number; zoom: number };
[key: string]: unknown;
};
export type CanvasOperationResult = {
createdNodeIds: string[];
createdNodeIdsByMediaItemId: Record<string, string>;
updatedNodeIds: string[];
createdEdgeIds: string[];
canvasData: CanvasDataLike;
canvasRevision: string;
canvasUpdatedAt: string;
baseCanvasWasStale: boolean;
};
export class CanvasAgentMutationError extends Error {
status: number;
code?: string;
constructor(message: string, status = 400, code?: string) {
super(message);
this.name = "CanvasAgentMutationError";
this.status = status;
this.code = code;
}
}
function generateId() {
return crypto.randomUUID();
}
function normalizeCanvasData(value: unknown): CanvasDataLike {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
};
}
const raw = value as CanvasDataLike;
return {
...raw,
nodes: Array.isArray(raw.nodes) ? [...raw.nodes] : [],
edges: Array.isArray(raw.edges) ? [...raw.edges] : [],
selectedNodeIds: Array.isArray(raw.selectedNodeIds)
? raw.selectedNodeIds.filter((id): id is string => typeof id === "string")
: [],
viewport: raw.viewport || { x: 0, y: 0, zoom: 1 },
};
}
function normalizeMediaItems(plan: CanvasOperationPlan): CanvasMediaItem[] {
if (Array.isArray(plan.mediaItems) && plan.mediaItems.length > 0) {
return plan.mediaItems
.map((item, index) => ({
...item,
mediaItemId:
typeof item.mediaItemId === "string" && item.mediaItemId.trim()
? item.mediaItemId.trim()
: `${plan.operationId}:media-${index + 1}`,
url: typeof item.url === "string" ? item.url.trim() : "",
}))
.filter((item) => item.url);
}
if (plan.media?.url) {
return [
{
...plan.media,
mediaItemId: `${plan.operationId}:media-1`,
url: plan.media.url.trim(),
},
];
}
return [];
}
function findNode(nodes: any[], nodeId?: string) {
if (!nodeId) return null;
return nodes.find((node) => node?.id === nodeId) || null;
}
function mediaNodeType(item: CanvasMediaItem) {
return item.type === "video" ? "video" : "image";
}
function buildMediaNode(params: {
item: CanvasMediaItem;
plan: CanvasOperationPlan;
position: { x: number; y: number };
size: { width: number; height: number };
sourceNodeIds: string[];
}) {
const now = new Date().toISOString();
const type = mediaNodeType(params.item);
const metadata = {
...(params.item.metadata || {}),
generatedByAgent: true,
canvasAgentOperationId: params.plan.operationId,
canvasAgentOperation: params.plan.operation,
canvasAgentMediaItemId: params.item.mediaItemId,
canvasAgentPlacement: params.plan.placement,
canvasAgentPlacementStrategy: params.plan.placementStrategy,
sourceNodeIds: params.sourceNodeIds,
revisionOf:
params.plan.operation === "append_revision"
? params.sourceNodeIds[0] || null
: undefined,
};
return {
id: generateId(),
type,
position: params.position,
size: params.size,
data:
type === "video"
? {
src: params.item.url,
assetId: params.item.assetId,
status: "completed",
prompt: params.item.prompt,
title: params.item.title || "Agent 视频结果",
metadata,
}
: {
src: params.item.url,
assetId: params.item.assetId,
status: "completed",
prompt: params.item.prompt,
title: params.item.title || "Agent 图片结果",
metadata,
},
connections: params.sourceNodeIds,
createdAt: now,
updatedAt: now,
};
}
function buildEdge(source: string, target: string, type: "default" | "reference") {
return {
id: generateId(),
source,
target,
type,
};
}
function rebuildConnections(nodes: any[], edges: any[]) {
const connectionMap = new Map<string, Set<string>>();
nodes.forEach((node) => connectionMap.set(node.id, new Set()));
edges.forEach((edge) => {
connectionMap.get(edge.source)?.add(edge.target);
connectionMap.get(edge.target)?.add(edge.source);
});
nodes.forEach((node) => {
node.connections = Array.from(connectionMap.get(node.id) || []);
});
}
function findExistingOperationNodes(nodes: any[], operationId: string) {
return nodes.filter(
(node) => node?.data?.metadata?.canvasAgentOperationId === operationId
);
}
function getPlanBaseTime(plan: CanvasOperationPlan) {
const raw = String(plan.baseCanvasUpdatedAt || plan.baseCanvasRevision || "").trim();
if (!raw) return null;
const parsed = new Date(raw);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function isPlanBaseStale(plan: CanvasOperationPlan, projectUpdatedAt: Date) {
const baseTime = getPlanBaseTime(plan);
if (!baseTime) return false;
return baseTime.getTime() + 1000 < projectUpdatedAt.getTime();
}
function normalizeSlotFillPolicy(value: unknown) {
return value === "append-version" || value === "replace-existing"
? value
: "replace-empty";
}
function normalizeAgentSlotType(value: unknown): "image" | "video" | "ui-screen" {
return value === "video" || value === "ui-screen" ? value : "image";
}
function normalizeAgentSlotAcceptedSources(value: unknown) {
const allowed = new Set(["text", "image", "selection", "annotation"]);
const sources = Array.isArray(value)
? value
.map((item) => String(item || "").trim())
.filter((item) => allowed.has(item))
: [];
return sources.length > 0
? Array.from(new Set(sources))
: ["text", "image", "selection", "annotation"];
}
function getAgentSlotDefaults(slotType: "image" | "video" | "ui-screen") {
if (slotType === "video") {
return {
label: "视频槽位",
aspectRatio: "16:9",
size: { width: 360, height: 220 },
};
}
if (slotType === "ui-screen") {
return {
label: "UI 槽位",
aspectRatio: "16:9",
size: { width: 360, height: 260 },
};
}
return {
label: "图片槽位",
aspectRatio: "1:1",
size: { width: 320, height: 320 },
};
}
function getCanvasViewportCenterPosition(params: {
canvasData: CanvasDataLike;
size: { width: number; height: number };
}) {
const viewport = params.canvasData.viewport || { x: 0, y: 0, zoom: 1 };
const zoom = Math.max(0.1, Number(viewport.zoom || 1));
const viewportWidth = Math.max(320, Number((viewport as any).width || 1200));
const viewportHeight = Math.max(320, Number((viewport as any).height || 800));
return {
x: (viewportWidth / 2 - Number(viewport.x || 0)) / zoom - params.size.width / 2,
y: (viewportHeight / 2 - Number(viewport.y || 0)) / zoom - params.size.height / 2,
};
}
function normalizeEdgeType(value: unknown): "default" | "reference" | "conversation" {
return value === "default" || value === "conversation" ? value : "reference";
}
function normalizeAnnotationType(
value: unknown
): "text-note" | "arrow-note" | "box" | "freehand" | "crop-box" {
return value === "arrow-note" ||
value === "box" ||
value === "freehand" ||
value === "crop-box"
? value
: "text-note";
}
function normalizeAnnotationPoint(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const raw = value as Record<string, unknown>;
const x = Number(raw.x);
const y = Number(raw.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined;
return { x, y };
}
function normalizeAnnotationBounds(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const raw = value as Record<string, unknown>;
const x = Number(raw.x);
const y = Number(raw.y);
const width = Number(raw.width);
const height = Number(raw.height);
if (
!Number.isFinite(x) ||
!Number.isFinite(y) ||
!Number.isFinite(width) ||
!Number.isFinite(height)
) {
return undefined;
}
return {
x,
y,
width: Math.max(0, width),
height: Math.max(0, height),
};
}
function edgeMatches(edge: any, source: string, target: string) {
return (
(edge?.source === source && edge?.target === target) ||
(edge?.source === target && edge?.target === source)
);
}
function getOperationTargetNodeIds(plan: CanvasOperationPlan) {
return Array.from(
new Set(
[
plan.anchorNodeId || "",
...(plan.targetNodeIds || []),
...(plan.sourceNodeIds || []),
].filter(Boolean)
)
);
}
function normalizeCanvasMetadata(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value)
? { ...(value as Record<string, unknown>) }
: {};
}
function getNodeSize(node: any) {
return {
width: Math.max(1, Number(node?.size?.width || 220) || 220),
height: Math.max(1, Number(node?.size?.height || 160) || 160),
};
}
function getNodePosition(node: any) {
return {
x: Number(node?.position?.x || 0) || 0,
y: Number(node?.position?.y || 0) || 0,
};
}
function isConfirmedHighRiskPlan(plan: CanvasOperationPlan) {
const metadata = (plan.metadata || {}) as Record<string, unknown>;
return plan.confirmed === true || metadata.confirmedByUser === true;
}
function assertFreshBaseForCanvasAgentHighRiskMutation(
plan: CanvasOperationPlan,
baseCanvasWasStale?: boolean
) {
if (
shouldRejectStaleCanvasAgentHighRiskMutation({
operation: plan.operation,
confirmed: plan.confirmed,
metadata: plan.metadata,
baseCanvasWasStale,
})
) {
throw new CanvasAgentMutationError(
"画布已更新,高风险操作需要刷新后重新确认",
409,
"STALE_CANVAS_REVISION"
);
}
}
function normalizeLayoutStrategy(value: unknown): "row" | "column" | "grid" {
return value === "column" || value === "grid" ? value : "row";
}
function resolveMoveStartPosition(nodes: any[], plan: CanvasOperationPlan) {
const target = plan.targetPosition;
if (
target &&
Number.isFinite(Number(target.x)) &&
Number.isFinite(Number(target.y))
) {
return {
x: Number(target.x),
y: Number(target.y),
};
}
const positions = nodes.map(getNodePosition);
return {
x: Math.min(...positions.map((position) => position.x)),
y: Math.min(...positions.map((position) => position.y)),
};
}
function applyMoveNodesOperation(params: {
canvasData: CanvasDataLike;
nodes: any[];
edges: any[];
plan: CanvasOperationPlan;
}) {
if (!isConfirmedHighRiskPlan(params.plan)) {
throw new CanvasAgentMutationError(
"移动画布节点需要用户确认",
409,
"CANVAS_AGENT_CONFIRMATION_REQUIRED"
);
}
const targetNodeIds = getOperationTargetNodeIds(params.plan);
if (targetNodeIds.length === 0) {
throw new CanvasAgentMutationError("缺少要移动的节点");
}
const targetNodes = targetNodeIds.map((nodeId) => findNode(params.nodes, nodeId));
const missingIndex = targetNodes.findIndex((node) => !node);
if (missingIndex >= 0) {
throw new CanvasAgentMutationError(`移动节点不存在:${targetNodeIds[missingIndex]}`);
}
const orderedNodes = (targetNodes as any[]).sort((a, b) => {
const posA = getNodePosition(a);
const posB = getNodePosition(b);
return posA.y === posB.y ? posA.x - posB.x : posA.y - posB.y;
});
const spacing = Math.max(8, Math.min(240, Number(params.plan.spacing || 40) || 40));
const layoutStrategy = normalizeLayoutStrategy(params.plan.layoutStrategy);
const start = resolveMoveStartPosition(orderedNodes, params.plan);
const sizes = orderedNodes.map(getNodeSize);
const maxWidth = Math.max(...sizes.map((size) => size.width));
const maxHeight = Math.max(...sizes.map((size) => size.height));
const now = new Date().toISOString();
orderedNodes.forEach((node, index) => {
let position = { x: start.x, y: start.y };
if (layoutStrategy === "column") {
const previousHeights = sizes
.slice(0, index)
.reduce((sum, size) => sum + size.height + spacing, 0);
position = { x: start.x, y: start.y + previousHeights };
} else if (layoutStrategy === "grid") {
const cols = Math.max(1, Math.ceil(Math.sqrt(orderedNodes.length)));
const col = index % cols;
const row = Math.floor(index / cols);
position = {
x: start.x + col * (maxWidth + spacing),
y: start.y + row * (maxHeight + spacing),
};
} else {
const previousWidths = sizes
.slice(0, index)
.reduce((sum, size) => sum + size.width + spacing, 0);
position = { x: start.x + previousWidths, y: start.y };
}
const previousPosition = getNodePosition(node);
const data = (node.data || {}) as Record<string, any>;
node.position = position;
node.data = {
...data,
metadata: {
...(data.metadata || {}),
movedByAgent: true,
canvasAgentOperationId: params.plan.operationId,
canvasAgentOperation: params.plan.operation,
canvasAgentLayoutStrategy: layoutStrategy,
previousPosition,
agentMovedAt: now,
},
};
node.updatedAt = now;
});
return {
canvasData: {
...params.canvasData,
nodes: params.nodes,
edges: params.edges,
selectedNodeIds: orderedNodes.map((node) => node.id),
},
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds: orderedNodes.map((node) => node.id),
createdEdgeIds: [],
};
}
function getNodeMediaSnapshot(node: any) {
const data = (node?.data || {}) as Record<string, any>;
return {
type: node?.type,
src: typeof data.src === "string" ? data.src : "",
assetId: typeof data.assetId === "string" ? data.assetId : undefined,
title: typeof data.title === "string" ? data.title : undefined,
prompt: typeof data.prompt === "string" ? data.prompt : undefined,
status: typeof data.status === "string" ? data.status : undefined,
};
}
function getReplacementMediaItemsForTargets(params: {
mediaItems: CanvasMediaItem[];
targetNodeIds: string[];
}) {
if (params.mediaItems.length === params.targetNodeIds.length) {
return params.targetNodeIds.map((targetNodeId, index) => {
const explicit = params.mediaItems.find((item) =>
Array.isArray(item.sourceNodeIds)
? item.sourceNodeIds.includes(targetNodeId)
: false
);
return explicit || params.mediaItems[index];
});
}
if (params.mediaItems.length === 1 && params.targetNodeIds.length === 1) {
return params.mediaItems;
}
throw new CanvasAgentMutationError("替换媒体数量必须与目标节点数量一致");
}
function applyReplaceNodeMediaOperation(params: {
canvasData: CanvasDataLike;
nodes: any[];
edges: any[];
plan: CanvasOperationPlan;
}) {
if (!isConfirmedHighRiskPlan(params.plan)) {
throw new CanvasAgentMutationError(
"替换画布媒体需要用户确认",
409,
"CANVAS_AGENT_CONFIRMATION_REQUIRED"
);
}
const targetNodeIds = getOperationTargetNodeIds(params.plan);
if (targetNodeIds.length === 0) {
throw new CanvasAgentMutationError("缺少要替换的媒体节点");
}
const mediaItems = normalizeMediaItems(params.plan);
if (mediaItems.length === 0) {
throw new CanvasAgentMutationError("缺少替换媒体结果");
}
const replacementItems = getReplacementMediaItemsForTargets({
mediaItems,
targetNodeIds,
});
const now = new Date().toISOString();
const createdNodeIdsByMediaItemId: Record<string, string> = {};
const updatedNodeIds: string[] = [];
targetNodeIds.forEach((nodeId, index) => {
const node = findNode(params.nodes, nodeId);
if (!node) {
throw new CanvasAgentMutationError(`替换节点不存在:${nodeId}`);
}
if (node.type !== "image" && node.type !== "video") {
throw new CanvasAgentMutationError("replace_node_media 只能替换图片或视频节点");
}
const item = replacementItems[index];
if (!item) {
throw new CanvasAgentMutationError("缺少替换媒体结果");
}
if (item.type !== node.type) {
throw new CanvasAgentMutationError("替换媒体类型必须与目标节点类型一致");
}
const data = (node.data || {}) as Record<string, any>;
const previousMedia = getNodeMediaSnapshot(node);
node.data = {
...data,
src: item.url,
assetId: item.assetId || data.assetId,
status: "completed",
prompt: item.prompt || data.prompt,
title: item.title || data.title,
metadata: {
...(data.metadata || {}),
...(item.metadata || {}),
replacedByAgent: true,
canvasAgentOperationId: params.plan.operationId,
canvasAgentOperation: params.plan.operation,
canvasAgentMediaItemId: item.mediaItemId,
previousMedia,
agentReplacedAt: now,
},
};
node.updatedAt = now;
updatedNodeIds.push(node.id);
createdNodeIdsByMediaItemId[item.mediaItemId] = node.id;
});
rebuildConnections(params.nodes, params.edges);
return {
canvasData: {
...params.canvasData,
nodes: params.nodes,
edges: params.edges,
selectedNodeIds: updatedNodeIds,
},
createdNodeIds: [],
createdNodeIdsByMediaItemId,
updatedNodeIds,
createdEdgeIds: [],
};
}
function applyDeleteNodesOperation(params: {
canvasData: CanvasDataLike;
nodes: any[];
edges: any[];
plan: CanvasOperationPlan;
}) {
if (!isConfirmedHighRiskPlan(params.plan)) {
throw new CanvasAgentMutationError(
"删除画布节点需要用户确认",
409,
"CANVAS_AGENT_CONFIRMATION_REQUIRED"
);
}
const targetNodeIds = getOperationTargetNodeIds(params.plan);
if (targetNodeIds.length === 0) {
throw new CanvasAgentMutationError("缺少要删除的节点");
}
const targetNodes = targetNodeIds.map((nodeId) => findNode(params.nodes, nodeId));
const missingIndex = targetNodes.findIndex((node) => !node);
if (missingIndex >= 0) {
throw new CanvasAgentMutationError(`删除节点不存在:${targetNodeIds[missingIndex]}`);
}
if (targetNodes.some((node) => node?.type === "agent-slot")) {
throw new CanvasAgentMutationError("Agent slot 默认不允许删除,请手动处理 slot");
}
const targetSet = new Set(targetNodeIds);
const deletedNodes = targetNodes.map((node) => JSON.parse(JSON.stringify(node)));
const deletedEdges = params.edges
.filter((edge) => targetSet.has(edge.source) || targetSet.has(edge.target))
.map((edge) => JSON.parse(JSON.stringify(edge)));
const remainingNodes = params.nodes.filter((node) => !targetSet.has(node.id));
const remainingEdges = params.edges.filter(
(edge) => !targetSet.has(edge.source) && !targetSet.has(edge.target)
);
rebuildConnections(remainingNodes, remainingEdges);
const auditedCanvasData = appendCanvasAgentOperationAudit({
canvasData: params.canvasData,
auditEntry: {
operationId: params.plan.operationId,
operation: params.plan.operation,
targetNodeIds,
deletedNodeIds: targetNodeIds,
deletedNodes,
deletedEdges,
deletedAt: new Date().toISOString(),
metadata: params.plan.metadata || {},
},
});
return {
canvasData: {
...auditedCanvasData,
nodes: remainingNodes,
edges: remainingEdges,
selectedNodeIds: [],
},
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds: targetNodeIds,
createdEdgeIds: [],
};
}
function normalizePlanAnnotations(plan: CanvasOperationPlan, fallbackText: string) {
const rawAnnotations = Array.isArray(plan.annotations) ? plan.annotations : [];
const normalized = rawAnnotations
.map((annotation) => {
if (!annotation || typeof annotation !== "object") return null;
const raw = annotation as Record<string, unknown>;
const text =
typeof raw.text === "string"
? String(raw.text).trim().slice(0, 1000)
: undefined;
return {
id: typeof raw.id === "string" ? String(raw.id).trim().slice(0, 128) : "",
sourceNodeId:
typeof raw.sourceNodeId === "string"
? String(raw.sourceNodeId).trim().slice(0, 128)
: "",
type: normalizeAnnotationType(raw.type),
text,
bounds: normalizeAnnotationBounds(raw.bounds),
targetPoint: normalizeAnnotationPoint(raw.targetPoint),
color:
typeof raw.color === "string" ? String(raw.color).trim().slice(0, 64) : undefined,
createdAt:
typeof raw.createdAt === "string"
? String(raw.createdAt).trim().slice(0, 64)
: undefined,
};
})
.filter((annotation): annotation is NonNullable<typeof annotation> =>
Boolean(annotation)
);
if (normalized.length > 0) return normalized.slice(0, 20);
if (!fallbackText) return [];
return [
{
id: "",
sourceNodeId: "",
type: "text-note" as const,
text: fallbackText.slice(0, 1000),
bounds: undefined,
targetPoint: undefined,
color: undefined,
createdAt: undefined,
},
];
}
function applyAddAnnotationOperation(params: {
canvasData: CanvasDataLike;
nodes: any[];
edges: any[];
plan: CanvasOperationPlan;
}) {
const targetNodeIds = getOperationTargetNodeIds(params.plan);
if (targetNodeIds.length === 0) {
throw new CanvasAgentMutationError("缺少要添加批注的节点");
}
const fallbackText = String(params.plan.textContent || "").trim();
const annotations = normalizePlanAnnotations(params.plan, fallbackText);
if (annotations.length === 0) {
throw new CanvasAgentMutationError("缺少批注内容");
}
const now = new Date().toISOString();
const updatedNodeIds: string[] = [];
targetNodeIds.forEach((nodeId) => {
const node = findNode(params.nodes, nodeId);
if (!node) {
throw new CanvasAgentMutationError(`批注节点不存在:${nodeId}`);
}
if (node.type !== "image" && node.type !== "video") {
throw new CanvasAgentMutationError("add_annotation 只能给图片或视频节点添加批注");
}
const data = (node.data || {}) as Record<string, any>;
const metadata = normalizeCanvasMetadata(data.metadata);
const existingAnnotations = Array.isArray(metadata.annotations)
? metadata.annotations.filter((item) => item && typeof item === "object")
: [];
const nextAnnotations = annotations.map((annotation, index) => ({
...annotation,
id:
annotation.id ||
`${params.plan.operationId}:annotation-${node.id}-${index + 1}`,
sourceNodeId: annotation.sourceNodeId || node.id,
createdAt: annotation.createdAt || now,
createdByAgent: true,
canvasAgentOperationId: params.plan.operationId,
}));
node.data = {
...data,
hasAnnotations: true,
metadata: {
...metadata,
annotations: [...existingAnnotations, ...nextAnnotations].slice(-50),
annotationsUpdatedAt: now,
},
};
node.updatedAt = now;
updatedNodeIds.push(node.id);
});
return {
canvasData: {
...params.canvasData,
nodes: params.nodes,
edges: params.edges,
selectedNodeIds: updatedNodeIds,
},
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds,
createdEdgeIds: [],
};
}
function applyCreateAgentSlotOperation(params: {
canvasData: CanvasDataLike;
nodes: any[];
edges: any[];
plan: CanvasOperationPlan;
}) {
const now = new Date().toISOString();
const slotMetadata = (params.plan.metadata || {}) as Record<string, unknown>;
const slotSpec = (params.plan.slot || {}) as Record<string, unknown>;
const slotType = normalizeAgentSlotType(slotSpec.slotType || slotMetadata.slotType);
const defaults = getAgentSlotDefaults(slotType);
const label = String(slotSpec.label || defaults.label).trim().slice(0, 120);
const aspectRatio = String(slotSpec.aspectRatio || defaults.aspectRatio)
.trim()
.slice(0, 32);
const fillPolicy = normalizeSlotFillPolicy(slotSpec.fillPolicy);
const acceptedSources = normalizeAgentSlotAcceptedSources(slotSpec.acceptedSources);
const targetPosition =
params.plan.targetPosition &&
Number.isFinite(Number(params.plan.targetPosition.x)) &&
Number.isFinite(Number(params.plan.targetPosition.y))
? {
x: Number(params.plan.targetPosition.x),
y: Number(params.plan.targetPosition.y),
}
: getCanvasViewportCenterPosition({
canvasData: params.canvasData,
size: defaults.size,
});
const node = {
id: generateId(),
type: "agent-slot",
position: targetPosition,
size: defaults.size,
data: {
slotType,
label,
status: "empty",
aspectRatio,
fillPolicy,
acceptedSources,
filledNodeIds: [],
metadata: {
...(params.plan.metadata || {}),
createdByAgent: true,
canvasAgentOperationId: params.plan.operationId,
canvasAgentOperation: params.plan.operation,
agentCreatedAt: now,
},
},
connections: [],
createdAt: now,
updatedAt: now,
};
params.nodes.push(node);
return {
canvasData: {
...params.canvasData,
nodes: params.nodes,
edges: params.edges,
selectedNodeIds: [node.id],
},
createdNodeIds: [node.id],
createdNodeIdsByMediaItemId: {},
updatedNodeIds: [],
createdEdgeIds: [],
};
}
function applyOperationToCanvasData(params: {
canvasData: CanvasDataLike;
plan: CanvasOperationPlan;
baseCanvasWasStale?: boolean;
}) {
if (!params.plan.operationId) {
throw new CanvasAgentMutationError("缺少 operationId");
}
if (
params.plan.operation !== "create_agent_slot" &&
params.plan.operation !== "insert_beside" &&
params.plan.operation !== "append_revision" &&
params.plan.operation !== "fill_slot" &&
params.plan.operation !== "link_nodes" &&
params.plan.operation !== "add_annotation" &&
params.plan.operation !== "move_nodes" &&
params.plan.operation !== "replace_node_media" &&
params.plan.operation !== "delete_nodes" &&
params.plan.operation !== "update_text"
) {
throw new CanvasAgentMutationError("当前仅支持非破坏式画布写回操作");
}
const nodes = [...(params.canvasData.nodes || [])];
const edges = [...(params.canvasData.edges || [])];
const existingOperationNodes = findExistingOperationNodes(
nodes,
params.plan.operationId
);
const existingAudit = findCanvasAgentOperationAudit(
params.canvasData,
params.plan.operationId
);
if (existingAudit) {
const auditResult = buildIdempotentCanvasAgentAuditResult({
canvasData: params.canvasData,
audit: existingAudit,
});
if (params.plan.operation !== "delete_nodes") {
return auditResult;
}
const deletedNodeIds = normalizeCanvasAgentAuditStringList(
existingAudit.deletedNodeIds
);
return {
canvasData: params.canvasData,
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds: deletedNodeIds.length > 0 ? deletedNodeIds : auditResult.updatedNodeIds,
createdEdgeIds: [],
};
}
assertFreshBaseForCanvasAgentHighRiskMutation(
params.plan,
params.baseCanvasWasStale
);
if (
existingOperationNodes.length > 0 &&
(params.plan.operation === "replace_node_media" ||
params.plan.operation === "move_nodes")
) {
return {
canvasData: params.canvasData,
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds: existingOperationNodes.map((node) => node.id),
createdEdgeIds: [],
};
}
if (params.plan.operation === "delete_nodes") {
return applyDeleteNodesOperation({
canvasData: params.canvasData,
nodes,
edges,
plan: params.plan,
});
}
if (params.plan.operation === "add_annotation") {
return applyAddAnnotationOperation({
canvasData: params.canvasData,
nodes,
edges,
plan: params.plan,
});
}
if (params.plan.operation === "replace_node_media") {
return applyReplaceNodeMediaOperation({
canvasData: params.canvasData,
nodes,
edges,
plan: params.plan,
});
}
if (params.plan.operation === "move_nodes") {
return applyMoveNodesOperation({
canvasData: params.canvasData,
nodes,
edges,
plan: params.plan,
});
}
if (params.plan.operation === "create_agent_slot") {
if (existingOperationNodes.length > 0) {
return {
canvasData: params.canvasData,
createdNodeIds: existingOperationNodes.map((node) => node.id),
createdNodeIdsByMediaItemId: {},
updatedNodeIds: [],
createdEdgeIds: [],
};
}
return applyCreateAgentSlotOperation({
canvasData: params.canvasData,
nodes,
edges,
plan: params.plan,
});
}
if (params.plan.operation === "update_text") {
const textContent = String(params.plan.textContent || "").trim();
if (!textContent) {
throw new CanvasAgentMutationError("缺少文本内容");
}
const targetNodeIds = getOperationTargetNodeIds(params.plan);
if (targetNodeIds.length === 0) {
throw new CanvasAgentMutationError("缺少要更新的文本节点");
}
const now = new Date().toISOString();
const updatedNodeIds: string[] = [];
targetNodeIds.forEach((nodeId) => {
const node = findNode(nodes, nodeId);
if (!node) {
throw new CanvasAgentMutationError(`文本节点不存在:${nodeId}`);
}
if (node.type !== "text") {
throw new CanvasAgentMutationError("update_text 只能修改文本节点");
}
const data = (node.data || {}) as Record<string, any>;
node.data = {
...data,
content: textContent,
metadata: {
...(data.metadata || {}),
updatedByAgent: true,
canvasAgentOperationId: params.plan.operationId,
canvasAgentOperation: params.plan.operation,
previousContent:
typeof data.content === "string" ? String(data.content).slice(0, 2000) : undefined,
agentUpdatedAt: now,
},
};
node.updatedAt = now;
updatedNodeIds.push(node.id);
});
return {
canvasData: {
...params.canvasData,
nodes,
edges,
selectedNodeIds: updatedNodeIds,
},
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds,
createdEdgeIds: [],
};
}
if (params.plan.operation === "link_nodes") {
const nodeIds = getOperationTargetNodeIds(params.plan);
if (nodeIds.length < 2) {
throw new CanvasAgentMutationError("连线操作至少需要两个节点");
}
const missingNodeId = nodeIds.find((nodeId) => !findNode(nodes, nodeId));
if (missingNodeId) {
throw new CanvasAgentMutationError(`连线节点不存在:${missingNodeId}`);
}
const edgeType = normalizeEdgeType(params.plan.edgeType);
const [sourceNodeId, ...targetNodeIds] = nodeIds;
const createdEdgeIds: string[] = [];
targetNodeIds.forEach((targetNodeId) => {
const exists = edges.some((edge) => edgeMatches(edge, sourceNodeId, targetNodeId));
if (exists) return;
const edge = {
id: generateId(),
source: sourceNodeId,
target: targetNodeId,
type: edgeType,
};
edges.push(edge);
createdEdgeIds.push(edge.id);
});
rebuildConnections(nodes, edges);
return {
canvasData: {
...params.canvasData,
nodes,
edges,
selectedNodeIds: nodeIds,
},
createdNodeIds: [],
createdNodeIdsByMediaItemId: {},
updatedNodeIds: nodeIds,
createdEdgeIds,
};
}
if (existingOperationNodes.length > 0) {
const byMediaItemId: Record<string, string> = {};
existingOperationNodes.forEach((node) => {
const mediaItemId = String(
node?.data?.metadata?.canvasAgentMediaItemId || ""
).trim();
if (mediaItemId) byMediaItemId[mediaItemId] = node.id;
});
return {
canvasData: params.canvasData,
createdNodeIds: existingOperationNodes.map((node) => node.id),
createdNodeIdsByMediaItemId: byMediaItemId,
updatedNodeIds: [],
createdEdgeIds: [],
};
}
const mediaItems = normalizeMediaItems(params.plan);
if (mediaItems.length === 0) {
throw new CanvasAgentMutationError("缺少可写回的媒体结果");
}
const targetSlot =
params.plan.operation === "fill_slot"
? findNode(nodes, params.plan.targetSlotId)
: null;
if (params.plan.operation === "fill_slot" && !targetSlot) {
throw new CanvasAgentMutationError("目标 slot 不存在");
}
if (targetSlot && params.plan.operation === "fill_slot") {
const targetSlotData = (targetSlot.data || {}) as Record<string, unknown>;
const fillPolicy = normalizeSlotFillPolicy(targetSlotData.fillPolicy);
const filledNodeIds = Array.isArray(targetSlotData.filledNodeIds)
? targetSlotData.filledNodeIds.filter((id) => typeof id === "string")
: [];
if (fillPolicy === "replace-existing" && filledNodeIds.length > 0) {
throw new CanvasAgentMutationError(
"当前 slot 已有结果,替换已有结果需要用户确认",
409,
"CANVAS_AGENT_CONFIRMATION_REQUIRED"
);
}
if (fillPolicy === "replace-empty" && filledNodeIds.length > 0) {
throw new CanvasAgentMutationError(
"当前 slot 已被填充,请新建 slot 或使用追加版本",
409,
"CANVAS_AGENT_CONFLICT"
);
}
if (params.baseCanvasWasStale && fillPolicy === "replace-existing") {
throw new CanvasAgentMutationError(
"画布已更新,替换 slot 前需要刷新并确认",
409,
"STALE_CANVAS_REVISION"
);
}
}
const createdNodeIds: string[] = [];
const createdNodeIdsByMediaItemId: Record<string, string> = {};
const createdEdgeIds: string[] = [];
const updatedNodeIds: string[] = [];
const placements = resolveCanvasAgentMediaPlacements({
plan: params.plan,
mediaItems,
nodes,
targetSlot,
viewport: params.canvasData.viewport,
});
placements.forEach((placement) => {
const node = buildMediaNode({
item: placement.item,
plan: params.plan,
position: placement.position,
size: placement.size,
sourceNodeIds: placement.sourceNodeIds,
});
nodes.push(node);
createdNodeIds.push(node.id);
createdNodeIdsByMediaItemId[placement.item.mediaItemId] = node.id;
const edgeSource = placement.edgeSourceId;
if (edgeSource && edgeSource !== node.id) {
const edge = buildEdge(
edgeSource,
node.id,
params.plan.operation === "append_revision" ? "reference" : "default"
);
edges.push(edge);
createdEdgeIds.push(edge.id);
}
if (targetSlot && params.plan.operation === "fill_slot") {
const data = { ...(targetSlot.data || {}) };
const filledNodeIds = Array.isArray(data.filledNodeIds)
? data.filledNodeIds
: [];
data.status = "completed";
data.filledNodeIds = Array.from(new Set([...filledNodeIds, node.id]));
targetSlot.data = data;
if (!updatedNodeIds.includes(targetSlot.id)) {
updatedNodeIds.push(targetSlot.id);
}
}
});
rebuildConnections(nodes, edges);
return {
canvasData: {
...params.canvasData,
nodes,
edges,
selectedNodeIds: createdNodeIds,
},
createdNodeIds,
createdNodeIdsByMediaItemId,
updatedNodeIds,
createdEdgeIds,
};
}
export function applyCanvasOperationPlanToCanvasData(params: {
canvasData: CanvasDataLike;
plan: CanvasOperationPlan;
baseCanvasWasStale?: boolean;
}) {
return applyOperationToCanvasData(params);
}
export async function applyCanvasOperationPlan(params: {
userId: string;
projectId: string;
plan: CanvasOperationPlan;
}): Promise<CanvasOperationResult> {
if (params.plan.projectId && params.plan.projectId !== params.projectId) {
throw new CanvasAgentMutationError("operation projectId 与请求项目不一致");
}
const [project] = await db
.select()
.from(canvasProject)
.where(
and(
eq(canvasProject.id, params.projectId),
eq(canvasProject.userId, params.userId)
)
);
if (!project) {
throw new CanvasAgentMutationError("画布不存在", 404);
}
const currentCanvasData = normalizeCanvasData(project.canvasData);
const baseCanvasWasStale = isPlanBaseStale(params.plan, project.updatedAt);
const applied = applyOperationToCanvasData({
canvasData: currentCanvasData,
plan: {
...params.plan,
projectId: params.projectId,
},
baseCanvasWasStale,
});
const existingAudit = findCanvasAgentOperationAudit(
currentCanvasData,
params.plan.operationId
);
if (existingAudit) {
const currentRevision = project.updatedAt.toISOString();
return {
...applied,
canvasData: currentCanvasData,
canvasRevision: currentRevision,
canvasUpdatedAt: currentRevision,
baseCanvasWasStale,
};
}
const auditedCanvasData = appendCanvasAgentOperationAuditIfMissing({
canvasData: applied.canvasData,
plan: {
...params.plan,
projectId: params.projectId,
},
result: applied,
}) as CanvasDataLike;
const { syncCanvasNodesForProject } = await import("@/lib/db/queries");
const syncedCanvasData = await syncCanvasNodesForProject({
projectId: params.projectId,
userId: params.userId,
canvasData: auditedCanvasData,
});
const updatedAt = new Date();
const [updatedProject] = await db
.update(canvasProject)
.set({
canvasData: syncedCanvasData,
updatedAt,
})
.where(
and(
eq(canvasProject.id, params.projectId),
eq(canvasProject.userId, params.userId)
)
)
.returning();
return {
...applied,
canvasData: (updatedProject?.canvasData || syncedCanvasData) as CanvasDataLike,
canvasRevision: updatedAt.toISOString(),
canvasUpdatedAt: updatedAt.toISOString(),
baseCanvasWasStale,
};
}