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

505 lines
16 KiB
TypeScript
Raw Permalink 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";
import {
buildCanvasAgentSlotCreationPlan,
resolveCanvasAgentSlotCreationIntent,
} from "@/lib/canvas/agent-model-planner";
type ExplicitOperationKind =
| "add_annotation"
| "create_agent_slot"
| "create_text_node"
| "link_nodes"
| "rename_node"
| "update_text"
| "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 hasImageTextEditIntent(userText: string) {
return /(?:图片|图像|照片|主图|海报|画面|图中|图里|图片里|图片上|海报上).*(?:文字|文本|文案|字).*(?:改|换|替换|编辑|修改|去掉|删除)|(?:文字|文本|文案|字).*(?:图片|图像|照片|主图|海报|画面|图中|图里|图片里|图片上|海报上).*(?:改|换|替换|编辑|修改|去掉|删除)/.test(
userText
);
}
function buildUpdateTextOperation(params: {
context: CanvasAgentContext;
projectId: string;
messageId: string;
chatId: string;
userText: string;
requestedNodeIds?: string[];
}): CanvasAgentExplicitOperationPlan | null {
const text = params.userText.trim();
const hasUpdateTextIntent =
/(?:选中|当前|这个|这段|该).*(?:文本|文字|文案|标语|说明|copy|text).*(?:改成|改为|修改为|更新为|设为|设置为|换成)|(?:文本|文字|文案|标语|说明|copy|text).*(?:改成|改为|修改为|更新为|设为|设置为|换成)/i.test(
text
);
if (!hasUpdateTextIntent || hasMediaMutationIntent(text)) return null;
if (hasImageTextEditIntent(text)) return null;
const textContent = extractContentFromPatterns(
text,
[
/(?:改成|改为|修改为|更新为|设为|设置为|换成)\s*[::]?\s*([\s\S]+)$/i,
/(?:文本|文字|文案|标语|说明|copy|text)[^::]*(?:改成|改为|修改为|更新为|设为|设置为|换成)\s*[::]?\s*([\s\S]+)$/i,
/[::]\s*([\s\S]+)$/i,
],
4000
);
if (!textContent) return null;
const targetNodeIds = getTargetNodeIds({
context: params.context,
requestedNodeIds: params.requestedNodeIds,
allowedTypes: ["text"],
}).slice(0, 10);
if (targetNodeIds.length === 0) return null;
return {
operationPlan: {
...buildPlanBase({ ...params, operation: "update_text" }),
targetNodeIds,
textContent,
},
reply: "已更新选中文本内容。",
progressTitle: "正在更新画布文本",
progressMessage: "我会只修改选中文本节点的内容,不移动画布节点。",
completedTitle: "画布文本已更新",
failedTitle: "画布文本更新失败",
};
}
function buildLinkNodesOperation(params: {
context: CanvasAgentContext;
projectId: string;
messageId: string;
chatId: string;
userText: string;
requestedNodeIds?: string[];
}): CanvasAgentExplicitOperationPlan | null {
const text = params.userText.trim();
const hasLinkIntent = /连起来|连线|连接|关联|参考关系|版本关系|建立关系/.test(text);
if (!hasLinkIntent || hasMediaMutationIntent(text)) return null;
const targetNodeIds = getTargetNodeIds({
context: params.context,
requestedNodeIds: params.requestedNodeIds,
}).slice(0, 20);
if (targetNodeIds.length < 2) return null;
return {
operationPlan: {
...buildPlanBase({ ...params, operation: "link_nodes" }),
sourceNodeIds: targetNodeIds,
edgeType: "reference",
},
reply: "已为选中的画布节点建立参考连线。",
progressTitle: "正在连接画布节点",
progressMessage: "我会给选中的画布节点建立参考关系,不修改节点内容。",
completedTitle: "画布节点已连接",
failedTitle: "画布节点连接失败",
};
}
function getSlotTypeLabel(slotType: "image" | "video" | "ui-screen") {
if (slotType === "video") return "视频";
if (slotType === "ui-screen") return "UI";
return "图片";
}
function buildCreateAgentSlotOperation(params: {
context: CanvasAgentContext;
projectId: string;
messageId: string;
chatId: string;
userText: string;
}): CanvasAgentExplicitOperationPlan | null {
const intent = resolveCanvasAgentSlotCreationIntent({
userText: params.userText,
});
if (!intent.shouldCreate || intent.combinedWithMediaExecution) return null;
const operationPlan = buildCanvasAgentSlotCreationPlan({
...params,
slotType: intent.slotType,
slotLabel: intent.label,
aspectRatio: intent.aspectRatio,
});
if (!operationPlan) return null;
const slotTypeLabel = getSlotTypeLabel(intent.slotType);
return {
operationPlan: {
...operationPlan,
metadata: {
...(operationPlan.metadata || {}),
planner: "deterministic",
},
},
reply: `已在当前画布创建${slotTypeLabel}槽位。`,
progressTitle: "正在创建画布槽位",
progressMessage: `我会在当前视口中心创建一个空${slotTypeLabel}槽位。`,
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: "图片备注更新失败",
};
}
function buildAddAnnotationOperation(params: {
context: CanvasAgentContext;
projectId: string;
messageId: string;
chatId: string;
userText: string;
requestedNodeIds?: string[];
}): CanvasAgentExplicitOperationPlan | null {
const text = params.userText.trim();
if (!/批注|标注|箭头|框选/i.test(text)) return null;
if (/按.*(?:批注|标注|箭头|框选).*(?:修改|编辑|改图|改主图|生成|出图|修图)/i.test(text)) {
return null;
}
if (hasMediaMutationIntent(text)) return null;
const annotationText = extractContentFromPatterns(
text,
[
/(?:添加批注|加批注|写批注|批注为|批注成|批注|添加标注|加标注|写标注|标注为|标注成|标注|箭头说明|框选说明)\s*[::]?\s*([\s\S]+)$/i,
/[::]\s*([\s\S]+)$/i,
],
1000
);
if (!annotationText) 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: "add_annotation" }),
targetNodeIds,
textContent: annotationText,
annotations: [
{
type: "text-note",
text: annotationText,
color: "#f59e0b",
},
],
},
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 (
buildCreateAgentSlotOperation(commonParams) ||
buildCreateTextOperation(commonParams) ||
buildUpdateTextOperation(commonParams) ||
buildLinkNodesOperation(commonParams) ||
buildRenameOperation(commonParams) ||
buildUpdateNoteOperation(commonParams) ||
buildAddAnnotationOperation(commonParams)
);
}