mirror of
https://github.com/hansjone/mind-map.git
synced 2026-10-09 02:10:45 +08:00
Initial commit: DSH mind-map canvas monorepo
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
2ecd300ada
92 changed files with 20486 additions and 0 deletions
32
apps/server/package.json
Normal file
32
apps/server/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "@mind-map/server",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.14.1",
|
||||
"@mind-map/dream-skin": "workspace:*",
|
||||
"@mind-map/graph-core": "workspace:*",
|
||||
"@mind-map/graph-store": "workspace:*",
|
||||
"@mind-map/layout-engine": "workspace:*",
|
||||
"@mind-map/shared": "workspace:*",
|
||||
"hono": "^4.7.5",
|
||||
"ai": "^4.3.16",
|
||||
"@ai-sdk/openai": "^1.3.22",
|
||||
"ws": "^8.18.1",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/ws": "^8.18.0",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.9"
|
||||
}
|
||||
}
|
||||
206
apps/server/src/chat.ts
Normal file
206
apps/server/src/chat.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { streamText, tool, type CoreMessage } from "ai";
|
||||
import { z } from "zod";
|
||||
import type { ToolContext } from "./tools.js";
|
||||
import { buildSpatialContext, runTool } from "./tools.js";
|
||||
|
||||
export function llmConfigured(): boolean {
|
||||
return Boolean(process.env.LLM_API_KEY || process.env.OPENAI_API_KEY);
|
||||
}
|
||||
|
||||
export function llmStatus(sourceHint?: "dsh" | "env" | "none") {
|
||||
const configured = llmConfigured();
|
||||
const viaDsh =
|
||||
sourceHint === "dsh" ||
|
||||
process.env.MINDMAP_LLM_SOURCE === "dsh" ||
|
||||
Boolean(process.env.MINDMAP_DSH_PROVIDER);
|
||||
return {
|
||||
configured,
|
||||
source: viaDsh ? "dsh" : configured ? "env" : "none",
|
||||
provider: process.env.MINDMAP_DSH_PROVIDER || undefined,
|
||||
model:
|
||||
process.env.MINDMAP_DSH_MODEL ||
|
||||
process.env.LLM_MODEL ||
|
||||
"deepseek-chat",
|
||||
baseURL: process.env.LLM_BASE_URL || process.env.OPENAI_BASE_URL || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function getModel() {
|
||||
const apiKey = process.env.LLM_API_KEY || process.env.OPENAI_API_KEY || "missing";
|
||||
const baseURL =
|
||||
process.env.LLM_BASE_URL ||
|
||||
process.env.OPENAI_BASE_URL ||
|
||||
"https://api.deepseek.com/v1";
|
||||
// When bridged through DSH OpenAI shim, model id is ignored by the shim;
|
||||
// settings card provider/model win on the host side.
|
||||
const modelId = process.env.LLM_MODEL || "deepseek-chat";
|
||||
const provider = createOpenAI({ apiKey, baseURL });
|
||||
return provider(modelId);
|
||||
}
|
||||
|
||||
const OpZ = z
|
||||
.object({
|
||||
type: z.string().describe("Op discriminator: create_node | link | update_text | … (NOT `op`)"),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export function createChatHandler(ctx: ToolContext) {
|
||||
return async function handleChat(body: {
|
||||
canvasId: string;
|
||||
messages: { role: "user" | "assistant" | "system"; content: string }[];
|
||||
}) {
|
||||
const canvasId = body.canvasId;
|
||||
const spatial = buildSpatialContext(ctx.store, canvasId);
|
||||
const system = `你是 Mind Map 画布上的 AI 协作者。图是唯一事实源。
|
||||
规则:
|
||||
1. 先用 mindmap_get_spatial_context / mindmap_find 定位,禁止臆造节点 ID。找边用 searchScope=edge_label|edge_note。
|
||||
2. find 返回 ambiguous=true 时必须向用户反问,不得自行挑选。
|
||||
3. 快速档(riskProfile=fast):低风险用 mindmap_apply_direct(新建、改文本、关联、样式);删除/换父等用 mindmap_propose。
|
||||
4. 严格档:一律 propose,等用户接受。
|
||||
5. 禁止输出整棵树 JSON 覆盖;只提交增量 ops。每个 op 必须用字段 type(禁止用 op)。
|
||||
6. 对话里先用自然语言说明你在做什么;工具会改图。
|
||||
7. 用户若说「特朗普家族」等关系梳理:围绕焦点创建人物节点与 relation/hierarchy 边,边可带 label(如配偶、子女)。link 示例:{"type":"link","from":"...","to":"...","kind":"relation","label":"配偶"}。
|
||||
8. 固定坐标用 set_pinned 或 create_node 带 pos;不确定时 mindmap_get_schema。
|
||||
9. 节点属性:标题用 update_text;描述/多标签/多链接/图片URL/时间用 update_node_meta(patch);边用 update_edge_meta 或 set_edge_label。
|
||||
10. 节点类型 stylePreset:default|card|title|decision|risk|note|muted(title 仅画布锚点,勿新建;card=新闻快照卡片,配图用 imageUrl、摘要用 note、来源用 links)。
|
||||
11. 同级排序用 reorder:优先 {"type":"reorder","nodeId":"...","order":10};也可用 afterSiblingId(null=置顶)。
|
||||
12. 折叠 set_collapsed 会隐藏该节点主父链下的全部子孙并重新布局;展开后恢复。
|
||||
13. 问图:用 mindmap_query(neighbors|path|stats|orphans|missing_meta);答完可用 mindmap_highlight 高亮路径;细节用 mindmap_get_node / mindmap_get_edge。禁止编造图中没有的事实。
|
||||
|
||||
当前空间上下文(摘要):
|
||||
${JSON.stringify(spatial, null, 0).slice(0, 6000)}
|
||||
`;
|
||||
|
||||
const toolCtx: ToolContext = { ...ctx, defaultCanvasId: canvasId };
|
||||
|
||||
const result = streamText({
|
||||
model: getModel(),
|
||||
system,
|
||||
messages: body.messages as CoreMessage[],
|
||||
maxSteps: 10,
|
||||
tools: {
|
||||
mindmap_get_spatial_context: tool({
|
||||
description: "获取选区/视口/外围簇等空间上下文",
|
||||
parameters: z.object({ canvasId: z.string().optional() }),
|
||||
execute: async (args) => runTool("mindmap_get_spatial_context", args, toolCtx),
|
||||
}),
|
||||
mindmap_find: tool({
|
||||
description:
|
||||
"检索节点或边;searchScope=text|note|tags|description|edge_label|edge_note|all;可能返回 ambiguous",
|
||||
parameters: z.object({
|
||||
query: z.string(),
|
||||
searchScope: z
|
||||
.enum([
|
||||
"text",
|
||||
"note",
|
||||
"tags",
|
||||
"description",
|
||||
"edge_label",
|
||||
"edge_note",
|
||||
"all",
|
||||
])
|
||||
.optional(),
|
||||
canvasId: z.string().optional(),
|
||||
}),
|
||||
execute: async (args) => runTool("mindmap_find", args, toolCtx),
|
||||
}),
|
||||
mindmap_query: tool({
|
||||
description:
|
||||
"图问答:neighbors|path|stats|orphans|missing_meta。path 含 paths[].edges;stats 含 longestPath/mostConnected",
|
||||
parameters: z.object({
|
||||
op: z.enum(["neighbors", "path", "stats", "orphans", "missing_meta"]),
|
||||
node: z.string().optional(),
|
||||
nodeId: z.string().optional(),
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
depth: z.number().optional(),
|
||||
maxHops: z.number().optional(),
|
||||
direction: z.enum(["out", "in", "both"]).optional(),
|
||||
edgeKinds: z.enum(["all", "hierarchy", "relation"]).optional(),
|
||||
throughRelationNodes: z.boolean().optional(),
|
||||
canvasId: z.string().optional(),
|
||||
}),
|
||||
execute: async (args) =>
|
||||
runTool("mindmap_query", args as Record<string, unknown>, toolCtx),
|
||||
}),
|
||||
mindmap_get_node: tool({
|
||||
description: "读单个节点详情(description/note/tags/links)",
|
||||
parameters: z.object({
|
||||
nodeId: z.string(),
|
||||
fields: z.array(z.enum(["all", "description", "note", "tags", "links"])).optional(),
|
||||
canvasId: z.string().optional(),
|
||||
}),
|
||||
execute: async (args) => runTool("mindmap_get_node", args, toolCtx),
|
||||
}),
|
||||
mindmap_get_edge: tool({
|
||||
description: "读单条边详情(label/weight/lineStyle/direction/note)",
|
||||
parameters: z.object({
|
||||
edgeId: z.string(),
|
||||
fields: z.array(z.enum(["all", "label", "meta"])).optional(),
|
||||
canvasId: z.string().optional(),
|
||||
}),
|
||||
execute: async (args) => runTool("mindmap_get_edge", args, toolCtx),
|
||||
}),
|
||||
mindmap_highlight: tool({
|
||||
description: "在画布上高亮节点/边(临时,不改图);问答后可视化路径",
|
||||
parameters: z.object({
|
||||
nodeIds: z.array(z.string()).optional(),
|
||||
edgeIds: z.array(z.string()).optional(),
|
||||
ttlMs: z.number().optional(),
|
||||
style: z
|
||||
.object({
|
||||
nodeColor: z.string().optional(),
|
||||
nodeGlow: z.boolean().optional(),
|
||||
edgeWidth: z.number().optional(),
|
||||
edgeColor: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
canvasId: z.string().optional(),
|
||||
}),
|
||||
execute: async (args) =>
|
||||
runTool("mindmap_highlight", args as Record<string, unknown>, toolCtx),
|
||||
}),
|
||||
mindmap_get_subgraph: tool({
|
||||
description: "读取以某节点为根的子图(depth<=3)",
|
||||
parameters: z.object({
|
||||
rootId: z.string().optional(),
|
||||
depth: z.number().optional(),
|
||||
canvasId: z.string().optional(),
|
||||
}),
|
||||
execute: async (args) => runTool("mindmap_get_subgraph", args, toolCtx),
|
||||
}),
|
||||
mindmap_apply_direct: tool({
|
||||
description: "快速档直接应用低风险 ops",
|
||||
parameters: z.object({
|
||||
ops: z.array(OpZ),
|
||||
summary: z.string().optional(),
|
||||
canvasId: z.string().optional(),
|
||||
}),
|
||||
execute: async (args) =>
|
||||
runTool("mindmap_apply_direct", args as Record<string, unknown>, toolCtx),
|
||||
}),
|
||||
mindmap_propose: tool({
|
||||
description: "提交待确认提案(高风险或严格档)",
|
||||
parameters: z.object({
|
||||
ops: z.array(OpZ),
|
||||
rationale: z.string(),
|
||||
canvasId: z.string().optional(),
|
||||
}),
|
||||
execute: async (args) =>
|
||||
runTool("mindmap_propose", args as Record<string, unknown>, toolCtx),
|
||||
}),
|
||||
mindmap_focus: tool({
|
||||
description: "移动视觉焦点",
|
||||
parameters: z.object({
|
||||
nodeId: z.string(),
|
||||
canvasId: z.string().optional(),
|
||||
}),
|
||||
execute: async (args) => runTool("mindmap_focus", args, toolCtx),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
736
apps/server/src/create-server.ts
Normal file
736
apps/server/src/create-server.ts
Normal file
|
|
@ -0,0 +1,736 @@
|
|||
import fs from "node:fs";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Server } from "node:http";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import { SqliteGraphStore } from "@mind-map/graph-store";
|
||||
import { coerceOpInput, OpSchema, type Op } from "@mind-map/shared";
|
||||
import { listSkins } from "@mind-map/dream-skin";
|
||||
import { GraphError } from "@mind-map/graph-core";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createChatHandler,
|
||||
llmConfigured,
|
||||
llmStatus,
|
||||
} from "./chat.js";
|
||||
import {
|
||||
listProposals,
|
||||
runTool,
|
||||
setActivity,
|
||||
clientCanvasPayload,
|
||||
finalizeAutoFit,
|
||||
} from "./tools.js";
|
||||
|
||||
export type SessionBridge = {
|
||||
prompt: (canvasId: string, text: string) => Promise<{ ok: boolean; sessionId?: string }>
|
||||
history: (canvasId: string) => Promise<{ sessionId: string | null; messages: { role: string; content: string }[] }>
|
||||
status: (canvasId?: string) => { ok: boolean; mode: string; bound?: boolean; sessionId?: string | null; agents?: boolean }
|
||||
}
|
||||
|
||||
function bridgeBase() {
|
||||
return (process.env.MINDMAP_DSH_BRIDGE || "").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function internalToken() {
|
||||
return process.env.MINDMAP_INTERNAL_TOKEN || "";
|
||||
}
|
||||
|
||||
async function callDshBridge(path: string, init?: RequestInit) {
|
||||
const base = bridgeBase();
|
||||
if (!base) throw new Error("DSH bridge not configured");
|
||||
const res = await fetch(`${base}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
if (!res.ok) {
|
||||
throw new Error(String(body.message || body.error || `bridge ${res.status}`));
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const mindMapRoot = path.resolve(__dirname, "../../..");
|
||||
|
||||
export function loadDotEnv(root = mindMapRoot) {
|
||||
try {
|
||||
const envPath = path.join(root, ".env");
|
||||
if (!fs.existsSync(envPath)) return;
|
||||
for (const line of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) {
|
||||
const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/.exec(line);
|
||||
if (!m || line.trimStart().startsWith("#")) continue;
|
||||
const key = m[1]!;
|
||||
let val = m[2]!;
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
if (process.env[key] == null) process.env[key] = val;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export type MindMapServerOptions = {
|
||||
port?: number;
|
||||
bind?: string;
|
||||
dataDir?: string;
|
||||
root?: string;
|
||||
/** When set by DSH plugin, overrides env-based LLM status messaging. */
|
||||
llmSource?: "dsh" | "env" | "none";
|
||||
/** Optional in-process session bridge (preferred over HTTP MINDMAP_DSH_BRIDGE). */
|
||||
sessionBridge?: SessionBridge;
|
||||
};
|
||||
|
||||
export type MindMapServerHandle = {
|
||||
app: Hono;
|
||||
store: SqliteGraphStore;
|
||||
port: number;
|
||||
bind: string;
|
||||
dbPath: string;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function startMindMapServer(
|
||||
options: MindMapServerOptions = {},
|
||||
): MindMapServerHandle {
|
||||
const root = options.root ?? mindMapRoot;
|
||||
loadDotEnv(root);
|
||||
|
||||
const port = Number(options.port ?? process.env.PORT ?? 17890);
|
||||
const bind = options.bind ?? process.env.BIND ?? "127.0.0.1";
|
||||
const dataDir = options.dataDir ?? process.env.DATA_DIR ?? path.join(root, "data");
|
||||
const dbPath = path.join(dataDir, "mindmap.sqlite");
|
||||
|
||||
const store = new SqliteGraphStore(dbPath);
|
||||
const app = new Hono();
|
||||
app.use("*", cors({ origin: "*" }));
|
||||
|
||||
const sockets = new Set<WebSocket>();
|
||||
|
||||
function broadcast(payload: unknown) {
|
||||
const data = JSON.stringify(payload);
|
||||
for (const ws of sockets) {
|
||||
if (ws.readyState === ws.OPEN) ws.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
app.get("/health", (c) =>
|
||||
c.json({ ok: true, port, bind, dbPath, skins: listSkins().map((s) => s.id) }),
|
||||
);
|
||||
|
||||
app.get("/api/skins", (c) => c.json({ skins: listSkins() }));
|
||||
|
||||
app.get("/api/canvases", (c) =>
|
||||
c.json({
|
||||
canvases: store.listCanvases().map((x) => ({
|
||||
id: x.id,
|
||||
title: x.title,
|
||||
focusNodeId: x.focusNodeId === x.anchorNodeId ? null : x.focusNodeId,
|
||||
viewport: x.viewport,
|
||||
prefs: x.prefs,
|
||||
rev: x.rev,
|
||||
agentMode: x.agentMode,
|
||||
createdAt: x.createdAt,
|
||||
updatedAt: x.updatedAt,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
app.post("/api/canvases", async (c) => {
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
title?: string;
|
||||
rootText?: string;
|
||||
};
|
||||
const g = store.createCanvas(body.title, body.rootText);
|
||||
broadcast({ type: "canvas/created", canvasId: g.canvas.id });
|
||||
return c.json(clientCanvasPayload(store, g.canvas.id));
|
||||
});
|
||||
|
||||
app.get("/api/canvases/:id", (c) => {
|
||||
const id = c.req.param("id");
|
||||
try {
|
||||
return c.json(clientCanvasPayload(store, id));
|
||||
} catch {
|
||||
return c.json({ ok: false, error: "not_found" }, 404);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/api/canvases/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const body = (await c.req.json()) as {
|
||||
title?: string;
|
||||
viewport?: { x: number; y: number; zoom: number };
|
||||
};
|
||||
if (body.title) store.renameCanvas(id, body.title);
|
||||
if (body.viewport) store.updateViewport(id, body.viewport);
|
||||
return c.json({ canvas: store.loadCanvas(id).canvas });
|
||||
});
|
||||
|
||||
app.delete("/api/canvases/:id", (c) => {
|
||||
const id = c.req.param("id");
|
||||
store.hardDeleteCanvas(id);
|
||||
broadcast({ type: "canvas/deleted", canvasId: id });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/api/canvases/:id/ops", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
const coercedOps = Array.isArray(body?.ops)
|
||||
? body.ops.map((o: unknown) => coerceOpInput(o))
|
||||
: body?.ops;
|
||||
const parsed = z
|
||||
.object({
|
||||
ops: z.array(OpSchema),
|
||||
origin: z.enum(["user", "ai"]).default("user"),
|
||||
summary: z.string().optional(),
|
||||
})
|
||||
.safeParse({ ...body, ops: coercedOps });
|
||||
if (!parsed.success) {
|
||||
return c.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message:
|
||||
"Each op needs field `type` (not `op`). Valid: create_node, link, update_text, set_pinned, …",
|
||||
details: parsed.error.flatten(),
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = store.applyChangeSet(id, parsed.data.ops as Op[], {
|
||||
origin: parsed.data.origin,
|
||||
summary: parsed.data.summary,
|
||||
});
|
||||
const fitViewport =
|
||||
result.autoFitPadding != null
|
||||
? finalizeAutoFit(store, id, result.autoFitPadding)
|
||||
: null;
|
||||
const payload = clientCanvasPayload(store, id);
|
||||
const preserveUnpinned = parsed.data.ops.every((o) => o.type === "set_pinned");
|
||||
broadcast({
|
||||
type: "canvas/event",
|
||||
canvasId: id,
|
||||
changeSetId: result.changeSetId,
|
||||
rev: result.rev,
|
||||
preserveUnpinned,
|
||||
affectedNodeIds: result.affectedNodeIds.filter(
|
||||
(nid) => nid !== result.snapshot.canvas.anchorNodeId,
|
||||
),
|
||||
fitViewport: fitViewport ?? undefined,
|
||||
});
|
||||
return c.json({
|
||||
ok: true,
|
||||
changeSetId: result.changeSetId,
|
||||
rev: result.rev,
|
||||
affectedNodeIds: result.affectedNodeIds.filter(
|
||||
(nid) => nid !== result.snapshot.canvas.anchorNodeId,
|
||||
),
|
||||
fitViewport: fitViewport ?? undefined,
|
||||
...payload,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof GraphError) {
|
||||
return c.json({ ok: false, error: e.code, message: e.message }, 409);
|
||||
}
|
||||
return c.json({ ok: false, error: "invalid_op", message: String(e) }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/canvases/:id/undo", async (c) => {
|
||||
const canvasId = c.req.param("id");
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
changeSetId?: string;
|
||||
};
|
||||
try {
|
||||
let changeSetId = body.changeSetId;
|
||||
if (!changeSetId) {
|
||||
changeSetId = store.latestUndoableChangeSetId(canvasId) ?? undefined;
|
||||
}
|
||||
if (!changeSetId) {
|
||||
return c.json(
|
||||
{ ok: false, error: "nothing_to_undo", message: "No undoable change set" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
const result = store.undoChangeSet(changeSetId);
|
||||
const payload = clientCanvasPayload(store, canvasId);
|
||||
broadcast({
|
||||
type: "canvas/event",
|
||||
canvasId,
|
||||
changeSetId: result.changeSetId,
|
||||
rev: result.rev,
|
||||
});
|
||||
return c.json({
|
||||
ok: true,
|
||||
undoneChangeSetId: changeSetId,
|
||||
changeSetId: result.changeSetId,
|
||||
rev: result.rev,
|
||||
...payload,
|
||||
});
|
||||
} catch (e) {
|
||||
return c.json({ ok: false, error: "undo_failed", message: String(e) }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/canvases/:id/search", (c) => {
|
||||
const q = c.req.query("q") ?? "";
|
||||
const scopeRaw = c.req.query("scope") ?? "text";
|
||||
const scope =
|
||||
scopeRaw === "note" ||
|
||||
scopeRaw === "tags" ||
|
||||
scopeRaw === "description" ||
|
||||
scopeRaw === "edge_label" ||
|
||||
scopeRaw === "edge_note" ||
|
||||
scopeRaw === "all"
|
||||
? scopeRaw
|
||||
: "text";
|
||||
return c.json({ hits: store.search(c.req.param("id"), q, 20, scope) });
|
||||
});
|
||||
|
||||
app.get("/api/canvases/:id/trash", (c) => {
|
||||
try {
|
||||
return c.json({ nodes: store.listDeletedNodes(c.req.param("id")) });
|
||||
} catch {
|
||||
return c.json({ ok: false, error: "not_found" }, 404);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/canvases/:id/trash/:nodeId/restore", (c) => {
|
||||
try {
|
||||
const result = store.restoreNode(c.req.param("id"), c.req.param("nodeId"));
|
||||
broadcast({
|
||||
type: "canvas/event",
|
||||
canvasId: c.req.param("id"),
|
||||
changeSetId: result.changeSetId,
|
||||
});
|
||||
return c.json({ ok: true, ...result });
|
||||
} catch (e) {
|
||||
return c.json({ ok: false, message: String(e) }, 400);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/canvases/:id/activity", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const body = (await c.req.json()) as {
|
||||
selectionIds?: string[];
|
||||
focusNodeId?: string | null;
|
||||
viewport?: { x: number; y: number; zoom: number };
|
||||
};
|
||||
setActivity({
|
||||
canvasId: id,
|
||||
selectionIds: body.selectionIds ?? [],
|
||||
focusNodeId: body.focusNodeId ?? null,
|
||||
viewport: body.viewport ?? { x: 0, y: 0, zoom: 1 },
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get("/api/canvases/:id/proposals", (c) => {
|
||||
return c.json({ proposals: listProposals(c.req.param("id")) });
|
||||
});
|
||||
|
||||
app.post("/api/canvases/:id/proposals/:pid/accept", async (c) => {
|
||||
const result = await runTool(
|
||||
"mindmap_accept_proposal",
|
||||
{ proposalId: c.req.param("pid"), canvasId: c.req.param("id") },
|
||||
{ store, broadcast, defaultCanvasId: c.req.param("id") },
|
||||
);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
app.post("/api/canvases/:id/proposals/:pid/reject", async (c) => {
|
||||
const result = await runTool(
|
||||
"mindmap_reject_proposal",
|
||||
{ proposalId: c.req.param("pid") },
|
||||
{ store, broadcast, defaultCanvasId: c.req.param("id") },
|
||||
);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
app.get("/api/canvases/:id/export.json", (c) => {
|
||||
try {
|
||||
const snap = store.getSnapshot(c.req.param("id"));
|
||||
return c.json(snap);
|
||||
} catch {
|
||||
return c.json({ ok: false, error: "not_found" }, 404);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/tools/:name", async (c) => {
|
||||
const name = c.req.param("name");
|
||||
const args = (await c.req.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
try {
|
||||
const result = await runTool(name, args, { store, broadcast });
|
||||
return c.json(result);
|
||||
} catch (e) {
|
||||
return c.json(
|
||||
{ ok: false, error: "tool_error", message: String(e) },
|
||||
400,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const handleChat = createChatHandler({ store, broadcast });
|
||||
|
||||
async function sessionStatus(canvasId?: string) {
|
||||
if (options.sessionBridge) {
|
||||
return { ...options.sessionBridge.status(canvasId), canvasUrl: `http://${bind}:${port}` };
|
||||
}
|
||||
if (bridgeBase()) {
|
||||
const q = canvasId ? `?canvasId=${encodeURIComponent(canvasId)}` : "";
|
||||
return callDshBridge(`/status${q}`);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
mode: llmConfigured() ? "legacy-env-llm" : "unconfigured",
|
||||
bound: false,
|
||||
sessionId: null,
|
||||
agents: false,
|
||||
message: "未连接 DSH:请通过 dsh-mind-map 插件启动,对话将镜像 DSH 会话",
|
||||
};
|
||||
}
|
||||
|
||||
app.get("/api/session/status", async (c) => {
|
||||
try {
|
||||
const canvasId = c.req.query("canvasId") || undefined;
|
||||
return c.json(await sessionStatus(canvasId));
|
||||
} catch (e) {
|
||||
return c.json({ ok: false, message: String(e) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/session/prompt", async (c) => {
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
canvasId?: string;
|
||||
text?: string;
|
||||
};
|
||||
if (!body.canvasId || !body.text?.trim()) {
|
||||
return c.json({ ok: false, message: "canvasId and text required" }, 400);
|
||||
}
|
||||
try {
|
||||
if (options.sessionBridge) {
|
||||
const result = await options.sessionBridge.prompt(body.canvasId, body.text);
|
||||
return c.json(result);
|
||||
}
|
||||
if (bridgeBase()) {
|
||||
const result = await callDshBridge("/prompt", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ canvasId: body.canvasId, text: body.text }),
|
||||
});
|
||||
return c.json(result);
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "dsh_bridge_missing",
|
||||
message: "对话需经 DSH 会话。请用 dsh-mind-map 插件启动画布服务。",
|
||||
},
|
||||
503,
|
||||
);
|
||||
} catch (e) {
|
||||
return c.json({ ok: false, message: String(e) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/session/history", async (c) => {
|
||||
const canvasId = c.req.query("canvasId");
|
||||
if (!canvasId) return c.json({ ok: false, message: "canvasId required" }, 400);
|
||||
try {
|
||||
if (options.sessionBridge) {
|
||||
const hist = await options.sessionBridge.history(canvasId);
|
||||
return c.json({ ok: true, ...hist });
|
||||
}
|
||||
if (bridgeBase()) {
|
||||
const hist = await callDshBridge(
|
||||
`/history?canvasId=${encodeURIComponent(canvasId)}`,
|
||||
);
|
||||
return c.json(hist);
|
||||
}
|
||||
return c.json({ ok: true, sessionId: null, messages: [] });
|
||||
} catch (e) {
|
||||
return c.json({ ok: false, message: String(e) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/session/ensure", async (c) => {
|
||||
const body = (await c.req.json().catch(() => ({}))) as { canvasId?: string };
|
||||
if (!body.canvasId) return c.json({ ok: false, message: "canvasId required" }, 400);
|
||||
try {
|
||||
if (options.sessionBridge) {
|
||||
// ensure via prompt path's ensureBinding is private; status+history enough
|
||||
const st = options.sessionBridge.status(body.canvasId);
|
||||
if (st.bound) return c.json({ ok: true, sessionId: st.sessionId });
|
||||
}
|
||||
if (bridgeBase()) {
|
||||
const result = await callDshBridge("/ensure", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ canvasId: body.canvasId }),
|
||||
});
|
||||
return c.json(result);
|
||||
}
|
||||
return c.json({ ok: false, message: "DSH bridge missing" }, 503);
|
||||
} catch (e) {
|
||||
return c.json({ ok: false, message: String(e) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/internal/session-event", async (c) => {
|
||||
const token = c.req.header("x-mindmap-token") || "";
|
||||
const expected = internalToken();
|
||||
if (expected && token !== expected) {
|
||||
return c.json({ ok: false, error: "forbidden" }, 403);
|
||||
}
|
||||
const body = (await c.req.json().catch(() => ({}))) as {
|
||||
canvasId?: string;
|
||||
mirror?: Record<string, unknown>;
|
||||
};
|
||||
if (!body.canvasId || !body.mirror) {
|
||||
return c.json({ ok: false, message: "canvasId and mirror required" }, 400);
|
||||
}
|
||||
broadcast({
|
||||
type: "session/mirror",
|
||||
canvasId: body.canvasId,
|
||||
mirror: body.mirror,
|
||||
});
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
const uploadsDir = path.join(dataDir, "uploads");
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
|
||||
app.post("/api/uploads", async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as {
|
||||
dataBase64?: string;
|
||||
mimeType?: string;
|
||||
filename?: string;
|
||||
} | null;
|
||||
const raw = String(body?.dataBase64 || "").trim();
|
||||
if (!raw) {
|
||||
return c.json({ ok: false, message: "dataBase64 required" }, 400);
|
||||
}
|
||||
let b64 = raw;
|
||||
let mime = String(body?.mimeType || "").trim().toLowerCase();
|
||||
const dataUrl = /^data:([^;]+);base64,(.+)$/i.exec(raw);
|
||||
if (dataUrl) {
|
||||
mime = dataUrl[1]!.toLowerCase();
|
||||
b64 = dataUrl[2]!;
|
||||
}
|
||||
const allowed: Record<string, string> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
"image/gif": "gif",
|
||||
};
|
||||
const ext = allowed[mime];
|
||||
if (!ext) {
|
||||
return c.json(
|
||||
{
|
||||
ok: false,
|
||||
message: "仅支持 jpeg/png/webp/gif",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
let buf: Buffer;
|
||||
try {
|
||||
buf = Buffer.from(b64, "base64");
|
||||
} catch {
|
||||
return c.json({ ok: false, message: "invalid base64" }, 400);
|
||||
}
|
||||
if (!buf.length || buf.length > 6 * 1024 * 1024) {
|
||||
return c.json({ ok: false, message: "图片需小于 6MB(压缩后)" }, 400);
|
||||
}
|
||||
const id = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
const name = `${id}.${ext}`;
|
||||
fs.writeFileSync(path.join(uploadsDir, name), buf);
|
||||
const url = `/uploads/${name}`;
|
||||
return c.json({ ok: true, url, mimeType: mime, bytes: buf.length });
|
||||
});
|
||||
|
||||
app.get("/uploads/:name", (c) => {
|
||||
const name = c.req.param("name");
|
||||
if (!/^[\w.-]+\.(jpe?g|png|webp|gif)$/i.test(name)) {
|
||||
return c.notFound();
|
||||
}
|
||||
const fp = path.join(uploadsDir, name);
|
||||
if (!fs.existsSync(fp)) return c.notFound();
|
||||
const lower = name.toLowerCase();
|
||||
const mime = lower.endsWith(".png")
|
||||
? "image/png"
|
||||
: lower.endsWith(".webp")
|
||||
? "image/webp"
|
||||
: lower.endsWith(".gif")
|
||||
? "image/gif"
|
||||
: "image/jpeg";
|
||||
const data = fs.readFileSync(fp);
|
||||
return new Response(data, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mime,
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Legacy env-LLM path kept only as offline fallback; prefer DSH session.
|
||||
app.post("/api/chat", async (c) => {
|
||||
if (bridgeBase() || options.sessionBridge) {
|
||||
return c.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "use_session_prompt",
|
||||
message: "请使用 /api/session/prompt(DSH 会话镜像)",
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (!llmConfigured()) {
|
||||
return c.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "llm_not_configured",
|
||||
message:
|
||||
"请通过 DSH 插件 dsh-mind-map 使用会话对话;独立调试才需要 LLM_API_KEY",
|
||||
},
|
||||
503,
|
||||
);
|
||||
}
|
||||
const body = (await c.req.json()) as {
|
||||
canvasId: string;
|
||||
messages: { role: "user" | "assistant" | "system"; content: string }[];
|
||||
};
|
||||
if (!body.canvasId || !body.messages?.length) {
|
||||
return c.json({ ok: false, message: "canvasId and messages required" }, 400);
|
||||
}
|
||||
try {
|
||||
const result = await handleChat(body);
|
||||
return result.toDataStreamResponse();
|
||||
} catch (e) {
|
||||
return c.json({ ok: false, message: String(e) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/llm/status", async (c) => {
|
||||
try {
|
||||
const st = await sessionStatus();
|
||||
const dshOk = st.mode === "dsh-session" || Boolean(bridgeBase()) || Boolean(options.sessionBridge);
|
||||
return c.json({
|
||||
...llmStatus(options.llmSource ?? (dshOk ? "dsh" : undefined)),
|
||||
configured: dshOk || llmConfigured(),
|
||||
session: st,
|
||||
});
|
||||
} catch {
|
||||
return c.json({
|
||||
...llmStatus(options.llmSource),
|
||||
configured: llmConfigured(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Static UI: cwd is mind-map root when launched by the plugin.
|
||||
const webDistRel = "apps/web/dist";
|
||||
const webDistAbs = path.join(root, "apps/web/dist");
|
||||
if (fs.existsSync(webDistAbs) || fs.existsSync(path.join(process.cwd(), webDistRel))) {
|
||||
app.use(
|
||||
"/*",
|
||||
serveStatic({
|
||||
root: fs.existsSync(webDistAbs) ? webDistAbs : webDistRel,
|
||||
index: "index.html",
|
||||
}),
|
||||
);
|
||||
// SPA fallback (never swallow API / health)
|
||||
app.get("*", async (c) => {
|
||||
const p = c.req.path
|
||||
if (p.startsWith("/api") || p === "/health" || p.startsWith("/ws") || p.startsWith("/uploads")) {
|
||||
return c.notFound()
|
||||
}
|
||||
const indexPath = path.join(
|
||||
fs.existsSync(webDistAbs) ? webDistAbs : path.join(process.cwd(), webDistRel),
|
||||
"index.html",
|
||||
);
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
return c.text(
|
||||
"Mind Map UI not built. From mind-map repo run: pnpm --filter @mind-map/web build",
|
||||
503,
|
||||
);
|
||||
}
|
||||
return c.html(fs.readFileSync(indexPath, "utf8"));
|
||||
});
|
||||
} else {
|
||||
app.get("/", (c) =>
|
||||
c.text(
|
||||
"Mind Map UI missing (apps/web/dist). Run: pnpm --filter @mind-map/web build",
|
||||
503,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const server = serve({ fetch: app.fetch, port, hostname: bind }, (info) => {
|
||||
console.log(`[mind-map] http://${info.address}:${info.port}`);
|
||||
console.log(`[mind-map] db ${dbPath}`);
|
||||
}) as unknown as Server;
|
||||
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
console.error(`[mind-map] listen error: ${err.code || err.message}`);
|
||||
if (err.code === "EADDRINUSE") {
|
||||
console.error(
|
||||
`[mind-map] ${bind}:${port} is busy — stop the old process or change the port in DSH settings`,
|
||||
);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
try {
|
||||
store.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ server, path: "/ws" });
|
||||
wss.on("error", (err) => {
|
||||
console.error(`[mind-map] ws error: ${String(err)}`);
|
||||
});
|
||||
wss.on("connection", (ws) => {
|
||||
sockets.add(ws);
|
||||
ws.send(JSON.stringify({ type: "hello", ok: true }));
|
||||
ws.on("message", (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(String(raw)) as { type?: string };
|
||||
if (msg.type === "ping") ws.send(JSON.stringify({ type: "pong" }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
ws.on("close", () => sockets.delete(ws));
|
||||
});
|
||||
|
||||
return {
|
||||
app,
|
||||
store,
|
||||
port,
|
||||
bind,
|
||||
dbPath,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close();
|
||||
server.close(() => resolve());
|
||||
});
|
||||
store.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
3
apps/server/src/index.ts
Normal file
3
apps/server/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { startMindMapServer } from "./create-server.js";
|
||||
|
||||
startMindMapServer();
|
||||
2005
apps/server/src/tools.ts
Normal file
2005
apps/server/src/tools.ts
Normal file
File diff suppressed because it is too large
Load diff
16
apps/server/tsconfig.json
Normal file
16
apps/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"references": [
|
||||
{ "path": "../../packages/shared" },
|
||||
{ "path": "../../packages/graph-core" },
|
||||
{ "path": "../../packages/graph-store" },
|
||||
{ "path": "../../packages/layout-engine" },
|
||||
{ "path": "../../packages/dream-skin" }
|
||||
]
|
||||
}
|
||||
1
apps/server/tsconfig.tsbuildinfo
Normal file
1
apps/server/tsconfig.tsbuildinfo
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"root":["./src/chat.ts","./src/create-server.ts","./src/index.ts","./src/tools.ts"],"version":"5.9.3"}
|
||||
Loading…
Add table
Add a link
Reference in a new issue