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

663 lines
19 KiB
TypeScript

import "server-only";
import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { canvasProject } from "@/lib/db/schema";
import { syncCanvasNodesForProject } from "@/lib/db/queries";
import { resolveCanvasAgentMediaPlacements } from "@/lib/canvas/agent-placement";
import type {
CanvasMediaItem,
CanvasOperationPlan,
} from "@/types/canvas/agent-protocol";
type CanvasDataLike = {
nodes?: any[];
edges?: any[];
selectedNodeIds?: string[];
viewport?: { x: number; y: number; zoom: number };
[key: string]: unknown;
};
export type CanvasOperationResult = {
createdNodeIds: string[];
createdNodeIdsByMediaItemId: Record<string, string>;
updatedNodeIds: string[];
createdEdgeIds: string[];
canvasData: CanvasDataLike;
canvasRevision: string;
canvasUpdatedAt: string;
baseCanvasWasStale: boolean;
};
export class CanvasAgentMutationError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.name = "CanvasAgentMutationError";
this.status = status;
}
}
function generateId() {
return crypto.randomUUID();
}
function normalizeCanvasData(value: unknown): CanvasDataLike {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
};
}
const raw = value as CanvasDataLike;
return {
...raw,
nodes: Array.isArray(raw.nodes) ? [...raw.nodes] : [],
edges: Array.isArray(raw.edges) ? [...raw.edges] : [],
selectedNodeIds: Array.isArray(raw.selectedNodeIds)
? raw.selectedNodeIds.filter((id): id is string => typeof id === "string")
: [],
viewport: raw.viewport || { x: 0, y: 0, zoom: 1 },
};
}
function normalizeMediaItems(plan: CanvasOperationPlan): CanvasMediaItem[] {
if (Array.isArray(plan.mediaItems) && plan.mediaItems.length > 0) {
return plan.mediaItems
.map((item, index) => ({
...item,
mediaItemId:
typeof item.mediaItemId === "string" && item.mediaItemId.trim()
? item.mediaItemId.trim()
: `${plan.operationId}:media-${index + 1}`,
url: typeof item.url === "string" ? item.url.trim() : "",
}))
.filter((item) => item.url);
}
if (plan.media?.url) {
return [
{
...plan.media,
mediaItemId: `${plan.operationId}:media-1`,
url: plan.media.url.trim(),
},
];
}
return [];
}
function findNode(nodes: any[], nodeId?: string) {
if (!nodeId) return null;
return nodes.find((node) => node?.id === nodeId) || null;
}
function mediaNodeType(item: CanvasMediaItem) {
return item.type === "video" ? "video" : "image";
}
function buildMediaNode(params: {
item: CanvasMediaItem;
plan: CanvasOperationPlan;
position: { x: number; y: number };
size: { width: number; height: number };
sourceNodeIds: string[];
}) {
const now = new Date().toISOString();
const type = mediaNodeType(params.item);
const metadata = {
...(params.item.metadata || {}),
generatedByAgent: true,
canvasAgentOperationId: params.plan.operationId,
canvasAgentOperation: params.plan.operation,
canvasAgentMediaItemId: params.item.mediaItemId,
canvasAgentPlacement: params.plan.placement,
canvasAgentPlacementStrategy: params.plan.placementStrategy,
sourceNodeIds: params.sourceNodeIds,
revisionOf:
params.plan.operation === "append_revision"
? params.sourceNodeIds[0] || null
: undefined,
};
return {
id: generateId(),
type,
position: params.position,
size: params.size,
data:
type === "video"
? {
src: params.item.url,
assetId: params.item.assetId,
status: "completed",
prompt: params.item.prompt,
title: params.item.title || "Agent 视频结果",
metadata,
}
: {
src: params.item.url,
assetId: params.item.assetId,
status: "completed",
prompt: params.item.prompt,
title: params.item.title || "Agent 图片结果",
metadata,
},
connections: params.sourceNodeIds,
createdAt: now,
updatedAt: now,
};
}
function buildEdge(source: string, target: string, type: "default" | "reference") {
return {
id: generateId(),
source,
target,
type,
};
}
function rebuildConnections(nodes: any[], edges: any[]) {
const connectionMap = new Map<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 normalizeEdgeType(value: unknown): "default" | "reference" | "conversation" {
return value === "default" || value === "conversation" ? value : "reference";
}
function edgeMatches(edge: any, source: string, target: string) {
return (
(edge?.source === source && edge?.target === target) ||
(edge?.source === target && edge?.target === source)
);
}
function getOperationTargetNodeIds(plan: CanvasOperationPlan) {
return Array.from(
new Set(
[
plan.anchorNodeId || "",
...(plan.targetNodeIds || []),
...(plan.sourceNodeIds || []),
].filter(Boolean)
)
);
}
function getNodeSize(node: any) {
return {
width: Math.max(1, Number(node?.size?.width || 220) || 220),
height: Math.max(1, Number(node?.size?.height || 160) || 160),
};
}
function getNodePosition(node: any) {
return {
x: Number(node?.position?.x || 0) || 0,
y: Number(node?.position?.y || 0) || 0,
};
}
function isConfirmedHighRiskPlan(plan: CanvasOperationPlan) {
const metadata = (plan.metadata || {}) as Record<string, unknown>;
return plan.confirmed === true || metadata.confirmedByUser === true;
}
function normalizeLayoutStrategy(value: unknown): "row" | "column" | "grid" {
return value === "column" || value === "grid" ? value : "row";
}
function resolveMoveStartPosition(nodes: any[], plan: CanvasOperationPlan) {
const target = plan.targetPosition;
if (
target &&
Number.isFinite(Number(target.x)) &&
Number.isFinite(Number(target.y))
) {
return {
x: Number(target.x),
y: Number(target.y),
};
}
const positions = nodes.map(getNodePosition);
return {
x: Math.min(...positions.map((position) => position.x)),
y: Math.min(...positions.map((position) => position.y)),
};
}
function applyMoveNodesOperation(params: {
canvasData: CanvasDataLike;
nodes: any[];
edges: any[];
plan: CanvasOperationPlan;
}) {
if (!isConfirmedHighRiskPlan(params.plan)) {
throw new CanvasAgentMutationError("移动画布节点需要用户确认", 409);
}
const targetNodeIds = getOperationTargetNodeIds(params.plan);
if (targetNodeIds.length === 0) {
throw new CanvasAgentMutationError("缺少要移动的节点");
}
const targetNodes = targetNodeIds.map((nodeId) => findNode(params.nodes, nodeId));
const missingIndex = targetNodes.findIndex((node) => !node);
if (missingIndex >= 0) {
throw new CanvasAgentMutationError(`移动节点不存在:${targetNodeIds[missingIndex]}`);
}
const orderedNodes = (targetNodes as any[]).sort((a, b) => {
const posA = getNodePosition(a);
const posB = getNodePosition(b);
return posA.y === posB.y ? posA.x - posB.x : posA.y - posB.y;
});
const spacing = Math.max(8, Math.min(240, Number(params.plan.spacing || 40) || 40));
const layoutStrategy = normalizeLayoutStrategy(params.plan.layoutStrategy);
const start = resolveMoveStartPosition(orderedNodes, params.plan);
const sizes = orderedNodes.map(getNodeSize);
const maxWidth = Math.max(...sizes.map((size) => size.width));
const maxHeight = Math.max(...sizes.map((size) => size.height));
const now = new Date().toISOString();
orderedNodes.forEach((node, index) => {
let position = { x: start.x, y: start.y };
if (layoutStrategy === "column") {
const previousHeights = sizes
.slice(0, index)
.reduce((sum, size) => sum + size.height + spacing, 0);
position = { x: start.x, y: start.y + previousHeights };
} else if (layoutStrategy === "grid") {
const cols = Math.max(1, Math.ceil(Math.sqrt(orderedNodes.length)));
const col = index % cols;
const row = Math.floor(index / cols);
position = {
x: start.x + col * (maxWidth + spacing),
y: start.y + row * (maxHeight + spacing),
};
} else {
const previousWidths = sizes
.slice(0, index)
.reduce((sum, size) => sum + size.width + spacing, 0);
position = { x: start.x + previousWidths, y: start.y };
}
const previousPosition = getNodePosition(node);
const data = (node.data || {}) as Record<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 applyOperationToCanvasData(params: {
canvasData: CanvasDataLike;
plan: CanvasOperationPlan;
baseCanvasWasStale?: boolean;
}) {
if (!params.plan.operationId) {
throw new CanvasAgentMutationError("缺少 operationId");
}
if (
params.plan.operation !== "insert_beside" &&
params.plan.operation !== "append_revision" &&
params.plan.operation !== "fill_slot" &&
params.plan.operation !== "link_nodes" &&
params.plan.operation !== "move_nodes" &&
params.plan.operation !== "update_text"
) {
throw new CanvasAgentMutationError("当前仅支持非破坏式画布写回操作");
}
const nodes = [...(params.canvasData.nodes || [])];
const edges = [...(params.canvasData.edges || [])];
if (params.plan.operation === "move_nodes") {
return applyMoveNodesOperation({
canvasData: params.canvasData,
nodes,
edges,
plan: params.plan,
});
}
if (params.plan.operation === "update_text") {
const textContent = String(params.plan.textContent || "").trim();
if (!textContent) {
throw new CanvasAgentMutationError("缺少文本内容");
}
const targetNodeIds = getOperationTargetNodeIds(params.plan);
if (targetNodeIds.length === 0) {
throw new CanvasAgentMutationError("缺少要更新的文本节点");
}
const now = new Date().toISOString();
const updatedNodeIds: string[] = [];
targetNodeIds.forEach((nodeId) => {
const node = findNode(nodes, nodeId);
if (!node) {
throw new CanvasAgentMutationError(`文本节点不存在:${nodeId}`);
}
if (node.type !== "text") {
throw new CanvasAgentMutationError("update_text 只能修改文本节点");
}
const data = (node.data || {}) as Record<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,
};
}
const existingOperationNodes = findExistingOperationNodes(
nodes,
params.plan.operationId
);
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
);
}
if (fillPolicy === "replace-empty" && filledNodeIds.length > 0) {
throw new CanvasAgentMutationError("当前 slot 已被填充,请新建 slot 或使用追加版本", 409);
}
if (params.baseCanvasWasStale && fillPolicy === "replace-existing") {
throw new CanvasAgentMutationError(
"画布已更新,替换 slot 前需要刷新并确认",
409
);
}
}
const createdNodeIds: string[] = [];
const createdNodeIdsByMediaItemId: Record<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 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 syncedCanvasData = await syncCanvasNodesForProject({
projectId: params.projectId,
userId: params.userId,
canvasData: applied.canvasData,
});
const updatedAt = new Date();
const [updatedProject] = await db
.update(canvasProject)
.set({
canvasData: syncedCanvasData,
updatedAt,
})
.where(
and(
eq(canvasProject.id, params.projectId),
eq(canvasProject.userId, params.userId)
)
)
.returning();
return {
...applied,
canvasData: (updatedProject?.canvasData || syncedCanvasData) as CanvasDataLike,
canvasRevision: updatedAt.toISOString(),
canvasUpdatedAt: updatedAt.toISOString(),
baseCanvasWasStale,
};
}