1719 lines
51 KiB
TypeScript
1719 lines
51 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) {
|
||
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,
|
||
};
|
||
}
|