Initial commit: DSH mind-map canvas monorepo

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-12 08:53:37 +08:00
commit 2ecd300ada
92 changed files with 20486 additions and 0 deletions

7
.env.example Normal file
View file

@ -0,0 +1,7 @@
# Optional fallback when NOT running as a DSH plugin.
# Prefer: DSH Settings → Mind Map → pick a model (no key here).
LLM_API_KEY=
LLM_BASE_URL=https://api.deepseek.com/v1
LLM_MODEL=deepseek-chat
PORT=17890
BIND=127.0.0.1

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
node_modules
dist
data
uploads
*.sqlite
*.sqlite-*
.DS_Store
*.log
.env
.env.local
apps/web/dist
apps/server/dist
packages/*/dist

37
README.md Normal file
View file

@ -0,0 +1,37 @@
# Mind Map
挂在 **DSH** 上的 AI 无限画布:自建 Web 管画布;**对话 = DSH 会话镜像**(本地不存聊天)。
## 要求
- Node.js ≥ 22
- pnpm ≥ 9
- 正式使用:DeepSeek Harness(安装本仓 `packages/dsh-plugin` → `dsh-mind-map`)
## 用 DSH 插件(推荐)
1. 把 `packages/dsh-plugin` 装进 DSH profile(`dsh plugin` / 本地 link)
2. 设置 → **Mind Map**:端口、自动启动、「打开画布」
3. 在画布右栏输入 ≡ 向绑定的 DSH 会话发消息;模型与历史在 DSH
插件会:启停 `127.0.0.1:17890`、注册 `mindmap_*` 工具、把 `session/event` 镜像到 Web。
## 本地只调画布(无对话大脑)
```bash
pnpm install
pnpm dev
```
浏览器打开 <http://127.0.0.1:17890>。图 API / 手改可用;右栏会提示需 DSH 会话。
独立调试对话(非正式路径)才需要 `.env` 里的 `LLM_API_KEY`。
## 结构
- `apps/server` — Hono + SQLite + 图 API/WS + session 桥
- `apps/web` — React 三区 UI(右栏 = 会话镜像)
- `packages/dsh-plugin` — `dsh-mind-map` Host/Client
- `packages/*` — graph-core / store / layout / dream-skin / shared
详见 `docs/dsh-bridge.md`。

32
apps/server/package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "@mind-map/server",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@hono/node-server": "^1.14.1",
"@mind-map/dream-skin": "workspace:*",
"@mind-map/graph-core": "workspace:*",
"@mind-map/graph-store": "workspace:*",
"@mind-map/layout-engine": "workspace:*",
"@mind-map/shared": "workspace:*",
"hono": "^4.7.5",
"ai": "^4.3.16",
"@ai-sdk/openai": "^1.3.22",
"ws": "^8.18.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.13.10",
"@types/ws": "^8.18.0",
"tsx": "^4.19.3",
"typescript": "^5.8.2",
"vitest": "^3.0.9"
}
}

206
apps/server/src/chat.ts Normal file
View file

@ -0,0 +1,206 @@
import { createOpenAI } from "@ai-sdk/openai";
import { streamText, tool, type CoreMessage } from "ai";
import { z } from "zod";
import type { ToolContext } from "./tools.js";
import { buildSpatialContext, runTool } from "./tools.js";
export function llmConfigured(): boolean {
return Boolean(process.env.LLM_API_KEY || process.env.OPENAI_API_KEY);
}
export function llmStatus(sourceHint?: "dsh" | "env" | "none") {
const configured = llmConfigured();
const viaDsh =
sourceHint === "dsh" ||
process.env.MINDMAP_LLM_SOURCE === "dsh" ||
Boolean(process.env.MINDMAP_DSH_PROVIDER);
return {
configured,
source: viaDsh ? "dsh" : configured ? "env" : "none",
provider: process.env.MINDMAP_DSH_PROVIDER || undefined,
model:
process.env.MINDMAP_DSH_MODEL ||
process.env.LLM_MODEL ||
"deepseek-chat",
baseURL: process.env.LLM_BASE_URL || process.env.OPENAI_BASE_URL || undefined,
};
}
function getModel() {
const apiKey = process.env.LLM_API_KEY || process.env.OPENAI_API_KEY || "missing";
const baseURL =
process.env.LLM_BASE_URL ||
process.env.OPENAI_BASE_URL ||
"https://api.deepseek.com/v1";
// When bridged through DSH OpenAI shim, model id is ignored by the shim;
// settings card provider/model win on the host side.
const modelId = process.env.LLM_MODEL || "deepseek-chat";
const provider = createOpenAI({ apiKey, baseURL });
return provider(modelId);
}
const OpZ = z
.object({
type: z.string().describe("Op discriminator: create_node | link | update_text | … (NOT `op`)"),
})
.passthrough();
export function createChatHandler(ctx: ToolContext) {
return async function handleChat(body: {
canvasId: string;
messages: { role: "user" | "assistant" | "system"; content: string }[];
}) {
const canvasId = body.canvasId;
const spatial = buildSpatialContext(ctx.store, canvasId);
const system = `你是 Mind Map 画布上的 AI 协作者。图是唯一事实源。
规则:
1. 先用 mindmap_get_spatial_context / mindmap_find 定位,禁止臆造节点 ID。找边用 searchScope=edge_label|edge_note。
2. find 返回 ambiguous=true 时必须向用户反问,不得自行挑选。
3. 快速档(riskProfile=fast):低风险用 mindmap_apply_direct(新建、改文本、关联、样式);删除/换父等用 mindmap_propose。
4. 严格档:一律 propose,等用户接受。
5. 禁止输出整棵树 JSON 覆盖;只提交增量 ops。每个 op 必须用字段 type(禁止用 op)。
6. 对话里先用自然语言说明你在做什么;工具会改图。
7. 用户若说「特朗普家族」等关系梳理:围绕焦点创建人物节点与 relation/hierarchy 边,边可带 label(如配偶、子女)。link 示例:{"type":"link","from":"...","to":"...","kind":"relation","label":"配偶"}。
8. 固定坐标用 set_pinned 或 create_node 带 pos;不确定时 mindmap_get_schema。
9. 节点属性:标题用 update_text;描述/多标签/多链接/图片URL/时间用 update_node_meta(patch);边用 update_edge_meta 或 set_edge_label。
10. 节点类型 stylePreset:default|card|title|decision|risk|note|muted(title 仅画布锚点,勿新建;card=新闻快照卡片,配图用 imageUrl、摘要用 note、来源用 links)。
11. 同级排序用 reorder:优先 {"type":"reorder","nodeId":"...","order":10};也可用 afterSiblingId(null=置顶)。
12. 折叠 set_collapsed 会隐藏该节点主父链下的全部子孙并重新布局;展开后恢复。
13. 问图:用 mindmap_query(neighbors|path|stats|orphans|missing_meta);答完可用 mindmap_highlight 高亮路径;细节用 mindmap_get_node / mindmap_get_edge。禁止编造图中没有的事实。
当前空间上下文(摘要):
${JSON.stringify(spatial, null, 0).slice(0, 6000)}
`;
const toolCtx: ToolContext = { ...ctx, defaultCanvasId: canvasId };
const result = streamText({
model: getModel(),
system,
messages: body.messages as CoreMessage[],
maxSteps: 10,
tools: {
mindmap_get_spatial_context: tool({
description: "获取选区/视口/外围簇等空间上下文",
parameters: z.object({ canvasId: z.string().optional() }),
execute: async (args) => runTool("mindmap_get_spatial_context", args, toolCtx),
}),
mindmap_find: tool({
description:
"检索节点或边;searchScope=text|note|tags|description|edge_label|edge_note|all;可能返回 ambiguous",
parameters: z.object({
query: z.string(),
searchScope: z
.enum([
"text",
"note",
"tags",
"description",
"edge_label",
"edge_note",
"all",
])
.optional(),
canvasId: z.string().optional(),
}),
execute: async (args) => runTool("mindmap_find", args, toolCtx),
}),
mindmap_query: tool({
description:
"图问答:neighbors|path|stats|orphans|missing_meta。path 含 paths[].edges;stats 含 longestPath/mostConnected",
parameters: z.object({
op: z.enum(["neighbors", "path", "stats", "orphans", "missing_meta"]),
node: z.string().optional(),
nodeId: z.string().optional(),
from: z.string().optional(),
to: z.string().optional(),
depth: z.number().optional(),
maxHops: z.number().optional(),
direction: z.enum(["out", "in", "both"]).optional(),
edgeKinds: z.enum(["all", "hierarchy", "relation"]).optional(),
throughRelationNodes: z.boolean().optional(),
canvasId: z.string().optional(),
}),
execute: async (args) =>
runTool("mindmap_query", args as Record<string, unknown>, toolCtx),
}),
mindmap_get_node: tool({
description: "读单个节点详情(description/note/tags/links)",
parameters: z.object({
nodeId: z.string(),
fields: z.array(z.enum(["all", "description", "note", "tags", "links"])).optional(),
canvasId: z.string().optional(),
}),
execute: async (args) => runTool("mindmap_get_node", args, toolCtx),
}),
mindmap_get_edge: tool({
description: "读单条边详情(label/weight/lineStyle/direction/note)",
parameters: z.object({
edgeId: z.string(),
fields: z.array(z.enum(["all", "label", "meta"])).optional(),
canvasId: z.string().optional(),
}),
execute: async (args) => runTool("mindmap_get_edge", args, toolCtx),
}),
mindmap_highlight: tool({
description: "在画布上高亮节点/边(临时,不改图);问答后可视化路径",
parameters: z.object({
nodeIds: z.array(z.string()).optional(),
edgeIds: z.array(z.string()).optional(),
ttlMs: z.number().optional(),
style: z
.object({
nodeColor: z.string().optional(),
nodeGlow: z.boolean().optional(),
edgeWidth: z.number().optional(),
edgeColor: z.string().optional(),
})
.optional(),
canvasId: z.string().optional(),
}),
execute: async (args) =>
runTool("mindmap_highlight", args as Record<string, unknown>, toolCtx),
}),
mindmap_get_subgraph: tool({
description: "读取以某节点为根的子图(depth<=3)",
parameters: z.object({
rootId: z.string().optional(),
depth: z.number().optional(),
canvasId: z.string().optional(),
}),
execute: async (args) => runTool("mindmap_get_subgraph", args, toolCtx),
}),
mindmap_apply_direct: tool({
description: "快速档直接应用低风险 ops",
parameters: z.object({
ops: z.array(OpZ),
summary: z.string().optional(),
canvasId: z.string().optional(),
}),
execute: async (args) =>
runTool("mindmap_apply_direct", args as Record<string, unknown>, toolCtx),
}),
mindmap_propose: tool({
description: "提交待确认提案(高风险或严格档)",
parameters: z.object({
ops: z.array(OpZ),
rationale: z.string(),
canvasId: z.string().optional(),
}),
execute: async (args) =>
runTool("mindmap_propose", args as Record<string, unknown>, toolCtx),
}),
mindmap_focus: tool({
description: "移动视觉焦点",
parameters: z.object({
nodeId: z.string(),
canvasId: z.string().optional(),
}),
execute: async (args) => runTool("mindmap_focus", args, toolCtx),
}),
},
});
return result;
};
}

View file

@ -0,0 +1,736 @@
import fs from "node:fs";
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { Hono } from "hono";
import { cors } from "hono/cors";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Server } from "node:http";
import { WebSocketServer, type WebSocket } from "ws";
import { SqliteGraphStore } from "@mind-map/graph-store";
import { coerceOpInput, OpSchema, type Op } from "@mind-map/shared";
import { listSkins } from "@mind-map/dream-skin";
import { GraphError } from "@mind-map/graph-core";
import { z } from "zod";
import {
createChatHandler,
llmConfigured,
llmStatus,
} from "./chat.js";
import {
listProposals,
runTool,
setActivity,
clientCanvasPayload,
finalizeAutoFit,
} from "./tools.js";
export type SessionBridge = {
prompt: (canvasId: string, text: string) => Promise<{ ok: boolean; sessionId?: string }>
history: (canvasId: string) => Promise<{ sessionId: string | null; messages: { role: string; content: string }[] }>
status: (canvasId?: string) => { ok: boolean; mode: string; bound?: boolean; sessionId?: string | null; agents?: boolean }
}
function bridgeBase() {
return (process.env.MINDMAP_DSH_BRIDGE || "").replace(/\/$/, "");
}
function internalToken() {
return process.env.MINDMAP_INTERNAL_TOKEN || "";
}
async function callDshBridge(path: string, init?: RequestInit) {
const base = bridgeBase();
if (!base) throw new Error("DSH bridge not configured");
const res = await fetch(`${base}${path}`, {
...init,
headers: {
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
if (!res.ok) {
throw new Error(String(body.message || body.error || `bridge ${res.status}`));
}
return body;
}
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const mindMapRoot = path.resolve(__dirname, "../../..");
export function loadDotEnv(root = mindMapRoot) {
try {
const envPath = path.join(root, ".env");
if (!fs.existsSync(envPath)) return;
for (const line of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) {
const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
if (!m || line.trimStart().startsWith("#")) continue;
const key = m[1]!;
let val = m[2]!;
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
if (process.env[key] == null) process.env[key] = val;
}
} catch {
/* ignore */
}
}
export type MindMapServerOptions = {
port?: number;
bind?: string;
dataDir?: string;
root?: string;
/** When set by DSH plugin, overrides env-based LLM status messaging. */
llmSource?: "dsh" | "env" | "none";
/** Optional in-process session bridge (preferred over HTTP MINDMAP_DSH_BRIDGE). */
sessionBridge?: SessionBridge;
};
export type MindMapServerHandle = {
app: Hono;
store: SqliteGraphStore;
port: number;
bind: string;
dbPath: string;
close: () => Promise<void>;
};
export function startMindMapServer(
options: MindMapServerOptions = {},
): MindMapServerHandle {
const root = options.root ?? mindMapRoot;
loadDotEnv(root);
const port = Number(options.port ?? process.env.PORT ?? 17890);
const bind = options.bind ?? process.env.BIND ?? "127.0.0.1";
const dataDir = options.dataDir ?? process.env.DATA_DIR ?? path.join(root, "data");
const dbPath = path.join(dataDir, "mindmap.sqlite");
const store = new SqliteGraphStore(dbPath);
const app = new Hono();
app.use("*", cors({ origin: "*" }));
const sockets = new Set<WebSocket>();
function broadcast(payload: unknown) {
const data = JSON.stringify(payload);
for (const ws of sockets) {
if (ws.readyState === ws.OPEN) ws.send(data);
}
}
app.get("/health", (c) =>
c.json({ ok: true, port, bind, dbPath, skins: listSkins().map((s) => s.id) }),
);
app.get("/api/skins", (c) => c.json({ skins: listSkins() }));
app.get("/api/canvases", (c) =>
c.json({
canvases: store.listCanvases().map((x) => ({
id: x.id,
title: x.title,
focusNodeId: x.focusNodeId === x.anchorNodeId ? null : x.focusNodeId,
viewport: x.viewport,
prefs: x.prefs,
rev: x.rev,
agentMode: x.agentMode,
createdAt: x.createdAt,
updatedAt: x.updatedAt,
})),
}),
);
app.post("/api/canvases", async (c) => {
const body = (await c.req.json().catch(() => ({}))) as {
title?: string;
rootText?: string;
};
const g = store.createCanvas(body.title, body.rootText);
broadcast({ type: "canvas/created", canvasId: g.canvas.id });
return c.json(clientCanvasPayload(store, g.canvas.id));
});
app.get("/api/canvases/:id", (c) => {
const id = c.req.param("id");
try {
return c.json(clientCanvasPayload(store, id));
} catch {
return c.json({ ok: false, error: "not_found" }, 404);
}
});
app.patch("/api/canvases/:id", async (c) => {
const id = c.req.param("id");
const body = (await c.req.json()) as {
title?: string;
viewport?: { x: number; y: number; zoom: number };
};
if (body.title) store.renameCanvas(id, body.title);
if (body.viewport) store.updateViewport(id, body.viewport);
return c.json({ canvas: store.loadCanvas(id).canvas });
});
app.delete("/api/canvases/:id", (c) => {
const id = c.req.param("id");
store.hardDeleteCanvas(id);
broadcast({ type: "canvas/deleted", canvasId: id });
return c.json({ ok: true });
});
app.post("/api/canvases/:id/ops", async (c) => {
const id = c.req.param("id");
const body = await c.req.json();
const coercedOps = Array.isArray(body?.ops)
? body.ops.map((o: unknown) => coerceOpInput(o))
: body?.ops;
const parsed = z
.object({
ops: z.array(OpSchema),
origin: z.enum(["user", "ai"]).default("user"),
summary: z.string().optional(),
})
.safeParse({ ...body, ops: coercedOps });
if (!parsed.success) {
return c.json(
{
ok: false,
error: "invalid_op",
message:
"Each op needs field `type` (not `op`). Valid: create_node, link, update_text, set_pinned, …",
details: parsed.error.flatten(),
},
400,
);
}
try {
const result = store.applyChangeSet(id, parsed.data.ops as Op[], {
origin: parsed.data.origin,
summary: parsed.data.summary,
});
const fitViewport =
result.autoFitPadding != null
? finalizeAutoFit(store, id, result.autoFitPadding)
: null;
const payload = clientCanvasPayload(store, id);
const preserveUnpinned = parsed.data.ops.every((o) => o.type === "set_pinned");
broadcast({
type: "canvas/event",
canvasId: id,
changeSetId: result.changeSetId,
rev: result.rev,
preserveUnpinned,
affectedNodeIds: result.affectedNodeIds.filter(
(nid) => nid !== result.snapshot.canvas.anchorNodeId,
),
fitViewport: fitViewport ?? undefined,
});
return c.json({
ok: true,
changeSetId: result.changeSetId,
rev: result.rev,
affectedNodeIds: result.affectedNodeIds.filter(
(nid) => nid !== result.snapshot.canvas.anchorNodeId,
),
fitViewport: fitViewport ?? undefined,
...payload,
});
} catch (e) {
if (e instanceof GraphError) {
return c.json({ ok: false, error: e.code, message: e.message }, 409);
}
return c.json({ ok: false, error: "invalid_op", message: String(e) }, 400);
}
});
app.post("/api/canvases/:id/undo", async (c) => {
const canvasId = c.req.param("id");
const body = (await c.req.json().catch(() => ({}))) as {
changeSetId?: string;
};
try {
let changeSetId = body.changeSetId;
if (!changeSetId) {
changeSetId = store.latestUndoableChangeSetId(canvasId) ?? undefined;
}
if (!changeSetId) {
return c.json(
{ ok: false, error: "nothing_to_undo", message: "No undoable change set" },
400,
);
}
const result = store.undoChangeSet(changeSetId);
const payload = clientCanvasPayload(store, canvasId);
broadcast({
type: "canvas/event",
canvasId,
changeSetId: result.changeSetId,
rev: result.rev,
});
return c.json({
ok: true,
undoneChangeSetId: changeSetId,
changeSetId: result.changeSetId,
rev: result.rev,
...payload,
});
} catch (e) {
return c.json({ ok: false, error: "undo_failed", message: String(e) }, 400);
}
});
app.get("/api/canvases/:id/search", (c) => {
const q = c.req.query("q") ?? "";
const scopeRaw = c.req.query("scope") ?? "text";
const scope =
scopeRaw === "note" ||
scopeRaw === "tags" ||
scopeRaw === "description" ||
scopeRaw === "edge_label" ||
scopeRaw === "edge_note" ||
scopeRaw === "all"
? scopeRaw
: "text";
return c.json({ hits: store.search(c.req.param("id"), q, 20, scope) });
});
app.get("/api/canvases/:id/trash", (c) => {
try {
return c.json({ nodes: store.listDeletedNodes(c.req.param("id")) });
} catch {
return c.json({ ok: false, error: "not_found" }, 404);
}
});
app.post("/api/canvases/:id/trash/:nodeId/restore", (c) => {
try {
const result = store.restoreNode(c.req.param("id"), c.req.param("nodeId"));
broadcast({
type: "canvas/event",
canvasId: c.req.param("id"),
changeSetId: result.changeSetId,
});
return c.json({ ok: true, ...result });
} catch (e) {
return c.json({ ok: false, message: String(e) }, 400);
}
});
app.post("/api/canvases/:id/activity", async (c) => {
const id = c.req.param("id");
const body = (await c.req.json()) as {
selectionIds?: string[];
focusNodeId?: string | null;
viewport?: { x: number; y: number; zoom: number };
};
setActivity({
canvasId: id,
selectionIds: body.selectionIds ?? [],
focusNodeId: body.focusNodeId ?? null,
viewport: body.viewport ?? { x: 0, y: 0, zoom: 1 },
updatedAt: Date.now(),
});
return c.json({ ok: true });
});
app.get("/api/canvases/:id/proposals", (c) => {
return c.json({ proposals: listProposals(c.req.param("id")) });
});
app.post("/api/canvases/:id/proposals/:pid/accept", async (c) => {
const result = await runTool(
"mindmap_accept_proposal",
{ proposalId: c.req.param("pid"), canvasId: c.req.param("id") },
{ store, broadcast, defaultCanvasId: c.req.param("id") },
);
return c.json(result);
});
app.post("/api/canvases/:id/proposals/:pid/reject", async (c) => {
const result = await runTool(
"mindmap_reject_proposal",
{ proposalId: c.req.param("pid") },
{ store, broadcast, defaultCanvasId: c.req.param("id") },
);
return c.json(result);
});
app.get("/api/canvases/:id/export.json", (c) => {
try {
const snap = store.getSnapshot(c.req.param("id"));
return c.json(snap);
} catch {
return c.json({ ok: false, error: "not_found" }, 404);
}
});
app.post("/api/tools/:name", async (c) => {
const name = c.req.param("name");
const args = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;
try {
const result = await runTool(name, args, { store, broadcast });
return c.json(result);
} catch (e) {
return c.json(
{ ok: false, error: "tool_error", message: String(e) },
400,
);
}
});
const handleChat = createChatHandler({ store, broadcast });
async function sessionStatus(canvasId?: string) {
if (options.sessionBridge) {
return { ...options.sessionBridge.status(canvasId), canvasUrl: `http://${bind}:${port}` };
}
if (bridgeBase()) {
const q = canvasId ? `?canvasId=${encodeURIComponent(canvasId)}` : "";
return callDshBridge(`/status${q}`);
}
return {
ok: true,
mode: llmConfigured() ? "legacy-env-llm" : "unconfigured",
bound: false,
sessionId: null,
agents: false,
message: "未连接 DSH:请通过 dsh-mind-map 插件启动,对话将镜像 DSH 会话",
};
}
app.get("/api/session/status", async (c) => {
try {
const canvasId = c.req.query("canvasId") || undefined;
return c.json(await sessionStatus(canvasId));
} catch (e) {
return c.json({ ok: false, message: String(e) }, 500);
}
});
app.post("/api/session/prompt", async (c) => {
const body = (await c.req.json().catch(() => ({}))) as {
canvasId?: string;
text?: string;
};
if (!body.canvasId || !body.text?.trim()) {
return c.json({ ok: false, message: "canvasId and text required" }, 400);
}
try {
if (options.sessionBridge) {
const result = await options.sessionBridge.prompt(body.canvasId, body.text);
return c.json(result);
}
if (bridgeBase()) {
const result = await callDshBridge("/prompt", {
method: "POST",
body: JSON.stringify({ canvasId: body.canvasId, text: body.text }),
});
return c.json(result);
}
return c.json(
{
ok: false,
error: "dsh_bridge_missing",
message: "对话需经 DSH 会话。请用 dsh-mind-map 插件启动画布服务。",
},
503,
);
} catch (e) {
return c.json({ ok: false, message: String(e) }, 500);
}
});
app.get("/api/session/history", async (c) => {
const canvasId = c.req.query("canvasId");
if (!canvasId) return c.json({ ok: false, message: "canvasId required" }, 400);
try {
if (options.sessionBridge) {
const hist = await options.sessionBridge.history(canvasId);
return c.json({ ok: true, ...hist });
}
if (bridgeBase()) {
const hist = await callDshBridge(
`/history?canvasId=${encodeURIComponent(canvasId)}`,
);
return c.json(hist);
}
return c.json({ ok: true, sessionId: null, messages: [] });
} catch (e) {
return c.json({ ok: false, message: String(e) }, 500);
}
});
app.post("/api/session/ensure", async (c) => {
const body = (await c.req.json().catch(() => ({}))) as { canvasId?: string };
if (!body.canvasId) return c.json({ ok: false, message: "canvasId required" }, 400);
try {
if (options.sessionBridge) {
// ensure via prompt path's ensureBinding is private; status+history enough
const st = options.sessionBridge.status(body.canvasId);
if (st.bound) return c.json({ ok: true, sessionId: st.sessionId });
}
if (bridgeBase()) {
const result = await callDshBridge("/ensure", {
method: "POST",
body: JSON.stringify({ canvasId: body.canvasId }),
});
return c.json(result);
}
return c.json({ ok: false, message: "DSH bridge missing" }, 503);
} catch (e) {
return c.json({ ok: false, message: String(e) }, 500);
}
});
app.post("/api/internal/session-event", async (c) => {
const token = c.req.header("x-mindmap-token") || "";
const expected = internalToken();
if (expected && token !== expected) {
return c.json({ ok: false, error: "forbidden" }, 403);
}
const body = (await c.req.json().catch(() => ({}))) as {
canvasId?: string;
mirror?: Record<string, unknown>;
};
if (!body.canvasId || !body.mirror) {
return c.json({ ok: false, message: "canvasId and mirror required" }, 400);
}
broadcast({
type: "session/mirror",
canvasId: body.canvasId,
mirror: body.mirror,
});
return c.json({ ok: true });
});
const uploadsDir = path.join(dataDir, "uploads");
fs.mkdirSync(uploadsDir, { recursive: true });
app.post("/api/uploads", async (c) => {
const body = (await c.req.json().catch(() => null)) as {
dataBase64?: string;
mimeType?: string;
filename?: string;
} | null;
const raw = String(body?.dataBase64 || "").trim();
if (!raw) {
return c.json({ ok: false, message: "dataBase64 required" }, 400);
}
let b64 = raw;
let mime = String(body?.mimeType || "").trim().toLowerCase();
const dataUrl = /^data:([^;]+);base64,(.+)$/i.exec(raw);
if (dataUrl) {
mime = dataUrl[1]!.toLowerCase();
b64 = dataUrl[2]!;
}
const allowed: Record<string, string> = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
};
const ext = allowed[mime];
if (!ext) {
return c.json(
{
ok: false,
message: "仅支持 jpeg/png/webp/gif",
},
400,
);
}
let buf: Buffer;
try {
buf = Buffer.from(b64, "base64");
} catch {
return c.json({ ok: false, message: "invalid base64" }, 400);
}
if (!buf.length || buf.length > 6 * 1024 * 1024) {
return c.json({ ok: false, message: "图片需小于 6MB(压缩后)" }, 400);
}
const id = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
const name = `${id}.${ext}`;
fs.writeFileSync(path.join(uploadsDir, name), buf);
const url = `/uploads/${name}`;
return c.json({ ok: true, url, mimeType: mime, bytes: buf.length });
});
app.get("/uploads/:name", (c) => {
const name = c.req.param("name");
if (!/^[\w.-]+\.(jpe?g|png|webp|gif)$/i.test(name)) {
return c.notFound();
}
const fp = path.join(uploadsDir, name);
if (!fs.existsSync(fp)) return c.notFound();
const lower = name.toLowerCase();
const mime = lower.endsWith(".png")
? "image/png"
: lower.endsWith(".webp")
? "image/webp"
: lower.endsWith(".gif")
? "image/gif"
: "image/jpeg";
const data = fs.readFileSync(fp);
return new Response(data, {
status: 200,
headers: {
"Content-Type": mime,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
});
// Legacy env-LLM path kept only as offline fallback; prefer DSH session.
app.post("/api/chat", async (c) => {
if (bridgeBase() || options.sessionBridge) {
return c.json(
{
ok: false,
error: "use_session_prompt",
message: "请使用 /api/session/prompt(DSH 会话镜像)",
},
400,
);
}
if (!llmConfigured()) {
return c.json(
{
ok: false,
error: "llm_not_configured",
message:
"请通过 DSH 插件 dsh-mind-map 使用会话对话;独立调试才需要 LLM_API_KEY",
},
503,
);
}
const body = (await c.req.json()) as {
canvasId: string;
messages: { role: "user" | "assistant" | "system"; content: string }[];
};
if (!body.canvasId || !body.messages?.length) {
return c.json({ ok: false, message: "canvasId and messages required" }, 400);
}
try {
const result = await handleChat(body);
return result.toDataStreamResponse();
} catch (e) {
return c.json({ ok: false, message: String(e) }, 500);
}
});
app.get("/api/llm/status", async (c) => {
try {
const st = await sessionStatus();
const dshOk = st.mode === "dsh-session" || Boolean(bridgeBase()) || Boolean(options.sessionBridge);
return c.json({
...llmStatus(options.llmSource ?? (dshOk ? "dsh" : undefined)),
configured: dshOk || llmConfigured(),
session: st,
});
} catch {
return c.json({
...llmStatus(options.llmSource),
configured: llmConfigured(),
});
}
});
// Static UI: cwd is mind-map root when launched by the plugin.
const webDistRel = "apps/web/dist";
const webDistAbs = path.join(root, "apps/web/dist");
if (fs.existsSync(webDistAbs) || fs.existsSync(path.join(process.cwd(), webDistRel))) {
app.use(
"/*",
serveStatic({
root: fs.existsSync(webDistAbs) ? webDistAbs : webDistRel,
index: "index.html",
}),
);
// SPA fallback (never swallow API / health)
app.get("*", async (c) => {
const p = c.req.path
if (p.startsWith("/api") || p === "/health" || p.startsWith("/ws") || p.startsWith("/uploads")) {
return c.notFound()
}
const indexPath = path.join(
fs.existsSync(webDistAbs) ? webDistAbs : path.join(process.cwd(), webDistRel),
"index.html",
);
if (!fs.existsSync(indexPath)) {
return c.text(
"Mind Map UI not built. From mind-map repo run: pnpm --filter @mind-map/web build",
503,
);
}
return c.html(fs.readFileSync(indexPath, "utf8"));
});
} else {
app.get("/", (c) =>
c.text(
"Mind Map UI missing (apps/web/dist). Run: pnpm --filter @mind-map/web build",
503,
),
);
}
const server = serve({ fetch: app.fetch, port, hostname: bind }, (info) => {
console.log(`[mind-map] http://${info.address}:${info.port}`);
console.log(`[mind-map] db ${dbPath}`);
}) as unknown as Server;
server.on("error", (err: NodeJS.ErrnoException) => {
console.error(`[mind-map] listen error: ${err.code || err.message}`);
if (err.code === "EADDRINUSE") {
console.error(
`[mind-map] ${bind}:${port} is busy — stop the old process or change the port in DSH settings`,
);
}
process.exitCode = 1;
try {
store.close();
} catch {
/* ignore */
}
process.exit(1);
});
const wss = new WebSocketServer({ server, path: "/ws" });
wss.on("error", (err) => {
console.error(`[mind-map] ws error: ${String(err)}`);
});
wss.on("connection", (ws) => {
sockets.add(ws);
ws.send(JSON.stringify({ type: "hello", ok: true }));
ws.on("message", (raw) => {
try {
const msg = JSON.parse(String(raw)) as { type?: string };
if (msg.type === "ping") ws.send(JSON.stringify({ type: "pong" }));
} catch {
/* ignore */
}
});
ws.on("close", () => sockets.delete(ws));
});
return {
app,
store,
port,
bind,
dbPath,
close: async () => {
await new Promise<void>((resolve) => {
wss.close();
server.close(() => resolve());
});
store.close();
},
};
}

3
apps/server/src/index.ts Normal file
View file

@ -0,0 +1,3 @@
import { startMindMapServer } from "./create-server.js";
startMindMapServer();

2005
apps/server/src/tools.ts Normal file

File diff suppressed because it is too large Load diff

16
apps/server/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*"],
"references": [
{ "path": "../../packages/shared" },
{ "path": "../../packages/graph-core" },
{ "path": "../../packages/graph-store" },
{ "path": "../../packages/layout-engine" },
{ "path": "../../packages/dream-skin" }
]
}

View file

@ -0,0 +1 @@
{"root":["./src/chat.ts","./src/create-server.ts","./src/index.ts","./src/tools.ts"],"version":"5.9.3"}

12
apps/web/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mind Map</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
apps/web/package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "@mind-map/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json --noEmit && vite build",
"typecheck": "tsc -p tsconfig.json --noEmit",
"preview": "vite preview",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@mind-map/dream-skin": "workspace:*",
"@mind-map/shared": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.8.2",
"vite": "^6.2.2",
"vitest": "^3.0.9"
}
}

View file

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from pathlib import Path
import re
t = Path(r"d:\project\chatgpt\mind-map\apps\web\src\TopologyCanvas.tsx").read_text(encoding="utf-8")
print("quoted ??", re.findall(r'"\?{2,}"', t))
print("选择" in t, "单点" in t, "撤销" in t)

View file

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-
from pathlib import Path
import re
p = Path(r"d:\project\chatgpt\mind-map\apps\web\src\TopologyCanvas.tsx")
t = p.read_text(encoding="utf-8")
for m in re.finditer(r'.{0,60}"\?{2,}".{0,40}', t):
print(repr(m.group(0)))
# fix leftover ??? for create_node
if ': { type: "create_node", text: "???" }' in t:
t = t.replace(': { type: "create_node", text: "???" }', ': { type: "create_node", text: "新节点" }')
p.write_text(t, encoding="utf-8", newline="\n")
print("fixed leftover")
print("quoted left", re.findall(r'"\?{2,}"', t))
print("cjk count", len(re.findall(r"[\u4e00-\u9fff]+", t)))

View file

@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
from pathlib import Path
t = Path(r"d:\project\chatgpt\mind-map\apps\web\src\TopologyCanvas.tsx").read_text(encoding="utf-8")
i = t.find('dragScope === "node"')
print("=== drag node ===")
print(repr(t[i - 80 : i + 320]))
print("=== hint ===")
j = t.find("canvas-hint")
print(repr(t[j : j + 400]))
print("=== remaining ?? ===")
import re
for m in re.finditer(r".{0,40}\?{2,}.{0,40}", t):
print(repr(m.group(0)))

View file

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from pathlib import Path
import re
src = Path(r"d:\project\chatgpt\mind-map\apps\web\src\TopologyCanvas.tsx")
dist = Path(r"d:\project\chatgpt\mind-map\apps\web\dist\assets")
b = src.read_bytes()
print("src has 选择 bytes:", "选择".encode("utf-8") in b)
print("src has literal ?? button:", bool(re.search(rb">\s*\?\?\s*<", b)))
# show hex around select label
idx = b.find(b'["select"')
print("src select snippet:", b[idx : idx + 30])
for f in dist.glob("index-*.js"):
jb = f.read_bytes()
print(f.name, "size", len(jb))
print(" has 选择:", "选择".encode("utf-8") in jb)
print(" has [\"select\",\"??\":", b'["select","??"' in jb or b'["select", "??"' in jb)
m = re.search(rb'\["select",\s*"[^"]*"', jb)
if m:
print(" select match:", m.group(0))
m2 = re.search(rb'\["pan",\s*"[^"]*"', jb)
if m2:
print(" pan match:", m2.group(0))

View file

@ -0,0 +1,207 @@
# -*- coding: utf-8 -*-
"""Restore CJK strings in TopologyCanvas.tsx corrupted to '?' / '??'."""
from __future__ import annotations
import re
from pathlib import Path
p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx"
text = p.read_text(encoding="utf-8")
misses: list[str] = []
def sub(pattern: str, repl: str, *, flags: int = 0, count: int = 0, label: str = "") -> None:
global text
new, n = re.subn(pattern, repl, text, count=count, flags=flags)
tag = label or pattern[:56]
if n == 0:
misses.append(tag)
print("MISS:", tag)
else:
print(f"OK x{n}:", tag)
text = new
# comments
sub(r"// iframe \?+ Tab \?+", "// iframe 内无法全屏时,通知父页 Tab 全屏", label="fullscreen comment")
sub(r"// Free place \?pin", "// Free place → pin", label="free place comment")
# create defaults
sub(
r'text: "\?+", parentId: parent\b',
'text: "新节点", parentId: parent',
label="tab child",
)
sub(
r': \{ type: "create_node", text: "\?+" \},',
': { type: "create_node", text: "新节点" },',
label="tab orphan / enter orphan",
)
sub(
r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: -1',
'text: "左侧子主题",\n parentId: selectedIds[0],\n sidePref: -1',
label="arrow left",
)
sub(
r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: 1',
'text: "右侧子主题",\n parentId: selectedIds[0],\n sidePref: 1',
label="arrow right",
)
sub(
r'text: "\?+",\n parentId: parentOfSel',
'text: "兄弟节点",\n parentId: parentOfSel',
label="enter sibling",
)
sub(
r'text: "\?+",\n \.\.\.\(selectedIds\[0\]',
'text: "新节点",\n ...(selectedIds[0]',
label="dblclick create",
)
sub(r'label: "\?+"', 'label: "关联"', label="relation label")
# toolbar: back
sub(
r'(className="canvas-tool-btn"\n title=")\?+("\n onClick=\{requestBackToRoster\}\n >\n )\?+(\n </button>)',
r'\1返回画布列表\2返回\3',
label="back to roster",
)
# tool modes
sub(r'\["select", "\?+", "V"\]', '["select", "选择", "V"]', label="select")
sub(r'\["pan", "\?+", "H"\]', '["pan", "平移", "H"]', label="pan")
sub(r'\["link", "\?+", "C"\]', '["link", "连线", "C"]', label="link")
# drag scope
sub(
r'(className=\{`canvas-tool-btn\$\{dragScope === "node" \? " is-active" : ""\}`\}\n title=")\?+("\n aria-pressed=\{dragScope === "node"\}\n onClick=\{\(\) => setDragScope\("node"\)\}\n >\n )\?+(\n </button>)',
r'\1单点拖拽:只移动当前节点 (N);按住 Shift 也可临时单点\2单点\3',
label="drag node",
)
sub(
r'(className=\{`canvas-tool-btn\$\{dragScope === "branch" \? " is-active" : ""\}`\}\n title=")\?+("\n aria-pressed=\{dragScope === "branch"\}\n onClick=\{\(\) => setDragScope\("branch"\)\}\n >\n )\?+(\n </button>)',
r'\1整枝拖拽:节点与子孙一起移动 (B)\2整枝\3',
label="drag branch",
)
# bubble / card / unpin
sub(
r'(className=\{`canvas-tool-btn\$\{cardView \? "" : " is-active"\}`\}\n title=")\?+("\n aria-pressed=\{!cardView\})',
r'\1气泡视图:精简标题\2',
label="bubble title",
)
sub(
r'(void setPrefs\(\{ nodeViewMode: "bubble" \}\);\n \}\}\n >\n )\?+(\n </button>\n <button\n type="button"\n className=\{`canvas-tool-btn\$\{cardView \? " is-active" : ""\}`\})',
r'\1气泡\2',
label="bubble label",
)
sub(
r'(className=\{`canvas-tool-btn\$\{cardView \? " is-active" : ""\}`\}\n title=")\?+("\n aria-pressed=\{cardView\})',
r'\1卡片视图:展开属性面板\2',
label="card title",
)
sub(
r'(void setPrefs\(\{ nodeViewMode: "card" \}\);\n \}\}\n >\n )\?+(\n </button>\n <button\n type="button"\n className="canvas-tool-btn"\n title=")\?+(")',
r'\1卡片\2取消钉住:让选中节点重新参与自动布局\3',
label="card label + unpin title",
)
sub(
r'(pinned: false \},\n \]\);\n \}\}\n >\n )\?+(\n </button>\n </div>\n <div className="canvas-toolbar__sep" />\n <div className="canvas-toolbar__group">\n <button\n type="button"\n className="canvas-tool-btn"\n title=")\?+("\n disabled=\{!lastChangeSetId\})',
r'\1解钉\2撤销\3',
label="unpin + undo titles/labels",
)
sub(
r'(disabled=\{!lastChangeSetId\}\n onClick=\{\(\) => void undo\(\)\}\n >\n )\?+(\n </button>)',
r'\1撤销\2',
label="undo label",
)
# aria-labels for toolbars
sub(
r'(className="canvas-toolbar"\n role="toolbar"\n aria-label=")\?+(")',
r'\1画布工具\2',
label="toolbar aria",
)
sub(
r'(className="canvas-controls"\n role="toolbar"\n aria-label=")\?+(")',
r'\1视图控制\2',
label="controls aria",
)
# controls titles
sub(
r'title=\{fullscreen \? "\?+" : "\?+"\}\n aria-label=\{fullscreen \? "\?+" : "\?+"\}',
'title={fullscreen ? "退出全屏" : "全屏"}\n aria-label={fullscreen ? "退出全屏" : "全屏"}',
label="fullscreen",
)
sub(
r'title="\?+ \(\+\)"\n aria-label="\?+"',
'title="放大 (+)"\n aria-label="放大"',
label="zoom in",
)
sub(
r'title="\?+ \(-\)"\n aria-label="\?+"',
'title="缩小 (-)"\n aria-label="缩小"',
label="zoom out",
)
sub(
r'title="\?+ \(F\)"\n aria-label="\?+"',
'title="适应画布 (F)"\n aria-label="适应画布"',
label="fit",
)
sub(
r'title=\{locked \? "\?+ \(L\)" : "\?+ \(L\)"\}\n aria-label=\{locked \? "\?+" : "\?+"\}',
'title={locked ? "解锁画布 (L)" : "锁定画布 (L)"}\n aria-label={locked ? "解锁" : "锁定"}',
label="lock",
)
# handles
sub(
r'const text = side < 0 \? "\?+" : "\?+";',
'const text = side < 0 ? "左侧子主题" : "右侧子主题";',
label="handle text",
)
sub(
r'(className="node-handle node-handle--left"\n style=\{\{ left: cx - hw - 14, top: cy - 10 \}\}\n title=")\?+(")',
r'\1添加左侧子主题\2',
label="handle left title",
)
sub(
r'(className="node-handle node-handle--right"\n style=\{\{ left: cx \+ hw - 2, top: cy - 10 \}\}\n title=")\?+(")',
r'\1添加右侧子主题\2',
label="handle right title",
)
# hint footer
sub(
r'''\{locked
\? "\?+"
: dragScope === "node"
\? "\?+"
: "\?+"\}''',
'''{locked
? "已锁定 · 仅浏览/缩放/平移"
: dragScope === "node"
? "单点拖 · 只移动当前节点 · Shift 临时单点 · Esc 取消"
: "整枝拖 · 空白钉住 · 落到节点换父 · N 单点 · Esc 取消"}''',
label="canvas hint",
)
# context menu labels if any remain
sub(
r'label: "\?+"',
'label: "关联"',
count=1,
label="label leftover",
)
remaining = len(re.findall(r"\?{2,}", text))
cjk = len(re.findall(r"[\u4e00-\u9fff]+", text))
print("remaining ?? sequences:", remaining)
print("cjk tokens:", cjk)
print("misses:", len(misses))
if misses:
raise SystemExit(1)
p.write_text(text, encoding="utf-8", newline="\n")
print("wrote", p)

View file

@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
"""Restore remaining CJK in TopologyCanvas.tsx (context-anchored)."""
from pathlib import Path
p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx"
text = p.read_text(encoding="utf-8")
replacements = [
("// iframe ??????????? Tab ??", "// iframe 内无法全屏时,通知父页 Tab 全屏"),
('{ type: "create_node", text: "???", parentId: parent }', '{ type: "create_node", text: "新节点", parentId: parent }'),
(': { type: "create_node", text: "???" },', ': { type: "create_node", text: "新节点" },'),
(
' text: "????",\n parentId: selectedIds[0],\n sidePref: -1,',
' text: "左侧子主题",\n parentId: selectedIds[0],\n sidePref: -1,',
),
(
' text: "????",\n parentId: selectedIds[0],\n sidePref: 1,',
' text: "右侧子主题",\n parentId: selectedIds[0],\n sidePref: 1,',
),
(
' text: "????",\n parentId: parentOfSel,',
' text: "兄弟节点",\n parentId: parentOfSel,',
),
('label: "??",', 'label: "关联",'),
("// Free place ?pin every dragged id", "// Free place → pin every dragged id"),
(
' text: "???",\n ...(selectedIds[0] ? { parentId: selectedIds[0] } : {}),',
' text: "新节点",\n ...(selectedIds[0] ? { parentId: selectedIds[0] } : {}),',
),
('aria-label="????"\n onPointerDown={(e) => e.stopPropagation()}\n >\n {embed ? (', 'aria-label="画布工具"\n onPointerDown={(e) => e.stopPropagation()}\n >\n {embed ? ('),
('title="??????"\n onClick={requestBackToRoster}\n >\n ??\n </button>', 'title="返回画布列表"\n onClick={requestBackToRoster}\n >\n 返回\n </button>'),
('["select", "??", "V"]', '["select", "选择", "V"]'),
('["pan", "??", "H"]', '["pan", "平移", "H"]'),
('["link", "??", "C"]', '["link", "连线", "C"]'),
(
'title="???????????? (N)??? Shift ??????"\n aria-pressed={dragScope === "node"}\n onClick={() => setDragScope("node")}\n >\n ??\n </button>',
'title="单点拖拽:只移动当前节点 (N);按住 Shift 也可临时单点"\n aria-pressed={dragScope === "node"}\n onClick={() => setDragScope("node")}\n >\n 单点\n </button>',
),
(
'title="?????????????? (B)"\n aria-pressed={dragScope === "branch"}\n onClick={() => setDragScope("branch")}\n >\n ??\n </button>',
'title="整枝拖拽:节点与子孙一起移动 (B)"\n aria-pressed={dragScope === "branch"}\n onClick={() => setDragScope("branch")}\n >\n 整枝\n </button>',
),
(
'title="?????????????"\n aria-pressed={!cardView}\n disabled={locked}\n onClick={() => {\n if (!cardView) return;\n void setPrefs({ nodeViewMode: "bubble" });\n }}\n >\n ??\n </button>',
'title="气泡视图:精简标题"\n aria-pressed={!cardView}\n disabled={locked}\n onClick={() => {\n if (!cardView) return;\n void setPrefs({ nodeViewMode: "bubble" });\n }}\n >\n 气泡\n </button>',
),
(
'title="??????????????"\n aria-pressed={cardView}\n disabled={locked}\n onClick={() => {\n if (cardView) return;\n void setPrefs({ nodeViewMode: "card" });\n }}\n >\n ??\n </button>',
'title="卡片视图:展开属性面板"\n aria-pressed={cardView}\n disabled={locked}\n onClick={() => {\n if (cardView) return;\n void setPrefs({ nodeViewMode: "card" });\n }}\n >\n 卡片\n </button>',
),
(
'title="??????????????????"\n disabled={\n locked ||\n !selectedIds[0] ||\n !nodes.find((n) => n.id === selectedIds[0])?.pinned\n }\n onClick={() => {\n if (!selectedIds[0]) return;\n void applyOps([\n { type: "set_pinned", nodeId: selectedIds[0], pinned: false },\n ]);\n }}\n >\n ??\n </button>',
'title="取消钉住:让选中节点重新参与自动布局"\n disabled={\n locked ||\n !selectedIds[0] ||\n !nodes.find((n) => n.id === selectedIds[0])?.pinned\n }\n onClick={() => {\n if (!selectedIds[0]) return;\n void applyOps([\n { type: "set_pinned", nodeId: selectedIds[0], pinned: false },\n ]);\n }}\n >\n 解钉\n </button>',
),
(
'title="??"\n disabled={!lastChangeSetId}\n onClick={() => void undo()}\n >\n ??\n </button>',
'title="撤销"\n disabled={!lastChangeSetId}\n onClick={() => void undo()}\n >\n 撤销\n </button>',
),
(
'aria-label="????"\n onPointerDown={(e) => e.stopPropagation()}\n >\n <button\n type="button"\n className="canvas-controls__btn"\n title={fullscreen ? "????" : "??"}\n aria-label={fullscreen ? "????" : "??"}',
'aria-label="视图控制"\n onPointerDown={(e) => e.stopPropagation()}\n >\n <button\n type="button"\n className="canvas-controls__btn"\n title={fullscreen ? "退出全屏" : "全屏"}\n aria-label={fullscreen ? "退出全屏" : "全屏"}',
),
(
'title="?? (+)"\n aria-label="??"',
'title="放大 (+)"\n aria-label="放大"',
),
(
'title="?? (-)"\n aria-label="??"',
'title="缩小 (-)"\n aria-label="缩小"',
),
(
'title="???? (F)"\n aria-label="????"',
'title="适应画布 (F)"\n aria-label="适应画布"',
),
(
'title={locked ? "???? (L)" : "???? (L)"}\n aria-label={locked ? "??" : "??"}',
'title={locked ? "解锁画布 (L)" : "锁定画布 (L)"}\n aria-label={locked ? "解锁" : "锁定"}',
),
(
'const text = side < 0 ? "????" : "????";',
'const text = side < 0 ? "左侧子主题" : "右侧子主题";',
),
(
'className="node-handle node-handle--left"\n style={{ left: cx - hw - 14, top: cy - 10 }}\n title="????????"',
'className="node-handle node-handle--left"\n style={{ left: cx - hw - 14, top: cy - 10 }}\n title="添加左侧子主题"',
),
(
'className="node-handle node-handle--right"\n style={{ left: cx + hw - 2, top: cy - 10 }}\n title="????????"',
'className="node-handle node-handle--right"\n style={{ left: cx + hw - 2, top: cy - 10 }}\n title="添加右侧子主题"',
),
(
'{locked\n ? "??? ? ???/??/????"\n : dragScope === "node"\n ? "??? ? ?????? ? Shift ???? ? Esc ??"\n : "??? ? ??? ? ???? ? ?????? ? N ??? ? Esc ??"}',
'{locked\n ? "已锁定 · 仅浏览/缩放/平移"\n : dragScope === "node"\n ? "单点拖 · 只移动当前节点 · Shift 临时单点 · Esc 取消"\n : "整枝拖 · 空白钉住 · 落到节点换父 · N 单点 · Esc 取消"}',
),
]
miss = []
for old, new in replacements:
if old not in text:
miss.append(old[:70])
print("MISS:", old[:70].replace("\n", "\\n"))
else:
text = text.replace(old, new, 1)
print("OK:", old[:50].replace("\n", "\\n"))
import re
# leftover UI ?? (not ?? nullish coalescing)
# count Chinese question-mark runs that are quoted UI strings
ui_q = re.findall(r'"\?{2,}"', text)
print("quoted ?? left:", ui_q)
print("misses:", len(miss))
if miss:
raise SystemExit(1)
p.write_text(text, encoding="utf-8", newline="\n")
print("wrote", p)

View file

@ -0,0 +1,221 @@
# -*- coding: utf-8 -*-
"""Restore all CJK in TopologyCanvas.tsx after encoding corruption."""
from __future__ import annotations
import re
from pathlib import Path
p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx"
text = p.read_text(encoding="utf-8")
misses: list[str] = []
def sub(pattern: str, repl: str, *, flags: int = 0, count: int = 0, label: str = "") -> None:
global text
new, n = re.subn(pattern, repl, text, count=count, flags=flags)
tag = label or pattern[:56]
if n == 0:
misses.append(tag)
print("MISS:", tag)
else:
print(f"OK x{n}:", tag)
text = new
# comments
sub(r"// iframe \?+ Tab \?+", "// iframe 内无法全屏时,通知父页 Tab 全屏", label="fullscreen comment")
sub(r"// Free place .+?pin every dragged id", "// Free place → pin every dragged id", label="free place comment")
# placeholders used in draw / create
sub(r'(wrapTextLines\(ctx, n\.text \|\| )"\?+"', r'\1"未命名"', label="unnamed wrap")
sub(r'(n\.text \|\| )"\?+"', r'\1"未命名"', label="unnamed text")
# create defaults
sub(
r'text: "\?+", parentId: parent\b',
'text: "新节点", parentId: parent',
label="tab child",
)
sub(
r': \{ type: "create_node", text: "\?+" \},',
': { type: "create_node", text: "新节点" },',
label="tab orphan / enter orphan",
)
sub(
r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: -1',
'text: "左侧子主题",\n parentId: selectedIds[0],\n sidePref: -1',
label="arrow left",
)
sub(
r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: 1',
'text: "右侧子主题",\n parentId: selectedIds[0],\n sidePref: 1',
label="arrow right",
)
sub(
r'text: "\?+",\n parentId: parentOfSel',
'text: "兄弟节点",\n parentId: parentOfSel',
label="enter sibling",
)
sub(
r'text: "\?+",\n \.\.\.\(selectedIds\[0\]',
'text: "新节点",\n ...(selectedIds[0]',
label="dblclick create",
)
sub(r'label: "\?+"', 'label: "关联"', label="relation label")
# toolbar: back
sub(
r'(className="canvas-tool-btn"\n title=")\?+("\n onClick=\{requestBackToRoster\}\n >\n )\?+(\n </button>)',
r"\1返回画布列表\2返回\3",
label="back to roster",
)
# tool modes
sub(r'\["select", "\?+", "V"\]', '["select", "选择", "V"]', label="select")
sub(r'\["pan", "\?+", "H"\]', '["pan", "平移", "H"]', label="pan")
sub(r'\["link", "\?+", "C"\]', '["link", "连线", "C"]', label="link")
# drag scope — title may contain latin (N)/(B)/Shift so match loosely
sub(
r'(aria-pressed=\{dragScope === "node"\}\n onClick=\{\(\) => setDragScope\("node"\)\}\n >\n )\?+(\n </button>)',
r"\1单点\2",
label="drag node label",
)
sub(
r'(dragScope === "node" \? " is-active" : ""\}`\}\n title=")\?+(")',
r"\1单点拖拽:只移动当前节点 (N);按住 Shift 也可临时单点\2",
label="drag node title",
)
sub(
r'(aria-pressed=\{dragScope === "branch"\}\n onClick=\{\(\) => setDragScope\("branch"\)\}\n >\n )\?+(\n </button>)',
r"\1整枝\2",
label="drag branch label",
)
sub(
r'(dragScope === "branch" \? " is-active" : ""\}`\}\n title=")\?+(")',
r"\1整枝拖拽:节点与子孙一起移动 (B)\2",
label="drag branch title",
)
# bubble / card / unpin
sub(
r'(className=\{`canvas-tool-btn\$\{cardView \? "" : " is-active"\}`\}\n title=")\?+("\n aria-pressed=\{!cardView\})',
r"\1气泡视图:精简标题\2",
label="bubble title",
)
sub(
r'(void setPrefs\(\{ nodeViewMode: "bubble" \}\);\n \}\}\n >\n )\?+(\n </button>\n <button\n type="button"\n className=\{`canvas-tool-btn\$\{cardView \? " is-active" : ""\}`\})',
r"\1气泡\2",
label="bubble label",
)
sub(
r'(className=\{`canvas-tool-btn\$\{cardView \? " is-active" : ""\}`\}\n title=")\?+("\n aria-pressed=\{cardView\})',
r"\1卡片视图:展开属性面板\2",
label="card title",
)
sub(
r'(void setPrefs\(\{ nodeViewMode: "card" \}\);\n \}\}\n >\n )\?+(\n </button>\n <button\n type="button"\n className="canvas-tool-btn"\n title=")\?+(")',
r"\1卡片\2取消钉住:让选中节点重新参与自动布局\3",
label="card label + unpin title",
)
sub(
r'(pinned: false \},\n \]\);\n \}\}\n >\n )\?+(\n </button>\n </div>\n <div className="canvas-toolbar__sep" />\n <div className="canvas-toolbar__group">\n <button\n type="button"\n className="canvas-tool-btn"\n title=")\?+("\n disabled=\{!lastChangeSetId\})',
r"\1解钉\2撤销\3",
label="unpin + undo titles/labels",
)
sub(
r'(disabled=\{!lastChangeSetId\}\n onClick=\{\(\) => void undo\(\)\}\n >\n )\?+(\n </button>)',
r"\1撤销\2",
label="undo label",
)
# aria-labels for toolbars
sub(
r'(className="canvas-toolbar"\n role="toolbar"\n aria-label=")\?+(")',
r"\1画布工具\2",
label="toolbar aria",
)
sub(
r'(className="canvas-controls"\n role="toolbar"\n aria-label=")\?+(")',
r"\1视图控制\2",
label="controls aria",
)
# controls titles
sub(
r'title=\{fullscreen \? "\?+" : "\?+"\}\n aria-label=\{fullscreen \? "\?+" : "\?+"\}',
'title={fullscreen ? "退出全屏" : "全屏"}\n aria-label={fullscreen ? "退出全屏" : "全屏"}',
label="fullscreen",
)
sub(
r'title="\?+ \(\+\)"\n aria-label="\?+"',
'title="放大 (+)"\n aria-label="放大"',
label="zoom in",
)
sub(
r'title="\?+ \(-\)"\n aria-label="\?+"',
'title="缩小 (-)"\n aria-label="缩小"',
label="zoom out",
)
sub(
r'title="\?+ \(F\)"\n aria-label="\?+"',
'title="适应画布 (F)"\n aria-label="适应画布"',
label="fit",
)
sub(
r'title=\{locked \? "\?+ \(L\)" : "\?+ \(L\)"\}\n aria-label=\{locked \? "\?+" : "\?+"\}',
'title={locked ? "解锁画布 (L)" : "锁定画布 (L)"}\n aria-label={locked ? "解锁" : "锁定"}',
label="lock",
)
# handles
sub(
r'const text = side < 0 \? "\?+" : "\?+";',
'const text = side < 0 ? "左侧子主题" : "右侧子主题";',
label="handle text",
)
sub(
r'(className="node-handle node-handle--left"\n style=\{\{ left: cx - hw - 14, top: cy - 10 \}\}\n title=")\?+(")',
r"\1添加左侧子主题\2",
label="handle left title",
)
sub(
r'(className="node-handle node-handle--right"\n style=\{\{ left: cx \+ hw - 2, top: cy - 10 \}\}\n title=")\?+(")',
r"\1添加右侧子主题\2",
label="handle right title",
)
# hint footer — allow · and mixed ?
sub(
r'''\{locked
\? "[^"]*"
: dragScope === "node"
\? "[^"]*"
: "[^"]*"\}''',
'''{locked
? "已锁定 · 仅浏览/缩放/平移"
: dragScope === "node"
? "单点拖 · 只移动当前节点 · Shift 临时单点 · Esc 取消"
: "整枝拖 · 空白钉住 · 落到节点换父 · N 单点 · Esc 取消"}''',
label="canvas hint",
)
# draw-sheet placeholders already Chinese in other files
remaining_ui = re.findall(r'"\?{2,}"', text)
# also button bodies that are only ??
remaining_btn = re.findall(r">\n\s+\?{2,}\n\s+</button>", text)
cjk = len(re.findall(r"[\u4e00-\u9fff]+", text))
print("quoted ?? left:", remaining_ui)
print("button ?? left:", len(remaining_btn))
print("cjk tokens:", cjk)
print("misses:", len(misses))
for m in misses:
print(" -", m)
# Always write — partial fix is better than none; report leftovers
p.write_text(text, encoding="utf-8", newline="\n")
print("wrote", p)
if remaining_ui or remaining_btn or misses:
raise SystemExit(2)

View file

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
from pathlib import Path
p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx"
t = p.read_text(encoding="utf-8")
pairs = [
(
'title="???????????? (N)??? Shift ??????"',
'title="单点拖拽:只移动当前节点 (N);按住 Shift 也可临时单点"',
),
(
'title="?????????????? (B)"',
'title="整枝拖拽:节点与子孙一起移动 (B)"',
),
]
for old, new in pairs:
if old not in t:
raise SystemExit(f"missing: {old}")
t = t.replace(old, new, 1)
print("fixed:", new[:40])
p.write_text(t, encoding="utf-8", newline="\n")
print("done")

View file

@ -0,0 +1,214 @@
# -*- coding: utf-8 -*-
"""Restore CJK in TopologyCanvas.tsx; tolerate latin1/broken middot encoding."""
from __future__ import annotations
import re
from pathlib import Path
p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx"
raw = p.read_bytes()
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
text = raw.decode("latin1")
# Normalize stray CP1252/latin1 middot into a matchable char
text = text.replace("\xb7", "·")
misses: list[str] = []
def sub(pattern: str, repl: str, *, flags: int = 0, count: int = 0, label: str = "") -> None:
global text
new, n = re.subn(pattern, repl, text, count=count, flags=flags)
tag = label or pattern[:56]
if n == 0:
misses.append(tag)
print("MISS:", tag)
else:
print(f"OK x{n}:", tag)
text = new
sub(r"// iframe \?+ Tab \?+", "// iframe 内无法全屏时,通知父页 Tab 全屏", label="fullscreen comment")
sub(r"// Free place .+?pin every dragged id", "// Free place → pin every dragged id", label="free place comment")
sub(r"// Contain: \?+", "// Contain: 等比例完整放入框内,不裁切", label="contain comment")
sub(r'(wrapTextLines\(ctx, n\.text \|\| )"\?+"', r'\1"未命名"', label="unnamed wrap")
sub(r'(n\.text \|\| )"\?+"', r'\1"未命名"', label="unnamed text")
sub(
r'text: "\?+", parentId: parent\b',
'text: "新节点", parentId: parent',
label="tab child",
)
sub(
r': \{ type: "create_node", text: "\?+" \},',
': { type: "create_node", text: "新节点" },',
label="tab orphan / enter orphan",
)
sub(
r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: -1',
'text: "左侧子主题",\n parentId: selectedIds[0],\n sidePref: -1',
label="arrow left",
)
sub(
r'text: "\?+",\n parentId: selectedIds\[0\],\n sidePref: 1',
'text: "右侧子主题",\n parentId: selectedIds[0],\n sidePref: 1',
label="arrow right",
)
sub(
r'text: "\?+",\n parentId: parentOfSel',
'text: "兄弟节点",\n parentId: parentOfSel',
label="enter sibling",
)
sub(
r'text: "\?+",\n \.\.\.\(selectedIds\[0\]',
'text: "新节点",\n ...(selectedIds[0]',
label="dblclick create",
)
sub(r'label: "\?+"', 'label: "关联"', label="relation label")
sub(
r'(className="canvas-tool-btn"\n title=")\?+("\n onClick=\{requestBackToRoster\}\n >\n )\?+(\n </button>)',
r"\1返回画布列表\2返回\3",
label="back to roster",
)
sub(r'\["select", "\?+", "V"\]', '["select", "选择", "V"]', label="select")
sub(r'\["pan", "\?+", "H"\]', '["pan", "平移", "H"]', label="pan")
sub(r'\["link", "\?+", "C"\]', '["link", "连线", "C"]', label="link")
sub(
r'(aria-pressed=\{dragScope === "node"\}\n onClick=\{\(\) => setDragScope\("node"\)\}\n >\n )\?+(\n </button>)',
r"\1单点\2",
label="drag node label",
)
sub(
r'(dragScope === "node" \? " is-active" : ""\}`\}\n title=")\?+(")',
r"\1单点拖拽:只移动当前节点 (N);按住 Shift 也可临时单点\2",
label="drag node title",
)
sub(
r'(aria-pressed=\{dragScope === "branch"\}\n onClick=\{\(\) => setDragScope\("branch"\)\}\n >\n )\?+(\n </button>)',
r"\1整枝\2",
label="drag branch label",
)
sub(
r'(dragScope === "branch" \? " is-active" : ""\}`\}\n title=")\?+(")',
r"\1整枝拖拽:节点与子孙一起移动 (B)\2",
label="drag branch title",
)
sub(
r'(className=\{`canvas-tool-btn\$\{cardView \? "" : " is-active"\}`\}\n title=")\?+("\n aria-pressed=\{!cardView\})',
r"\1气泡视图:精简标题\2",
label="bubble title",
)
sub(
r'(void setPrefs\(\{ nodeViewMode: "bubble" \}\);\n \}\}\n >\n )\?+(\n </button>\n <button\n type="button"\n className=\{`canvas-tool-btn\$\{cardView \? " is-active" : ""\}`\})',
r"\1气泡\2",
label="bubble label",
)
sub(
r'(className=\{`canvas-tool-btn\$\{cardView \? " is-active" : ""\}`\}\n title=")\?+("\n aria-pressed=\{cardView\})',
r"\1卡片视图:展开属性面板\2",
label="card title",
)
sub(
r'(void setPrefs\(\{ nodeViewMode: "card" \}\);\n \}\}\n >\n )\?+(\n </button>\n <button\n type="button"\n className="canvas-tool-btn"\n title=")\?+(")',
r"\1卡片\2取消钉住:让选中节点重新参与自动布局\3",
label="card label + unpin title",
)
sub(
r'(pinned: false \},\n \]\);\n \}\}\n >\n )\?+(\n </button>\n </div>\n <div className="canvas-toolbar__sep" />\n <div className="canvas-toolbar__group">\n <button\n type="button"\n className="canvas-tool-btn"\n title=")\?+("\n disabled=\{!lastChangeSetId\})',
r"\1解钉\2撤销\3",
label="unpin + undo titles/labels",
)
sub(
r'(disabled=\{!lastChangeSetId\}\n onClick=\{\(\) => void undo\(\)\}\n >\n )\?+(\n </button>)',
r"\1撤销\2",
label="undo label",
)
sub(
r'(className="canvas-toolbar"\n role="toolbar"\n aria-label=")\?+(")',
r"\1画布工具\2",
label="toolbar aria",
)
sub(
r'(className="canvas-controls"\n role="toolbar"\n aria-label=")\?+(")',
r"\1视图控制\2",
label="controls aria",
)
sub(
r'title=\{fullscreen \? "\?+" : "\?+"\}\n aria-label=\{fullscreen \? "\?+" : "\?+"\}',
'title={fullscreen ? "退出全屏" : "全屏"}\n aria-label={fullscreen ? "退出全屏" : "全屏"}',
label="fullscreen",
)
sub(
r'title="\?+ \(\+\)"\n aria-label="\?+"',
'title="放大 (+)"\n aria-label="放大"',
label="zoom in",
)
sub(
r'title="\?+ \(-\)"\n aria-label="\?+"',
'title="缩小 (-)"\n aria-label="缩小"',
label="zoom out",
)
sub(
r'title="\?+ \(F\)"\n aria-label="\?+"',
'title="适应画布 (F)"\n aria-label="适应画布"',
label="fit",
)
sub(
r'title=\{locked \? "\?+ \(L\)" : "\?+ \(L\)"\}\n aria-label=\{locked \? "\?+" : "\?+"\}',
'title={locked ? "解锁画布 (L)" : "锁定画布 (L)"}\n aria-label={locked ? "解锁" : "锁定"}',
label="lock",
)
sub(
r'const text = side < 0 \? "\?+" : "\?+";',
'const text = side < 0 ? "左侧子主题" : "右侧子主题";',
label="handle text",
)
sub(
r'(className="node-handle node-handle--left"\n style=\{\{ left: cx - hw - 14, top: cy - 10 \}\}\n title=")\?+(")',
r"\1添加左侧子主题\2",
label="handle left title",
)
sub(
r'(className="node-handle node-handle--right"\n style=\{\{ left: cx \+ hw - 2, top: cy - 10 \}\}\n title=")\?+(")',
r"\1添加右侧子主题\2",
label="handle right title",
)
sub(
r"""\{locked
\? "[^"]*"
: dragScope === "node"
\? "[^"]*"
: "[^"]*"\}""",
"""{locked
? "已锁定 · 仅浏览/缩放/平移"
: dragScope === "node"
? "单点拖 · 只移动当前节点 · Shift 临时单点 · Esc 取消"
: "整枝拖 · 空白钉住 · 落到节点换父 · N 单点 · Esc 取消"}""",
label="canvas hint",
)
remaining_ui = re.findall(r'"\?{2,}"', text)
remaining_btn = re.findall(r">\n\s+\?{2,}\n\s+</button>", text)
cjk = len(re.findall(r"[\u4e00-\u9fff]+", text))
print("quoted ?? left:", remaining_ui)
print("button ?? left:", len(remaining_btn))
print("cjk tokens:", cjk)
print("misses:", len(misses))
for m in misses:
print(" -", m)
p.write_text(text, encoding="utf-8", newline="\n")
print("wrote", p)
if remaining_ui or remaining_btn or misses:
raise SystemExit(2)
print("SUCCESS")

View file

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
from pathlib import Path
d = Path(__file__).resolve().parents[1] / "dist" / "assets"
js_path = next(d.glob("index-*.js"))
js = js_path.read_text(encoding="utf-8")
needles = ["选择", "平移", "连线", "单点", "整枝", "气泡", "卡片", "解钉", "撤销"]
print(js_path.name)
print({n: (n in js) for n in needles})
print("corrupt select:", '["select","??"' in js or '["select", "??"' in js)

View file

@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
from pathlib import Path
import re
p = Path(__file__).resolve().parents[1] / "src" / "TopologyCanvas.tsx"
t = p.read_text(encoding="utf-8")
print("utf8 ok, cjk tokens:", len(re.findall(r"[\u4e00-\u9fff]+", t)))
print("quoted ??:", re.findall(r'"\?{2,}"', t))
for i, line in enumerate(t.splitlines(), 1):
if "title=" in line and ("(N)" in line or "(B)" in line or "选择" in line or "气泡" in line):
print(f"{i}: {line.strip()}")
# toolbar labels
for needle in ("选择", "平移", "连线", "单点", "整枝", "气泡", "卡片", "解钉", "撤销"):
print(needle, "OK" if needle in t else "MISSING")

346
apps/web/src/App.tsx Normal file
View file

@ -0,0 +1,346 @@
import { useEffect, useMemo } from "react";
import { NodeInspector } from "./NodeInspector";
import { applyThemeFromQuery } from "./system-theme";
import { TopologyCanvas } from "./TopologyCanvas";
import { useAppStore } from "./store";
function readQuery() {
try {
return new URLSearchParams(window.location.search);
} catch {
return new URLSearchParams();
}
}
export function App() {
const query = useMemo(() => readQuery(), []);
const embed = query.get("embed") === "1" || query.get("embed") === "true";
const queryCanvasId = query.get("canvasId") || "";
const {
canvases,
canvasId,
canvas,
nodes,
selectedIds,
leftCollapsed,
loadCanvases,
createCanvas,
openCanvas,
undo,
setCollapsedPanels,
setPrefs,
applyOps,
trash,
restoreFromTrash,
} = useAppStore();
useEffect(() => {
applyThemeFromQuery();
}, []);
useEffect(() => {
void (async () => {
await loadCanvases();
if (queryCanvasId) {
await openCanvas(queryCanvasId);
return;
}
if (!useAppStore.getState().canvasId) {
await createCanvas("未命名画布");
}
})();
}, [loadCanvases, createCanvas, openCanvas, queryCanvasId]);
useEffect(() => {
const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`;
let ws: WebSocket | null = null;
let closed = false;
let retryTimer: number | undefined;
let refreshTimer: number | undefined;
let retryMs = 800;
const scheduleRefresh = (id: string, preserveUnpinned?: boolean) => {
window.clearTimeout(refreshTimer);
refreshTimer = window.setTimeout(async () => {
if (useAppStore.getState().canvasId !== id) return;
try {
const data = await fetch(`/api/canvases/${id}`).then((r) => r.json()) as {
canvas: import("@mind-map/shared").CanvasMeta;
nodes: import("@mind-map/shared").MindNode[];
edges: import("@mind-map/shared").MindEdge[];
positions: import("./store").Positions;
};
useAppStore.getState().applyCanvasPayload(data, { preserveUnpinned });
} catch {
void useAppStore.getState().openCanvas(id);
}
}, 120);
};
const connect = () => {
if (closed) return;
try {
ws = new WebSocket(wsUrl);
} catch {
retryTimer = window.setTimeout(connect, retryMs);
retryMs = Math.min(8000, retryMs * 1.6);
return;
}
ws.onopen = () => {
retryMs = 800;
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(String(ev.data)) as {
type?: string;
canvasId?: string;
preserveUnpinned?: boolean;
nodeIds?: string[];
edgeIds?: string[];
style?: {
nodeColor?: string;
nodeGlow?: boolean;
edgeWidth?: number;
edgeColor?: string;
};
ttlMs?: number;
fitViewport?: { x: number; y: number; zoom: number };
mirror?: {
kind: "user" | "assistant" | "text-delta" | "tool" | "turn-end";
text?: string;
name?: string;
callId?: string;
sessionId?: string;
reason?: unknown;
};
};
if (
msg.type === "canvas/event" &&
msg.canvasId &&
msg.canvasId === useAppStore.getState().canvasId
) {
if (msg.fitViewport) {
useAppStore.getState().requestFitViewport(msg.fitViewport);
}
scheduleRefresh(msg.canvasId, msg.preserveUnpinned === true);
}
if (
msg.type === "canvas/highlight" &&
msg.canvasId &&
msg.canvasId === useAppStore.getState().canvasId
) {
const nodeIds = Array.isArray(msg.nodeIds) ? msg.nodeIds : [];
const edgeIds = Array.isArray(msg.edgeIds) ? msg.edgeIds : [];
useAppStore.getState().flashHighlight({
nodeIds,
edgeIds,
style:
msg.style && typeof msg.style === "object" ? msg.style : null,
ttlMs:
typeof msg.ttlMs === "number"
? msg.ttlMs
: edgeIds.length
? 5000
: 2200,
});
}
} catch {
/* ignore */
}
};
ws.onclose = () => {
if (closed) return;
retryTimer = window.setTimeout(connect, retryMs);
retryMs = Math.min(8000, retryMs * 1.6);
};
ws.onerror = () => {
try {
ws?.close();
} catch {
/* ignore */
}
};
};
connect();
return () => {
closed = true;
window.clearTimeout(retryTimer);
window.clearTimeout(refreshTimer);
try {
ws?.close();
} catch {
/* ignore */
}
};
}, []);
useEffect(() => {
const id = canvasId;
if (!id) return;
const t = window.setTimeout(() => {
void fetch(`/api/canvases/${id}/activity`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
selectionIds: selectedIds,
focusNodeId: canvas?.focusNodeId ?? null,
viewport: canvas?.viewport ?? { x: 0, y: 0, zoom: 1 },
}),
});
}, 200);
return () => window.clearTimeout(t);
}, [canvasId, selectedIds, canvas?.focusNodeId, canvas?.viewport]);
const focusId = canvas?.focusNodeId;
const outline = nodes.filter((n) => !n.deletedAt);
if (embed) {
return (
<div className="app-shell embed">
<div className="main embed-main">
<TopologyCanvas />
</div>
</div>
);
}
return (
<div className="app-shell">
<header className="topbar">
<div className="brand">Mind Map</div>
<button type="button" onClick={() => setCollapsedPanels("left", !leftCollapsed)}>
{leftCollapsed ? "目录" : "收目录"}
</button>
<button type="button" onClick={() => void createCanvas()}>
新建
</button>
<button type="button" onClick={() => void undo()} disabled={!useAppStore.getState().lastChangeSetId}>
撤销
</button>
<button
type="button"
onClick={() =>
void setPrefs({
density: canvas?.prefs.density === "compact" ? "comfortable" : "compact",
})
}
>
密度: {canvas?.prefs.density ?? "comfortable"}
</button>
<button
type="button"
onClick={() =>
void setPrefs({ branchColoring: !(canvas?.prefs.branchColoring ?? true) })
}
>
分色: {canvas?.prefs.branchColoring === false ? "关" : "开"}
</button>
<button
type="button"
onClick={() =>
void setPrefs({
nodeViewMode:
canvas?.prefs.nodeViewMode !== "bubble" ? "bubble" : "card",
})
}
>
模式: {canvas?.prefs.nodeViewMode !== "bubble" ? "卡片" : "气泡"}
</button>
<button
type="button"
onClick={() => {
const id = canvasId;
if (!id) return;
void fetch(`/api/canvases/${id}/export.json`)
.then((r) => r.json())
.then((data) => {
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${canvas?.title ?? "mindmap"}.json`;
a.click();
});
}}
>
导出JSON
</button>
<button
type="button"
onClick={() => {
const el = document.querySelector(".canvas-host canvas") as HTMLCanvasElement | null;
if (!el) return;
el.toBlob((blob) => {
if (!blob) return;
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${canvas?.title ?? "mindmap"}.png`;
a.click();
});
}}
>
导出PNG
</button>
<div className="spacer" />
</header>
<div
className={`main ${leftCollapsed ? "left-collapsed" : ""}`}
>
<aside className="panel" style={{ display: leftCollapsed ? "none" : undefined }}>
<div className="panel-header">目录</div>
<div className="panel-body">
<div className="muted" style={{ marginBottom: 8 }}>
画布
</div>
{canvases.map((c) => (
<div
key={c.id}
className={`canvas-list-item ${c.id === canvasId ? "active" : ""}`}
onClick={() => void openCanvas(c.id)}
>
{c.title}
</div>
))}
<div className="muted" style={{ margin: "12px 0 8px" }}>
大纲
</div>
{outline.map((n) => (
<div
key={n.id}
className={`outline-item ${selectedIds.includes(n.id) ? "active" : ""}`}
onClick={() => {
useAppStore.getState().setSelection([n.id]);
void applyOps([{ type: "set_focus", nodeId: n.id }]);
}}
>
{n.id === focusId ? "◎ " : "• "}
{n.text}
</div>
))}
<div className="muted" style={{ margin: "12px 0 8px" }}>
属性
</div>
<NodeInspector />
<div className="muted" style={{ margin: "12px 0 8px" }}>
回收站 ({trash.length})
</div>
{trash.length === 0 && <div className="muted">空</div>}
{trash.map((n) => (
<div key={n.id} className="trash-item">
<span style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{n.text}</span>
<button type="button" onClick={() => void restoreFromTrash(n.id)}>
恢复
</button>
</div>
))}
</div>
</aside>
<TopologyCanvas />
</div>
</div>
);
}

View file

@ -0,0 +1,37 @@
import type { MindNode } from "@mind-map/shared";
export function HoverCard({
node,
x,
y,
}: {
node: MindNode;
x: number;
y: number;
}) {
const tags = (node.tags ?? []).slice(0, 4);
const note = (node.note ?? "").trim();
const links = node.links?.length ?? 0;
return (
<div className="hover-card" style={{ left: x, top: y }}>
<div className="hover-card__title">{node.text}</div>
{note ? <div className="hover-card__note">{note.slice(0, 120)}</div> : null}
{tags.length ? (
<div className="hover-card__tags">
{tags.map((t) => (
<span key={t} className="hover-card__tag">
{t}
</span>
))}
</div>
) : null}
<div className="hover-card__meta">
{links ? `${links} 链接` : null}
{node.dueAt != null
? `${links ? " · " : ""}截止 ${new Date(node.dueAt).toLocaleDateString()}`
: null}
{node.stylePreset !== "default" ? `${links || node.dueAt ? " · " : ""}${node.stylePreset}` : null}
</div>
</div>
);
}

View file

@ -0,0 +1,197 @@
import type { MindEdge, MindNode } from "@mind-map/shared";
import { useEffect, useMemo, useState } from "react";
import { NodePropEditor } from "./node-props";
import { useAppStore } from "./store";
type ApplyOps = ReturnType<typeof useAppStore.getState>["applyOps"];
export function NodeInspector() {
const canvas = useAppStore((s) => s.canvas);
const nodes = useAppStore((s) => s.nodes);
const edges = useAppStore((s) => s.edges);
const selectedIds = useAppStore((s) => s.selectedIds);
const selectedEdgeId = useAppStore((s) => s.selectedEdgeId);
const applyOps = useAppStore((s) => s.applyOps);
const mode = canvas?.prefs.inspectorMode ?? "selection";
const node = useMemo(
() => (selectedIds[0] ? nodes.find((n) => n.id === selectedIds[0]) : undefined),
[nodes, selectedIds],
);
const edge = useMemo(
() => (selectedEdgeId ? edges.find((e) => e.id === selectedEdgeId) : undefined),
[edges, selectedEdgeId],
);
if (mode === "selection" && !node && !edge) {
return <div className="inspector muted">选中节点或边以编辑属性</div>;
}
if (edge && !node) {
return <EdgeForm edge={edge} applyOps={applyOps} />;
}
if (!node) {
return <div className="inspector muted">选中节点或边以编辑属性</div>;
}
return <NodeForm node={node} applyOps={applyOps} />;
}
function NodeForm({ node, applyOps }: { node: MindNode; applyOps: ApplyOps }) {
return (
<div className="inspector">
<div className="inspector__title">节点属性</div>
<NodePropEditor
node={node}
applyOps={applyOps}
variant="panel"
showStylePreset
/>
<div className="inspector__row" style={{ marginTop: 8 }}>
<button
type="button"
onClick={() =>
void applyOps([
{
type: "set_collapsed",
nodeId: node.id,
collapsed: !node.collapsed,
},
])
}
>
{node.collapsed ? "展开" : "折叠"}
</button>
</div>
</div>
);
}
function EdgeForm({ edge, applyOps }: { edge: MindEdge; applyOps: ApplyOps }) {
const [tagsText, setTagsText] = useState(edge.tags?.join(", ") ?? "");
useEffect(() => {
setTagsText(edge.tags?.join(", ") ?? "");
}, [edge.id, edge.version, edge.tags]);
return (
<div className="inspector">
<div className="inspector__title">边属性</div>
<label className="inspector__field">
<span>标签</span>
<input
key={`el-${edge.id}-${edge.version}`}
defaultValue={edge.label ?? ""}
onBlur={(e) => {
void applyOps([
{
type: "update_edge_meta",
edgeId: edge.id,
patch: { label: e.target.value || null },
},
]);
}}
/>
</label>
<label className="inspector__field">
<span>备注</span>
<textarea
key={`en-${edge.id}-${edge.version}`}
defaultValue={edge.note ?? ""}
rows={2}
onBlur={(e) => {
void applyOps([
{
type: "update_edge_meta",
edgeId: edge.id,
patch: { note: e.target.value || null },
},
]);
}}
/>
</label>
<label className="inspector__field">
<span>线粗 (1-5)</span>
<input
key={`ew-${edge.id}-${edge.version}`}
type="number"
min={1}
max={5}
defaultValue={edge.weight ?? 1}
onBlur={(e) => {
const n = Number(e.target.value);
const weight = Number.isFinite(n)
? Math.max(1, Math.min(5, Math.round(n)))
: null;
void applyOps([
{
type: "update_edge_meta",
edgeId: edge.id,
patch: { weight },
},
]);
}}
/>
</label>
<label className="inspector__field">
<span>线型</span>
<select
key={`es-${edge.id}-${edge.version}`}
defaultValue={edge.lineStyle ?? (edge.kind === "relation" ? "dashed" : "solid")}
onChange={(e) => {
const v = e.target.value as "solid" | "dashed" | "dotted";
void applyOps([
{
type: "update_edge_meta",
edgeId: edge.id,
patch: { lineStyle: v },
},
]);
}}
>
<option value="solid">实线</option>
<option value="dashed">虚线</option>
<option value="dotted">点线</option>
</select>
</label>
<label className="inspector__field">
<span>箭头</span>
<select
key={`ed-${edge.id}-${edge.version}`}
defaultValue={
edge.direction ?? (edge.kind === "relation" ? "forward" : "none")
}
onChange={(e) => {
const v = e.target.value as "forward" | "both" | "none";
void applyOps([
{
type: "update_edge_meta",
edgeId: edge.id,
patch: { direction: v },
},
]);
}}
>
<option value="forward">单向 →</option>
<option value="both">双向 ↔</option>
<option value="none">无箭头</option>
</select>
</label>
<label className="inspector__field">
<span>标签(逗号分隔)</span>
<input
value={tagsText}
onChange={(e) => setTagsText(e.target.value)}
onBlur={() => {
const tags = tagsText
.split(/[,,]/)
.map((s) => s.trim())
.filter(Boolean);
void applyOps([{ type: "update_edge_meta", edgeId: edge.id, patch: { tags } }]);
}}
/>
</label>
<div className="muted" style={{ fontSize: 11 }}>
{edge.kind} · {edge.from.slice(0, 8)} → {edge.to.slice(0, 8)}
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,104 @@
/** Shared canvas typography + chrome helpers for bubble/card nodes. */
export const CANVAS_FONT =
'"Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", system-ui, sans-serif';
export function canvasFont(
sizePx: number,
weight: 400 | 500 | 600 | 700 = 400,
): string {
return `${weight === 400 ? "" : `${weight} `}${sizePx}px ${CANVAS_FONT}`;
}
/** Soft drop shadow under a node (world units). */
export function paintNodeShadow(
ctx: CanvasRenderingContext2D,
zoom: number,
opts?: { selected?: boolean },
) {
ctx.shadowColor = opts?.selected
? "rgba(0, 0, 0, 0.45)"
: "rgba(0, 0, 0, 0.28)";
ctx.shadowBlur = (opts?.selected ? 18 : 12) / Math.max(0.5, zoom);
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 3 / Math.max(0.5, zoom);
}
export function clearShadow(ctx: CanvasRenderingContext2D) {
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
}
/** Low-alpha accent wash clipped to current path. */
export function fillAccentWash(
ctx: CanvasRenderingContext2D,
accent: string,
alpha = 0.14,
) {
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = accent;
ctx.fill();
ctx.restore();
}
/** Left accent rail inside a rounded rect (not for diamonds/pills). */
export function paintAccentRail(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
h: number,
accent: string,
radius = 10,
) {
const railW = 4;
ctx.save();
ctx.beginPath();
const rr = Math.min(radius, h / 2);
ctx.moveTo(x + rr, y);
ctx.lineTo(x + railW, y);
ctx.lineTo(x + railW, y + h);
ctx.lineTo(x + rr, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - rr);
ctx.lineTo(x, y + rr);
ctx.quadraticCurveTo(x, y, x + rr, y);
ctx.closePath();
ctx.fillStyle = accent;
ctx.globalAlpha = 0.95;
ctx.fill();
ctx.restore();
}
export function paintBadgeChip(
ctx: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
theme: { labelSecondary: string; border: string; bgLayer1: string },
zoom: number,
): number {
const padX = 6;
const h = 16 / Math.max(0.75, Math.min(1.2, zoom));
ctx.font = canvasFont(10 / Math.max(0.75, Math.min(1.2, zoom)), 500);
const tw = ctx.measureText(text).width;
const w = tw + padX * 2;
ctx.beginPath();
const r = h / 2;
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
ctx.fillStyle = "rgba(255,255,255,0.06)";
ctx.fill();
ctx.strokeStyle = theme.border;
ctx.lineWidth = 1 / Math.max(0.5, zoom);
ctx.stroke();
ctx.fillStyle = theme.labelSecondary;
ctx.textBaseline = "middle";
ctx.fillText(text, x + padX, y + h / 2);
return w + 4;
}

View file

@ -0,0 +1,56 @@
import type { MindEdge } from "@mind-map/shared";
/** Primary-hierarchy descendants including root. */
export function collectSubtreeIds(
rootId: string,
edges: MindEdge[],
): string[] {
const kids = new Map<string, string[]>();
for (const e of edges) {
if (e.deletedAt || e.kind !== "hierarchy" || !e.isPrimaryParent) continue;
const list = kids.get(e.from) ?? [];
list.push(e.to);
kids.set(e.from, list);
}
const out: string[] = [rootId];
const seen = new Set<string>([rootId]);
const queue = [rootId];
while (queue.length) {
const cur = queue.shift()!;
for (const c of kids.get(cur) ?? []) {
if (seen.has(c)) continue;
seen.add(c);
out.push(c);
queue.push(c);
}
}
return out;
}
export function isDescendantOrSelf(
candidateId: string,
rootId: string,
edges: MindEdge[],
): boolean {
return collectSubtreeIds(rootId, edges).includes(candidateId);
}
export type DragGhost = {
rootId: string;
/** World delta from positions at pointer-down. */
dx: number;
dy: number;
ids: string[];
};
export function ghostPos(
id: string,
base: { x: number; y: number } | undefined,
ghost: DragGhost | null,
): { x: number; y: number } | undefined {
if (!base) return undefined;
if (ghost && ghost.ids.includes(id)) {
return { x: base.x + ghost.dx, y: base.y + ghost.dy };
}
return base;
}

View file

@ -0,0 +1,31 @@
/** Shared image loader for canvas card thumbnails. */
const cache = new Map<string, HTMLImageElement | "loading" | "error">();
export function getCachedImage(
url: string,
onUpdate: () => void,
): HTMLImageElement | null {
const hit = cache.get(url);
if (hit instanceof HTMLImageElement) return hit;
if (hit === "loading" || hit === "error") return null;
cache.set(url, "loading");
const img = new Image();
img.decoding = "async";
// Best-effort; some CDNs block canvas without CORS — we still draw when allowed.
img.crossOrigin = "anonymous";
img.onload = () => {
cache.set(url, img);
onUpdate();
};
img.onerror = () => {
cache.set(url, "error");
onUpdate();
};
img.src = url;
return null;
}
export function imageLoadFailed(url: string): boolean {
return cache.get(url) === "error";
}

5
apps/web/src/main.tsx Normal file
View file

@ -0,0 +1,5 @@
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(<App />);

View file

@ -0,0 +1,180 @@
import type { MindNode, StylePreset } from "@mind-map/shared";
import { estimateCardSize } from "./node-props/sizing";
/** Keep roughly in sync with layout-engine nodeLayoutSize (comfortable / compact). */
export function nodeBoxSize(
n: MindNode,
dens: boolean,
opts?: { asCard?: boolean },
): { w: number; h: number } {
if (opts?.asCard || n.stylePreset === "card") {
return estimateCardSize(n, dens);
}
let w = dens ? 168 : 212;
let h = dens ? 48 : 60;
const hasMeta =
Boolean(n.tags?.length) ||
Boolean(n.links?.length) ||
n.dueAt != null ||
Boolean(n.note);
h += hasMeta ? (dens ? 20 : 26) : dens ? 8 : 10;
const p: StylePreset = n.stylePreset;
if (p === "title") {
w *= 1.18;
h *= 1.15;
} else if (p === "decision") {
w *= 1.08;
h *= 1.12;
} else if (p === "note") {
h *= 1.12;
}
if (n.imageUrl) {
w = Math.max(w, dens ? 200 : 248);
h += dens ? 78 : 96;
}
return { w, h };
}
export function drawNodeShape(
ctx: CanvasRenderingContext2D,
preset: StylePreset,
x: number,
y: number,
w: number,
h: number,
radius: number,
) {
ctx.beginPath();
if (preset === "decision") {
const cx = x + w / 2;
const cy = y + h / 2;
ctx.moveTo(cx, y);
ctx.lineTo(x + w, cy);
ctx.lineTo(cx, y + h);
ctx.lineTo(x, cy);
ctx.closePath();
} else if (preset === "note") {
const fold = Math.min(12, w * 0.12);
ctx.moveTo(x, y);
ctx.lineTo(x + w - fold, y);
ctx.lineTo(x + w, y + fold);
ctx.lineTo(x + w, y + h);
ctx.lineTo(x, y + h);
ctx.closePath();
} else if (preset === "title") {
const r = h / 2;
roundRectPath(ctx, x, y, w, h, r);
} else {
// default / risk / muted / card
roundRectPath(ctx, x, y, w, h, radius);
}
}
export function roundRectPath(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
r: number,
) {
const rr = Math.min(r, w / 2, h / 2);
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
/** Wrap text to at most `maxLines` lines within `maxWidth`. */
export function wrapTextLines(
ctx: CanvasRenderingContext2D,
text: string,
maxWidth: number,
maxLines: number,
): string[] {
const raw = text.replace(/\s+/g, " ").trim();
if (!raw) return [];
const lines: string[] = [];
let rest = raw;
while (rest && lines.length < maxLines) {
if (ctx.measureText(rest).width <= maxWidth) {
lines.push(rest);
break;
}
let lo = 1;
let hi = rest.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (ctx.measureText(rest.slice(0, mid)).width <= maxWidth) lo = mid;
else hi = mid - 1;
}
let cut = Math.max(1, lo);
const space = rest.lastIndexOf(" ", cut);
if (space > cut * 0.4) cut = space;
let line = rest.slice(0, cut).trimEnd();
rest = rest.slice(cut).trimStart();
if (lines.length === maxLines - 1 && rest) {
while (line.length > 1 && ctx.measureText(`${line}…`).width > maxWidth) {
line = line.slice(0, -1);
}
lines.push(`${line}…`);
break;
}
lines.push(line);
}
return lines;
}
export function cardSourceLabel(n: MindNode): string | null {
const link = n.links?.[0];
if (!link) return null;
if (link.title?.trim()) return link.title.trim().slice(0, 28);
try {
return new URL(link.url).hostname.replace(/^www\./, "");
} catch {
return link.url.slice(0, 28);
}
}
export function cardTimeLabel(n: MindNode): string | null {
const ms = n.startAt ?? n.dueAt ?? null;
if (ms == null || !Number.isFinite(ms)) return null;
try {
return new Date(ms).toLocaleDateString();
} catch {
return null;
}
}
export function nodeBadges(n: MindNode): string[] {
const out: string[] = [];
if (n.icon) {
const glyph = ICON_GLYPH[n.icon];
if (glyph) out.push(glyph);
}
if (n.collapsed) out.push("▸");
if (n.links?.length) out.push("🔗");
if (n.imageUrl) out.push("🖼");
if (n.dueAt != null) out.push("⏰");
if (n.tags?.length) out.push(`#${n.tags.length}`);
if (n.pinned) out.push("📌");
return out;
}
const ICON_GLYPH: Record<string, string> = {
server: "[服]",
database: "[库]",
cloud: "[云]",
person: "[人]",
folder: "[夹]",
doc: "[档]",
link: "[链]",
warning: "[警]",
check: "[✓]",
star: "[★]",
gear: "[设]",
globe: "[球]",
};

View file

@ -0,0 +1,411 @@
import type { MindNode, Op, StylePreset } from "@mind-map/shared";
import { useEffect, useMemo, useRef, useState } from "react";
import {
commitField,
fieldEditorSeed,
hiddenAddableFields,
visiblePropRows,
} from "./registry";
import type { PropFieldDef, PropFieldId } from "./types";
import { uploadImageFile } from "./upload-image";
import "./node-shell.css";
type ApplyOps = (ops: Op[], summary?: string) => void | Promise<void>;
export function NodePropEditor({
node,
applyOps,
accent,
variant = "overlay",
onDragHandlePointerDown,
showStylePreset,
}: {
node: MindNode;
applyOps: ApplyOps;
accent?: string;
variant?: "overlay" | "panel";
onDragHandlePointerDown?: (e: React.PointerEvent) => void;
showStylePreset?: boolean;
}) {
const [forced, setForced] = useState<Set<string>>(() => new Set());
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
setForced(new Set());
setMenuOpen(false);
}, [node.id]);
const rows = useMemo(
() => visiblePropRows(node, forced),
[node, forced],
);
const addable = useMemo(
() => hiddenAddableFields(node, forced),
[node, forced],
);
const commit = (id: PropFieldId, raw: string) => {
const ops = commitField(node, id, raw);
if (!ops.length) return;
void applyOps(ops, `prop ${id}`);
if (!raw.trim() && id !== "text") {
setForced((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
};
return (
<div
className={`ns-editor${variant === "panel" ? " ns-editor--panel" : ""}`}
>
<div
className="ns-editor__header"
data-ns-drag-handle
onPointerDown={onDragHandlePointerDown}
>
<span
className="ns-editor__dot"
style={accent ? { background: accent } : undefined}
/>
<input
className="ns-editor__title"
key={`title-${node.id}-${node.version}`}
defaultValue={node.text}
onPointerDown={(e) => e.stopPropagation()}
onBlur={(e) => {
if (e.target.value !== node.text) commit("text", e.target.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
/>
</div>
<div className="ns-editor__body">
{showStylePreset ? (
<div className="ns-row">
<span className="ns-row__label">类型</span>
<div className="ns-row__ctrl">
<select
value={node.stylePreset}
onChange={(e) => {
void applyOps(
[
{
type: "set_style_preset",
nodeId: node.id,
stylePreset: e.target.value as StylePreset,
},
],
"style",
);
}}
>
<option value="default">默认外形</option>
<option value="title">title</option>
<option value="decision">决策</option>
<option value="risk">风险</option>
<option value="note">便签</option>
<option value="muted">弱化</option>
</select>
</div>
<span />
</div>
) : null}
<div className="ns-row">
<span className="ns-row__label">强调色</span>
<div className="ns-row__ctrl">
<input
type="text"
placeholder="#e74c3c"
defaultValue={node.accentColor ?? ""}
key={`ac-${node.id}-${node.version}`}
onBlur={(e) => {
const v = e.target.value.trim();
const next = v || null;
if ((node.accentColor ?? null) === next) return;
void applyOps([
{
type: "update_node_meta",
nodeId: node.id,
patch: { accentColor: next as never },
},
]);
}}
/>
</div>
<span />
</div>
<div className="ns-row">
<span className="ns-row__label">图标</span>
<div className="ns-row__ctrl">
<select
value={node.icon ?? ""}
onChange={(e) => {
const v = e.target.value;
void applyOps([
{
type: "update_node_meta",
nodeId: node.id,
patch: {
icon: (v || null) as never,
},
},
]);
}}
>
<option value="">无</option>
<option value="server">🖥 服务器</option>
<option value="database">🗄 数据库</option>
<option value="cloud">☁ 云</option>
<option value="person">👤 人物</option>
<option value="folder">📁 文件夹</option>
<option value="doc">📄 文档</option>
<option value="link">🔗 链接</option>
<option value="warning">⚠ 警告</option>
<option value="check">✓ 完成</option>
<option value="star">★ 星标</option>
<option value="gear">⚙ 设置</option>
<option value="globe">🌐 全球</option>
</select>
</div>
<span />
</div>
{rows
.filter((r) => r.def.id !== "text")
.map(({ def }) => (
<FieldRow
key={def.id}
def={def}
node={node}
onCommit={(raw) => commit(def.id, raw)}
onClear={() => {
commit(def.id, "");
setForced((prev) => {
const next = new Set(prev);
next.delete(def.id);
return next;
});
}}
/>
))}
</div>
{addable.length > 0 ? (
<div
className="ns-add"
onPointerDown={(e) => e.stopPropagation()}
>
{menuOpen ? (
<div className="ns-add__menu">
{addable.map((f) => (
<button
key={f.id}
type="button"
className="ns-add__item"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setForced((prev) => new Set(prev).add(f.id));
setMenuOpen(false);
}}
>
{f.label}
</button>
))}
</div>
) : null}
<button
type="button"
className="ns-add__btn"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen((v) => !v);
}}
>
+ 添加字段
</button>
</div>
) : null}
</div>
);
}
function FieldRow({
def,
node,
onCommit,
onClear,
}: {
def: PropFieldDef;
node: MindNode;
onCommit: (raw: string) => void;
onClear: () => void;
}) {
const seed = fieldEditorSeed(node, def.id);
const canClear = !def.always;
const [uploading, setUploading] = useState(false);
const [uploadErr, setUploadErr] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
if (def.kind === "image") {
return (
<div className="ns-row">
<span className="ns-row__label">{def.label}</span>
<div className="ns-row__ctrl ns-row__ctrl--image" onPointerDown={(e) => e.stopPropagation()}>
{seed ? <img className="ns-thumb" src={seed} alt="" /> : null}
<div className="ns-image-actions">
<button
type="button"
className="ns-image-pick"
disabled={uploading}
onClick={() => fileRef.current?.click()}
>
{uploading ? "上传中…" : "选择文件"}
</button>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setUploadErr(null);
setUploading(true);
void uploadImageFile(file)
.then((url) => onCommit(url))
.catch((err) =>
setUploadErr(String(err?.message || err || "上传失败")),
)
.finally(() => setUploading(false));
}}
/>
<input
key={`${def.id}-${node.id}-${node.version}`}
className="ns-image-url"
defaultValue={seed}
placeholder="或粘贴图片 URL"
onBlur={(e) => {
const v = e.target.value.trim();
if (v !== (seed || "")) onCommit(v);
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
/>
{canClear ? (
<button
type="button"
className="ns-row__clear"
title="清除"
onClick={onClear}
>
×
</button>
) : null}
</div>
{uploadErr ? <div className="ns-image-err">{uploadErr}</div> : null}
<div className="ns-image-hint">支持本地选图或粘贴 https://… 链接</div>
</div>
</div>
);
}
return (
<div className="ns-row">
<span className="ns-row__label">{def.label}</span>
<div className="ns-row__ctrl" onPointerDown={(e) => e.stopPropagation()}>
{def.kind === "textarea" || def.kind === "links" ? (
<textarea
key={`${def.id}-${node.id}-${node.version}`}
defaultValue={seed}
rows={def.kind === "links" ? 3 : 2}
placeholder={def.kind === "links" ? "标题|url 每行一条" : ""}
onBlur={(e) => onCommit(e.target.value)}
/>
) : def.kind === "datetime" ? (
<input
key={`${def.id}-${node.id}-${node.version}`}
type="datetime-local"
defaultValue={seed}
onBlur={(e) => onCommit(e.target.value)}
/>
) : def.kind === "flag" ? (
<button
type="button"
onClick={() => onCommit(node.pinned ? "0" : "1")}
>
{node.pinned ? "解钉" : "钉住"}
</button>
) : (
<input
key={`${def.id}-${node.id}-${node.version}`}
defaultValue={seed}
placeholder={def.kind === "tags" ? "逗号分隔" : ""}
onBlur={(e) => onCommit(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
/>
)}
{canClear ? (
<button
type="button"
className="ns-row__clear"
title="清除"
onClick={onClear}
>
×
</button>
) : null}
</div>
</div>
);
}
export function NodePropOverlay({
node,
applyOps,
screenX,
screenY,
zoom,
accent,
width,
showStylePreset,
}: {
node: MindNode;
applyOps: ApplyOps;
screenX: number;
screenY: number;
zoom: number;
accent?: string;
width: number;
showStylePreset?: boolean;
}) {
return (
<div
className="ns-overlay"
style={{
left: screenX,
top: screenY,
width,
transform: `translate(-50%, -50%) scale(${zoom})`,
}}
>
<NodePropEditor
node={node}
applyOps={applyOps}
accent={accent}
showStylePreset={showStylePreset}
/>
</div>
);
}

View file

@ -0,0 +1,162 @@
import type { MindNode } from "@mind-map/shared";
import { canvasFont } from "../canvas-chrome";
import { getCachedImage, imageLoadFailed } from "../image-cache";
import type { SystemTheme } from "../system-theme";
import { roundRectPath, wrapTextLines } from "../node-geometry";
import { visiblePropRows } from "./registry";
import {
CARD_GAP,
CARD_HEADER_H,
CARD_PAD_X,
CARD_PAD_Y,
estimateCardSize,
} from "./sizing";
/** Read-only card chrome + stacked property rows (shell already filled by canvas). */
export function drawPropSheet(
ctx: CanvasRenderingContext2D,
node: MindNode,
x: number,
y: number,
theme: SystemTheme,
zoom: number,
dens: boolean,
accent: string,
onImage: () => void,
) {
const { w, h } = estimateCardSize(node, dens);
const rows = visiblePropRows(node);
const radius = dens ? 12 : 14;
// Top accent hairline
ctx.save();
ctx.beginPath();
roundRectPath(ctx, x, y, w, h, radius);
ctx.clip();
ctx.fillStyle = accent;
ctx.globalAlpha = 0.9;
ctx.fillRect(x, y, w, 3);
ctx.globalAlpha = 1;
// Low zoom: title only
if (zoom < 0.35) {
ctx.fillStyle = theme.labelPrimary;
ctx.font = canvasFont(dens ? 12 : 13, 600);
ctx.textBaseline = "middle";
const lines = wrapTextLines(
ctx,
node.text || "未命名",
w - CARD_PAD_X * 2,
2,
);
const lineH = dens ? 14 : 16;
const startY = y + h / 2 - ((lines.length - 1) * lineH) / 2;
lines.forEach((line, i) => {
ctx.fillText(line, x + CARD_PAD_X, startY + i * lineH);
});
ctx.restore();
return;
}
// Header band
ctx.fillStyle = "rgba(0,0,0,0.2)";
ctx.fillRect(x, y, w, CARD_HEADER_H);
ctx.beginPath();
ctx.arc(x + 14, y + CARD_HEADER_H / 2, 4.5, 0, Math.PI * 2);
ctx.fillStyle = accent;
ctx.fill();
ctx.fillStyle = theme.labelPrimary;
ctx.font = canvasFont(dens ? 12 : 13, 600);
ctx.textBaseline = "middle";
const titleMaxW = w - 28 - CARD_PAD_X;
const titleLines = wrapTextLines(
ctx,
node.text || "未命名",
titleMaxW,
dens ? 1 : 2,
);
if (titleLines.length === 1) {
ctx.fillText(titleLines[0]!, x + 26, y + CARD_HEADER_H / 2);
} else {
const lh = 13;
const ty = y + CARD_HEADER_H / 2 - lh / 2;
titleLines.forEach((line, i) => {
ctx.fillText(line, x + 26, ty + i * lh);
});
}
ctx.strokeStyle = theme.border;
ctx.lineWidth = 1 / Math.max(0.5, zoom);
ctx.beginPath();
ctx.moveTo(x + CARD_PAD_X, y + CARD_HEADER_H);
ctx.lineTo(x + w - CARD_PAD_X, y + CARD_HEADER_H);
ctx.stroke();
let cy = y + CARD_HEADER_H + CARD_PAD_Y;
const contentW = w - CARD_PAD_X * 2;
for (const { def, preview } of rows) {
if (def.id === "text") continue;
if (def.imageRow && preview) {
const ih = dens ? 52 : 64;
const img = getCachedImage(preview, onImage);
ctx.save();
ctx.beginPath();
roundRectPath(ctx, x + CARD_PAD_X, cy, contentW, ih, 8);
ctx.clip();
if (img && img.naturalWidth > 0) {
// Contain: 等比例缩小完整入框,不裁切
const scale = Math.min(
contentW / img.naturalWidth,
ih / img.naturalHeight,
);
const dw = img.naturalWidth * scale;
const dh = img.naturalHeight * scale;
ctx.fillStyle = "rgba(0,0,0,0.22)";
ctx.fillRect(x + CARD_PAD_X, cy, contentW, ih);
ctx.drawImage(
img,
x + CARD_PAD_X + (contentW - dw) / 2,
cy + (ih - dh) / 2,
dw,
dh,
);
} else {
ctx.fillStyle = theme.border;
ctx.fillRect(x + CARD_PAD_X, cy, contentW, ih);
ctx.fillStyle = theme.labelSecondary;
ctx.font = canvasFont(11, 500);
ctx.textBaseline = "middle";
ctx.fillText(
imageLoadFailed(preview) ? "配图失败" : "加载中…",
x + CARD_PAD_X + 10,
cy + ih / 2,
);
}
ctx.restore();
cy += ih + CARD_GAP;
continue;
}
ctx.fillStyle = theme.labelSecondary;
ctx.font = canvasFont(dens ? 10 : 11, 500);
ctx.textBaseline = "top";
ctx.fillText(def.label, x + CARD_PAD_X, cy);
ctx.fillStyle = theme.labelPrimary;
ctx.font = canvasFont(dens ? 11 : 12, 400);
const text = preview ?? "—";
const maxLines = def.kind === "textarea" ? 3 : 2;
const lines = wrapTextLines(ctx, text, contentW, maxLines);
let ly = cy + (dens ? 14 : 15);
for (const line of lines) {
ctx.fillText(line, x + CARD_PAD_X, ly);
ly += dens ? 14 : 15;
}
cy = Math.max(cy + def.rowHeight, ly) + CARD_GAP;
}
ctx.restore();
}

View file

@ -0,0 +1,5 @@
export { PROP_FIELDS, visiblePropRows, hiddenAddableFields, commitField } from "./registry";
export { estimateCardSize, CARD_WIDTH } from "./sizing";
export { drawPropSheet } from "./draw-sheet";
export { NodePropEditor, NodePropOverlay } from "./NodePropEditor";
export type { PropFieldId, PropFieldDef } from "./types";

View file

@ -0,0 +1,311 @@
/* Node property system — card / inspector shell */
.ns-editor {
--ns-bg: var(--dsw-alias-bg-layer-1, #1b1e28);
--ns-border: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.1));
--ns-label: var(--dsw-alias-label-secondary, #a5adb8);
--ns-text: var(--dsw-alias-label-primary, #f4f5f7);
--ns-brand: var(--dsw-alias-brand-primary, #5e6ad2);
--ns-input: rgba(255, 255, 255, 0.04);
--ns-font: "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans SC", system-ui, sans-serif;
display: flex;
flex-direction: column;
gap: 0;
background: var(--ns-bg);
border: 1px solid var(--ns-border);
border-radius: 14px;
overflow: visible;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 10px 32px rgba(0, 0, 0, 0.38);
color: var(--ns-text);
font-family: var(--ns-font);
font-size: 12.5px;
line-height: 1.45;
min-width: 248px;
max-width: 320px;
}
.ns-editor--panel {
box-shadow: none;
max-width: none;
width: 100%;
background: transparent;
border: none;
border-radius: 0;
}
.ns-editor__header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.28) 0%,
rgba(0, 0, 0, 0.14) 100%
);
border-bottom: 1px solid var(--ns-border);
border-radius: 14px 14px 0 0;
cursor: grab;
user-select: none;
overflow: hidden;
}
.ns-editor__header:active {
cursor: grabbing;
}
.ns-editor__dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex-shrink: 0;
background: var(--ns-brand);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--ns-brand) 28%, transparent);
}
.ns-editor__title {
flex: 1;
min-width: 0;
border: none;
background: transparent;
color: var(--ns-text);
font-family: inherit;
font-weight: 600;
font-size: 13.5px;
letter-spacing: 0.01em;
padding: 2px 0;
outline: none;
}
.ns-editor__title:focus {
box-shadow: inset 0 -1.5px 0 var(--ns-brand);
}
.ns-editor__body {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px 10px;
}
.ns-row {
display: grid;
grid-template-columns: 1fr;
gap: 4px;
align-items: start;
padding: 8px 6px;
border-radius: 8px;
}
.ns-row:hover {
background: rgba(255, 255, 255, 0.035);
}
.ns-row__label {
color: var(--ns-label);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
line-height: 1.2;
user-select: none;
}
.ns-row__ctrl {
min-width: 0;
display: flex;
gap: 6px;
align-items: flex-start;
}
.ns-row__ctrl input,
.ns-row__ctrl textarea,
.ns-row__ctrl select {
flex: 1;
width: 100%;
border: 1px solid transparent;
background: var(--ns-input);
color: var(--ns-text);
font-family: inherit;
font-size: 12.5px;
border-radius: 8px;
padding: 7px 10px;
outline: none;
resize: vertical;
transition: border-color 0.12s ease, background 0.12s ease;
color-scheme: dark;
}
.ns-row__ctrl select option {
color: #f4f5f7;
background: #1b1e28;
}
.ns-row__ctrl input:focus,
.ns-row__ctrl textarea:focus,
.ns-row__ctrl select:focus {
border-color: color-mix(in srgb, var(--ns-brand) 70%, transparent);
background: rgba(255, 255, 255, 0.055);
}
.ns-row__clear {
border: none;
background: transparent;
color: var(--ns-label);
padding: 6px 6px;
font-size: 11px;
line-height: 1;
opacity: 0.65;
border-radius: 6px;
flex-shrink: 0;
}
.ns-row__clear:hover {
opacity: 1;
color: var(--ns-text);
background: rgba(255, 255, 255, 0.06);
}
.ns-thumb {
width: 100%;
max-height: 140px;
height: auto;
object-fit: contain;
object-position: center;
border-radius: 8px;
background: rgba(0, 0, 0, 0.22);
display: block;
margin-bottom: 6px;
}
.ns-row__ctrl--image {
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.ns-image-actions {
display: flex;
gap: 6px;
align-items: center;
width: 100%;
}
.ns-image-pick {
flex-shrink: 0;
border: 1px solid var(--ns-border);
background: rgba(255, 255, 255, 0.06);
color: var(--ns-text);
border-radius: 8px;
padding: 7px 10px;
font-family: inherit;
font-size: 12px;
cursor: pointer;
}
.ns-image-pick:hover:not(:disabled) {
border-color: var(--ns-brand);
}
.ns-image-pick:disabled {
opacity: 0.6;
cursor: wait;
}
.ns-image-url {
flex: 1;
min-width: 0;
}
.ns-image-hint {
font-size: 10.5px;
color: var(--ns-label);
line-height: 1.3;
}
.ns-image-err {
font-size: 11px;
color: #f87171;
}
.ns-add {
margin: 2px 10px 10px;
position: relative;
z-index: 6;
}
.ns-add__btn {
width: 100%;
border: 1px dashed color-mix(in srgb, var(--ns-border) 80%, transparent);
background: transparent;
color: var(--ns-label);
border-radius: 10px;
padding: 8px 10px;
font-family: inherit;
font-size: 11.5px;
cursor: pointer;
transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
}
.ns-add__btn:hover {
border-color: var(--ns-brand);
color: var(--ns-text);
background: rgba(255, 255, 255, 0.03);
}
.ns-add__menu {
position: absolute;
left: 0;
right: 0;
/* Open downward so it isn't clipped by siblings / host */
top: calc(100% + 6px);
bottom: auto;
background: var(--ns-bg);
border: 1px solid var(--ns-border);
border-radius: 10px;
padding: 4px;
z-index: 20;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
max-height: 220px;
overflow: auto;
pointer-events: auto;
}
.ns-add__item {
display: block;
width: 100%;
text-align: left;
border: none;
background: transparent;
color: var(--ns-text);
font-family: inherit;
padding: 8px 10px;
border-radius: 8px;
font-size: 12px;
}
.ns-add__item:hover {
background: rgba(255, 255, 255, 0.07);
}
.ns-overlay {
position: absolute;
z-index: 8;
pointer-events: none;
transform-origin: center center;
}
.ns-editor {
pointer-events: none;
}
.ns-editor--panel {
pointer-events: auto;
}
.ns-editor__title,
.ns-editor__body,
.ns-add {
pointer-events: auto;
}

View file

@ -0,0 +1,283 @@
import type { LinkRef, MindNode, Op } from "@mind-map/shared";
import type { PropFieldDef, PropFieldId, VisiblePropRow } from "./types";
function hostnameOf(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return url.slice(0, 32);
}
}
function formatDate(ms?: number): string | null {
if (ms == null || !Number.isFinite(ms)) return null;
try {
return new Date(ms).toLocaleDateString();
} catch {
return null;
}
}
function parseDatetimeLocal(v: string): number | null {
if (!v.trim()) return null;
const t = Date.parse(v);
return Number.isFinite(t) ? t : null;
}
function parseLinks(text: string): LinkRef[] {
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const i = line.indexOf("|");
if (i >= 0) {
return {
title: line.slice(0, i).trim(),
url: line.slice(i + 1).trim(),
};
}
return { url: line };
})
.filter((l) => l.url);
}
function linksToText(links: LinkRef[] | undefined): string {
return (links ?? [])
.map((l) => (l.title ? `${l.title}|${l.url}` : l.url))
.join("\n");
}
export const PROP_FIELDS: PropFieldDef[] = [
{
id: "text",
label: "标题",
kind: "title",
always: true,
rowHeight: 22,
isEmpty: (n) => !n.text?.trim(),
preview: (n) => n.text?.trim() || "未命名",
commit: (nodeId, raw) => [
{ type: "update_text", nodeId, text: raw.trim() || "未命名" },
],
},
{
id: "note",
label: "摘要",
kind: "textarea",
rowHeight: 18,
isEmpty: (n) => !n.note?.trim(),
preview: (n) => {
const t = n.note?.trim();
if (!t) return null;
return t.length > 48 ? `${t.slice(0, 48)}…` : t;
},
commit: (nodeId, raw) => [
{
type: "update_node_meta",
nodeId,
patch: { note: raw.trim() || null },
},
],
},
{
id: "description",
label: "结构化描述 (JSON)",
kind: "textarea",
rowHeight: 36,
isEmpty: (n) => !n.description || Object.keys(n.description).length === 0,
preview: (n) => {
if (!n.description || !Object.keys(n.description).length) return null;
const t = JSON.stringify(n.description);
return t.length > 48 ? `${t.slice(0, 48)}…` : t;
},
commit: (nodeId, raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return [
{
type: "update_node_meta",
nodeId,
patch: { description: null },
},
];
}
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("description must be a JSON object");
}
return [
{
type: "update_node_meta",
nodeId,
patch: { description: parsed as Record<string, unknown> },
},
];
} catch {
return [];
}
},
},
{
id: "imageUrl",
label: "配图",
kind: "image",
imageRow: true,
rowHeight: 64,
isEmpty: (n) => !n.imageUrl?.trim(),
preview: (n) => n.imageUrl?.trim() || null,
commit: (nodeId, raw) => [
{
type: "update_node_meta",
nodeId,
patch: { imageUrl: raw.trim() || null },
},
],
},
{
id: "tags",
label: "标签",
kind: "tags",
rowHeight: 18,
isEmpty: (n) => !(n.tags?.length > 0),
preview: (n) =>
n.tags?.length ? n.tags.slice(0, 4).map((t) => `#${t}`).join(" ") : null,
commit: (nodeId, raw) => {
const tags = raw
.split(/[,,]/)
.map((s) => s.trim())
.filter(Boolean);
return [{ type: "update_node_meta", nodeId, patch: { tags } }];
},
},
{
id: "links",
label: "来源",
kind: "links",
rowHeight: 18,
isEmpty: (n) => !(n.links?.length > 0),
preview: (n) => {
const l = n.links?.[0];
if (!l) return null;
const head = l.title?.trim() || hostnameOf(l.url);
const more = (n.links?.length ?? 0) > 1 ? ` +${(n.links!.length - 1)}` : "";
return `${head}${more}`;
},
commit: (nodeId, raw) => [
{ type: "update_node_meta", nodeId, patch: { links: parseLinks(raw) } },
],
},
{
id: "startAt",
label: "开始",
kind: "datetime",
rowHeight: 18,
isEmpty: (n) => n.startAt == null,
preview: (n) => formatDate(n.startAt),
commit: (nodeId, raw) => [
{
type: "update_node_meta",
nodeId,
patch: { startAt: parseDatetimeLocal(raw) },
},
],
},
{
id: "dueAt",
label: "截止",
kind: "datetime",
rowHeight: 18,
isEmpty: (n) => n.dueAt == null,
preview: (n) => formatDate(n.dueAt),
commit: (nodeId, raw) => [
{
type: "update_node_meta",
nodeId,
patch: { dueAt: parseDatetimeLocal(raw) },
},
],
},
{
id: "pinned",
label: "固定",
kind: "flag",
rowHeight: 18,
isEmpty: (n) => !n.pinned,
preview: (n) => (n.pinned ? "已钉住" : null),
commit: (nodeId, raw) => {
const on = raw === "1" || raw === "true";
return [{ type: "set_pinned", nodeId, pinned: on }];
},
},
];
export const PROP_FIELD_BY_ID = Object.fromEntries(
PROP_FIELDS.map((f) => [f.id, f]),
) as Record<PropFieldId, PropFieldDef>;
/** Fields visible for display / edit given optional user-forced ids. */
export function visiblePropRows(
node: MindNode,
forced: ReadonlySet<string> = new Set(),
): VisiblePropRow[] {
const out: VisiblePropRow[] = [];
for (const def of PROP_FIELDS) {
if (def.always || !def.isEmpty(node) || forced.has(def.id)) {
out.push({ def, preview: def.preview(node) });
}
}
return out;
}
/** Fields that can be added (currently hidden). */
export function hiddenAddableFields(
node: MindNode,
forced: ReadonlySet<string> = new Set(),
): PropFieldDef[] {
return PROP_FIELDS.filter(
(f) => !f.always && f.isEmpty(node) && !forced.has(f.id),
);
}
export function fieldEditorSeed(node: MindNode, id: PropFieldId): string {
switch (id) {
case "text":
return node.text ?? "";
case "note":
return node.note ?? "";
case "description":
return node.description
? JSON.stringify(node.description, null, 2)
: "";
case "imageUrl":
return node.imageUrl ?? "";
case "tags":
return (node.tags ?? []).join(", ");
case "links":
return linksToText(node.links);
case "startAt":
return toDatetimeLocal(node.startAt);
case "dueAt":
return toDatetimeLocal(node.dueAt);
case "pinned":
return node.pinned ? "1" : "0";
default:
return "";
}
}
function toDatetimeLocal(ms?: number): string {
if (ms == null || !Number.isFinite(ms)) return "";
const d = new Date(ms);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function commitField(
node: MindNode,
id: PropFieldId,
raw: string,
): Op[] {
return PROP_FIELD_BY_ID[id].commit(node.id, raw, node);
}

View file

@ -0,0 +1,29 @@
import type { MindNode } from "@mind-map/shared";
import { visiblePropRows } from "./registry";
export const CARD_WIDTH = { comfortable: 288, compact: 248 } as const;
export const CARD_HEADER_H = 36;
export const CARD_PAD_Y = 12;
export const CARD_PAD_X = 12;
export const CARD_GAP = 10;
/** Layout / hit-test height for a card node. */
export function estimateCardSize(
node: MindNode,
dens: boolean,
forced: ReadonlySet<string> = new Set(),
): { w: number; h: number } {
const w = dens ? CARD_WIDTH.compact : CARD_WIDTH.comfortable;
const rows = visiblePropRows(node, forced);
let body = 0;
for (const { def } of rows) {
if (def.id === "text") continue;
// Stacked label+value needs a bit more than legacy side-by-side rows.
const rh =
def.imageRow ? (dens ? 52 : 64) + 4 : Math.max(def.rowHeight + 12, dens ? 28 : 32);
body += rh + CARD_GAP;
}
if (body > 0) body -= CARD_GAP;
const h = CARD_HEADER_H + CARD_PAD_Y * 2 + Math.max(body, 12);
return { w, h: Math.max(h, dens ? 84 : 100) };
}

View file

@ -0,0 +1,41 @@
import type { MindNode, Op } from "@mind-map/shared";
export type PropFieldId =
| "text"
| "note"
| "description"
| "imageUrl"
| "tags"
| "links"
| "startAt"
| "dueAt"
| "pinned";
export type PropWidgetKind =
| "title"
| "textarea"
| "image"
| "tags"
| "links"
| "datetime"
| "flag";
export type PropFieldDef = {
id: PropFieldId;
label: string;
kind: PropWidgetKind;
/** Always show even when empty (title). */
always?: boolean;
/** Extra height when this row is an image preview. */
imageRow?: boolean;
rowHeight: number;
isEmpty: (node: MindNode) => boolean;
preview: (node: MindNode) => string | null;
/** Build ops to commit a raw editor string/value. Empty clears. */
commit: (nodeId: string, raw: string, node: MindNode) => Op[];
};
export type VisiblePropRow = {
def: PropFieldDef;
preview: string | null;
};

View file

@ -0,0 +1,77 @@
/** Compress a local image file and upload to mind-map server. */
const MAX_EDGE = 1280;
const JPEG_QUALITY = 0.82;
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(reader.error || new Error("read failed"));
reader.readAsDataURL(file);
});
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("image decode failed"));
img.src = src;
});
}
/** Resize + JPEG encode in browser; returns data URL. */
export async function compressImageFile(file: File): Promise<{
dataUrl: string;
mimeType: string;
}> {
const rawUrl = await readFileAsDataUrl(file);
const img = await loadImage(rawUrl);
const scale = Math.min(1, MAX_EDGE / Math.max(img.width, img.height));
const w = Math.max(1, Math.round(img.width * scale));
const h = Math.max(1, Math.round(img.height * scale));
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas unavailable");
ctx.drawImage(img, 0, 0, w, h);
// Keep PNG only for graphics with alpha when small; otherwise JPEG.
const preferPng =
file.type === "image/png" && file.size < 400_000 && scale === 1;
const mimeType = preferPng ? "image/png" : "image/jpeg";
const dataUrl = canvas.toDataURL(
mimeType,
preferPng ? undefined : JPEG_QUALITY,
);
return { dataUrl, mimeType };
}
export async function uploadImageFile(file: File): Promise<string> {
if (!file.type.startsWith("image/")) {
throw new Error("请选择图片文件");
}
if (file.size > 20 * 1024 * 1024) {
throw new Error("原图过大(请小于 20MB)");
}
const { dataUrl, mimeType } = await compressImageFile(file);
const res = await fetch("/api/uploads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
dataBase64: dataUrl,
mimeType,
filename: file.name,
}),
});
const body = (await res.json().catch(() => ({}))) as {
ok?: boolean;
url?: string;
message?: string;
};
if (!res.ok || !body.ok || !body.url) {
throw new Error(body.message || `上传失败 (${res.status})`);
}
return body.url;
}

378
apps/web/src/store.ts Normal file
View file

@ -0,0 +1,378 @@
import type { CanvasMeta, MindEdge, MindNode, Op, StylePreset } from "@mind-map/shared";
import { create } from "zustand";
export type Positions = Record<string, { x: number; y: number }>;
type State = {
canvases: CanvasMeta[];
canvasId: string | null;
canvas: CanvasMeta | null;
nodes: MindNode[];
edges: MindEdge[];
positions: Positions;
selectedIds: string[];
selectedEdgeId: string | null;
/** Transient find/focus flash ids. */
highlightIds: string[];
highlightEdgeIds: string[];
highlightStyle: {
nodeColor?: string;
nodeGlow?: boolean;
edgeWidth?: number;
edgeColor?: string;
} | null;
/** One-shot camera fit from server auto_fit. */
fitViewport: { x: number; y: number; zoom: number } | null;
leftCollapsed: boolean;
themeId: string;
lastChangeSetId: string | null;
loadCanvases: () => Promise<void>;
openCanvas: (id: string) => Promise<void>;
createCanvas: (title?: string) => Promise<void>;
applyOps: (ops: Op[], summary?: string) => Promise<void>;
applyCanvasPayload: (
data: {
canvas: CanvasMeta;
nodes: MindNode[];
edges: MindEdge[];
positions: Positions;
},
opts?: { preserveUnpinned?: boolean },
) => void;
patchLocal: (patch: {
nodes?: MindNode[];
edges?: MindEdge[];
positions?: Positions;
canvas?: CanvasMeta;
}) => void;
requestFitViewport: (vp: { x: number; y: number; zoom: number }) => void;
consumeFitViewport: () => { x: number; y: number; zoom: number } | null;
undo: () => Promise<void>;
setSelection: (ids: string[]) => void;
setSelectedEdge: (id: string | null) => void;
flashHighlight: (
idsOrOpts:
| string[]
| {
nodeIds?: string[];
edgeIds?: string[];
style?: {
nodeColor?: string;
nodeGlow?: boolean;
edgeWidth?: number;
edgeColor?: string;
} | null;
ttlMs?: number;
},
) => void;
setThemeId: (id: string) => void;
setCollapsedPanels: (side: "left", collapsed: boolean) => void;
setPrefs: (patch: Partial<CanvasMeta["prefs"]>) => Promise<void>;
setStylePreset: (nodeId: string, stylePreset: StylePreset) => Promise<void>;
loadTrash: () => Promise<void>;
restoreFromTrash: (nodeId: string) => Promise<void>;
trash: MindNode[];
};
async function api<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
const data = (await res.json()) as T & { ok?: boolean; message?: string; error?: string };
if (!res.ok) throw new Error(data.message ?? data.error ?? res.statusText);
return data;
}
function softMergePositions(prev: Positions, next: Positions): Positions {
const out: Positions = { ...prev };
for (const [id, p] of Object.entries(next)) {
out[id] = { ...p };
}
for (const id of Object.keys(out)) {
if (!(id in next)) delete out[id];
}
return out;
}
/** After pin-only ops: keep non-pinned nodes where the client already had them. */
function mergePinStable(
before: Positions,
server: Positions,
pinnedIds: Set<string>,
): Positions {
const out: Positions = { ...server };
for (const [id, p] of Object.entries(before)) {
if (!pinnedIds.has(id) && out[id]) {
out[id] = { ...p };
}
}
for (const id of pinnedIds) {
if (server[id]) out[id] = { ...server[id]! };
else if (before[id]) out[id] = { ...before[id]! };
}
return out;
}
const pinStableFreeze = new Map<
string,
{ positions: Positions; pinnedIds: string[]; until: number }
>();
export const useAppStore = create<State>((set, get) => ({
canvases: [],
canvasId: null,
canvas: null,
nodes: [],
edges: [],
positions: {},
selectedIds: [],
selectedEdgeId: null,
highlightIds: [],
highlightEdgeIds: [],
highlightStyle: null,
fitViewport: null,
leftCollapsed: false,
themeId: localStorage.getItem("mind-map:theme") ?? "abyss",
lastChangeSetId: null,
trash: [],
loadCanvases: async () => {
const data = await api<{ canvases: CanvasMeta[] }>("/api/canvases");
set({ canvases: data.canvases });
if (!get().canvasId && data.canvases[0]) {
await get().openCanvas(data.canvases[0].id);
}
},
openCanvas: async (id) => {
const data = await api<{
canvas: CanvasMeta;
nodes: MindNode[];
edges: MindEdge[];
positions: Positions;
}>(`/api/canvases/${id}`);
set({
canvasId: id,
canvas: data.canvas,
nodes: data.nodes,
edges: data.edges,
positions: data.positions,
selectedIds: [],
selectedEdgeId: null,
themeId: data.canvas.prefs.themeId ?? get().themeId,
});
await get().loadTrash();
},
createCanvas: async (title) => {
const data = await api<{
canvas: CanvasMeta;
nodes: MindNode[];
edges: MindEdge[];
positions: Positions;
}>("/api/canvases", {
method: "POST",
body: JSON.stringify({ title }),
});
await get().loadCanvases();
set({
canvasId: data.canvas.id,
canvas: data.canvas,
nodes: data.nodes,
edges: data.edges,
positions: data.positions,
selectedIds: [],
});
},
patchLocal: (patch) => {
set((s) => ({
nodes: patch.nodes ?? s.nodes,
edges: patch.edges ?? s.edges,
positions: patch.positions ?? s.positions,
canvas: patch.canvas ?? s.canvas,
}));
},
requestFitViewport: (vp) => set({ fitViewport: vp }),
consumeFitViewport: () => {
const vp = get().fitViewport;
if (vp) set({ fitViewport: null });
return vp;
},
applyOps: async (ops, summary) => {
const id = get().canvasId;
if (!id) return;
const touchesTrash = ops.some(
(o) => o.type === "delete_node" || o.type === "restore_node",
);
const touchesRoster = ops.some(
(o) => o.type === "set_prefs" || o.type === "update_text",
);
const onlyPins =
ops.length > 0 && ops.every((o) => o.type === "set_pinned");
const pinnedIds = new Set(
ops
.filter(
(o): o is Extract<Op, { type: "set_pinned" }> =>
o.type === "set_pinned" && o.pinned === true,
)
.map((o) => o.nodeId),
);
// Snapshot after any optimistic patchLocal the caller already applied.
const beforePos = { ...get().positions };
const data = await api<{
canvas: CanvasMeta;
nodes: MindNode[];
edges: MindEdge[];
positions: Positions;
changeSetId: string;
fitViewport?: { x: number; y: number; zoom: number };
}>(`/api/canvases/${id}/ops`, {
method: "POST",
body: JSON.stringify({ ops, origin: "user", summary }),
});
const positions =
onlyPins && pinnedIds.size > 0
? mergePinStable(beforePos, data.positions, pinnedIds)
: softMergePositions(beforePos, data.positions);
set({
canvas: data.canvas,
nodes: data.nodes,
edges: data.edges,
positions,
lastChangeSetId: data.changeSetId,
themeId: data.canvas.prefs.themeId,
...(data.fitViewport ? { fitViewport: data.fitViewport } : {}),
});
// Remember pin-stable freeze so a trailing WS refresh doesn't undo single-node drag.
if (onlyPins && pinnedIds.size > 0) {
pinStableFreeze.set(id, {
positions: { ...positions },
pinnedIds: [...pinnedIds],
until: Date.now() + 2000,
});
}
if (touchesRoster) void get().loadCanvases();
if (touchesTrash) void get().loadTrash();
},
applyCanvasPayload: (
data: {
canvas: CanvasMeta;
nodes: MindNode[];
edges: MindEdge[];
positions: Positions;
},
opts?: { preserveUnpinned?: boolean },
) => {
const id = data.canvas.id;
const freeze = pinStableFreeze.get(id);
const before = get().positions;
let positions = data.positions;
if (opts?.preserveUnpinned || (freeze && freeze.until > Date.now())) {
const pinnedIds = new Set(
freeze?.pinnedIds ??
data.nodes.filter((n) => n.pinned && n.pos).map((n) => n.id),
);
positions = mergePinStable(
freeze?.positions ?? before,
data.positions,
pinnedIds,
);
}
set({
canvasId: id,
canvas: data.canvas,
nodes: data.nodes,
edges: data.edges,
positions,
themeId: data.canvas.prefs.themeId ?? get().themeId,
});
},
undo: async () => {
const id = get().canvasId;
const cs = get().lastChangeSetId;
if (!id || !cs) return;
const data = await api<{
snapshot: { canvas: CanvasMeta; nodes: MindNode[]; edges: MindEdge[] };
changeSetId: string;
}>(`/api/canvases/${id}/undo`, {
method: "POST",
body: JSON.stringify({ changeSetId: cs }),
});
await get().openCanvas(id);
set({ lastChangeSetId: data.changeSetId });
},
setSelection: (ids) => set({ selectedIds: ids, selectedEdgeId: null }),
setSelectedEdge: (id) => set({ selectedEdgeId: id, selectedIds: id ? [] : get().selectedIds }),
flashHighlight: (idsOrOpts) => {
const opts = Array.isArray(idsOrOpts)
? { nodeIds: idsOrOpts }
: idsOrOpts;
const nodeIds = opts.nodeIds ?? [];
const edgeIds = opts.edgeIds ?? [];
const style = opts.style ?? null;
const ttlMs = Math.max(500, Math.min(60000, opts.ttlMs ?? 2200));
set({
highlightIds: nodeIds,
highlightEdgeIds: edgeIds,
highlightStyle: style,
selectedIds: nodeIds.length ? nodeIds : get().selectedIds,
selectedEdgeId: edgeIds.length === 1 ? edgeIds[0]! : get().selectedEdgeId,
});
window.setTimeout(() => {
const cur = useAppStore.getState();
if (
cur.highlightIds.length === nodeIds.length &&
cur.highlightIds.every((x, i) => x === nodeIds[i]) &&
cur.highlightEdgeIds.length === edgeIds.length &&
cur.highlightEdgeIds.every((x, i) => x === edgeIds[i])
) {
set({
highlightIds: [],
highlightEdgeIds: [],
highlightStyle: null,
});
}
}, ttlMs);
},
setThemeId: (id) => {
localStorage.setItem("mind-map:theme", id);
set({ themeId: id });
},
setCollapsedPanels: (side, collapsed) => {
if (side === "left") set({ leftCollapsed: collapsed });
},
setPrefs: async (patch) => {
await get().applyOps([{ type: "set_prefs", prefs: patch }], "update prefs");
},
setStylePreset: async (nodeId, stylePreset) => {
await get().applyOps([{ type: "set_style_preset", nodeId, stylePreset }], "style");
},
loadTrash: async () => {
const id = get().canvasId;
if (!id) return;
const data = await api<{ nodes: MindNode[] }>(`/api/canvases/${id}/trash`);
set({ trash: data.nodes });
},
restoreFromTrash: async (nodeId) => {
const id = get().canvasId;
if (!id) return;
await api(`/api/canvases/${id}/trash/${nodeId}/restore`, { method: "POST" });
await get().openCanvas(id);
await get().loadTrash();
},
}));

560
apps/web/src/styles.css Normal file
View file

@ -0,0 +1,560 @@
:root {
color: var(--dsw-alias-label-primary, #f4f5f7);
background: var(--dsw-alias-bg-base, #101014);
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 13px;
line-height: 1.45;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
height: 100%;
overflow: hidden;
}
button,
input {
font: inherit;
color: inherit;
}
button {
cursor: pointer;
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
background: var(--dsw-specific-selector, rgba(255, 255, 255, 0.08));
border-radius: 8px;
padding: 6px 10px;
}
button:hover {
background: var(--dsw-alias-interactive-bg-hover, rgba(94, 106, 210, 0.16));
}
button.primary {
background: var(--dsw-alias-brand-primary, #5e6ad2);
color: var(--dsw-alias-brand-text, #fff);
border-color: transparent;
}
input {
background: var(--dsw-specific-input-major, rgba(255, 255, 255, 0.08));
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
border-radius: 8px;
padding: 6px 8px;
outline: none;
}
.app-shell {
display: grid;
grid-template-rows: 48px 1fr;
height: 100%;
}
.app-shell.embed {
grid-template-rows: 1fr;
}
.embed-main {
display: grid !important;
grid-template-columns: 1fr !important;
grid-template-rows: 1fr;
min-height: 0;
height: 100%;
}
.topbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
background: var(--dsw-alias-bg-layer-1, #1b1e28);
}
.topbar .brand {
font-weight: 650;
letter-spacing: 0.02em;
margin-right: 8px;
}
.topbar .spacer {
flex: 1;
}
.main {
display: grid;
grid-template-columns: var(--left, 240px) 1fr;
min-height: 0;
}
/* Aside is display:none when collapsed — only canvas remains as grid child,
so switch to a single column or it sits in the 0-width track and vanishes. */
.main.left-collapsed {
grid-template-columns: 1fr;
}
.panel {
min-width: 0;
min-height: 0;
border-right: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
background: var(--dsw-specific-sidebar-fill, rgba(16, 16, 20, 0.92));
display: flex;
flex-direction: column;
}
.panel-header {
padding: 10px 12px;
font-weight: 600;
color: var(--dsw-alias-label-secondary, #a5adb8);
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
}
.panel-body {
flex: 1;
overflow: auto;
padding: 8px;
}
.canvas-host {
position: relative;
min-width: 0;
min-height: 0;
background: var(--dsw-alias-bg-base, #101014);
}
.canvas-host canvas {
width: 100%;
height: 100%;
display: block;
touch-action: none;
}
.canvas-list-item,
.outline-item {
padding: 8px 10px;
border-radius: 8px;
margin-bottom: 4px;
cursor: pointer;
color: var(--dsw-alias-label-primary, #f4f5f7);
}
.canvas-list-item:hover,
.outline-item:hover {
background: var(--dsw-specific-sidebar-nav-item-hover, rgba(255, 255, 255, 0.05));
}
.canvas-list-item.active,
.outline-item.active {
background: var(--dsw-specific-sidebar-nav-item-active, rgba(255, 255, 255, 0.09));
}
.muted {
color: var(--dsw-alias-label-tertiary, #7b838f);
}
.skin-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 6px;
}
.skin-swatch {
height: 28px;
border-radius: 6px;
border: 2px solid transparent;
cursor: pointer;
}
.skin-swatch.active {
border-color: var(--dsw-alias-brand-primary, #5e6ad2);
}
.proposal-box {
padding: 8px 10px;
border-top: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.08));
max-height: 180px;
overflow: auto;
}
.proposal-item {
padding: 8px;
border-radius: 8px;
margin-bottom: 6px;
background: var(--dsw-specific-selector, rgba(255, 255, 255, 0.06));
}
.canvas-hint {
position: absolute;
left: 56px;
bottom: 10px;
font-size: 12px;
pointer-events: none;
opacity: 0.75;
max-width: calc(100% - 240px);
z-index: 1;
}
.canvas-controls {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 4;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 8px;
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
background: color-mix(
in srgb,
var(--dsw-alias-bg-layer1, #1a1d27) 92%,
transparent
);
backdrop-filter: blur(8px);
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.28);
}
.canvas-controls__btn {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
border: 0;
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.1));
border-radius: 0;
background: transparent;
color: var(--dsw-alias-label-secondary, #a5adb8);
cursor: pointer;
}
.canvas-controls__btn:last-child {
border-bottom: 0;
}
.canvas-controls__btn:hover {
background: var(--dsw-specific-sidebar-nav-item-hover, rgba(255, 255, 255, 0.06));
color: var(--dsw-alias-label-primary, #f4f5f7);
}
.canvas-controls__btn.is-active {
color: var(--dsw-alias-state-warning, #d4a017);
background: rgba(212, 160, 23, 0.12);
}
.canvas-controls__btn svg {
display: block;
}
.canvas-toolbar {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 10px;
background: color-mix(
in srgb,
var(--dsw-alias-bg-layer1, #1a1d27) 92%,
transparent
);
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.1));
backdrop-filter: blur(8px);
z-index: 4;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.22);
max-width: calc(100% - 24px);
}
.canvas-toolbar__group {
display: flex;
align-items: center;
gap: 2px;
}
.canvas-toolbar__sep {
width: 1px;
height: 18px;
background: var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
margin: 0 2px;
}
.canvas-toolbar__zoom {
font-size: 11px;
color: var(--dsw-alias-label-tertiary, #7b838f);
min-width: 36px;
text-align: right;
padding-left: 4px;
}
.canvas-tool-btn {
appearance: none;
border: none;
background: transparent;
color: var(--dsw-alias-label-secondary, #a5adb8);
font-size: 12px;
line-height: 1;
padding: 6px 9px;
border-radius: 7px;
cursor: pointer;
white-space: nowrap;
}
.canvas-tool-btn:disabled {
opacity: 0.35;
cursor: default;
}
.canvas-tool-btn:hover {
background: var(--dsw-specific-sidebar-nav-item-hover, rgba(255, 255, 255, 0.06));
color: var(--dsw-alias-label-primary, #f4f5f7);
}
.canvas-tool-btn.is-active {
background: var(--dsw-specific-sidebar-nav-item-active, rgba(255, 255, 255, 0.1));
color: var(--dsw-alias-brand-primary, #5e6ad2);
}
.canvas-style-menu {
position: relative;
}
.canvas-style-menu__trigger:disabled {
opacity: 0.45;
}
.canvas-style-menu__panel {
position: absolute;
left: 0;
top: calc(100% + 6px);
z-index: 40;
min-width: 112px;
padding: 4px;
border-radius: 8px;
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
background: var(--dsw-alias-bg-layer-1, #1b1e28);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
display: flex;
flex-direction: column;
gap: 2px;
}
.canvas-style-menu__item {
appearance: none;
border: none;
background: transparent;
color: var(--dsw-alias-label-secondary, #a5adb8);
font-size: 12px;
text-align: left;
padding: 7px 10px;
border-radius: 6px;
cursor: pointer;
}
.canvas-style-menu__item:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--dsw-alias-label-primary, #f4f5f7);
}
.canvas-style-menu__item.is-active {
background: var(--dsw-specific-sidebar-nav-item-active, rgba(255, 255, 255, 0.1));
color: var(--dsw-alias-brand-primary, #5e6ad2);
}
.canvas-host.is-locked .canvas-tool-btn.is-active {
color: var(--dsw-alias-state-warning, #d4a017);
}
.canvas-minimap {
position: absolute;
right: 12px;
bottom: 12px;
width: 168px;
height: 118px;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.14));
background: color-mix(
in srgb,
var(--dsw-alias-bg-layer1, #1a1d27) 88%,
transparent
);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.28);
cursor: crosshair;
z-index: 3;
}
.canvas-minimap canvas {
display: block;
width: 100%;
height: 100%;
}
.trash-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
border-radius: 8px;
margin-bottom: 4px;
background: var(--dsw-specific-selector, rgba(255, 255, 255, 0.06));
}
.trash-item button {
padding: 4px 8px;
font-size: 12px;
}
.node-handles {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 4;
}
.node-handle {
position: absolute;
pointer-events: auto;
width: 20px;
height: 20px;
border-radius: 999px;
border: 1px solid var(--dsw-alias-brand-primary, #5e6ad2);
background: var(--dsw-alias-bg-layer-1, #1b1e28);
color: var(--dsw-alias-brand-primary, #5e6ad2);
font-size: 14px;
line-height: 1;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}
.node-handle:hover {
background: var(--dsw-alias-brand-primary, #5e6ad2);
color: var(--dsw-alias-brand-text, #fff);
}
.hover-card {
position: absolute;
z-index: 5;
max-width: 280px;
min-width: 180px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
background: color-mix(
in srgb,
var(--dsw-alias-bg-layer-1, #1b1e28) 92%,
transparent
);
backdrop-filter: blur(10px);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.05) inset,
0 12px 36px rgba(0, 0, 0, 0.42);
pointer-events: none;
font-family: "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Noto Sans SC", system-ui, sans-serif;
}
.hover-card__title {
font-weight: 600;
font-size: 13.5px;
letter-spacing: 0.01em;
line-height: 1.35;
margin-bottom: 6px;
color: var(--dsw-alias-label-primary, #f4f5f7);
}
.hover-card__note {
font-size: 12px;
line-height: 1.45;
color: var(--dsw-alias-label-secondary, #a5adb8);
margin-bottom: 8px;
}
.hover-card__tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 6px;
}
.hover-card__tag {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.06);
color: var(--dsw-alias-label-secondary, #a5adb8);
}
.hover-card__meta {
font-size: 11px;
color: var(--dsw-alias-label-tertiary, #7b838f);
line-height: 1.35;
}
.edit-overlay {
position: absolute;
z-index: 6;
min-width: 140px;
padding: 6px 10px;
border-radius: 10px;
border: 1px solid var(--dsw-alias-brand-primary, #5e6ad2);
background: var(--dsw-alias-bg-layer-1, #1b1e28);
color: var(--dsw-alias-label-primary, #f4f5f7);
font: 600 13px "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui,
sans-serif;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
outline: none;
}
.inspector {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 0 12px;
}
.inspector__title {
font-weight: 600;
font-size: 13px;
}
.inspector__field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--dsw-alias-label-secondary, #a5adb8);
}
.inspector__field input,
.inspector__field textarea,
.inspector__field select {
width: 100%;
color: var(--dsw-alias-label-primary, #f4f5f7);
}
.inspector__field textarea {
resize: vertical;
min-height: 56px;
}
.inspector__row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}

View file

@ -0,0 +1,137 @@
/** Read DSH / host CSS tokens (with dark fallbacks). */
export type SystemTheme = {
bgBase: string;
bgLayer1: string;
labelPrimary: string;
labelSecondary: string;
brand: string;
border: string;
bubble: string;
warn: string;
error: string;
};
const FALLBACK: SystemTheme = {
bgBase: "#101014",
bgLayer1: "#1b1e28",
labelPrimary: "#f4f5f7",
labelSecondary: "#a5adb8",
brand: "#5e6ad2",
border: "rgba(255,255,255,0.08)",
bubble: "rgba(37,42,58,0.9)",
warn: "#f59e5b",
error: "#ef4444",
};
const VAR_MAP: { key: keyof SystemTheme; css: string }[] = [
{ key: "bgBase", css: "--dsw-alias-bg-base" },
{ key: "bgLayer1", css: "--dsw-alias-bg-layer-1" },
{ key: "labelPrimary", css: "--dsw-alias-label-primary" },
{ key: "labelSecondary", css: "--dsw-alias-label-secondary" },
{ key: "brand", css: "--dsw-alias-brand-primary" },
{ key: "border", css: "--dsw-alias-border-l1" },
{ key: "bubble", css: "--dsw-specific-bubble" },
{ key: "warn", css: "--dsw-alias-state-warn-primary" },
{ key: "error", css: "--dsw-alias-state-error-primary" },
];
function cssVar(name: string): string {
try {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
} catch {
return "";
}
}
/** Apply theme tokens from query string (host → iframe). */
export function applyThemeFromQuery(search = window.location.search): void {
const q = new URLSearchParams(search);
const root = document.documentElement;
for (const { key, css } of VAR_MAP) {
const v = q.get(`t_${key}`);
if (v) root.style.setProperty(css, decodeURIComponent(v));
}
const scheme = q.get("colorScheme");
if (scheme === "light" || scheme === "dark") {
root.dataset.colorScheme = scheme;
}
}
export function readSystemTheme(): SystemTheme {
const out = { ...FALLBACK };
for (const { key, css } of VAR_MAP) {
const v = cssVar(css);
if (v) out[key] = v;
}
return out;
}
/** Snapshot DSH page tokens for embedding into iframe URL. */
export function snapshotHostTheme(): Record<string, string> {
const out: Record<string, string> = {};
for (const { key, css } of VAR_MAP) {
const v = cssVar(css);
if (v) out[`t_${key}`] = v;
}
try {
const scheme =
document.documentElement.dataset.colorScheme ||
(matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
out.colorScheme = scheme;
} catch {
out.colorScheme = "dark";
}
return out;
}
/** Simple branch hues from brand (same idea as dream-skin.branchPalette). */
export function branchPalette(brand: string, count = 8): string[] {
const hex = brand.startsWith("#") ? brand : FALLBACK.brand;
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
const n = m ? parseInt(m[1]!, 16) : 0x5e6ad2;
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
const max = Math.max(r, g, b) / 255;
const min = Math.min(r, g, b) / 255;
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
const rr = r / 255;
const gg = g / 255;
const bb = b / 255;
switch (max) {
case rr:
h = ((gg - bb) / d + (gg < bb ? 6 : 0)) / 6;
break;
case gg:
h = ((bb - rr) / d + 2) / 6;
break;
default:
h = ((rr - gg) / d + 4) / 6;
}
}
const out: string[] = [];
for (let i = 0; i < count; i++) {
const nh = (h * 360 + i * (360 / count)) % 360;
const ns = Math.min(0.55, s * 0.85);
const nl = Math.min(0.62, Math.max(0.42, l));
out.push(hslToHex(nh, ns, nl));
}
return out;
}
function hslToHex(h: number, s: number, l: number): string {
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
const c = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * c)
.toString(16)
.padStart(2, "0");
};
return `#${f(0)}${f(8)}${f(4)}`;
}

11
apps/web/tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*"]
}

14
apps/web/vite.config.ts Normal file
View file

@ -0,0 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://127.0.0.1:17890",
"/health": "http://127.0.0.1:17890",
"/ws": { target: "ws://127.0.0.1:17890", ws: true },
},
},
});

30
docs/dsh-bridge.md Normal file
View file

@ -0,0 +1,30 @@
# DSH ↔ Mind Map(大脑在 DSH,UI 自建)
## 一句话
Mind Map 是 **DSH 插件**:画布 Web 自己开端口;**对话就是 DSH 会话**。Web 右栏只做镜像显示 + 输入转发,**不保存**聊天记录。
## 分工
| 职责 | 谁管 |
|---|---|
| 模型 / Agent 循环 / 会话历史 | **DSH Session** |
| 图数据 / 布局 / 提案 | **mind-map GraphStore** |
| 画布 UI | 本机 Web(`127.0.0.1:17890`) |
| 对话 UI | 本机 Web **镜像** DSH 事件 |
## 数据流
1. 用户在 Web 输入 → 插件转发为该绑定会话的用户消息(= 在 DSH 里打字)
2. DSH 流式回复 → 插件推到 Web 右栏显示
3. Agent 调 `mindmap_*` → 改 SQLite 图 → WS 推画布
4. 用户也可以直接在 DSH 原生聊天对同一会话说话,效果相同
## 设置卡
端口、启停、打开画布、绑定会话。模型用 DSH 会话自己的选择器,不另配 Key。
## 明确不做
- 不在 mind-map 内自建 LLM runtime / 自存聊天记录
- 不把画布嵌进 DSH Web GUI

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "mind-map",
"private": true,
"version": "0.1.0",
"packageManager": "pnpm@11.7.0",
"scripts": {
"dev": "pnpm --parallel --filter @mind-map/server --filter @mind-map/web dev",
"dev:server": "pnpm --filter @mind-map/server dev",
"dev:web": "pnpm --filter @mind-map/web dev",
"build": "pnpm -r run build",
"test": "pnpm -r run test",
"start": "pnpm --filter @mind-map/server start",
"typecheck": "pnpm -r run typecheck",
"stress": "tsx scripts/stress-10k.mts"
},
"devDependencies": {
"tsx": "^4.19.3"
},
"engines": {
"node": ">=22"
}
}

View file

@ -0,0 +1,22 @@
{
"name": "@mind-map/dream-skin",
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run --passWithNoTests"
},
"devDependencies": {
"typescript": "^5.8.2",
"vitest": "^3.0.9"
}
}

View file

@ -0,0 +1,133 @@
import skinsJson from "./skins.json" with { type: "json" };
export type DreamSkin = {
id: string;
colorScheme: "light" | "dark";
name: string;
nameZh: string;
tokens: Record<string, string>;
};
export const SKINS = skinsJson as DreamSkin[];
export const DEFAULT_SKIN_ID = "abyss";
export function getSkin(id: string): DreamSkin {
return SKINS.find((s) => s.id === id) ?? SKINS[0]!;
}
export function listSkins(): DreamSkin[] {
return SKINS;
}
/** Apply CSS variables onto an element (usually document.documentElement). */
export function applySkinToElement(
el: {
style: { setProperty(k: string, v: string): void };
dataset: { theme?: string; colorScheme?: string };
},
skinId: string,
): DreamSkin {
const skin = getSkin(skinId);
el.dataset.theme = skin.id;
el.dataset.colorScheme = skin.colorScheme;
for (const [key, value] of Object.entries(skin.tokens)) {
el.style.setProperty(key, value);
}
return skin;
}
export type ThemeSnapshot = {
skinId: string;
colorScheme: "light" | "dark";
bgBase: string;
bgLayer1: string;
labelPrimary: string;
labelSecondary: string;
brand: string;
border: string;
bubble: string;
warn: string;
error: string;
};
export function toThemeSnapshot(skinId: string): ThemeSnapshot {
const skin = getSkin(skinId);
const t = skin.tokens;
return {
skinId: skin.id,
colorScheme: skin.colorScheme,
bgBase: t["--dsw-alias-bg-base"] ?? "#101014",
bgLayer1: t["--dsw-alias-bg-layer-1"] ?? "#1b1e28",
labelPrimary: t["--dsw-alias-label-primary"] ?? "#f4f5f7",
labelSecondary: t["--dsw-alias-label-secondary"] ?? "#a5adb8",
brand: t["--dsw-alias-brand-primary"] ?? "#5e6ad2",
border: t["--dsw-alias-border-l1"] ?? "rgba(255,255,255,0.08)",
bubble: t["--dsw-specific-bubble"] ?? "rgba(37,42,58,0.9)",
warn: t["--dsw-alias-state-warn-primary"] ?? "#f59e5b",
error: t["--dsw-alias-state-error-primary"] ?? "#ef4444",
};
}
/** Generate restrained branch hues from brand color. */
export function branchPalette(brandHex: string, count = 8): string[] {
const rgb = hexToRgb(brandHex) ?? { r: 94, g: 106, b: 210 };
const { h, s, l } = rgbToHsl(rgb.r, rgb.g, rgb.b);
const out: string[] = [];
for (let i = 0; i < count; i++) {
const nh = (h + i * (360 / count)) % 360;
out.push(hslToHex(nh, Math.min(0.55, s * 0.85), Math.min(0.62, Math.max(0.42, l))));
}
return out;
}
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return null;
const n = parseInt(m[1]!, 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
function rgbToHsl(r: number, g: number, b: number) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
const l = (max + min) / 2;
const d = max - min;
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
if (d !== 0) {
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
break;
case g:
h = ((b - r) / d + 2) * 60;
break;
default:
h = ((r - g) / d + 4) * 60;
}
}
return { h, s, l };
}
function hslToHex(h: number, s: number, l: number): string {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let r = 0,
g = 0,
b = 0;
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
const to = (v: number) =>
Math.round((v + m) * 255)
.toString(16)
.padStart(2, "0");
return `#${to(r)}${to(g)}${to(b)}`;
}

View file

@ -0,0 +1,330 @@
[
{
"id": "abyss",
"colorScheme": "dark",
"name": "Abyss",
"nameZh": "深渊",
"tokens": {
"--dsw-alias-bg-base": "#101014",
"--dsw-alias-bg-layer-1": "#1b1e28",
"--dsw-alias-bg-layer-2": "rgba(26, 30, 42, 0.85)",
"--dsw-alias-bg-layer-3": "#1a1d27",
"--dsw-alias-bg-module-platform": "#151821",
"--dsw-alias-bg-overlay": "rgba(24, 24, 30, 0.86)",
"--dsw-specific-input-major": "rgba(255, 255, 255, 0.08)",
"--dsw-specific-tip": "rgba(30, 33, 46, 0.9)",
"--dsw-alias-border-l1": "rgba(255, 255, 255, 0.07)",
"--dsw-alias-border-l2": "rgba(255, 255, 255, 0.13)",
"--dsw-alias-label-primary": "#f4f5f7",
"--dsw-alias-label-secondary": "#a5adb8",
"--dsw-alias-label-tertiary": "#7b838f",
"--dsw-alias-brand-primary": "#5e6ad2",
"--dsw-specific-bubble": "rgba(37, 42, 58, 0.9)",
"--dsw-specific-bubble-highlight": "rgba(94, 106, 210, 0.16)",
"--dsw-specific-selector": "rgba(255, 255, 255, 0.10)",
"--dsw-alias-brand-text": "#ffffff",
"--dsw-alias-button-primary-hover": "#6f7be0",
"--dsw-alias-button-primary-dimmed": "rgba(94, 106, 210, 0.16)",
"--dsw-alias-state-business-primary": "#5e6ad2",
"--dsw-alias-state-business-tertiary": "rgba(94, 106, 210, 0.16)",
"--dsw-alias-interactive-bg-hover": "rgba(94, 106, 210, 0.16)",
"--dsw-alias-interactive-bg-active": "rgba(94, 106, 210, 0.26)",
"--dsw-alias-markdown-code-block": "rgba(0, 0, 0, 0.35)",
"--dsw-alias-markdown-inline-code": "rgba(255, 255, 255, 0.09)",
"--dsw-specific-sidebar-fill": "rgba(16, 16, 20, 0.92)",
"--dsw-specific-sidebar-nav-item-active": "rgba(255, 255, 255, 0.09)",
"--dsw-specific-sidebar-nav-item-hover": "rgba(255, 255, 255, 0.05)",
"--dsw-alias-scrollbar-bg-l1": "rgba(255, 255, 255, 0.09)",
"--dsw-alias-scrollbar-bg-l2": "rgba(255, 255, 255, 0.14)",
"--dsw-alias-scrollbar-hover-l1": "rgba(255, 255, 255, 0.2)",
"--dsw-alias-scrollbar-hover-l2": "rgba(255, 255, 255, 0.2)"
}
},
{
"id": "aurora",
"colorScheme": "dark",
"name": "Aurora",
"nameZh": "极光",
"tokens": {
"--dsw-alias-bg-base": "#0e1316",
"--dsw-alias-bg-layer-1": "#162128",
"--dsw-alias-bg-layer-2": "rgba(22, 32, 34, 0.85)",
"--dsw-alias-bg-layer-3": "#182128",
"--dsw-alias-bg-module-platform": "#131c20",
"--dsw-alias-bg-overlay": "rgba(18, 24, 27, 0.86)",
"--dsw-specific-input-major": "rgba(255, 255, 255, 0.08)",
"--dsw-specific-tip": "rgba(24, 35, 36, 0.9)",
"--dsw-alias-border-l1": "rgba(110, 231, 183, 0.10)",
"--dsw-alias-border-l2": "rgba(110, 231, 183, 0.18)",
"--dsw-alias-label-primary": "#eefaf4",
"--dsw-alias-label-secondary": "#9fc9b8",
"--dsw-alias-label-tertiary": "#74a494",
"--dsw-alias-brand-primary": "#2dd4bf",
"--dsw-specific-bubble": "rgba(30, 42, 44, 0.9)",
"--dsw-specific-bubble-highlight": "rgba(45, 212, 191, 0.14)",
"--dsw-specific-selector": "rgba(255, 255, 255, 0.10)",
"--dsw-alias-brand-text": "#03211b",
"--dsw-alias-button-primary-hover": "#45e0cd",
"--dsw-alias-button-primary-dimmed": "rgba(45, 212, 191, 0.14)",
"--dsw-alias-state-business-primary": "#2dd4bf",
"--dsw-alias-state-business-tertiary": "rgba(45, 212, 191, 0.14)",
"--dsw-alias-interactive-bg-hover": "rgba(45, 212, 191, 0.14)",
"--dsw-alias-interactive-bg-active": "rgba(45, 212, 191, 0.22)",
"--dsw-alias-markdown-code-block": "rgba(0, 0, 0, 0.32)",
"--dsw-alias-markdown-inline-code": "rgba(255, 255, 255, 0.085)",
"--dsw-specific-sidebar-fill": "rgba(14, 19, 22, 0.92)",
"--dsw-specific-sidebar-nav-item-active": "rgba(255, 255, 255, 0.085)",
"--dsw-specific-sidebar-nav-item-hover": "rgba(255, 255, 255, 0.045)",
"--dsw-alias-scrollbar-bg-l1": "rgba(255, 255, 255, 0.085)",
"--dsw-alias-scrollbar-bg-l2": "rgba(255, 255, 255, 0.13)",
"--dsw-alias-scrollbar-hover-l1": "rgba(255, 255, 255, 0.2)",
"--dsw-alias-scrollbar-hover-l2": "rgba(255, 255, 255, 0.2)"
}
},
{
"id": "nebula",
"colorScheme": "dark",
"name": "Nebula",
"nameZh": "星云",
"tokens": {
"--dsw-alias-bg-base": "#12101a",
"--dsw-alias-bg-layer-1": "#1c1a2a",
"--dsw-alias-bg-layer-2": "rgba(30, 27, 44, 0.85)",
"--dsw-alias-bg-layer-3": "#1c1a28",
"--dsw-alias-bg-module-platform": "#171523",
"--dsw-alias-bg-overlay": "rgba(22, 20, 30, 0.86)",
"--dsw-specific-input-major": "rgba(255, 255, 255, 0.08)",
"--dsw-specific-tip": "rgba(32, 28, 46, 0.9)",
"--dsw-alias-border-l1": "rgba(196, 181, 253, 0.10)",
"--dsw-alias-border-l2": "rgba(196, 181, 253, 0.18)",
"--dsw-alias-label-primary": "#f3f0fb",
"--dsw-alias-label-secondary": "#b6a8d9",
"--dsw-alias-label-tertiary": "#8a7cb0",
"--dsw-alias-brand-primary": "#8b7cf6",
"--dsw-specific-bubble": "rgba(40, 36, 56, 0.9)",
"--dsw-specific-bubble-highlight": "rgba(139, 124, 246, 0.14)",
"--dsw-specific-selector": "rgba(255, 255, 255, 0.10)",
"--dsw-alias-brand-text": "#0d0a1c",
"--dsw-alias-button-primary-hover": "#9d90f8",
"--dsw-alias-button-primary-dimmed": "rgba(139, 124, 246, 0.14)",
"--dsw-alias-state-business-primary": "#8b7cf6",
"--dsw-alias-state-business-tertiary": "rgba(139, 124, 246, 0.14)",
"--dsw-alias-interactive-bg-hover": "rgba(139, 124, 246, 0.14)",
"--dsw-alias-interactive-bg-active": "rgba(139, 124, 246, 0.22)",
"--dsw-alias-markdown-code-block": "rgba(0, 0, 0, 0.32)",
"--dsw-alias-markdown-inline-code": "rgba(255, 255, 255, 0.085)",
"--dsw-specific-sidebar-fill": "rgba(18, 16, 26, 0.92)",
"--dsw-specific-sidebar-nav-item-active": "rgba(255, 255, 255, 0.085)",
"--dsw-specific-sidebar-nav-item-hover": "rgba(255, 255, 255, 0.045)",
"--dsw-alias-scrollbar-bg-l1": "rgba(255, 255, 255, 0.085)",
"--dsw-alias-scrollbar-bg-l2": "rgba(255, 255, 255, 0.13)",
"--dsw-alias-scrollbar-hover-l1": "rgba(255, 255, 255, 0.2)",
"--dsw-alias-scrollbar-hover-l2": "rgba(255, 255, 255, 0.2)"
}
},
{
"id": "ember",
"colorScheme": "dark",
"name": "Ember",
"nameZh": "余烬",
"tokens": {
"--dsw-alias-bg-base": "#16110d",
"--dsw-alias-bg-layer-1": "#211a15",
"--dsw-alias-bg-layer-2": "rgba(36, 28, 20, 0.85)",
"--dsw-alias-bg-layer-3": "#231b15",
"--dsw-alias-bg-module-platform": "#1b1712",
"--dsw-alias-bg-overlay": "rgba(28, 20, 15, 0.86)",
"--dsw-specific-input-major": "rgba(255, 255, 255, 0.08)",
"--dsw-specific-tip": "rgba(38, 28, 20, 0.9)",
"--dsw-alias-border-l1": "rgba(253, 186, 116, 0.10)",
"--dsw-alias-border-l2": "rgba(253, 186, 116, 0.18)",
"--dsw-alias-label-primary": "#fdf0e6",
"--dsw-alias-label-secondary": "#d0a98a",
"--dsw-alias-label-tertiary": "#a48266",
"--dsw-alias-brand-primary": "#f59e5b",
"--dsw-specific-bubble": "rgba(48, 38, 28, 0.9)",
"--dsw-specific-bubble-highlight": "rgba(245, 158, 91, 0.14)",
"--dsw-specific-selector": "rgba(255, 255, 255, 0.10)",
"--dsw-alias-brand-text": "#1f0f06",
"--dsw-alias-button-primary-hover": "#f8b06f",
"--dsw-alias-button-primary-dimmed": "rgba(245, 158, 91, 0.14)",
"--dsw-alias-state-business-primary": "#f59e5b",
"--dsw-alias-state-business-tertiary": "rgba(245, 158, 91, 0.14)",
"--dsw-alias-interactive-bg-hover": "rgba(245, 158, 91, 0.14)",
"--dsw-alias-interactive-bg-active": "rgba(245, 158, 91, 0.22)",
"--dsw-alias-markdown-code-block": "rgba(0, 0, 0, 0.32)",
"--dsw-alias-markdown-inline-code": "rgba(255, 255, 255, 0.085)",
"--dsw-specific-sidebar-fill": "rgba(22, 17, 13, 0.92)",
"--dsw-specific-sidebar-nav-item-active": "rgba(255, 255, 255, 0.085)",
"--dsw-specific-sidebar-nav-item-hover": "rgba(255, 255, 255, 0.045)",
"--dsw-alias-scrollbar-bg-l1": "rgba(255, 255, 255, 0.085)",
"--dsw-alias-scrollbar-bg-l2": "rgba(255, 255, 255, 0.13)",
"--dsw-alias-scrollbar-hover-l1": "rgba(255, 255, 255, 0.2)",
"--dsw-alias-scrollbar-hover-l2": "rgba(255, 255, 255, 0.2)"
}
},
{
"id": "midnight",
"colorScheme": "dark",
"name": "Midnight",
"nameZh": "午夜",
"tokens": {
"--dsw-alias-bg-base": "#0b0b0e",
"--dsw-alias-bg-layer-1": "#17171f",
"--dsw-alias-bg-layer-2": "rgba(22, 22, 28, 0.85)",
"--dsw-alias-bg-layer-3": "#181820",
"--dsw-alias-bg-module-platform": "#11111a",
"--dsw-alias-bg-overlay": "rgba(20, 20, 25, 0.86)",
"--dsw-specific-input-major": "rgba(255, 255, 255, 0.08)",
"--dsw-specific-tip": "rgba(26, 26, 32, 0.9)",
"--dsw-alias-border-l1": "rgba(255, 255, 255, 0.06)",
"--dsw-alias-border-l2": "rgba(255, 255, 255, 0.12)",
"--dsw-alias-label-primary": "#f2f2f6",
"--dsw-alias-label-secondary": "#a4a4b2",
"--dsw-alias-label-tertiary": "#787884",
"--dsw-alias-brand-primary": "#7c8cff",
"--dsw-specific-bubble": "rgba(30, 30, 38, 0.9)",
"--dsw-specific-bubble-highlight": "rgba(124, 140, 255, 0.14)",
"--dsw-specific-selector": "rgba(255, 255, 255, 0.10)",
"--dsw-alias-brand-text": "#05050f",
"--dsw-alias-button-primary-hover": "#93a1ff",
"--dsw-alias-button-primary-dimmed": "rgba(124, 140, 255, 0.14)",
"--dsw-alias-state-business-primary": "#7c8cff",
"--dsw-alias-state-business-tertiary": "rgba(124, 140, 255, 0.14)",
"--dsw-alias-interactive-bg-hover": "rgba(124, 140, 255, 0.12)",
"--dsw-alias-interactive-bg-active": "rgba(124, 140, 255, 0.20)",
"--dsw-alias-markdown-code-block": "rgba(0, 0, 0, 0.4)",
"--dsw-alias-markdown-inline-code": "rgba(255, 255, 255, 0.085)",
"--dsw-specific-sidebar-fill": "rgba(11, 11, 14, 0.92)",
"--dsw-specific-sidebar-nav-item-active": "rgba(255, 255, 255, 0.085)",
"--dsw-specific-sidebar-nav-item-hover": "rgba(255, 255, 255, 0.045)",
"--dsw-alias-scrollbar-bg-l1": "rgba(255, 255, 255, 0.085)",
"--dsw-alias-scrollbar-bg-l2": "rgba(255, 255, 255, 0.13)",
"--dsw-alias-scrollbar-hover-l1": "rgba(255, 255, 255, 0.2)",
"--dsw-alias-scrollbar-hover-l2": "rgba(255, 255, 255, 0.2)"
}
},
{
"id": "ivory",
"colorScheme": "light",
"name": "Ivory",
"nameZh": "象牙",
"tokens": {
"--dsw-alias-bg-base": "#f4f4f6",
"--dsw-alias-bg-layer-1": "#ffffff",
"--dsw-alias-bg-layer-2": "#ffffff",
"--dsw-alias-bg-layer-3": "#fafafc",
"--dsw-alias-bg-module-platform": "#ededf0",
"--dsw-alias-bg-overlay": "#ffffff",
"--dsw-specific-input-major": "#ffffff",
"--dsw-specific-tip": "#ffffff",
"--dsw-alias-border-l1": "rgba(0, 0, 0, 0.08)",
"--dsw-alias-border-l2": "rgba(0, 0, 0, 0.14)",
"--dsw-alias-label-primary": "#1c1c1e",
"--dsw-alias-label-secondary": "#6e6e73",
"--dsw-alias-label-tertiary": "#86868b",
"--dsw-alias-brand-primary": "#0071e3",
"--dsw-specific-bubble": "#ffffff",
"--dsw-specific-bubble-highlight": "rgba(0, 113, 227, 0.08)",
"--dsw-specific-selector": "rgba(0, 0, 0, 0.04)",
"--dsw-alias-brand-text": "#ffffff",
"--dsw-alias-button-primary-hover": "#3395ff",
"--dsw-alias-button-primary-dimmed": "rgba(0, 113, 227, 0.12)",
"--dsw-alias-state-business-primary": "#0071e3",
"--dsw-alias-state-business-tertiary": "rgba(0, 113, 227, 0.12)",
"--dsw-alias-interactive-bg-hover": "rgba(0, 113, 227, 0.10)",
"--dsw-alias-interactive-bg-active": "rgba(0, 113, 227, 0.16)",
"--dsw-alias-markdown-code-block": "#f2f2f4",
"--dsw-alias-markdown-inline-code": "rgba(0, 113, 227, 0.10)",
"--dsw-specific-sidebar-fill": "#f4f4f6",
"--dsw-specific-sidebar-nav-item-active": "#e4e4e8",
"--dsw-specific-sidebar-nav-item-hover": "rgba(0, 0, 0, 0.04)",
"--dsw-alias-scrollbar-bg-l1": "#d1d1d6",
"--dsw-alias-scrollbar-bg-l2": "#c0c0c6",
"--dsw-alias-scrollbar-hover-l1": "#a6a6ad",
"--dsw-alias-scrollbar-hover-l2": "#a6a6ad"
}
},
{
"id": "mist",
"colorScheme": "light",
"name": "Mist",
"nameZh": "晨雾",
"tokens": {
"--dsw-alias-bg-base": "#e9eef6",
"--dsw-alias-bg-layer-1": "rgba(255, 255, 255, 0.5)",
"--dsw-alias-bg-layer-2": "rgba(255, 255, 255, 0.6)",
"--dsw-alias-bg-layer-3": "rgba(255, 255, 255, 0.68)",
"--dsw-alias-bg-module-platform": "rgba(243, 247, 252, 0.9)",
"--dsw-alias-bg-overlay": "rgba(255, 255, 255, 0.55)",
"--dsw-specific-input-major": "rgba(255, 255, 255, 0.45)",
"--dsw-specific-tip": "rgba(255, 255, 255, 0.5)",
"--dsw-alias-border-l1": "rgba(30, 41, 59, 0.10)",
"--dsw-alias-border-l2": "rgba(30, 41, 59, 0.16)",
"--dsw-alias-label-primary": "#0f1b33",
"--dsw-alias-label-secondary": "#3d5270",
"--dsw-alias-label-tertiary": "#6b80a0",
"--dsw-alias-brand-primary": "#2196f3",
"--dsw-specific-bubble": "rgba(255, 255, 255, 0.7)",
"--dsw-specific-bubble-highlight": "rgba(33, 150, 243, 0.12)",
"--dsw-specific-selector": "rgba(255, 255, 255, 0.5)",
"--dsw-alias-brand-text": "#ffffff",
"--dsw-alias-button-primary-hover": "#42a5f5",
"--dsw-alias-button-primary-dimmed": "rgba(33, 150, 243, 0.14)",
"--dsw-alias-state-business-primary": "#2196f3",
"--dsw-alias-state-business-tertiary": "rgba(33, 150, 243, 0.14)",
"--dsw-alias-interactive-bg-hover": "rgba(33, 150, 243, 0.12)",
"--dsw-alias-interactive-bg-active": "rgba(33, 150, 243, 0.20)",
"--dsw-alias-markdown-code-block": "rgba(255, 255, 255, 0.6)",
"--dsw-alias-markdown-inline-code": "rgba(33, 150, 243, 0.12)",
"--dsw-specific-sidebar-fill": "rgba(255, 255, 255, 0.5)",
"--dsw-specific-sidebar-nav-item-active": "rgba(255, 255, 255, 0.72)",
"--dsw-specific-sidebar-nav-item-hover": "rgba(255, 255, 255, 0.35)",
"--dsw-alias-scrollbar-bg-l1": "rgba(30, 41, 59, 0.18)",
"--dsw-alias-scrollbar-bg-l2": "rgba(30, 41, 59, 0.24)",
"--dsw-alias-scrollbar-hover-l1": "rgba(30, 41, 59, 0.34)",
"--dsw-alias-scrollbar-hover-l2": "rgba(30, 41, 59, 0.34)"
}
},
{
"id": "rose",
"colorScheme": "light",
"name": "Rose",
"nameZh": "玫瑰",
"tokens": {
"--dsw-alias-bg-base": "#f7f0f3",
"--dsw-alias-bg-layer-1": "#ffffff",
"--dsw-alias-bg-layer-2": "#fffdfd",
"--dsw-alias-bg-layer-3": "#fdeef3",
"--dsw-alias-bg-module-platform": "#f6e9ef",
"--dsw-alias-bg-overlay": "#ffffff",
"--dsw-specific-input-major": "#ffffff",
"--dsw-specific-tip": "#fff7fa",
"--dsw-alias-border-l1": "rgba(154, 55, 118, 0.12)",
"--dsw-alias-border-l2": "rgba(154, 55, 118, 0.20)",
"--dsw-alias-label-primary": "#3a1424",
"--dsw-alias-label-secondary": "#8a4a63",
"--dsw-alias-label-tertiary": "#a86b82",
"--dsw-alias-brand-primary": "#e91e63",
"--dsw-specific-bubble": "#ffffff",
"--dsw-specific-bubble-highlight": "rgba(233, 30, 99, 0.10)",
"--dsw-specific-selector": "rgba(233, 30, 99, 0.06)",
"--dsw-alias-brand-text": "#ffffff",
"--dsw-alias-button-primary-hover": "#f06292",
"--dsw-alias-button-primary-dimmed": "rgba(233, 30, 99, 0.12)",
"--dsw-alias-state-business-primary": "#e91e63",
"--dsw-alias-state-business-tertiary": "rgba(233, 30, 99, 0.12)",
"--dsw-alias-interactive-bg-hover": "rgba(233, 30, 99, 0.10)",
"--dsw-alias-interactive-bg-active": "rgba(233, 30, 99, 0.18)",
"--dsw-alias-markdown-code-block": "#fdeef3",
"--dsw-alias-markdown-inline-code": "rgba(233, 30, 99, 0.10)",
"--dsw-specific-sidebar-fill": "#f6e9ef",
"--dsw-specific-sidebar-nav-item-active": "#f5d4e2",
"--dsw-specific-sidebar-nav-item-hover": "rgba(233, 30, 99, 0.06)",
"--dsw-alias-scrollbar-bg-l1": "#e5c7d4",
"--dsw-alias-scrollbar-bg-l2": "#d9b3c4",
"--dsw-alias-scrollbar-hover-l1": "#c392ab",
"--dsw-alias-scrollbar-hover-l2": "#c392ab"
}
}
]

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": true
},
"include": ["src/**/*"]
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,15 @@
# dsh-mind-map 本地安装到 DSH web profile(开发用)
```powershell
$plugin = "d:\project\chatgpt\mind-map\packages\dsh-plugin"
# 用 dsh plugin add 链到本地包,或在 profile 里 link
dsh plugin --profile web add -w $plugin
```
装好后:
1. 重启 `dsh web`,浏览器硬刷新(Ctrl+Shift+R)
2. 右侧栏「+」选 **思维导图**,或 设置 → Mind Map → **打开思维导图 Tab**
3. 拓扑占满右侧内容区;对话继续用中间 DSH 输入框
依赖:已安装 `dsh-better-sidebar`(提供 `ctx.betterSidebar.registerTab`)。

View file

@ -0,0 +1,11 @@
# dsh-mind-map
DSH 插件:自建画布 Web;**对话 = DSH 会话镜像**(输入转发,历史由 DSH 管)。
## 架构
- Host:注册 `mindmap_*`、启停本机 Web、会话绑定
- Client:设置卡(端口 / 打开画布 / 绑定)
- Web 右栏:零持久化镜像,不做独立 chat
详见仓库 `docs/dsh-bridge.md` 与设计稿 v2.9。

View file

@ -0,0 +1,4 @@
# Mind Map — DSH profile patch
- insert:
- id: dsh-mind-map
name: dsh-mind-map

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,380 @@
/**
* dsh-mind-map — Host half.
* Canvas Web is local; chat IS a DSH Session (mirror only on the Web).
*/
import { createRequire } from 'node:module'
import { randomBytes } from 'node:crypto'
import { homedir } from 'node:os'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createRuntime } from './runtime.js'
import { createSessionBridge } from './session-bridge.js'
import { makeMindmapSkill, registerMindmapTools } from './tools.js'
export const name = 'dsh-mind-map'
export const NS = 'dsh-mind-map'
export const inject = []
const API_PREFIX = '/dsh-mind-map'
const DEFAULTS = {
enabled: true,
port: 17890,
autoStart: true,
}
function loadPkg(id) {
const homes = [
fileURLToPath(import.meta.url),
join(process.cwd(), 'package.json'),
join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'profiles', 'web', 'package.json'),
]
const errors = []
for (const from of homes) {
try {
const require = createRequire(from)
return require(id)
} catch (e) {
errors.push(e)
}
}
const err = new Error(`cannot resolve ${id}`)
err.cause = errors[0]
throw err
}
const SchemaMod = loadPkg('@deepseek-ai/schemastery')
const Schema = SchemaMod.default || SchemaMod
export const Config = Schema.object({
enabled: Schema.boolean().default(DEFAULTS.enabled),
port: Schema.number().min(1024).max(65535).step(1).default(DEFAULTS.port),
autoStart: Schema.boolean().default(DEFAULTS.autoStart),
})
function resolveConfig(config = {}) {
return {
enabled: config.enabled !== false,
port: Number(config.port) > 0 ? Number(config.port) : DEFAULTS.port,
autoStart: config.autoStart !== false,
}
}
function tryGet(ctx, key) {
try {
return ctx.get?.(key) ?? ctx[key]
} catch {
return undefined
}
}
function writeJson(res, status, body) {
if (res.headersSent || res.writableEnded) return
res.writeHead(status, {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
})
res.end(JSON.stringify(body))
}
async function readJson(req) {
const chunks = []
for await (const c of req) chunks.push(c)
if (!chunks.length) return {}
try {
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
} catch {
return {}
}
}
function registerWebRoute(ctx, handler) {
const register = (webServer) => {
if (!webServer || typeof webServer.register !== 'function') return () => {}
return webServer.register({
kind: 'prefix',
path: API_PREFIX,
handler,
})
}
const present = tryGet(ctx, 'webServer')
if (present !== undefined) return register(present)
const disposers = []
let registered = false
const off = ctx.on('internal/service', (serviceName, value) => {
if (serviceName !== 'webServer' || registered) return
registered = true
disposers.push(register(value))
})
return () => {
off()
for (const d of disposers) {
if (typeof d === 'function') d()
}
}
}
export function apply(ctx, config = {}) {
const entry = resolveConfig(config)
const runtime = createRuntime()
const internalToken = randomBytes(16).toString('hex')
let mindMapBase = `http://127.0.0.1:${entry.port}`
let bridge = null
const getBase = () => mindMapBase
const getToken = () => internalToken
bridge = createSessionBridge({
ctx,
getMindMapBase: getBase,
getInternalToken: getToken,
logger: ctx.logger,
})
async function ensureServer(cfg = entry) {
if (!cfg.enabled) {
await runtime.stop()
return runtime.status()
}
let dshPort = tryGet(ctx, 'webServer')?.port
for (let i = 0; !dshPort && i < 40; i++) {
await new Promise((r) => setTimeout(r, 100))
dshPort = tryGet(ctx, 'webServer')?.port
}
if (!dshPort) {
throw new Error('DSH webServer.port unavailable; cannot wire session bridge')
}
const bridgeUrl = `http://127.0.0.1:${dshPort}${API_PREFIX}`
const st = await runtime.start({
port: cfg.port,
bridgeUrl,
internalToken,
})
mindMapBase = st.url
return st
}
ctx.inject(['settings'], (sctx) => {
sctx.settings.register(NS, Config, { base: entry, applies: 'live' })
})
ctx.inject(['tools'], (tctx) => {
registerMindmapTools(
tctx,
getBase,
(sessionId) => bridge.getActiveCanvas(sessionId),
(sessionId, canvasId) => bridge.setActiveCanvas(sessionId, canvasId),
(sessionId, info) => bridge.queueUiOpen(sessionId, info),
)
tctx.logger?.info?.('[dsh-mind-map] mindmap_* tools registered')
})
ctx.inject(['skills'], (sctx) => {
if (typeof sctx.skills?.register === 'function') {
sctx.skills.register(makeMindmapSkill())
}
})
ctx.effect(() => {
const off = ctx.on('session/event', (session, event) => {
bridge.onSessionEvent(session, event)
})
return () => {
if (typeof off === 'function') off()
}
}, 'dsh-mind-map: session mirror')
ctx.effect(() => {
return registerWebRoute(ctx, async (req, res) => {
try {
const url = new URL(req.url || '/', 'http://dsh.local')
const path = url.pathname.replace(API_PREFIX, '') || '/'
const method = (req.method || 'GET').toUpperCase()
if (path === '/health' && method === 'GET') {
writeJson(res, 200, {
ok: true,
plugin: name,
server: runtime.status(),
canvasUrl: mindMapBase,
})
return
}
if (path === '/status' && method === 'GET') {
const canvasId = url.searchParams.get('canvasId') || ''
writeJson(res, 200, {
...bridge.status(canvasId),
server: runtime.status(),
canvasUrl: mindMapBase,
config: entry,
})
return
}
if (path === '/prompt' && method === 'POST') {
const body = await readJson(req)
const result = await bridge.prompt(body.canvasId, body.text)
writeJson(res, 200, result)
return
}
if (path === '/history' && method === 'GET') {
const canvasId = url.searchParams.get('canvasId') || ''
const hist = await bridge.history(canvasId)
writeJson(res, 200, { ok: true, ...hist })
return
}
if (path === '/ensure' && method === 'POST') {
const body = await readJson(req)
const binding = await bridge.ensureBinding(body.canvasId)
writeJson(res, 200, { ok: true, sessionId: binding.sessionId })
return
}
if (path === '/open' && method === 'POST') {
await ensureServer(entry)
writeJson(res, 200, { ok: true, url: mindMapBase })
return
}
if (path === '/canvases' && method === 'GET') {
await ensureServer(entry)
const r = await fetch(`${mindMapBase}/api/canvases`)
const body = await r.json().catch(() => ({}))
writeJson(res, r.status, body)
return
}
if (path === '/canvases' && method === 'POST') {
await ensureServer(entry)
const body = await readJson(req)
const r = await fetch(`${mindMapBase}/api/canvases`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
title: body.title || '未命名画布',
rootText: body.rootText,
}),
})
const out = await r.json().catch(() => ({}))
const sessionId = String(body.sessionId || '').trim()
const canvasId = out?.canvas?.id
if (r.ok && sessionId && canvasId) {
bridge.setActiveCanvas(sessionId, canvasId)
bridge.queueUiOpen(sessionId, {
canvasId,
title: out.canvas?.title || body.title || 'Mind Map',
})
}
writeJson(res, r.status, { ...out, activeCanvasId: canvasId || null })
return
}
if (path.startsWith('/canvases/') && method === 'PATCH') {
await ensureServer(entry)
const id = path.slice('/canvases/'.length).split('/')[0]
const body = await readJson(req)
const r = await fetch(`${mindMapBase}/api/canvases/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ title: body.title }),
})
const out = await r.json().catch(() => ({}))
writeJson(res, r.status, out)
return
}
if (path.startsWith('/canvases/') && method === 'DELETE') {
await ensureServer(entry)
const id = path.slice('/canvases/'.length).split('/')[0]
const r = await fetch(`${mindMapBase}/api/canvases/${encodeURIComponent(id)}`, {
method: 'DELETE',
})
const out = await r.json().catch(() => ({}))
if (r.ok) bridge.clearActiveForCanvas(id)
writeJson(res, r.status, out)
return
}
if (path === '/active' && method === 'GET') {
const sessionId = url.searchParams.get('sessionId') || ''
writeJson(res, 200, {
ok: true,
sessionId,
canvasId: bridge.getActiveCanvas(sessionId),
})
return
}
if (path === '/active' && method === 'POST') {
const body = await readJson(req)
const sessionId = String(body.sessionId || '').trim()
const canvasId = body.canvasId == null ? '' : String(body.canvasId).trim()
if (!sessionId) {
writeJson(res, 400, { ok: false, error: 'sessionId_required' })
return
}
const bound = bridge.setActiveCanvas(sessionId, canvasId)
writeJson(res, 200, { ok: true, sessionId, canvasId: bound })
return
}
if (path === '/ui-open' && method === 'GET') {
const sessionId = url.searchParams.get('sessionId') || ''
const cmd = bridge.consumeUiOpen(sessionId)
writeJson(res, 200, { ok: true, ...(cmd || {}) })
return
}
writeJson(res, 404, { ok: false, error: 'not_found' })
} catch (e) {
ctx.logger?.error?.('[dsh-mind-map] route error %s', e)
writeJson(res, 500, { ok: false, message: String(e?.message || e) })
}
})
}, 'dsh-mind-map: web routes')
ctx.effect(() => {
let stopped = false
const boot = async () => {
if (stopped) return
if (entry.autoStart && entry.enabled) {
try {
await ensureServer(entry)
ctx.logger?.info?.('[dsh-mind-map] canvas at %s', mindMapBase)
} catch (e) {
ctx.logger?.error?.('[dsh-mind-map] failed to start server: %s', e?.message || e)
}
}
}
void boot()
const settings = tryGet(ctx, 'settings')
let unwatch = null
if (settings?.watch) {
try {
unwatch = settings.watch(NS, (next) => {
Object.assign(entry, resolveConfig(next))
if (entry.autoStart && entry.enabled) void ensureServer(entry)
else if (!entry.enabled) void runtime.stop()
})
} catch {
/* optional */
}
}
return () => {
stopped = true
if (typeof unwatch === 'function') unwatch()
bridge.dispose()
// Sync kill so DSH process exit does not leave canvas server orphaned.
try {
runtime.stop()
} catch (e) {
ctx.logger?.warn?.('[dsh-mind-map] stop failed: %s', e?.message || e)
}
}
}, 'dsh-mind-map: lifecycle')
}

View file

@ -0,0 +1,284 @@
/**
* Start / stop the mind-map Hono server as a child process.
* Lifetime is tied to the DSH host: when the plugin disposes / process exits,
* the canvas server is killed (including process tree on Windows).
*/
import { spawn, execSync } from 'node:child_process'
import { existsSync, statSync } from 'node:fs'
import { join, dirname, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createRequire } from 'node:module'
const __dirname = dirname(fileURLToPath(import.meta.url))
export const mindMapRoot = join(__dirname, '../../..')
function resolveTsxCliRel() {
const candidates = [
join(mindMapRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'),
join(mindMapRoot, 'apps', 'server', 'node_modules', 'tsx', 'dist', 'cli.mjs'),
]
for (const abs of candidates) {
if (existsSync(abs)) {
return relative(mindMapRoot, abs).split('\\').join('/')
}
}
try {
const requireFromRoot = createRequire(join(mindMapRoot, 'package.json'))
const abs = requireFromRoot.resolve('tsx/dist/cli.mjs')
return relative(mindMapRoot, abs).split('\\').join('/')
} catch {
return null
}
}
function ensureWebDist() {
const indexHtml = join(mindMapRoot, 'apps', 'web', 'dist', 'index.html')
const watchSrc = [
join(mindMapRoot, 'apps', 'web', 'src', 'TopologyCanvas.tsx'),
join(mindMapRoot, 'apps', 'web', 'src', 'store.ts'),
join(mindMapRoot, 'apps', 'web', 'src', 'App.tsx'),
join(mindMapRoot, 'apps', 'web', 'src', 'drag-subtree.ts'),
]
let needsBuild = !existsSync(indexHtml)
if (!needsBuild && existsSync(indexHtml)) {
try {
const distM = statSync(indexHtml).mtimeMs
for (const src of watchSrc) {
if (existsSync(src) && statSync(src).mtimeMs > distM) {
needsBuild = true
break
}
}
} catch {
/* ignore */
}
}
if (!needsBuild) return
console.log('[dsh-mind-map] building web UI (apps/web/dist missing or stale)…')
try {
execSync('pnpm --filter @mind-map/web build', {
cwd: mindMapRoot,
stdio: 'inherit',
env: process.env,
windowsHide: true,
})
} catch (e) {
throw new Error(
`failed to build mind-map web UI: ${e?.message || e}. Run manually: pnpm --filter @mind-map/web build`,
)
}
if (!existsSync(indexHtml)) {
throw new Error('web build finished but apps/web/dist/index.html is still missing')
}
}
async function probeMindMap(url) {
try {
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1500) })
if (!res.ok) return null
const body = await res.json().catch(() => null)
if (body && body.ok === true) return body
} catch {
/* not up */
}
return null
}
/** Kill a PID and its children (Windows needs /T). */
function killPidTree(pid) {
if (!pid || !Number.isFinite(pid)) return
try {
if (process.platform === 'win32') {
execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' })
} else {
try {
process.kill(pid, 'SIGTERM')
} catch {
/* ignore */
}
try {
process.kill(pid, 'SIGKILL')
} catch {
/* ignore */
}
}
} catch {
/* already gone */
}
}
/** Free listeners on bind:port so DSH can own a fresh child. */
function killListenersOnPort(port) {
try {
if (process.platform === 'win32') {
const out = execSync('netstat -ano', { encoding: 'utf8' })
const pids = new Set()
for (const line of out.split(/\r?\n/)) {
if (!line.includes(`:${port}`) || !/LISTENING/i.test(line)) continue
const parts = line.trim().split(/\s+/)
const pid = Number(parts[parts.length - 1])
if (pid > 0) pids.add(pid)
}
for (const pid of pids) {
// Never kill ourselves
if (pid === process.pid) continue
console.log(`[dsh-mind-map] freeing port ${port} (pid ${pid})`)
killPidTree(pid)
}
} else {
try {
execSync(`fuser -k ${port}/tcp`, { stdio: 'ignore' })
} catch {
/* ignore */
}
}
} catch {
/* ignore */
}
}
export function createRuntime() {
let child = null
let last = { port: 17890, url: 'http://127.0.0.1:17890', running: false }
let exitHooksInstalled = false
function killOwnedChildSync() {
const c = child
child = null
if (!c) return
killPidTree(c.pid)
}
function installExitHooks() {
if (exitHooksInstalled) return
exitHooksInstalled = true
const onExit = () => {
killOwnedChildSync()
}
process.on('exit', onExit)
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']) {
try {
process.on(sig, () => {
killOwnedChildSync()
})
} catch {
/* unsupported signal on this platform */
}
}
}
async function start({ port = 17890, bridgeUrl, internalToken, dataDir } = {}) {
const url = `http://127.0.0.1:${port}`
installExitHooks()
if (child && !child.killed) {
last = { ...last, port, url, running: true }
return last
}
ensureWebDist()
// Stale orphan from a previous DSH run: kill it so we own the next child
// and can tear it down when DSH exits.
if (await probeMindMap(url)) {
console.log(`[dsh-mind-map] stopping leftover server on ${url}`)
killListenersOnPort(port)
await new Promise((r) => setTimeout(r, 400))
}
const entryAbs = join(mindMapRoot, 'apps/server/src/index.ts')
const distAbs = join(mindMapRoot, 'apps/server/dist/index.js')
const useTs = existsSync(entryAbs)
const scriptAbs = useTs ? entryAbs : distAbs
if (!existsSync(scriptAbs)) {
throw new Error(`mind-map server entry not found under ${mindMapRoot}`)
}
const env = {
...process.env,
PORT: String(port),
BIND: '127.0.0.1',
MINDMAP_DSH_BRIDGE: bridgeUrl || '',
MINDMAP_INTERNAL_TOKEN: internalToken || '',
MINDMAP_LLM_SOURCE: 'dsh',
}
if (dataDir) env.DATA_DIR = dataDir
const scriptRel = useTs ? 'apps/server/src/index.ts' : 'apps/server/dist/index.js'
let args
if (useTs) {
const tsxCli = resolveTsxCliRel()
if (!tsxCli) throw new Error('tsx not found; run pnpm install in mind-map')
args = [tsxCli, scriptRel]
} else {
args = [scriptRel]
}
child = spawn(process.execPath, args, {
cwd: mindMapRoot,
env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
// Keep attached to parent so closing DSH can reap it; do NOT detach.
detached: false,
})
let spawnError = null
child.stdout?.on('data', (buf) => {
const line = String(buf).trim()
if (line) console.log(`[dsh-mind-map:server] ${line}`)
})
child.stderr?.on('data', (buf) => {
const line = String(buf).trim()
if (line) {
console.error(`[dsh-mind-map:server] ${line}`)
if (line.includes('EADDRINUSE')) spawnError = new Error(`port ${port} already in use`)
}
})
child.on('exit', (code) => {
console.warn(`[dsh-mind-map] server exited code=${code}`)
if (child) child = null
last = { ...last, running: false }
})
const deadline = Date.now() + 20000
let ok = false
while (Date.now() < deadline) {
if (!child) break
if (await probeMindMap(url)) {
ok = true
break
}
await new Promise((r) => setTimeout(r, 200))
}
if (!ok) {
killOwnedChildSync()
killListenersOnPort(port)
throw spawnError || new Error(`mind-map server failed to become healthy on ${url}`)
}
last = { port, url, running: true }
return last
}
async function stop() {
const port = last.port
killOwnedChildSync()
// Belt-and-suspenders: anything still listening on our canvas port goes down with DSH.
killListenersOnPort(port)
await new Promise((r) => setTimeout(r, 200))
last = { ...last, running: false }
}
function status() {
return {
...last,
running: Boolean(child && !child.killed),
pid: child?.pid || null,
}
}
return { start, stop, status, mindMapRoot }
}

View file

@ -0,0 +1,444 @@
/**
* Canvas ↔ DSH Session binding.
* Web input → agent.followup; session/event → push to mind-map WS mirror.
*/
import { randomUUID } from 'node:crypto'
import { homedir } from 'node:os'
import { join } from 'node:path'
const PLUGIN = 'dsh-mind-map'
function dshHome() {
return process.env.DSH_HOME || join(homedir(), '.dsh')
}
function textOf(message) {
const content = message?.content
if (typeof content === 'string') return content
if (!Array.isArray(content)) return ''
return content
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
.map((b) => b.text)
.join('')
}
function tryGet(ctx, name) {
try {
return ctx.get?.(name) ?? ctx[name]
} catch {
return undefined
}
}
/** Same default model a New Session in DSH Web would use. */
function currentDefaultModel(ctx) {
try {
const selection = tryGet(ctx, 'agentDefaultModel')?.currentSelection?.()
const provider = typeof selection?.provider === 'string' ? selection.provider.trim() : ''
const model = typeof selection?.model === 'string' ? selection.model.trim() : ''
if (!provider || !model) return null
return {
provider,
model,
...(selection.reasoningEffort === undefined
? {}
: { reasoningEffort: selection.reasoningEffort }),
}
} catch {
return null
}
}
/** Drop harness context notices / tool rows — only real chat turns for the mirror. */
function isMirrorableChatMessage(message) {
if (!message || typeof message !== 'object') return false
const source = message.source || {}
const kind = source.kind
const form = source.form
if (form === 'snapshot' || form === 'notice' || form === 'instructions' || form === 'catalog') {
return false
}
if (kind === 'tool') return false
if (message.role === 'assistant') return kind === 'model' || kind == null || kind === undefined
if (message.role === 'user') {
return kind === 'user' || kind === 'plugin' || kind == null || kind === undefined
}
return false
}
function isRuntimeContextText(text) {
return (
typeof text === 'string' &&
(text.includes('Current runtime context') ||
text.includes('Current DSH file policy') ||
text.includes('This snapshot supersedes'))
)
}
export function createSessionBridge({ ctx, getMindMapBase, getInternalToken, logger }) {
/** @type {Map<string, { sessionId: string, handle: any, agent: any }>} */
const byCanvas = new Map()
/** @type {Map<string, string>} mirror agent session → canvas */
const canvasBySession = new Map()
/** @type {Map<string, string>} DSH chat session → currently displayed canvas */
const activeBySession = new Map()
/** @type {Map<string, { canvasId: string, title: string, at: number }>} */
const uiOpenBySession = new Map()
function setActiveCanvas(sessionId, canvasId) {
const sid = String(sessionId || '').trim()
if (!sid) return null
const cid = String(canvasId || '').trim()
if (!cid) {
activeBySession.delete(sid)
return null
}
activeBySession.set(sid, cid)
return cid
}
function getActiveCanvas(sessionId) {
const sid = String(sessionId || '').trim()
if (!sid) return null
return activeBySession.get(sid) || canvasBySession.get(sid) || null
}
function clearActiveCanvas(sessionId) {
const sid = String(sessionId || '').trim()
if (sid) activeBySession.delete(sid)
}
function clearActiveForCanvas(canvasId) {
const cid = String(canvasId || '')
for (const [sid, id] of activeBySession.entries()) {
if (id === cid) activeBySession.delete(sid)
}
}
/** Ask the DSH client to open the 思维导图 tab on this canvas. */
function queueUiOpen(sessionId, { canvasId, title } = {}) {
const sid = String(sessionId || '').trim()
const cid = String(canvasId || '').trim()
if (!sid || !cid) return false
setActiveCanvas(sid, cid)
const prev = uiOpenBySession.get(sid)
// Coalesce: same canvas already queued → just refresh title/time (don't churn).
if (prev && prev.canvasId === cid) {
prev.title = String(title || prev.title || 'Mind Map')
prev.at = Date.now()
return true
}
uiOpenBySession.set(sid, {
canvasId: cid,
title: String(title || 'Mind Map'),
at: Date.now(),
})
return true
}
function consumeUiOpen(sessionId) {
const sid = String(sessionId || '').trim()
if (!sid) return null
const cmd = uiOpenBySession.get(sid) || null
if (cmd) uiOpenBySession.delete(sid)
return cmd
}
async function ensureBinding(canvasId) {
const existing = byCanvas.get(canvasId)
if (existing?.agent) return existing
const agents = tryGet(ctx, 'agents')
if (!agents || typeof agents.create !== 'function') {
throw new Error('DSH agents service unavailable')
}
const selection = currentDefaultModel(ctx)
if (!selection) {
throw new Error(
'DSH 尚未选择默认模型。请先在 DSH 设置 → Models 选好模型,再在画布对话。',
)
}
const sessionId = randomUUID()
const cwd =
process.env.MINDMAP_SESSION_CWD ||
join(dshHome(), 'user-workspaces', 'administrator')
const handle = await agents.create({
sessionId,
agentOptions: {
provider: selection.provider,
model: selection.model,
...selection.reasoningEffort === undefined
? {}
: { reasoningEffort: selection.reasoningEffort },
},
meta: {
cwd,
title: `Mind Map · ${String(canvasId).slice(0, 8)}`,
},
setup: (agentCtx) => {
// Fill {{model}} / {{provider}} for persona assembly (same as dsh-ops-cron).
if (!agentCtx || typeof agentCtx.on !== 'function') return
const snapshot = {
provider: selection.provider,
model: selection.model,
...selection.reasoningEffort === undefined
? {}
: { reasoningEffort: selection.reasoningEffort },
}
agentCtx.on('system-prompt/assemble', async (...args) => {
const next = args.find((arg) => typeof arg === 'function')
const assembled = next ? await next() : (args[0] || {})
return {
...assembled,
variables: {
...assembled.variables,
provider: snapshot.provider,
model: snapshot.model,
},
}
})
agentCtx.on('agent/request', async (...args) => {
const next = args.find((arg) => typeof arg === 'function')
const resolved = next ? await next() : (args[0] || {})
const { reasoningEffort: _inherited, ...rest } = resolved || {}
return {
...rest,
provider: snapshot.provider,
model: snapshot.model,
...snapshot.reasoningEffort === undefined
? {}
: { reasoningEffort: snapshot.reasoningEffort },
}
})
},
})
const agent = handle?.agent
if (!agent || typeof agent.followup !== 'function') {
try {
handle?.dispose?.()
} catch {
/* ignore */
}
throw new Error('agents.create did not return a usable agent')
}
try {
if (typeof agent.session?.append === 'function') {
agent.session.append('session/title', {
title: `Mind Map · ${String(canvasId).slice(0, 8)}`,
source: 'plugin',
})
}
} catch {
/* best-effort */
}
// Best-effort: show session in workspace list
try {
const registry = tryGet(ctx, 'workspaceRegistry')
const list =
typeof registry?.list === 'function'
? registry.list()
: typeof registry?.workspaces === 'function'
? registry.workspaces()
: []
const rows = Array.isArray(list) ? list : []
const ws =
rows.find((w) => {
const p = String(w?.path || w?.cwd || '')
return p && p.replace(/\\/g, '/') === cwd.replace(/\\/g, '/')
}) || rows[0]
if (ws && typeof ws.attachSession === 'function') {
await ws.attachSession(sessionId)
}
} catch (e) {
logger?.warn?.('[dsh-mind-map] workspace attach skipped: %s', e?.message || e)
}
const binding = { sessionId, handle, agent, model: selection }
byCanvas.set(canvasId, binding)
canvasBySession.set(sessionId, canvasId)
logger?.info?.(
'[dsh-mind-map] bound canvas %s → session %s (%s/%s)',
canvasId,
sessionId,
selection.provider,
selection.model,
)
return binding
}
async function prompt(canvasId, text) {
const trimmed = String(text || '').trim()
if (!canvasId || !trimmed) throw new Error('canvasId and text required')
const binding = await ensureBinding(canvasId)
// Match dsh-ops-cron: plain message object + plugin source (reliable wake).
const message = {
id: randomUUID(),
role: 'user',
content: [{ type: 'text', text: trimmed }],
source: { kind: 'plugin', plugin: PLUGIN },
}
binding.agent.followup(message)
return {
ok: true,
sessionId: binding.sessionId,
model: binding.model || null,
}
}
async function history(canvasId) {
const binding = byCanvas.get(canvasId)
if (!binding) return { sessionId: null, messages: [], model: null }
const session = binding.agent?.session
let messages = []
try {
const derived =
typeof session?.deriveMessages === 'function'
? session.deriveMessages()
: session?.messages || []
const list = Array.isArray(derived) ? derived : []
messages = list
.filter(isMirrorableChatMessage)
.map((m) => {
const role = m?.role === 'assistant' ? 'assistant' : m?.role === 'user' ? 'user' : null
if (!role) return null
const content = textOf(m)
if (!content || isRuntimeContextText(content)) return null
// Skip our own plugin echoes if duplicated; keep user-visible text.
return { role, content }
})
.filter(Boolean)
} catch {
messages = []
}
return { sessionId: binding.sessionId, messages, model: binding.model || null }
}
function status(canvasId) {
const binding = canvasId ? byCanvas.get(canvasId) : null
const selection = currentDefaultModel(ctx)
return {
ok: true,
mode: 'dsh-session',
bound: Boolean(binding),
sessionId: binding?.sessionId || null,
agents: Boolean(tryGet(ctx, 'agents')),
model: binding?.model || selection,
hasDefaultModel: Boolean(selection),
}
}
async function pushMirror(canvasId, payload) {
const base = getMindMapBase?.()
if (!base) return
const token = getInternalToken?.() || ''
try {
await fetch(`${base}/api/internal/session-event`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-mindmap-token': token,
},
body: JSON.stringify({ canvasId, ...payload }),
})
} catch (e) {
logger?.warn?.('[dsh-mind-map] mirror push failed: %s', e?.message || e)
}
}
function onSessionEvent(session, event) {
const sessionId = session?.id || session?.header?.id
if (!sessionId) return
const canvasId = canvasBySession.get(sessionId)
if (!canvasId) return
const type = event?.type
if (type === 'assistant/chunk') {
const chunk = event.data?.chunk
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
void pushMirror(canvasId, {
mirror: { kind: 'text-delta', text: chunk.text, sessionId },
})
}
return
}
if (type === 'user/message') {
const message = event.data?.message || event.data
if (!isMirrorableChatMessage(message)) return
const text = textOf(message)
if (!text || isRuntimeContextText(text)) return
// Don't mirror our own plugin-sourced prompt twice (UI already shows optimistic bubble).
if (message?.source?.kind === 'plugin' && message?.source?.plugin === PLUGIN) return
void pushMirror(canvasId, {
mirror: { kind: 'user', text, sessionId },
})
return
}
if (type === 'assistant/message') {
const message = event.data?.message || event.data
if (!isMirrorableChatMessage(message)) return
const text = textOf(message)
if (!text || isRuntimeContextText(text)) return
void pushMirror(canvasId, {
mirror: { kind: 'assistant', text, sessionId },
})
return
}
if (type === 'tool/call') {
void pushMirror(canvasId, {
mirror: {
kind: 'tool',
name: event.data?.name || 'tool',
callId: event.data?.callId,
sessionId,
},
})
return
}
if (type === 'turn/end') {
void pushMirror(canvasId, {
mirror: {
kind: 'turn-end',
reason: event.data?.reason,
sessionId,
},
})
}
}
function dispose() {
for (const binding of byCanvas.values()) {
try {
binding.handle?.dispose?.()
} catch {
/* ignore */
}
}
byCanvas.clear()
canvasBySession.clear()
activeBySession.clear()
uiOpenBySession.clear()
}
return {
ensureBinding,
prompt,
history,
status,
onSessionEvent,
dispose,
byCanvas,
canvasBySession,
activeBySession,
setActiveCanvas,
getActiveCanvas,
clearActiveCanvas,
clearActiveForCanvas,
queueUiOpen,
consumeUiOpen,
}
}

View file

@ -0,0 +1,717 @@
/**
* Register mindmap_* tools that call the local mind-map HTTP API.
* Plain ToolDefinition objects (same style as dsh-ops-cron).
*/
function text(value) {
return [{ type: 'text', text: String(value ?? '') }]
}
function jsonText(value) {
return text(typeof value === 'string' ? value : JSON.stringify(value, null, 0))
}
async function callTool(getBase, name, args) {
const base = getBase?.()
if (!base) throw new Error('mind-map server is not running')
const res = await fetch(`${base}/api/tools/${name}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(args || {}),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
const err = new Error(body.message || body.error || `HTTP ${res.status}`)
err.code = body.error || 'TOOL_HTTP'
throw err
}
return body
}
const OBJECT = {
type: 'object',
additionalProperties: true,
}
const OP_ITEM = {
type: 'object',
additionalProperties: true,
description:
'Graph op. REQUIRED discriminator field is `type` (NOT `op`). Examples: {"type":"create_node","text":"贾宝玉","parentId":"..."}, {"type":"link","from":"...","to":"...","kind":"relation","label":"木石前盟"}, {"type":"set_pinned","nodeId":"...","pinned":true,"pos":{"x":120,"y":-80}}',
properties: {
type: {
type: 'string',
description:
'Op kind: create_node | update_text | link | unlink | set_pinned | set_focus | set_collapsed | set_style_preset | set_edge_label | set_prefs | move_node | delete_node | …',
},
},
required: ['type'],
}
export function mindmapToolDefinitions(getBase, getCanvasIdForSession, setActiveCanvas, queueUiOpen) {
/** Tools that must open/switch the right-panel canvas. */
const HARD_OPEN_UI = new Set(['mindmap_create_canvas', 'mindmap_open_canvas'])
/** Soft reveal: bind session + open tab only if needed (client dedupes). */
const SOFT_REVEAL_UI = new Set([
'mindmap_query',
'mindmap_find',
'mindmap_find_and_focus',
'mindmap_get_node',
'mindmap_get_edge',
'mindmap_highlight',
'mindmap_get_spatial_context',
'mindmap_get_subgraph',
'mindmap_focus',
'mindmap_apply_direct',
'mindmap_propose',
'mindmap_accept_proposal',
'mindmap_export',
])
const run = (name) => async (args, exec) => {
const sessionId = String(
exec?.agent?.session?.id ||
exec?.agent?.session?.header?.id ||
exec?.sessionId ||
'',
).trim()
const bound =
typeof getCanvasIdForSession === 'function' && sessionId
? getCanvasIdForSession(sessionId)
: null
const payload = {
...(args || {}),
canvasId: args?.canvasId || bound || undefined,
sessionId: sessionId || undefined,
}
const body = await callTool(getBase, name, payload)
if (name === 'mindmap_delete_canvas' && sessionId && typeof setActiveCanvas === 'function') {
const deleted = args?.canvasId || bound
const still = getCanvasIdForSession?.(sessionId)
if (deleted && still === deleted) setActiveCanvas(sessionId, null)
}
const cid =
body?.canvas?.id ||
payload.canvasId ||
(typeof getCanvasIdForSession === 'function' && sessionId
? getCanvasIdForSession(sessionId)
: null)
if (body && body.ok !== false && sessionId && cid) {
if (HARD_OPEN_UI.has(name) && typeof queueUiOpen === 'function') {
queueUiOpen(sessionId, {
canvasId: cid,
title: body?.canvas?.title || body?.title || args?.title || 'Mind Map',
})
} else if (SOFT_REVEAL_UI.has(name)) {
// Bind session; open UI at most via queue (client skips if already live).
if (typeof setActiveCanvas === 'function') setActiveCanvas(sessionId, cid)
if (typeof queueUiOpen === 'function') {
queueUiOpen(sessionId, {
canvasId: cid,
title: body?.canvas?.title || body?.title || args?.title || 'Mind Map',
})
}
}
}
return body
}
return [
{
name: 'mindmap_get_schema',
description:
'Return mind-map op schema: valid `type` values, examples (create_node / link / set_pinned), and tool list. Call when unsure about op shape.',
parameters: { type: 'object', additionalProperties: false, properties: {} },
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_get_schema'),
},
{
name: 'mindmap_list_canvases',
description: 'List mind-map canvases (id + title).',
parameters: { type: 'object', additionalProperties: false, properties: {} },
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_list_canvases'),
},
{
name: 'mindmap_create_canvas',
description:
'Create a brand-new mind-map canvas (new id; does NOT overwrite existing canvases), bind it to this chat session, and open it in the 思维导图 tab. Canvas starts visually empty. Top-level nodes: omit parentId.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
title: { type: 'string', description: 'Canvas title, e.g. 红楼梦人物关系' },
rootText: {
type: 'string',
description:
'Optional internal label / title sync only — there is no visible root node. Prefer title alone.',
},
},
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_create_canvas'),
},
{
name: 'mindmap_open_canvas',
description:
'Open an existing canvas in the 思维导图 tab and bind it as the edit target for this session.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
canvasId: { type: 'string', description: 'Canvas id from mindmap_list_canvases' },
},
required: ['canvasId'],
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_open_canvas'),
},
{
name: 'mindmap_delete_canvas',
description:
'Permanently delete a canvas (hard delete). Omit canvasId to delete the currently open canvas.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
canvasId: { type: 'string' },
},
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_delete_canvas'),
},
{
name: 'mindmap_get_spatial_context',
description:
'Get spatial harness context for the active mind-map canvas. Always call before inventing node ids.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
canvasId: { type: 'string', description: 'Omit to use the open/bound canvas.' },
},
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_get_spatial_context'),
},
{
name: 'mindmap_find',
description:
'Find nodes or edges. searchScope: text|note|tags|description|edge_label|edge_note|all (default text). Edge scopes return {kind:"edge", edgeId, fromText, toText, label}. If ambiguous, ask the user — do not pick silently.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
query: { type: 'string' },
searchScope: {
type: 'string',
enum: [
'text',
'note',
'tags',
'description',
'edge_label',
'edge_note',
'all',
],
description:
'Default text. Use edge_label/edge_note for edges; all includes nodes + edges.',
},
canvasId: { type: 'string' },
},
required: ['query'],
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_find'),
},
{
name: 'mindmap_find_and_focus',
description:
'Find then focus+highlight the best unique match. Edge hits highlight endpoints + edge. If ambiguous, returns candidates without focusing.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
query: { type: 'string' },
searchScope: {
type: 'string',
enum: [
'text',
'note',
'tags',
'description',
'edge_label',
'edge_note',
'all',
],
},
canvasId: { type: 'string' },
},
required: ['query'],
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_find_and_focus'),
},
{
name: 'mindmap_export',
description:
'Export canvas as json | md | svg. format=png also returns SVG (vector) for agents; use web UI for raster PNG.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
format: { type: 'string', enum: ['json', 'md', 'svg', 'png'] },
canvasId: { type: 'string' },
},
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_export'),
},
{
name: 'mindmap_get_subgraph',
description:
'Read a subgraph around a node (depth <= 3). rootId: id|@alias|title. edgeKinds: hierarchy (default, tree children) | relation | all — same as mindmap_query. Use relation/all for relationship networks.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
rootId: {
type: 'string',
description: 'Node id, @alias, or unique title',
},
depth: { type: 'number' },
edgeKinds: {
type: 'string',
enum: ['hierarchy', 'relation', 'all'],
description:
'hierarchy = primary-parent children (default); relation|all = undirected neighborhood',
},
canvasId: { type: 'string' },
},
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_get_subgraph'),
},
{
name: 'mindmap_query',
description:
'Deterministic graph Q&A primitives (no LLM inventing). op: neighbors|path|stats|orphans|missing_meta. Node refs: id | @alias | unique title. path returns paths[].edges with meta; stats includes longestPath + mostConnected. If empty, say the graph has no such info.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
op: {
type: 'string',
enum: ['neighbors', 'path', 'stats', 'orphans', 'missing_meta'],
},
node: { type: 'string', description: 'For neighbors: id|@alias|title' },
nodeId: { type: 'string' },
from: { type: 'string', description: 'For path: start node' },
to: { type: 'string', description: 'For path: end node' },
depth: { type: 'number' },
maxDepth: { type: 'number' },
maxHops: {
type: 'number',
description:
'For path: max edges in a path (default 8). Do NOT pass depth:1 — that was neighbors-only.',
},
throughRelationNodes: {
type: 'boolean',
description:
'For path: allow multi-hop via intermediate nodes (default true). false = direct edge only.',
},
maxPaths: { type: 'number' },
direction: {
type: 'string',
enum: ['out', 'in', 'both'],
description:
'neighbors default both; path default both when throughRelationNodes, else out',
},
edgeKinds: {
type: 'string',
enum: ['all', 'hierarchy', 'relation'],
},
canvasId: { type: 'string' },
},
required: ['op'],
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_query'),
},
{
name: 'mindmap_get_node',
description:
'Read one node. Default returns id/text/alias/function + full description. Use after Q&A when user asks for details on a cited node.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
nodeId: { type: 'string', description: 'id | @alias | unique title' },
node: { type: 'string' },
fields: {
type: 'array',
items: {
type: 'string',
enum: ['all', 'description', 'note', 'tags', 'links'],
},
},
canvasId: { type: 'string' },
},
required: ['nodeId'],
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_get_node'),
},
{
name: 'mindmap_get_edge',
description:
'Read one edge by id. Returns from/to texts + label/direction/weight/lineStyle/note. fields: all|label|meta.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
edgeId: { type: 'string' },
fields: {
type: 'array',
items: { type: 'string', enum: ['all', 'label', 'meta'] },
},
canvasId: { type: 'string' },
},
required: ['edgeId'],
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_get_edge'),
},
{
name: 'mindmap_highlight',
description:
'Flash nodes and/or edges on the open canvas (no graph mutation). Use after path/neighbors Q&A. Optional style + ttlMs (default 5000).',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
nodeIds: {
type: 'array',
items: { type: 'string' },
description: 'id | @alias | title',
},
edgeIds: { type: 'array', items: { type: 'string' } },
style: {
type: 'object',
additionalProperties: false,
properties: {
nodeColor: { type: 'string' },
nodeGlow: { type: 'boolean' },
edgeWidth: { type: 'number' },
edgeColor: { type: 'string' },
},
},
ttlMs: { type: 'number', description: 'Auto-clear ms (500–60000)' },
canvasId: { type: 'string' },
},
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_highlight'),
},
{
name: 'mindmap_apply_direct',
description:
'Apply low-risk graph ops immediately. Prefer batch_create for trees; relations[] for inline edges; auto_fit after large builds. EACH op MUST have field `type`. Response includes nodeIdMap. Set preview:true for dry-run. High-risk delete_node/move_node need mindmap_propose.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
ops: { type: 'array', items: OP_ITEM },
summary: { type: 'string' },
canvasId: { type: 'string' },
preview: {
type: 'boolean',
description: 'If true, simulate apply + layout without persisting',
},
},
required: ['ops'],
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_apply_direct'),
},
{
name: 'mindmap_propose',
description:
'Submit a validated proposal (ops must use `type`). Then mindmap_accept_proposal({proposalId}) to apply. Failures return invalid_op with details — not a silent stale.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
ops: { type: 'array', items: OP_ITEM },
rationale: { type: 'string' },
canvasId: { type: 'string' },
},
required: ['ops', 'rationale'],
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_propose'),
},
{
name: 'mindmap_accept_proposal',
description: 'Accept a pending proposal by id returned from mindmap_propose.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
proposalId: { type: 'string' },
canvasId: { type: 'string' },
},
required: ['proposalId'],
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_accept_proposal'),
},
{
name: 'mindmap_reject_proposal',
description: 'Reject a pending proposal by id.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
proposalId: { type: 'string' },
},
required: ['proposalId'],
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_reject_proposal'),
},
{
name: 'mindmap_focus',
description: 'Move visual focus to a node.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
nodeId: { type: 'string' },
canvasId: { type: 'string' },
},
required: ['nodeId'],
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_focus'),
},
{
name: 'mindmap_undo',
description:
'Undo latest change set (or specific changeSetId). Creates an undo companion that mindmap_redo can reverse.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
changeSetId: { type: 'string' },
canvasId: { type: 'string' },
},
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_undo'),
},
{
name: 'mindmap_redo',
description:
'Redo after undo: undoes the latest undo-companion change set on the canvas.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
canvasId: { type: 'string' },
},
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_redo'),
},
{
name: 'mindmap_history',
description:
'List recent change sets + named snapshots; shows latest undoable/redoable ids.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
canvasId: { type: 'string' },
limit: { type: 'number' },
},
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_history'),
},
{
name: 'mindmap_snapshot',
description:
'Create a named canvas snapshot, or action:"list" to list snapshots.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
canvasId: { type: 'string' },
name: { type: 'string' },
action: { type: 'string', enum: ['create', 'list'] },
limit: { type: 'number' },
},
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_snapshot'),
},
{
name: 'mindmap_restore_snapshot',
description: 'Restore canvas to a prior snapshot id from mindmap_snapshot / mindmap_history.',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
snapshotId: { type: 'string' },
canvasId: { type: 'string' },
},
required: ['snapshotId'],
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
execute: run('mindmap_restore_snapshot'),
},
]
}
export function registerMindmapTools(
ctx,
getBase,
getCanvasIdForSession,
setActiveCanvas,
queueUiOpen,
) {
const tools = ctx?.tools
if (!tools || typeof tools.register !== 'function') return () => {}
const offs = mindmapToolDefinitions(
getBase,
getCanvasIdForSession,
setActiveCanvas,
queueUiOpen,
).map((def) => tools.register(def))
return () => {
for (const off of offs) {
if (typeof off === 'function') off()
}
}
}
export function makeMindmapSkill() {
return {
name: 'mind-map',
description:
'AI 无限画布思维导图:右侧「思维导图」Tab;用 mindmap_* 建图/改图。Ops 必须用 type 字段。',
whenToUse:
'User asks for 思维导图 / 关系图 / 梳理人物或家族关系 / Mind Map, OR asks questions about an existing map (路径/依赖/邻居/统计/缺描述).',
source: 'runtime',
provider: 'runtime',
content: `Mind Map workflow (dsh-mind-map):
CRITICAL — op shape:
- Every graph mutation item MUST use discriminator field **type** (NOT op / operation).
- If unsure, call mindmap_get_schema first.
- Prefer **batch_create** for trees (up to 200 nodes) instead of many create_node rounds.
- Valid types include: batch_create, batch_link, create_node, update_text, link, unlink, set_pinned, set_focus, set_collapsed, auto_fit, set_style_preset, set_edge_label, delete_node, move_node, …
Examples:
{"type":"batch_create","nodes":[{"text":"数通","alias":"datacom"},{"text":"交换机","alias":"sw","parentId":"@datacom"},{"text":"AIOps","alias":"aiops","parentId":"@datacom","relations":[{"to":"@restart","label":"触发","kind":"relation"}],"description":{"function":"根因诊断"}},{"text":"自动重启","alias":"restart","parentId":"@datacom"}]}
{"type":"batch_link","edges":[{"from":"@daiyu","to":"@baoyu","kind":"relation","label":"知己"},{"from":"@baoyu","to":"@baochai","kind":"relation","label":"金玉良缘"}]}
{"type":"auto_fit","padding":50}
{"type":"create_node","text":"贾宝玉"} ← top-level (omit parentId); appears as independent node
{"type":"create_node","text":"贾政","parentId":"<贾宝玉的可见父节点或主题id>"}
{"type":"create_node","text":"AIOps","alias":"aiops","relations":[{"to":"@restart","kind":"relation","label":"触发"}]}
{"type":"link","from":"<黛玉Id>","to":"<宝玉Id>","kind":"relation","label":"木石前盟"}
{"type":"update_text","nodeId":"<id>","text":"新文案"}
{"type":"set_pinned","nodeId":"<id>","pinned":true,"pos":{"x":120,"y":-80}}
{"type":"update_node_meta","nodeId":"@aiops","patch":{"description":{"owner":"SRE","sla":"30s"},"note":"自由文本"}}
{"type":"set_style_preset","nodeId":"@baoyu","stylePreset":"decision"} ← field is stylePreset (not preset)
{"type":"unlink","from":"@daiyu","to":"@baoyu","kind":"relation"} ← or edgeId
{"type":"restore_node","nodeId":"<softDeletedId>"} ← same id after delete_node; also @alias/title of deleted nodes
There is NO visible root node. Canvas title is metadata only. Internally top-level nodes hang under a hidden anchor — agents must never mention or target a root id.
When the user asks to build a map (e.g. 「输出红楼梦人物关系图」):
1. mindmap_create_canvas({ title: "红楼梦人物关系" }) — ALWAYS creates a NEW canvas id (never overwrites an old one). Opens the right tab on that new canvas.
2. mindmap_get_spatial_context — orient on the NEW empty canvas
3. mindmap_apply_direct({ ops: [ {type:"batch_create", nodes:[...]}, {type:"auto_fit"} ] })
4. Keep adding in small batches; never dump a full tree JSON overwrite
If the user asks for another topic later: call mindmap_create_canvas again with the new title. Do NOT reuse the previous canvas unless they explicitly say to continue the same map.
mindmap_list_canvases shows all canvases; mindmap_open_canvas switches the edit target.
Hierarchy vs relation:
- omit parentId = top-level theme/person (siblings look unrelated, which is intended)
- parentId = nest under a visible node
- link kind:"relation" = cross links (人物关系、姻亲等)
- create_node.relations / batch_create[].relations = outgoing graph edges in the same op (not URL bookmarks; those are links[])
Layout:
- Default positions come from auto layout.
- After large batch_create, include {type:"auto_fit"} so the camera frames the map.
- To place manually / avoid overwrite: set_pinned + pos, or create_node with pos (auto-pins).
Propose/accept:
- mindmap_propose validates ops first; then mindmap_accept_proposal({proposalId}).
- Apply failures return error invalid_op (proposal stays pending) — not a fake proposal_stale.
- proposal_stale only means rev drifted > 50 or proposal already finished / missing after restart.
Other tools:
- mindmap_list_canvases / mindmap_open_canvas / mindmap_delete_canvas
- Query/find/highlight also auto-open the 思维导图 tab on the bound canvas (same as create/open)
- mindmap_find — searchScope text|note|tags|description|edge_label|edge_note|all; if ambiguous, ask then mindmap_focus / mindmap_highlight with quickActions[i].args
- mindmap_find_and_focus — unique hit → focus + highlight flash (edges → highlight endpoints)
- mindmap_query — graph Q&A: neighbors|path|stats|orphans|missing_meta (deterministic; cite returned nodes)
- mindmap_get_node / mindmap_get_edge — details for follow-up
- mindmap_highlight — flash path (nodeIds + edgeIds + optional ttlMs/style)
- mindmap_export — format json|md|svg (png→svg for agents; UI has raster PNG)
- mindmap_focus, mindmap_undo, mindmap_redo, mindmap_history
- mindmap_snapshot / mindmap_restore_snapshot — named checkpoints
- mindmap_apply_direct({ preview:true }) — dry-run layout + overlaps, no persist
- mindmap_get_subgraph, mindmap_get_schema
- get_subgraph: edgeKinds hierarchy (default tree) | relation | all — use relation/all for 关系网络 around a leaf
Graph Q&A workflow:
1. Resolve entities with mindmap_find or @alias (edge labels: searchScope edge_label)
2. Call mindmap_query (never guess paths/relations from memory)
3. If empty/message says no match → answer "图中没有相关信息" — do NOT invent
4. Answer with node names + paths[].edges (label/weight/note); mention citedNodeIds
5. Follow-up details → mindmap_get_node / mindmap_get_edge
6. Visualize → mindmap_highlight({ nodeIds, edgeIds }) after path/neighbors
Examples:
mindmap_find({ query:"高置信", searchScope:"edge_label" })
mindmap_query({ op:"neighbors", node:"@aiops", direction:"out", depth:1 })
mindmap_query({ op:"path", from:"@detect", to:"@knowledge_write", throughRelationNodes:true, maxHops:5, edgeKinds:"relation" })
mindmap_query({ op:"path", from:"@detect", to:"@aiops", throughRelationNodes:false }) // direct edge only
mindmap_query({ op:"stats" }) // connectedComponents + longestPath/mostConnected
mindmap_query({ op:"stats", edgeKinds:"relation" }) // diameter on relation edges only
mindmap_query({ op:"missing_meta" })
mindmap_query({ op:"orphans" })
mindmap_highlight({ nodeIds:["@detect","@aiops"], edgeIds:["e_…"], ttlMs:5000 })
Node refs (P0 — prefer these over raw ids):
{"type":"create_node","text":"AIOps","alias":"aiops"}
{"type":"create_node","text":"重启","parentId":"@aiops"}
{"type":"link","from":"@aiops","to":"重启","kind":"relation","label":"诊断→重启"}
{"type":"link","fromText":"AIOps","toText":"重启","kind":"relation"}
{"type":"batch_link","edges":[{"from":"@daiyu","to":"@baoyu","kind":"relation","label":"知己"},{"from":"@baoyu","to":"@baochai","kind":"relation","label":"金玉良缘"}]}
apply_direct returns nodeIdMap: { "AIOps":"n_…", "@aiops":"n_…" }
Node/edge meta extras:
- update_node_meta patch: description (JSON object; put a short "function" field for Q&A), accentColor, icon, alias, note
- update_edge_meta patch: weight 1-5, lineStyle solid|dashed|dotted, direction forward|both|none
- Prefer batch_create for trees; relations[] for inline edges; batch_link for bulk edges later; auto_fit after big builds
Rules:
1. Always create or open a canvas BEFORE apply/propose.
2. Never invent node ids; use find or create_node results. Never ask for / use a root node id — there is no visible root.
3. Prefer batch_create (or many small ops) over one giant rewrite of unrelated topics.
4. Always use type, never op.
5. Top-level siblings: omit parentId on each create_node.
6. For questions about the map, use mindmap_query first; never fabricate graph facts.`,
}
}

View file

@ -0,0 +1,43 @@
{
"name": "dsh-mind-map",
"version": "0.1.0",
"description": "AI 双向无限画布:DSH 右侧思维导图 Tab;对话用 DSH。",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./lib/index.js",
"./client": "./lib/client.js",
"./package.json": "./package.json"
},
"files": [
"lib",
"cordis.patch.yml",
"README.md"
],
"scripts": {
"test": "node --test test/*.test.js"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"platform": "web",
"immediately": true,
"inject": [
"@deepseek-ai/dsh-client-ui-settings-plugins",
"@deepseek-ai/dsh-client-locale"
]
}
},
"keywords": [
"dsh-plugin",
"dsh",
"mind-map",
"canvas"
],
"engines": {
"node": ">=22"
},
"license": "MIT"
}

View file

@ -0,0 +1,25 @@
{
"name": "@mind-map/graph-core",
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"@mind-map/shared": "workspace:*"
},
"devDependencies": {
"typescript": "^5.8.2",
"vitest": "^3.0.9"
}
}

View file

@ -0,0 +1,242 @@
import { describe, expect, it } from "vitest";
import { applyOps, createEmptyGraph, GraphError } from "./index.js";
describe("graph-core", () => {
it("creates empty graph with anchor", () => {
const g = createEmptyGraph("Demo");
expect(g.nodes.size).toBe(1);
expect(g.canvas.focusNodeId).toBeTruthy();
});
it("creates child and rejects hierarchy cycle", () => {
const g = createEmptyGraph("Root");
const rootId = g.canvas.focusNodeId!;
const r1 = applyOps(g, [
{ type: "create_node", text: "A", parentId: rootId, id: "n_a" },
]);
const r2 = applyOps(r1.graph, [
{ type: "create_node", text: "B", parentId: "n_a", id: "n_b" },
]);
expect(() =>
applyOps(r2.graph, [
{ type: "link", from: "n_b", to: rootId, kind: "hierarchy" },
]),
).toThrow(GraphError);
});
it("update_text is invertible", () => {
const g = createEmptyGraph("Root");
const id = g.canvas.focusNodeId!;
const r = applyOps(g, [{ type: "update_text", nodeId: id, text: "Hello" }]);
expect(r.graph.nodes.get(id)!.text).toBe("Hello");
const back = applyOps(r.graph, r.inverseOps);
expect(back.graph.nodes.get(id)!.text).toBe("Root");
});
it("create_node inverse deletes the node (undo-safe)", () => {
const g = createEmptyGraph("Root");
const r = applyOps(g, [
{ type: "create_node", text: "凤霞", id: "n_fx" },
]);
expect(r.graph.nodes.get("n_fx")?.deletedAt ?? null).toBeNull();
const back = applyOps(r.graph, r.inverseOps);
expect(back.graph.nodes.get("n_fx")!.deletedAt).toBeTruthy();
});
it("reorder is invertible and clears pin", () => {
const g = createEmptyGraph("Root");
const rootId = g.canvas.focusNodeId!;
let r = applyOps(g, [
{ type: "create_node", text: "A", parentId: rootId, id: "n_a" },
{ type: "create_node", text: "B", parentId: rootId, id: "n_b" },
]);
r = applyOps(r.graph, [
{
type: "set_pinned",
nodeId: "n_b",
pinned: true,
pos: { x: 100, y: 50 },
},
]);
const ordered = applyOps(r.graph, [
{ type: "reorder", nodeId: "n_b", order: -1 },
]);
expect(ordered.graph.nodes.get("n_b")!.pinned).toBe(false);
const edge = [...ordered.graph.edges.values()].find(
(e) => e.to === "n_b" && e.isPrimaryParent && !e.deletedAt,
);
expect(edge!.order).toBe(-1);
const back = applyOps(ordered.graph, ordered.inverseOps);
expect(back.graph.nodes.get("n_b")!.pinned).toBe(true);
});
it("batch_create two-pass parents/relations and inverse", () => {
const g = createEmptyGraph("Root");
const r = applyOps(g, [
{
type: "batch_create",
nodes: [
{ text: "数通", alias: "datacom", id: "n_dc" },
{
text: "AIOps",
alias: "aiops",
id: "n_ai",
parentId: "n_dc",
description: { function: "诊断" },
relations: [
{ to: "n_rs", kind: "relation", label: "触发" },
],
},
{ text: "重启", alias: "restart", id: "n_rs", parentId: "n_dc" },
],
},
]);
expect(r.graph.nodes.get("n_ai")!.description?.function).toBe("诊断");
const hier = [...r.graph.edges.values()].filter(
(e) => e.kind === "hierarchy" && !e.deletedAt && e.from === "n_dc",
);
expect(hier.length).toBe(2);
const rel = [...r.graph.edges.values()].find(
(e) =>
e.kind === "relation" &&
e.from === "n_ai" &&
e.to === "n_rs" &&
!e.deletedAt,
);
expect(rel?.label).toBe("触发");
const back = applyOps(r.graph, r.inverseOps);
expect(back.graph.nodes.get("n_ai")!.deletedAt).toBeTruthy();
expect(back.graph.nodes.get("n_dc")!.deletedAt).toBeTruthy();
});
it("batch_link creates multiple relation edges", () => {
const g = createEmptyGraph("Root");
const seeded = applyOps(g, [
{ type: "create_node", text: "A", id: "n_a", alias: "a" },
{ type: "create_node", text: "B", id: "n_b", alias: "b" },
{ type: "create_node", text: "C", id: "n_c", alias: "c" },
]);
const r = applyOps(seeded.graph, [
{
type: "batch_link",
edges: [
{ from: "n_a", to: "n_b", kind: "relation", label: "ab" },
{ from: "n_a", to: "n_c", kind: "relation", label: "ac" },
],
},
]);
const rels = [...r.graph.edges.values()].filter(
(e) => e.kind === "relation" && !e.deletedAt,
);
expect(rels).toHaveLength(2);
expect(rels.map((e) => e.label).sort()).toEqual(["ab", "ac"]);
const back = applyOps(r.graph, r.inverseOps);
const left = [...back.graph.edges.values()].filter(
(e) => e.kind === "relation" && !e.deletedAt,
);
expect(left).toHaveLength(0);
});
it("auto_fit marks padding for host", () => {
const g = createEmptyGraph("Root");
const r = applyOps(g, [{ type: "auto_fit", padding: 80 }]);
expect(r.autoFitPadding).toBe(80);
expect(r.inverseOps[0]?.type).toBe("set_viewport");
});
it("delete_node + restore_node restores leaf and parent edge", () => {
const g = createEmptyGraph("Root");
const rootId = g.canvas.focusNodeId!;
let r = applyOps(g, [
{ type: "create_node", text: "贾母", id: "n_jm", parentId: rootId },
{ type: "create_node", text: "黛玉", id: "n_dy", parentId: "n_jm" },
{
type: "link",
from: "n_dy",
to: "n_jm",
kind: "relation",
label: "外孙女",
},
]);
r = applyOps(r.graph, [{ type: "delete_node", nodeId: "n_dy" }]);
expect(r.graph.nodes.get("n_dy")!.deletedAt).toBeTruthy();
expect(
[...r.graph.edges.values()].filter(
(e) =>
!e.deletedAt &&
(e.from === "n_dy" || e.to === "n_dy"),
),
).toHaveLength(0);
const restored = applyOps(r.graph, [
{ type: "restore_node", nodeId: "n_dy" },
]);
expect(restored.graph.nodes.get("n_dy")!.deletedAt).toBeNull();
const parentEdge = [...restored.graph.edges.values()].find(
(e) =>
!e.deletedAt &&
e.kind === "hierarchy" &&
e.isPrimaryParent &&
e.from === "n_jm" &&
e.to === "n_dy",
);
expect(parentEdge).toBeTruthy();
const rel = [...restored.graph.edges.values()].find(
(e) =>
!e.deletedAt &&
e.kind === "relation" &&
e.from === "n_dy" &&
e.to === "n_jm",
);
expect(rel?.label).toBe("外孙女");
});
it("delete_node undo restores via restore_node without duplicate edges", () => {
const g = createEmptyGraph("Root");
const rootId = g.canvas.focusNodeId!;
let r = applyOps(g, [
{ type: "create_node", text: "A", id: "n_a", parentId: rootId },
{ type: "create_node", text: "B", id: "n_b", parentId: "n_a" },
]);
const beforeEdges = [...r.graph.edges.values()].filter((e) => !e.deletedAt)
.length;
const deleted = applyOps(r.graph, [{ type: "delete_node", nodeId: "n_a" }]);
// B promoted under root
const promo = [...deleted.graph.edges.values()].find(
(e) =>
!e.deletedAt &&
e.kind === "hierarchy" &&
e.isPrimaryParent &&
e.to === "n_b",
);
expect(promo?.from).toBe(rootId);
const undone = applyOps(deleted.graph, deleted.inverseOps);
expect(undone.graph.nodes.get("n_a")!.deletedAt).toBeNull();
const aParent = [...undone.graph.edges.values()].find(
(e) =>
!e.deletedAt &&
e.kind === "hierarchy" &&
e.isPrimaryParent &&
e.to === "n_a",
);
expect(aParent?.from).toBe(rootId);
const bParent = [...undone.graph.edges.values()].find(
(e) =>
!e.deletedAt &&
e.kind === "hierarchy" &&
e.isPrimaryParent &&
e.to === "n_b",
);
expect(bParent?.from).toBe("n_a");
const liveHier = [...undone.graph.edges.values()].filter(
(e) => !e.deletedAt && e.kind === "hierarchy" && e.isPrimaryParent,
);
// root→a, a→b (plus possibly root title edges depending on create)
expect(liveHier.filter((e) => e.to === "n_b")).toHaveLength(1);
expect(liveHier.filter((e) => e.to === "n_a")).toHaveLength(1);
expect(
[...undone.graph.edges.values()].filter((e) => !e.deletedAt).length,
).toBeGreaterThanOrEqual(beforeEdges);
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": true
},
"include": ["src/**/*"],
"references": [{ "path": "../shared" }]
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,27 @@
{
"name": "@mind-map/graph-store",
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"@mind-map/graph-core": "workspace:*",
"@mind-map/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.8.2",
"vitest": "^3.0.9"
}
}

View file

@ -0,0 +1,28 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { SqliteGraphStore } from "./index.js";
describe("SqliteGraphStore undo", () => {
it("undoes latest create without throwing", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-undo-"));
const store = new SqliteGraphStore(path.join(dir, "g.sqlite"));
const g = store.createCanvas("undo-test");
const canvasId = g.canvas.id;
const applied = store.applyChangeSet(
canvasId,
[{ type: "create_node", text: "临时节点", id: "n_tmp" }],
{ origin: "user", summary: "create" },
);
expect(
store.getSnapshot(canvasId).nodes.some((n) => n.id === "n_tmp" && !n.deletedAt),
).toBe(true);
const undone = store.undoChangeSet(applied.changeSetId);
expect(undone.rev).toBeGreaterThan(applied.rev);
const tmp = store.getSnapshot(canvasId).nodes.find((n) => n.id === "n_tmp");
// Soft-deleted or fully absent both mean undo removed it from the live graph.
expect(!tmp || tmp.deletedAt).toBe(true);
expect(store.latestUndoableChangeSetId(canvasId)).toBe(undone.changeSetId);
});
});

View file

@ -0,0 +1,841 @@
import { DatabaseSync } from "node:sqlite";
import fs from "node:fs";
import path from "node:path";
import {
applyOps,
cloneGraph,
createEmptyGraph,
resolveAnchorNodeId,
toSnapshot,
type MutableGraph,
} from "@mind-map/graph-core";
import type {
CanvasMeta,
MindEdge,
MindNode,
Op,
} from "@mind-map/shared";
import { newId, now } from "@mind-map/shared";
const SCHEMA = `
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS canvases (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
focus_node_id TEXT,
viewport_json TEXT NOT NULL,
prefs_json TEXT NOT NULL,
rev INTEGER NOT NULL DEFAULT 0,
agent_mode TEXT NOT NULL DEFAULT 'edit',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
canvas_id TEXT NOT NULL,
text TEXT NOT NULL,
note TEXT,
collapsed INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
pos_json TEXT,
side_pref INTEGER NOT NULL DEFAULT 0,
style_preset TEXT NOT NULL DEFAULT 'default',
version INTEGER NOT NULL DEFAULT 1,
tags_json TEXT NOT NULL DEFAULT '[]',
links_json TEXT NOT NULL DEFAULT '[]',
image_url TEXT,
due_at INTEGER,
start_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE TABLE IF NOT EXISTS edges (
id TEXT PRIMARY KEY,
canvas_id TEXT NOT NULL,
"from" TEXT NOT NULL,
"to" TEXT NOT NULL,
kind TEXT NOT NULL,
is_primary_parent INTEGER NOT NULL DEFAULT 0,
"order" REAL NOT NULL DEFAULT 0,
label TEXT,
tags_json TEXT NOT NULL DEFAULT '[]',
note TEXT,
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER
);
CREATE TABLE IF NOT EXISTS events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
canvas_id TEXT NOT NULL,
change_set_id TEXT NOT NULL,
op_json TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS change_sets (
id TEXT PRIMARY KEY,
canvas_id TEXT NOT NULL,
base_rev INTEGER NOT NULL,
origin TEXT NOT NULL,
summary TEXT NOT NULL,
inverse_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
undone_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_nodes_canvas ON nodes(canvas_id);
CREATE INDEX IF NOT EXISTS idx_edges_canvas ON edges(canvas_id);
CREATE INDEX IF NOT EXISTS idx_events_canvas ON events(canvas_id);
`;
function rowNode(r: Record<string, unknown>): MindNode {
return {
id: String(r.id),
canvasId: String(r.canvas_id),
text: String(r.text),
note: r.note != null ? String(r.note) : undefined,
collapsed: Boolean(r.collapsed),
pinned: Boolean(r.pinned),
pos: r.pos_json ? (JSON.parse(String(r.pos_json)) as { x: number; y: number }) : null,
sidePref: Number(r.side_pref) as -1 | 0 | 1,
stylePreset: String(r.style_preset) as MindNode["stylePreset"],
version: Number(r.version),
tags: JSON.parse(String(r.tags_json ?? "[]")) as string[],
links: JSON.parse(String(r.links_json ?? "[]")) as MindNode["links"],
imageUrl: r.image_url != null ? String(r.image_url) : undefined,
dueAt: r.due_at != null ? Number(r.due_at) : undefined,
startAt: r.start_at != null ? Number(r.start_at) : undefined,
accentColor:
r.accent_color != null ? (String(r.accent_color) as MindNode["accentColor"]) : undefined,
icon: r.icon != null ? (String(r.icon) as MindNode["icon"]) : undefined,
alias: r.alias != null ? String(r.alias) : undefined,
description: r.description_json
? (JSON.parse(String(r.description_json)) as MindNode["description"])
: undefined,
createdAt: Number(r.created_at),
updatedAt: Number(r.updated_at),
deletedAt: r.deleted_at != null ? Number(r.deleted_at) : null,
};
}
function rowEdge(r: Record<string, unknown>): MindEdge {
return {
id: String(r.id),
canvasId: String(r.canvas_id),
from: String(r.from),
to: String(r.to),
kind: String(r.kind) as MindEdge["kind"],
isPrimaryParent: Boolean(r.is_primary_parent),
order: Number(r.order),
label: r.label != null ? String(r.label) : undefined,
tags: JSON.parse(String(r.tags_json ?? "[]")) as string[],
note: r.note != null ? String(r.note) : undefined,
weight: r.weight != null ? Number(r.weight) : undefined,
lineStyle:
r.line_style != null ? (String(r.line_style) as MindEdge["lineStyle"]) : undefined,
direction:
r.direction != null ? (String(r.direction) as MindEdge["direction"]) : undefined,
version: Number(r.version),
createdAt: Number(r.created_at),
updatedAt: Number(r.updated_at),
deletedAt: r.deleted_at != null ? Number(r.deleted_at) : null,
};
}
export class SqliteGraphStore {
readonly db: DatabaseSync;
private cache = new Map<string, MutableGraph>();
constructor(dbPath: string) {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
this.db = new DatabaseSync(dbPath);
this.db.exec("PRAGMA journal_mode = WAL;");
this.db.exec("PRAGMA foreign_keys = ON;");
this.db.exec(SCHEMA);
this.migrateSchema();
}
private migrateSchema() {
const verRow = this.db.prepare("SELECT value FROM meta WHERE key = ?").get("schema_version") as
| { value: string }
| undefined;
let ver = verRow ? Number(verRow.value) : 0;
if (!verRow) {
this.db.prepare("INSERT INTO meta(key, value) VALUES(?, ?)").run("schema_version", "1");
ver = 1;
}
const tableInfo = (table: string) =>
(this.db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map(
(c) => c.name,
);
const ensureCol = (table: string, col: string, ddl: string) => {
if (!tableInfo(table).includes(col)) {
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
}
};
if (ver < 2) {
ensureCol("nodes", "links_json", `links_json TEXT NOT NULL DEFAULT '[]'`);
ensureCol("nodes", "image_url", `image_url TEXT`);
ensureCol("nodes", "due_at", `due_at INTEGER`);
ensureCol("nodes", "start_at", `start_at INTEGER`);
ensureCol("edges", "tags_json", `tags_json TEXT NOT NULL DEFAULT '[]'`);
ensureCol("edges", "note", `note TEXT`);
this.db
.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
.run("schema_version", "2");
ver = 2;
}
if (ver < 3) {
ensureCol("nodes", "accent_color", `accent_color TEXT`);
ensureCol("nodes", "icon", `icon TEXT`);
ensureCol("edges", "weight", `weight REAL`);
ensureCol("edges", "line_style", `line_style TEXT`);
this.db
.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
.run("schema_version", "3");
ver = 3;
}
if (ver < 4) {
ensureCol("nodes", "alias", `alias TEXT`);
this.db
.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
.run("schema_version", "4");
ver = 4;
}
if (ver < 5) {
ensureCol("edges", "direction", `direction TEXT`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS snapshots (
id TEXT PRIMARY KEY,
canvas_id TEXT NOT NULL,
name TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_snapshots_canvas ON snapshots(canvas_id);
`);
this.db
.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
.run("schema_version", "5");
ver = 5;
}
if (ver < 6) {
ensureCol("nodes", "description_json", `description_json TEXT`);
this.db
.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
.run("schema_version", "6");
}
}
close(): void {
this.db.close();
}
listCanvases(): CanvasMeta[] {
const rows = this.db
.prepare(
`SELECT * FROM canvases WHERE deleted_at IS NULL ORDER BY updated_at DESC`,
)
.all() as Record<string, unknown>[];
return rows.map((r) => this.rowCanvas(r));
}
private rowCanvas(r: Record<string, unknown>): CanvasMeta {
const rawPrefs = JSON.parse(String(r.prefs_json || "{}")) as Record<string, unknown>;
const anchorNodeId =
rawPrefs.anchorNodeId != null ? String(rawPrefs.anchorNodeId) : null;
const prefs = {
themeId: "abyss",
branchColoring: true,
density: "comfortable",
riskProfile: "fast",
showRelationEdges: true,
nodeBadges: "icons",
showHoverCard: true,
inspectorMode: "selection",
nodeViewMode: "card",
...rawPrefs,
} as CanvasMeta["prefs"];
delete (prefs as Record<string, unknown>).anchorNodeId;
return {
id: String(r.id),
title: String(r.title),
focusNodeId: r.focus_node_id != null ? String(r.focus_node_id) : null,
anchorNodeId,
viewport: JSON.parse(String(r.viewport_json)),
prefs,
rev: Number(r.rev),
agentMode: String(r.agent_mode) as CanvasMeta["agentMode"],
createdAt: Number(r.created_at),
updatedAt: Number(r.updated_at),
deletedAt: r.deleted_at != null ? Number(r.deleted_at) : null,
};
}
createCanvas(title?: string, rootText?: string): MutableGraph {
const g = createEmptyGraph(title ?? "未命名画布", rootText);
this.persistGraph(g, true);
this.cache.set(g.canvas.id, g);
return g;
}
loadCanvas(canvasId: string): MutableGraph {
const cached = this.cache.get(canvasId);
if (cached) return cloneGraph(cached);
const crow = this.db.prepare(`SELECT * FROM canvases WHERE id = ?`).get(canvasId) as
| Record<string, unknown>
| undefined;
if (!crow || crow.deleted_at != null) throw new Error(`canvas not found: ${canvasId}`);
const nodes = (
this.db.prepare(`SELECT * FROM nodes WHERE canvas_id = ?`).all(canvasId) as Record<
string,
unknown
>[]
).map(rowNode);
const edges = (
this.db.prepare(`SELECT * FROM edges WHERE canvas_id = ?`).all(canvasId) as Record<
string,
unknown
>[]
).map(rowEdge);
const g: MutableGraph = {
canvas: this.rowCanvas(crow),
nodes: new Map(nodes.map((n) => [n.id, n])),
edges: new Map(edges.map((e) => [e.id, e])),
};
if (!g.canvas.anchorNodeId) {
resolveAnchorNodeId(g);
}
this.cache.set(canvasId, g);
return cloneGraph(g);
}
getSnapshot(canvasId: string) {
return toSnapshot(this.loadCanvas(canvasId));
}
applyChangeSet(
canvasId: string,
ops: Op[],
opts: { origin: "user" | "ai"; summary?: string },
) {
const g = this.loadCanvas(canvasId);
const baseRev = g.canvas.rev;
const result = applyOps(g, ops);
result.graph.canvas.rev = baseRev + 1;
const changeSetId = newId("cs");
this.db.exec("BEGIN");
try {
this.persistGraph(result.graph, false);
this.db
.prepare(
`INSERT INTO change_sets(id, canvas_id, base_rev, origin, summary, inverse_json, created_at)
VALUES(?,?,?,?,?,?,?)`,
)
.run(
changeSetId,
canvasId,
baseRev,
opts.origin,
opts.summary ?? `${ops.length} ops`,
JSON.stringify(result.inverseOps),
now(),
);
const ins = this.db.prepare(
`INSERT INTO events(canvas_id, change_set_id, op_json, created_at) VALUES(?,?,?,?)`,
);
for (const op of ops) {
ins.run(canvasId, changeSetId, JSON.stringify(op), now());
}
this.db.exec("COMMIT");
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
this.cache.set(canvasId, result.graph);
return {
changeSetId,
rev: result.graph.canvas.rev,
snapshot: toSnapshot(result.graph),
affectedNodeIds: result.affectedNodeIds,
inverseOps: result.inverseOps,
autoFitPadding: result.autoFitPadding,
};
}
undoChangeSet(changeSetId: string) {
const row = this.db
.prepare(`SELECT * FROM change_sets WHERE id = ?`)
.get(changeSetId) as Record<string, unknown> | undefined;
if (!row) throw new Error("changeset not found");
if (row.undone_at != null) throw new Error("already undone");
const canvasId = String(row.canvas_id);
const inverse = JSON.parse(String(row.inverse_json)) as Op[];
// Undo must not fail on stale expectedVersion from the forward pass.
const cleaned = inverse.map((op) => {
if (!op || typeof op !== "object") return op;
const { expectedVersion: _ev, ...rest } = op as Op & {
expectedVersion?: number;
};
return rest as Op;
});
const result = this.applyChangeSet(canvasId, cleaned, {
origin: "user",
summary: `undo ${changeSetId}`,
});
this.db
.prepare(`UPDATE change_sets SET undone_at = ? WHERE id = ?`)
.run(now(), changeSetId);
return result;
}
/** Most recent change set that can still be undone (for mindmap_undo without id). */
latestUndoableChangeSetId(canvasId: string): string | null {
const row = this.db
.prepare(
`SELECT id FROM change_sets
WHERE canvas_id = ? AND undone_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
)
.get(canvasId) as { id: string } | undefined;
return row?.id ?? null;
}
/** Latest undo companion (summary starts with "undo ") — redo by undoing it. */
latestRedoableChangeSetId(canvasId: string): string | null {
const row = this.db
.prepare(
`SELECT id, summary FROM change_sets
WHERE canvas_id = ? AND undone_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
)
.get(canvasId) as { id: string; summary: string } | undefined;
if (!row) return null;
if (!String(row.summary).startsWith("undo ")) return null;
return row.id;
}
listChangeSets(canvasId: string, limit = 30) {
const rows = this.db
.prepare(
`SELECT id, base_rev, origin, summary, created_at, undone_at
FROM change_sets WHERE canvas_id = ?
ORDER BY created_at DESC LIMIT ?`,
)
.all(canvasId, limit) as Record<string, unknown>[];
return rows.map((r) => ({
id: String(r.id),
baseRev: Number(r.base_rev),
origin: String(r.origin),
summary: String(r.summary),
createdAt: Number(r.created_at),
undone: r.undone_at != null,
isUndoCompanion: String(r.summary).startsWith("undo "),
}));
}
createSnapshot(canvasId: string, name: string) {
const snap = this.getSnapshot(canvasId);
const id = newId("snap");
const t = now();
this.db
.prepare(
`INSERT INTO snapshots(id, canvas_id, name, snapshot_json, created_at) VALUES(?,?,?,?,?)`,
)
.run(id, canvasId, name || `snapshot-${t}`, JSON.stringify(snap), t);
return { id, name: name || `snapshot-${t}`, createdAt: t };
}
listSnapshots(canvasId: string, limit = 20) {
const rows = this.db
.prepare(
`SELECT id, name, created_at FROM snapshots WHERE canvas_id = ?
ORDER BY created_at DESC LIMIT ?`,
)
.all(canvasId, limit) as Record<string, unknown>[];
return rows.map((r) => ({
id: String(r.id),
name: String(r.name),
createdAt: Number(r.created_at),
}));
}
restoreSnapshot(snapshotId: string) {
const row = this.db
.prepare(`SELECT * FROM snapshots WHERE id = ?`)
.get(snapshotId) as Record<string, unknown> | undefined;
if (!row) throw new Error("snapshot not found");
const canvasId = String(row.canvas_id);
const snap = JSON.parse(String(row.snapshot_json)) as {
canvas: CanvasMeta;
nodes: MindNode[];
edges: MindEdge[];
};
const g = this.loadCanvas(canvasId);
// Soft-delete current live nodes/edges then restore snapshot rows.
const t = now();
for (const n of g.nodes.values()) {
if (!n.deletedAt) {
n.deletedAt = t;
n.version += 1;
n.updatedAt = t;
}
}
for (const e of g.edges.values()) {
if (!e.deletedAt) {
e.deletedAt = t;
e.version += 1;
e.updatedAt = t;
}
}
for (const n of snap.nodes) {
g.nodes.set(n.id, {
...n,
canvasId,
deletedAt: n.deletedAt ?? null,
updatedAt: t,
});
}
for (const e of snap.edges) {
g.edges.set(e.id, {
...e,
canvasId,
deletedAt: e.deletedAt ?? null,
updatedAt: t,
});
}
g.canvas.title = snap.canvas.title;
g.canvas.focusNodeId = snap.canvas.focusNodeId;
g.canvas.anchorNodeId = snap.canvas.anchorNodeId;
g.canvas.prefs = { ...g.canvas.prefs, ...snap.canvas.prefs };
g.canvas.viewport = { ...snap.canvas.viewport };
g.canvas.rev += 1;
g.canvas.updatedAt = t;
const changeSetId = newId("cs");
this.db.exec("BEGIN");
try {
this.persistGraph(g, false);
this.db
.prepare(
`INSERT INTO change_sets(id, canvas_id, base_rev, origin, summary, inverse_json, created_at)
VALUES(?,?,?,?,?,?,?)`,
)
.run(
changeSetId,
canvasId,
g.canvas.rev - 1,
"user",
`restore snapshot ${snapshotId}`,
JSON.stringify([]),
t,
);
this.db.exec("COMMIT");
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
this.cache.set(canvasId, g);
return {
changeSetId,
rev: g.canvas.rev,
snapshot: toSnapshot(g),
affectedNodeIds: [...g.nodes.keys()],
};
}
search(
canvasId: string,
query: string,
limit = 20,
scope:
| "text"
| "note"
| "tags"
| "description"
| "edge_label"
| "edge_note"
| "all" = "text",
) {
const q = query.trim().toLowerCase();
if (!q) return [];
const g = this.loadCanvas(canvasId);
type NodeHit = {
kind: "node";
id: string;
text: string;
score: number;
matchIn: ("text" | "note" | "tags" | "description")[];
};
type EdgeHit = {
kind: "edge";
id: string;
from: string;
to: string;
fromText: string;
toText: string;
label?: string;
note?: string;
score: number;
matchIn: ("edge_label" | "edge_note")[];
};
const hits: (NodeHit | EdgeHit)[] = [];
const wantNodes =
scope === "text" ||
scope === "note" ||
scope === "tags" ||
scope === "description" ||
scope === "all";
const wantEdgeLabel = scope === "edge_label" || scope === "all";
const wantEdgeNote = scope === "edge_note" || scope === "all";
if (wantNodes) {
for (const n of g.nodes.values()) {
if (n.deletedAt) continue;
const matchIn: ("text" | "note" | "tags" | "description")[] = [];
const t = n.text.toLowerCase();
const note = (n.note ?? "").toLowerCase();
const tags = (n.tags ?? []).map((x) => x.toLowerCase());
const desc = n.description
? JSON.stringify(n.description).toLowerCase()
: "";
const wantText = scope === "text" || scope === "all";
const wantNote = scope === "note" || scope === "all";
const wantTags = scope === "tags" || scope === "all";
const wantDesc = scope === "description" || scope === "all";
if (wantText && t.includes(q)) matchIn.push("text");
if (wantNote && note.includes(q)) matchIn.push("note");
if (wantTags && tags.some((tag) => tag.includes(q))) matchIn.push("tags");
if (wantDesc && desc.includes(q)) matchIn.push("description");
if (!matchIn.length) continue;
let score = 0.4;
if (matchIn.includes("text")) score = t.startsWith(q) ? 1 : 0.75;
else if (matchIn.includes("tags")) score = 0.55;
else if (matchIn.includes("description")) score = 0.5;
else score = 0.45;
hits.push({ kind: "node", id: n.id, text: n.text, score, matchIn });
}
}
if (wantEdgeLabel || wantEdgeNote) {
for (const e of g.edges.values()) {
if (e.deletedAt) continue;
const matchIn: ("edge_label" | "edge_note")[] = [];
const label = (e.label ?? "").toLowerCase();
const note = (e.note ?? "").toLowerCase();
if (wantEdgeLabel && label.includes(q)) matchIn.push("edge_label");
if (wantEdgeNote && note.includes(q)) matchIn.push("edge_note");
if (!matchIn.length) continue;
const fromN = g.nodes.get(e.from);
const toN = g.nodes.get(e.to);
let score = 0.5;
if (matchIn.includes("edge_label")) {
score = label.startsWith(q) ? 0.95 : 0.7;
} else {
score = 0.55;
}
hits.push({
kind: "edge",
id: e.id,
from: e.from,
to: e.to,
fromText: fromN?.text ?? e.from,
toText: toN?.text ?? e.to,
...(e.label ? { label: e.label } : {}),
...(e.note ? { note: e.note } : {}),
score,
matchIn,
});
}
}
return hits.sort((a, b) => b.score - a.score).slice(0, limit);
}
renameCanvas(canvasId: string, title: string) {
const g = this.loadCanvas(canvasId);
g.canvas.title = title;
g.canvas.updatedAt = now();
this.db
.prepare(`UPDATE canvases SET title = ?, updated_at = ? WHERE id = ?`)
.run(title, g.canvas.updatedAt, canvasId);
this.cache.set(canvasId, g);
return g.canvas;
}
softDeleteCanvas(canvasId: string) {
const t = now();
this.db.prepare(`UPDATE canvases SET deleted_at = ?, updated_at = ? WHERE id = ?`).run(t, t, canvasId);
this.cache.delete(canvasId);
}
/** Permanent delete of a canvas and all related rows (P17). */
hardDeleteCanvas(canvasId: string) {
this.db.prepare(`DELETE FROM events WHERE canvas_id = ?`).run(canvasId);
this.db.prepare(`DELETE FROM change_sets WHERE canvas_id = ?`).run(canvasId);
this.db.prepare(`DELETE FROM edges WHERE canvas_id = ?`).run(canvasId);
this.db.prepare(`DELETE FROM nodes WHERE canvas_id = ?`).run(canvasId);
this.db.prepare(`DELETE FROM canvases WHERE id = ?`).run(canvasId);
this.cache.delete(canvasId);
}
listDeletedNodes(canvasId: string): MindNode[] {
const rows = this.db
.prepare(
`SELECT * FROM nodes WHERE canvas_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT 100`,
)
.all(canvasId) as Record<string, unknown>[];
return rows.map(rowNode);
}
restoreNode(canvasId: string, nodeId: string) {
const g = this.loadCanvas(canvasId);
const focus = g.canvas.focusNodeId;
if (!g.nodes.get(nodeId)) throw new Error("not found");
const ops: Op[] = [{ type: "restore_node", nodeId }];
if (focus && focus !== nodeId) {
ops.push({
type: "link",
from: focus,
to: nodeId,
kind: "hierarchy",
asPrimary: true,
});
}
return this.applyChangeSet(canvasId, ops, {
origin: "user",
summary: `restore ${nodeId}`,
});
}
updateViewport(canvasId: string, viewport: CanvasMeta["viewport"]) {
const g = this.loadCanvas(canvasId);
g.canvas.viewport = viewport;
g.canvas.updatedAt = now();
this.db
.prepare(`UPDATE canvases SET viewport_json = ?, updated_at = ? WHERE id = ?`)
.run(JSON.stringify(viewport), g.canvas.updatedAt, canvasId);
this.cache.set(canvasId, g);
}
private persistGraph(g: MutableGraph, isNew: boolean) {
const c = g.canvas;
if (!c.anchorNodeId) resolveAnchorNodeId(g);
const prefsJson = JSON.stringify({
...c.prefs,
anchorNodeId: c.anchorNodeId ?? null,
});
if (isNew) {
this.db
.prepare(
`INSERT INTO canvases(id, title, focus_node_id, viewport_json, prefs_json, rev, agent_mode, created_at, updated_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
)
.run(
c.id,
c.title,
c.focusNodeId,
JSON.stringify(c.viewport),
prefsJson,
c.rev,
c.agentMode,
c.createdAt,
c.updatedAt,
);
} else {
this.db
.prepare(
`UPDATE canvases SET title=?, focus_node_id=?, viewport_json=?, prefs_json=?, rev=?, agent_mode=?, updated_at=? WHERE id=?`,
)
.run(
c.title,
c.focusNodeId,
JSON.stringify(c.viewport),
prefsJson,
c.rev,
c.agentMode,
c.updatedAt,
c.id,
);
}
const upsertNode = this.db.prepare(
`INSERT INTO nodes(id, canvas_id, text, note, collapsed, pinned, pos_json, side_pref, style_preset, version, tags_json, links_json, image_url, due_at, start_at, accent_color, icon, alias, description_json, created_at, updated_at, deleted_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
text=excluded.text, note=excluded.note, collapsed=excluded.collapsed, pinned=excluded.pinned,
pos_json=excluded.pos_json, side_pref=excluded.side_pref, style_preset=excluded.style_preset,
version=excluded.version, tags_json=excluded.tags_json, links_json=excluded.links_json,
image_url=excluded.image_url, due_at=excluded.due_at, start_at=excluded.start_at,
accent_color=excluded.accent_color, icon=excluded.icon, alias=excluded.alias,
description_json=excluded.description_json,
updated_at=excluded.updated_at, deleted_at=excluded.deleted_at`,
);
for (const n of g.nodes.values()) {
upsertNode.run(
n.id,
n.canvasId,
n.text,
n.note ?? null,
n.collapsed ? 1 : 0,
n.pinned ? 1 : 0,
n.pos ? JSON.stringify(n.pos) : null,
n.sidePref,
n.stylePreset,
n.version,
JSON.stringify(n.tags ?? []),
JSON.stringify(n.links ?? []),
n.imageUrl ?? null,
n.dueAt ?? null,
n.startAt ?? null,
n.accentColor ?? null,
n.icon ?? null,
n.alias ?? null,
n.description ? JSON.stringify(n.description) : null,
n.createdAt,
n.updatedAt,
n.deletedAt ?? null,
);
}
const upsertEdge = this.db.prepare(
`INSERT INTO edges(id, canvas_id, "from", "to", kind, is_primary_parent, "order", label, tags_json, note, weight, line_style, direction, version, created_at, updated_at, deleted_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
"from"=excluded."from", "to"=excluded."to", kind=excluded.kind, is_primary_parent=excluded.is_primary_parent,
"order"=excluded."order", label=excluded.label, tags_json=excluded.tags_json, note=excluded.note,
weight=excluded.weight, line_style=excluded.line_style, direction=excluded.direction,
version=excluded.version, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at`,
);
for (const e of g.edges.values()) {
upsertEdge.run(
e.id,
e.canvasId,
e.from,
e.to,
e.kind,
e.isPrimaryParent ? 1 : 0,
e.order,
e.label ?? null,
JSON.stringify(e.tags ?? []),
e.note ?? null,
e.weight ?? null,
e.lineStyle ?? null,
e.direction ?? null,
e.version,
e.createdAt,
e.updatedAt,
e.deletedAt ?? null,
);
}
}
}

View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": true,
"types": ["node"]
},
"include": ["src/**/*"],
"references": [{ "path": "../shared" }, { "path": "../graph-core" }]
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,28 @@
{
"name": "@mind-map/layout-engine",
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
},
"dependencies": {
"@mind-map/shared": "workspace:*",
"d3-flextree": "^2.1.2",
"d3-hierarchy": "^3.1.2"
},
"devDependencies": {
"@types/d3-hierarchy": "^3.1.7",
"typescript": "^5.8.2",
"vitest": "^3.0.9"
}
}

View file

@ -0,0 +1,19 @@
declare module "d3-flextree" {
type Accessor<T, R> = ((node: T) => R) | R;
interface FlexLayout {
(root: unknown): unknown;
nodeSize(arg: Accessor<{ data: unknown }, [number, number]>): FlexLayout;
spacing(arg: Accessor<unknown, number> | ((a: unknown, b: unknown) => number)): FlexLayout;
children(arg: (d: unknown) => unknown[] | null | undefined): FlexLayout;
hierarchy(data: unknown, children?: (d: unknown) => unknown[] | null | undefined): {
each: (fn: (d: { depth: number; x: number; y: number; data: unknown }) => void) => void;
x: number;
y: number;
depth: number;
data: unknown;
};
}
export function flextree(options?: Record<string, unknown>): FlexLayout;
}

View file

@ -0,0 +1,236 @@
import { describe, expect, it } from "vitest";
import { hierarchyFingerprint, layout, nodeLayoutSize } from "./index.js";
import type { MindEdge, MindNode } from "@mind-map/shared";
function node(id: string, text: string, sidePref: -1 | 0 | 1 = 0): MindNode {
return {
id,
canvasId: "c",
text,
collapsed: false,
pinned: false,
sidePref,
stylePreset: "default",
version: 1,
tags: [],
links: [],
createdAt: 0,
updatedAt: 0,
};
}
function edge(from: string, to: string, order = 0): MindEdge {
return {
id: `e_${from}_${to}`,
canvasId: "c",
from,
to,
kind: "hierarchy",
isPrimaryParent: true,
order,
tags: [],
version: 1,
createdAt: 0,
updatedAt: 0,
};
}
describe("layout-engine", () => {
it("places focus at origin and children left/right", () => {
const nodes = [
node("root", "R"),
node("a", "A", -1),
node("b", "B", 1),
];
const edges = [edge("root", "a", 0), edge("root", "b", 1)];
const pos = layout({ nodes, edges, focusNodeId: "root" });
expect(pos.root).toEqual({ x: 0, y: 0 });
expect(pos.a!.x).toBeLessThan(0);
expect(pos.b!.x).toBeGreaterThan(0);
});
it("nested left subtree stays on negative x", () => {
const nodes = [
node("root", "R"),
node("a", "A", -1),
node("a1", "A1", 0),
node("b", "B", 1),
];
const edges = [
edge("root", "a", 0),
edge("a", "a1", 0),
edge("root", "b", 1),
];
const pos = layout({ nodes, edges, focusNodeId: "root" });
expect(pos.a!.x).toBeLessThan(0);
expect(pos.a1!.x).toBeLessThan(pos.a!.x);
expect(pos.b!.x).toBeGreaterThan(0);
});
it("respects pinned positions", () => {
const n = node("a", "A", 1);
n.pinned = true;
n.pos = { x: 999, y: -50 };
const nodes = [node("root", "R"), n];
const edges = [edge("root", "a", 0)];
const pos = layout({ nodes, edges, focusNodeId: "root" });
expect(pos.a).toEqual({ x: 999, y: -50 });
});
it("re-roots on focus so ancestors are not stacked as orphans", () => {
const nodes = [
node("root", "R"),
node("a", "A", -1),
node("a1", "A1"),
node("b", "B", 1),
];
const edges = [
edge("root", "a", 0),
edge("a", "a1", 0),
edge("root", "b", 1),
];
const pos = layout({ nodes, edges, focusNodeId: "a1" });
expect(pos.a1).toEqual({ x: 0, y: 0 });
// former parent `a` becomes outward neighbor — not at x=0 tower
expect(pos.a).toBeTruthy();
expect(Math.abs(pos.a!.x)).toBeGreaterThan(50);
// siblings of the path should also be placed off-center
expect(pos.root).toBeTruthy();
expect(pos.b).toBeTruthy();
expect(Math.abs(pos.b!.x) + Math.abs(pos.b!.y)).toBeGreaterThan(50);
});
it("does not overlap sibling boxes after separation", () => {
const kids = Array.from({ length: 10 }, (_, i) => node(`k${i}`, `很长的标题节点${i}`));
const nodes = [node("root", "中心"), ...kids];
const edges = kids.map((k, i) => edge("root", k.id, i));
const pos = layout({ nodes, edges, focusNodeId: "root" });
const box = { w: 200, h: 70 };
const pad = 12;
for (let i = 0; i < kids.length; i++) {
for (let j = i + 1; j < kids.length; j++) {
const a = pos[kids[i]!.id]!;
const b = pos[kids[j]!.id]!;
const ox = box.w + pad - Math.abs(b.x - a.x);
const oy = box.h + pad - Math.abs(b.y - a.y);
expect(ox <= 0 || oy <= 0).toBe(true);
}
}
});
it("hides primary descendants of collapsed nodes and re-packs siblings", () => {
const branch = node("core", "核心矛盾", -1);
branch.collapsed = true;
const nodes = [
node("root", "雷雨"),
branch,
node("c1", "周萍↔繁漪"),
node("c2", "矿工劳资矛盾"),
node("era", "时代背景", 1),
node("e1", "半封建半殖民地"),
];
const edges = [
edge("root", "core", 0),
edge("core", "c1", 0),
edge("core", "c2", 1),
edge("root", "era", 1),
edge("era", "e1", 0),
];
const pos = layout({ nodes, edges, focusNodeId: "root" });
expect(pos.core).toBeTruthy();
expect(pos.c1).toBeUndefined();
expect(pos.c2).toBeUndefined();
expect(pos.era).toBeTruthy();
expect(pos.e1).toBeTruthy();
});
it("hides hierarchy subtree even without isPrimaryParent", () => {
const branch = node("theme", "余华主题", -1);
branch.collapsed = true;
const child = node("c", "苦难");
const nodes = [node("root", "活着"), branch, child];
const edges: MindEdge[] = [
{
...edge("root", "theme", 0),
isPrimaryParent: true,
},
{
...edge("theme", "c", 0),
isPrimaryParent: false,
},
];
const pos = layout({ nodes, edges, focusNodeId: "root" });
expect(pos.theme).toBeTruthy();
expect(pos.c).toBeUndefined();
});
it("pin-only with prevPositions keeps unrelated nodes stable", () => {
const nodes = [
node("root", "R"),
node("a", "A", -1),
node("a1", "A1"),
node("b", "B", 1),
];
const edges = [
edge("root", "a", 0),
edge("a", "a1", 0),
edge("root", "b", 1),
];
const first = layout({ nodes, edges, focusNodeId: "root" });
const fp = hierarchyFingerprint(edges);
const pinned = nodes.map((n) =>
n.id === "a"
? { ...n, pinned: true, pos: { x: -400, y: 120 } }
: n,
);
const second = layout({
nodes: pinned,
edges,
focusNodeId: "root",
prevPositions: first,
prevHierarchyFp: fp,
});
expect(second.a).toEqual({ x: -400, y: 120 });
// sibling branch barely moves
expect(Math.abs((second.b?.x ?? 0) - (first.b?.x ?? 0))).toBeLessThan(30);
expect(Math.abs((second.b?.y ?? 0) - (first.b?.y ?? 0))).toBeLessThan(30);
// single-node pin: child stays at previous layout pos (not dragged with parent)
expect(second.a1!.x).toBeCloseTo(first.a1!.x, 0);
expect(second.a1!.y).toBeCloseTo(first.a1!.y, 0);
});
it("card preset height grows with visible fields", () => {
const dens = { gapX: 72, gapY: 36, nodeW: 200, nodeH: 56, pad: 20 };
const bare = nodeLayoutSize(
{
stylePreset: "default",
imageUrl: undefined,
tags: [],
links: [],
note: undefined,
dueAt: undefined,
startAt: undefined,
pinned: false,
},
dens,
{ asCard: true },
);
const rich = nodeLayoutSize(
{
stylePreset: "default",
imageUrl: "https://example.com/a.jpg",
tags: ["a"],
links: [{ url: "https://ex.com" }],
note: "hello",
dueAt: 1,
startAt: undefined,
pinned: true,
},
dens,
{ asCard: true },
);
expect(bare.w).toBe(288);
expect(rich.w).toBe(288);
expect(rich.h).toBeGreaterThan(bare.h);
});
});

View file

@ -0,0 +1,579 @@
import { flextree } from "d3-flextree";
import type {
Density,
MindEdge,
MindNode,
NodeViewMode,
StylePreset,
} from "@mind-map/shared";
export type LayoutInput = {
nodes: MindNode[];
edges: MindEdge[];
focusNodeId: string;
density?: Density;
/** Canvas-wide bubble vs card chrome (affects box sizes). */
nodeViewMode?: NodeViewMode;
/** Previous frame positions — reused when hierarchy structure is unchanged. */
prevPositions?: PositionMap;
/** Fingerprint of edges used to produce prevPositions. */
prevHierarchyFp?: string;
/** Force full flextree (e.g. after move_node / create / delete). */
structureChanged?: boolean;
};
export type PositionMap = Record<string, { x: number; y: number }>;
type Dens = {
gapX: number;
gapY: number;
nodeW: number;
nodeH: number;
pad: number;
};
const DENSITY: Record<Density, Dens> = {
comfortable: { gapX: 80, gapY: 40, nodeW: 212, nodeH: 60, pad: 20 },
compact: { gapX: 52, gapY: 28, nodeW: 168, nodeH: 48, pad: 12 },
};
/** Actual layout box — must match canvas hit/draw sizing intent. */
export function nodeLayoutSize(
node: Pick<
MindNode,
| "stylePreset"
| "imageUrl"
| "tags"
| "links"
| "note"
| "dueAt"
| "startAt"
| "pinned"
>,
dens: Dens,
opts?: { asCard?: boolean },
): { w: number; h: number } {
let w = dens.nodeW;
let h = dens.nodeH;
// meta strip (badges / tags / due)
const hasMeta =
Boolean(node.tags?.length) ||
Boolean(node.links?.length) ||
Boolean(node.dueAt) ||
Boolean(node.note);
if (hasMeta) h += dens.nodeH * 0.4;
else h += dens.nodeH * 0.25;
const preset: StylePreset = node.stylePreset ?? "default";
const asCard = Boolean(opts?.asCard) || preset === "card";
if (asCard) {
// Keep in sync with apps/web/src/node-props/sizing.ts
const w = dens.nodeW >= 200 ? 288 : 248;
const gap = 10;
let body = 0;
if (node.note) body += 32 + gap;
if (node.imageUrl) body += 68 + gap;
if (node.tags?.length) body += 32 + gap;
if (node.links?.length) body += 32 + gap;
if (node.startAt != null) body += 32 + gap;
if (node.dueAt != null) body += 32 + gap;
if (node.pinned) body += 32 + gap;
if (body > 0) body -= gap;
const h = 36 + 24 + Math.max(body, 12);
return { w, h: Math.max(h, dens.nodeW >= 200 ? 100 : 84) };
}
if (preset === "title") {
w *= 1.2;
h *= 1.2;
} else if (preset === "decision") {
w *= 1.1;
h *= 1.15;
} else if (preset === "note") {
h *= 1.15;
}
if (node.imageUrl) {
w = Math.max(w, dens.nodeW * 1.2);
h += dens.nodeH * 1.7;
}
return { w, h };
}
type FlexDatum = {
id: string;
size: [number, number];
children?: FlexDatum[];
};
function buildAdj(edges: MindEdge[]): {
hierarchy: Map<string, { other: string; primaryTo: boolean; order: number }[]>;
relation: Map<string, string[]>;
} {
const hierarchy = new Map<string, { other: string; primaryTo: boolean; order: number }[]>();
const relation = new Map<string, string[]>();
const addH = (from: string, to: string, primaryTo: boolean, order: number) => {
const list = hierarchy.get(from) ?? [];
list.push({ other: to, primaryTo, order });
hierarchy.set(from, list);
};
const addR = (a: string, b: string) => {
const la = relation.get(a) ?? [];
la.push(b);
relation.set(a, la);
};
for (const e of edges) {
if (e.deletedAt) continue;
if (e.kind === "hierarchy") {
addH(e.from, e.to, e.isPrimaryParent, e.order);
addH(e.to, e.from, false, e.order);
} else if (e.kind === "relation") {
addR(e.from, e.to);
addR(e.to, e.from);
}
}
for (const [, list] of hierarchy) {
list.sort((a, b) => {
if (a.primaryTo !== b.primaryTo) return a.primaryTo ? -1 : 1;
return a.order - b.order;
});
}
return { hierarchy, relation };
}
/**
* Hierarchy subtree under any collapsed node — must not be laid out or rendered.
* Walks all hierarchy edges (not only primary-parent), so AI-built trees still collapse.
* The collapsed node itself stays visible.
*/
export function nodesHiddenByCollapse(
nodes: MindNode[],
edges: MindEdge[],
): Set<string> {
const children = new Map<string, string[]>();
for (const e of edges) {
if (e.deletedAt || e.kind !== "hierarchy") continue;
const list = children.get(e.from) ?? [];
list.push(e.to);
children.set(e.from, list);
}
const hidden = new Set<string>();
const queue: string[] = [];
for (const n of nodes) {
if (n.deletedAt || !n.collapsed) continue;
for (const c of children.get(n.id) ?? []) queue.push(c);
}
while (queue.length) {
const id = queue.shift()!;
if (hidden.has(id)) continue;
hidden.add(id);
for (const c of children.get(id) ?? []) queue.push(c);
}
return hidden;
}
function spanningChildren(
focusId: string,
adj: ReturnType<typeof buildAdj>,
nodesById: Map<string, MindNode>,
hidden: Set<string>,
): Map<string, string[]> {
const children = new Map<string, string[]>();
const seen = new Set<string>([focusId]);
const queue = [focusId];
const tryEnqueue = (other: string, kids: string[]) => {
if (seen.has(other) || hidden.has(other)) return;
const node = nodesById.get(other);
if (!node || node.deletedAt) return;
seen.add(other);
kids.push(other);
queue.push(other);
};
while (queue.length) {
const cur = queue.shift()!;
const curNode = nodesById.get(cur);
// Collapsed: keep the node, do not expand its neighborhood into the tree.
if (curNode?.collapsed) continue;
const kids: string[] = [];
for (const n of adj.hierarchy.get(cur) ?? []) tryEnqueue(n.other, kids);
for (const other of adj.relation.get(cur) ?? []) tryEnqueue(other, kids);
if (kids.length) children.set(cur, kids);
}
return children;
}
function assignSides(
focusId: string,
nodesById: Map<string, MindNode>,
children: Map<string, string[]>,
): Map<string, -1 | 1> {
const sideOf = new Map<string, -1 | 1>();
let left = 0;
let right = 0;
for (const id of children.get(focusId) ?? []) {
const node = nodesById.get(id);
if (!node || node.deletedAt) continue;
let side: -1 | 1;
if (node.sidePref === -1) side = -1;
else if (node.sidePref === 1) side = 1;
else side = left <= right ? -1 : 1;
sideOf.set(id, side);
if (side === -1) left++;
else right++;
}
return sideOf;
}
function buildSubtree(
id: string,
nodesById: Map<string, MindNode>,
children: Map<string, string[]>,
dens: Dens,
path: Set<string>,
asCard: boolean,
childFilter?: (childId: string) => boolean,
): FlexDatum | null {
if (path.has(id)) return null;
const node = nodesById.get(id);
if (!node || node.deletedAt) return null;
const nextPath = new Set(path);
nextPath.add(id);
const kids: FlexDatum[] = [];
if (!node.collapsed) {
for (const cid of children.get(id) ?? []) {
if (childFilter && !childFilter(cid)) continue;
const child = buildSubtree(
cid,
nodesById,
children,
dens,
nextPath,
asCard,
);
if (child) kids.push(child);
}
}
const box = nodeLayoutSize(node, dens, { asCard });
return {
id,
// flextree size = full box + gutters (claim real space, not a rigid grid cell)
size: [box.w + dens.gapX, box.h + dens.gapY],
children: kids.length ? kids : undefined,
};
}
/**
* flextree: y = depth, x = sibling.
* Mind map: depth → ±X, sibling → Y.
* nodeSize [xSize,ySize] = [sibling span ≈ height, depth step ≈ width].
*/
function layoutSideTree(
tree: FlexDatum,
dens: Dens,
side: -1 | 1,
positions: PositionMap,
) {
const lay = flextree()
.children((d) => (d as FlexDatum).children ?? null)
.nodeSize((d) => {
const [boxW, boxH] = (d as { data: FlexDatum }).data.size;
// data.size already includes gutters as [w+gapX, h+gapY]
return [boxH, boxW];
})
.spacing(() => dens.gapY * 0.5);
const root = lay.hierarchy(tree);
lay(root);
root.each((d) => {
if (d.depth === 0) return;
const data = d.data as FlexDatum;
positions[data.id] = {
x: side * d.y,
y: d.x,
};
});
}
/**
* Iterative AABB separation: nodes push apart until boxes (+pad) no longer overlap.
* Pinned / focus stay fixed; others move. This is soft — not a locked grid.
*/
function resolveOverlaps(
positions: PositionMap,
sizes: Map<string, { w: number; h: number }>,
fixed: Set<string>,
pad: number,
iterations = 60,
/** If set, only pairs involving at least one of these ids are separated. */
active?: Set<string>,
) {
const ids = Object.keys(positions).filter((id) => sizes.has(id));
for (let iter = 0; iter < iterations; iter++) {
let moved = false;
for (let i = 0; i < ids.length; i++) {
const a = ids[i]!;
const pa = positions[a]!;
const sa = sizes.get(a)!;
const aw = sa.w / 2 + pad;
const ah = sa.h / 2 + pad;
for (let j = i + 1; j < ids.length; j++) {
const b = ids[j]!;
if (active && !active.has(a) && !active.has(b)) continue;
const pb = positions[b]!;
const sb = sizes.get(b)!;
const bw = sb.w / 2 + pad;
const bh = sb.h / 2 + pad;
const dx = pb.x - pa.x;
const dy = pb.y - pa.y;
const ox = aw + bw - Math.abs(dx);
const oy = ah + bh - Math.abs(dy);
if (ox <= 0 || oy <= 0) continue;
// Push along the axis of least penetration
const aFixed = fixed.has(a);
const bFixed = fixed.has(b);
if (aFixed && bFixed) continue;
if (ox < oy) {
const push = (ox / 2) * (dx === 0 ? (a < b ? 1 : -1) : Math.sign(dx) || 1);
if (!aFixed && !bFixed) {
pa.x -= push;
pb.x += push;
} else if (aFixed) {
pb.x += push * 2;
} else {
pa.x -= push * 2;
}
} else {
const push = (oy / 2) * (dy === 0 ? (a < b ? 1 : -1) : Math.sign(dy) || 1);
if (!aFixed && !bFixed) {
pa.y -= push;
pb.y += push;
} else if (aFixed) {
pb.y += push * 2;
} else {
pa.y -= push * 2;
}
}
moved = true;
}
}
if (!moved) break;
}
}
/** Stable fingerprint of hierarchy topology + sibling order (ignores relation edges). */
export function hierarchyFingerprint(edges: MindEdge[]): string {
const parts: string[] = [];
for (const e of edges) {
if (e.deletedAt || e.kind !== "hierarchy") continue;
parts.push(
`${e.from}>${e.to}:${e.isPrimaryParent ? 1 : 0}:${e.order}`,
);
}
parts.sort();
return parts.join("|");
}
export function layout(input: LayoutInput): PositionMap {
const dens = DENSITY[input.density ?? "comfortable"];
const asCard = input.nodeViewMode === "card";
const nodesById = new Map(input.nodes.map((n) => [n.id, n]));
if (!nodesById.has(input.focusNodeId)) return {};
const hidden = nodesHiddenByCollapse(input.nodes, input.edges);
// Focus inside a collapsed branch: still show the focus path (rare UI case).
hidden.delete(input.focusNodeId);
const sizes = new Map<string, { w: number; h: number }>();
for (const n of input.nodes) {
if (n.deletedAt || hidden.has(n.id)) continue;
sizes.set(n.id, nodeLayoutSize(n, dens, { asCard }));
}
const prev = input.prevPositions;
const fp = hierarchyFingerprint(input.edges);
const structureUnchanged =
!input.structureChanged &&
Boolean(prev) &&
Boolean(input.prevHierarchyFp) &&
input.prevHierarchyFp === fp;
const canReusePrev =
structureUnchanged &&
input.nodes.some((n) => !n.deletedAt && !hidden.has(n.id) && prev![n.id]);
if (canReusePrev && prev) {
const positions: PositionMap = {};
for (const n of input.nodes) {
if (n.deletedAt || hidden.has(n.id)) continue;
const p = prev[n.id];
if (p) positions[n.id] = { ...p };
}
const fixed = new Set<string>();
const active = new Set<string>();
for (const n of input.nodes) {
if (hidden.has(n.id) || n.deletedAt) continue;
if (!n.pinned) continue;
const pinPos = n.pos ?? prev[n.id];
if (!pinPos) continue;
const before = positions[n.id];
positions[n.id] = { ...pinPos };
fixed.add(n.id);
if (
!before ||
Math.abs(before.x - pinPos.x) > 0.5 ||
Math.abs(before.y - pinPos.y) > 0.5
) {
active.add(n.id);
}
}
// Focus stays centered unless it is pinned (pinned never moves).
const focus = nodesById.get(input.focusNodeId);
if (!(focus?.pinned && positions[input.focusNodeId])) {
positions[input.focusNodeId] = { x: 0, y: 0 };
fixed.add(input.focusNodeId);
} else {
fixed.add(input.focusNodeId);
}
// Place brand-new nodes (no prev) near focus, then local-separate only.
const orphans = input.nodes.filter(
(n) => !n.deletedAt && !hidden.has(n.id) && !positions[n.id],
);
orphans.forEach((n, i) => {
const angle = (i / Math.max(1, orphans.length)) * Math.PI * 2;
const r = dens.nodeW + dens.gapX * 2;
positions[n.id] = {
x: Math.cos(angle) * r,
y: Math.sin(angle) * r,
};
active.add(n.id);
});
if (active.size) {
resolveOverlaps(positions, sizes, fixed, dens.pad, 24, active);
if (!(focus?.pinned && positions[input.focusNodeId])) {
positions[input.focusNodeId] = { x: 0, y: 0 };
}
resolveOverlaps(positions, sizes, fixed, dens.pad, 12, active);
} else if (!(focus?.pinned && positions[input.focusNodeId])) {
positions[input.focusNodeId] = { x: 0, y: 0 };
}
for (const id of hidden) delete positions[id];
return positions;
}
// Full flextree path (structure changed or first layout).
const adj = buildAdj(input.edges);
const children = spanningChildren(input.focusNodeId, adj, nodesById, hidden);
const sideOf = assignSides(input.focusNodeId, nodesById, children);
const positions: PositionMap = {
[input.focusNodeId]: { x: 0, y: 0 },
};
for (const side of [-1, 1] as const) {
const tree = buildSubtree(
input.focusNodeId,
nodesById,
children,
dens,
new Set(),
asCard,
(childId) => sideOf.get(childId) === side,
);
if (!tree?.children?.length) continue;
layoutSideTree(tree, dens, side, positions);
}
positions[input.focusNodeId] = { x: 0, y: 0 };
// Soft blend from prev — never override pinned nodes.
if (prev) {
for (const [id, p] of Object.entries(prev)) {
if (hidden.has(id) || id === input.focusNodeId) continue;
if (!positions[id]) continue;
const node = nodesById.get(id);
if (node?.pinned) continue;
const sug = positions[id]!;
const dist = Math.hypot(p.x - sug.x, p.y - sug.y);
if (dist < dens.nodeW * 4) {
positions[id] = { ...p };
}
}
}
const fixed = new Set<string>();
for (const n of input.nodes) {
if (hidden.has(n.id) || n.deletedAt) continue;
if (!n.pinned) continue;
const pinPos = n.pos ?? prev?.[n.id] ?? positions[n.id];
if (!pinPos) continue;
positions[n.id] = { ...pinPos };
fixed.add(n.id);
}
const focusNode = nodesById.get(input.focusNodeId);
if (!(focusNode?.pinned && positions[input.focusNodeId])) {
positions[input.focusNodeId] = { x: 0, y: 0 };
}
fixed.add(input.focusNodeId);
// Disconnected visible nodes only — never re-seed collapsed descendants.
const placed = new Set(Object.keys(positions));
const orphans = input.nodes.filter(
(n) => !n.deletedAt && !hidden.has(n.id) && !placed.has(n.id),
);
orphans.forEach((n, i) => {
if (prev?.[n.id]) {
positions[n.id] = { ...prev[n.id]! };
return;
}
const angle = (i / Math.max(1, orphans.length)) * Math.PI * 2;
const r = dens.nodeW + dens.gapX * 2 + Math.floor(i / 8) * dens.nodeW;
positions[n.id] = {
x: Math.cos(angle) * r,
y: Math.sin(angle) * r,
};
});
resolveOverlaps(positions, sizes, fixed, dens.pad);
// Keep focus centered after pushes — but never yank a pinned focus off its pos.
if (!(focusNode?.pinned && (focusNode.pos || prev?.[focusNode.id]))) {
positions[input.focusNodeId] = { x: 0, y: 0 };
}
resolveOverlaps(positions, sizes, fixed, dens.pad, 20);
// Ensure collapsed descendants never leak into the position map.
for (const id of hidden) delete positions[id];
return positions;
}
export function layoutDiff(
prev: PositionMap,
next: PositionMap,
): { id: string; x: number; y: number }[] {
const out: { id: string; x: number; y: number }[] = [];
for (const [id, p] of Object.entries(next)) {
const o = prev[id];
if (!o || o.x !== p.x || o.y !== p.y) out.push({ id, x: p.x, y: p.y });
}
return out;
}
export function primaryParentMap(edges: MindEdge[]): Map<string, string> {
const m = new Map<string, string>();
for (const e of edges) {
if (e.kind === "hierarchy" && e.isPrimaryParent && !e.deletedAt) {
m.set(e.to, e.from);
}
}
return m;
}

View file

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": true
},
"include": ["src/**/*"],
"references": [{ "path": "../shared" }]
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,25 @@
{
"name": "@mind-map/shared",
"version": "0.1.0",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"zod": "^3.24.2"
},
"devDependencies": {
"typescript": "^5.8.2",
"vitest": "^3.0.9"
}
}

View file

@ -0,0 +1,212 @@
import { describe, expect, it } from "vitest";
import {
queryMissingMeta,
queryNeighbors,
queryOrphans,
queryPaths,
queryStats,
resolveQueryNodeRef,
type MindEdge,
type MindNode,
} from "./index.js";
function node(
partial: Partial<MindNode> & { id: string; text: string },
): MindNode {
const t = 1;
return {
canvasId: "c1",
collapsed: false,
pinned: false,
sidePref: 0,
stylePreset: "default",
version: 1,
tags: [],
links: [],
createdAt: t,
updatedAt: t,
...partial,
};
}
function edge(
partial: Partial<MindEdge> & {
id: string;
from: string;
to: string;
kind: "hierarchy" | "relation";
},
): MindEdge {
const t = 1;
return {
canvasId: "c1",
isPrimaryParent: partial.kind === "hierarchy",
order: 0,
tags: [],
version: 1,
createdAt: t,
updatedAt: t,
...partial,
};
}
describe("graph-query", () => {
const nodes = [
node({
id: "a",
text: "AIOps",
alias: "aiops",
description: { function: "根因诊断", sla: "30s" },
}),
node({ id: "b", text: "自动重启", alias: "restart" }),
node({ id: "c", text: "工单", alias: "ticket" }),
node({ id: "d", text: "孤立点" }),
];
const edges = [
edge({
id: "e1",
from: "a",
to: "b",
kind: "relation",
label: "高置信→重启",
}),
edge({
id: "e2",
from: "a",
to: "c",
kind: "relation",
label: "低置信→工单",
}),
edge({ id: "e3", from: "c", to: "b", kind: "relation", label: "也可能重启" }),
];
it("neighbors out depth 1", () => {
const r = queryNeighbors({
nodes,
edges,
nodeId: "a",
depth: 1,
direction: "out",
});
expect(r.citedNodeIds.sort()).toEqual(["a", "b", "c"]);
expect(r.nodes.find((n) => n.id === "a")?.function).toBe("根因诊断");
});
it("paths finds both branches", () => {
const r = queryPaths({
nodes,
edges,
fromId: "a",
toId: "b",
direction: "out",
maxPaths: 5,
});
expect(r.paths.length).toBe(2);
const texts = r.paths.map((p) => p.texts.join("→")).sort();
expect(texts).toContain("AIOps→自动重启");
expect(texts).toContain("AIOps→工单→自动重启");
expect(r.paths.find((p) => p.hops === 2)?.hops).toBe(2);
const direct = r.paths.find((p) => p.hops === 1)!;
expect(direct.edges[0]?.id).toBe("e1");
expect(direct.edges[0]?.label).toBe("高置信→重启");
expect(direct.edgeLabels[0]).toBe("高置信→重启");
expect(r.truncated).toBe(false);
expect(r.pathsReturned).toBe(2);
expect(r.totalPathsFound).toBe(2);
expect(r.omittedPaths).toBe(0);
expect(r.maxPaths).toBe(5);
});
it("path truncation reports omitted count", () => {
const r = queryPaths({
nodes,
edges,
fromId: "a",
toId: "b",
direction: "out",
maxPaths: 1,
});
expect(r.pathsReturned).toBe(1);
expect(r.truncated).toBe(true);
expect(r.totalPathsFound).toBeGreaterThan(1);
expect(r.omittedPaths).toBe(r.totalPathsFound - 1);
expect(r.maxPaths).toBe(1);
});
it("multi-hop through intermediate relation node (BFS)", () => {
const n = [
node({ id: "detect", text: "检测", alias: "detect" }),
node({ id: "trig", text: "诊断触发", alias: "diag_trigger" }),
node({ id: "aiops", text: "AIOps", alias: "aiops" }),
node({ id: "kb", text: "知识入库", alias: "knowledge_write" }),
];
const e = [
edge({ id: "1", from: "detect", to: "trig", kind: "relation", label: "触发" }),
edge({ id: "2", from: "trig", to: "aiops", kind: "relation", label: "进入诊断" }),
edge({ id: "3", from: "aiops", to: "kb", kind: "relation", label: "沉淀" }),
];
const r = queryPaths({
nodes: n,
edges: e,
fromId: "detect",
toId: "kb",
throughRelationNodes: true,
maxHops: 5,
direction: "out",
});
expect(r.paths.length).toBe(1);
expect(r.paths[0]!.texts).toEqual([
"检测",
"诊断触发",
"AIOps",
"知识入库",
]);
expect(r.paths[0]!.hops).toBe(3);
expect(r.paths[0]!.edges.map((x) => x.id)).toEqual(["1", "2", "3"]);
});
it("throughRelationNodes false forces 1 hop", () => {
const r = queryPaths({
nodes,
edges,
fromId: "a",
toId: "b",
throughRelationNodes: false,
direction: "out",
});
expect(r.maxHops).toBe(1);
expect(r.paths.every((p) => p.hops === 1)).toBe(true);
expect(r.paths.map((p) => p.texts.join("→"))).toEqual(["AIOps→自动重启"]);
});
it("stats and orphans / missing meta", () => {
const s = queryStats({ nodes, edges });
expect(s.nodeCount).toBe(4);
expect(s.orphanNodes).toBe(1);
// empty description → withoutDescription; withoutFunction only if description present
expect(s.nodesWithoutDescription).toBe(3);
expect(s.nodesWithoutFunction).toBe(0);
expect(s.connectedComponents.count).toBe(s.islands);
expect(s.orphanDef).toMatch(/full graph/i);
expect(s.nodesWithoutRelation).toBeUndefined();
expect(s.mostConnected?.nodeId).toBe("a");
expect(s.mostConnected?.degree).toBe(2);
expect(s.longestPath?.hops).toBeGreaterThanOrEqual(1);
const rel = queryStats({ nodes, edges, edgeKinds: "relation" });
expect(rel.longestPath?.edgeKinds).toBe("relation");
expect(rel.nodesWithoutRelation).toBe(rel.orphanNodes);
expect(rel.orphanDef).toMatch(/relation/i);
const o = queryOrphans({ nodes, edges });
expect(o.citedNodeIds).toEqual(["d"]);
const m = queryMissingMeta({ nodes, edges });
expect(m.nodesWithoutDescription.some((n) => n.id === "b")).toBe(true);
expect(m.nodesWithoutFunction.length).toBe(0);
});
it("resolveQueryNodeRef alias and title", () => {
expect(resolveQueryNodeRef(nodes, "@aiops").ok).toBe(true);
expect(resolveQueryNodeRef(nodes, "自动重启").ok).toBe(true);
const miss = resolveQueryNodeRef(nodes, "不存在");
expect(miss.ok).toBe(false);
});
});

View file

@ -0,0 +1,679 @@
import type { MindEdge, MindNode } from "./types.js";
export type QueryEdgeKind = "hierarchy" | "relation" | "all";
export type QueryDirection = "out" | "in" | "both";
export type CompactNode = {
id: string;
text: string;
alias?: string;
/** description.function or description.summary — token-cheap for QA. */
function?: string;
};
export type CompactEdge = {
id: string;
from: string;
to: string;
fromText: string;
toText: string;
kind: MindEdge["kind"];
label?: string;
direction?: MindEdge["direction"];
weight?: number;
lineStyle?: MindEdge["lineStyle"];
note?: string;
tags?: string[];
};
/** Path-local edge payload (keeps edgeLabels for backward compat). */
export type PathEdge = {
id: string;
label?: string | null;
direction?: MindEdge["direction"];
weight?: number;
lineStyle?: MindEdge["lineStyle"];
note?: string;
};
/** Prefer description.function, then summary. */
export function nodeFunction(n: MindNode): string | undefined {
const d = n.description;
if (!d || typeof d !== "object") return undefined;
if (typeof d.function === "string" && d.function.trim()) return d.function.trim();
if (typeof d.summary === "string" && d.summary.trim()) return d.summary.trim();
return undefined;
}
export function toCompactNode(n: MindNode): CompactNode {
const fn = nodeFunction(n);
return {
id: n.id,
text: n.text,
...(n.alias ? { alias: n.alias } : {}),
...(fn ? { function: fn } : {}),
};
}
function textOf(byId: Map<string, MindNode>, id: string): string {
return byId.get(id)?.text ?? id;
}
export function toCompactEdge(
e: MindEdge,
byId: Map<string, MindNode>,
): CompactEdge {
return {
id: e.id,
from: e.from,
to: e.to,
fromText: textOf(byId, e.from),
toText: textOf(byId, e.to),
kind: e.kind,
...(e.label ? { label: e.label } : {}),
...(e.direction ? { direction: e.direction } : {}),
...(e.weight != null ? { weight: e.weight } : {}),
...(e.lineStyle ? { lineStyle: e.lineStyle } : {}),
...(e.note ? { note: e.note } : {}),
...(e.tags?.length ? { tags: e.tags } : {}),
};
}
export function toPathEdge(e: MindEdge): PathEdge {
return {
id: e.id,
label: e.label ?? null,
...(e.direction ? { direction: e.direction } : {}),
...(e.weight != null ? { weight: e.weight } : {}),
...(e.lineStyle ? { lineStyle: e.lineStyle } : {}),
...(e.note ? { note: e.note } : {}),
};
}
function filterEdges(
edges: MindEdge[],
edgeKinds: QueryEdgeKind = "all",
): MindEdge[] {
if (edgeKinds === "all") return edges.filter((e) => !e.deletedAt);
return edges.filter((e) => !e.deletedAt && e.kind === edgeKinds);
}
function buildAdj(
edges: MindEdge[],
direction: QueryDirection,
): Map<string, { to: string; edge: MindEdge }[]> {
const adj = new Map<string, { to: string; edge: MindEdge }[]>();
const add = (from: string, to: string, edge: MindEdge) => {
const list = adj.get(from) ?? [];
list.push({ to, edge });
adj.set(from, list);
};
for (const e of edges) {
if (direction === "out" || direction === "both") add(e.from, e.to, e);
if (direction === "in" || direction === "both") add(e.to, e.from, e);
}
return adj;
}
export function queryNeighbors(input: {
nodes: MindNode[];
edges: MindEdge[];
nodeId: string;
depth?: number;
direction?: QueryDirection;
edgeKinds?: QueryEdgeKind;
}): {
rootId: string;
nodes: CompactNode[];
edges: CompactEdge[];
citedNodeIds: string[];
} {
const depth = Math.max(1, Math.min(6, input.depth ?? 1));
const direction = input.direction ?? "both";
const byId = new Map(input.nodes.map((n) => [n.id, n]));
const live = filterEdges(input.edges, input.edgeKinds ?? "all");
const adj = buildAdj(live, direction);
const keep = new Set<string>([input.nodeId]);
const usedEdges = new Set<string>();
let frontier = [input.nodeId];
for (let d = 0; d < depth; d++) {
const next: string[] = [];
for (const id of frontier) {
for (const { to, edge } of adj.get(id) ?? []) {
usedEdges.add(edge.id);
if (!keep.has(to)) {
keep.add(to);
next.push(to);
}
}
}
frontier = next;
}
const nodes = [...keep]
.map((id) => byId.get(id))
.filter((n): n is MindNode => n != null && !n.deletedAt)
.map(toCompactNode);
const edges = live
.filter((e) => usedEdges.has(e.id) && keep.has(e.from) && keep.has(e.to))
.map((e) => toCompactEdge(e, byId));
return {
rootId: input.nodeId,
nodes,
edges,
citedNodeIds: [...keep],
};
}
/**
* All simple paths from→to (directed by `direction`), capped by edge hops.
* `maxHops` = number of edges (so node count along a path is hops+1).
* Intermediate nodes are always allowed when maxHops > 1 (e.g. detect→diag_trigger→aiops).
*/
export function queryPaths(input: {
nodes: MindNode[];
edges: MindEdge[];
fromId: string;
toId: string;
/** Max edges in a path (default 8). */
maxHops?: number;
/** @deprecated use maxHops — treated as maxHops when maxHops omitted. */
maxDepth?: number;
maxPaths?: number;
direction?: QueryDirection;
edgeKinds?: QueryEdgeKind;
/**
* When false, only direct edges (maxHops forced to 1).
* When true (default), allow multi-hop via intermediate nodes.
*/
throughRelationNodes?: boolean;
}): {
paths: {
nodeIds: string[];
texts: string[];
/** @deprecated prefer paths[].edges — kept for older clients */
edgeLabels: (string | null)[];
edges: PathEdge[];
hops: number;
}[];
nodes: CompactNode[];
edges: CompactEdge[];
citedNodeIds: string[];
truncated: boolean;
/** Cap used while collecting paths (default 5, max 20). */
maxPaths: number;
/** How many paths are included in `paths`. */
pathsReturned: number;
/** How many simple paths were found before hop/budget stop (may equal pathsReturned). */
totalPathsFound: number;
/** totalPathsFound - pathsReturned when truncated. */
omittedPaths: number;
maxHops: number;
throughRelationNodes: boolean;
} {
const throughRelationNodes = input.throughRelationNodes !== false;
let maxHops = Math.max(
1,
Math.min(12, input.maxHops ?? input.maxDepth ?? 8),
);
if (!throughRelationNodes) maxHops = 1;
const maxPaths = Math.max(1, Math.min(20, input.maxPaths ?? 5));
const direction = input.direction ?? (throughRelationNodes ? "both" : "out");
const byId = new Map(input.nodes.map((n) => [n.id, n]));
const live = filterEdges(input.edges, input.edgeKinds ?? "all");
const adj = buildAdj(live, direction);
const paths: {
nodeIds: string[];
texts: string[];
edgeLabels: (string | null)[];
edges: PathEdge[];
hops: number;
}[] = [];
let totalPathsFound = 0;
// Soft cap on enumeration so huge DAGs don't hang (still report totalPathsFound up to this).
const enumerateCap = Math.max(maxPaths, Math.min(200, maxPaths * 20));
type Frame = {
node: string;
path: string[];
pathEdges: MindEdge[];
visited: Set<string>;
};
// BFS so shorter paths surface first
const queue: Frame[] = [
{
node: input.fromId,
path: [input.fromId],
pathEdges: [],
visited: new Set([input.fromId]),
},
];
while (queue.length) {
const cur = queue.shift()!;
const hops = cur.pathEdges.length;
if (cur.node === input.toId && hops > 0) {
totalPathsFound += 1;
if (paths.length < maxPaths) {
paths.push({
nodeIds: cur.path,
texts: cur.path.map((id) => textOf(byId, id)),
edgeLabels: cur.pathEdges.map((e) => e.label ?? null),
edges: cur.pathEdges.map(toPathEdge),
hops,
});
}
if (totalPathsFound >= enumerateCap) break;
continue;
}
if (hops >= maxHops) continue;
for (const { to, edge } of adj.get(cur.node) ?? []) {
if (cur.visited.has(to)) continue;
const visited = new Set(cur.visited);
visited.add(to);
queue.push({
node: to,
path: [...cur.path, to],
pathEdges: [...cur.pathEdges, edge],
visited,
});
}
}
const truncated = totalPathsFound > paths.length;
const cited = new Set<string>();
const edgeIds = new Set<string>();
for (const p of paths) {
for (const id of p.nodeIds) cited.add(id);
for (const e of p.edges) edgeIds.add(e.id);
}
return {
paths,
nodes: [...cited]
.map((id) => byId.get(id))
.filter((n): n is MindNode => n != null)
.map(toCompactNode),
edges: live
.filter((e) => edgeIds.has(e.id))
.map((e) => toCompactEdge(e, byId)),
citedNodeIds: [...cited],
truncated,
maxPaths,
pathsReturned: paths.length,
totalPathsFound,
omittedPaths: Math.max(0, totalPathsFound - paths.length),
maxHops,
throughRelationNodes,
};
}
export function queryStats(input: {
nodes: MindNode[];
edges: MindEdge[];
/** Limit longestPath / degree / components to these edge kinds (default all). */
edgeKinds?: QueryEdgeKind;
}): {
nodeCount: number;
edgeCount: number;
hierarchyEdgeCount: number;
relationEdgeCount: number;
orphanNodes: number;
/**
* What orphanNodes means under the current edgeKinds filter.
* When edgeKinds=relation, these are nodes with no relation edges (not global isolates).
*/
orphanDef: string;
/** Alias of orphanNodes when edgeKinds=relation — clearer name for that view. */
nodesWithoutRelation?: number;
/** Alias of orphanNodes when edgeKinds=hierarchy. */
nodesWithoutHierarchy?: number;
/** @deprecated use connectedComponents.count — undirected component count over edgeKinds. */
islands: number;
/** Undirected connected components (filtered by edgeKinds). */
connectedComponents: {
count: number;
edgeKinds: QueryEdgeKind;
components: {
size: number;
nodeIds: string[];
texts: string[];
}[];
};
avgChildrenPerNode: number;
deepestDepth: number;
nodesWithoutDescription: number;
nodesWithoutFunction: number;
edgesWithoutLabel: number;
/** Graph diameter on edgeKinds subgraph (undirected shortest-path hops). */
longestPath: {
hops: number;
from: string;
to: string;
fromText: string;
toText: string;
edgeKinds: QueryEdgeKind;
} | null;
/** Highest undirected degree on edgeKinds subgraph. */
mostConnected: {
nodeId: string;
text: string;
degree: number;
edgeKinds: QueryEdgeKind;
} | null;
} {
const edgeKinds: QueryEdgeKind = input.edgeKinds ?? "all";
const nodes = input.nodes.filter((n) => !n.deletedAt);
const allEdges = input.edges.filter((e) => !e.deletedAt);
const edges = filterEdges(input.edges, edgeKinds);
const nodeIds = new Set(nodes.map((n) => n.id));
const byId = new Map(nodes.map((n) => [n.id, n]));
const connected = new Set<string>();
for (const e of edges) {
if (nodeIds.has(e.from)) connected.add(e.from);
if (nodeIds.has(e.to)) connected.add(e.to);
}
const orphans = nodes.filter((n) => !connected.has(n.id)).length;
const orphanDef =
edgeKinds === "relation"
? "Nodes with degree 0 on the relation-only subgraph (no relation edges). Not global isolates — hierarchy links are ignored in this view."
: edgeKinds === "hierarchy"
? "Nodes with degree 0 on the hierarchy-only subgraph (no hierarchy edges). Relation-only nodes count here."
: "Nodes with degree 0 on the full graph (no hierarchy or relation edges).";
const orphanAliases =
edgeKinds === "relation"
? { nodesWithoutRelation: orphans }
: edgeKinds === "hierarchy"
? { nodesWithoutHierarchy: orphans }
: {};
// Undirected components
const adj = new Map<string, string[]>();
for (const id of nodeIds) adj.set(id, []);
for (const e of edges) {
if (!nodeIds.has(e.from) || !nodeIds.has(e.to)) continue;
adj.get(e.from)!.push(e.to);
adj.get(e.to)!.push(e.from);
}
const seen = new Set<string>();
const componentLists: string[][] = [];
for (const id of nodeIds) {
if (seen.has(id)) continue;
const q = [id];
seen.add(id);
const members: string[] = [];
while (q.length) {
const cur = q.pop()!;
members.push(cur);
for (const nxt of adj.get(cur) ?? []) {
if (seen.has(nxt)) continue;
seen.add(nxt);
q.push(nxt);
}
}
members.sort();
componentLists.push(members);
}
componentLists.sort((a, b) => b.length - a.length || a[0]!.localeCompare(b[0]!));
const connectedComponents = {
count: componentLists.length,
edgeKinds,
components: componentLists.slice(0, 40).map((ids) => ({
size: ids.length,
nodeIds: ids.slice(0, 24),
texts: ids.slice(0, 24).map((id) => byId.get(id)?.text ?? id),
})),
};
const islands = connectedComponents.count;
const children = new Map<string, string[]>();
for (const e of allEdges) {
if (e.kind !== "hierarchy" || !e.isPrimaryParent) continue;
const list = children.get(e.from) ?? [];
list.push(e.to);
children.set(e.from, list);
}
let childSum = 0;
let parents = 0;
for (const [, list] of children) {
parents += 1;
childSum += list.length;
}
let deepestDepth = 0;
const roots = [...nodeIds].filter((id) => {
return !allEdges.some(
(e) =>
e.kind === "hierarchy" &&
e.isPrimaryParent &&
e.to === id &&
nodeIds.has(e.from),
);
});
for (const root of roots) {
const q: { id: string; d: number }[] = [{ id: root, d: 0 }];
const vis = new Set<string>([root]);
while (q.length) {
const { id, d } = q.shift()!;
deepestDepth = Math.max(deepestDepth, d);
for (const c of children.get(id) ?? []) {
if (vis.has(c)) continue;
vis.add(c);
q.push({ id: c, d: d + 1 });
}
}
}
const degree = new Map<string, number>();
for (const id of nodeIds) degree.set(id, 0);
for (const e of edges) {
if (!nodeIds.has(e.from) || !nodeIds.has(e.to)) continue;
degree.set(e.from, (degree.get(e.from) ?? 0) + 1);
degree.set(e.to, (degree.get(e.to) ?? 0) + 1);
}
let mostConnected: {
nodeId: string;
text: string;
degree: number;
edgeKinds: QueryEdgeKind;
} | null = null;
for (const [id, deg] of degree) {
if (!mostConnected || deg > mostConnected.degree) {
mostConnected = {
nodeId: id,
text: byId.get(id)?.text ?? id,
degree: deg,
edgeKinds,
};
}
}
if (mostConnected && mostConnected.degree === 0) mostConnected = null;
let longestPath: {
hops: number;
from: string;
to: string;
fromText: string;
toText: string;
edgeKinds: QueryEdgeKind;
} | null = null;
const bfsFarthest = (start: string) => {
const dist = new Map<string, number>([[start, 0]]);
const q = [start];
let far = start;
while (q.length) {
const cur = q.shift()!;
const d = dist.get(cur)!;
if (d > (dist.get(far) ?? 0)) far = cur;
for (const nxt of adj.get(cur) ?? []) {
if (dist.has(nxt)) continue;
dist.set(nxt, d + 1);
q.push(nxt);
}
}
return { far, hops: dist.get(far) ?? 0 };
};
const starts = nodes.slice(0, 200).map((n) => n.id);
for (const start of starts) {
const { far, hops } = bfsFarthest(start);
if (hops > 0 && (!longestPath || hops > longestPath.hops)) {
longestPath = {
hops,
from: start,
to: far,
fromText: byId.get(start)?.text ?? start,
toText: byId.get(far)?.text ?? far,
edgeKinds,
};
}
}
return {
nodeCount: nodes.length,
edgeCount: allEdges.length,
hierarchyEdgeCount: allEdges.filter((e) => e.kind === "hierarchy").length,
relationEdgeCount: allEdges.filter((e) => e.kind === "relation").length,
orphanNodes: orphans,
orphanDef,
...orphanAliases,
islands,
connectedComponents,
avgChildrenPerNode: parents ? Math.round((childSum / parents) * 10) / 10 : 0,
deepestDepth,
nodesWithoutDescription: nodes.filter(
(n) => !n.description || Object.keys(n.description).length === 0,
).length,
nodesWithoutFunction: nodes.filter((n) => {
if (!n.description || Object.keys(n.description).length === 0) return false;
return !nodeFunction(n);
}).length,
edgesWithoutLabel: allEdges.filter((e) => !e.label?.trim()).length,
longestPath,
mostConnected,
};
}
export function queryOrphans(input: {
nodes: MindNode[];
edges: MindEdge[];
}): { nodes: CompactNode[]; citedNodeIds: string[] } {
const nodes = input.nodes.filter((n) => !n.deletedAt);
const edges = input.edges.filter((e) => !e.deletedAt);
const connected = new Set<string>();
for (const e of edges) {
connected.add(e.from);
connected.add(e.to);
}
const orphans = nodes.filter((n) => !connected.has(n.id));
return {
nodes: orphans.map(toCompactNode),
citedNodeIds: orphans.map((n) => n.id),
};
}
export function queryMissingMeta(input: {
nodes: MindNode[];
edges: MindEdge[];
}): {
nodesWithoutDescription: CompactNode[];
/**
* Nodes that already have a description object but lack function/summary.
* Complementary to nodesWithoutDescription (not a duplicate of empty description).
*/
nodesWithoutFunction: CompactNode[];
edgesWithoutLabel: CompactEdge[];
citedNodeIds: string[];
} {
const byId = new Map(input.nodes.map((n) => [n.id, n]));
const nodes = input.nodes.filter((n) => !n.deletedAt);
const edges = input.edges.filter((e) => !e.deletedAt);
const withoutDesc = nodes.filter(
(n) => !n.description || Object.keys(n.description).length === 0,
);
const withoutFn = nodes.filter((n) => {
if (!n.description || Object.keys(n.description).length === 0) return false;
return !nodeFunction(n);
});
const withoutLabel = edges.filter((e) => !e.label?.trim());
const cited = new Set<string>([
...withoutDesc.map((n) => n.id),
...withoutFn.map((n) => n.id),
...withoutLabel.flatMap((e) => [e.from, e.to]),
]);
return {
nodesWithoutDescription: withoutDesc.map(toCompactNode),
nodesWithoutFunction: withoutFn.map(toCompactNode),
edgesWithoutLabel: withoutLabel.map((e) => toCompactEdge(e, byId)),
citedNodeIds: [...cited],
};
}
/** Resolve id / @alias / unique title against a node list. */
export function resolveQueryNodeRef(
nodes: MindNode[],
token: string,
opts?: { anchorId?: string | null },
):
| { ok: true; id: string }
| {
ok: false;
error: "not_found" | "ambiguous";
message: string;
candidates?: { id: string; text: string }[];
} {
const raw = token.trim();
if (!raw) {
return { ok: false, error: "not_found", message: "empty node ref" };
}
const anchorId = opts?.anchorId ?? null;
const live = nodes.filter((n) => !n.deletedAt && n.id !== anchorId);
if (live.some((n) => n.id === raw)) return { ok: true, id: raw };
const aliasKey = raw.replace(/^@/, "").toLowerCase();
const byAlias = live.filter((n) => n.alias === aliasKey);
if (byAlias.length === 1) return { ok: true, id: byAlias[0]!.id };
if (byAlias.length > 1) {
return {
ok: false,
error: "ambiguous",
message: `alias @${aliasKey} matches multiple nodes`,
candidates: byAlias.slice(0, 8).map((n) => ({ id: n.id, text: n.text })),
};
}
const key = raw.toLowerCase();
const exact = live.filter((n) => n.text.trim().toLowerCase() === key);
if (exact.length === 1) return { ok: true, id: exact[0]!.id };
if (exact.length > 1) {
return {
ok: false,
error: "ambiguous",
message: `title "${raw}" matches ${exact.length} nodes; use @alias`,
candidates: exact.slice(0, 8).map((n) => ({ id: n.id, text: n.text })),
};
}
const soft = live.filter(
(n) =>
n.text.toLowerCase().includes(key) ||
key.includes(n.text.toLowerCase()) ||
(n.alias && n.alias.includes(aliasKey)),
);
if (soft.length === 1) return { ok: true, id: soft[0]!.id };
if (soft.length > 1) {
return {
ok: false,
error: "ambiguous",
message: `"${raw}" is ambiguous`,
candidates: soft.slice(0, 8).map((n) => ({ id: n.id, text: n.text })),
};
}
return {
ok: false,
error: "not_found",
message: `No node matched "${raw}". Use mindmap_find or create it first.`,
};
}

View file

@ -0,0 +1,55 @@
export * from "./types.js";
export * from "./ops.js";
export * from "./resolve-ops.js";
export * from "./graph-query.js";
export function now(): number {
return Date.now();
}
export function newId(prefix = "n"): string {
return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
}
/** Fit camera so content bbox fills a virtual viewport (server-side auto_fit). */
export function computeFitViewport(
positions: Record<string, { x: number; y: number }>,
opts?: {
padding?: number;
viewW?: number;
viewH?: number;
nodeW?: number;
nodeH?: number;
},
): { x: number; y: number; zoom: number } {
const pad = opts?.padding ?? 50;
const viewW = opts?.viewW ?? 1280;
const viewH = opts?.viewH ?? 800;
const nodeW = opts?.nodeW ?? 160;
const nodeH = opts?.nodeH ?? 44;
const pts = Object.values(positions);
if (!pts.length) return { x: 0, y: 0, zoom: 1 };
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const p of pts) {
minX = Math.min(minX, p.x - nodeW / 2);
maxX = Math.max(maxX, p.x + nodeW / 2);
minY = Math.min(minY, p.y - nodeH / 2);
maxY = Math.max(maxY, p.y + nodeH / 2);
}
minX -= pad;
maxX += pad;
minY -= pad;
maxY += pad;
const bw = Math.max(40, maxX - minX);
const bh = Math.max(40, maxY - minY);
const zoom = Math.min(
2.5,
Math.max(0.12, Math.min((viewW * 0.86) / bw, (viewH * 0.86) / bh)),
);
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
return { x: -cx * zoom, y: -cy * zoom, zoom };
}

518
packages/shared/src/ops.ts Normal file
View file

@ -0,0 +1,518 @@
import { z } from "zod";
import {
AccentColorSchema,
DescriptionSchema,
EdgeDirectionSchema,
EdgeLineStyleSchema,
LinkRefSchema,
NodeIconSchema,
StylePresetSchema,
ViewportSchema,
} from "./types.js";
export const OP_TYPES = [
"create_node",
"batch_create",
"batch_link",
"update_text",
"update_node_meta",
"move_node",
"reorder",
"set_primary_parent",
"link",
"unlink",
"delete_node",
"restore_node",
"set_pinned",
"set_collapsed",
"set_style_preset",
"set_edge_label",
"update_edge_meta",
"set_prefs",
"set_focus",
"set_viewport",
"auto_fit",
] as const;
export type OpType = (typeof OP_TYPES)[number];
const PosSchema = z.object({ x: z.number(), y: z.number() });
/** Outgoing graph edges declared on create / batch_create (not URL bookmarks). */
export const InlineRelationSchema = z.object({
to: z.string(),
kind: z.enum(["hierarchy", "relation"]).default("relation"),
label: z.string().optional(),
weight: z.number().min(1).max(5).optional(),
lineStyle: EdgeLineStyleSchema.optional(),
direction: EdgeDirectionSchema.optional(),
});
export type InlineRelation = z.infer<typeof InlineRelationSchema>;
const NodeMetaPatchSchema = z.object({
note: z.string().nullable().optional(),
description: DescriptionSchema.nullable().optional(),
tags: z.array(z.string()).optional(),
links: z.array(LinkRefSchema).optional(),
imageUrl: z.string().nullable().optional(),
dueAt: z.number().nullable().optional(),
startAt: z.number().nullable().optional(),
accentColor: AccentColorSchema.nullable().optional(),
icon: NodeIconSchema.nullable().optional(),
alias: z.string().min(1).max(64).nullable().optional(),
});
const EdgeMetaPatchSchema = z.object({
label: z.string().nullable().optional(),
tags: z.array(z.string()).optional(),
note: z.string().nullable().optional(),
weight: z.number().min(1).max(5).nullable().optional(),
lineStyle: EdgeLineStyleSchema.nullable().optional(),
direction: EdgeDirectionSchema.nullable().optional(),
});
const CreateNodeFieldsSchema = z.object({
id: z.string().optional(),
text: z.string(),
parentId: z.string().nullable().optional(),
sidePref: z.union([z.literal(-1), z.literal(0), z.literal(1)]).optional(),
stylePreset: StylePresetSchema.optional(),
/** Optional absolute position; implies pinned=true when set. */
pos: PosSchema.optional(),
pinned: z.boolean().optional(),
note: z.string().optional(),
description: DescriptionSchema.optional(),
tags: z.array(z.string()).optional(),
/** URL bookmarks (not graph edges). */
links: z.array(LinkRefSchema).optional(),
/** Outgoing graph edges created with this node. */
relations: z.array(InlineRelationSchema).optional(),
imageUrl: z.string().optional(),
dueAt: z.number().optional(),
startAt: z.number().optional(),
accentColor: AccentColorSchema.optional(),
icon: NodeIconSchema.optional(),
/** Agent-friendly handle; later refs use @alias or bare alias. */
alias: z.string().min(1).max(64).optional(),
});
export const OpSchema = z.discriminatedUnion("type", [
CreateNodeFieldsSchema.extend({
type: z.literal("create_node"),
}),
z.object({
type: z.literal("batch_create"),
nodes: z.array(CreateNodeFieldsSchema).min(1).max(200),
}),
z.object({
type: z.literal("batch_link"),
edges: z
.array(
z.object({
from: z.string(),
to: z.string(),
kind: z.enum(["hierarchy", "relation"]).default("relation"),
label: z.string().optional(),
asPrimary: z.boolean().optional(),
weight: z.number().min(1).max(5).optional(),
lineStyle: EdgeLineStyleSchema.optional(),
direction: EdgeDirectionSchema.optional(),
}),
)
.min(1)
.max(100),
}),
z.object({
type: z.literal("update_text"),
nodeId: z.string(),
text: z.string(),
expectedVersion: z.number().optional(),
}),
z.object({
type: z.literal("update_node_meta"),
nodeId: z.string(),
patch: NodeMetaPatchSchema,
expectedVersion: z.number().optional(),
}),
z.object({
type: z.literal("move_node"),
nodeId: z.string(),
newParentId: z.string().nullable(),
sidePref: z.union([z.literal(-1), z.literal(0), z.literal(1)]).optional(),
order: z.number().optional(),
expectedVersion: z.number().optional(),
}),
z.object({
type: z.literal("reorder"),
nodeId: z.string(),
/** Numeric sibling sort key on the primary hierarchy edge (preferred). */
order: z.number().optional(),
/**
* Convenience alias: place after this sibling under the same parent.
* `null` = move to front. Server resolves to `order` when applying.
*/
afterSiblingId: z.string().nullable().optional(),
}),
z.object({
type: z.literal("set_primary_parent"),
nodeId: z.string(),
edgeId: z.string(),
}),
z.object({
type: z.literal("link"),
from: z.string(),
to: z.string(),
kind: z.enum(["hierarchy", "relation"]),
label: z.string().optional(),
asPrimary: z.boolean().optional(),
weight: z.number().min(1).max(5).optional(),
lineStyle: EdgeLineStyleSchema.optional(),
direction: EdgeDirectionSchema.optional(),
}),
z.object({
type: z.literal("unlink"),
edgeId: z.string(),
}),
z.object({
type: z.literal("delete_node"),
nodeId: z.string(),
}),
z.object({
type: z.literal("restore_node"),
nodeId: z.string(),
}),
z.object({
type: z.literal("set_pinned"),
nodeId: z.string(),
pinned: z.boolean(),
pos: PosSchema.optional(),
}),
z.object({
type: z.literal("set_collapsed"),
nodeId: z.string(),
collapsed: z.boolean(),
}),
z.object({
type: z.literal("set_style_preset"),
nodeId: z.string(),
stylePreset: StylePresetSchema,
}),
z.object({
type: z.literal("set_edge_label"),
edgeId: z.string(),
label: z.string(),
}),
z.object({
type: z.literal("update_edge_meta"),
edgeId: z.string(),
patch: EdgeMetaPatchSchema,
expectedVersion: z.number().optional(),
}),
z.object({
type: z.literal("set_prefs"),
prefs: z.record(z.unknown()),
}),
z.object({
type: z.literal("set_focus"),
nodeId: z.string(),
}),
z.object({
type: z.literal("set_viewport"),
viewport: ViewportSchema,
}),
z.object({
type: z.literal("auto_fit"),
/** Extra world-space padding around content bbox (default 50). */
padding: z.number().min(0).max(400).optional(),
}),
]);
export type Op = z.infer<typeof OpSchema>;
/** Accept agent typos: op / operation → type. */
export function coerceOpInput(raw: unknown): unknown {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
const o = { ...(raw as Record<string, unknown>) };
if (o.type == null) {
if (typeof o.op === "string") o.type = o.op;
else if (typeof o.operation === "string") o.type = o.operation;
}
delete o.op;
delete o.operation;
return o;
}
export function parseOps(
raw: unknown,
):
| { ok: true; ops: Op[] }
| {
ok: false;
error: "invalid_op";
message: string;
validTypes: readonly string[];
issues?: unknown;
} {
if (!Array.isArray(raw)) {
return {
ok: false,
error: "invalid_op",
message: "ops must be an array of objects with discriminator field `type` (not `op`).",
validTypes: OP_TYPES,
};
}
if (raw.length === 0) {
return {
ok: false,
error: "invalid_op",
message: "ops array is empty",
validTypes: OP_TYPES,
};
}
const coerced = raw.map(coerceOpInput);
const parsed = z.array(OpSchema).safeParse(coerced);
if (!parsed.success) {
const first = parsed.error.issues[0];
const hint =
first?.path?.join(".") === "0.type" || String(first?.message || "").includes("discriminator")
? " Each op MUST use field `type` (e.g. {\"type\":\"create_node\",\"text\":\"...\",\"parentId\":\"...\"}). Do not use `op`."
: "";
return {
ok: false,
error: "invalid_op",
message: `Invalid ops: ${first?.message ?? "schema error"}.${hint}`,
validTypes: OP_TYPES,
issues: parsed.error.issues.slice(0, 8),
};
}
for (const op of parsed.data) {
if (
op.type === "reorder" &&
op.order == null &&
op.afterSiblingId === undefined
) {
return {
ok: false,
error: "invalid_op",
message:
'reorder requires `order` (number) or `afterSiblingId` (sibling id | null for first). Example: {"type":"reorder","nodeId":"...","order":10}',
validTypes: OP_TYPES,
};
}
}
return { ok: true, ops: parsed.data };
}
export const OP_SCHEMA_DOC = {
discriminator: "type",
note: "Use `type`, never `op` / `operation`. Prefer batch_create for trees. There is no visible root node — omit parentId for top-level creates.",
validTypes: OP_TYPES,
examples: [
{
type: "batch_create",
nodes: [
{ text: "数通产品线", alias: "datacom" },
{
text: "数据中心交换机",
alias: "dc_switch",
parentId: "@datacom",
},
{
text: "AIOps诊断",
alias: "aiops",
parentId: "@datacom",
description: {
function: "故障根因诊断",
inputs: ["指标", "日志"],
sla: "30秒内输出",
},
relations: [
{ to: "@restart", label: "触发重启", kind: "relation" },
],
},
{ text: "自动重启", alias: "restart", parentId: "@datacom" },
],
_comment: "Two-pass: all nodes first, then parents/relations. Max 200.",
},
{
type: "batch_link",
edges: [
{ from: "@daiyu", to: "@baoyu", kind: "relation", label: "知己" },
{ from: "@baoyu", to: "@baochai", kind: "relation", label: "金玉良缘" },
],
_comment: "Bulk relation/hierarchy links (max 100). Prefer after nodes exist.",
},
{
type: "create_node",
text: "AIOps诊断",
alias: "aiops",
relations: [{ to: "@restart", label: "触发重启", kind: "relation" }],
_comment: "relations = graph edges; links = URL bookmarks",
},
{ type: "create_node", text: "贾宝玉" },
{
type: "create_node",
text: "贾政",
parentId: "<visibleParentId>",
},
{
type: "link",
from: "<fromNodeId>",
to: "<toNodeId>",
kind: "relation",
label: "木石前盟",
},
{
type: "create_node",
text: "AIOps",
alias: "aiops",
_comment: "Later refs: parentId:'@aiops' or parentText:'AIOps'",
},
{
type: "create_node",
text: "自动重启",
parentId: "@aiops",
},
{
type: "link",
from: "@aiops",
to: "自动重启",
kind: "relation",
label: "诊断→重启",
_comment:
"from/to accept id, @alias, or unique title. Or use fromText/toText before resolve.",
},
{ type: "update_text", nodeId: "<id>", text: "新文案" },
{
type: "update_node_meta",
nodeId: "@aiops",
patch: {
note: "自由文本备注",
description: {
function: "故障根因诊断",
owner: "SRE团队",
},
tags: ["人物", "贾府"],
links: [{ url: "https://example.com", title: "百科" }],
accentColor: "#e74c3c",
icon: "person",
alias: "aiops",
dueAt: null,
},
},
{
type: "update_edge_meta",
edgeId: "<edgeId>",
patch: {
label: "配偶",
tags: ["亲属"],
weight: 3,
lineStyle: "dashed",
direction: "both",
note: null,
},
},
{
type: "set_pinned",
nodeId: "<id>",
pinned: true,
pos: { x: 120, y: -80 },
_comment: "Pure position move / lock — prefer this over move_node",
},
{
type: "move_node",
nodeId: "<id>",
newParentId: "<visibleParentId or null for top-level>",
_comment:
"Reparent only (change hierarchy parent). To change x/y only, use set_pinned + pos instead.",
},
{ type: "set_edge_label", edgeId: "<edgeId>", label: "配偶" },
{
type: "reorder",
nodeId: "<id>",
order: 10,
_comment:
"Changes sibling sort; also unpins so auto-layout can move the node visually.",
},
{
type: "reorder",
nodeId: "<id>",
afterSiblingId: "<siblingId or null for first>",
_comment: "Optional alias; server resolves to order",
},
{
type: "set_collapsed",
nodeId: "<id>",
collapsed: true,
_comment: "Hides hierarchy descendants from layout/viewport until expanded",
},
{
type: "set_style_preset",
nodeId: "@baoyu",
stylePreset: "decision",
_comment: "Field is stylePreset (not preset). Values: default|title|decision|risk|note|muted|card",
},
{
type: "unlink",
edgeId: "<edgeId>",
_comment:
"Prefer edgeId when multiple edges exist between the same pair. Sugar from+to(+kind) only works if exactly one live match — label is ignored.",
},
{
type: "unlink",
from: "@daiyu",
to: "@baoyu",
kind: "relation",
_comment:
"Sugar form; fails with ambiguous if >1 edge of that kind between the pair — then use edgeId.",
},
{
type: "restore_node",
nodeId: "<softDeletedNodeId>",
_comment:
"Restores a soft-deleted node (+ compatible incident edges). Use the same id returned by delete_node; @alias/title of deleted nodes also work.",
},
{
type: "auto_fit",
padding: 50,
_comment: "Fit camera to content bbox after layout; pair with batch_create",
},
],
opNotes: {
batch_create:
"Create up to 200 nodes in one op. Two-pass (nodes then parents/relations) so forward @alias refs work. Prefer this over many create_node calls.",
batch_link:
"Create up to 100 edges in one op: {type:'batch_link', edges:[{from,to,kind,label,...}]}. from/to accept id|@alias|title.",
move_node:
"Requires newParentId (visible parent id, or null for top-level). Changes hierarchy parent only — NOT free-drag. Position → set_pinned + pos.",
link:
"kind: hierarchy | relation. from/to: node id, @alias, or unique title. Wire sugar: fromText/toText (resolved server-side before apply).",
unlink:
"Remove an edge. Prefer {type:'unlink', edgeId} (exact). Sugar: {from, to, kind?} with id|@alias|title — server resolves to edgeId when unique. If multiple edges share the same from+to(+kind), sugar is ambiguous → must use edgeId (label is NOT a disambiguator).",
set_style_preset:
"Field name is stylePreset (not preset). Example: {type:'set_style_preset', nodeId:'@x', stylePreset:'risk'}.",
restore_node:
"Soft-deleted nodes keep their id. Pass that id (or deleted @alias / unique title). Restores compatible incident edges; children already promoted stay under the new parent.",
create_node:
"Omit parentId for top-level. Optional pos pins immediately. Optional alias. Optional relations[] for outgoing graph edges (not URL links). Optional description object.",
update_node_meta:
"Patch note/description/tags/links/imageUrl/dueAt/startAt/accentColor/icon/alias. description is a JSON object (knowledge fields). Null clears scalar fields. links = URL bookmarks.",
update_edge_meta:
"Patch label/tags/note/weight(1-5)/lineStyle(solid|dashed|dotted)/direction(forward|both|none) on an edge.",
set_edge_label: "Need edgeId from spatial_context.edges or get_subgraph.",
reorder:
"Sibling order on primary hierarchy edge. Also unpins the node so auto-layout can move it. Example: {\"type\":\"reorder\",\"nodeId\":\"…\",\"order\":0}. Optional afterSiblingId (null=first).",
set_collapsed:
"Recursively hides ALL hierarchy descendants (via primary parent edges) from viewport/layout. Relation-only neighbors stay. Coordinates of collapsed nodes are preserved; expand restores them in place. Does not delete data.",
auto_fit:
"Camera fit to laid-out content. Does not reflow flextree sides. Optional padding (world units, default 50).",
},
highRisk: ["delete_node", "move_node", "set_primary_parent"],
layout:
"No visible root. Prefer batch_create for trees. Pin with set_pinned + pos. After big creates, call auto_fit. Agent refs: prefer alias over raw ids.",
refs: {
alias: "create_node.alias / batch_create → reference as @alias in parentId/nodeId/from/to/relations.to / get_subgraph.rootId",
text: "Unique exact title also works as from/to/parentId; duplicates → ambiguous error",
applyResponse: "mindmap_apply_direct returns nodeIdMap { text|@alias → id }",
},
};

View file

@ -0,0 +1,198 @@
import { describe, expect, it } from "vitest";
import { resolveWireOps, type MindNode } from "./index.js";
function node(partial: Partial<MindNode> & { id: string; text: string }): MindNode {
const t = Date.now();
return {
canvasId: "c1",
collapsed: false,
pinned: false,
sidePref: 0,
stylePreset: "default",
version: 1,
tags: [],
links: [],
createdAt: t,
updatedAt: t,
...partial,
};
}
describe("resolveWireOps", () => {
it("resolves @alias within the same batch", () => {
const r = resolveWireOps(
[
{ type: "create_node", text: "AIOps", alias: "aiops" },
{ type: "create_node", text: "重启", parentId: "@aiops" },
{
type: "link",
from: "@aiops",
to: "重启",
kind: "relation",
label: "诊断→重启",
},
],
[],
);
expect(r.ok).toBe(true);
if (!r.ok) return;
const [a, b, link] = r.ops as Array<Record<string, unknown>>;
expect(a?.id).toBeTruthy();
expect(b?.parentId).toBe(a?.id);
expect(link?.from).toBe(a?.id);
expect(link?.to).toBe(b?.id);
expect(r.nodeIdMap["@aiops"]).toBe(a?.id);
expect(r.nodeIdMap.AIOps).toBe(a?.id);
});
it("resolves fromText/toText against existing nodes", () => {
const live = [
node({ id: "n1", text: "诊断" }),
node({ id: "n2", text: "重启" }),
];
const r = resolveWireOps(
[
{
type: "link",
fromText: "诊断",
toText: "重启",
kind: "relation",
},
],
live,
);
expect(r.ok).toBe(true);
if (!r.ok) return;
const link = r.ops[0] as Record<string, unknown>;
expect(link.from).toBe("n1");
expect(link.to).toBe("n2");
expect(link.fromText).toBeUndefined();
});
it("returns ambiguous when titles collide", () => {
const live = [
node({ id: "n1", text: "同名" }),
node({ id: "n2", text: "同名" }),
];
const r = resolveWireOps(
[{ type: "link", fromText: "同名", toText: "同名", kind: "relation" }],
live,
);
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.error).toBe("ambiguous");
});
it("resolves batch_create with forward @alias parents and relations", () => {
const r = resolveWireOps(
[
{
type: "batch_create",
nodes: [
{ text: "数通", alias: "datacom" },
{
text: "AIOps",
alias: "aiops",
parentId: "@datacom",
relations: [{ to: "@restart", kind: "relation", label: "触发" }],
},
{ text: "重启", alias: "restart", parentId: "@datacom" },
],
},
],
[],
);
expect(r.ok).toBe(true);
if (!r.ok) return;
const batch = r.ops[0] as {
nodes: Array<{
id: string;
parentId?: string;
relations?: Array<{ to: string }>;
}>;
};
const [a, b, c] = batch.nodes;
expect(b?.parentId).toBe(a?.id);
expect(c?.parentId).toBe(a?.id);
expect(b?.relations?.[0]?.to).toBe(c?.id);
expect(r.nodeIdMap["@aiops"]).toBe(b?.id);
});
it("resolves restore_node against soft-deleted id/alias/title", () => {
const live = [
node({ id: "n_live", text: "贾母", alias: "jiamu" }),
node({
id: "n_gone",
text: "林黛玉",
alias: "daiyu",
deletedAt: Date.now(),
}),
];
const byId = resolveWireOps(
[{ type: "restore_node", nodeId: "n_gone" }],
live,
);
expect(byId.ok).toBe(true);
if (byId.ok) {
expect((byId.ops[0] as { nodeId: string }).nodeId).toBe("n_gone");
}
const byAlias = resolveWireOps(
[{ type: "restore_node", nodeId: "@daiyu" }],
live,
);
expect(byAlias.ok).toBe(true);
if (byAlias.ok) {
expect((byAlias.ops[0] as { nodeId: string }).nodeId).toBe("n_gone");
}
const byText = resolveWireOps(
[{ type: "restore_node", nodeText: "林黛玉" }],
live,
);
expect(byText.ok).toBe(true);
if (byText.ok) {
expect((byText.ops[0] as { nodeId: string }).nodeId).toBe("n_gone");
}
const liveReject = resolveWireOps(
[{ type: "restore_node", nodeId: "n_live" }],
live,
);
expect(liveReject.ok).toBe(false);
});
it("resolves unlink from+to to edgeId", () => {
const live = [
node({ id: "n1", text: "黛玉", alias: "daiyu" }),
node({ id: "n2", text: "宝玉", alias: "baoyu" }),
];
const edges = [
{
id: "e1",
canvasId: "c1",
from: "n1",
to: "n2",
kind: "relation" as const,
isPrimaryParent: false,
order: 0,
label: "知己",
tags: [],
version: 1,
createdAt: 1,
updatedAt: 1,
},
];
const r = resolveWireOps(
[{ type: "unlink", from: "@daiyu", to: "@baoyu", kind: "relation" }],
live,
{ edges },
);
expect(r.ok).toBe(true);
if (!r.ok) return;
const op = r.ops[0] as Record<string, unknown>;
expect(op.edgeId).toBe("e1");
expect(op.from).toBeUndefined();
expect(op.to).toBeUndefined();
});
});

View file

@ -0,0 +1,597 @@
import type { MindEdge, MindNode } from "./types.js";
export type WireOp = Record<string, unknown> & { type?: string };
export type ResolveOpsResult =
| {
ok: true;
ops: unknown[];
/** alias / exact text → nodeId (batch + existing). */
nodeIdMap: Record<string, string>;
}
| {
ok: false;
error: "invalid_op" | "ambiguous";
message: string;
candidates?: { id: string; text: string }[];
};
function newId(prefix = "n"): string {
return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
}
function isRefToken(v: unknown): v is string {
return typeof v === "string" && v.length > 0;
}
function normalizeAlias(raw: string): string {
return raw.trim().replace(/^@/, "").toLowerCase();
}
/**
* Expand agent-friendly refs before parseOps:
* - create_node / batch_create.alias → @alias resolvable in same batch / later
* - parentText / fromText / toText / nodeText → unique text match
* - nodeId/parentId/from/to/relations.to starting with @ → alias
* - restore_node resolves against soft-deleted nodes (id / @alias / unique title)
* - unlink accepts edgeId or from+to (optional kind)
* Assigns create_node.id early so later ops in the same batch can reference it.
* batch_create: two-pass register all ids/aliases, then resolve parents/relations.
*/
export function resolveWireOps(
rawOps: unknown[],
liveNodes: MindNode[],
opts?: { anchorId?: string | null; edges?: MindEdge[] },
): ResolveOpsResult {
const anchorId = opts?.anchorId ?? null;
const edges = opts?.edges ?? [];
const aliasToId = new Map<string, string>();
const textToIds = new Map<string, string[]>();
const deletedAliasToId = new Map<string, string>();
const deletedTextToIds = new Map<string, string[]>();
const knownIds = new Set(liveNodes.filter((n) => !n.deletedAt).map((n) => n.id));
const deletedIds = new Set(
liveNodes
.filter((n) => n.deletedAt && n.id !== anchorId)
.map((n) => n.id),
);
const addText = (
map: Map<string, string[]>,
text: string,
id: string,
) => {
const key = text.trim().toLowerCase();
if (!key) return;
const list = map.get(key) ?? [];
if (!list.includes(id)) list.push(id);
map.set(key, list);
};
for (const n of liveNodes) {
if (n.id === anchorId) continue;
if (n.deletedAt) {
addText(deletedTextToIds, n.text, n.id);
if (n.alias) deletedAliasToId.set(normalizeAlias(n.alias), n.id);
continue;
}
addText(textToIds, n.text, n.id);
if (n.alias) aliasToId.set(normalizeAlias(n.alias), n.id);
}
const resolveAlias = (token: string): ResolveOpsResult | string => {
const key = normalizeAlias(token);
const id = aliasToId.get(key);
if (!id) {
return {
ok: false,
error: "invalid_op",
message: `Unknown alias @${key}. Create with alias first, or use mindmap_find.`,
};
}
return id;
};
const resolveDeletedAlias = (token: string): ResolveOpsResult | string => {
const key = normalizeAlias(token);
const id = deletedAliasToId.get(key);
if (!id) {
return {
ok: false,
error: "invalid_op",
message: `Unknown deleted alias @${key}. Pass the soft-deleted node id from delete_node / history.`,
};
}
return id;
};
const resolveDeletedText = (
text: string,
field: string,
): ResolveOpsResult | string => {
const key = text.trim().toLowerCase();
const ids = deletedTextToIds.get(key) ?? [];
if (ids.length === 1) return ids[0]!;
if (ids.length > 1) {
return {
ok: false,
error: "ambiguous",
message: `${field} "${text}" matches ${ids.length} deleted nodes; use node id`,
candidates: ids.slice(0, 8).map((id) => {
const n = liveNodes.find((x) => x.id === id);
return { id, text: n?.text ?? id };
}),
};
}
return {
ok: false,
error: "invalid_op",
message: `${field} "${text}" matched no deleted node. Soft-deleted ids stay valid for restore_node.`,
};
};
const resolveText = (
text: string,
field: string,
): ResolveOpsResult | string => {
const key = text.trim().toLowerCase();
const ids = textToIds.get(key) ?? [];
if (ids.length === 1) return ids[0]!;
if (ids.length === 0) {
const soft: { id: string; text: string }[] = [];
for (const [t, list] of textToIds) {
if (t.includes(key) || key.includes(t)) {
for (const id of list) {
const n = liveNodes.find((x) => x.id === id);
soft.push({
id,
text: n?.text ?? [...textToIds.entries()].find(([, v]) => v.includes(id))?.[0] ?? t,
});
}
}
}
const uniq = [...new Map(soft.map((s) => [s.id, s])).values()];
if (uniq.length === 1) return uniq[0]!.id;
if (uniq.length > 1) {
return {
ok: false,
error: "ambiguous",
message: `${field}="${text}" matches multiple nodes; use @alias or exact id`,
candidates: uniq.slice(0, 8),
};
}
return {
ok: false,
error: "invalid_op",
message: `${field}="${text}" matched no node. Create it first or check spelling.`,
};
}
return {
ok: false,
error: "ambiguous",
message: `${field}="${text}" matches ${ids.length} nodes with the same title; use @alias`,
candidates: ids.slice(0, 8).map((id) => ({
id,
text: liveNodes.find((n) => n.id === id)?.text ?? text,
})),
};
};
const resolveNodeRef = (
value: unknown,
field: string,
mode: "live" | "deleted" = "live",
): ResolveOpsResult | string | null | undefined => {
if (value === undefined) return undefined;
if (value === null) return null;
if (!isRefToken(value)) {
return {
ok: false,
error: "invalid_op",
message: `${field} must be a string id, @alias, or omitted`,
};
}
if (mode === "deleted") {
if (value.startsWith("@")) return resolveDeletedAlias(value);
if (deletedIds.has(value)) return value;
if (deletedAliasToId.has(normalizeAlias(value))) {
return deletedAliasToId.get(normalizeAlias(value))!;
}
// Exact live id that isn't deleted → clearer error than text miss.
if (knownIds.has(value)) {
return {
ok: false,
error: "invalid_op",
message: `${field} ${value} is not deleted; restore_node only targets soft-deleted nodes`,
};
}
return resolveDeletedText(value, field);
}
if (value.startsWith("@")) return resolveAlias(value);
if (knownIds.has(value)) return value;
if (aliasToId.has(normalizeAlias(value))) {
return aliasToId.get(normalizeAlias(value))!;
}
return resolveText(value, field);
};
const registerCreateFields = (
fields: Record<string, unknown>,
path: string,
): ResolveOpsResult | Record<string, unknown> => {
const id =
typeof fields.id === "string" && fields.id.length
? fields.id
: newId("n");
fields.id = id;
knownIds.add(id);
const text = String(fields.text ?? "");
const aliasRaw =
typeof fields.alias === "string" && fields.alias.trim()
? normalizeAlias(fields.alias)
: null;
if (aliasRaw) {
if (aliasToId.has(aliasRaw) && aliasToId.get(aliasRaw) !== id) {
return {
ok: false,
error: "invalid_op",
message: `alias "${aliasRaw}" already used by ${aliasToId.get(aliasRaw)} (${path})`,
};
}
fields.alias = aliasRaw;
aliasToId.set(aliasRaw, id);
}
addText(textToIds, text, id);
return fields;
};
const resolveCreateParentsAndRelations = (
fields: Record<string, unknown>,
path: string,
): ResolveOpsResult | Record<string, unknown> => {
if (fields.parentText != null && fields.parentId == null) {
const r = resolveText(String(fields.parentText), `${path}.parentText`);
if (typeof r !== "string") return r;
fields.parentId = r;
}
delete fields.parentText;
const parentResolved = resolveNodeRef(fields.parentId, `${path}.parentId`);
if (
parentResolved &&
typeof parentResolved === "object" &&
"ok" in parentResolved
) {
return parentResolved;
}
if (parentResolved !== undefined) fields.parentId = parentResolved;
if (Array.isArray(fields.relations)) {
const nextRels: unknown[] = [];
for (let ri = 0; ri < fields.relations.length; ri++) {
const rel = fields.relations[ri];
if (!rel || typeof rel !== "object" || Array.isArray(rel)) {
return {
ok: false,
error: "invalid_op",
message: `${path}.relations[${ri}] must be an object`,
};
}
const r = { ...(rel as Record<string, unknown>) };
if (r.toText != null && r.to == null) {
const tr = resolveText(String(r.toText), `${path}.relations[${ri}].toText`);
if (typeof tr !== "string") return tr;
r.to = tr;
}
delete r.toText;
const toR = resolveNodeRef(r.to, `${path}.relations[${ri}].to`);
if (toR && typeof toR === "object" && "ok" in toR) return toR;
if (typeof toR === "string") r.to = toR;
nextRels.push(r);
}
fields.relations = nextRels;
}
return fields;
};
const out: unknown[] = [];
const batchTextById = new Map<string, string>();
for (let i = 0; i < rawOps.length; i++) {
const raw = rawOps[i];
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return {
ok: false,
error: "invalid_op",
message: `ops[${i}] must be an object`,
};
}
const op: WireOp = { ...(raw as WireOp) };
const type = String(op.type ?? op.op ?? op.operation ?? "");
if (type === "batch_create") {
if (!Array.isArray(op.nodes) || op.nodes.length === 0) {
return {
ok: false,
error: "invalid_op",
message: `ops[${i}].nodes must be a non-empty array`,
};
}
if (op.nodes.length > 200) {
return {
ok: false,
error: "invalid_op",
message: `batch_create supports at most 200 nodes (got ${op.nodes.length})`,
};
}
const registered: Record<string, unknown>[] = [];
for (let ni = 0; ni < op.nodes.length; ni++) {
const nraw = op.nodes[ni];
if (!nraw || typeof nraw !== "object" || Array.isArray(nraw)) {
return {
ok: false,
error: "invalid_op",
message: `ops[${i}].nodes[${ni}] must be an object`,
};
}
const fields = { ...(nraw as Record<string, unknown>) };
const reg = registerCreateFields(fields, `ops[${i}].nodes[${ni}]`);
if ("ok" in reg && reg.ok === false) return reg as ResolveOpsResult;
const f = reg as Record<string, unknown>;
batchTextById.set(String(f.id), String(f.text ?? ""));
registered.push(f);
}
const resolvedNodes: Record<string, unknown>[] = [];
for (let ni = 0; ni < registered.length; ni++) {
const r = resolveCreateParentsAndRelations(
registered[ni]!,
`ops[${i}].nodes[${ni}]`,
);
if ("ok" in r && r.ok === false) return r as ResolveOpsResult;
resolvedNodes.push(r as Record<string, unknown>);
}
op.nodes = resolvedNodes;
out.push(op);
continue;
}
if (type === "batch_link") {
if (!Array.isArray(op.edges) || op.edges.length === 0) {
return {
ok: false,
error: "invalid_op",
message: `ops[${i}].edges must be a non-empty array`,
};
}
if (op.edges.length > 100) {
return {
ok: false,
error: "invalid_op",
message: `batch_link supports at most 100 edges (got ${op.edges.length})`,
};
}
const resolvedEdges: Record<string, unknown>[] = [];
for (let ei = 0; ei < op.edges.length; ei++) {
const eraw = op.edges[ei];
if (!eraw || typeof eraw !== "object" || Array.isArray(eraw)) {
return {
ok: false,
error: "invalid_op",
message: `ops[${i}].edges[${ei}] must be an object`,
};
}
const e: Record<string, unknown> = { ...(eraw as Record<string, unknown>) };
if (e.fromText != null && e.from == null) {
const r = resolveText(String(e.fromText), `edges[${ei}].fromText`);
if (typeof r !== "string") return r;
e.from = r;
}
if (e.toText != null && e.to == null) {
const r = resolveText(String(e.toText), `edges[${ei}].toText`);
if (typeof r !== "string") return r;
e.to = r;
}
delete e.fromText;
delete e.toText;
const fromR = resolveNodeRef(e.from, `edges[${ei}].from`);
if (fromR && typeof fromR === "object" && "ok" in fromR) return fromR;
if (typeof fromR === "string") e.from = fromR;
const toR = resolveNodeRef(e.to, `edges[${ei}].to`);
if (toR && typeof toR === "object" && "ok" in toR) return toR;
if (typeof toR === "string") e.to = toR;
if (!e.kind) e.kind = "relation";
resolvedEdges.push(e);
}
op.edges = resolvedEdges;
out.push(op);
continue;
}
if (type === "create_node") {
const reg = registerCreateFields(op, `ops[${i}]`);
if ("ok" in reg && reg.ok === false) return reg as ResolveOpsResult;
Object.assign(op, reg);
batchTextById.set(String(op.id), String(op.text ?? ""));
const resolved = resolveCreateParentsAndRelations(op, `ops[${i}]`);
if ("ok" in resolved && resolved.ok === false) {
return resolved as ResolveOpsResult;
}
Object.assign(op, resolved);
out.push(op);
continue;
}
if (type === "link") {
if (op.fromText != null && op.from == null) {
const r = resolveText(String(op.fromText), "fromText");
if (typeof r !== "string") return r;
op.from = r;
}
if (op.toText != null && op.to == null) {
const r = resolveText(String(op.toText), "toText");
if (typeof r !== "string") return r;
op.to = r;
}
delete op.fromText;
delete op.toText;
const fromR = resolveNodeRef(op.from, "from");
if (fromR && typeof fromR === "object" && "ok" in fromR) return fromR;
if (typeof fromR === "string") op.from = fromR;
const toR = resolveNodeRef(op.to, "to");
if (toR && typeof toR === "object" && "ok" in toR) return toR;
if (typeof toR === "string") op.to = toR;
out.push(op);
continue;
}
if (type === "restore_node") {
if (op.nodeText != null && op.nodeId == null) {
const r = resolveDeletedText(String(op.nodeText), "nodeText");
if (typeof r !== "string") return r;
op.nodeId = r;
}
delete op.nodeText;
const r = resolveNodeRef(op.nodeId, "nodeId", "deleted");
if (r && typeof r === "object" && "ok" in r) return r;
if (typeof r === "string") op.nodeId = r;
out.push(op);
continue;
}
if (type === "unlink") {
if (op.fromText != null && op.from == null) {
const r = resolveText(String(op.fromText), "fromText");
if (typeof r !== "string") return r;
op.from = r;
}
if (op.toText != null && op.to == null) {
const r = resolveText(String(op.toText), "toText");
if (typeof r !== "string") return r;
op.to = r;
}
delete op.fromText;
delete op.toText;
if (!op.edgeId) {
const fromR = resolveNodeRef(op.from, "from");
if (fromR && typeof fromR === "object" && "ok" in fromR) return fromR;
if (typeof fromR === "string") op.from = fromR;
const toR = resolveNodeRef(op.to, "to");
if (toR && typeof toR === "object" && "ok" in toR) return toR;
if (typeof toR === "string") op.to = toR;
const from = typeof op.from === "string" ? op.from : null;
const to = typeof op.to === "string" ? op.to : null;
if (!from || !to) {
return {
ok: false,
error: "invalid_op",
message:
"unlink needs edgeId, or from+to (id|@alias|title). Optional kind: hierarchy|relation.",
};
}
const kind =
op.kind === "hierarchy" || op.kind === "relation"
? op.kind
: undefined;
const match = (a: string, b: string) =>
edges.filter(
(e) =>
!e.deletedAt &&
e.from === a &&
e.to === b &&
(kind == null || e.kind === kind),
);
let hits = match(from, to);
if (!hits.length) hits = match(to, from);
if (!hits.length) {
return {
ok: false,
error: "invalid_op",
message: `No live edge between ${from} and ${to}${kind ? ` (kind=${kind})` : ""}. Use mindmap_get_edge / spatial_context.edges for edgeId.`,
};
}
if (hits.length > 1) {
return {
ok: false,
error: "ambiguous",
message: `${hits.length} edges between those nodes; pass edgeId or kind`,
candidates: hits.slice(0, 8).map((e) => ({
id: e.id,
text: `${e.kind}${e.label ? `:${e.label}` : ""} ${e.from}→${e.to}`,
})),
};
}
op.edgeId = hits[0]!.id;
}
delete op.from;
delete op.to;
delete op.kind;
out.push(op);
continue;
}
if (op.nodeText != null && op.nodeId == null) {
const r = resolveText(String(op.nodeText), "nodeText");
if (typeof r !== "string") return r;
op.nodeId = r;
}
delete op.nodeText;
for (const field of [
"nodeId",
"newParentId",
"afterSiblingId",
"from",
"to",
"parentId",
] as const) {
if (op[field] === undefined) continue;
const r = resolveNodeRef(op[field], field);
if (r && typeof r === "object" && "ok" in r) return r;
if (r !== undefined) op[field] = r;
}
out.push(op);
}
const nodeIdMap: Record<string, string> = {};
for (const [alias, id] of aliasToId) {
nodeIdMap[`@${alias}`] = id;
nodeIdMap[alias] = id;
}
for (const [text, ids] of textToIds) {
if (ids.length === 1) {
const id = ids[0]!;
const label =
batchTextById.get(id) ??
liveNodes.find((n) => n.id === id)?.text ??
text;
nodeIdMap[label] = id;
}
}
return { ok: true, ops: out, nodeIdMap };
}
/** Build text/alias → id map from a snapshot (for responses). */
export function buildNodeIdMap(
nodes: MindNode[],
opts?: { anchorId?: string | null; onlyIds?: Set<string> },
): Record<string, string> {
const map: Record<string, string> = {};
const anchorId = opts?.anchorId ?? null;
for (const n of nodes) {
if (n.deletedAt || n.id === anchorId) continue;
if (opts?.onlyIds && !opts.onlyIds.has(n.id)) continue;
map[n.text] = n.id;
if (n.alias) {
map[n.alias] = n.id;
map[`@${n.alias}`] = n.id;
}
}
return map;
}

View file

@ -0,0 +1,219 @@
import { z } from "zod";
export const StylePresetSchema = z.enum([
"default",
"title",
"decision",
"risk",
"note",
"muted",
"card",
]);
export type StylePreset = z.infer<typeof StylePresetSchema>;
export const EdgeKindSchema = z.enum(["hierarchy", "relation"]);
export type EdgeKind = z.infer<typeof EdgeKindSchema>;
export const DensitySchema = z.enum(["comfortable", "compact"]);
export type Density = z.infer<typeof DensitySchema>;
export const RiskProfileSchema = z.enum(["fast", "strict"]);
export type RiskProfile = z.infer<typeof RiskProfileSchema>;
export const AgentModeSchema = z.enum(["explore", "edit", "critique"]);
export type AgentMode = z.infer<typeof AgentModeSchema>;
export const NodeBadgesSchema = z.enum(["off", "icons", "tags"]);
export type NodeBadges = z.infer<typeof NodeBadgesSchema>;
export const InspectorModeSchema = z.enum(["selection", "always"]);
export type InspectorMode = z.infer<typeof InspectorModeSchema>;
/** Whole-canvas node chrome: bubble titles vs property cards. */
export const NodeViewModeSchema = z.enum(["bubble", "card"]);
export type NodeViewMode = z.infer<typeof NodeViewModeSchema>;
/** Controlled node icon set (agent/UI). */
export const NodeIconSchema = z.enum([
"server",
"database",
"cloud",
"person",
"folder",
"doc",
"link",
"warning",
"check",
"star",
"gear",
"globe",
]);
export type NodeIcon = z.infer<typeof NodeIconSchema>;
export const EdgeLineStyleSchema = z.enum(["solid", "dashed", "dotted"]);
export type EdgeLineStyle = z.infer<typeof EdgeLineStyleSchema>;
/** Arrowheads: forward = to only, both = ends, none = no arrows. */
export const EdgeDirectionSchema = z.enum(["forward", "both", "none"]);
export type EdgeDirection = z.infer<typeof EdgeDirectionSchema>;
export const SearchScopeSchema = z.enum([
"text",
"note",
"tags",
"description",
"edge_label",
"edge_note",
"all",
]);
export type SearchScope = z.infer<typeof SearchScopeSchema>;
/** Free-form structured knowledge blob (JSON object). */
export const DescriptionSchema = z.record(z.string(), z.unknown());
export type NodeDescription = z.infer<typeof DescriptionSchema>;
/** #RGB or #RRGGBB */
export const AccentColorSchema = z
.string()
.regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
export type AccentColor = z.infer<typeof AccentColorSchema>;
export const ViewportSchema = z.object({
x: z.number(),
y: z.number(),
zoom: z.number(),
});
export type Viewport = z.infer<typeof ViewportSchema>;
export const LinkRefSchema = z.object({
url: z.string().min(1),
title: z.string().optional(),
});
export type LinkRef = z.infer<typeof LinkRefSchema>;
export const CanvasPrefsSchema = z.object({
themeId: z.string().default("abyss"),
branchColoring: z.boolean().default(true),
density: DensitySchema.default("comfortable"),
riskProfile: RiskProfileSchema.default("fast"),
showRelationEdges: z.boolean().default(true),
nodeBadges: NodeBadgesSchema.default("icons"),
showHoverCard: z.boolean().default(true),
inspectorMode: InspectorModeSchema.default("selection"),
nodeViewMode: NodeViewModeSchema.default("card"),
});
export type CanvasPrefs = z.infer<typeof CanvasPrefsSchema>;
export const NodeSchema = z.object({
id: z.string(),
canvasId: z.string(),
text: z.string(),
/** Longer free-text body. */
note: z.string().optional(),
/** Structured knowledge fields (JSON object; keys free-form). */
description: DescriptionSchema.optional(),
collapsed: z.boolean().default(false),
pinned: z.boolean().default(false),
pos: z.object({ x: z.number(), y: z.number() }).nullable().optional(),
sidePref: z.union([z.literal(-1), z.literal(0), z.literal(1)]).default(0),
stylePreset: StylePresetSchema.default("default"),
version: z.number().int().nonnegative(),
tags: z.array(z.string()).default([]),
links: z.array(LinkRefSchema).default([]),
imageUrl: z.string().optional(),
dueAt: z.number().optional(),
startAt: z.number().optional(),
/** Optional accent stroke/fill override (#RGB / #RRGGBB). */
accentColor: AccentColorSchema.optional(),
/** Optional controlled icon id. */
icon: NodeIconSchema.optional(),
/** Stable agent-facing alias (unique per canvas, lowercase). */
alias: z.string().min(1).max(64).optional(),
createdAt: z.number(),
updatedAt: z.number(),
deletedAt: z.number().nullable().optional(),
});
export type MindNode = z.infer<typeof NodeSchema>;
export const EdgeSchema = z.object({
id: z.string(),
canvasId: z.string(),
from: z.string(),
to: z.string(),
kind: EdgeKindSchema,
isPrimaryParent: z.boolean().default(false),
order: z.number().default(0),
label: z.string().optional(),
tags: z.array(z.string()).default([]),
note: z.string().optional(),
/** Relative stroke weight 1–5 (default 1). */
weight: z.number().min(1).max(5).optional(),
lineStyle: EdgeLineStyleSchema.optional(),
direction: EdgeDirectionSchema.optional(),
version: z.number().int().nonnegative(),
createdAt: z.number(),
updatedAt: z.number(),
deletedAt: z.number().nullable().optional(),
});
export type MindEdge = z.infer<typeof EdgeSchema>;
export const CanvasMetaSchema = z.object({
id: z.string(),
title: z.string(),
focusNodeId: z.string().nullable(),
/** Hidden system root — not shown to users/agents; top-level nodes hang under it. */
anchorNodeId: z.string().nullable().optional(),
viewport: ViewportSchema,
rev: z.number().int().nonnegative(),
prefs: CanvasPrefsSchema,
agentMode: AgentModeSchema.default("edit"),
createdAt: z.number(),
updatedAt: z.number(),
deletedAt: z.number().nullable().optional(),
});
export type CanvasMeta = z.infer<typeof CanvasMetaSchema>;
export type GraphSnapshot = {
canvas: CanvasMeta;
nodes: MindNode[];
edges: MindEdge[];
};
export const ErrorCodeSchema = z.enum([
"not_found",
"version_conflict",
"ambiguous",
"budget_exceeded",
"invalid_op",
"proposal_stale",
"cycle_rejected",
"permission_denied",
"mode_denied",
]);
export type ErrorCode = z.infer<typeof ErrorCodeSchema>;
export const TOOL_NAMES = [
"mindmap_list_canvases",
"mindmap_get_context",
"mindmap_get_spatial_context",
"mindmap_get_subgraph",
"mindmap_find",
"mindmap_find_and_focus",
"mindmap_query",
"mindmap_get_node",
"mindmap_export",
"mindmap_history",
"mindmap_redo",
"mindmap_snapshot",
"mindmap_restore_snapshot",
"mindmap_propose",
"mindmap_revise_proposal",
"mindmap_accept_proposal",
"mindmap_reject_proposal",
"mindmap_apply_direct",
"mindmap_undo",
"mindmap_recent_changes",
"mindmap_focus",
"mindmap_set_mode",
] as const;
export type ToolName = (typeof TOOL_NAMES)[number];

View file

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": true
},
"include": ["src/**/*"]
}

File diff suppressed because one or more lines are too long

2168
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

5
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,5 @@
packages:
- "apps/*"
- "packages/*"
allowBuilds:
esbuild: true

40
scripts/extract-skins.mjs Normal file
View file

@ -0,0 +1,40 @@
import fs from "node:fs";
import path from "node:path";
const srcPath =
"C:/Users/zhout/.dsh/profiles/web/node_modules/dsh-dream-skin/lib/client.js";
const src = fs.readFileSync(srcPath, "utf8");
const start = src.indexOf("const SKINS = [");
const end = src.indexOf("];", start);
if (start < 0 || end < 0) throw new Error("SKINS not found");
const block = src.slice(start, end + 2);
const skins = new Function(`${block}; return SKINS;`)();
const names = {
abyss: ["Abyss", "深渊"],
aurora: ["Aurora", "极光"],
nebula: ["Nebula", "星云"],
ember: ["Ember", "余烬"],
midnight: ["Midnight", "午夜"],
ivory: ["Ivory", "象牙"],
mist: ["Mist", "晨雾"],
rose: ["Rose", "玫瑰"],
};
const out = skins.map((s) => ({
id: s.id,
colorScheme: s.colorScheme,
name: names[s.id]?.[0] ?? s.id,
nameZh: names[s.id]?.[1] ?? s.id,
tokens: s.tokens,
}));
const dir = path.resolve("packages/dream-skin/src");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "skins.json"), JSON.stringify(out, null, 2));
console.log(
"wrote",
out.length,
"skins:",
out.map((s) => s.id).join(","),
);

84
scripts/stress-10k.mts Normal file
View file

@ -0,0 +1,84 @@
/**
* Stress: create a canvas and grow to N nodes via API.
* Usage: node --import tsx scripts/stress-10k.mts [count=10000]
*/
const BASE = process.env.MINDMAP_URL ?? "http://127.0.0.1:17890";
const COUNT = Number(process.argv[2] ?? 10_000);
async function json<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
...init,
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res.json() as Promise<T>;
}
async function main() {
const t0 = performance.now();
const created = await json<{
canvas: { id: string; focusNodeId: string };
}>(`${BASE}/api/canvases`, {
method: "POST",
body: JSON.stringify({ title: `stress-${COUNT}` }),
});
const canvasId = created.canvas.id;
let parent = created.canvas.focusNodeId;
const batch = 50;
let made = 1;
console.log(`canvas ${canvasId}, target ${COUNT}`);
while (made < COUNT) {
const n = Math.min(batch, COUNT - made);
const ops = Array.from({ length: n }, (_, i) => ({
type: "create_node" as const,
text: `n${made + i}`,
parentId: parent,
sidePref: (made + i) % 2 === 0 ? (-1 as const) : (1 as const),
}));
const tBatch = performance.now();
const result = await json<{
snapshot: { nodes: { id: string }[]; canvas: { focusNodeId: string } };
positions: Record<string, unknown>;
}>(`${BASE}/api/canvases/${canvasId}/ops`, {
method: "POST",
body: JSON.stringify({ ops, origin: "user", summary: `batch ${made}` }),
});
made += n;
// widen tree: periodically use a recent child as parent
const nodes = result.snapshot.nodes;
parent = nodes[Math.floor(nodes.length * 0.5)]?.id ?? parent;
const dt = performance.now() - tBatch;
if (made % 500 === 0 || made >= COUNT) {
console.log(
`nodes=${made} batch=${dt.toFixed(0)}ms layoutKeys=${Object.keys(result.positions).length}`,
);
}
}
const tLoad0 = performance.now();
const loaded = await json<{ nodes: unknown[]; positions: Record<string, unknown> }>(
`${BASE}/api/canvases/${canvasId}`,
);
const tLoad = performance.now() - tLoad0;
const total = performance.now() - t0;
console.log(
JSON.stringify(
{
ok: true,
canvasId,
nodes: loaded.nodes.length,
positions: Object.keys(loaded.positions).length,
loadMs: Math.round(tLoad),
totalMs: Math.round(total),
},
null,
2,
),
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

19
tsconfig.base.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
}
}