505 lines
16 KiB
TypeScript
505 lines
16 KiB
TypeScript
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)
|
||
);
|
||
}
|