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

1719 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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) {
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<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,
};
}