ai-chatbot/lib/canvas/agent-explicit-operations.ts

308 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import type {
CanvasAgentContext,
CanvasOperationPlan,
} from "@/types/canvas/agent-protocol";
type ExplicitOperationKind = "create_text_node" | "rename_node" | "update_note";
export type CanvasAgentExplicitOperationPlan = {
operationPlan: CanvasOperationPlan;
reply: string;
progressTitle: string;
progressMessage: string;
completedTitle: string;
failedTitle: string;
};
function trimString(value: unknown, maxLength: number) {
return typeof value === "string" ? value.trim().slice(0, maxLength) : "";
}
function normalizeNodeIds(values: Array<string | undefined | null>, maxItems = 20) {
return Array.from(
new Set(values.map((value) => trimString(value, 160)).filter(Boolean))
).slice(0, maxItems);
}
function stripCommandContent(value: unknown, maxLength: number) {
return trimString(value, maxLength)
.replace(/^["'“”‘’]+|["'“”‘’。;;]+$/g, "")
.trim()
.slice(0, maxLength);
}
function getKnownNodeTypes(context: CanvasAgentContext) {
const nodeMap = new Map<string, string>();
[...context.candidateNodes, ...context.selectedNodes].forEach((node) => {
if (node.nodeId) nodeMap.set(node.nodeId, node.type);
});
context.slots.forEach((slot) => {
if (slot.nodeId) nodeMap.set(slot.nodeId, "agent-slot");
});
return nodeMap;
}
function getSelectedNodeIds(context: CanvasAgentContext) {
return normalizeNodeIds([
...context.selectedNodeIds,
...context.selectedNodes.map((node) => node.nodeId),
...context.slots
.filter((slot) => slot.isSelected || context.selectedNodeIds.includes(slot.nodeId))
.map((slot) => slot.nodeId),
]);
}
function getTargetNodeIds(params: {
context: CanvasAgentContext;
requestedNodeIds?: string[];
allowedTypes?: string[];
}) {
const knownNodeTypes = getKnownNodeTypes(params.context);
const allowedTypes = params.allowedTypes ? new Set(params.allowedTypes) : null;
return normalizeNodeIds([
...(params.requestedNodeIds || []),
...getSelectedNodeIds(params.context),
]).filter((nodeId) => {
const type = knownNodeTypes.get(nodeId);
if (!type) return false;
return allowedTypes ? allowedTypes.has(type) : true;
});
}
function getRenameAllowedTypes(userText: string) {
const lowerText = userText.toLowerCase();
if (/槽位|slot/.test(lowerText)) return ["agent-slot"];
if (/视频|video/.test(lowerText)) return ["video"];
if (/图片|图像|照片|主图|参考图|image/.test(lowerText)) return ["image"];
if (/文本|文字|文案|text/.test(lowerText)) return ["text"];
return undefined;
}
function hasMediaMutationIntent(userText: string) {
return /生成|出图|做一版|再出|换|改图|编辑图片|编辑图|抠图|去水印|高清|清晰|扩图|裁剪|删除|替换|背景|模型|洗图|重绘|尺寸|像素|分辨率/.test(
userText.toLowerCase()
);
}
function extractContentFromPatterns(userText: string, patterns: RegExp[], maxLength: number) {
for (const pattern of patterns) {
const match = userText.match(pattern);
const content = stripCommandContent(match?.[1] || "", maxLength);
if (content) return content;
}
return "";
}
function getViewportCenteredPosition(params: {
context: CanvasAgentContext;
size: { width: number; height: number };
}) {
const viewport = params.context.viewport || { x: 0, y: 0, zoom: 1 };
const zoom = Math.max(0.1, Number(viewport.zoom || 1));
const viewportWidth = Math.max(320, Number(viewport.width || 1200));
const viewportHeight = Math.max(320, Number(viewport.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 buildPlanBase(params: {
context: CanvasAgentContext;
projectId: string;
messageId: string;
chatId: string;
userText: string;
operation: ExplicitOperationKind;
}): Pick<
CanvasOperationPlan,
| "version"
| "operationId"
| "operation"
| "projectId"
| "baseCanvasRevision"
| "baseCanvasUpdatedAt"
| "preserveOriginal"
| "metadata"
> {
return {
version: 1,
operationId: `${params.messageId}:canvas-${params.operation}`,
operation: params.operation,
projectId: params.projectId,
baseCanvasRevision: params.context.canvasRevision,
baseCanvasUpdatedAt: params.context.canvasUpdatedAt,
preserveOriginal: true,
metadata: {
messageId: params.messageId,
chatId: params.chatId,
planner: "deterministic",
prompt: params.userText,
},
};
}
function buildCreateTextOperation(params: {
context: CanvasAgentContext;
projectId: string;
messageId: string;
chatId: string;
userText: string;
}): CanvasAgentExplicitOperationPlan | null {
const text = params.userText.trim();
const hasCreateTextIntent =
/(?:创建|新增|新建|添加|加一段|加一句|写一段|写一句|放一段|放一句|写入|写上|放上).*(?:文本|文字|文案|标题|标语|说明|caption|copy|text)|(?:文本|文字|文案|标题|标语|说明).*(?:创建|新增|新建|添加|写入|写上)/i.test(
text
);
if (!hasCreateTextIntent || hasMediaMutationIntent(text)) return null;
const textContent = extractContentFromPatterns(
text,
[
/(?:文本|文字|文案|标题|标语|说明|caption|copy|text)[^::]*[::]\s*([\s\S]+)$/i,
/(?:写入|写上|写成|内容是|设为|设置为)\s*[::]?\s*([\s\S]+)$/i,
/[::]\s*([\s\S]+)$/i,
],
4000
);
if (!textContent) return null;
const textSize = { width: 320, height: 120 };
const basePlan = buildPlanBase({ ...params, operation: "create_text_node" });
return {
operationPlan: {
...basePlan,
targetPosition: getViewportCenteredPosition({
context: params.context,
size: textSize,
}),
textContent,
metadata: {
...basePlan.metadata,
canvasAgentViewport: params.context.viewport,
},
},
reply: "已在当前画布添加文本节点。",
progressTitle: "正在添加画布文本",
progressMessage: "我会在当前视口中心创建一个新的文本节点。",
completedTitle: "画布文本已添加",
failedTitle: "画布文本添加失败",
};
}
function buildRenameOperation(params: {
context: CanvasAgentContext;
projectId: string;
messageId: string;
chatId: string;
userText: string;
requestedNodeIds?: string[];
}): CanvasAgentExplicitOperationPlan | null {
const text = params.userText.trim();
const hasRenameIntent = /改名|重命名|命名为|标题(?:改成|改为|设为|设置为)|改标题|修改标题/.test(
text
);
if (!hasRenameIntent || hasMediaMutationIntent(text)) return null;
const title = extractContentFromPatterns(
text,
[
/(?:改名为|重命名为|命名为|标题(?:改成|改为|设为|设置为)|改标题为|修改标题为)\s*[::]?\s*([\s\S]+)$/i,
/[::]\s*([\s\S]+)$/i,
],
120
);
if (!title) return null;
const targetNodeIds = getTargetNodeIds({
context: params.context,
requestedNodeIds: params.requestedNodeIds,
allowedTypes: getRenameAllowedTypes(text),
}).slice(0, 10);
if (targetNodeIds.length === 0) return null;
return {
operationPlan: {
...buildPlanBase({ ...params, operation: "rename_node" }),
targetNodeIds,
textContent: title,
},
reply: "已更新选中节点标题。",
progressTitle: "正在更新节点标题",
progressMessage: "我会只修改选中节点的标题,不改变媒体内容。",
completedTitle: "节点标题已更新",
failedTitle: "节点标题更新失败",
};
}
function buildUpdateNoteOperation(params: {
context: CanvasAgentContext;
projectId: string;
messageId: string;
chatId: string;
userText: string;
requestedNodeIds?: string[];
}): CanvasAgentExplicitOperationPlan | null {
const text = params.userText.trim();
if (!/备注|note/i.test(text)) return null;
if (/按.*(?:备注|note).*(?:修改|编辑|改图|改主图|生成|出图)/i.test(text)) {
return null;
}
if (hasMediaMutationIntent(text)) return null;
const note = extractContentFromPatterns(
text,
[
/(?:备注为|备注成|添加备注|加备注|写备注|备注|note)\s*[::]?\s*([\s\S]+)$/i,
/[::]\s*([\s\S]+)$/i,
],
1000
);
if (!note) return null;
const targetNodeIds = getTargetNodeIds({
context: params.context,
requestedNodeIds: params.requestedNodeIds,
allowedTypes: ["image", "video"],
}).slice(0, 10);
if (targetNodeIds.length === 0) return null;
return {
operationPlan: {
...buildPlanBase({ ...params, operation: "update_note" }),
targetNodeIds,
textContent: note,
},
reply: "已更新选中图片备注。",
progressTitle: "正在更新图片备注",
progressMessage: "我会把备注保存到选中的图片或视频节点。",
completedTitle: "图片备注已更新",
failedTitle: "图片备注更新失败",
};
}
export function buildCanvasAgentExplicitOperationPlan(params: {
context: CanvasAgentContext;
projectId: string;
messageId: string;
chatId: string;
userText: string;
requestedNodeIds?: string[];
}): CanvasAgentExplicitOperationPlan | null {
const commonParams = {
...params,
projectId: trimString(params.projectId, 160),
messageId: trimString(params.messageId, 160),
chatId: trimString(params.chatId, 160),
};
if (!commonParams.projectId || !commonParams.messageId || !commonParams.chatId) {
return null;
}
return (
buildCreateTextOperation(commonParams) ||
buildRenameOperation(commonParams) ||
buildUpdateNoteOperation(commonParams)
);
}