diff --git a/README.md b/README.md index b1a0b92b..82a335c5 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ Repository file: [docs/openapi.json](docs/openapi.json). ## MCP -Create an API token in **Profile** -> **MCP settings** and give it to your AI Agent. The Agent can then securely read, organize, and import notes, manage note templates and AI instructions, and connect your notes with tools such as Notion databases and Feishu Bitable—all within your account permissions. +Create an API token in **Profile** -> **MCP settings** and give it to your AI Agent. The Agent can then securely read, organize, and import notes, use `create_diagram_memo` to create editable mind map, flowchart, and architecture diagram notes from semantic graphs with automatic sizing and layout, manage note templates and AI instructions, and connect your notes with tools such as Notion databases and Feishu Bitable—all within your account permissions. > Let your ideas run free: ask an AI Agent to organize fleeting thoughts, build a personal knowledge graph, create a profile from your notes, or tag them automatically. diff --git a/README.zh-CN.md b/README.zh-CN.md index 86122075..617aa975 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -231,7 +231,7 @@ https://你的域名/api/openapi.json ## MCP -在 **个人中心** -> **MCP 设置** 中创建 API Token 并交给 AI Agent,即可让 Agent 在账号授权范围内安全地读取、整理和导入笔记,管理笔记模板与 AI 指令,并与 Notion Database、飞书多维表格等工具联动。 +在 **个人中心** -> **MCP 设置** 中创建 API Token 并交给 AI Agent,即可让 Agent 在账号授权范围内安全地读取、整理和导入笔记,使用 `create_diagram_memo` 通过语义图数据创建可编辑的思维导图、流程图和架构图笔记,节点尺寸与排版由 EdgeEver 自动生成;同时还可管理笔记模板与 AI 指令,并与 Notion Database、飞书多维表格等工具联动。 > 放飞你的想法:让 AI Agent 归纳随手记录的灵感、构建个人知识图谱、根据笔记生成用户画像,或自动为笔记打标签。 diff --git a/apps/api/src/mcp-management-tools.test.mjs b/apps/api/src/mcp-management-tools.test.mjs index 0f3f0fb3..1d31bd85 100644 --- a/apps/api/src/mcp-management-tools.test.mjs +++ b/apps/api/src/mcp-management-tools.test.mjs @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { globSync, readFileSync } from "node:fs"; import { Database } from "bun:sqlite"; import { callMcpTool } from "./index.ts"; +import { parseDiagramDocument } from "@edgeever/shared"; class SqliteD1PreparedStatement { constructor(db, sql, bindings = []) { @@ -71,6 +72,65 @@ const createFixture = (scopes = ["read:memos", "write:memos"]) => { }; describe("MCP template and AI instruction management", () => { + test.each([ + ["mind-map", [ + { id: "root", label: "Root" }, + { id: "branch", label: "Branch", parentId: "root" }, + ], []], + ["flowchart", [ + { id: "start", label: "Start", type: "start" }, + { id: "pay", label: "Pay", type: "process" }, + ], [{ source: "start", target: "pay", label: "Continue" }]], + ["architecture", [ + { id: "system", label: "System", type: "boundary" }, + { id: "api", label: "API", type: "service", parentId: "system" }, + ], []], + ])("creates editable %s diagram memos from structured graphs", async (kind, nodes, edges) => { + const { sqlite, auth, context } = createFixture(); + sqlite.query("INSERT INTO notebooks (id, workspace_id, name) VALUES (?, ?, ?)") + .run("nb_diagrams", "ws_mcp", "Diagrams"); + + const created = await callMcpTool(context, auth, "create_diagram_memo", { + notebookId: "nb_diagrams", + title: "Generated diagram", + kind, + theme: "brand", + tags: ["design"], + nodes, + edges, + }); + + expect(created).toMatchObject({ diagramKind: kind, memo: { title: "Generated diagram", tags: ["design"] } }); + const diagram = parseDiagramDocument(created.memo.contentMarkdown); + expect(diagram).toMatchObject({ kind, theme: "brand" }); + expect(diagram.nodes.map((node) => node.id)).toEqual(nodes.map((node) => node.id)); + expect(diagram.nodes.every((node) => Number.isFinite(node.x) && Number.isFinite(node.y))).toBeTrue(); + expect(diagram.nodes.every((node) => node.width > 0 && node.height > 0)).toBeTrue(); + if (kind === "mind-map") expect(diagram.edges).toHaveLength(1); + if (kind === "flowchart") { + expect(diagram.edges).toMatchObject([{ id: "edge-1", source: "start", target: "pay" }]); + expect(diagram.nodes.find((node) => node.id === "start").y) + .toBeLessThan(diagram.nodes.find((node) => node.id === "pay").y); + } + if (kind === "architecture") { + const boundary = diagram.nodes.find((node) => node.id === "system"); + const api = diagram.nodes.find((node) => node.id === "api"); + expect(boundary.x).toBeLessThan(api.x); + expect(boundary.y).toBeLessThan(api.y); + expect(boundary.x + boundary.width).toBeGreaterThan(api.x + api.width); + } + }); + + test("rejects invalid diagram graphs before creating a memo", async () => { + const { auth, context } = createFixture(); + await expect(callMcpTool(context, auth, "create_diagram_memo", { + notebookId: "nb_diagrams", + kind: "mind-map", + nodes: [{ id: "root", label: "Root", type: "database" }], + edges: [], + })).rejects.toMatchObject({ code: "invalid_params" }); + }); + test("manages note templates within the authenticated workspace", async () => { const { sqlite, auth, context } = createFixture(); diff --git a/apps/api/src/mcp-tool-service.ts b/apps/api/src/mcp-tool-service.ts index e8a3a2c5..01f2d922 100644 --- a/apps/api/src/mcp-tool-service.ts +++ b/apps/api/src/mcp-tool-service.ts @@ -1,13 +1,17 @@ import { AiPromptTemplateCreateSchema, AiPromptTemplateUpdateSchema, + ARCHITECTURE_RESOURCE_ICONS, TemplateCreateSchema, TemplateUpdateSchema, markdownToDoc, + parseDiagramDocument, + serializeDiagramDocument, type MemoDetail, type MemoSummary, type MemoUpdateInput, } from "@edgeever/shared"; +import type { DiagramIr, DiagramIrNodeType } from "@edgeever/shared/diagram-layout"; import { audit, auditStatement } from "./audit"; import type { AppContext, AuditActor, AuthContext, Bindings } from "./api-context"; import { AppError } from "./app-error"; @@ -192,6 +196,155 @@ const assertMcpMutationAllowed = (environment: Bindings) => { } }; +const DIAGRAM_IR_NODE_TYPES = new Set([ + "topic", "process", "decision", "start", "end", "client", "frontend", "service", "database", "storage", + "queue", "security", "external", "boundary", +]); +const DIAGRAM_EDGE_KINDS = new Set(["dependency", "request", "async", "data"]); +const DIAGRAM_MEMO_KEYS = new Set(["notebookId", "title", "kind", "theme", "layout", "tags", "nodes", "edges"]); +const DIAGRAM_NODE_KEYS = new Set(["id", "label", "type", "parentId", "resourceIcon"]); +const DIAGRAM_EDGE_KEYS = new Set(["source", "target", "label", "type", "bidirectional"]); + +const isPlainRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === "object" && !Array.isArray(value); + +const assertAllowedKeys = (value: Record, allowed: Set, path: string) => { + const unexpected = Object.keys(value).find((key) => !allowed.has(key)); + if (unexpected) throw new AppError("invalid_params", `${path}.${unexpected} is not supported`, 400); +}; + +const parseDiagramNode = (value: unknown, index: number) => { + const path = `nodes.${index}`; + if (!isPlainRecord(value)) throw new AppError("invalid_params", `${path} must be an object`, 400); + assertAllowedKeys(value, DIAGRAM_NODE_KEYS, path); + const id = getRequiredString(value.id, `${path}.id`); + if (id.length > 100) throw new AppError("invalid_params", `${path}.id must be at most 100 characters`, 400); + if (typeof value.label !== "string" || value.label.length > 500) { + throw new AppError("invalid_params", `${path}.label must be a string with at most 500 characters`, 400); + } + if (value.type !== undefined && ( + typeof value.type !== "string" || !DIAGRAM_IR_NODE_TYPES.has(value.type as DiagramIrNodeType) + )) { + throw new AppError("invalid_params", `${path}.type is not supported`, 400); + } + if (value.parentId !== undefined && (typeof value.parentId !== "string" || !value.parentId.trim() || value.parentId.length > 100)) { + throw new AppError("invalid_params", `${path}.parentId must be a non-empty string with at most 100 characters`, 400); + } + if (value.resourceIcon !== undefined && ( + typeof value.resourceIcon !== "string" + || !ARCHITECTURE_RESOURCE_ICONS.includes(value.resourceIcon as typeof ARCHITECTURE_RESOURCE_ICONS[number]) + )) { + throw new AppError("invalid_params", `${path}.resourceIcon is not supported`, 400); + } + return { ...value, id } as DiagramIr["nodes"][number]; +}; + +const parseDiagramEdge = (value: unknown, index: number) => { + const path = `edges.${index}`; + if (!isPlainRecord(value)) throw new AppError("invalid_params", `${path} must be an object`, 400); + assertAllowedKeys(value, DIAGRAM_EDGE_KEYS, path); + const source = getRequiredString(value.source, `${path}.source`); + const target = getRequiredString(value.target, `${path}.target`); + if ([source, target].some((item) => item.length > 100)) { + throw new AppError("invalid_params", `${path} node IDs must be at most 100 characters`, 400); + } + if (value.label !== undefined && (typeof value.label !== "string" || value.label.length > 500)) { + throw new AppError("invalid_params", `${path}.label must be a string with at most 500 characters`, 400); + } + if (value.type !== undefined && (typeof value.type !== "string" || !DIAGRAM_EDGE_KINDS.has(value.type))) { + throw new AppError("invalid_params", `${path}.type is not supported`, 400); + } + if (value.bidirectional !== undefined && typeof value.bidirectional !== "boolean") { + throw new AppError("invalid_params", `${path}.bidirectional must be a boolean`, 400); + } + return { ...value, source, target } as NonNullable[number]; +}; + +const parseDiagramMemoIr = (args: Record): DiagramIr => { + assertAllowedKeys(args, DIAGRAM_MEMO_KEYS, "arguments"); + const kind = getRequiredString(args.kind, "kind"); + if (!(["mind-map", "flowchart", "architecture"] as const).includes(kind as DiagramIr["kind"])) { + throw new AppError("invalid_params", "kind must be mind-map, flowchart, or architecture", 400); + } + if (!Array.isArray(args.nodes) || args.nodes.length < 1 || args.nodes.length > 200) { + throw new AppError("invalid_params", "nodes must include between 1 and 200 items", 400); + } + if (args.edges !== undefined && (!Array.isArray(args.edges) || args.edges.length > 400)) { + throw new AppError("invalid_params", "edges must be an array with at most 400 items", 400); + } + if (args.theme !== undefined && !["brand", "ocean", "ink"].includes(String(args.theme))) { + throw new AppError("invalid_params", "theme must be brand, ocean, or ink", 400); + } + if (args.title !== undefined && (typeof args.title !== "string" || args.title.length > 160)) { + throw new AppError("invalid_params", "title must be a string with at most 160 characters", 400); + } + if (args.tags !== undefined && ( + !Array.isArray(args.tags) || args.tags.length > 100 || args.tags.some((tag) => typeof tag !== "string") + )) { + throw new AppError("invalid_params", "tags must be an array of at most 100 strings", 400); + } + if (args.layout !== undefined && ( + !isPlainRecord(args.layout) + || Object.keys(args.layout).some((key) => key !== "direction") + || (args.layout.direction !== undefined && !["left-to-right", "top-to-bottom"].includes(String(args.layout.direction))) + )) { + throw new AppError("invalid_params", "layout may only specify direction as left-to-right or top-to-bottom", 400); + } + + const ir: DiagramIr = { + kind: kind as DiagramIr["kind"], + ...(args.theme === undefined ? {} : { theme: args.theme as DiagramIr["theme"] }), + ...(args.layout === undefined ? {} : { layout: args.layout as DiagramIr["layout"] }), + nodes: args.nodes.map(parseDiagramNode), + edges: (args.edges as unknown[] | undefined)?.map(parseDiagramEdge), + }; + const nodeIds = new Set(ir.nodes.map((node) => node.id)); + if (nodeIds.size !== ir.nodes.length) throw new AppError("invalid_params", "node IDs must be unique", 400); + for (const node of ir.nodes) { + const allowedTypes = ir.kind === "mind-map" + ? new Set([undefined, "topic"]) + : ir.kind === "flowchart" + ? new Set([undefined, "process", "decision", "start", "end"]) + : new Set([undefined, "client", "frontend", "service", "database", "storage", "queue", "security", "external", "boundary"]); + if (!allowedTypes.has(node.type)) { + throw new AppError("invalid_params", `${node.type} is not a valid node type for ${ir.kind}`, 400); + } + if (ir.kind === "flowchart" && node.parentId) { + throw new AppError("invalid_params", "flowchart nodes cannot use parentId", 400); + } + if (ir.kind !== "architecture" && node.resourceIcon) { + throw new AppError("invalid_params", "resourceIcon is only available for architecture nodes", 400); + } + if (node.parentId && !nodeIds.has(node.parentId)) { + throw new AppError("invalid_params", `${node.id}.parentId must reference an existing node`, 400); + } + if (ir.kind === "architecture" && node.parentId && ir.nodes.find((candidate) => candidate.id === node.parentId)?.type !== "boundary") { + throw new AppError("invalid_params", `${node.id}.parentId must reference an architecture boundary`, 400); + } + } + for (const edge of ir.edges ?? []) { + if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) { + throw new AppError("invalid_params", "every edge endpoint must reference an existing node", 400); + } + if (ir.kind === "architecture" && ir.nodes.some((node) => (node.id === edge.source || node.id === edge.target) && node.type === "boundary")) { + throw new AppError("invalid_params", "architecture boundaries cannot be edge endpoints", 400); + } + } + const parentByNodeId = new Map(ir.nodes.flatMap((node) => node.parentId ? [[node.id, node.parentId]] : [])); + for (const node of ir.nodes) { + const ancestors = new Set(); + let currentId: string | undefined = node.id; + while (currentId) { + if (ancestors.has(currentId)) { + throw new AppError("invalid_params", "diagram parent relationships must not contain cycles", 400); + } + ancestors.add(currentId); + currentId = parentByNodeId.get(currentId); + } + } + return ir; +}; + export const callMcpTool = async ( c: AppContext, auth: AuthContext, @@ -276,6 +429,25 @@ export const callMcpTool = async ( return { memo }; } + case "create_diagram_memo": { + assertScope(auth, "write:memos"); + const notebookId = getRequiredString(args.notebookId, "notebookId"); + const ir = parseDiagramMemoIr(args); + const { compileDiagramIr } = await import("@edgeever/shared/diagram-layout"); + const document = compileDiagramIr(ir); + const contentMarkdown = serializeDiagramDocument(document); + if (!parseDiagramDocument(contentMarkdown)) { + throw new AppError("invalid_params", "The diagram graph could not be compiled", 400); + } + const memo = await createMemoRecord(c.env.storage.db, auth.workspaceId, { + notebookId, + title: getOptionalString(args.title) ?? undefined, + contentMarkdown, + tags: getOptionalStringArray(args.tags), + }, getAuditActor(c), getActorLabel(c)); + + return { memo, diagramKind: document.kind }; + } case "import_memos": { assertScope(auth, "write:memos"); return await importMemosRecord(c.env.storage.db, auth.workspaceId, { diff --git a/apps/api/src/mcp-tools.test.mjs b/apps/api/src/mcp-tools.test.mjs index 344df106..7197d2bc 100644 --- a/apps/api/src/mcp-tools.test.mjs +++ b/apps/api/src/mcp-tools.test.mjs @@ -32,6 +32,26 @@ describe("MCP tool catalog", () => { destructiveHint: false, idempotentHint: true, }); + expect(byName.get("create_diagram_memo")?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + }); + expect(byName.get("create_diagram_memo")?.inputSchema).toMatchObject({ + required: ["notebookId", "kind", "nodes"], + properties: { + kind: { enum: ["mind-map", "flowchart", "architecture"] }, + nodes: { + minItems: 1, + maxItems: 200, + items: { required: ["id", "label"] }, + }, + edges: { maxItems: 400 }, + layout: { properties: { direction: { enum: ["left-to-right", "top-to-bottom"] } } }, + }, + }); + expect(byName.get("create_diagram_memo")?.inputSchema.properties.nodes.items.properties.x).toBeUndefined(); + expect(byName.get("create_diagram_memo")?.inputSchema.properties.nodes.items.properties.width).toBeUndefined(); expect(byName.get("rename_notebook")?.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false, diff --git a/apps/api/src/mcp-tools.ts b/apps/api/src/mcp-tools.ts index d7fbf641..17f7489c 100644 --- a/apps/api/src/mcp-tools.ts +++ b/apps/api/src/mcp-tools.ts @@ -1,3 +1,22 @@ +import { ARCHITECTURE_RESOURCE_ICONS } from "@edgeever/shared"; + +const DIAGRAM_IR_NODE_TYPES = [ + "topic", + "process", + "decision", + "start", + "end", + "client", + "frontend", + "service", + "database", + "storage", + "queue", + "security", + "external", + "boundary", +] as const; + const MCP_TOOL_DEFINITIONS = [ { name: "get_current_user", @@ -74,6 +93,63 @@ const MCP_TOOL_DEFINITIONS = [ }, }, }, + { + name: "create_diagram_memo", + description: + "Create an editable visual diagram memo from a semantic graph; EdgeEver generates node sizes, coordinates, edge IDs, and a deterministic layout. For mind maps, omit node type and use parentId for hierarchy. Flowchart node types are process, decision, start, or end. Architecture node types are client, frontend, service, database, storage, queue, security, external, or boundary; boundary nodes may contain nodes through parentId but cannot be edge endpoints.", + inputSchema: { + type: "object", + required: ["notebookId", "kind", "nodes"], + additionalProperties: false, + properties: { + notebookId: { type: "string", minLength: 1 }, + title: { type: "string", maxLength: 160 }, + kind: { type: "string", enum: ["mind-map", "flowchart", "architecture"] }, + theme: { type: "string", enum: ["brand", "ocean", "ink"] }, + layout: { + type: "object", + additionalProperties: false, + properties: { + direction: { type: "string", enum: ["left-to-right", "top-to-bottom"] }, + }, + }, + tags: { type: "array", maxItems: 100, items: { type: "string" } }, + nodes: { + type: "array", + minItems: 1, + maxItems: 200, + items: { + type: "object", + required: ["id", "label"], + additionalProperties: false, + properties: { + id: { type: "string", minLength: 1, maxLength: 100 }, + label: { type: "string", maxLength: 500 }, + type: { type: "string", enum: [...DIAGRAM_IR_NODE_TYPES] }, + parentId: { type: "string", minLength: 1, maxLength: 100 }, + resourceIcon: { type: "string", enum: [...ARCHITECTURE_RESOURCE_ICONS] }, + }, + }, + }, + edges: { + type: "array", + maxItems: 400, + items: { + type: "object", + required: ["source", "target"], + additionalProperties: false, + properties: { + source: { type: "string", minLength: 1, maxLength: 100 }, + target: { type: "string", minLength: 1, maxLength: 100 }, + label: { type: "string", maxLength: 500 }, + type: { type: "string", enum: ["dependency", "request", "async", "data"] }, + bidirectional: { type: "boolean" }, + }, + }, + }, + }, + }, + }, { name: "import_memos", description: @@ -642,6 +718,7 @@ const READ_ONLY_MCP_TOOLS = new Set([ ]); const NON_DESTRUCTIVE_MCP_TOOLS = new Set([ "create_memo", + "create_diagram_memo", "import_memos", "restore_memos", "move_memos", diff --git a/apps/web/package.json b/apps/web/package.json index 09c795b7..700e4dc0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,6 @@ "dependencies": { "@antv/x6": "3.1.8", "@codemirror/lang-markdown": "^6.5.2", - "@dagrejs/dagre": "3.1.1", "@edgeever/client": "workspace:*", "@floating-ui/react-dom": "^2.1.8", "@fontsource-variable/noto-sans-sc": "^5.2.8", diff --git a/apps/web/src/lib/diagram-layout.test.mjs b/apps/web/src/lib/diagram-layout.test.mjs index 69f2e8e1..72dace23 100644 --- a/apps/web/src/lib/diagram-layout.test.mjs +++ b/apps/web/src/lib/diagram-layout.test.mjs @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { createDefaultDiagramDocument } from "@edgeever/shared"; -import { compactArchitectureNodeSize, compactFlowchartNodeSize, compactMindMapNodeSize, computeDiagramLayout } from "./diagram-layout.ts"; +import { compileDiagramIr, compactArchitectureNodeSize, compactFlowchartNodeSize, compactMindMapNodeSize, computeDiagramLayout } from "./diagram-layout.ts"; describe("diagram auto layout", () => { test("places mind-map children to the right of their root", () => { @@ -74,4 +74,27 @@ describe("diagram auto layout", () => { expect(compactArchitectureNodeSize("database")).toEqual({ width: 150, height: 72 }); expect(compactArchitectureNodeSize("boundary", { width: 640, height: 360 })).toEqual({ width: 640, height: 360 }); }); + + test("separates generated architecture boundaries and keeps their children inside", () => { + const document = compileDiagramIr({ + kind: "architecture", + nodes: [ + { id: "one", label: "One", type: "boundary" }, + { id: "one-api", label: "API one", type: "service", parentId: "one" }, + { id: "two", label: "Two", type: "boundary" }, + { id: "two-api", label: "API two", type: "service", parentId: "two" }, + ], + }); + const one = document.nodes.find((node) => node.id === "one"); + const oneApi = document.nodes.find((node) => node.id === "one-api"); + const two = document.nodes.find((node) => node.id === "two"); + const twoApi = document.nodes.find((node) => node.id === "two-api"); + expect(one.y + one.height + 40).toBeLessThanOrEqual(two.y); + for (const [boundary, child] of [[one, oneApi], [two, twoApi]]) { + expect(child.x).toBeGreaterThan(boundary.x); + expect(child.y).toBeGreaterThan(boundary.y); + expect(child.x + child.width).toBeLessThan(boundary.x + boundary.width); + expect(child.y + child.height).toBeLessThan(boundary.y + boundary.height); + } + }); }); diff --git a/apps/web/src/lib/diagram-layout.ts b/apps/web/src/lib/diagram-layout.ts index 45780d8e..e1aaeb87 100644 --- a/apps/web/src/lib/diagram-layout.ts +++ b/apps/web/src/lib/diagram-layout.ts @@ -1,170 +1,7 @@ -import { graphlib, layout as runDagreLayout } from "@dagrejs/dagre"; -import type { DiagramDocument } from "@edgeever/shared"; - -export type DiagramLayoutPositions = Record; - -export type DiagramLayoutOptions = { - insertedNodeId?: string; - insertAfterNodeId?: string; -}; - -const MIND_MAP_HORIZONTAL_GAP = 72; -const MIND_MAP_VERTICAL_GAP = 16; - -const visualTextUnits = (label: string) => Array.from(label).reduce( - (total, character) => total + (/[^\u0000-\u00ff]/.test(character) ? 1 : 0.55), - 0, -); - -export const compactMindMapNodeSize = (label: string, isRoot: boolean) => ({ - width: Math.round(Math.min(isRoot ? 168 : 156, Math.max(isRoot ? 112 : 92, visualTextUnits(label) * 13 + 28))), - height: isRoot ? 42 : 36, -}); - -export const compactFlowchartNodeSize = (shape: DiagramDocument["nodes"][number]["shape"]) => ( - shape === "decision" - ? { width: 116, height: 72 } - : shape === "terminator" - ? { width: 116, height: 44 } - : { width: 124, height: 44 } -); - -export const compactArchitectureNodeSize = ( - shape: DiagramDocument["nodes"][number]["shape"], - authored?: { width: number; height: number }, -) => { - if (shape === "boundary") return authored ?? { width: 560, height: 320 }; - if (shape === "database") return { width: 150, height: 72 }; - if (shape === "queue") return { width: 156, height: 60 }; - if (shape === "security") return { width: 148, height: 68 }; - return { width: 156, height: 64 }; -}; - -const computeMindMapLayout = ( - document: DiagramDocument, - options: DiagramLayoutOptions, -): DiagramLayoutPositions => { - const nodeById = new Map(document.nodes.map((node) => [node.id, node])); - const childrenByParent = new Map(); - for (const node of document.nodes) { - if (!node.parentId || !nodeById.has(node.parentId)) continue; - const children = childrenByParent.get(node.parentId) ?? []; - children.push(node.id); - childrenByParent.set(node.parentId, children); - } - - for (const children of childrenByParent.values()) { - children.sort((leftId, rightId) => { - const left = nodeById.get(leftId)!; - const right = nodeById.get(rightId)!; - return left.y - right.y || left.x - right.x || left.id.localeCompare(right.id); - }); - } - - if (options.insertedNodeId && options.insertAfterNodeId) { - const inserted = nodeById.get(options.insertedNodeId); - const target = nodeById.get(options.insertAfterNodeId); - if (inserted?.parentId && inserted.parentId === target?.parentId) { - const siblings = childrenByParent.get(inserted.parentId); - if (siblings) { - const withoutInserted = siblings.filter((id) => id !== inserted.id); - const targetIndex = withoutInserted.indexOf(target.id); - withoutInserted.splice(targetIndex < 0 ? withoutInserted.length : targetIndex + 1, 0, inserted.id); - childrenByParent.set(inserted.parentId, withoutInserted); - } - } - } - - const subtreeHeights = new Map(); - const measureSubtree = (nodeId: string, ancestors: Set): number => { - const cached = subtreeHeights.get(nodeId); - if (cached !== undefined) return cached; - const node = nodeById.get(nodeId); - if (!node || ancestors.has(nodeId)) return 0; - const nextAncestors = new Set(ancestors).add(nodeId); - const childHeights = (childrenByParent.get(nodeId) ?? []) - .map((childId) => measureSubtree(childId, nextAncestors)) - .filter((height) => height > 0); - const childrenHeight = childHeights.reduce((total, height) => total + height, 0) - + Math.max(0, childHeights.length - 1) * MIND_MAP_VERTICAL_GAP; - const height = Math.max(node.height, childrenHeight); - subtreeHeights.set(nodeId, height); - return height; - }; - - const positions: DiagramLayoutPositions = Object.fromEntries( - document.nodes.map((node) => [node.id, { x: node.x, y: node.y }]), - ); - const placeChildren = (nodeId: string, subtreeTop: number, ancestors: Set) => { - const node = nodeById.get(nodeId); - if (!node || ancestors.has(nodeId)) return; - const nextAncestors = new Set(ancestors).add(nodeId); - const childIds = (childrenByParent.get(nodeId) ?? []) - .filter((childId) => !nextAncestors.has(childId)); - const childHeights = childIds.map((childId) => measureSubtree(childId, nextAncestors)); - const childrenHeight = childHeights.reduce((total, height) => total + height, 0) - + Math.max(0, childHeights.length - 1) * MIND_MAP_VERTICAL_GAP; - let cursor = subtreeTop + (measureSubtree(nodeId, ancestors) - childrenHeight) / 2; - for (let index = 0; index < childIds.length; index += 1) { - const childId = childIds[index]; - const child = nodeById.get(childId)!; - const childSubtreeHeight = childHeights[index]; - positions[childId] = { - x: positions[nodeId].x + node.width + MIND_MAP_HORIZONTAL_GAP, - y: Math.round(cursor + (childSubtreeHeight - child.height) / 2), - }; - placeChildren(childId, cursor, nextAncestors); - cursor += childSubtreeHeight + MIND_MAP_VERTICAL_GAP; - } - }; - - const roots = document.nodes - .filter((node) => !node.parentId || !nodeById.has(node.parentId)) - .sort((left, right) => left.y - right.y || left.id.localeCompare(right.id)); - for (const root of roots) { - const subtreeHeight = measureSubtree(root.id, new Set()); - const subtreeTop = root.y + root.height / 2 - subtreeHeight / 2; - placeChildren(root.id, subtreeTop, new Set()); - } - return positions; -}; - -export const computeDiagramLayout = ( - document: DiagramDocument, - options: DiagramLayoutOptions = {}, -): DiagramLayoutPositions => { - if (document.kind === "mind-map") return computeMindMapLayout(document, options); - const layoutGraph = new graphlib.Graph(); - layoutGraph.setGraph({ - rankdir: "LR", - ranksep: 96, - nodesep: 40, - marginx: 32, - marginy: 32, - }); - layoutGraph.setDefaultEdgeLabel(() => ({})); - - const layoutNodes = document.kind === "architecture" - ? document.nodes.filter((node) => node.shape !== "boundary") - : document.nodes; - const layoutNodeIds = new Set(layoutNodes.map((node) => node.id)); - for (const node of layoutNodes) { - layoutGraph.setNode(node.id, { width: node.width, height: node.height }); - } - for (const edge of document.edges) { - if (layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target)) { - layoutGraph.setEdge(edge.source, edge.target); - } - } - - runDagreLayout(layoutGraph); - return Object.fromEntries(document.nodes.flatMap((node) => { - if (node.shape === "boundary") return [[node.id, { x: node.x, y: node.y }]]; - const position = layoutGraph.node(node.id) as { x: number; y: number } | undefined; - if (!position) return []; - return [[node.id, { - x: Math.round(position.x - node.width / 2), - y: Math.round(position.y - node.height / 2), - }]]; - })); -}; +export { + compileDiagramIr, + compactArchitectureNodeSize, + compactFlowchartNodeSize, + compactMindMapNodeSize, + computeDiagramLayout, +} from "@edgeever/shared/diagram-layout"; diff --git a/bun.lock b/bun.lock index 534b7e94..4a51f8ae 100644 --- a/bun.lock +++ b/bun.lock @@ -214,7 +214,6 @@ "dependencies": { "@antv/x6": "3.1.8", "@codemirror/lang-markdown": "^6.5.2", - "@dagrejs/dagre": "3.1.1", "@edgeever/client": "workspace:*", "@floating-ui/react-dom": "^2.1.8", "@fontsource-variable/noto-sans-sc": "^5.2.8", @@ -253,6 +252,7 @@ "name": "@edgeever/shared", "version": "0.1.3", "dependencies": { + "@dagrejs/dagre": "3.1.1", "@tiptap/core": "3.31.0", "@tiptap/extension-image": "3.31.0", "@tiptap/extension-link": "3.31.0", diff --git a/docs/visual-diagram-notes.md b/docs/visual-diagram-notes.md index dae4fcbf..7927bcef 100644 --- a/docs/visual-diagram-notes.md +++ b/docs/visual-diagram-notes.md @@ -78,6 +78,8 @@ The native apps currently hide regular rich-text editing, double-tap editing, an For MCP and AI, the IR is a positive capability and the foundation of a stable tool protocol. An AI can work with semantic IR instead of producing X6 internals, SVG, or an entire Mermaid document. It can therefore perform a local request such as “add a Redis cache between the API and database” while preserving the user's other nodes and layout decisions. +The `create_diagram_memo` MCP tool accepts this semantic creation IR: node identities, labels, semantic types, containment, and connections. EdgeEver generates edge identities, node dimensions, coordinates, deterministic layout, and architecture boundary geometry before compiling the result into the persisted `DiagramDocument`. An optional layout direction is a hint rather than authored geometry. + MCP should expose small, explicit operations such as reading a diagram, adding or updating a node, connecting components, moving a node into a boundary, validating changes, and applying layout. It should not rely solely on a high-risk `replace_diagram_ir` operation. The recommended flow is: ```text diff --git a/docs/visual-diagram-notes.zh-CN.md b/docs/visual-diagram-notes.zh-CN.md index cd42bd97..5a6d8f6d 100644 --- a/docs/visual-diagram-notes.zh-CN.md +++ b/docs/visual-diagram-notes.zh-CN.md @@ -78,6 +78,8 @@ EdgeEver 已经围绕 AntV X6 建立了选择、拖拽、缩放、连线、撤 对于 MCP 和 AI,IR 是正向能力,也是稳定的工具协议基础。AI 面向语义化 IR 工作,不必生成 X6 内部配置、SVG 或整张 Mermaid 源码,因此可以理解和执行“在 API 与数据库之间增加 Redis 缓存”这类局部修改,同时保留用户已经调整好的其他节点和布局。 +`create_diagram_memo` MCP 工具已接收这层语义创建 IR:节点标识、名称、语义类型、容器关系与连接关系。EdgeEver 会生成连线标识、节点尺寸、坐标、确定性布局和架构边界几何信息,再编译为持久化 `DiagramDocument`。可选的布局方向只是提示,不是由 Agent 编排的几何数据。 + MCP 应优先暴露小而明确的操作,例如读取图表、增加或更新节点、连接组件、移动到系统边界、校验变更和应用布局,而不是只提供一个高风险的 `replace_diagram_ir`。推荐流程是: ```text diff --git a/packages/shared/package.json b/packages/shared/package.json index ccb4642b..1280f3ce 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -5,6 +5,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./diagram-layout": "./src/diagram-layout.ts", "./ai-prompt-seeds": "./src/ai-prompt-seeds.ts", "./deployment-metadata": "./src/deployment-metadata.ts", "./editor-link": "./src/editor-link.ts", @@ -20,6 +21,7 @@ }, "types": "./src/index.ts", "dependencies": { + "@dagrejs/dagre": "3.1.1", "@tiptap/core": "3.31.0", "@tiptap/extension-image": "3.31.0", "@tiptap/extension-link": "3.31.0", diff --git a/packages/shared/src/diagram-layout.ts b/packages/shared/src/diagram-layout.ts new file mode 100644 index 00000000..6d4b12cd --- /dev/null +++ b/packages/shared/src/diagram-layout.ts @@ -0,0 +1,329 @@ +import { graphlib, layout as runDagreLayout } from "@dagrejs/dagre"; +import { + ARCHITECTURE_DIAGRAM_SCHEMA_VERSION, + DIAGRAM_SCHEMA_VERSION, + type ArchitectureResourceIcon, + type DiagramDocument, + type DiagramEdgeKind, + type DiagramKind, + type DiagramNodeShape, + type DiagramTheme, +} from "./diagram"; + +export type DiagramLayoutPositions = Record; + +export type DiagramLayoutOptions = { + insertedNodeId?: string; + insertAfterNodeId?: string; + direction?: "left-to-right" | "top-to-bottom"; +}; + +export type DiagramIrNodeType = + | "topic" + | "process" + | "decision" + | "start" + | "end" + | "client" + | "frontend" + | "service" + | "database" + | "storage" + | "queue" + | "security" + | "external" + | "boundary"; + +export type DiagramIr = { + kind: DiagramKind; + theme?: DiagramTheme; + layout?: { direction?: "left-to-right" | "top-to-bottom" }; + nodes: Array<{ + id: string; + label: string; + type?: DiagramIrNodeType; + parentId?: string; + resourceIcon?: ArchitectureResourceIcon; + }>; + edges?: Array<{ + source: string; + target: string; + label?: string; + type?: DiagramEdgeKind; + bidirectional?: boolean; + }>; +}; + +const MIND_MAP_HORIZONTAL_GAP = 72; +const MIND_MAP_VERTICAL_GAP = 16; + +const visualTextUnits = (label: string) => Array.from(label).reduce( + (total, character) => total + (/[^\u0000-\u00ff]/.test(character) ? 1 : 0.55), + 0, +); + +export const compactMindMapNodeSize = (label: string, isRoot: boolean) => ({ + width: Math.round(Math.min(isRoot ? 168 : 156, Math.max(isRoot ? 112 : 92, visualTextUnits(label) * 13 + 28))), + height: isRoot ? 42 : 36, +}); + +export const compactFlowchartNodeSize = (shape: DiagramNodeShape) => ( + shape === "decision" + ? { width: 116, height: 72 } + : shape === "terminator" + ? { width: 116, height: 44 } + : { width: 124, height: 44 } +); + +export const compactArchitectureNodeSize = ( + shape: DiagramNodeShape, + authored?: { width: number; height: number }, +) => { + if (shape === "boundary") return authored ?? { width: 560, height: 320 }; + if (shape === "database") return { width: 150, height: 72 }; + if (shape === "queue") return { width: 156, height: 60 }; + if (shape === "security") return { width: 148, height: 68 }; + return { width: 156, height: 64 }; +}; + +const computeMindMapLayout = ( + document: DiagramDocument, + options: DiagramLayoutOptions, +): DiagramLayoutPositions => { + const nodeById = new Map(document.nodes.map((node) => [node.id, node])); + const childrenByParent = new Map(); + for (const node of document.nodes) { + if (!node.parentId || !nodeById.has(node.parentId)) continue; + const children = childrenByParent.get(node.parentId) ?? []; + children.push(node.id); + childrenByParent.set(node.parentId, children); + } + + for (const children of childrenByParent.values()) { + children.sort((leftId, rightId) => { + const left = nodeById.get(leftId)!; + const right = nodeById.get(rightId)!; + return left.y - right.y || left.x - right.x || left.id.localeCompare(right.id); + }); + } + + if (options.insertedNodeId && options.insertAfterNodeId) { + const inserted = nodeById.get(options.insertedNodeId); + const target = nodeById.get(options.insertAfterNodeId); + if (inserted?.parentId && inserted.parentId === target?.parentId) { + const siblings = childrenByParent.get(inserted.parentId); + if (siblings) { + const withoutInserted = siblings.filter((id) => id !== inserted.id); + const targetIndex = withoutInserted.indexOf(target.id); + withoutInserted.splice(targetIndex < 0 ? withoutInserted.length : targetIndex + 1, 0, inserted.id); + childrenByParent.set(inserted.parentId, withoutInserted); + } + } + } + + const subtreeHeights = new Map(); + const measureSubtree = (nodeId: string, ancestors: Set): number => { + const cached = subtreeHeights.get(nodeId); + if (cached !== undefined) return cached; + const node = nodeById.get(nodeId); + if (!node || ancestors.has(nodeId)) return 0; + const nextAncestors = new Set(ancestors).add(nodeId); + const childHeights = (childrenByParent.get(nodeId) ?? []) + .map((childId) => measureSubtree(childId, nextAncestors)) + .filter((height) => height > 0); + const childrenHeight = childHeights.reduce((total, height) => total + height, 0) + + Math.max(0, childHeights.length - 1) * MIND_MAP_VERTICAL_GAP; + const height = Math.max(node.height, childrenHeight); + subtreeHeights.set(nodeId, height); + return height; + }; + + const positions: DiagramLayoutPositions = Object.fromEntries( + document.nodes.map((node) => [node.id, { x: node.x, y: node.y }]), + ); + const placeChildren = (nodeId: string, subtreeTop: number, ancestors: Set) => { + const node = nodeById.get(nodeId); + if (!node || ancestors.has(nodeId)) return; + const nextAncestors = new Set(ancestors).add(nodeId); + const childIds = (childrenByParent.get(nodeId) ?? []).filter((childId) => !nextAncestors.has(childId)); + const childHeights = childIds.map((childId) => measureSubtree(childId, nextAncestors)); + const childrenHeight = childHeights.reduce((total, height) => total + height, 0) + + Math.max(0, childHeights.length - 1) * MIND_MAP_VERTICAL_GAP; + let cursor = subtreeTop + (measureSubtree(nodeId, ancestors) - childrenHeight) / 2; + for (let index = 0; index < childIds.length; index += 1) { + const childId = childIds[index]; + const child = nodeById.get(childId)!; + const childSubtreeHeight = childHeights[index]; + positions[childId] = { + x: positions[nodeId].x + node.width + MIND_MAP_HORIZONTAL_GAP, + y: Math.round(cursor + (childSubtreeHeight - child.height) / 2), + }; + placeChildren(childId, cursor, nextAncestors); + cursor += childSubtreeHeight + MIND_MAP_VERTICAL_GAP; + } + }; + + const roots = document.nodes + .filter((node) => !node.parentId || !nodeById.has(node.parentId)) + .sort((left, right) => left.y - right.y || left.id.localeCompare(right.id)); + for (const root of roots) { + const subtreeHeight = measureSubtree(root.id, new Set()); + placeChildren(root.id, root.y + root.height / 2 - subtreeHeight / 2, new Set()); + } + return positions; +}; + +export const computeDiagramLayout = ( + document: DiagramDocument, + options: DiagramLayoutOptions = {}, +): DiagramLayoutPositions => { + if (document.kind === "mind-map") return computeMindMapLayout(document, options); + const layoutGraph = new graphlib.Graph(); + layoutGraph.setGraph({ + rankdir: options.direction === "top-to-bottom" ? "TB" : "LR", + ranksep: 96, + nodesep: 40, + marginx: 32, + marginy: 32, + }); + layoutGraph.setDefaultEdgeLabel(() => ({})); + + const layoutNodes = document.kind === "architecture" + ? document.nodes.filter((node) => node.shape !== "boundary") + : document.nodes; + const layoutNodeIds = new Set(layoutNodes.map((node) => node.id)); + for (const node of layoutNodes) layoutGraph.setNode(node.id, { width: node.width, height: node.height }); + for (const edge of document.edges) { + if (layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target)) layoutGraph.setEdge(edge.source, edge.target); + } + + runDagreLayout(layoutGraph); + return Object.fromEntries(document.nodes.flatMap((node) => { + if (node.shape === "boundary") return [[node.id, { x: node.x, y: node.y }]]; + const position = layoutGraph.node(node.id) as { x: number; y: number } | undefined; + if (!position) return []; + return [[node.id, { + x: Math.round(position.x - node.width / 2), + y: Math.round(position.y - node.height / 2), + }]]; + })); +}; + +const irNodeShape = (kind: DiagramKind, type: DiagramIrNodeType | undefined): DiagramNodeShape => { + if (kind === "mind-map") return "topic"; + if (kind === "flowchart") return type === "decision" ? "decision" : type === "start" || type === "end" ? "terminator" : "process"; + return (type ?? "service") as DiagramNodeShape; +}; + +const fitArchitectureBoundaries = (document: DiagramDocument) => { + const nodeById = new Map(document.nodes.map((node) => [node.id, node])); + const boundaryDepth = (nodeId: string) => { + let depth = 0; + let current = nodeById.get(nodeId); + const visited = new Set(); + while (current?.parentId && !visited.has(current.parentId)) { + visited.add(current.parentId); + depth += 1; + current = nodeById.get(current.parentId); + } + return depth; + }; + const boundaries = document.nodes + .filter((node) => node.shape === "boundary") + .sort((left, right) => boundaryDepth(right.id) - boundaryDepth(left.id)); + for (const boundary of boundaries) { + const children = document.nodes.filter((node) => node.parentId === boundary.id); + if (children.length === 0) continue; + const left = Math.min(...children.map((node) => node.x)) - 36; + const top = Math.min(...children.map((node) => node.y)) - 56; + const right = Math.max(...children.map((node) => node.x + node.width)) + 36; + const bottom = Math.max(...children.map((node) => node.y + node.height)) + 36; + boundary.x = left; + boundary.y = top; + boundary.width = Math.max(260, right - left); + boundary.height = Math.max(180, bottom - top); + } + + const topLevelBoundaries = boundaries + .filter((boundary) => !boundary.parentId) + .sort((left, right) => left.y - right.y || left.x - right.x); + const placed: typeof topLevelBoundaries = []; + const shiftGroup = (boundaryId: string, deltaY: number) => { + for (const node of document.nodes) { + let current = node; + const visited = new Set(); + while (current.parentId && !visited.has(current.parentId)) { + visited.add(current.parentId); + if (current.parentId === boundaryId) { + node.y += deltaY; + break; + } + const parent = nodeById.get(current.parentId); + if (!parent) break; + current = parent; + } + } + nodeById.get(boundaryId)!.y += deltaY; + }; + for (const boundary of topLevelBoundaries) { + const overlappingBottom = placed + .filter((other) => boundary.x < other.x + other.width + 40 && boundary.x + boundary.width + 40 > other.x) + .reduce((bottom, other) => Math.max(bottom, other.y + other.height), Number.NEGATIVE_INFINITY); + if (Number.isFinite(overlappingBottom) && boundary.y < overlappingBottom + 40) { + shiftGroup(boundary.id, overlappingBottom + 40 - boundary.y); + } + placed.push(boundary); + } +}; + +export const compileDiagramIr = (ir: DiagramIr): DiagramDocument => { + const nodes = ir.nodes.map((node, index) => { + const shape = irNodeShape(ir.kind, node.type); + const size = ir.kind === "mind-map" + ? compactMindMapNodeSize(node.label, !node.parentId) + : ir.kind === "architecture" + ? compactArchitectureNodeSize(shape) + : compactFlowchartNodeSize(shape); + return { + id: node.id, + label: node.label, + x: 72, + y: 64 + index * 80, + ...size, + shape, + ...(node.parentId ? { parentId: node.parentId } : {}), + ...(node.resourceIcon ? { resourceIcon: node.resourceIcon } : {}), + }; + }); + const edges = (ir.edges ?? []).map((edge, index) => ({ + id: `edge-${index + 1}`, + source: edge.source, + target: edge.target, + ...(edge.label ? { label: edge.label } : {}), + ...(edge.type ? { kind: edge.type } : {}), + ...(edge.bidirectional !== undefined ? { bidirectional: edge.bidirectional } : {}), + })); + if (ir.kind === "mind-map") { + const pairs = new Set(edges.map((edge) => `${edge.source}\0${edge.target}`)); + for (const node of nodes) { + if (!node.parentId || pairs.has(`${node.parentId}\0${node.id}`)) continue; + edges.push({ id: `edge-${edges.length + 1}`, source: node.parentId, target: node.id }); + } + } + const document: DiagramDocument = { + schemaVersion: ir.kind === "architecture" ? ARCHITECTURE_DIAGRAM_SCHEMA_VERSION : DIAGRAM_SCHEMA_VERSION, + kind: ir.kind, + ...(ir.theme ? { theme: ir.theme } : {}), + nodes, + edges, + }; + const positions = computeDiagramLayout(document, { + direction: ir.layout?.direction ?? (ir.kind === "flowchart" ? "top-to-bottom" : "left-to-right"), + }); + for (const node of document.nodes) { + const position = positions[node.id]; + if (position) Object.assign(node, position); + } + if (ir.kind === "architecture") fitArchitectureBoundaries(document); + return document; +};