feat(*): 插件中心与模型协议 - 完善声明式协议运行时并增加功能开放控制

This commit is contained in:
ddCat committed 2026-08-23 23:19:27 +08:00
1 parent 7cc6021f6e
commit 919037e5d9
106 files changed
+8816 -499

No files matched your search

+4
View File
@@ -419,6 +419,10 @@ function stringOrNull(value: unknown) {
function toolName(name: string) {
if (name === "canvas_apply_ops") return "画布操作";
if (name === "canvas_get_state") return "读取画布";
if (name === "canvas_get_context") return "读取画布上下文";
if (name === "canvas_find_nodes") return "检索画布节点";
if (name === "canvas_get_resources") return "读取画布资源";
if (name === "canvas_validate_ops") return "校验画布操作";
if (name === "canvas_get_selection") return "读取选区";
if (name === "canvas_export_snapshot") return "导出快照";
if (name === "canvas_create_text_node") return "创建文本";
+273
View File
@@ -0,0 +1,273 @@
import crypto from "node:crypto";
import type { CanvasConnection, CanvasNode, CanvasSnapshot } from "./types.js";
export type CanvasResourceSummary = {
nodeId: string;
nodeTitle: string;
nodeType: CanvasNode["type"];
status?: string;
resourceId?: string;
storageKey?: string;
assetId?: string;
assetCategory?: string;
mimeType?: string;
bytes?: number;
width?: number;
height?: number;
durationMs?: number;
isReady: boolean;
};
export type CanvasContext = {
schemaVersion: 1;
stateHash: string;
canvas: {
projectId?: string;
domainProjectId?: string;
title?: string;
viewport?: CanvasSnapshot["viewport"];
nodeCount: number;
connectionCount: number;
selectedNodeCount: number;
nodeTypeCounts: Record<string, number>;
};
selection: ReturnType<typeof compactContextNode>[];
nodes: ReturnType<typeof compactContextNode>[];
connections: Array<{
id: string;
fromNodeId: string;
fromTitle: string;
toNodeId: string;
toTitle: string;
fromHandleId?: string;
toHandleId?: string;
}>;
resources: CanvasResourceSummary[];
warnings: string[];
};
export function buildCanvasContext(state: CanvasSnapshot | null): CanvasContext {
if (!state) throw new Error("当前没有已连接画布");
const nodes = state.nodes || [];
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const selectedIds = new Set(state.selectedNodeIds || []);
const nodeTypeCounts = nodes.reduce<Record<string, number>>((counts, node) => {
counts[node.type] = (counts[node.type] || 0) + 1;
return counts;
}, {});
const resources = nodes.flatMap((node) => {
const resource = resourceSummary(node);
return resource ? [resource] : [];
});
const warnings: string[] = [];
if (!nodes.length) warnings.push("画布为空;创建前先确认用户要放置的区域或使用默认网格布局。");
if (nodes.some((node) => node.metadata?.status === "error")) warnings.push("画布中存在生成失败节点;重试前应读取节点的 errorDetails 或 generationErrorCode。");
if (resources.some((resource) => !resource.isReady)) warnings.push("存在尚未就绪或缺少持久化资源引用的媒体节点;不要把占位节点当作可用参考素材。");
return {
schemaVersion: 1,
stateHash: hashState(state),
canvas: {
projectId: state.projectId,
domainProjectId: state.domainProjectId,
title: state.title,
viewport: state.viewport,
nodeCount: nodes.length,
connectionCount: (state.connections || []).length,
selectedNodeCount: selectedIds.size,
nodeTypeCounts,
},
selection: nodes.filter((node) => selectedIds.has(node.id)).map(compactContextNode),
nodes: nodes.map(compactContextNode),
connections: (state.connections || []).map((connection) => connectionSummary(connection, nodeById)),
resources,
warnings,
};
}
export function findCanvasNodes(state: CanvasSnapshot | null, input: { query?: string; ids?: string[]; types?: string[]; statuses?: string[]; resourceOnly?: boolean; limit?: number }) {
if (!state) throw new Error("当前没有已连接画布");
const query = input.query?.trim().toLocaleLowerCase();
const ids = input.ids?.length ? new Set(input.ids) : null;
const types = input.types?.length ? new Set(input.types) : null;
const statuses = input.statuses?.length ? new Set(input.statuses) : null;
const limit = Math.min(Math.max(input.limit || 50, 1), 200);
const nodes = (state.nodes || []).filter((node) => {
const metadata = node.metadata || {};
if (ids && !ids.has(node.id)) return false;
if (types && !types.has(node.type)) return false;
if (statuses && !statuses.has(String(metadata.status || "idle"))) return false;
if (input.resourceOnly && !resourceSummary(node)) return false;
if (!query) return true;
return [node.id, node.title, metadata.content, metadata.prompt, metadata.composerContent, metadata.assetId, Array.isArray(metadata.assetTags) ? metadata.assetTags.join(" ") : "", metadata.workflowKind, metadata.workflowTitle, metadata.characterName]
.some((value) => String(value || "").toLocaleLowerCase().includes(query));
});
return { query: input.query || "", total: nodes.length, truncated: nodes.length > limit, nodes: nodes.slice(0, limit).map(compactContextNode) };
}
export function getCanvasResources(state: CanvasSnapshot | null, input: { nodeIds?: string[]; status?: string; limit?: number }) {
if (!state) throw new Error("当前没有已连接画布");
const nodeIds = input.nodeIds?.length ? new Set(input.nodeIds) : null;
const limit = Math.min(Math.max(input.limit || 100, 1), 300);
const resources = (state.nodes || []).flatMap((node) => {
if (nodeIds && !nodeIds.has(node.id)) return [];
const resource = resourceSummary(node);
if (!resource || (input.status && resource.status !== input.status)) return [];
return [resource];
});
return { total: resources.length, truncated: resources.length > limit, resources: resources.slice(0, limit) };
}
export function validateCanvasOps(state: CanvasSnapshot | null, ops: unknown[]) {
if (!state) throw new Error("当前没有已连接画布");
const nodes = state.nodes || [];
const nodeIds = new Set(nodes.map((node) => node.id));
const issues: Array<{ index: number; severity: "error" | "warning"; message: string }> = [];
const addedIds = new Set<string>();
const requireNode = (index: number, id: unknown, label: string) => {
if (typeof id !== "string" || !id) issues.push({ index, severity: "error", message: `${label} 缺少节点 id` });
else if (!nodeIds.has(id) && !addedIds.has(id)) issues.push({ index, severity: "error", message: `${label}「${id}」不在当前画布状态中;先重新读取 canvas_get_context 或 canvas_find_nodes` });
};
ops.forEach((raw, index) => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
issues.push({ index, severity: "error", message: "操作必须是对象" });
return;
}
const op = raw as Record<string, unknown>;
switch (op.type) {
case "add_node":
if (typeof op.id === "string") {
if (nodeIds.has(op.id) || addedIds.has(op.id)) issues.push({ index, severity: "error", message: `新增节点 id「${op.id}」重复` });
addedIds.add(op.id);
}
break;
case "update_node":
case "run_generation":
requireNode(index, op.nodeId ?? op.id, op.type === "run_generation" ? "生成目标" : "更新目标");
break;
case "delete_node":
if (Array.isArray(op.ids)) op.ids.forEach((id) => requireNode(index, id, "删除目标"));
else requireNode(index, op.id, "删除目标");
break;
case "connect_nodes":
requireNode(index, op.fromNodeId, "连接起点");
requireNode(index, op.toNodeId, "连接终点");
if (op.fromNodeId === op.toNodeId) issues.push({ index, severity: "error", message: "不能连接节点自身" });
break;
case "select_nodes":
if (Array.isArray(op.ids)) op.ids.forEach((id) => requireNode(index, id, "选区节点"));
break;
case "delete_connections":
if (!op.all && !op.id && !(Array.isArray(op.ids) && op.ids.length)) issues.push({ index, severity: "error", message: "删除连线必须提供 id、ids 或 all=true" });
break;
case "set_viewport":
if (!op.viewport || typeof op.viewport !== "object") issues.push({ index, severity: "error", message: "视口参数无效" });
break;
default:
issues.push({ index, severity: "error", message: `不支持的操作类型「${String(op.type)}」` });
}
});
const errors = issues.filter((item) => item.severity === "error");
return { ok: errors.length === 0, issues, operationCount: ops.length, currentStateHash: hashState(state) };
}
export function hashState(state: CanvasSnapshot) {
return crypto.createHash("sha256").update(stableStringify({
projectId: state.projectId,
domainProjectId: state.domainProjectId,
title: state.title,
nodes: state.nodes || [],
connections: state.connections || [],
selectedNodeIds: state.selectedNodeIds || [],
viewport: state.viewport,
})).digest("hex").slice(0, 16);
}
function compactContextNode(node: CanvasNode) {
const metadata = node.metadata || {};
const resource = resourceSummary(node);
return {
id: node.id,
type: node.type,
title: node.title || "未命名节点",
position: node.position,
size: { width: node.width, height: node.height },
parentId: node.parentId,
status: String(metadata.status || "idle"),
content: preview(metadata.content, 240),
prompt: preview(metadata.prompt || metadata.composerContent, 300),
generation: metadata.generationMode || metadata.workflowKind ? {
mode: metadata.generationMode,
model: metadata.model,
workflowKind: metadata.workflowKind,
workflowTitle: metadata.workflowTitle,
} : undefined,
asset: metadata.assetId || metadata.characterAssetId ? {
assetId: metadata.assetId || metadata.characterAssetId,
versionId: metadata.characterVersionId,
category: metadata.assetCategory,
tags: metadata.assetTags,
characterName: metadata.characterName,
} : undefined,
resource: resource ? { resourceId: resource.resourceId, storageKey: resource.storageKey, mimeType: resource.mimeType, bytes: resource.bytes, width: resource.width, height: resource.height, durationMs: resource.durationMs, ready: resource.isReady } : undefined,
};
}
function resourceSummary(node: CanvasNode): CanvasResourceSummary | null {
const metadata = node.metadata || {};
const storageKey = stringValue(metadata.storageKey);
const resourceId = storageKey?.startsWith("resource:") ? storageKey.slice("resource:".length) : undefined;
const hasResourceSignal = Boolean(storageKey || metadata.resourceId || metadata.assetId || metadata.primaryImageId || metadata.mimeType || ["image", "video", "audio"].includes(node.type));
if (!hasResourceSignal) return null;
const status = stringValue(metadata.status) || "idle";
return {
nodeId: node.id,
nodeTitle: node.title || "未命名节点",
nodeType: node.type,
status,
resourceId: resourceId || stringValue(metadata.resourceId),
storageKey,
assetId: stringValue(metadata.assetId || metadata.characterAssetId),
assetCategory: stringValue(metadata.assetCategory),
mimeType: stringValue(metadata.mimeType),
bytes: numberValue(metadata.bytes),
width: numberValue(metadata.naturalWidth),
height: numberValue(metadata.naturalHeight),
durationMs: numberValue(metadata.durationMs),
isReady: status === "success" && Boolean(storageKey || metadata.resourceId || metadata.primaryImageId),
};
}
function connectionSummary(connection: CanvasConnection, nodeById: Map<string, CanvasNode>) {
return {
id: connection.id,
fromNodeId: connection.fromNodeId,
fromTitle: nodeById.get(connection.fromNodeId)?.title || "未知节点",
toNodeId: connection.toNodeId,
toTitle: nodeById.get(connection.toNodeId)?.title || "未知节点",
fromHandleId: connection.fromHandleId,
toHandleId: connection.toHandleId,
};
}
function preview(value: unknown, limit: number) {
if (typeof value !== "string") return undefined;
const text = value.trim();
if (!text) return undefined;
return text.length > limit ? `${text.slice(0, limit)}…` : text;
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function numberValue(value: unknown) {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
if (value && typeof value === "object") return `{${Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(",")}}`;
return JSON.stringify(value);
}
+12 -1
View File
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
import type { ServerResponse } from "node:http";
import { CANVAS_GENERATION_CONTINUATION_TIMEOUT_MS } from "./canvas-tool-timeouts.js";
import { buildCanvasContext, findCanvasNodes, getCanvasResources, validateCanvasOps } from "./canvas-context.js";
import { type ToolName } from "./schemas.js";
import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInput } from "./tools.js";
import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
@@ -88,9 +89,13 @@ export class CanvasSession {
if (!input.projectId) throw new Error("当前画布没有关联短剧项目");
return await this.requestCanvasTool(tool, input);
}
const readTool = ["canvas_get_state", "canvas_get_selection", "canvas_export_snapshot"].includes(tool);
const readTool = ["canvas_get_state", "canvas_get_context", "canvas_find_nodes", "canvas_get_resources", "canvas_validate_ops", "canvas_get_selection", "canvas_export_snapshot"].includes(tool);
if (readTool && (!this.clients.size || !this.canvasState)) throw new Error("当前没有已连接画布");
if (tool === "canvas_get_state" || tool === "canvas_export_snapshot") return compactCanvasState(this.canvasState);
if (tool === "canvas_get_context") return buildCanvasContext(this.canvasState);
if (tool === "canvas_find_nodes") return findCanvasNodes(this.canvasState, input as Parameters<typeof findCanvasNodes>[1]);
if (tool === "canvas_get_resources") return getCanvasResources(this.canvasState, input as Parameters<typeof getCanvasResources>[1]);
if (tool === "canvas_validate_ops") return validateCanvasOps(this.canvasState, (input as { ops: unknown[] }).ops);
if (tool === "canvas_get_selection") {
const ids = new Set(this.canvasState?.selectedNodeIds || []);
return { nodes: (this.canvasState?.nodes || []).filter((node) => ids.has(node.id)).map(compactNode) };
@@ -176,6 +181,12 @@ export class CanvasSession {
}
if (tool !== "canvas_apply_ops") throw new Error(`未知工具:${tool}`);
if (!this.clients.size) throw new Error("当前没有已连接画布");
const expectedStateHash = typeof input.expectedStateHash === "string" ? input.expectedStateHash : "";
if (expectedStateHash && expectedStateHash !== buildCanvasContext(this.canvasState).stateHash) {
throw new Error("画布状态已变化,请重新读取 canvas_get_context 后再执行写操作");
}
const validation = validateCanvasOps(this.canvasState, (input as { ops: unknown[] }).ops);
if (!validation.ok) throw new Error(`画布操作校验失败:${validation.issues.filter((item) => item.severity === "error").map((item) => item.message).join(";")}`);
return await this.requestCanvasTool(tool, input);
}
+22 -1
View File
@@ -11,7 +11,28 @@ export const DEFAULT_PORT = LOCAL_RUNTIME_DEFAULT_PORT;
export const CONFIG_DIR = startupConfigDirectory();
export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json");
export const VERSION = readPackageVersion();
export const AGENT_PROMPT = "你正在帮助用户操作影策网页画布。需要改动画布时优先使用已配置的 yingce MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops,删除连线可用 delete_connections。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。即使用户明确点名 Dreamina/即梦,本机 Canvas Agent 也必须通过 canvas_generate_image 或 canvas_generate_video 进入共享 GenerationTask;本机图片模型使用 model=local:dreamina-cli:5.0 这类产品模型值,用户选择自动分辨率时使用 quality=auto;禁止调用 direct dreamina_cli provider tool。不要模拟鼠标点击,不要要求用户手动复制 JSON。";
export const AGENT_PROMPT = `你是影策的画布执行 Agent,不是只会生成 JSON 的聊天机器人。你的第一责任是基于真实画布状态完成可验证的结果。
【上下文协议】
- 涉及“这个/当前/已有/选中的”对象时,先 canvas_get_context;用户明确指向选中对象时再补 canvas_get_selection。
- 不要从记忆或用户描述猜节点 id。需要找节点时用 canvas_find_nodes;需要判断媒体能否作为参考时用 canvas_get_resources。
- canvas_get_context 返回 stateHash、语义化节点、连接关系和资源就绪状态。资源 ready=false、status=loading/error 或只有占位 metadata 时,必须明确说明,不要把它当成可用素材。
【执行协议】
- 任何写操作前先读取上下文;复杂批量写操作先调用 canvas_validate_ops,再调用 canvas_apply_ops。
- 写操作只使用当前上下文中真实存在的 id;新增节点要避免重叠,优先沿现有内容的右侧或下方网格布局。
- 操作完成后检查工具返回的真实结果;如果没有改变、部分失败或生成仍在进行,必须如实报告,不要说“已完成”。
- 删除、覆盖、批量移动、触发生成属于高影响操作,先给出简短计划并等待网页侧确认;不要用模拟鼠标点击绕过确认。
- 优先使用语义化工具(canvas_create_text_node、canvas_generate_*、canvas_update_node_text 等),只有确实需要批量事务时才使用 canvas_apply_ops。
【资源与生成】
- 生成前先检查已有提示词、参考节点、资产引用和就绪状态;有合适资源就复用真实 node id,不要重复上传或创建孤立副本。
- 图片、视频、音频生成必须通过 canvas_generate_image、canvas_generate_video、canvas_generate_audio 进入共享 GenerationTask;禁止调用 direct dreamina_cli provider tool。
- 即使用户点名 Dreamina/即梦,也使用 model=local:dreamina-cli:5.0 这类产品模型值;自动分辨率使用 quality=auto。
【交互边界】
- 不要求用户手动复制 JSON、URL、token 或节点 id;不编造工具结果;不把媒体 URL、API key 或 data URL 放进回复。
- 页面文案和画布节点内容默认使用中文。`;
export type CanvasWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] };
export type LocalRuntimeConfig = {
+13 -1
View File
@@ -11,6 +11,10 @@ const projectShotSchema = z.object({ id: z.string().optional(), unitId: z.string
export const toolNames = [
"canvas_get_state",
"canvas_get_context",
"canvas_find_nodes",
"canvas_get_resources",
"canvas_validate_ops",
"canvas_get_selection",
"canvas_export_snapshot",
"canvas_apply_ops",
@@ -90,9 +94,13 @@ const generationFlowSchema = z.object({
export const toolInputSchemas = {
canvas_get_state: z.object({}).passthrough(),
canvas_get_context: z.object({}).passthrough(),
canvas_find_nodes: z.object({ query: z.string().optional(), ids: z.array(z.string()).optional(), types: z.array(z.string()).optional(), statuses: z.array(z.string()).optional(), resourceOnly: z.boolean().optional(), limit: z.number().int().min(1).max(200).optional() }),
canvas_get_resources: z.object({ nodeIds: z.array(z.string()).optional(), status: z.string().optional(), limit: z.number().int().min(1).max(300).optional() }),
canvas_validate_ops: z.object({ ops: z.array(canvasOpSchema) }),
canvas_get_selection: z.object({}).passthrough(),
canvas_export_snapshot: z.object({}).passthrough(),
canvas_apply_ops: z.object({ ops: z.array(canvasOpSchema) }),
canvas_apply_ops: z.object({ ops: z.array(canvasOpSchema), expectedStateHash: z.string().min(1).optional() }),
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_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() }),
@@ -125,6 +133,10 @@ export const toolInputSchemas = {
export const toolDescriptions: Record<ToolName, string> = {
canvas_get_state: "读取当前网页画布的节点、连线、选区和视口。",
canvas_get_context: "读取面向 Agent 的语义化画布上下文:节点用途、连接关系、选区、资源引用、生成状态和状态哈希。优先于直接猜测节点 metadata。",
canvas_find_nodes: "按标题、内容、提示词、类型、状态、资产或工作流检索真实节点,返回可用于后续写操作的节点 id。",
canvas_get_resources: "读取当前画布引用的媒体资源清单,包括资源/资产引用、类型、尺寸、大小、时长和就绪状态;不会返回媒体 URL。",
canvas_validate_ops: "在真正写入前校验批量操作中的节点 id、连接关系和参数,避免对不存在节点或错误资源执行操作。",
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。",
+5 -2
View File
@@ -16,8 +16,11 @@ export function compactCanvasState(state: CanvasSnapshot | null) {
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 };
for (const key of ["url", "dataUrl", "previewUrl", "coverUrl"]) delete metadata[key];
for (const key of ["content", "prompt", "composerContent", "errorDetails"]) {
if (typeof metadata[key] === "string" && metadata[key].length > 360) metadata[key] = `${metadata[key].slice(0, 340)}...`;
}
return { id: node.id, type: node.type, title: node.title, position: node.position, width: node.width, height: node.height, parentId: node.parentId, metadata };
}
export function nextCanvasX(state: CanvasSnapshot | null) {