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