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 { syncCanvasNodeNoteAnnotationMetadata } from "@/lib/canvas/agent-annotation-metadata"; 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; 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 itemMetadata = params.item.metadata && typeof params.item.metadata === "object" && !Array.isArray(params.item.metadata) ? params.item.metadata : {}; const planMetadata = params.plan.metadata && typeof params.plan.metadata === "object" && !Array.isArray(params.plan.metadata) ? params.plan.metadata : {}; const sourceMessageId = String( itemMetadata.messageId || planMetadata.messageId || "" ).trim(); const sourcePrompt = String( params.item.prompt || itemMetadata.prompt || planMetadata.prompt || "" ).trim(); const sourceModel = String(itemMetadata.model || planMetadata.model || "").trim(); const primarySourceNodeId = String( itemMetadata.sourceNodeId || planMetadata.sourceNodeId || params.sourceNodeIds[0] || "" ).trim(); const metadata = { ...planMetadata, ...itemMetadata, generatedByAgent: true, canvasAgentOperationId: params.plan.operationId, canvasAgentOperation: params.plan.operation, canvasAgentMediaItemId: params.item.mediaItemId, canvasAgentPlacement: params.plan.placement, canvasAgentPlacementStrategy: params.plan.placementStrategy, messageId: sourceMessageId || undefined, prompt: sourcePrompt || undefined, model: sourceModel || undefined, sourceNodeId: primarySourceNodeId || undefined, sourceMessageId: sourceMessageId || undefined, sourceImageKey: type === "image" && sourceMessageId ? params.item.url : undefined, 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>(); 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 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 }; }) { 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; 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; 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 assertCanvasAgentReferencedNodesExist(params: { nodes: any[]; plan: CanvasOperationPlan; mediaItems?: CanvasMediaItem[]; }) { if (params.plan.anchorNodeId && !findNode(params.nodes, params.plan.anchorNodeId)) { throw new CanvasAgentMutationError( `锚点节点不存在:${params.plan.anchorNodeId}` ); } const missingSourceNodeId = (params.plan.sourceNodeIds || []).find( (nodeId) => !findNode(params.nodes, nodeId) ); if (missingSourceNodeId) { throw new CanvasAgentMutationError(`来源节点不存在:${missingSourceNodeId}`); } const missingMediaSourceNodeId = (params.mediaItems || []) .flatMap((item) => item.sourceNodeIds || []) .find((nodeId) => !findNode(params.nodes, nodeId)); if (missingMediaSourceNodeId) { throw new CanvasAgentMutationError( `媒体来源节点不存在:${missingMediaSourceNodeId}` ); } } function normalizeCanvasMetadata(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Record) } : {}; } 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 removeCanvasNodesAndConnectedEdges(params: { nodes: any[]; edges: any[]; nodeIds: string[]; }) { const deleteSet = new Set(params.nodeIds.filter(Boolean)); if (deleteSet.size === 0) return []; const removedNodeIds = params.nodes .filter((node) => deleteSet.has(node?.id)) .map((node) => node.id); if (removedNodeIds.length === 0) return []; for (let index = params.nodes.length - 1; index >= 0; index -= 1) { if (deleteSet.has(params.nodes[index]?.id)) { params.nodes.splice(index, 1); } } for (let index = params.edges.length - 1; index >= 0; index -= 1) { const edge = params.edges[index]; if (deleteSet.has(edge?.source) || deleteSet.has(edge?.target)) { params.edges.splice(index, 1); } } return removedNodeIds; } function isConfirmedHighRiskPlan(plan: CanvasOperationPlan) { const metadata = (plan.metadata || {}) as Record; 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; 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; 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 = {}; 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; 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; 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 => 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; const metadata = normalizeCanvasMetadata(data.metadata); const existingAnnotations = Array.isArray(metadata.annotations) ? metadata.annotations.filter((item) => item && typeof item === "object") : []; const hasExistingOperationAnnotation = existingAnnotations.some( (item) => (item as Record).canvasAgentOperationId === params.plan.operationId ); if (hasExistingOperationAnnotation) return; 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; const slotSpec = (params.plan.slot || {}) as Record; 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 getTextNodeSize(textContent: string) { const lines = textContent.split(/\r?\n/); const longestLineLength = Math.max( 1, ...lines.map((line) => Array.from(line).length) ); return { width: Math.min(520, Math.max(260, longestLineLength * 10 + 48)), height: Math.min(280, Math.max(96, lines.length * 30 + 56)), }; } function applyCreateTextNodeOperation(params: { canvasData: CanvasDataLike; nodes: any[]; edges: any[]; plan: CanvasOperationPlan; }) { const textContent = String(params.plan.textContent || "").trim().slice(0, 4000); if (!textContent) { throw new CanvasAgentMutationError("缺少文本内容"); } const now = new Date().toISOString(); const size = getTextNodeSize(textContent); 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, }); const node = { id: generateId(), type: "text", position: targetPosition, size, data: { content: textContent, fontSize: 18, color: "#111827", lineHeight: 1.4, 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 !== "create_text_node" && 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 !== "rename_node" && params.plan.operation !== "update_note" && 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" || params.plan.operation === "rename_node" || params.plan.operation === "update_note" || params.plan.operation === "update_text") ) { 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 === "create_text_node") { if (existingOperationNodes.length > 0) { return { canvasData: params.canvasData, createdNodeIds: existingOperationNodes.map((node) => node.id), createdNodeIdsByMediaItemId: {}, updatedNodeIds: [], createdEdgeIds: [], }; } return applyCreateTextNodeOperation({ canvasData: params.canvasData, nodes, edges, plan: params.plan, }); } if (params.plan.operation === "rename_node") { const nextTitle = String(params.plan.textContent || "").trim().slice(0, 120); if (!nextTitle) { 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}`); } const data = (node.data || {}) as Record; const previousTitle = typeof data.title === "string" ? data.title : typeof data.label === "string" ? data.label : undefined; node.data = { ...data, title: nextTitle, label: node.type === "agent-slot" ? nextTitle : data.label, metadata: { ...(data.metadata || {}), renamedByAgent: true, canvasAgentOperationId: params.plan.operationId, canvasAgentOperation: params.plan.operation, previousTitle, agentRenamedAt: now, }, }; node.updatedAt = now; updatedNodeIds.push(node.id); }); return { canvasData: { ...params.canvasData, nodes, edges, selectedNodeIds: updatedNodeIds, }, createdNodeIds: [], createdNodeIdsByMediaItemId: {}, updatedNodeIds, createdEdgeIds: [], }; } if (params.plan.operation === "update_note") { const note = String(params.plan.textContent || "").trim().slice(0, 1000); if (!note) { 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 !== "image" && node.type !== "video") { throw new CanvasAgentMutationError("update_note 只能更新图片或视频节点备注"); } const data = (node.data || {}) as Record; const previousNote = typeof data.note === "string" ? String(data.note).slice(0, 1000) : undefined; const noteSync = syncCanvasNodeNoteAnnotationMetadata({ metadata: data.metadata, nodeId: node.id, note, now, }); const syncedMetadata = normalizeCanvasMetadata(noteSync.metadata); const annotations = Array.isArray(syncedMetadata.annotations) ? syncedMetadata.annotations.map((annotation) => { if ( annotation && typeof annotation === "object" && (annotation as Record).id === noteSync.noteAnnotationId ) { return { ...(annotation as Record), createdByAgent: true, updatedByAgent: true, canvasAgentOperationId: params.plan.operationId, }; } return annotation; }) : []; node.data = { ...data, note, hasAnnotations: noteSync.hasStructuredAnnotations || data.hasAnnotations, metadata: { ...syncedMetadata, ...(params.plan.metadata || {}), annotations, noteUpdatedByAgent: true, canvasAgentOperationId: params.plan.operationId, canvasAgentOperation: params.plan.operation, previousNote, agentNoteUpdatedAt: now, }, }; node.updatedAt = now; updatedNodeIds.push(node.id); }); return { canvasData: { ...params.canvasData, nodes, edges, selectedNodeIds: updatedNodeIds, }, createdNodeIds: [], createdNodeIdsByMediaItemId: {}, updatedNodeIds, createdEdgeIds: [], }; } 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; node.data = { ...data, content: textContent, metadata: { ...(data.metadata || {}), ...(params.plan.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 = {}; 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("缺少可写回的媒体结果"); } assertCanvasAgentReferencedNodesExist({ nodes, plan: params.plan, mediaItems, }); const targetSlot = params.plan.operation === "fill_slot" ? findNode(nodes, params.plan.targetSlotId) : null; if (params.plan.operation === "fill_slot" && !targetSlot) { throw new CanvasAgentMutationError("目标 slot 不存在"); } let replacedSlotNodeIds: string[] = []; if (targetSlot && params.plan.operation === "fill_slot") { const targetSlotData = (targetSlot.data || {}) as Record; const fillPolicy = normalizeSlotFillPolicy(targetSlotData.fillPolicy); const filledNodeIds = Array.isArray(targetSlotData.filledNodeIds) ? targetSlotData.filledNodeIds.filter((id) => typeof id === "string") : []; assertMediaItemsCompatibleWithAgentSlot({ targetSlot, mediaItems, }); if (params.baseCanvasWasStale && fillPolicy === "replace-existing") { throw new CanvasAgentMutationError( "画布已更新,替换 slot 前需要刷新并确认", 409, "STALE_CANVAS_REVISION" ); } if (fillPolicy === "replace-existing" && filledNodeIds.length > 0) { if (!isConfirmedHighRiskPlan(params.plan)) { throw new CanvasAgentMutationError( "当前 slot 已有结果,替换已有结果需要用户确认", 409, "CANVAS_AGENT_CONFIRMATION_REQUIRED" ); } replacedSlotNodeIds = removeCanvasNodesAndConnectedEdges({ nodes, edges, nodeIds: filledNodeIds, }); } if (fillPolicy === "replace-empty" && filledNodeIds.length > 0) { throw new CanvasAgentMutationError( "当前 slot 已被填充,请新建 slot 或使用追加版本", 409, "CANVAS_AGENT_CONFLICT" ); } } const createdNodeIds: string[] = []; const createdNodeIdsByMediaItemId: Record = {}; 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, }); if (replacedSlotNodeIds.length > 0) { const nodeData = (node.data || {}) as Record; (node as any).data = { ...nodeData, metadata: { ...(nodeData.metadata || {}), replacedSlotNodeIds, replacedSlotId: targetSlot?.id, }, }; } 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 = replacedSlotNodeIds.length > 0 ? Array.from(new Set(createdNodeIds)) : Array.from(new Set([...filledNodeIds, node.id])); data.metadata = { ...((data.metadata || {}) as Record), ...(replacedSlotNodeIds.length > 0 ? { replacedSlotNodeIds, replacedByCanvasAgentOperationId: params.plan.operationId, replacedAt: new Date().toISOString(), } : {}), }; 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 { 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, }; }