import "server-only"; import { and, eq } from "drizzle-orm"; import { db } from "@/lib/db"; import { canvasProject } from "@/lib/db/schema"; import { syncCanvasNodesForProject } from "@/lib/db/queries"; import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement"; import type { CanvasMediaItem, CanvasOperationPlan, } from "@/types/canvas/agent-protocol"; 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; constructor(message: string, status = 400) { super(message); this.name = "CanvasAgentMutationError"; this.status = status; } } 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>(); 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 normalizeEdgeType(value: unknown): "default" | "reference" | "conversation" { return value === "default" || value === "conversation" ? value : "reference"; } 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 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; return plan.confirmed === true || metadata.confirmedByUser === true; } 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); } 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 applyOperationToCanvasData(params: { canvasData: CanvasDataLike; plan: CanvasOperationPlan; baseCanvasWasStale?: boolean; }) { if (!params.plan.operationId) { throw new CanvasAgentMutationError("缺少 operationId"); } if ( params.plan.operation !== "insert_beside" && params.plan.operation !== "append_revision" && params.plan.operation !== "fill_slot" && params.plan.operation !== "link_nodes" && params.plan.operation !== "move_nodes" && params.plan.operation !== "update_text" ) { throw new CanvasAgentMutationError("当前仅支持非破坏式画布写回操作"); } const nodes = [...(params.canvasData.nodes || [])]; const edges = [...(params.canvasData.edges || [])]; if (params.plan.operation === "move_nodes") { return applyMoveNodesOperation({ 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; 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, }; } const existingOperationNodes = findExistingOperationNodes( nodes, params.plan.operationId ); 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("缺少可写回的媒体结果"); } 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; 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 ); } if (fillPolicy === "replace-empty" && filledNodeIds.length > 0) { throw new CanvasAgentMutationError("当前 slot 已被填充,请新建 slot 或使用追加版本", 409); } if (params.baseCanvasWasStale && fillPolicy === "replace-existing") { throw new CanvasAgentMutationError( "画布已更新,替换 slot 前需要刷新并确认", 409 ); } } 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, }); 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 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 syncedCanvasData = await syncCanvasNodesForProject({ projectId: params.projectId, userId: params.userId, canvasData: applied.canvasData, }); 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, }; }