308 lines
9.5 KiB
TypeScript
308 lines
9.5 KiB
TypeScript
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)
|
||
);
|
||
}
|