feat(canvas): restructure source directory for Canvas Agent, enhancing code maintainability

This commit is contained in:
HouYunFei
2026-07-29 10:47:06 +08:00
parent 92fd0ce129
commit 40c47dd7ff
20 changed files with 3095 additions and 777 deletions
+168
View File
@@ -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 !== ""));
}
+163
View File
@@ -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。",
};
+297
View File
@@ -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");
}
}
+261
View File
@@ -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;
}
+31
View File
@@ -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;
}
+7
View File
@@ -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 };