mirror of
https://github.com/hansjone/mind-map.git
synced 2026-10-08 22:21:05 +08:00
Initial commit: DSH mind-map canvas monorepo
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
2ecd300ada
92 changed files with 20486 additions and 0 deletions
7
.env.example
Normal file
7
.env.example
Normal 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
13
.gitignore
vendored
Normal 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
37
README.md
Normal 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
32
apps/server/package.json
Normal 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
206
apps/server/src/chat.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
736
apps/server/src/create-server.ts
Normal file
736
apps/server/src/create-server.ts
Normal 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
3
apps/server/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { startMindMapServer } from "./create-server.js";
|
||||
|
||||
startMindMapServer();
|
||||
2005
apps/server/src/tools.ts
Normal file
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
16
apps/server/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
1
apps/server/tsconfig.tsbuildinfo
Normal file
1
apps/server/tsconfig.tsbuildinfo
Normal 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
12
apps/web/index.html
Normal 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
28
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/web/scripts/_check_cjk.py
Normal file
6
apps/web/scripts/_check_cjk.py
Normal 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)
|
||||
15
apps/web/scripts/_fix_leftover.py
Normal file
15
apps/web/scripts/_fix_leftover.py
Normal 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)))
|
||||
13
apps/web/scripts/_inspect_cjk.py
Normal file
13
apps/web/scripts/_inspect_cjk.py
Normal 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)))
|
||||
24
apps/web/scripts/check-cjk-dist.py
Normal file
24
apps/web/scripts/check-cjk-dist.py
Normal 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))
|
||||
207
apps/web/scripts/fix-topology-cjk.py
Normal file
207
apps/web/scripts/fix-topology-cjk.py
Normal 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)
|
||||
115
apps/web/scripts/fix-topology-cjk2.py
Normal file
115
apps/web/scripts/fix-topology-cjk2.py
Normal 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)
|
||||
221
apps/web/scripts/fix-topology-cjk3.py
Normal file
221
apps/web/scripts/fix-topology-cjk3.py
Normal 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)
|
||||
22
apps/web/scripts/fix-topology-cjk4.py
Normal file
22
apps/web/scripts/fix-topology-cjk4.py
Normal 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")
|
||||
214
apps/web/scripts/fix-topology-cjk5.py
Normal file
214
apps/web/scripts/fix-topology-cjk5.py
Normal 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")
|
||||
10
apps/web/scripts/verify-dist-cjk.py
Normal file
10
apps/web/scripts/verify-dist-cjk.py
Normal 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)
|
||||
14
apps/web/scripts/verify-topology-cjk.py
Normal file
14
apps/web/scripts/verify-topology-cjk.py
Normal 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
346
apps/web/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
apps/web/src/HoverCard.tsx
Normal file
37
apps/web/src/HoverCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
apps/web/src/NodeInspector.tsx
Normal file
197
apps/web/src/NodeInspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1581
apps/web/src/TopologyCanvas.tsx
Normal file
1581
apps/web/src/TopologyCanvas.tsx
Normal file
File diff suppressed because it is too large
Load diff
104
apps/web/src/canvas-chrome.ts
Normal file
104
apps/web/src/canvas-chrome.ts
Normal 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;
|
||||
}
|
||||
56
apps/web/src/drag-subtree.ts
Normal file
56
apps/web/src/drag-subtree.ts
Normal 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;
|
||||
}
|
||||
31
apps/web/src/image-cache.ts
Normal file
31
apps/web/src/image-cache.ts
Normal 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
5
apps/web/src/main.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
180
apps/web/src/node-geometry.ts
Normal file
180
apps/web/src/node-geometry.ts
Normal 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: "[球]",
|
||||
};
|
||||
411
apps/web/src/node-props/NodePropEditor.tsx
Normal file
411
apps/web/src/node-props/NodePropEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
162
apps/web/src/node-props/draw-sheet.ts
Normal file
162
apps/web/src/node-props/draw-sheet.ts
Normal 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();
|
||||
}
|
||||
5
apps/web/src/node-props/index.ts
Normal file
5
apps/web/src/node-props/index.ts
Normal 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";
|
||||
311
apps/web/src/node-props/node-shell.css
Normal file
311
apps/web/src/node-props/node-shell.css
Normal 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;
|
||||
}
|
||||
283
apps/web/src/node-props/registry.ts
Normal file
283
apps/web/src/node-props/registry.ts
Normal 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);
|
||||
}
|
||||
29
apps/web/src/node-props/sizing.ts
Normal file
29
apps/web/src/node-props/sizing.ts
Normal 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) };
|
||||
}
|
||||
41
apps/web/src/node-props/types.ts
Normal file
41
apps/web/src/node-props/types.ts
Normal 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;
|
||||
};
|
||||
77
apps/web/src/node-props/upload-image.ts
Normal file
77
apps/web/src/node-props/upload-image.ts
Normal 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
378
apps/web/src/store.ts
Normal 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
560
apps/web/src/styles.css
Normal 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;
|
||||
}
|
||||
137
apps/web/src/system-theme.ts
Normal file
137
apps/web/src/system-theme.ts
Normal 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
11
apps/web/tsconfig.json
Normal 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
14
apps/web/vite.config.ts
Normal 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
30
docs/dsh-bridge.md
Normal 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
22
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
22
packages/dream-skin/package.json
Normal file
22
packages/dream-skin/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
133
packages/dream-skin/src/index.ts
Normal file
133
packages/dream-skin/src/index.ts
Normal 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)}`;
|
||||
}
|
||||
330
packages/dream-skin/src/skins.json
Normal file
330
packages/dream-skin/src/skins.json
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
9
packages/dream-skin/tsconfig.json
Normal file
9
packages/dream-skin/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
1
packages/dream-skin/tsconfig.tsbuildinfo
Normal file
1
packages/dream-skin/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
15
packages/dsh-plugin/INSTALL.md
Normal file
15
packages/dsh-plugin/INSTALL.md
Normal 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`)。
|
||||
11
packages/dsh-plugin/README.md
Normal file
11
packages/dsh-plugin/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# dsh-mind-map
|
||||
|
||||
DSH 插件:自建画布 Web;**对话 = DSH 会话镜像**(输入转发,历史由 DSH 管)。
|
||||
|
||||
## 架构
|
||||
|
||||
- Host:注册 `mindmap_*`、启停本机 Web、会话绑定
|
||||
- Client:设置卡(端口 / 打开画布 / 绑定)
|
||||
- Web 右栏:零持久化镜像,不做独立 chat
|
||||
|
||||
详见仓库 `docs/dsh-bridge.md` 与设计稿 v2.9。
|
||||
4
packages/dsh-plugin/cordis.patch.yml
Normal file
4
packages/dsh-plugin/cordis.patch.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Mind Map — DSH profile patch
|
||||
- insert:
|
||||
- id: dsh-mind-map
|
||||
name: dsh-mind-map
|
||||
1223
packages/dsh-plugin/lib/client.js
Normal file
1223
packages/dsh-plugin/lib/client.js
Normal file
File diff suppressed because it is too large
Load diff
380
packages/dsh-plugin/lib/index.js
Normal file
380
packages/dsh-plugin/lib/index.js
Normal 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')
|
||||
}
|
||||
284
packages/dsh-plugin/lib/runtime.js
Normal file
284
packages/dsh-plugin/lib/runtime.js
Normal 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 }
|
||||
}
|
||||
444
packages/dsh-plugin/lib/session-bridge.js
Normal file
444
packages/dsh-plugin/lib/session-bridge.js
Normal 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,
|
||||
}
|
||||
}
|
||||
717
packages/dsh-plugin/lib/tools.js
Normal file
717
packages/dsh-plugin/lib/tools.js
Normal 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.`,
|
||||
}
|
||||
}
|
||||
43
packages/dsh-plugin/package.json
Normal file
43
packages/dsh-plugin/package.json
Normal 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"
|
||||
}
|
||||
25
packages/graph-core/package.json
Normal file
25
packages/graph-core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
242
packages/graph-core/src/index.test.ts
Normal file
242
packages/graph-core/src/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1017
packages/graph-core/src/index.ts
Normal file
1017
packages/graph-core/src/index.ts
Normal file
File diff suppressed because it is too large
Load diff
10
packages/graph-core/tsconfig.json
Normal file
10
packages/graph-core/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"references": [{ "path": "../shared" }]
|
||||
}
|
||||
1
packages/graph-core/tsconfig.tsbuildinfo
Normal file
1
packages/graph-core/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
27
packages/graph-store/package.json
Normal file
27
packages/graph-store/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
28
packages/graph-store/src/index.test.ts
Normal file
28
packages/graph-store/src/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
841
packages/graph-store/src/index.ts
Normal file
841
packages/graph-store/src/index.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
packages/graph-store/tsconfig.json
Normal file
11
packages/graph-store/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
1
packages/graph-store/tsconfig.tsbuildinfo
Normal file
1
packages/graph-store/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
28
packages/layout-engine/package.json
Normal file
28
packages/layout-engine/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
19
packages/layout-engine/src/d3-flextree.d.ts
vendored
Normal file
19
packages/layout-engine/src/d3-flextree.d.ts
vendored
Normal 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;
|
||||
}
|
||||
236
packages/layout-engine/src/index.test.ts
Normal file
236
packages/layout-engine/src/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
579
packages/layout-engine/src/index.ts
Normal file
579
packages/layout-engine/src/index.ts
Normal 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;
|
||||
}
|
||||
10
packages/layout-engine/tsconfig.json
Normal file
10
packages/layout-engine/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"references": [{ "path": "../shared" }]
|
||||
}
|
||||
1
packages/layout-engine/tsconfig.tsbuildinfo
Normal file
1
packages/layout-engine/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
25
packages/shared/package.json
Normal file
25
packages/shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
212
packages/shared/src/graph-query.test.ts
Normal file
212
packages/shared/src/graph-query.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
679
packages/shared/src/graph-query.ts
Normal file
679
packages/shared/src/graph-query.ts
Normal 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.`,
|
||||
};
|
||||
}
|
||||
55
packages/shared/src/index.ts
Normal file
55
packages/shared/src/index.ts
Normal 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
518
packages/shared/src/ops.ts
Normal 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 }",
|
||||
},
|
||||
};
|
||||
198
packages/shared/src/resolve-ops.test.ts
Normal file
198
packages/shared/src/resolve-ops.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
597
packages/shared/src/resolve-ops.ts
Normal file
597
packages/shared/src/resolve-ops.ts
Normal 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;
|
||||
}
|
||||
219
packages/shared/src/types.ts
Normal file
219
packages/shared/src/types.ts
Normal 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];
|
||||
9
packages/shared/tsconfig.json
Normal file
9
packages/shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
1
packages/shared/tsconfig.tsbuildinfo
Normal file
1
packages/shared/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
2168
pnpm-lock.yaml
generated
Normal file
2168
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
5
pnpm-workspace.yaml
Normal file
5
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
40
scripts/extract-skins.mjs
Normal file
40
scripts/extract-skins.mjs
Normal 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
84
scripts/stress-10k.mts
Normal 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
19
tsconfig.base.json
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue