Initial commit: DSH mind-map canvas monorepo

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

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

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

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

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

View file

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

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

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

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

File diff suppressed because it is too large Load diff

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

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

View file

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