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

1757 lines
52 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 { 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<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) {
if (item.type === "video") return "video";
if (item.type === "model3d") return "model3d";
if (item.type === "world3d") return "world3d";
return "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 modelVersion = String(
itemMetadata.modelVersion || planMetadata.modelVersion || ""
).trim();
const taskStatus =
itemMetadata.status === "completed" || itemMetadata.status === "failed"
? itemMetadata.status
: "generating";
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,
};
let data: Record<string, unknown>;
if (type === "video") {
data = {
src: params.item.url,
assetId: params.item.assetId,
status: "completed",
prompt: params.item.prompt,
title: params.item.title || "Agent 视频结果",
metadata,
};
} else if (type === "model3d") {
data = {
src: String(itemMetadata.modelUrl || "").trim(),
previewImage: params.item.url,
prompt: params.item.prompt,
title: params.item.title || "Agent 3D 模型结果",
modelVersion: modelVersion || "tripo-standard",
format: String(itemMetadata.format || "glb"),
status: taskStatus,
metadata,
};
} else if (type === "world3d") {
data = {
imageUrl: params.item.url,
previewImage: params.item.url,
prompt: params.item.prompt,
title: params.item.title || "Agent 3D 世界结果",
modelVersion: modelVersion || "world-standard",
status: taskStatus,
activeView: "image",
firstPerson: false,
hotspots: [],
metadata,
};
} else {
data = {
src: params.item.url,
assetId: params.item.assetId,
status: "completed",
prompt: params.item.prompt,
title: params.item.title || "Agent 图片结果",
metadata,
};
}
return {
id: generateId(),
type,
position: params.position,
size: params.size,
data,
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 isMediaItemCompatibleWithAgentSlot(params: {
slotType: "image" | "video" | "ui-screen";
mediaType: CanvasMediaItem["type"];
}) {
if (params.slotType === "video") return params.mediaType === "video";
return params.mediaType === "image";
}
function assertMediaItemsCompatibleWithAgentSlot(params: {
targetSlot: any;
mediaItems: CanvasMediaItem[];
}) {
const targetSlotData = (params.targetSlot?.data || {}) as Record<string, unknown>;
const slotType = normalizeAgentSlotType(targetSlotData.slotType);
const mismatchedItem = params.mediaItems.find(
(item) =>
!isMediaItemCompatibleWithAgentSlot({
slotType,
mediaType: item.type,
})
);
if (!mismatchedItem) return;
const expectedLabel = slotType === "video" ? "视频" : "图片";
const actualLabel = mismatchedItem.type === "video" ? "视频" : "图片";
throw new CanvasAgentMutationError(
`目标 slot 是${expectedLabel}槽位,不能填充${actualLabel}结果`,
400,
"CANVAS_AGENT_SLOT_MEDIA_TYPE_MISMATCH"
);
}
function getCanvasViewportCenterPosition(params: {
canvasData: CanvasDataLike;
size: { width: number; height: number };
}) {
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 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<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 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<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 hasExistingOperationAnnotation = existingAnnotations.some(
(item) =>
(item as Record<string, unknown>).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<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 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<string, any>;
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<string, any>;
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<string, unknown>).id === noteSync.noteAnnotationId
) {
return {
...(annotation as Record<string, unknown>),
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<string, any>;
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<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("缺少可写回的媒体结果");
}
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<string, unknown>;
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<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,
});
if (replacedSlotNodeIds.length > 0) {
const nodeData = (node.data || {}) as Record<string, any>;
(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<string, unknown>),
...(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<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,
};
}