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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long