commit 2ecd300ada2422a84d673a1c802f3182cad84120 Author: oliver Date: Sat Sep 12 08:53:37 2026 +0800 Initial commit: DSH mind-map canvas monorepo Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..36f8bea --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a21a13 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d7906fb --- /dev/null +++ b/README.md @@ -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 +``` + +浏览器打开 。图 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`。 diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..428b85d --- /dev/null +++ b/apps/server/package.json @@ -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" + } +} diff --git a/apps/server/src/chat.ts b/apps/server/src/chat.ts new file mode 100644 index 0000000..2a685a5 --- /dev/null +++ b/apps/server/src/chat.ts @@ -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, 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, 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, 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, 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; + }; +} diff --git a/apps/server/src/create-server.ts b/apps/server/src/create-server.ts new file mode 100644 index 0000000..1070f26 --- /dev/null +++ b/apps/server/src/create-server.ts @@ -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; + 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; +}; + +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(); + + 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; + 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; + }; + 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 = { + "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((resolve) => { + wss.close(); + server.close(() => resolve()); + }); + store.close(); + }, + }; +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..cf24bd1 --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,3 @@ +import { startMindMapServer } from "./create-server.js"; + +startMindMapServer(); diff --git a/apps/server/src/tools.ts b/apps/server/src/tools.ts new file mode 100644 index 0000000..d3c5b21 --- /dev/null +++ b/apps/server/src/tools.ts @@ -0,0 +1,2005 @@ +import type { SqliteGraphStore } from "@mind-map/graph-store"; +import { applyOps, cloneGraph, toSnapshot } from "@mind-map/graph-core"; +import type { MindEdge, MindNode, Op, SearchScope } from "@mind-map/shared"; +import { + newId, + now, + parseOps, + resolveWireOps, + buildNodeIdMap, + computeFitViewport, + queryNeighbors, + queryPaths, + queryStats, + queryOrphans, + queryMissingMeta, + resolveQueryNodeRef, + nodeFunction, + OP_SCHEMA_DOC, + OP_TYPES, +} from "@mind-map/shared"; +import { hierarchyFingerprint, layout } from "@mind-map/layout-engine"; + +export type ClientActivity = { + canvasId: string; + selectionIds: string[]; + focusNodeId: string | null; + viewport: { x: number; y: number; zoom: number }; + updatedAt: number; +}; + +const activityByCanvas = new Map(); + +function prepareOps( + store: SqliteGraphStore, + canvasId: string, + rawOps: unknown, +): + | { ok: true; ops: Op[]; nodeIdMap: Record } + | { + ok: false; + error: string; + message: string; + candidates?: { id: string; text: string }[]; + validTypes?: readonly string[]; + issues?: unknown; + } { + if (!Array.isArray(rawOps)) { + const parsed = parseOps(rawOps); + return parsed.ok + ? { ok: true, ops: parsed.ops, nodeIdMap: {} } + : parsed; + } + // Full graph (incl. soft-deleted) so restore_node / unlink from+to can resolve. + const g = store.loadCanvas(canvasId); + const resolved = resolveWireOps( + rawOps, + [...g.nodes.values()], + { + anchorId: g.canvas.anchorNodeId ?? null, + edges: [...g.edges.values()], + }, + ); + if (!resolved.ok) { + return { + ok: false, + error: resolved.error, + message: resolved.message, + candidates: resolved.candidates, + validTypes: OP_TYPES, + }; + } + const parsed = parseOps(resolved.ops); + if (!parsed.ok) return parsed; + return { ok: true, ops: parsed.ops, nodeIdMap: resolved.nodeIdMap }; +} + +function parseSearchScope(raw: unknown): SearchScope { + const v = String(raw ?? "text"); + if ( + v === "note" || + v === "tags" || + v === "all" || + v === "text" || + v === "description" || + v === "edge_label" || + v === "edge_note" + ) + return v; + return "text"; +} + +function findVisibleHits( + store: SqliteGraphStore, + canvasId: string, + query: string, + scope: SearchScope, + limit = 20, +) { + const view = publicGraphView(store, canvasId); + const visible = new Set(view.nodes.map((n) => n.id)); + const hits = store.search(canvasId, query, limit, scope).filter((h) => { + if (h.kind === "edge") { + return ( + visible.has(h.from) && + visible.has(h.to) && + h.from !== view.anchorId && + h.to !== view.anchorId + ); + } + return h.id !== view.anchorId && visible.has(h.id); + }); + const ambiguous = + hits.length > 1 && hits[0]!.score - (hits[1]?.score ?? 0) < 0.15; + return { view, hits, ambiguous }; +} + +function candidateFromHit( + h: ReturnType[number], +) { + if (h.kind === "edge") { + return { + kind: "edge" as const, + edgeId: h.id, + from: h.from, + to: h.to, + fromText: h.fromText, + toText: h.toText, + label: h.label, + note: h.note, + score: h.score, + matchIn: h.matchIn, + }; + } + return { + kind: "node" as const, + id: h.id, + text: h.text, + score: h.score, + matchIn: h.matchIn, + }; +} + +function exportMarkdown( + nodes: MindNode[], + edges: MindEdge[], + title: string, + anchorId: string | null, +): string { + const byId = new Map(nodes.map((n) => [n.id, n])); + const children = new Map(); + for (const e of edges) { + if (e.deletedAt || e.kind !== "hierarchy" || !e.isPrimaryParent) continue; + if (!byId.has(e.from) || !byId.has(e.to)) continue; + const list = children.get(e.from) ?? []; + list.push(e.to); + children.set(e.from, list); + } + for (const [from, list] of children) { + list.sort((a, b) => { + const oa = + edges.find( + (e) => + e.kind === "hierarchy" && + e.isPrimaryParent && + e.from === from && + e.to === a && + !e.deletedAt, + )?.order ?? 0; + const ob = + edges.find( + (e) => + e.kind === "hierarchy" && + e.isPrimaryParent && + e.from === from && + e.to === b && + !e.deletedAt, + )?.order ?? 0; + return oa - ob; + }); + } + + const lines: string[] = [`# ${title}`, ""]; + const walk = (id: string, depth: number, path: Set) => { + if (path.has(id)) return; + const n = byId.get(id); + if (!n || n.deletedAt) return; + const next = new Set(path); + next.add(id); + if (id !== anchorId) { + const pad = " ".repeat(Math.max(0, depth)); + const icon = n.icon ? `[${n.icon}] ` : ""; + lines.push(`${pad}- ${icon}${n.text}`); + if (n.note?.trim()) lines.push(`${pad} > ${n.note.trim()}`); + if (n.tags?.length) lines.push(`${pad} tags: ${n.tags.join(", ")}`); + } + for (const cid of children.get(id) ?? []) { + walk(cid, id === anchorId ? 0 : depth + 1, next); + } + }; + + const roots = + anchorId && children.get(anchorId)?.length + ? children.get(anchorId)! + : nodes + .filter((n) => !n.deletedAt && n.id !== anchorId) + .filter( + (n) => + !edges.some( + (e) => + e.kind === "hierarchy" && + e.isPrimaryParent && + e.to === n.id && + !e.deletedAt, + ), + ) + .map((n) => n.id); + + if (anchorId) walk(anchorId, 0, new Set()); + else for (const id of roots) walk(id, 0, new Set()); + + const relations = edges.filter( + (e) => e.kind === "relation" && !e.deletedAt && byId.has(e.from) && byId.has(e.to), + ); + if (relations.length) { + lines.push("", "## Relations", ""); + for (const e of relations) { + const a = byId.get(e.from)?.text ?? e.from; + const b = byId.get(e.to)?.text ?? e.to; + const label = e.label ? ` (${e.label})` : ""; + lines.push(`- ${a} → ${b}${label}`); + } + } + return `${lines.join("\n")}\n`; +} + +function escapeXml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function exportSvg( + nodes: MindNode[], + edges: MindEdge[], + positions: Record, + title: string, + anchorId: string | null, +): string { + const visible = nodes.filter( + (n) => !n.deletedAt && n.id !== anchorId && positions[n.id], + ); + if (!visible.length) { + return `${escapeXml(title)} (empty)`; + } + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + const boxW = 160; + const boxH = 40; + for (const n of visible) { + const p = positions[n.id]!; + minX = Math.min(minX, p.x - boxW / 2); + maxX = Math.max(maxX, p.x + boxW / 2); + minY = Math.min(minY, p.y - boxH / 2); + maxY = Math.max(maxY, p.y + boxH / 2); + } + const pad = 48; + const width = Math.ceil(maxX - minX + pad * 2); + const height = Math.ceil(maxY - minY + pad * 2); + const tx = (x: number) => x - minX + pad; + const ty = (y: number) => y - minY + pad; + const byId = new Set(visible.map((n) => n.id)); + const parts: string[] = [ + ``, + ` + + + +`, + ``, + `${escapeXml(title)}`, + ]; + for (const e of edges) { + if (e.deletedAt || !byId.has(e.from) || !byId.has(e.to)) continue; + const a = positions[e.from]!; + const b = positions[e.to]!; + const dash = + e.lineStyle === "dotted" + ? ' stroke-dasharray="2 4"' + : e.kind === "relation" || e.lineStyle === "dashed" + ? ' stroke-dasharray="6 4"' + : ""; + const dir = e.direction ?? (e.kind === "relation" ? "forward" : "none"); + const markerEnd = + dir === "forward" || dir === "both" ? ' marker-end="url(#arrow)"' : ""; + const markerStart = dir === "both" ? ' marker-start="url(#arrow)"' : ""; + parts.push( + ``, + ); + if (e.label) { + parts.push( + `${escapeXml(e.label)}`, + ); + } + } + for (const n of visible) { + const p = positions[n.id]!; + const x = tx(p.x) - boxW / 2; + const y = ty(p.y) - boxH / 2; + const fill = n.accentColor ?? "#1b1e28"; + const label = n.text.slice(0, 18); + parts.push( + ``, + `${escapeXml(label)}`, + ); + } + parts.push(``); + return parts.join("\n"); +} + +export function setActivity(a: ClientActivity) { + activityByCanvas.set(a.canvasId, { ...a, updatedAt: now() }); +} + +export function getActivity(canvasId: string): ClientActivity | null { + return activityByCanvas.get(canvasId) ?? null; +} + +export type ProposalRecord = { + id: string; + canvasId: string; + baseRev: number; + rationale: string; + status: "pending" | "accepted" | "rejected" | "partially_accepted" | "stale"; + revision: number; + ops: Op[]; + createdAt: number; + updatedAt: number; +}; + +const proposals = new Map(); + +export function listProposals(canvasId: string): ProposalRecord[] { + return [...proposals.values()] + .filter((p) => p.canvasId === canvasId) + .sort((a, b) => b.updatedAt - a.updatedAt); +} + +export function getProposal(id: string): ProposalRecord | undefined { + return proposals.get(id); +} + +export function createProposal( + canvasId: string, + baseRev: number, + ops: Op[], + rationale: string, +): ProposalRecord { + const id = newId("p"); + const t = now(); + const rec: ProposalRecord = { + id, + canvasId, + baseRev, + rationale, + status: "pending", + revision: 1, + ops, + createdAt: t, + updatedAt: t, + }; + proposals.set(id, rec); + return rec; +} + +export function reviseProposal( + id: string, + ops: Op[], + rationale: string, +): ProposalRecord { + const p = proposals.get(id); + if (!p || p.status !== "pending") throw new Error("proposal not pending"); + p.ops = ops; + p.rationale = rationale; + p.revision += 1; + p.updatedAt = now(); + return p; +} + +export function rejectProposal(id: string): ProposalRecord { + const p = proposals.get(id); + if (!p) throw new Error("not found"); + p.status = "rejected"; + p.updatedAt = now(); + return p; +} + +const HIGH_RISK = new Set(["delete_node", "move_node", "set_primary_parent"]); + +export function isHighRisk(op: Op): boolean { + return HIGH_RISK.has(op.type); +} + +export type ToolContext = { + store: SqliteGraphStore; + broadcast: (payload: unknown) => void; + defaultCanvasId?: string; +}; + +const layoutCache = new Map< + string, + { + positions: Record; + hierarchyFp: string; + density: string; + nodeViewMode: string; + } +>(); + +function withLayout(store: SqliteGraphStore, canvasId: string) { + const snap = store.getSnapshot(canvasId); + const anchorId = snap.canvas.anchorNodeId ?? null; + // Prefer hidden anchor as layout root so the map stays a stable left/right + // mind-map (central topic). Focus only drives camera / selection, not re-root. + const layoutRoot = + (anchorId && snap.nodes.some((n) => n.id === anchorId && !n.deletedAt) + ? anchorId + : null) ?? + snap.canvas.focusNodeId ?? + snap.nodes.find((n) => !n.deletedAt)?.id; + const cached = layoutCache.get(canvasId); + const density = snap.canvas.prefs.density; + const nodeViewMode = snap.canvas.prefs.nodeViewMode ?? "card"; + const viewChanged = + Boolean(cached) && + (cached!.density !== density || cached!.nodeViewMode !== nodeViewMode); + const positions = layoutRoot + ? layout({ + nodes: snap.nodes, + edges: snap.edges, + focusNodeId: layoutRoot, + density, + nodeViewMode, + prevPositions: cached?.positions, + prevHierarchyFp: cached?.hierarchyFp, + structureChanged: viewChanged, + }) + : {}; + if (layoutRoot) { + layoutCache.set(canvasId, { + positions: { ...positions }, + hierarchyFp: hierarchyFingerprint(snap.edges), + density, + nodeViewMode, + }); + } + return { snap, positions, anchorId }; +} + +/** After auto_fit op: layout then write canvas.viewport. */ +export function finalizeAutoFit( + store: SqliteGraphStore, + canvasId: string, + padding = 50, +) { + layoutCache.delete(canvasId); + const { positions, anchorId } = withLayout(store, canvasId); + const publicPositions = { ...positions }; + if (anchorId) delete publicPositions[anchorId]; + const viewport = computeFitViewport(publicPositions, { padding }); + store.updateViewport(canvasId, viewport); + return viewport; +} + +function estimateOverlaps( + nodes: MindNode[], + positions: Record, + nodeViewMode: string, + anchorId: string | null, +) { + const w = nodeViewMode === "card" ? 180 : 120; + const h = nodeViewMode === "card" ? 72 : 36; + const ids = nodes + .filter((n) => !n.deletedAt && n.id !== anchorId && positions[n.id]) + .map((n) => n.id); + const pairs: { a: string; b: string; dx: number; dy: number }[] = []; + for (let i = 0; i < ids.length; i++) { + for (let j = i + 1; j < ids.length; j++) { + const a = ids[i]!; + const b = ids[j]!; + const pa = positions[a]!; + const pb = positions[b]!; + const dx = Math.abs(pa.x - pb.x); + const dy = Math.abs(pa.y - pb.y); + if (dx < w && dy < h) { + pairs.push({ a, b, dx, dy }); + } + } + } + return pairs.slice(0, 40); +} + +function layoutGraphPreview( + _store: SqliteGraphStore, + canvasId: string, + graph: ReturnType, +) { + const snap = toSnapshot(graph); + const cached = layoutCache.get(canvasId); + const anchorId = snap.canvas.anchorNodeId ?? null; + const layoutRoot = + (anchorId && snap.nodes.some((n) => n.id === anchorId && !n.deletedAt) + ? anchorId + : null) ?? + snap.canvas.focusNodeId ?? + snap.nodes.find((n) => !n.deletedAt)?.id; + const density = snap.canvas.prefs.density; + const nodeViewMode = snap.canvas.prefs.nodeViewMode ?? "card"; + const positions = layoutRoot + ? layout({ + nodes: snap.nodes, + edges: snap.edges, + focusNodeId: layoutRoot, + density, + nodeViewMode, + prevPositions: cached?.positions, + prevHierarchyFp: cached?.hierarchyFp, + structureChanged: true, + }) + : {}; + return { + snap, + positions, + anchorId, + overlaps: estimateOverlaps(snap.nodes, positions, nodeViewMode, anchorId), + }; +} + +function isPublicNode( + n: { id: string; deletedAt?: number | null }, + anchorId: string | null, +) { + return !n.deletedAt && n.id !== anchorId; +} + +/** Agent/UI-facing snapshot: hide system anchor + collapsed-branch descendants. */ +export function publicGraphView(store: SqliteGraphStore, canvasId: string) { + const { snap, positions, anchorId } = withLayout(store, canvasId); + // Layout omits collapsed descendants from positions — treat as not rendered. + const nodes = snap.nodes.filter( + (n) => isPublicNode(n, anchorId) && positions[n.id] != null, + ); + const nodeIds = new Set(nodes.map((n) => n.id)); + const edges = snap.edges.filter( + (e) => + !e.deletedAt && + e.from !== anchorId && + e.to !== anchorId && + nodeIds.has(e.from) && + nodeIds.has(e.to), + ); + const publicFocus = + snap.canvas.focusNodeId && snap.canvas.focusNodeId !== anchorId + ? snap.canvas.focusNodeId + : nodes[0]?.id ?? null; + return { + snap, + anchorId, + nodes, + edges, + positions, + publicFocus, + canvas: { + id: snap.canvas.id, + title: snap.canvas.title, + rev: snap.canvas.rev, + focusNodeId: publicFocus, + prefs: snap.canvas.prefs, + agentMode: snap.canvas.agentMode, + }, + }; +} + +export function buildSpatialContext( + store: SqliteGraphStore, + canvasId: string, + opts?: { compact?: boolean }, +) { + const activity = getActivity(canvasId); + const view = publicGraphView(store, canvasId); + const { snap, positions, anchorId, nodes, edges } = view; + const compact = opts?.compact !== false; + + let focusId = + activity?.focusNodeId && activity.focusNodeId !== anchorId + ? activity.focusNodeId + : view.publicFocus; + + const selection = (activity?.selectionIds?.length + ? activity.selectionIds.filter((id) => id !== anchorId) + : focusId + ? [focusId] + : [] + ) + .map((id) => nodes.find((n) => n.id === id)) + .filter(Boolean) + .map((n) => ({ id: n!.id, text: n!.text })); + + const vp = activity?.viewport ?? snap.canvas.viewport; + const ranked = nodes + .map((n) => { + const p = positions[n.id] ?? { x: 0, y: 0 }; + const dx = p.x - (vp.x || 0); + const dy = p.y - (vp.y || 0); + return { n, d: dx * dx + dy * dy }; + }) + .sort((a, b) => a.d - b.d) + .slice(0, compact ? 60 : 80) + .map(({ n }) => { + const base: Record = { + id: n.id, + text: n.text, + collapsed: n.collapsed || undefined, + childCount: n.collapsed + ? 0 + : edges.filter( + (e) => + e.kind === "hierarchy" && e.isPrimaryParent && e.from === n.id, + ).length, + collapsedChildCount: n.collapsed + ? edges.filter( + (e) => e.kind === "hierarchy" && e.from === n.id, + ).length + : undefined, + }; + if (n.alias) base.alias = n.alias; + const fn = nodeFunction(n); + if (fn) base.function = fn; + return base; + }); + + const topIds = new Set(ranked.map((r) => r.id as string)); + const topLevelParent = anchorId; + const periphery = snap.edges + .filter( + (e) => + e.kind === "hierarchy" && + e.isPrimaryParent && + !e.deletedAt && + e.from === topLevelParent && + !topIds.has(e.to), + ) + .slice(0, 12) + .map((e) => { + const node = nodes.find((n) => n.id === e.to); + const count = edges.filter( + (x) => x.kind === "hierarchy" && x.isPrimaryParent && x.from === e.to, + ).length; + return { + clusterId: e.to, + title: node?.text ?? e.to, + nodeCount: count + 1, + }; + }); + + const rankedIds = new Set(ranked.map((r) => r.id as string)); + const visibleEdges = edges + .filter((e) => rankedIds.has(e.from) || rankedIds.has(e.to)) + .slice(0, compact ? 100 : 120) + .map((e) => ({ + id: e.id, + from: e.from, + to: e.to, + kind: e.kind, + label: e.label ?? null, + isPrimaryParent: e.kind === "hierarchy" ? e.isPrimaryParent : undefined, + })); + + return { + canvasId, + rev: snap.canvas.rev, + title: snap.canvas.title, + focusId, + agentMode: snap.canvas.agentMode, + riskProfile: snap.canvas.prefs.riskProfile, + compact, + selection, + viewport: { + nodes: ranked, + edgesHint: edges.length, + edges: visibleEdges, + }, + periphery, + rootAnchor: { + visible: false, + id: null, + note: "No visible root. Do not search for a root node id. Top-level: omit parentId.", + }, + hint: "Top-level nodes: omit parentId (or parentId:null). There is no visible root node. Edge ids are in viewport.edges[].id for set_edge_label.", + qaHint: + "For graph Q&A use mindmap_query (neighbors|path|stats|orphans|missing_meta). mindmap_get_node / mindmap_get_edge for details; mindmap_highlight to flash path. Never invent facts not returned by tools.", + pendingProposals: listProposals(canvasId) + .filter((p) => p.status === "pending") + .map((p) => ({ id: p.id, summary: p.rationale, opCount: p.ops.length })), + }; +} + +/** HTTP/UI payload with hidden anchor stripped. */ +export function clientCanvasPayload(store: SqliteGraphStore, canvasId: string) { + const view = publicGraphView(store, canvasId); + return { + canvas: view.canvas, + nodes: view.nodes, + edges: view.edges, + positions: publicPositions(view.positions, view.anchorId), + }; +} + +function publicPositions( + positions: Record, + anchorId: string | null | undefined, +): Record { + if (!anchorId) return { ...positions }; + const out: Record = {}; + for (const [id, p] of Object.entries(positions)) { + if (id !== anchorId) out[id] = p; + } + return out; +} + +export async function runTool( + name: string, + rawArgs: Record, + ctx: ToolContext, +): Promise { + const canvasId = + (rawArgs.canvasId as string | undefined) ?? ctx.defaultCanvasId; + const noCanvasOk = new Set([ + "mindmap_list_canvases", + "mindmap_create_canvas", + "mindmap_open_canvas", + "mindmap_get_schema", + ]); + if (!canvasId && !noCanvasOk.has(name)) { + return { + ok: false, + error: "no_active_canvas", + message: + "No active canvas. Call mindmap_create_canvas or mindmap_open_canvas first (or pass canvasId). Query/find tools will open the 思维导图 tab automatically.", + }; + } + + switch (name) { + case "mindmap_list_canvases": + return { + ok: true, + canvases: ctx.store.listCanvases().map((c) => ({ + id: c.id, + title: c.title, + rev: c.rev, + })), + }; + + case "mindmap_create_canvas": { + const title = String(rawArgs.title || "未命名画布"); + const rootText = + rawArgs.rootText != null && String(rawArgs.rootText).trim() + ? String(rawArgs.rootText) + : undefined; + const g = ctx.store.createCanvas(title, rootText); + return { + ok: true, + canvas: { id: g.canvas.id, title: g.canvas.title, rev: g.canvas.rev }, + hint: "Canvas is empty visually. Create top-level nodes with {type:'create_node', text:'...'} (omit parentId). Use parentId only when nesting under an existing visible node.", + }; + } + + case "mindmap_open_canvas": { + const id = String(rawArgs.canvasId || canvasId || ""); + if (!id) { + return { ok: false, error: "canvasId_required", message: "canvasId required" }; + } + try { + const snap = ctx.store.getSnapshot(id); + return { + ok: true, + canvas: { id: snap.canvas.id, title: snap.canvas.title, rev: snap.canvas.rev }, + }; + } catch { + return { ok: false, error: "not_found", message: `canvas ${id} not found` }; + } + } + + case "mindmap_delete_canvas": { + ctx.store.hardDeleteCanvas(canvasId!); + return { ok: true, deleted: canvasId }; + } + + case "mindmap_get_context": + case "mindmap_get_spatial_context": { + const compact = + rawArgs.compact === undefined + ? true + : rawArgs.compact === true || String(rawArgs.compact) === "true"; + return { + ok: true, + ...(buildSpatialContext(ctx.store, canvasId!, { compact }) as object), + }; + } + + case "mindmap_get_subgraph": { + const view = publicGraphView(ctx.store, canvasId!); + const rootToken = + (rawArgs.rootId as string | undefined) ?? + (rawArgs.root as string | undefined); + let rootId: string | undefined; + if (rootToken) { + const resolved = resolveQueryNodeRef(view.nodes, String(rootToken), { + anchorId: view.anchorId, + }); + if (!resolved.ok) { + return { + ok: false, + error: resolved.error, + message: resolved.message, + candidates: + "candidates" in resolved ? resolved.candidates : undefined, + }; + } + rootId = resolved.id; + } else { + rootId = + (getActivity(canvasId!)?.focusNodeId !== view.anchorId + ? getActivity(canvasId!)?.focusNodeId + : undefined) ?? view.publicFocus ?? undefined; + } + const depth = Math.min(3, Number(rawArgs.depth ?? 2)); + const edgeKindsRaw = String(rawArgs.edgeKinds ?? "hierarchy"); + const edgeKinds = + edgeKindsRaw === "relation" || edgeKindsRaw === "all" + ? edgeKindsRaw + : "hierarchy"; + if (!rootId || rootId === view.anchorId) { + return { + ok: true, + edgeKinds, + nodes: view.nodes.slice(0, 40), + edges: view.edges.filter( + (e) => + view.nodes.slice(0, 40).some((n) => n.id === e.from) && + view.nodes.slice(0, 40).some((n) => n.id === e.to), + ), + hint: "No rootId; returned top visible nodes. Pass rootId (id|@alias|title). edgeKinds: hierarchy (default) | relation | all.", + }; + } + + const walkEdges = view.edges.filter((e) => { + if (edgeKinds === "all") return true; + return e.kind === edgeKinds; + }); + + const keep = new Set([rootId]); + let frontier = [rootId]; + for (let d = 0; d < depth; d++) { + const next: string[] = []; + for (const id of frontier) { + for (const e of walkEdges) { + let neighbor: string | null = null; + if (edgeKinds === "hierarchy") { + // Preserve tree zoom: only primary-parent children downward. + if (e.isPrimaryParent && e.from === id) neighbor = e.to; + } else { + // relation | all: undirected neighborhood expansion. + if (e.from === id) neighbor = e.to; + else if (e.to === id) neighbor = e.from; + } + if (!neighbor || keep.has(neighbor)) continue; + keep.add(neighbor); + next.push(neighbor); + } + } + frontier = next; + } + if (keep.size > 150) { + return { + ok: false, + error: "budget_exceeded", + skeleton: { + rootId, + edgeKinds, + nodeCount: keep.size, + hint: "缩小 depth 或先 mindmap_find;关系网可用 edgeKinds:'relation' + depth:1", + }, + }; + } + const nodes = view.nodes.filter((n) => keep.has(n.id)); + const edges = walkEdges.filter( + (e) => keep.has(e.from) && keep.has(e.to), + ); + const hint = + nodes.length <= 1 && edges.length === 0 && edgeKinds === "hierarchy" + ? "No hierarchy children under this root. For relation networks pass edgeKinds:'relation' or 'all' (same as mindmap_query)." + : undefined; + return { + ok: true, + rootId, + edgeKinds, + depth, + nodes, + edges, + ...(hint ? { hint } : {}), + }; + } + + case "mindmap_find": { + const query = String(rawArgs.query ?? ""); + const scope = parseSearchScope(rawArgs.searchScope ?? rawArgs.scope); + const { hits, ambiguous } = findVisibleHits( + ctx.store, + canvasId!, + query, + scope, + ); + return { + ok: true, + searchScope: scope, + ambiguous, + candidates: hits.map(candidateFromHit), + quickActions: hits.slice(0, 8).map((h, index) => { + if (h.kind === "edge") { + return { + index, + action: "highlight" as const, + edgeId: h.id, + tool: "mindmap_highlight", + args: { + canvasId, + nodeIds: [h.from, h.to], + edgeIds: [h.id], + }, + }; + } + return { + index, + action: "focus" as const, + nodeId: h.id, + tool: "mindmap_focus", + args: { nodeId: h.id, canvasId }, + }; + }), + message: ambiguous + ? "多个候选接近,请向用户确认后再改,禁止自行挑选。确认后直接 mindmap_focus({nodeId}) 或 mindmap_highlight(边),可用 quickActions[i].args" + : hits.length === 0 + ? scope === "edge_label" || scope === "edge_note" + ? "无可见边匹配" + : "无可见匹配(折叠分支内的节点不会出现在结果中;先展开再搜)" + : undefined, + }; + } + + case "mindmap_find_and_focus": { + const query = String(rawArgs.query ?? ""); + const scope = parseSearchScope(rawArgs.searchScope ?? rawArgs.scope); + const { hits, ambiguous } = findVisibleHits( + ctx.store, + canvasId!, + query, + scope, + ); + const candidates = hits.map(candidateFromHit); + if (!hits.length) { + return { + ok: true, + searchScope: scope, + focused: false, + ambiguous: false, + candidates, + message: "无可见匹配", + }; + } + if (ambiguous) { + return { + ok: true, + searchScope: scope, + focused: false, + ambiguous: true, + candidates, + message: "多个候选接近,未自动聚焦;请确认后 mindmap_focus / mindmap_highlight", + }; + } + const top = hits[0]!; + if (top.kind === "edge") { + ctx.broadcast({ + type: "canvas/highlight", + canvasId, + nodeIds: [top.from, top.to], + edgeIds: [top.id], + ttlMs: 5000, + }); + return { + ok: true, + searchScope: scope, + focused: false, + highlighted: true, + edgeId: top.id, + nodeIds: [top.from, top.to], + ambiguous: false, + candidates, + }; + } + const nodeId = top.id; + const result = ctx.store.applyChangeSet( + canvasId!, + [{ type: "set_focus", nodeId }], + { origin: "ai", summary: `find_and_focus ${query}` }, + ); + ctx.broadcast({ + type: "canvas/event", + canvasId, + changeSetId: result.changeSetId, + }); + ctx.broadcast({ + type: "canvas/highlight", + canvasId, + nodeIds: [nodeId], + }); + return { + ok: true, + searchScope: scope, + focused: true, + focusNodeId: nodeId, + ambiguous: false, + candidates, + }; + } + + case "mindmap_export": { + const format = String(rawArgs.format ?? "json").toLowerCase(); + const snap = ctx.store.getSnapshot(canvasId!); + const laid = withLayout(ctx.store, canvasId!); + const anchorId = snap.canvas.anchorNodeId ?? null; + const publicNodes = snap.nodes.filter( + (n) => !n.deletedAt && n.id !== anchorId, + ); + const publicEdges = snap.edges.filter((e) => !e.deletedAt); + if (format === "md" || format === "markdown") { + const markdown = exportMarkdown( + snap.nodes, + snap.edges, + snap.canvas.title, + anchorId, + ); + return { + ok: true, + format: "md", + title: snap.canvas.title, + markdown, + nodeCount: publicNodes.length, + edgeCount: publicEdges.length, + }; + } + if (format === "json") { + return { + ok: true, + format: "json", + canvas: { + id: snap.canvas.id, + title: snap.canvas.title, + prefs: snap.canvas.prefs, + rev: snap.canvas.rev, + }, + nodes: publicNodes, + edges: publicEdges, + nodeIdMap: buildNodeIdMap(publicNodes), + }; + } + if (format === "svg" || format === "png") { + const svg = exportSvg( + snap.nodes, + snap.edges, + laid.positions, + snap.canvas.title, + anchorId, + ); + return { + ok: true, + format: format === "png" ? "svg" : "svg", + title: snap.canvas.title, + mimeType: "image/svg+xml", + svg, + nodeCount: publicNodes.length, + edgeCount: publicEdges.length, + hint: + format === "png" + ? "Agent export returns SVG (vector). For raster PNG use the web UI「导出PNG」; SVG pastes into docs/PPT as well." + : "SVG can be saved as .svg or opened in browser / Office.", + }; + } + return { + ok: false, + error: "invalid_op", + message: 'format must be "json" | "md" | "svg" (png → svg for agents; UI has raster PNG)', + }; + } + + + case "mindmap_get_node": { + const view = publicGraphView(ctx.store, canvasId!); + const token = String(rawArgs.nodeId ?? rawArgs.node ?? ""); + const resolved = resolveQueryNodeRef(view.nodes, token, { + anchorId: view.anchorId, + }); + if (!resolved.ok) { + return { + ok: false, + error: resolved.error, + message: resolved.message, + candidates: "candidates" in resolved ? resolved.candidates : undefined, + }; + } + const n = view.nodes.find((x) => x.id === resolved.id); + if (!n) { + return { ok: false, error: "not_found", message: `node ${resolved.id}` }; + } + const fieldsRaw = rawArgs.fields; + const wantAll = + !Array.isArray(fieldsRaw) || + fieldsRaw.length === 0 || + fieldsRaw.includes("all"); + const out: Record = { + id: n.id, + text: n.text, + alias: n.alias, + function: nodeFunction(n), + }; + if (wantAll || (Array.isArray(fieldsRaw) && fieldsRaw.includes("description"))) { + out.description = n.description ?? null; + } + if (wantAll || (Array.isArray(fieldsRaw) && fieldsRaw.includes("note"))) { + out.note = n.note ?? null; + } + if (wantAll || (Array.isArray(fieldsRaw) && fieldsRaw.includes("tags"))) { + out.tags = n.tags ?? []; + } + if (wantAll || (Array.isArray(fieldsRaw) && fieldsRaw.includes("links"))) { + out.links = n.links ?? []; + } + return { + ok: true, + node: out, + citedNodeIds: [n.id], + hint: "Use function for short answers; request fields:['description'] for full structured meta.", + }; + } + + case "mindmap_get_edge": { + const view = publicGraphView(ctx.store, canvasId!); + const edgeId = String(rawArgs.edgeId ?? rawArgs.id ?? "").trim(); + if (!edgeId) { + return { ok: false, error: "invalid_op", message: "edgeId required" }; + } + const e = view.edges.find((x) => x.id === edgeId && !x.deletedAt); + if (!e) { + return { + ok: false, + error: "not_found", + message: `edge ${edgeId} not found`, + }; + } + const fieldsRaw = rawArgs.fields; + const wantAll = + !Array.isArray(fieldsRaw) || + fieldsRaw.length === 0 || + fieldsRaw.includes("all"); + const wantLabel = + wantAll || + (Array.isArray(fieldsRaw) && fieldsRaw.includes("label")); + const wantMeta = + wantAll || + (Array.isArray(fieldsRaw) && fieldsRaw.includes("meta")); + const fromN = view.nodes.find((n) => n.id === e.from); + const toN = view.nodes.find((n) => n.id === e.to); + const out: Record = { + id: e.id, + from: e.from, + to: e.to, + fromText: fromN?.text ?? e.from, + toText: toN?.text ?? e.to, + kind: e.kind, + }; + if (wantLabel || wantAll) out.label = e.label ?? null; + if (wantMeta || wantAll) { + out.direction = e.direction ?? null; + out.weight = e.weight ?? null; + out.lineStyle = e.lineStyle ?? null; + } + if (wantAll) { + out.note = e.note ?? null; + out.tags = e.tags ?? []; + } + return { + ok: true, + edge: out, + citedNodeIds: [e.from, e.to], + citedEdgeIds: [e.id], + }; + } + + case "mindmap_highlight": { + const view = publicGraphView(ctx.store, canvasId!); + const nodeIdsRaw = Array.isArray(rawArgs.nodeIds) + ? rawArgs.nodeIds + : rawArgs.nodeId != null + ? [rawArgs.nodeId] + : []; + const edgeIdsRaw = Array.isArray(rawArgs.edgeIds) + ? rawArgs.edgeIds + : rawArgs.edgeId != null + ? [rawArgs.edgeId] + : []; + const nodeIds: string[] = []; + for (const token of nodeIdsRaw) { + const r = resolveQueryNodeRef(view.nodes, String(token), { + anchorId: view.anchorId, + }); + if (r.ok) nodeIds.push(r.id); + } + const edgeIds: string[] = []; + for (const eid of edgeIdsRaw) { + const id = String(eid).trim(); + const e = view.edges.find((x) => x.id === id && !x.deletedAt); + if (!e) continue; + edgeIds.push(e.id); + if (!nodeIds.includes(e.from)) nodeIds.push(e.from); + if (!nodeIds.includes(e.to)) nodeIds.push(e.to); + } + if (!nodeIds.length && !edgeIds.length) { + return { + ok: false, + error: "invalid_op", + message: "nodeIds and/or edgeIds required", + }; + } + const style = + rawArgs.style && typeof rawArgs.style === "object" + ? (rawArgs.style as Record) + : undefined; + const ttlMs = Math.max( + 500, + Math.min( + 60000, + Number(rawArgs.ttlMs ?? rawArgs.duration ?? style?.duration ?? 5000) || + 5000, + ), + ); + ctx.broadcast({ + type: "canvas/highlight", + canvasId, + nodeIds, + edgeIds, + style: style + ? { + nodeColor: + typeof style.nodeColor === "string" + ? style.nodeColor + : undefined, + nodeGlow: style.nodeGlow !== false, + edgeWidth: + typeof style.edgeWidth === "number" + ? style.edgeWidth + : undefined, + edgeColor: + typeof style.edgeColor === "string" + ? style.edgeColor + : undefined, + } + : undefined, + ttlMs, + }); + return { + ok: true, + nodeIds, + edgeIds, + ttlMs, + hint: "Transient highlight on the open canvas; does not mutate the graph.", + }; + } + + case "mindmap_query": { + const view = publicGraphView(ctx.store, canvasId!); + const op = String(rawArgs.op ?? rawArgs.type ?? "").trim(); + const edgeKindsRaw = String(rawArgs.edgeKinds ?? "all"); + const edgeKinds = + edgeKindsRaw === "hierarchy" || edgeKindsRaw === "relation" + ? edgeKindsRaw + : "all"; + const directionRaw = String(rawArgs.direction ?? "out"); + const direction = + directionRaw === "in" || directionRaw === "both" ? directionRaw : "out"; + + const resolveOne = (token: unknown, field: string) => { + if (token == null || String(token).trim() === "") { + return { + ok: false as const, + error: "invalid_op", + message: `${field} required`, + }; + } + const r = resolveQueryNodeRef(view.nodes, String(token), { + anchorId: view.anchorId, + }); + if (!r.ok) { + return { + ok: false as const, + error: r.error, + message: r.message, + candidates: "candidates" in r ? r.candidates : undefined, + }; + } + return { ok: true as const, id: r.id }; + }; + + if (op === "stats") { + const edgeKindsRaw = String(rawArgs.edgeKinds ?? "all"); + const statsEdgeKinds = + edgeKindsRaw === "hierarchy" || edgeKindsRaw === "relation" + ? edgeKindsRaw + : "all"; + return { + ok: true, + op, + edgeKinds: statsEdgeKinds, + stats: queryStats({ + nodes: view.nodes, + edges: view.edges, + edgeKinds: statsEdgeKinds, + }), + hint: "connectedComponents = undirected components on edgeKinds. longestPath/mostConnected use same filter. orphanNodes + orphanDef explain isolates on that subgraph; when edgeKinds=relation also returns nodesWithoutRelation (same count). islands ≈ connectedComponents.count.", + }; + } + + if (op === "orphans") { + const r = queryOrphans({ nodes: view.nodes, edges: view.edges }); + return { + ok: true, + op, + ...r, + empty: r.nodes.length === 0, + message: + r.nodes.length === 0 + ? "No orphan nodes in the visible graph." + : undefined, + }; + } + + if (op === "missing_meta") { + const r = queryMissingMeta({ nodes: view.nodes, edges: view.edges }); + return { + ok: true, + op, + ...r, + empty: + r.nodesWithoutDescription.length === 0 && + r.nodesWithoutFunction.length === 0 && + r.edgesWithoutLabel.length === 0, + hint: "nodesWithoutDescription = empty/missing description. nodesWithoutFunction = description exists but no function/summary field (complementary).", + }; + } + + if (op === "neighbors") { + const node = resolveOne(rawArgs.node ?? rawArgs.nodeId, "node"); + if (!node.ok) return node; + const r = queryNeighbors({ + nodes: view.nodes, + edges: view.edges, + nodeId: node.id, + depth: Number(rawArgs.depth ?? 1), + direction: + String(rawArgs.direction ?? "both") === "in" + ? "in" + : String(rawArgs.direction ?? "both") === "out" + ? "out" + : "both", + edgeKinds, + }); + return { + ok: true, + op, + ...r, + empty: r.edges.length === 0, + message: + r.edges.length === 0 + ? "No neighbors found for this node with the given filters." + : undefined, + hint: "Answer only from returned nodes/edges. Optional: mindmap_highlight({ nodeIds: citedNodeIds, edgeIds }) to flash on canvas.", + }; + } + + if (op === "path" || op === "between") { + const from = resolveOne(rawArgs.from ?? rawArgs.fromId, "from"); + if (!from.ok) return from; + const to = resolveOne(rawArgs.to ?? rawArgs.toId, "to"); + if (!to.ok) return to; + const throughRelationNodes = + rawArgs.throughRelationNodes === undefined + ? true + : rawArgs.throughRelationNodes === true || + String(rawArgs.throughRelationNodes) === "true"; + // Prefer maxHops; do NOT reuse neighbors' `depth` (agents often pass depth:1). + const maxHops = Number( + rawArgs.maxHops ?? rawArgs.maxDepth ?? (throughRelationNodes ? 8 : 1), + ); + const pathDirection = + rawArgs.direction != null + ? direction + : throughRelationNodes + ? "both" + : "out"; + const r = queryPaths({ + nodes: view.nodes, + edges: view.edges, + fromId: from.id, + toId: to.id, + maxHops, + maxPaths: Number(rawArgs.maxPaths ?? 5), + direction: pathDirection, + edgeKinds, + throughRelationNodes, + }); + return { + ok: true, + op: "path", + from: from.id, + to: to.id, + direction: pathDirection, + directionNote: + pathDirection === "both" + ? "Search treats edges as undirected (default when throughRelationNodes). Pass direction:'out' for forward-only." + : pathDirection === "out" + ? "Search follows edge from→to only." + : "Search follows edge to→from only.", + ...r, + citedEdgeIds: r.edges.map((e) => e.id), + empty: r.paths.length === 0, + message: + r.paths.length === 0 + ? "No path found in the graph between these nodes (with current filters)." + : r.truncated + ? `Showing ${r.pathsReturned}/${r.totalPathsFound} paths (maxPaths=${r.maxPaths}; ${r.omittedPaths} omitted).` + : undefined, + hint: r.truncated + ? `truncated=true: pathsReturned=${r.pathsReturned}, totalPathsFound=${r.totalPathsFound}, omittedPaths=${r.omittedPaths}, maxPaths=${r.maxPaths}. Raise maxPaths (≤20) for more. Use paths[].edges for meta; mindmap_highlight to flash.` + : "Use paths[].edges for weight/lineStyle/note (edgeLabels kept for compat). Flash with mindmap_highlight({ nodeIds: citedNodeIds, edgeIds: citedEdgeIds }). If empty, say no path — do not invent.", + }; + } + + return { + ok: false, + error: "invalid_op", + message: + 'mindmap_query op must be one of: neighbors | path | stats | orphans | missing_meta', + validOps: ["neighbors", "path", "stats", "orphans", "missing_meta"], + }; + } + + + case "mindmap_get_schema": + return { + ok: true, + ...OP_SCHEMA_DOC, + tools: [ + "mindmap_list_canvases", + "mindmap_create_canvas", + "mindmap_open_canvas", + "mindmap_delete_canvas", + "mindmap_get_spatial_context", + "mindmap_find", + "mindmap_find_and_focus", + "mindmap_export", + "mindmap_get_subgraph", + "mindmap_query", + "mindmap_get_node", + "mindmap_get_edge", + "mindmap_highlight", + "mindmap_apply_direct", + "mindmap_propose", + "mindmap_accept_proposal", + "mindmap_reject_proposal", + "mindmap_focus", + "mindmap_undo", + "mindmap_redo", + "mindmap_history", + "mindmap_snapshot", + "mindmap_restore_snapshot", + "mindmap_get_schema", + ], + searchScope: [ + "text", + "note", + "tags", + "description", + "edge_label", + "edge_note", + "all", + ], + nodeIcons: [ + "server", + "database", + "cloud", + "person", + "folder", + "doc", + "link", + "warning", + "check", + "star", + "gear", + "globe", + ], + edgeLineStyles: ["solid", "dashed", "dotted"], + edgeDirections: ["forward", "both", "none"], + getSubgraph: { + rootId: "id | @alias | unique title", + edgeKinds: { + values: ["hierarchy", "relation", "all"], + default: "hierarchy", + note: "hierarchy = primary-parent children only (tree zoom). relation|all = undirected neighborhood (关系网络). Same enum as mindmap_query.", + }, + depth: "1–3 (default 2)", + }, + }; + + case "mindmap_propose": { + const prepared = prepareOps(ctx.store, canvasId!, rawArgs.ops); + if (!prepared.ok) return prepared; + const rationale = String(rawArgs.rationale ?? ""); + const snap = ctx.store.getSnapshot(canvasId!); + const p = createProposal(canvasId!, snap.canvas.rev, prepared.ops, rationale); + ctx.broadcast({ + type: "proposal/updated", + canvasId, + proposalId: p.id, + }); + return { + ok: true, + proposalId: p.id, + revision: p.revision, + ops: p.ops, + nodeIdMap: prepared.nodeIdMap, + rationale, + hint: "Call mindmap_accept_proposal with this proposalId after the user confirms. Do not self-accept — proposals require explicit accept (UI or mindmap_accept_proposal).", + }; + } + + case "mindmap_revise_proposal": { + const proposalId = String(rawArgs.proposalId); + const prepared = prepareOps(ctx.store, canvasId!, rawArgs.ops); + if (!prepared.ok) return prepared; + const rationale = String(rawArgs.rationale ?? ""); + try { + const p = reviseProposal(proposalId, prepared.ops, rationale); + ctx.broadcast({ + type: "proposal/updated", + canvasId: p.canvasId, + proposalId: p.id, + }); + return { + ok: true, + proposalId: p.id, + revision: p.revision, + nodeIdMap: prepared.nodeIdMap, + }; + } catch (e) { + return { + ok: false, + error: "proposal_not_pending", + message: String(e), + }; + } + } + + case "mindmap_accept_proposal": { + const proposalId = String(rawArgs.proposalId); + const p = getProposal(proposalId); + if (!p) { + return { + ok: false, + error: "proposal_not_found", + message: `No proposal ${proposalId}. Proposals are in-memory — recreate with mindmap_propose if the server restarted.`, + }; + } + if (p.status !== "pending") { + return { + ok: false, + error: "proposal_not_pending", + message: `Proposal status is ${p.status}, not pending.`, + status: p.status, + }; + } + const snap = ctx.store.getSnapshot(p.canvasId); + if (snap.canvas.rev - p.baseRev > 50) { + p.status = "stale"; + return { + ok: false, + error: "proposal_stale", + message: `Canvas rev drifted (${snap.canvas.rev - p.baseRev} > 50). Re-propose against current context.`, + }; + } + try { + const result = ctx.store.applyChangeSet(p.canvasId, p.ops, { + origin: "ai", + summary: p.rationale || "accept proposal", + }); + p.status = "accepted"; + p.updatedAt = now(); + const fitViewport = + result.autoFitPadding != null + ? finalizeAutoFit(ctx.store, p.canvasId, result.autoFitPadding) + : null; + const laid = withLayout(ctx.store, p.canvasId); + const anchorId = laid.anchorId; + ctx.broadcast({ + type: "canvas/event", + canvasId: p.canvasId, + changeSetId: result.changeSetId, + rev: result.rev, + fitViewport: fitViewport ?? undefined, + }); + return { + ok: true, + changeSetId: result.changeSetId, + affected: result.affectedNodeIds.filter((id) => id !== anchorId), + positions: publicPositions(laid.positions, anchorId), + fitViewport: fitViewport ?? undefined, + }; + } catch (e) { + return { + ok: false, + error: "invalid_op", + message: String(e), + validTypes: OP_TYPES, + hint: "Proposal kept pending. Fix ops (must use `type`) and revise or re-propose.", + proposalId: p.id, + status: p.status, + }; + } + } + + case "mindmap_reject_proposal": { + try { + const p = rejectProposal(String(rawArgs.proposalId)); + ctx.broadcast({ type: "proposal/updated", canvasId: p.canvasId, proposalId: p.id }); + return { ok: true, status: p.status }; + } catch (e) { + return { ok: false, error: "proposal_not_found", message: String(e) }; + } + } + + case "mindmap_apply_direct": { + const prepared = prepareOps(ctx.store, canvasId!, rawArgs.ops); + if (!prepared.ok) return prepared; + const ops = prepared.ops; + const snap = ctx.store.getSnapshot(canvasId!); + const strict = snap.canvas.prefs.riskProfile === "strict"; + const preview = + rawArgs.preview === true || + rawArgs.dryRun === true || + String(rawArgs.preview ?? "") === "true"; + if (strict && !preview) { + return { + ok: false, + error: "permission_denied", + message: "严格档请用 mindmap_propose", + }; + } + if (ops.some(isHighRisk) && !preview) { + return { + ok: false, + error: "permission_denied", + message: + "高风险操作(delete_node / move_node / set_primary_parent)请用 mindmap_propose", + highRisk: [...HIGH_RISK], + }; + } + if (preview) { + try { + const g = cloneGraph(ctx.store.loadCanvas(canvasId!)); + const applied = applyOps(g, ops); + const laid = layoutGraphPreview(ctx.store, canvasId!, applied.graph); + const anchorId = laid.anchorId; + const affectedIds = applied.affectedNodeIds.filter( + (id) => id !== anchorId, + ); + const nodeIdMap = { + ...prepared.nodeIdMap, + ...buildNodeIdMap(laid.snap.nodes, { + anchorId, + onlyIds: new Set(affectedIds), + }), + }; + return { + ok: true, + preview: true, + nodeIdMap, + affected: affectedIds.map((id) => { + const n = laid.snap.nodes.find((x) => x.id === id); + return { + id, + version: n?.version, + text: n?.text, + alias: n?.alias, + }; + }), + positions: publicPositions(laid.positions, anchorId), + overlaps: laid.overlaps, + overlapCount: laid.overlaps.length, + hint: "Dry-run only — nothing persisted. Omit preview to apply.", + }; + } catch (e) { + return { + ok: false, + error: "invalid_op", + message: String(e), + preview: true, + validTypes: OP_TYPES, + }; + } + } + try { + const result = ctx.store.applyChangeSet(canvasId!, ops, { + origin: "ai", + summary: String(rawArgs.summary ?? "ai apply"), + }); + const fitViewport = + result.autoFitPadding != null + ? finalizeAutoFit(ctx.store, canvasId!, result.autoFitPadding) + : null; + const payload = clientCanvasPayload(ctx.store, canvasId!); + const anchorId = result.snapshot.canvas.anchorNodeId; + const affectedIds = result.affectedNodeIds.filter((id) => id !== anchorId); + ctx.broadcast({ + type: "canvas/event", + canvasId, + changeSetId: result.changeSetId, + rev: result.rev, + affectedNodeIds: affectedIds, + fitViewport: fitViewport ?? undefined, + }); + const nodeIdMap = { + ...prepared.nodeIdMap, + ...buildNodeIdMap(result.snapshot.nodes, { + anchorId, + onlyIds: new Set(affectedIds), + }), + }; + return { + ok: true, + changeSetId: result.changeSetId, + nodeIdMap, + affected: affectedIds.map((id) => { + const n = result.snapshot.nodes.find((x) => x.id === id); + return { + id, + version: n?.version, + text: n?.text, + alias: n?.alias, + }; + }), + positions: payload.positions, + fitViewport: fitViewport ?? undefined, + }; + } catch (e) { + return { + ok: false, + error: "invalid_op", + message: String(e), + validTypes: OP_TYPES, + }; + } + } + + case "mindmap_undo": { + try { + let changeSetId = + rawArgs.changeSetId != null && String(rawArgs.changeSetId).length + ? String(rawArgs.changeSetId) + : null; + if (!changeSetId) { + if (!canvasId) { + return { + ok: false, + error: "invalid_op", + message: "mindmap_undo needs canvasId or changeSetId", + }; + } + changeSetId = ctx.store.latestUndoableChangeSetId(canvasId); + if (!changeSetId) { + return { + ok: false, + error: "nothing_to_undo", + message: "No undoable change set on this canvas", + }; + } + } + const result = ctx.store.undoChangeSet(changeSetId); + const payload = clientCanvasPayload(ctx.store, result.snapshot.canvas.id); + ctx.broadcast({ + type: "canvas/event", + canvasId: result.snapshot.canvas.id, + changeSetId: result.changeSetId, + rev: result.rev, + }); + return { + ok: true, + undoneChangeSetId: changeSetId, + changeSetId: result.changeSetId, + rev: result.rev, + ...payload, + }; + } catch (e) { + return { + ok: false, + error: "undo_failed", + message: String(e), + hint: "Pass changeSetId from apply_direct / accept_proposal, or omit to undo the latest change.", + }; + } + } + + case "mindmap_redo": { + try { + if (!canvasId) { + return { + ok: false, + error: "invalid_op", + message: "mindmap_redo needs canvasId", + }; + } + const changeSetId = ctx.store.latestRedoableChangeSetId(canvasId); + if (!changeSetId) { + return { + ok: false, + error: "nothing_to_redo", + message: + "No redoable undo companion (latest change is not an undo). Undo first, then redo.", + }; + } + const result = ctx.store.undoChangeSet(changeSetId); + const payload = clientCanvasPayload(ctx.store, result.snapshot.canvas.id); + ctx.broadcast({ + type: "canvas/event", + canvasId: result.snapshot.canvas.id, + changeSetId: result.changeSetId, + rev: result.rev, + }); + return { + ok: true, + redoneViaChangeSetId: changeSetId, + changeSetId: result.changeSetId, + rev: result.rev, + ...payload, + }; + } catch (e) { + return { + ok: false, + error: "redo_failed", + message: String(e), + }; + } + } + + case "mindmap_history": { + if (!canvasId) { + return { + ok: false, + error: "invalid_op", + message: "mindmap_history needs canvasId", + }; + } + const limit = Math.min(100, Math.max(1, Number(rawArgs.limit ?? 30) || 30)); + const changes = ctx.store.listChangeSets(canvasId, limit); + const snapshots = ctx.store.listSnapshots(canvasId, 20); + const latestUndo = ctx.store.latestUndoableChangeSetId(canvasId); + const latestRedo = ctx.store.latestRedoableChangeSetId(canvasId); + return { + ok: true, + changes, + snapshots, + latestUndoableChangeSetId: latestUndo, + latestRedoableChangeSetId: latestRedo, + hint: "Use mindmap_undo / mindmap_redo, or mindmap_restore_snapshot({snapshotId}).", + }; + } + + case "mindmap_snapshot": { + if (!canvasId) { + return { + ok: false, + error: "invalid_op", + message: "mindmap_snapshot needs canvasId", + }; + } + const action = String(rawArgs.action ?? "create"); + if (action === "list") { + return { + ok: true, + snapshots: ctx.store.listSnapshots( + canvasId, + Math.min(50, Number(rawArgs.limit ?? 20) || 20), + ), + }; + } + const named = ctx.store.createSnapshot( + canvasId, + String(rawArgs.name ?? ""), + ); + return { ok: true, ...named }; + } + + case "mindmap_restore_snapshot": { + try { + const snapshotId = String(rawArgs.snapshotId ?? ""); + if (!snapshotId) { + return { + ok: false, + error: "invalid_op", + message: "mindmap_restore_snapshot needs snapshotId", + }; + } + const result = ctx.store.restoreSnapshot(snapshotId); + layoutCache.delete(result.snapshot.canvas.id); + const payload = clientCanvasPayload( + ctx.store, + result.snapshot.canvas.id, + ); + ctx.broadcast({ + type: "canvas/event", + canvasId: result.snapshot.canvas.id, + changeSetId: result.changeSetId, + rev: result.rev, + }); + return { + ok: true, + changeSetId: result.changeSetId, + rev: result.rev, + restoredSnapshotId: snapshotId, + ...payload, + }; + } catch (e) { + return { + ok: false, + error: "restore_failed", + message: String(e), + }; + } + } + + case "mindmap_recent_changes": + return { + ok: true, + changes: canvasId ? ctx.store.listChangeSets(canvasId, 20) : [], + proposals: listProposals(canvasId!), + }; + + case "mindmap_focus": { + const nodeId = String(rawArgs.nodeId); + const result = ctx.store.applyChangeSet( + canvasId!, + [{ type: "set_focus", nodeId }], + { origin: "ai", summary: "focus" }, + ); + ctx.broadcast({ + type: "canvas/event", + canvasId, + changeSetId: result.changeSetId, + }); + ctx.broadcast({ + type: "canvas/highlight", + canvasId, + nodeIds: [nodeId], + }); + return { ok: true, focusNodeId: nodeId }; + } + + case "mindmap_set_mode": { + const mode = String(rawArgs.mode ?? "edit"); + const g = ctx.store.loadCanvas(canvasId!); + g.canvas.agentMode = mode as typeof g.canvas.agentMode; + // persist via prefs hack / rename path — use set_prefs + field + ctx.store.applyChangeSet( + canvasId!, + [{ type: "set_prefs", prefs: { ...(g.canvas.prefs as object) } }], + { origin: "ai", summary: `mode ${mode}` }, + ); + g.canvas.agentMode = mode as typeof g.canvas.agentMode; + return { ok: true, mode }; + } + + default: + return { ok: false, error: "invalid_op", message: `unknown tool ${name}` }; + } +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..e02e0ea --- /dev/null +++ b/apps/server/tsconfig.json @@ -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" } + ] +} diff --git a/apps/server/tsconfig.tsbuildinfo b/apps/server/tsconfig.tsbuildinfo new file mode 100644 index 0000000..253ae09 --- /dev/null +++ b/apps/server/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/chat.ts","./src/create-server.ts","./src/index.ts","./src/tools.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..cc6ff2d --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + Mind Map + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..3d485ea --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/scripts/_check_cjk.py b/apps/web/scripts/_check_cjk.py new file mode 100644 index 0000000..62c1f82 --- /dev/null +++ b/apps/web/scripts/_check_cjk.py @@ -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) diff --git a/apps/web/scripts/_fix_leftover.py b/apps/web/scripts/_fix_leftover.py new file mode 100644 index 0000000..9007b32 --- /dev/null +++ b/apps/web/scripts/_fix_leftover.py @@ -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))) diff --git a/apps/web/scripts/_inspect_cjk.py b/apps/web/scripts/_inspect_cjk.py new file mode 100644 index 0000000..abd870a --- /dev/null +++ b/apps/web/scripts/_inspect_cjk.py @@ -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))) diff --git a/apps/web/scripts/check-cjk-dist.py b/apps/web/scripts/check-cjk-dist.py new file mode 100644 index 0000000..df04c7b --- /dev/null +++ b/apps/web/scripts/check-cjk-dist.py @@ -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)) diff --git a/apps/web/scripts/fix-topology-cjk.py b/apps/web/scripts/fix-topology-cjk.py new file mode 100644 index 0000000..096379d --- /dev/null +++ b/apps/web/scripts/fix-topology-cjk.py @@ -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 )', + 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 )', + 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 )', + 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 \n \n )\?+(\n \n \n )\?+(\n \n \n
\n
\n void undo\(\)\}\n >\n )\?+(\n )', + 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) diff --git a/apps/web/scripts/fix-topology-cjk2.py b/apps/web/scripts/fix-topology-cjk2.py new file mode 100644 index 0000000..b54a94c --- /dev/null +++ b/apps/web/scripts/fix-topology-cjk2.py @@ -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 ', 'title="返回画布列表"\n onClick={requestBackToRoster}\n >\n 返回\n '), + ('["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 ', + 'title="单点拖拽:只移动当前节点 (N);按住 Shift 也可临时单点"\n aria-pressed={dragScope === "node"}\n onClick={() => setDragScope("node")}\n >\n 单点\n ', + ), + ( + 'title="?????????????? (B)"\n aria-pressed={dragScope === "branch"}\n onClick={() => setDragScope("branch")}\n >\n ??\n ', + 'title="整枝拖拽:节点与子孙一起移动 (B)"\n aria-pressed={dragScope === "branch"}\n onClick={() => setDragScope("branch")}\n >\n 整枝\n ', + ), + ( + 'title="?????????????"\n aria-pressed={!cardView}\n disabled={locked}\n onClick={() => {\n if (!cardView) return;\n void setPrefs({ nodeViewMode: "bubble" });\n }}\n >\n ??\n ', + 'title="气泡视图:精简标题"\n aria-pressed={!cardView}\n disabled={locked}\n onClick={() => {\n if (!cardView) return;\n void setPrefs({ nodeViewMode: "bubble" });\n }}\n >\n 气泡\n ', + ), + ( + 'title="??????????????"\n aria-pressed={cardView}\n disabled={locked}\n onClick={() => {\n if (cardView) return;\n void setPrefs({ nodeViewMode: "card" });\n }}\n >\n ??\n ', + 'title="卡片视图:展开属性面板"\n aria-pressed={cardView}\n disabled={locked}\n onClick={() => {\n if (cardView) return;\n void setPrefs({ nodeViewMode: "card" });\n }}\n >\n 卡片\n ', + ), + ( + '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 ', + '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 ', + ), + ( + 'title="??"\n disabled={!lastChangeSetId}\n onClick={() => void undo()}\n >\n ??\n ', + 'title="撤销"\n disabled={!lastChangeSetId}\n onClick={() => void undo()}\n >\n 撤销\n ', + ), + ( + 'aria-label="????"\n onPointerDown={(e) => e.stopPropagation()}\n >\n e.stopPropagation()}\n >\n 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 )', + 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 )', + 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 )', + 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 \n \n )\?+(\n \n \n )\?+(\n \n
\n
\n
\n void undo\(\)\}\n >\n )\?+(\n )', + 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+", 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) diff --git a/apps/web/scripts/fix-topology-cjk4.py b/apps/web/scripts/fix-topology-cjk4.py new file mode 100644 index 0000000..5dfc4c1 --- /dev/null +++ b/apps/web/scripts/fix-topology-cjk4.py @@ -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") diff --git a/apps/web/scripts/fix-topology-cjk5.py b/apps/web/scripts/fix-topology-cjk5.py new file mode 100644 index 0000000..8378fc5 --- /dev/null +++ b/apps/web/scripts/fix-topology-cjk5.py @@ -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 )', + 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 )', + 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 )', + 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 \n \n )\?+(\n \n \n )\?+(\n \n
\n
\n
\n void undo\(\)\}\n >\n )\?+(\n )', + 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+", 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") diff --git a/apps/web/scripts/verify-dist-cjk.py b/apps/web/scripts/verify-dist-cjk.py new file mode 100644 index 0000000..7329a7c --- /dev/null +++ b/apps/web/scripts/verify-dist-cjk.py @@ -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) diff --git a/apps/web/scripts/verify-topology-cjk.py b/apps/web/scripts/verify-topology-cjk.py new file mode 100644 index 0000000..4addd02 --- /dev/null +++ b/apps/web/scripts/verify-topology-cjk.py @@ -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") diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..1ff0455 --- /dev/null +++ b/apps/web/src/App.tsx @@ -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 ( +
+
+ +
+
+ ); + } + + return ( +
+
+
Mind Map
+ + + + + + + + +
+
+ +
+ + + +
+
+ ); +} diff --git a/apps/web/src/HoverCard.tsx b/apps/web/src/HoverCard.tsx new file mode 100644 index 0000000..4879d20 --- /dev/null +++ b/apps/web/src/HoverCard.tsx @@ -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 ( +
+
{node.text}
+ {note ?
{note.slice(0, 120)}
: null} + {tags.length ? ( +
+ {tags.map((t) => ( + + {t} + + ))} +
+ ) : null} +
+ {links ? `${links} 链接` : null} + {node.dueAt != null + ? `${links ? " · " : ""}截止 ${new Date(node.dueAt).toLocaleDateString()}` + : null} + {node.stylePreset !== "default" ? `${links || node.dueAt ? " · " : ""}${node.stylePreset}` : null} +
+
+ ); +} diff --git a/apps/web/src/NodeInspector.tsx b/apps/web/src/NodeInspector.tsx new file mode 100644 index 0000000..540ba3f --- /dev/null +++ b/apps/web/src/NodeInspector.tsx @@ -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["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
选中节点或边以编辑属性
; + } + + if (edge && !node) { + return ; + } + if (!node) { + return
选中节点或边以编辑属性
; + } + return ; +} + +function NodeForm({ node, applyOps }: { node: MindNode; applyOps: ApplyOps }) { + return ( +
+
节点属性
+ +
+ +
+
+ ); +} + +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 ( +
+
边属性
+ +