feat(canvas): restructure source directory for Canvas Agent, enhancing code maintainability
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import type { ToolName } from "./schemas.js";
|
||||
import { nextCanvasX } from "./tools.js";
|
||||
import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
|
||||
|
||||
export type CanvasToolRequest = { name: "canvas_apply_ops"; input: Record<string, unknown> };
|
||||
|
||||
/** 将上层画布工具调用转换为前端可执行的批量操作。 */
|
||||
export function buildCanvasToolRequest(name: ToolName, input: Record<string, unknown>, state: CanvasSnapshot | null): CanvasToolRequest {
|
||||
if (name === "canvas_apply_ops") return { name, input };
|
||||
if (name === "canvas_create_node") {
|
||||
const data = input as { nodeType: CanvasNodeType; title?: string; x?: number; y?: number; width?: number; height?: number; metadata?: Record<string, unknown> };
|
||||
return applyOps([{ type: "add_node", nodeType: data.nodeType, title: data.title, position: { x: data.x ?? nextCanvasX(state), y: data.y ?? 0 }, width: data.width, height: data.height, metadata: data.metadata }]);
|
||||
}
|
||||
if (name === "canvas_create_text_node") {
|
||||
const data = input as { text?: string; x?: number; y?: number; title?: string; width?: number; height?: number };
|
||||
return applyOps([textNodeOp(data, data.x ?? nextCanvasX(state), data.y ?? 0)]);
|
||||
}
|
||||
if (name === "canvas_create_text_nodes") {
|
||||
const data = input as { items: Array<{ text: string; title?: string; x?: number; y?: number; width?: number; height?: number }>; x?: number; y?: number; gap?: number; direction?: "row" | "column" };
|
||||
const x = Number(data.x ?? nextCanvasX(state));
|
||||
const y = Number(data.y ?? 0);
|
||||
const gap = Number(data.gap ?? 40);
|
||||
return applyOps(data.items.map((item, index) => textNodeOp(item, item.x ?? (data.direction === "row" ? x + index * (340 + gap) : x), item.y ?? (data.direction === "row" ? y : y + index * (240 + gap)))));
|
||||
}
|
||||
if (name === "canvas_create_image_prompt_flow") return applyOps(generationFlowOps({ ...input, mode: "image" }, state));
|
||||
if (name === "canvas_create_config_node") {
|
||||
const x = Number(input.x ?? nextCanvasX(state));
|
||||
const y = Number(input.y ?? 0);
|
||||
const configId = `config-${crypto.randomUUID()}`;
|
||||
const mode = generationMode(input.mode);
|
||||
const prompt = String(input.prompt || "");
|
||||
return applyOps([configNodeOp(configId, input, x, y), ...(input.autoRun ? [runGenerationOp(configId, mode, prompt)] : [])]);
|
||||
}
|
||||
if (name === "canvas_create_generation_flow") return applyOps(generationFlowOps(input, state));
|
||||
if (name === "canvas_generate_text" || name === "canvas_generate_image" || name === "canvas_generate_video" || name === "canvas_generate_audio") {
|
||||
return applyOps(generationFlowOps({ ...input, mode: name.replace("canvas_generate_", ""), autoRun: true }, state));
|
||||
}
|
||||
if (name === "canvas_update_node") {
|
||||
const data = input as { id: string; patch?: Record<string, unknown>; metadata?: Record<string, unknown> };
|
||||
return applyOps([{ type: "update_node", id: data.id, patch: data.patch, metadata: data.metadata }]);
|
||||
}
|
||||
if (name === "canvas_update_node_text") {
|
||||
const data = input as { id: string; text: string; title?: string };
|
||||
return applyOps([{ type: "update_node", id: data.id, patch: { ...(data.title ? { title: data.title } : {}) }, metadata: { content: data.text, status: "success" } }]);
|
||||
}
|
||||
if (name === "canvas_move_nodes") {
|
||||
const data = input as { items: Array<{ id: string; x?: number; y?: number; dx?: number; dy?: number }> };
|
||||
return applyOps(data.items.map((item) => {
|
||||
const current = findNode(state, item.id);
|
||||
return { type: "update_node", id: item.id, patch: { position: { x: item.x ?? ((current?.position.x || 0) + (item.dx || 0)), y: item.y ?? ((current?.position.y || 0) + (item.dy || 0)) } } };
|
||||
}));
|
||||
}
|
||||
if (name === "canvas_resize_node") {
|
||||
const data = input as { id: string; width: number; height: number; freeResize?: boolean };
|
||||
return applyOps([{ type: "update_node", id: data.id, patch: { width: data.width, height: data.height }, metadata: data.freeResize === undefined ? undefined : { freeResize: data.freeResize } }]);
|
||||
}
|
||||
if (name === "canvas_delete_nodes") return applyOps([{ type: "delete_node", ids: (input as { ids: string[] }).ids }]);
|
||||
if (name === "canvas_connect_nodes") {
|
||||
const data = input as { connections: Array<{ fromNodeId: string; toNodeId: string }> };
|
||||
return applyOps(data.connections.map((connection) => ({ type: "connect_nodes", ...connection })));
|
||||
}
|
||||
if (name === "canvas_select_nodes") return applyOps([{ type: "select_nodes", ids: (input as { ids: string[] }).ids }]);
|
||||
if (name === "canvas_set_viewport") return applyOps([{ type: "set_viewport", viewport: (input as { viewport: unknown }).viewport }]);
|
||||
if (name === "canvas_run_generation") {
|
||||
const data = input as { nodeId: string; mode?: string; prompt?: string };
|
||||
return applyOps([runGenerationOp(data.nodeId, generationMode(data.mode), data.prompt)]);
|
||||
}
|
||||
throw new Error(`未知工具:${name}`);
|
||||
}
|
||||
|
||||
/** 按最大边限制计算附件图片节点尺寸,并保持原始比例。 */
|
||||
export function fitAttachmentNodeSize(width: number, height: number) {
|
||||
const scale = Math.min(1, 640 / width, 640 / height);
|
||||
return { width: width * scale, height: height * scale };
|
||||
}
|
||||
|
||||
/** 创建统一的批量画布操作请求。 */
|
||||
function applyOps(ops: unknown[]): CanvasToolRequest {
|
||||
return { name: "canvas_apply_ops", input: { ops } };
|
||||
}
|
||||
|
||||
/** 创建文本节点操作。 */
|
||||
function textNodeOp(input: { id?: string; text?: string; title?: string; width?: number; height?: number }, x: number, y: number) {
|
||||
return { type: "add_node", id: input.id, nodeType: "text", title: input.title, position: { x, y }, width: input.width, height: input.height, metadata: { content: input.text || "", status: "success", fontSize: 14 } };
|
||||
}
|
||||
|
||||
/** 创建生成配置节点操作。 */
|
||||
function configNodeOp(id: string, input: Record<string, unknown>, x: number, y: number) {
|
||||
const mode = generationMode(input.mode);
|
||||
const prompt = String(input.prompt || "");
|
||||
return {
|
||||
type: "add_node",
|
||||
id,
|
||||
nodeType: "config",
|
||||
title: String(input.title || generationTitle(mode)),
|
||||
position: { x, y },
|
||||
width: typeof input.width === "number" ? input.width : undefined,
|
||||
height: typeof input.height === "number" ? input.height : undefined,
|
||||
metadata: cleanRecord({
|
||||
generationMode: mode,
|
||||
composerContent: prompt,
|
||||
prompt,
|
||||
status: "idle",
|
||||
model: input.model,
|
||||
size: input.size,
|
||||
quality: input.quality,
|
||||
count: input.count,
|
||||
seconds: input.seconds,
|
||||
vquality: input.vquality,
|
||||
generateAudio: input.generateAudio,
|
||||
watermark: input.watermark,
|
||||
audioVoice: input.audioVoice,
|
||||
audioFormat: input.audioFormat,
|
||||
audioSpeed: input.audioSpeed,
|
||||
audioInstructions: input.audioInstructions,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建包含提示词、配置节点和引用连线的生成流程。 */
|
||||
function generationFlowOps(input: Record<string, unknown>, state: CanvasSnapshot | null) {
|
||||
const mode = generationMode(input.mode);
|
||||
const prompt = String(input.prompt || "");
|
||||
const x = Number(input.x ?? nextCanvasX(state));
|
||||
const y = Number(input.y ?? 0);
|
||||
const textId = `text-${crypto.randomUUID()}`;
|
||||
const configId = `config-${crypto.randomUUID()}`;
|
||||
const referenceNodeIds = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds.filter((id): id is string => typeof id === "string") : [];
|
||||
const tokens = [`@[node:${textId}]`, ...referenceNodeIds.map((id) => `@[node:${id}]`)];
|
||||
return [
|
||||
textNodeOp({ id: textId, text: prompt, title: String(input.title || "提示词") }, x, y),
|
||||
configNodeOp(configId, { ...input, prompt: tokens.join("\n") }, x + 420, y),
|
||||
{ type: "connect_nodes", fromNodeId: textId, toNodeId: configId },
|
||||
...referenceNodeIds.map((fromNodeId) => ({ type: "connect_nodes", fromNodeId, toNodeId: configId })),
|
||||
{ type: "select_nodes", ids: [configId] },
|
||||
...(input.autoRun ? [runGenerationOp(configId, mode, tokens.join("\n"))] : []),
|
||||
];
|
||||
}
|
||||
|
||||
/** 创建触发节点生成的画布操作。 */
|
||||
function runGenerationOp(nodeId: string, mode: "text" | "image" | "video" | "audio", prompt?: string) {
|
||||
return { type: "run_generation", nodeId, mode, prompt };
|
||||
}
|
||||
|
||||
/** 将未知生成模式归一为画布支持的模式。 */
|
||||
function generationMode(value: unknown): "text" | "image" | "video" | "audio" {
|
||||
return value === "text" || value === "video" || value === "audio" ? value : "image";
|
||||
}
|
||||
|
||||
/** 获取生成模式对应的默认节点标题。 */
|
||||
function generationTitle(mode: "text" | "image" | "video" | "audio") {
|
||||
if (mode === "text") return "文本生成";
|
||||
if (mode === "video") return "视频生成";
|
||||
if (mode === "audio") return "音频生成";
|
||||
return "图片生成";
|
||||
}
|
||||
|
||||
/** 按节点 ID 查找当前画布节点。 */
|
||||
function findNode(state: CanvasSnapshot | null, id: string): CanvasNode | undefined {
|
||||
return (state?.nodes || []).find((node) => node.id === id);
|
||||
}
|
||||
|
||||
/** 移除对象中未设置的生成参数。 */
|
||||
function cleanRecord(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== ""));
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const recordSchema = z.record(z.unknown());
|
||||
const positionSchema = z.object({ x: z.number(), y: z.number() });
|
||||
const viewportSchema = z.object({ x: z.number(), y: z.number(), k: z.number() });
|
||||
const nodeTypeSchema = z.enum(["image", "text", "config", "video", "audio"]);
|
||||
const generationModeSchema = z.enum(["text", "image", "video", "audio"]);
|
||||
|
||||
/** Canvas Agent 对外提供的工具名称。 */
|
||||
export const toolNames = [
|
||||
"site_navigate",
|
||||
"canvas_list_projects",
|
||||
"canvas_get_state",
|
||||
"canvas_get_selection",
|
||||
"canvas_export_snapshot",
|
||||
"canvas_apply_ops",
|
||||
"canvas_create_node",
|
||||
"canvas_create_attachment_nodes",
|
||||
"canvas_create_text_node",
|
||||
"canvas_create_text_nodes",
|
||||
"canvas_create_config_node",
|
||||
"canvas_create_image_prompt_flow",
|
||||
"canvas_create_generation_flow",
|
||||
"canvas_generate_text",
|
||||
"canvas_generate_image",
|
||||
"canvas_generate_video",
|
||||
"canvas_generate_audio",
|
||||
"canvas_update_node",
|
||||
"canvas_update_node_text",
|
||||
"canvas_move_nodes",
|
||||
"canvas_resize_node",
|
||||
"canvas_delete_nodes",
|
||||
"canvas_connect_nodes",
|
||||
"canvas_select_nodes",
|
||||
"canvas_set_viewport",
|
||||
"canvas_run_generation",
|
||||
"generation_get_status",
|
||||
"workbench_image_get_config",
|
||||
"workbench_image_generate",
|
||||
"workbench_video_get_config",
|
||||
"workbench_video_generate",
|
||||
"prompts_search",
|
||||
"assets_list",
|
||||
"assets_add",
|
||||
] as const;
|
||||
export type ToolName = (typeof toolNames)[number];
|
||||
|
||||
export const canvasOpSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("add_node"), nodeType: nodeTypeSchema.optional(), id: z.string().optional(), title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), position: positionSchema.optional(), metadata: recordSchema.optional() }).passthrough(),
|
||||
z.object({ type: z.literal("update_node"), id: z.string(), patch: recordSchema.optional(), metadata: recordSchema.optional() }).passthrough(),
|
||||
z.object({ type: z.literal("delete_node"), id: z.string().optional(), ids: z.array(z.string()).optional() }).passthrough(),
|
||||
z.object({ type: z.literal("delete_connections"), id: z.string().optional(), ids: z.array(z.string()).optional(), all: z.boolean().optional() }).passthrough(),
|
||||
z.object({ type: z.literal("connect_nodes"), id: z.string().optional(), fromNodeId: z.string(), toNodeId: z.string() }).passthrough(),
|
||||
z.object({ type: z.literal("set_viewport"), viewport: viewportSchema }).passthrough(),
|
||||
z.object({ type: z.literal("select_nodes"), ids: z.array(z.string()) }).passthrough(),
|
||||
z.object({ type: z.literal("run_generation"), nodeId: z.string(), mode: generationModeSchema.optional(), prompt: z.string().optional() }).passthrough(),
|
||||
]);
|
||||
|
||||
const textNodeSchema = z.object({
|
||||
text: z.string(),
|
||||
title: z.string().optional(),
|
||||
x: z.number().optional(),
|
||||
y: z.number().optional(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
});
|
||||
|
||||
const generationOptionsSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
size: z.string().optional(),
|
||||
quality: z.string().optional(),
|
||||
count: z.number().optional(),
|
||||
seconds: z.string().optional(),
|
||||
vquality: z.string().optional(),
|
||||
generateAudio: z.string().optional(),
|
||||
watermark: z.string().optional(),
|
||||
audioVoice: z.string().optional(),
|
||||
audioFormat: z.string().optional(),
|
||||
audioSpeed: z.string().optional(),
|
||||
audioInstructions: z.string().optional(),
|
||||
});
|
||||
|
||||
const generationFlowSchema = z.object({
|
||||
prompt: z.string(),
|
||||
title: z.string().optional(),
|
||||
x: z.number().optional(),
|
||||
y: z.number().optional(),
|
||||
referenceNodeIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const toolInputSchemas = {
|
||||
site_navigate: z.object({ path: z.string() }),
|
||||
canvas_list_projects: z.object({ keyword: z.string().optional(), page: z.number().optional(), pageSize: z.number().optional() }),
|
||||
canvas_get_state: z.object({}).passthrough(),
|
||||
canvas_get_selection: z.object({}).passthrough(),
|
||||
canvas_export_snapshot: z.object({}).passthrough(),
|
||||
canvas_apply_ops: z.object({ ops: z.array(canvasOpSchema) }),
|
||||
canvas_create_node: z.object({ nodeType: nodeTypeSchema, title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), metadata: recordSchema.optional() }),
|
||||
canvas_create_attachment_nodes: z.object({ attachmentIds: z.array(z.string()).min(1), x: z.number().optional(), y: z.number().optional(), gap: z.number().optional(), direction: z.enum(["row", "column"]).optional() }),
|
||||
canvas_create_text_node: z.object({ text: z.string().optional(), x: z.number().optional(), y: z.number().optional(), title: z.string().optional(), width: z.number().optional(), height: z.number().optional() }),
|
||||
canvas_create_text_nodes: z.object({ items: z.array(textNodeSchema).min(1), x: z.number().optional(), y: z.number().optional(), gap: z.number().optional(), direction: z.enum(["row", "column"]).optional() }),
|
||||
canvas_create_config_node: z.object({ prompt: z.string().optional(), mode: generationModeSchema.optional(), title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), autoRun: z.boolean().optional() }).merge(generationOptionsSchema),
|
||||
canvas_create_image_prompt_flow: z.object({ prompt: z.string(), x: z.number().optional(), y: z.number().optional(), autoRun: z.boolean().optional() }).merge(generationOptionsSchema),
|
||||
canvas_create_generation_flow: generationFlowSchema.extend({ mode: generationModeSchema.optional(), autoRun: z.boolean().optional() }).merge(generationOptionsSchema),
|
||||
canvas_generate_text: generationFlowSchema.merge(generationOptionsSchema),
|
||||
canvas_generate_image: generationFlowSchema.merge(generationOptionsSchema),
|
||||
canvas_generate_video: generationFlowSchema.merge(generationOptionsSchema),
|
||||
canvas_generate_audio: generationFlowSchema.merge(generationOptionsSchema),
|
||||
canvas_update_node: z.object({ id: z.string(), patch: recordSchema.optional(), metadata: recordSchema.optional() }),
|
||||
canvas_update_node_text: z.object({ id: z.string(), text: z.string(), title: z.string().optional() }),
|
||||
canvas_move_nodes: z.object({ items: z.array(z.object({ id: z.string(), x: z.number().optional(), y: z.number().optional(), dx: z.number().optional(), dy: z.number().optional() })).min(1) }),
|
||||
canvas_resize_node: z.object({ id: z.string(), width: z.number(), height: z.number(), freeResize: z.boolean().optional() }),
|
||||
canvas_delete_nodes: z.object({ ids: z.array(z.string()).min(1) }),
|
||||
canvas_connect_nodes: z.object({ connections: z.array(z.object({ fromNodeId: z.string(), toNodeId: z.string() })).min(1) }),
|
||||
canvas_select_nodes: z.object({ ids: z.array(z.string()) }),
|
||||
canvas_set_viewport: z.object({ viewport: viewportSchema }),
|
||||
canvas_run_generation: z.object({ nodeId: z.string(), mode: generationModeSchema.optional(), prompt: z.string().optional() }),
|
||||
generation_get_status: z.object({ scope: z.enum(["all", "canvas", "image", "video"]).optional(), taskId: z.string().optional(), nodeIds: z.array(z.string()).optional(), limit: z.number().optional() }),
|
||||
workbench_image_get_config: z.object({}).passthrough(),
|
||||
workbench_image_generate: z.object({ prompt: z.string(), model: z.string().optional(), quality: z.string().optional(), size: z.string().optional(), count: z.number().optional(), run: z.boolean().optional() }),
|
||||
workbench_video_get_config: z.object({}).passthrough(),
|
||||
workbench_video_generate: z.object({ prompt: z.string(), model: z.string().optional(), size: z.string().optional(), seconds: z.string().optional(), resolution: z.string().optional(), generateAudio: z.boolean().optional(), watermark: z.boolean().optional(), run: z.boolean().optional() }),
|
||||
prompts_search: z.object({ keyword: z.string().optional(), category: z.string().optional(), tags: z.array(z.string()).optional(), page: z.number().optional(), pageSize: z.number().optional() }),
|
||||
assets_list: z.object({ kind: z.enum(["all", "text", "image", "video"]).optional(), keyword: z.string().optional(), page: z.number().optional(), pageSize: z.number().optional() }),
|
||||
assets_add: z.object({ kind: z.enum(["text", "image"]), title: z.string(), content: z.string().optional(), imageUrl: z.string().optional(), tags: z.array(z.string()).optional(), source: z.string().optional(), note: z.string().optional() }),
|
||||
} satisfies Record<ToolName, z.AnyZodObject>;
|
||||
|
||||
export const toolDescriptions: Record<ToolName, string> = {
|
||||
site_navigate: "跳转网站页面。path 可为 / (首页)、/canvas (我的画布)、/canvas/:id (指定画布)、/image (生图工作台)、/video (视频创作台)、/prompts (提示词库)、/assets (我的素材)、/config (配置)。操作画布前若不在画布页,先用本工具打开画布。",
|
||||
canvas_list_projects: "列出用户全部画布(仅标题、创建/更新时间、节点数、连线数,不含完整数据),支持 keyword 搜索和 page/pageSize 分页。返回的 id 可配合 site_navigate 跳转到 /canvas/:id 打开对应画布。",
|
||||
canvas_get_state: "读取当前网页画布的节点、连线、选区和视口。",
|
||||
canvas_get_selection: "读取当前网页画布选中的节点。",
|
||||
canvas_export_snapshot: "导出当前画布快照,用于理解布局。",
|
||||
canvas_apply_ops: "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、delete_connections、connect_nodes、set_viewport、select_nodes、run_generation。",
|
||||
canvas_create_node: "创建任意类型节点:text、image、config、video、audio。适合创建占位图、媒体占位、配置节点或自定义 metadata 节点。",
|
||||
canvas_create_attachment_nodes: "把当前对话中用户上传的图片附件创建成真实画布图片节点。attachmentIds 使用本轮附件清单中的 ID;返回的节点 ID 可传给 canvas_create_generation_flow.referenceNodeIds 作为生成参考图。",
|
||||
canvas_create_text_node: "在当前画布创建单个文本节点。",
|
||||
canvas_create_text_nodes: "批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。",
|
||||
canvas_create_config_node: "创建生成配置节点,可指定 text/image/video/audio 模式和生成参数,可选择立即触发生成。",
|
||||
canvas_create_image_prompt_flow: "创建提示词文本节点和图片生成配置节点,并自动连线,可选择立即触发生图。",
|
||||
canvas_create_generation_flow: "创建通用生成流程:提示词文本节点、生成配置节点、参考节点连线,可用于文案、生图、视频或音频。",
|
||||
canvas_generate_text: "创建通用文本生成流程并立即触发生成。",
|
||||
canvas_generate_image: "创建通用图片生成流程并立即触发生成。",
|
||||
canvas_generate_video: "创建通用视频生成流程并立即触发生成。",
|
||||
canvas_generate_audio: "创建通用音频生成流程并立即触发生成。",
|
||||
canvas_update_node: "更新节点基础字段或 metadata。",
|
||||
canvas_update_node_text: "更新文本节点内容和标题。",
|
||||
canvas_move_nodes: "移动一个或多个节点,支持绝对坐标或 dx/dy 偏移。",
|
||||
canvas_resize_node: "调整节点尺寸。",
|
||||
canvas_delete_nodes: "删除指定节点及相关连线。",
|
||||
canvas_connect_nodes: "批量连接节点。",
|
||||
canvas_select_nodes: "设置当前选中节点。",
|
||||
canvas_set_viewport: "调整画布视口。",
|
||||
canvas_run_generation: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。",
|
||||
generation_get_status: "查询当前活动网页的生成任务状态。默认返回画布、生图工作台和视频工作台最近任务;可用 scope 过滤来源,用 taskId 查询工作台任务,用 nodeIds 查询画布节点。",
|
||||
workbench_image_get_config: "读取生图工作台的当前参数和可选项(可用模型、质量、尺寸/宽高比、张数范围),在调用 workbench_image_generate 前先了解可选值。",
|
||||
workbench_image_generate: "在生图工作台填入提示词并按需设置 model、quality、size(如 1:1 或 1024x1024)、count,run 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,提交后返回 taskId,可用 generation_get_status 查询状态。",
|
||||
workbench_video_get_config: "读取视频创作台的当前参数和可选项(可用模型、尺寸/比例、时长、清晰度/分辨率、是否生成声音与水印)。",
|
||||
workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermark,run 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,提交后返回 taskId,可用 generation_get_status 查询状态。",
|
||||
prompts_search: "搜索提示词库(第三方提示词合集),支持 keyword、category、tags 过滤和 page/pageSize 分页,返回标题、提示词、分类、标签、封面等。",
|
||||
assets_list: "列出用户「我的素材」,支持 kind(text/image/video)过滤、keyword 搜索和 page/pageSize 分页。为控制体积不返回图片/视频原始 data,仅返回封面与元信息。",
|
||||
assets_add: "向「我的素材」新增素材。kind=text 时用 content 传文本内容;kind=image 时用 imageUrl 传图片地址或 dataURL。可附带 title、tags、source、note。",
|
||||
};
|
||||
@@ -0,0 +1,297 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { ServerResponse } from "node:http";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { CanvasSession } from "./session.js";
|
||||
|
||||
test("MCP 读取当前激活网页的画布", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
|
||||
session.activateClient("first");
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
|
||||
|
||||
session.activateClient("second");
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
|
||||
});
|
||||
|
||||
test("画布写操作只发送给当前激活网页", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
session.activateClient("second");
|
||||
|
||||
const result = session.callTool("canvas_create_text_node", { text: "只写入第二个画布" });
|
||||
const call = second.event("tool_call");
|
||||
assert.equal(first.event("tool_call"), undefined);
|
||||
assert.equal(field(call, "name"), "canvas_apply_ops");
|
||||
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||
assert.deepEqual(await result, { ok: true });
|
||||
});
|
||||
|
||||
test("当前 turn 的图片附件可在发起标签页画布创建图片节点", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
t.after(() => first.close());
|
||||
const dataUrl = "data:image/png;base64,aW1hZ2U=";
|
||||
session.setTurnAttachments("first", [{ id: "attachment-1", name: "商品.png", type: "image/png", size: 5, width: 1200, height: 600, dataUrl }]);
|
||||
session.bindClient("first");
|
||||
|
||||
const result = session.callTool("canvas_create_attachment_nodes", { attachmentIds: ["attachment-1"], x: 100, y: 200 });
|
||||
const call = first.event("tool_call");
|
||||
const input = field(call, "input") as Record<string, unknown>;
|
||||
const nodes = input.nodes as Array<Record<string, unknown>>;
|
||||
assert.equal(field(call, "name"), "canvas_create_attachment_nodes");
|
||||
assert.equal(nodes.length, 1);
|
||||
assert.equal(nodes[0].attachmentId, "attachment-1");
|
||||
assert.equal(nodes[0].title, "商品.png");
|
||||
assert.deepEqual(nodes[0].position, { x: 100, y: 200 });
|
||||
assert.equal(nodes[0].width, 640);
|
||||
assert.equal(nodes[0].height, 320);
|
||||
assert.equal("dataUrl" in nodes[0], false);
|
||||
assert.equal(session.getTurnAttachment("first", "attachment-1").dataUrl, dataUrl);
|
||||
|
||||
session.resolveResult("first", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||
const created = (await result) as { nodes: Array<{ id: string; attachmentId: string; title: string }> };
|
||||
assert.equal(created.nodes[0].id, nodes[0].id);
|
||||
assert.equal(created.nodes[0].attachmentId, "attachment-1");
|
||||
session.clearTurnAttachments("first");
|
||||
assert.throws(() => session.getTurnAttachment("first", "attachment-1"), /找不到/);
|
||||
});
|
||||
|
||||
test("图片附件只允许发起 turn 的标签页读取和落入画布", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.setTurnAttachments("first", [{ id: "attachment-1", name: "商品.png", type: "image/png", dataUrl: "data:image/png;base64,aW1hZ2U=" }]);
|
||||
session.bindClient("second");
|
||||
|
||||
await assert.rejects(session.callTool("canvas_create_attachment_nodes", { attachmentIds: ["attachment-1"] }), /发起标签页/);
|
||||
assert.throws(() => session.getTurnAttachment("second", "attachment-1"), /发起标签页/);
|
||||
assert.equal(first.event("tool_call"), undefined);
|
||||
assert.equal(second.event("tool_call"), undefined);
|
||||
});
|
||||
|
||||
test("tool result is accepted only from the request client", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.activateClient("first");
|
||||
|
||||
const result = session.callTool("canvas_create_text_node", { text: "first only" });
|
||||
const call = first.event("tool_call");
|
||||
const requestId = String(field(call, "requestId"));
|
||||
|
||||
assert.equal(session.resolveResult("second", { requestId, result: { client: "second" } }), false);
|
||||
assert.equal(session.resolveResult("first", { requestId, result: { client: "first" } }), true);
|
||||
assert.deepEqual(await result, { client: "first" });
|
||||
});
|
||||
|
||||
test("生成状态查询由当前激活网页返回", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.activateClient("second");
|
||||
|
||||
const result = session.callTool("generation_get_status", { scope: "all" });
|
||||
const call = second.event("tool_call");
|
||||
assert.equal(first.event("tool_call"), undefined);
|
||||
assert.equal(field(call, "name"), "generation_get_status");
|
||||
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { total: 1, tasks: [{ id: "image-1", status: "running" }] } });
|
||||
assert.deepEqual(await result, { total: 1, tasks: [{ id: "image-1", status: "running" }] });
|
||||
});
|
||||
|
||||
test("活动网页关闭后回退到仍连接的画布", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
session.activateClient("second");
|
||||
second.close();
|
||||
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
|
||||
});
|
||||
|
||||
test("closing the active client falls back to the most recently focused client", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
const third = connect(session, "third");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
third.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
session.updateState(snapshot("canvas-third"), "third");
|
||||
session.activateClient("third");
|
||||
session.activateClient("second");
|
||||
second.close();
|
||||
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-third");
|
||||
});
|
||||
|
||||
test("closing a client rejects its pending tool requests", async () => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const result = session.callTool("canvas_create_text_node", { text: "pending" });
|
||||
const call = first.event("tool_call");
|
||||
const requestId = String(field(call, "requestId"));
|
||||
first.close();
|
||||
|
||||
const outcome = await Promise.race([
|
||||
result.then(() => "resolved", (error) => error instanceof Error ? error.message : String(error)),
|
||||
new Promise<string>((resolve) => setTimeout(() => resolve("pending"), 20)),
|
||||
]);
|
||||
if (outcome === "pending") session.resolveResult("first", { requestId, result: null });
|
||||
assert.match(outcome, /断开/);
|
||||
});
|
||||
|
||||
test("shared thread events are broadcast with the active thread id", (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
|
||||
session.emitThread("workspace_changed", "thread-2", { activeThreadId: "thread-2" });
|
||||
|
||||
assert.deepEqual(first.event("workspace_changed"), { activeThreadId: "thread-2", threadId: "thread-2" });
|
||||
assert.deepEqual(second.event("workspace_changed"), { activeThreadId: "thread-2", threadId: "thread-2" });
|
||||
});
|
||||
|
||||
test("new clients receive the current Codex state and later updates", (t) => {
|
||||
const session = new CanvasSession();
|
||||
session.setCodexState({ busy: true, threadId: "thread-2", turnId: "turn-1" });
|
||||
const client = connect(session, "first");
|
||||
t.after(() => client.close());
|
||||
|
||||
assert.deepEqual(field(client.event("hello"), "codex"), { busy: true, threadId: "thread-2", turnId: "turn-1" });
|
||||
|
||||
session.setCodexState({ busy: false });
|
||||
assert.deepEqual(client.event("codex_state"), { busy: false, threadId: "thread-2", turnId: "turn-1" });
|
||||
});
|
||||
|
||||
test("a bound client remains the tool target while focus changes", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
session.bindClient("first");
|
||||
session.activateClient("second");
|
||||
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
|
||||
const result = session.callTool("canvas_create_text_node", { text: "bound" });
|
||||
const call = first.event("tool_call");
|
||||
assert.equal(second.event("tool_call"), undefined);
|
||||
session.resolveResult("first", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||
assert.deepEqual(await result, { ok: true });
|
||||
|
||||
session.releaseClient("first");
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
|
||||
});
|
||||
|
||||
test("closing the bound client falls back to the active client", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
session.bindClient("first");
|
||||
session.activateClient("second");
|
||||
first.close();
|
||||
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
|
||||
const result = session.callTool("canvas_create_text_node", { text: "fallback" });
|
||||
const call = second.event("tool_call");
|
||||
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||
assert.deepEqual(await result, { ok: true });
|
||||
});
|
||||
|
||||
/** 创建用于测试的画布 SSE 连接。 */
|
||||
function connect(session: CanvasSession, clientId: string) {
|
||||
const response = new FakeSseResponse();
|
||||
session.openEvents(new URL(`http://127.0.0.1/events?clientId=${clientId}`), response as unknown as ServerResponse);
|
||||
return response;
|
||||
}
|
||||
|
||||
/** 创建最小画布快照。 */
|
||||
function snapshot(projectId: string) {
|
||||
return { projectId, title: projectId, nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
|
||||
}
|
||||
|
||||
/** 安全读取测试对象字段。 */
|
||||
function field(value: unknown, key: string) {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
||||
}
|
||||
|
||||
/** 模拟 Node SSE 响应并提供事件读取能力。 */
|
||||
class FakeSseResponse extends EventEmitter {
|
||||
private chunks: string[] = [];
|
||||
|
||||
/** 模拟写入响应头。 */
|
||||
writeHead() {
|
||||
return this;
|
||||
}
|
||||
|
||||
/** 保存写入的 SSE 文本块。 */
|
||||
write(chunk: string) {
|
||||
this.chunks.push(chunk);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 读取指定类型的首个 SSE 事件数据。 */
|
||||
event(type: string) {
|
||||
const chunk = this.chunks.find((item) => item.startsWith(`event: ${type}\n`));
|
||||
const data = chunk?.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
|
||||
return data ? (JSON.parse(data) as unknown) : undefined;
|
||||
}
|
||||
|
||||
/** 触发连接关闭事件。 */
|
||||
close() {
|
||||
this.emit("close");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { ServerResponse } from "node:http";
|
||||
|
||||
import type { AgentAttachment } from "../agent/types.js";
|
||||
import { logger } from "../utils/logger.js";
|
||||
import { buildCanvasToolRequest, fitAttachmentNodeSize } from "./operations.js";
|
||||
import type { ToolName } from "./schemas.js";
|
||||
import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInput } from "./tools.js";
|
||||
import type { CanvasSnapshot } from "./types.js";
|
||||
|
||||
type PendingRequest = { clientId: string; resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
type TurnAttachment = { clientId: string; id: string; name: string; type: string; size: number; width: number; height: number; dataUrl: string };
|
||||
export type CodexState = { busy: boolean; threadId: string; turnId: string };
|
||||
|
||||
const SITE_TOOLS = new Set<ToolName>([
|
||||
"site_navigate",
|
||||
"canvas_list_projects",
|
||||
"workbench_image_get_config",
|
||||
"workbench_image_generate",
|
||||
"workbench_video_get_config",
|
||||
"workbench_video_generate",
|
||||
"prompts_search",
|
||||
"assets_list",
|
||||
"assets_add",
|
||||
"generation_get_status",
|
||||
]);
|
||||
|
||||
/** 管理网页画布连接、状态、附件和工具请求。 */
|
||||
export class CanvasSession {
|
||||
private clients = new Map<string, ServerResponse>();
|
||||
private clientFocusOrder = new Map<string, number>();
|
||||
private pending = new Map<string, PendingRequest>();
|
||||
private canvasStates = new Map<string, CanvasSnapshot>();
|
||||
private turnAttachments = new Map<string, TurnAttachment>();
|
||||
private activeClientId = "";
|
||||
private boundClientId = "";
|
||||
private focusSequence = 0;
|
||||
private codexState: CodexState = { busy: false, threadId: "", turnId: "" };
|
||||
|
||||
/** 获取当前目标网页的画布状态。 */
|
||||
private get canvasState() {
|
||||
return this.canvasStates.get(this.targetClientId) || null;
|
||||
}
|
||||
|
||||
/** 获取当前 turn 绑定或最近激活的网页客户端。 */
|
||||
private get targetClientId() {
|
||||
return this.boundClientId || this.activeClientId;
|
||||
}
|
||||
|
||||
/** 返回 Canvas Agent 当前连接状态。 */
|
||||
health() {
|
||||
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy };
|
||||
}
|
||||
|
||||
/** 返回 Codex 是否正在执行任务。 */
|
||||
get codexBusy() {
|
||||
return this.codexState.busy;
|
||||
}
|
||||
|
||||
/** 更新并广播 Codex 运行状态。 */
|
||||
setCodexState(patch: Partial<CodexState>) {
|
||||
this.codexState = { ...this.codexState, ...patch };
|
||||
logger.debug("Codex state changed", this.codexState);
|
||||
this.emitAll("codex_state", this.codexState);
|
||||
}
|
||||
|
||||
/** 建立网页与 Canvas Agent 之间的 SSE 连接。 */
|
||||
openEvents(url: URL, res: ServerResponse) {
|
||||
const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
|
||||
const statusOnly = url.searchParams.get("role") === "status";
|
||||
logger.info("SSE client connected", { clientId, statusOnly });
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
||||
if (!statusOnly) {
|
||||
this.clients.set(clientId, res);
|
||||
if (!this.clientFocusOrder.has(clientId)) this.clientFocusOrder.set(clientId, 0);
|
||||
if (!this.activeClientId) {
|
||||
this.activeClientId = clientId;
|
||||
this.clientFocusOrder.set(clientId, ++this.focusSequence);
|
||||
}
|
||||
}
|
||||
sendEvent(res, "hello", { ok: true, clientId, codex: this.codexState });
|
||||
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
|
||||
res.on("close", () => {
|
||||
clearInterval(timer);
|
||||
logger.info("SSE client disconnected", { clientId, statusOnly });
|
||||
if (statusOnly || this.clients.get(clientId) !== res) return;
|
||||
this.clients.delete(clientId);
|
||||
this.clientFocusOrder.delete(clientId);
|
||||
this.canvasStates.delete(clientId);
|
||||
if (this.boundClientId === clientId) this.boundClientId = "";
|
||||
this.pending.forEach((item, requestId) => {
|
||||
if (item.clientId !== clientId) return;
|
||||
this.pending.delete(requestId);
|
||||
item.reject(new Error("请求页面已断开"));
|
||||
});
|
||||
if (this.activeClientId === clientId) this.activeClientId = [...this.clients.keys()].sort((a, b) => (this.clientFocusOrder.get(b) || 0) - (this.clientFocusOrder.get(a) || 0))[0] || "";
|
||||
});
|
||||
}
|
||||
|
||||
/** 保存指定网页上报的最新画布快照。 */
|
||||
updateState(body: unknown, clientId?: string) {
|
||||
const targetClientId = clientId || this.activeClientId;
|
||||
if (!targetClientId) return;
|
||||
this.canvasStates.set(targetClientId, { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId: targetClientId } as CanvasSnapshot);
|
||||
logger.debug("Canvas state updated", { clientId: targetClientId, nodes: Array.isArray((body as CanvasSnapshot | null)?.nodes) ? (body as CanvasSnapshot).nodes.length : 0, connections: Array.isArray((body as CanvasSnapshot | null)?.connections) ? (body as CanvasSnapshot).connections.length : 0 });
|
||||
}
|
||||
|
||||
/** 将指定网页设为最近激活的工具目标。 */
|
||||
activateClient(clientId: string) {
|
||||
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
|
||||
this.activeClientId = clientId;
|
||||
this.clientFocusOrder.set(clientId, ++this.focusSequence);
|
||||
logger.debug("Canvas client activated", { clientId });
|
||||
}
|
||||
|
||||
/** 将当前 Agent turn 固定绑定到指定网页。 */
|
||||
bindClient(clientId: string) {
|
||||
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
|
||||
this.boundClientId = clientId;
|
||||
logger.debug("Canvas client bound to turn", { clientId });
|
||||
}
|
||||
|
||||
/** 解除当前 Agent turn 的网页绑定。 */
|
||||
releaseClient(clientId: string) {
|
||||
if (this.boundClientId === clientId) this.boundClientId = "";
|
||||
logger.debug("Canvas client released from turn", { clientId });
|
||||
}
|
||||
|
||||
/** 保存当前 turn 可用的图片附件并返回安全引用。 */
|
||||
setTurnAttachments(clientId: string, attachments: AgentAttachment[]) {
|
||||
this.turnAttachments.clear();
|
||||
return attachments.flatMap((item, index) => {
|
||||
if (!item.dataUrl?.startsWith("data:image/")) return [];
|
||||
const id = item.id?.trim() || `attachment-${crypto.randomUUID()}`;
|
||||
const attachment: TurnAttachment = {
|
||||
clientId,
|
||||
id,
|
||||
name: item.name?.trim() || `图片 ${index + 1}`,
|
||||
type: item.type?.startsWith("image/") ? item.type : item.dataUrl.match(/^data:([^;]+)/)?.[1] || "image/png",
|
||||
size: positiveNumber(item.size, 0),
|
||||
width: positiveNumber(item.width, 1024),
|
||||
height: positiveNumber(item.height, 1024),
|
||||
dataUrl: item.dataUrl,
|
||||
};
|
||||
this.turnAttachments.set(id, attachment);
|
||||
return [{ id, name: attachment.name, type: attachment.type, size: attachment.size, width: attachment.width, height: attachment.height }];
|
||||
});
|
||||
}
|
||||
|
||||
/** 清理指定网页或全部 turn 附件。 */
|
||||
clearTurnAttachments(clientId?: string) {
|
||||
this.turnAttachments.forEach((item, id) => {
|
||||
if (!clientId || item.clientId === clientId) this.turnAttachments.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取属于指定网页 turn 的图片附件。 */
|
||||
getTurnAttachment(clientId: string, attachmentId: string) {
|
||||
const attachment = this.turnAttachments.get(attachmentId);
|
||||
if (!attachment) throw new Error(`找不到本轮图片附件:${attachmentId}`);
|
||||
if (attachment.clientId !== clientId) throw new Error("图片附件不属于当前 turn 的发起标签页");
|
||||
return attachment;
|
||||
}
|
||||
|
||||
/** 接收网页返回的工具调用结果。 */
|
||||
resolveResult(clientId: string, body: { requestId?: string; error?: string; result?: unknown }) {
|
||||
const item = body.requestId ? this.pending.get(body.requestId) : null;
|
||||
if (!item || !body.requestId || item.clientId !== clientId) return false;
|
||||
this.pending.delete(body.requestId);
|
||||
logger.debug("Canvas tool result received", { clientId, requestId: body.requestId, error: body.error, result: body.result });
|
||||
body.error ? item.reject(new Error(body.error)) : item.resolve(body.result);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 向全部已连接网页广播事件。 */
|
||||
emitAll(type: string, payload: unknown) {
|
||||
this.clients.forEach((client) => sendEvent(client, type, payload));
|
||||
}
|
||||
|
||||
/** 向全部网页广播带线程归属的事件。 */
|
||||
emitThread(type: string, threadId: string, payload: Record<string, unknown> = {}) {
|
||||
this.emitAll(type, { ...payload, threadId });
|
||||
}
|
||||
|
||||
/** 校验工具参数并将调用分派到当前目标网页。 */
|
||||
async callTool(name: unknown, rawInput: unknown) {
|
||||
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
|
||||
logger.info("MCP tool called", { name, input: rawInput, targetClientId: this.targetClientId });
|
||||
const input = parseToolInput(name, rawInput) as Record<string, unknown>;
|
||||
if (SITE_TOOLS.has(name)) {
|
||||
if (!this.clients.size) throw new Error("当前没有已连接网页");
|
||||
return await this.requestCanvasTool(name, input);
|
||||
}
|
||||
const readTool = ["canvas_get_state", "canvas_get_selection", "canvas_export_snapshot"].includes(name);
|
||||
if (readTool && (!this.clients.size || !this.canvasState)) throw new Error("当前没有已连接画布");
|
||||
if (name === "canvas_get_state" || name === "canvas_export_snapshot") return compactCanvasState(this.canvasState);
|
||||
if (name === "canvas_get_selection") {
|
||||
const ids = new Set(this.canvasState?.selectedNodeIds || []);
|
||||
return { nodes: (this.canvasState?.nodes || []).filter((node) => ids.has(node.id)).map(compactNode) };
|
||||
}
|
||||
if (name === "canvas_create_attachment_nodes") return await this.createAttachmentNodes(input as { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" });
|
||||
if (!this.clients.size) throw new Error("当前没有已连接画布");
|
||||
const request = buildCanvasToolRequest(name, input, this.canvasState);
|
||||
return await this.requestCanvasTool(request.name, request.input);
|
||||
}
|
||||
|
||||
/** 将当前 turn 的附件转换为画布图片节点。 */
|
||||
private async createAttachmentNodes(input: { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" }) {
|
||||
const clientId = this.targetClientId;
|
||||
if (!this.clients.has(clientId)) throw new Error("当前没有已连接画布");
|
||||
const attachments = input.attachmentIds.map((id) => this.getTurnAttachment(clientId, id));
|
||||
const x = Number(input.x ?? nextCanvasX(this.canvasState));
|
||||
const y = Number(input.y ?? 0);
|
||||
const gap = Number(input.gap ?? 40);
|
||||
const direction = input.direction || "row";
|
||||
let offset = 0;
|
||||
const nodes = attachments.map((attachment) => {
|
||||
const size = fitAttachmentNodeSize(attachment.width, attachment.height);
|
||||
const node = {
|
||||
id: `image-${crypto.randomUUID()}`,
|
||||
attachmentId: attachment.id,
|
||||
title: attachment.name,
|
||||
position: { x: direction === "row" ? x + offset : x, y: direction === "column" ? y + offset : y },
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
};
|
||||
offset += (direction === "row" ? size.width : size.height) + gap;
|
||||
return node;
|
||||
});
|
||||
await this.requestCanvasTool("canvas_create_attachment_nodes", { nodes });
|
||||
return { nodes: nodes.map(({ id, attachmentId, title }) => ({ id, attachmentId, title })) };
|
||||
}
|
||||
|
||||
/** 向目标网页发送工具请求并等待调用结果。 */
|
||||
private async requestCanvasTool(name: ToolName, input: Record<string, unknown>) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const clientId = this.targetClientId;
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) throw new Error("当前没有已连接画布");
|
||||
sendEvent(client, "tool_call", { requestId, name, input });
|
||||
logger.debug("Canvas tool request sent", { requestId, name, input, clientId });
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(requestId);
|
||||
logger.warn("Canvas tool request timed out", { requestId, name, clientId });
|
||||
reject(new Error("画布操作超时"));
|
||||
}, 30000);
|
||||
this.pending.set(requestId, { clientId, resolve: (value) => (clearTimeout(timer), resolve(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 向 SSE 连接写入一个事件。 */
|
||||
function sendEvent(res: ServerResponse, type: string, payload: unknown) {
|
||||
res.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
/** 将未知数值转换为正数,否则使用默认值。 */
|
||||
function positiveNumber(value: unknown, fallback: number) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? number : fallback;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { toolInputSchemas, toolNames, type ToolName } from "./schemas.js";
|
||||
import type { CanvasNode, CanvasSnapshot } from "./types.js";
|
||||
|
||||
/** 判断传入名称是否为已注册的画布工具。 */
|
||||
export function isToolName(name: unknown): name is ToolName {
|
||||
return typeof name === "string" && toolNames.includes(name as ToolName);
|
||||
}
|
||||
|
||||
/** 按工具名称校验并解析调用参数。 */
|
||||
export function parseToolInput(name: ToolName, input: unknown) {
|
||||
return toolInputSchemas[name].parse(input ?? {});
|
||||
}
|
||||
|
||||
/** 压缩画布快照,避免向 Agent 返回过长的节点内容。 */
|
||||
export function compactCanvasState(state: CanvasSnapshot | null) {
|
||||
if (!state) throw new Error("当前没有已连接画布");
|
||||
return { ...state, nodes: (state.nodes || []).map(compactNode) };
|
||||
}
|
||||
|
||||
/** 压缩单个画布节点的元数据内容。 */
|
||||
export function compactNode(node: CanvasNode) {
|
||||
const metadata = { ...(node.metadata || {}) };
|
||||
if (typeof metadata.content === "string" && metadata.content.length > 240) metadata.content = `${metadata.content.slice(0, 120)}...`;
|
||||
return { id: node.id, type: node.type, title: node.title, position: node.position, width: node.width, height: node.height, metadata };
|
||||
}
|
||||
|
||||
/** 计算新节点在当前画布右侧的默认横坐标。 */
|
||||
export function nextCanvasX(state: CanvasSnapshot | null) {
|
||||
const nodes = state?.nodes || [];
|
||||
return nodes.length ? Math.max(...nodes.map((node) => node.position.x + node.width)) + 80 : 0;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** 画布坐标。 */
|
||||
export type Position = { x: number; y: number };
|
||||
export type Viewport = { x: number; y: number; k: number };
|
||||
export type CanvasNodeType = "image" | "text" | "config" | "video" | "audio";
|
||||
export type CanvasNode = { id: string; type: CanvasNodeType; title?: string; position: Position; width: number; height: number; metadata?: Record<string, unknown> };
|
||||
export type CanvasConnection = { id: string; fromNodeId: string; toNodeId: string };
|
||||
export type CanvasSnapshot = { projectId?: string; title?: string; nodes?: CanvasNode[]; connections?: CanvasConnection[]; selectedNodeIds?: string[]; viewport?: Viewport; clientId?: string };
|
||||
Reference in New Issue
Block a user