mirror of
https://github.com/hansjone/mind-map.git
synced 2026-10-08 23:33:24 +08:00
Initial commit: DSH mind-map canvas monorepo
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
2ecd300ada
92 changed files with 20486 additions and 0 deletions
22
packages/dream-skin/package.json
Normal file
22
packages/dream-skin/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "@mind-map/dream-skin",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.9"
|
||||
}
|
||||
}
|
||||
133
packages/dream-skin/src/index.ts
Normal file
133
packages/dream-skin/src/index.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import skinsJson from "./skins.json" with { type: "json" };
|
||||
|
||||
export type DreamSkin = {
|
||||
id: string;
|
||||
colorScheme: "light" | "dark";
|
||||
name: string;
|
||||
nameZh: string;
|
||||
tokens: Record<string, string>;
|
||||
};
|
||||
|
||||
export const SKINS = skinsJson as DreamSkin[];
|
||||
export const DEFAULT_SKIN_ID = "abyss";
|
||||
|
||||
export function getSkin(id: string): DreamSkin {
|
||||
return SKINS.find((s) => s.id === id) ?? SKINS[0]!;
|
||||
}
|
||||
|
||||
export function listSkins(): DreamSkin[] {
|
||||
return SKINS;
|
||||
}
|
||||
|
||||
/** Apply CSS variables onto an element (usually document.documentElement). */
|
||||
export function applySkinToElement(
|
||||
el: {
|
||||
style: { setProperty(k: string, v: string): void };
|
||||
dataset: { theme?: string; colorScheme?: string };
|
||||
},
|
||||
skinId: string,
|
||||
): DreamSkin {
|
||||
const skin = getSkin(skinId);
|
||||
el.dataset.theme = skin.id;
|
||||
el.dataset.colorScheme = skin.colorScheme;
|
||||
for (const [key, value] of Object.entries(skin.tokens)) {
|
||||
el.style.setProperty(key, value);
|
||||
}
|
||||
return skin;
|
||||
}
|
||||
|
||||
export type ThemeSnapshot = {
|
||||
skinId: string;
|
||||
colorScheme: "light" | "dark";
|
||||
bgBase: string;
|
||||
bgLayer1: string;
|
||||
labelPrimary: string;
|
||||
labelSecondary: string;
|
||||
brand: string;
|
||||
border: string;
|
||||
bubble: string;
|
||||
warn: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export function toThemeSnapshot(skinId: string): ThemeSnapshot {
|
||||
const skin = getSkin(skinId);
|
||||
const t = skin.tokens;
|
||||
return {
|
||||
skinId: skin.id,
|
||||
colorScheme: skin.colorScheme,
|
||||
bgBase: t["--dsw-alias-bg-base"] ?? "#101014",
|
||||
bgLayer1: t["--dsw-alias-bg-layer-1"] ?? "#1b1e28",
|
||||
labelPrimary: t["--dsw-alias-label-primary"] ?? "#f4f5f7",
|
||||
labelSecondary: t["--dsw-alias-label-secondary"] ?? "#a5adb8",
|
||||
brand: t["--dsw-alias-brand-primary"] ?? "#5e6ad2",
|
||||
border: t["--dsw-alias-border-l1"] ?? "rgba(255,255,255,0.08)",
|
||||
bubble: t["--dsw-specific-bubble"] ?? "rgba(37,42,58,0.9)",
|
||||
warn: t["--dsw-alias-state-warn-primary"] ?? "#f59e5b",
|
||||
error: t["--dsw-alias-state-error-primary"] ?? "#ef4444",
|
||||
};
|
||||
}
|
||||
|
||||
/** Generate restrained branch hues from brand color. */
|
||||
export function branchPalette(brandHex: string, count = 8): string[] {
|
||||
const rgb = hexToRgb(brandHex) ?? { r: 94, g: 106, b: 210 };
|
||||
const { h, s, l } = rgbToHsl(rgb.r, rgb.g, rgb.b);
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nh = (h + i * (360 / count)) % 360;
|
||||
out.push(hslToHex(nh, Math.min(0.55, s * 0.85), Math.min(0.62, Math.max(0.42, l))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!m) return null;
|
||||
const n = parseInt(m[1]!, 16);
|
||||
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
||||
}
|
||||
|
||||
function rgbToHsl(r: number, g: number, b: number) {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
const l = (max + min) / 2;
|
||||
const d = max - min;
|
||||
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
|
||||
if (d !== 0) {
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) * 60;
|
||||
break;
|
||||
default:
|
||||
h = ((r - g) / d + 4) * 60;
|
||||
}
|
||||
}
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = l - c / 2;
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
if (h < 60) [r, g, b] = [c, x, 0];
|
||||
else if (h < 120) [r, g, b] = [x, c, 0];
|
||||
else if (h < 180) [r, g, b] = [0, c, x];
|
||||
else if (h < 240) [r, g, b] = [0, x, c];
|
||||
else if (h < 300) [r, g, b] = [x, 0, c];
|
||||
else [r, g, b] = [c, 0, x];
|
||||
const to = (v: number) =>
|
||||
Math.round((v + m) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `#${to(r)}${to(g)}${to(b)}`;
|
||||
}
|
||||
330
packages/dream-skin/src/skins.json
Normal file
330
packages/dream-skin/src/skins.json
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
[
|
||||
{
|
||||
"id": "abyss",
|
||||
"colorScheme": "dark",
|
||||
"name": "Abyss",
|
||||
"nameZh": "深渊",
|
||||
"tokens": {
|
||||
"--dsw-alias-bg-base": "#101014",
|
||||
"--dsw-alias-bg-layer-1": "#1b1e28",
|
||||
"--dsw-alias-bg-layer-2": "rgba(26, 30, 42, 0.85)",
|
||||
"--dsw-alias-bg-layer-3": "#1a1d27",
|
||||
"--dsw-alias-bg-module-platform": "#151821",
|
||||
"--dsw-alias-bg-overlay": "rgba(24, 24, 30, 0.86)",
|
||||
"--dsw-specific-input-major": "rgba(255, 255, 255, 0.08)",
|
||||
"--dsw-specific-tip": "rgba(30, 33, 46, 0.9)",
|
||||
"--dsw-alias-border-l1": "rgba(255, 255, 255, 0.07)",
|
||||
"--dsw-alias-border-l2": "rgba(255, 255, 255, 0.13)",
|
||||
"--dsw-alias-label-primary": "#f4f5f7",
|
||||
"--dsw-alias-label-secondary": "#a5adb8",
|
||||
"--dsw-alias-label-tertiary": "#7b838f",
|
||||
"--dsw-alias-brand-primary": "#5e6ad2",
|
||||
"--dsw-specific-bubble": "rgba(37, 42, 58, 0.9)",
|
||||
"--dsw-specific-bubble-highlight": "rgba(94, 106, 210, 0.16)",
|
||||
"--dsw-specific-selector": "rgba(255, 255, 255, 0.10)",
|
||||
"--dsw-alias-brand-text": "#ffffff",
|
||||
"--dsw-alias-button-primary-hover": "#6f7be0",
|
||||
"--dsw-alias-button-primary-dimmed": "rgba(94, 106, 210, 0.16)",
|
||||
"--dsw-alias-state-business-primary": "#5e6ad2",
|
||||
"--dsw-alias-state-business-tertiary": "rgba(94, 106, 210, 0.16)",
|
||||
"--dsw-alias-interactive-bg-hover": "rgba(94, 106, 210, 0.16)",
|
||||
"--dsw-alias-interactive-bg-active": "rgba(94, 106, 210, 0.26)",
|
||||
"--dsw-alias-markdown-code-block": "rgba(0, 0, 0, 0.35)",
|
||||
"--dsw-alias-markdown-inline-code": "rgba(255, 255, 255, 0.09)",
|
||||
"--dsw-specific-sidebar-fill": "rgba(16, 16, 20, 0.92)",
|
||||
"--dsw-specific-sidebar-nav-item-active": "rgba(255, 255, 255, 0.09)",
|
||||
"--dsw-specific-sidebar-nav-item-hover": "rgba(255, 255, 255, 0.05)",
|
||||
"--dsw-alias-scrollbar-bg-l1": "rgba(255, 255, 255, 0.09)",
|
||||
"--dsw-alias-scrollbar-bg-l2": "rgba(255, 255, 255, 0.14)",
|
||||
"--dsw-alias-scrollbar-hover-l1": "rgba(255, 255, 255, 0.2)",
|
||||
"--dsw-alias-scrollbar-hover-l2": "rgba(255, 255, 255, 0.2)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "aurora",
|
||||
"colorScheme": "dark",
|
||||
"name": "Aurora",
|
||||
"nameZh": "极光",
|
||||
"tokens": {
|
||||
"--dsw-alias-bg-base": "#0e1316",
|
||||
"--dsw-alias-bg-layer-1": "#162128",
|
||||
"--dsw-alias-bg-layer-2": "rgba(22, 32, 34, 0.85)",
|
||||
"--dsw-alias-bg-layer-3": "#182128",
|
||||
"--dsw-alias-bg-module-platform": "#131c20",
|
||||
"--dsw-alias-bg-overlay": "rgba(18, 24, 27, 0.86)",
|
||||
"--dsw-specific-input-major": "rgba(255, 255, 255, 0.08)",
|
||||
"--dsw-specific-tip": "rgba(24, 35, 36, 0.9)",
|
||||
"--dsw-alias-border-l1": "rgba(110, 231, 183, 0.10)",
|
||||
"--dsw-alias-border-l2": "rgba(110, 231, 183, 0.18)",
|
||||
"--dsw-alias-label-primary": "#eefaf4",
|
||||
"--dsw-alias-label-secondary": "#9fc9b8",
|
||||
"--dsw-alias-label-tertiary": "#74a494",
|
||||
"--dsw-alias-brand-primary": "#2dd4bf",
|
||||
"--dsw-specific-bubble": "rgba(30, 42, 44, 0.9)",
|
||||
"--dsw-specific-bubble-highlight": "rgba(45, 212, 191, 0.14)",
|
||||
"--dsw-specific-selector": "rgba(255, 255, 255, 0.10)",
|
||||
"--dsw-alias-brand-text": "#03211b",
|
||||
"--dsw-alias-button-primary-hover": "#45e0cd",
|
||||
"--dsw-alias-button-primary-dimmed": "rgba(45, 212, 191, 0.14)",
|
||||
"--dsw-alias-state-business-primary": "#2dd4bf",
|
||||
"--dsw-alias-state-business-tertiary": "rgba(45, 212, 191, 0.14)",
|
||||
"--dsw-alias-interactive-bg-hover": "rgba(45, 212, 191, 0.14)",
|
||||
"--dsw-alias-interactive-bg-active": "rgba(45, 212, 191, 0.22)",
|
||||
"--dsw-alias-markdown-code-block": "rgba(0, 0, 0, 0.32)",
|
||||
"--dsw-alias-markdown-inline-code": "rgba(255, 255, 255, 0.085)",
|
||||
"--dsw-specific-sidebar-fill": "rgba(14, 19, 22, 0.92)",
|
||||
"--dsw-specific-sidebar-nav-item-active": "rgba(255, 255, 255, 0.085)",
|
||||
"--dsw-specific-sidebar-nav-item-hover": "rgba(255, 255, 255, 0.045)",
|
||||
"--dsw-alias-scrollbar-bg-l1": "rgba(255, 255, 255, 0.085)",
|
||||
"--dsw-alias-scrollbar-bg-l2": "rgba(255, 255, 255, 0.13)",
|
||||
"--dsw-alias-scrollbar-hover-l1": "rgba(255, 255, 255, 0.2)",
|
||||
"--dsw-alias-scrollbar-hover-l2": "rgba(255, 255, 255, 0.2)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "nebula",
|
||||
"colorScheme": "dark",
|
||||
"name": "Nebula",
|
||||
"nameZh": "星云",
|
||||
"tokens": {
|
||||
"--dsw-alias-bg-base": "#12101a",
|
||||
"--dsw-alias-bg-layer-1": "#1c1a2a",
|
||||
"--dsw-alias-bg-layer-2": "rgba(30, 27, 44, 0.85)",
|
||||
"--dsw-alias-bg-layer-3": "#1c1a28",
|
||||
"--dsw-alias-bg-module-platform": "#171523",
|
||||
"--dsw-alias-bg-overlay": "rgba(22, 20, 30, 0.86)",
|
||||
"--dsw-specific-input-major": "rgba(255, 255, 255, 0.08)",
|
||||
"--dsw-specific-tip": "rgba(32, 28, 46, 0.9)",
|
||||
"--dsw-alias-border-l1": "rgba(196, 181, 253, 0.10)",
|
||||
"--dsw-alias-border-l2": "rgba(196, 181, 253, 0.18)",
|
||||
"--dsw-alias-label-primary": "#f3f0fb",
|
||||
"--dsw-alias-label-secondary": "#b6a8d9",
|
||||
"--dsw-alias-label-tertiary": "#8a7cb0",
|
||||
"--dsw-alias-brand-primary": "#8b7cf6",
|
||||
"--dsw-specific-bubble": "rgba(40, 36, 56, 0.9)",
|
||||
"--dsw-specific-bubble-highlight": "rgba(139, 124, 246, 0.14)",
|
||||
"--dsw-specific-selector": "rgba(255, 255, 255, 0.10)",
|
||||
"--dsw-alias-brand-text": "#0d0a1c",
|
||||
"--dsw-alias-button-primary-hover": "#9d90f8",
|
||||
"--dsw-alias-button-primary-dimmed": "rgba(139, 124, 246, 0.14)",
|
||||
"--dsw-alias-state-business-primary": "#8b7cf6",
|
||||
"--dsw-alias-state-business-tertiary": "rgba(139, 124, 246, 0.14)",
|
||||
"--dsw-alias-interactive-bg-hover": "rgba(139, 124, 246, 0.14)",
|
||||
"--dsw-alias-interactive-bg-active": "rgba(139, 124, 246, 0.22)",
|
||||
"--dsw-alias-markdown-code-block": "rgba(0, 0, 0, 0.32)",
|
||||
"--dsw-alias-markdown-inline-code": "rgba(255, 255, 255, 0.085)",
|
||||
"--dsw-specific-sidebar-fill": "rgba(18, 16, 26, 0.92)",
|
||||
"--dsw-specific-sidebar-nav-item-active": "rgba(255, 255, 255, 0.085)",
|
||||
"--dsw-specific-sidebar-nav-item-hover": "rgba(255, 255, 255, 0.045)",
|
||||
"--dsw-alias-scrollbar-bg-l1": "rgba(255, 255, 255, 0.085)",
|
||||
"--dsw-alias-scrollbar-bg-l2": "rgba(255, 255, 255, 0.13)",
|
||||
"--dsw-alias-scrollbar-hover-l1": "rgba(255, 255, 255, 0.2)",
|
||||
"--dsw-alias-scrollbar-hover-l2": "rgba(255, 255, 255, 0.2)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ember",
|
||||
"colorScheme": "dark",
|
||||
"name": "Ember",
|
||||
"nameZh": "余烬",
|
||||
"tokens": {
|
||||
"--dsw-alias-bg-base": "#16110d",
|
||||
"--dsw-alias-bg-layer-1": "#211a15",
|
||||
"--dsw-alias-bg-layer-2": "rgba(36, 28, 20, 0.85)",
|
||||
"--dsw-alias-bg-layer-3": "#231b15",
|
||||
"--dsw-alias-bg-module-platform": "#1b1712",
|
||||
"--dsw-alias-bg-overlay": "rgba(28, 20, 15, 0.86)",
|
||||
"--dsw-specific-input-major": "rgba(255, 255, 255, 0.08)",
|
||||
"--dsw-specific-tip": "rgba(38, 28, 20, 0.9)",
|
||||
"--dsw-alias-border-l1": "rgba(253, 186, 116, 0.10)",
|
||||
"--dsw-alias-border-l2": "rgba(253, 186, 116, 0.18)",
|
||||
"--dsw-alias-label-primary": "#fdf0e6",
|
||||
"--dsw-alias-label-secondary": "#d0a98a",
|
||||
"--dsw-alias-label-tertiary": "#a48266",
|
||||
"--dsw-alias-brand-primary": "#f59e5b",
|
||||
"--dsw-specific-bubble": "rgba(48, 38, 28, 0.9)",
|
||||
"--dsw-specific-bubble-highlight": "rgba(245, 158, 91, 0.14)",
|
||||
"--dsw-specific-selector": "rgba(255, 255, 255, 0.10)",
|
||||
"--dsw-alias-brand-text": "#1f0f06",
|
||||
"--dsw-alias-button-primary-hover": "#f8b06f",
|
||||
"--dsw-alias-button-primary-dimmed": "rgba(245, 158, 91, 0.14)",
|
||||
"--dsw-alias-state-business-primary": "#f59e5b",
|
||||
"--dsw-alias-state-business-tertiary": "rgba(245, 158, 91, 0.14)",
|
||||
"--dsw-alias-interactive-bg-hover": "rgba(245, 158, 91, 0.14)",
|
||||
"--dsw-alias-interactive-bg-active": "rgba(245, 158, 91, 0.22)",
|
||||
"--dsw-alias-markdown-code-block": "rgba(0, 0, 0, 0.32)",
|
||||
"--dsw-alias-markdown-inline-code": "rgba(255, 255, 255, 0.085)",
|
||||
"--dsw-specific-sidebar-fill": "rgba(22, 17, 13, 0.92)",
|
||||
"--dsw-specific-sidebar-nav-item-active": "rgba(255, 255, 255, 0.085)",
|
||||
"--dsw-specific-sidebar-nav-item-hover": "rgba(255, 255, 255, 0.045)",
|
||||
"--dsw-alias-scrollbar-bg-l1": "rgba(255, 255, 255, 0.085)",
|
||||
"--dsw-alias-scrollbar-bg-l2": "rgba(255, 255, 255, 0.13)",
|
||||
"--dsw-alias-scrollbar-hover-l1": "rgba(255, 255, 255, 0.2)",
|
||||
"--dsw-alias-scrollbar-hover-l2": "rgba(255, 255, 255, 0.2)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "midnight",
|
||||
"colorScheme": "dark",
|
||||
"name": "Midnight",
|
||||
"nameZh": "午夜",
|
||||
"tokens": {
|
||||
"--dsw-alias-bg-base": "#0b0b0e",
|
||||
"--dsw-alias-bg-layer-1": "#17171f",
|
||||
"--dsw-alias-bg-layer-2": "rgba(22, 22, 28, 0.85)",
|
||||
"--dsw-alias-bg-layer-3": "#181820",
|
||||
"--dsw-alias-bg-module-platform": "#11111a",
|
||||
"--dsw-alias-bg-overlay": "rgba(20, 20, 25, 0.86)",
|
||||
"--dsw-specific-input-major": "rgba(255, 255, 255, 0.08)",
|
||||
"--dsw-specific-tip": "rgba(26, 26, 32, 0.9)",
|
||||
"--dsw-alias-border-l1": "rgba(255, 255, 255, 0.06)",
|
||||
"--dsw-alias-border-l2": "rgba(255, 255, 255, 0.12)",
|
||||
"--dsw-alias-label-primary": "#f2f2f6",
|
||||
"--dsw-alias-label-secondary": "#a4a4b2",
|
||||
"--dsw-alias-label-tertiary": "#787884",
|
||||
"--dsw-alias-brand-primary": "#7c8cff",
|
||||
"--dsw-specific-bubble": "rgba(30, 30, 38, 0.9)",
|
||||
"--dsw-specific-bubble-highlight": "rgba(124, 140, 255, 0.14)",
|
||||
"--dsw-specific-selector": "rgba(255, 255, 255, 0.10)",
|
||||
"--dsw-alias-brand-text": "#05050f",
|
||||
"--dsw-alias-button-primary-hover": "#93a1ff",
|
||||
"--dsw-alias-button-primary-dimmed": "rgba(124, 140, 255, 0.14)",
|
||||
"--dsw-alias-state-business-primary": "#7c8cff",
|
||||
"--dsw-alias-state-business-tertiary": "rgba(124, 140, 255, 0.14)",
|
||||
"--dsw-alias-interactive-bg-hover": "rgba(124, 140, 255, 0.12)",
|
||||
"--dsw-alias-interactive-bg-active": "rgba(124, 140, 255, 0.20)",
|
||||
"--dsw-alias-markdown-code-block": "rgba(0, 0, 0, 0.4)",
|
||||
"--dsw-alias-markdown-inline-code": "rgba(255, 255, 255, 0.085)",
|
||||
"--dsw-specific-sidebar-fill": "rgba(11, 11, 14, 0.92)",
|
||||
"--dsw-specific-sidebar-nav-item-active": "rgba(255, 255, 255, 0.085)",
|
||||
"--dsw-specific-sidebar-nav-item-hover": "rgba(255, 255, 255, 0.045)",
|
||||
"--dsw-alias-scrollbar-bg-l1": "rgba(255, 255, 255, 0.085)",
|
||||
"--dsw-alias-scrollbar-bg-l2": "rgba(255, 255, 255, 0.13)",
|
||||
"--dsw-alias-scrollbar-hover-l1": "rgba(255, 255, 255, 0.2)",
|
||||
"--dsw-alias-scrollbar-hover-l2": "rgba(255, 255, 255, 0.2)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ivory",
|
||||
"colorScheme": "light",
|
||||
"name": "Ivory",
|
||||
"nameZh": "象牙",
|
||||
"tokens": {
|
||||
"--dsw-alias-bg-base": "#f4f4f6",
|
||||
"--dsw-alias-bg-layer-1": "#ffffff",
|
||||
"--dsw-alias-bg-layer-2": "#ffffff",
|
||||
"--dsw-alias-bg-layer-3": "#fafafc",
|
||||
"--dsw-alias-bg-module-platform": "#ededf0",
|
||||
"--dsw-alias-bg-overlay": "#ffffff",
|
||||
"--dsw-specific-input-major": "#ffffff",
|
||||
"--dsw-specific-tip": "#ffffff",
|
||||
"--dsw-alias-border-l1": "rgba(0, 0, 0, 0.08)",
|
||||
"--dsw-alias-border-l2": "rgba(0, 0, 0, 0.14)",
|
||||
"--dsw-alias-label-primary": "#1c1c1e",
|
||||
"--dsw-alias-label-secondary": "#6e6e73",
|
||||
"--dsw-alias-label-tertiary": "#86868b",
|
||||
"--dsw-alias-brand-primary": "#0071e3",
|
||||
"--dsw-specific-bubble": "#ffffff",
|
||||
"--dsw-specific-bubble-highlight": "rgba(0, 113, 227, 0.08)",
|
||||
"--dsw-specific-selector": "rgba(0, 0, 0, 0.04)",
|
||||
"--dsw-alias-brand-text": "#ffffff",
|
||||
"--dsw-alias-button-primary-hover": "#3395ff",
|
||||
"--dsw-alias-button-primary-dimmed": "rgba(0, 113, 227, 0.12)",
|
||||
"--dsw-alias-state-business-primary": "#0071e3",
|
||||
"--dsw-alias-state-business-tertiary": "rgba(0, 113, 227, 0.12)",
|
||||
"--dsw-alias-interactive-bg-hover": "rgba(0, 113, 227, 0.10)",
|
||||
"--dsw-alias-interactive-bg-active": "rgba(0, 113, 227, 0.16)",
|
||||
"--dsw-alias-markdown-code-block": "#f2f2f4",
|
||||
"--dsw-alias-markdown-inline-code": "rgba(0, 113, 227, 0.10)",
|
||||
"--dsw-specific-sidebar-fill": "#f4f4f6",
|
||||
"--dsw-specific-sidebar-nav-item-active": "#e4e4e8",
|
||||
"--dsw-specific-sidebar-nav-item-hover": "rgba(0, 0, 0, 0.04)",
|
||||
"--dsw-alias-scrollbar-bg-l1": "#d1d1d6",
|
||||
"--dsw-alias-scrollbar-bg-l2": "#c0c0c6",
|
||||
"--dsw-alias-scrollbar-hover-l1": "#a6a6ad",
|
||||
"--dsw-alias-scrollbar-hover-l2": "#a6a6ad"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "mist",
|
||||
"colorScheme": "light",
|
||||
"name": "Mist",
|
||||
"nameZh": "晨雾",
|
||||
"tokens": {
|
||||
"--dsw-alias-bg-base": "#e9eef6",
|
||||
"--dsw-alias-bg-layer-1": "rgba(255, 255, 255, 0.5)",
|
||||
"--dsw-alias-bg-layer-2": "rgba(255, 255, 255, 0.6)",
|
||||
"--dsw-alias-bg-layer-3": "rgba(255, 255, 255, 0.68)",
|
||||
"--dsw-alias-bg-module-platform": "rgba(243, 247, 252, 0.9)",
|
||||
"--dsw-alias-bg-overlay": "rgba(255, 255, 255, 0.55)",
|
||||
"--dsw-specific-input-major": "rgba(255, 255, 255, 0.45)",
|
||||
"--dsw-specific-tip": "rgba(255, 255, 255, 0.5)",
|
||||
"--dsw-alias-border-l1": "rgba(30, 41, 59, 0.10)",
|
||||
"--dsw-alias-border-l2": "rgba(30, 41, 59, 0.16)",
|
||||
"--dsw-alias-label-primary": "#0f1b33",
|
||||
"--dsw-alias-label-secondary": "#3d5270",
|
||||
"--dsw-alias-label-tertiary": "#6b80a0",
|
||||
"--dsw-alias-brand-primary": "#2196f3",
|
||||
"--dsw-specific-bubble": "rgba(255, 255, 255, 0.7)",
|
||||
"--dsw-specific-bubble-highlight": "rgba(33, 150, 243, 0.12)",
|
||||
"--dsw-specific-selector": "rgba(255, 255, 255, 0.5)",
|
||||
"--dsw-alias-brand-text": "#ffffff",
|
||||
"--dsw-alias-button-primary-hover": "#42a5f5",
|
||||
"--dsw-alias-button-primary-dimmed": "rgba(33, 150, 243, 0.14)",
|
||||
"--dsw-alias-state-business-primary": "#2196f3",
|
||||
"--dsw-alias-state-business-tertiary": "rgba(33, 150, 243, 0.14)",
|
||||
"--dsw-alias-interactive-bg-hover": "rgba(33, 150, 243, 0.12)",
|
||||
"--dsw-alias-interactive-bg-active": "rgba(33, 150, 243, 0.20)",
|
||||
"--dsw-alias-markdown-code-block": "rgba(255, 255, 255, 0.6)",
|
||||
"--dsw-alias-markdown-inline-code": "rgba(33, 150, 243, 0.12)",
|
||||
"--dsw-specific-sidebar-fill": "rgba(255, 255, 255, 0.5)",
|
||||
"--dsw-specific-sidebar-nav-item-active": "rgba(255, 255, 255, 0.72)",
|
||||
"--dsw-specific-sidebar-nav-item-hover": "rgba(255, 255, 255, 0.35)",
|
||||
"--dsw-alias-scrollbar-bg-l1": "rgba(30, 41, 59, 0.18)",
|
||||
"--dsw-alias-scrollbar-bg-l2": "rgba(30, 41, 59, 0.24)",
|
||||
"--dsw-alias-scrollbar-hover-l1": "rgba(30, 41, 59, 0.34)",
|
||||
"--dsw-alias-scrollbar-hover-l2": "rgba(30, 41, 59, 0.34)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "rose",
|
||||
"colorScheme": "light",
|
||||
"name": "Rose",
|
||||
"nameZh": "玫瑰",
|
||||
"tokens": {
|
||||
"--dsw-alias-bg-base": "#f7f0f3",
|
||||
"--dsw-alias-bg-layer-1": "#ffffff",
|
||||
"--dsw-alias-bg-layer-2": "#fffdfd",
|
||||
"--dsw-alias-bg-layer-3": "#fdeef3",
|
||||
"--dsw-alias-bg-module-platform": "#f6e9ef",
|
||||
"--dsw-alias-bg-overlay": "#ffffff",
|
||||
"--dsw-specific-input-major": "#ffffff",
|
||||
"--dsw-specific-tip": "#fff7fa",
|
||||
"--dsw-alias-border-l1": "rgba(154, 55, 118, 0.12)",
|
||||
"--dsw-alias-border-l2": "rgba(154, 55, 118, 0.20)",
|
||||
"--dsw-alias-label-primary": "#3a1424",
|
||||
"--dsw-alias-label-secondary": "#8a4a63",
|
||||
"--dsw-alias-label-tertiary": "#a86b82",
|
||||
"--dsw-alias-brand-primary": "#e91e63",
|
||||
"--dsw-specific-bubble": "#ffffff",
|
||||
"--dsw-specific-bubble-highlight": "rgba(233, 30, 99, 0.10)",
|
||||
"--dsw-specific-selector": "rgba(233, 30, 99, 0.06)",
|
||||
"--dsw-alias-brand-text": "#ffffff",
|
||||
"--dsw-alias-button-primary-hover": "#f06292",
|
||||
"--dsw-alias-button-primary-dimmed": "rgba(233, 30, 99, 0.12)",
|
||||
"--dsw-alias-state-business-primary": "#e91e63",
|
||||
"--dsw-alias-state-business-tertiary": "rgba(233, 30, 99, 0.12)",
|
||||
"--dsw-alias-interactive-bg-hover": "rgba(233, 30, 99, 0.10)",
|
||||
"--dsw-alias-interactive-bg-active": "rgba(233, 30, 99, 0.18)",
|
||||
"--dsw-alias-markdown-code-block": "#fdeef3",
|
||||
"--dsw-alias-markdown-inline-code": "rgba(233, 30, 99, 0.10)",
|
||||
"--dsw-specific-sidebar-fill": "#f6e9ef",
|
||||
"--dsw-specific-sidebar-nav-item-active": "#f5d4e2",
|
||||
"--dsw-specific-sidebar-nav-item-hover": "rgba(233, 30, 99, 0.06)",
|
||||
"--dsw-alias-scrollbar-bg-l1": "#e5c7d4",
|
||||
"--dsw-alias-scrollbar-bg-l2": "#d9b3c4",
|
||||
"--dsw-alias-scrollbar-hover-l1": "#c392ab",
|
||||
"--dsw-alias-scrollbar-hover-l2": "#c392ab"
|
||||
}
|
||||
}
|
||||
]
|
||||
9
packages/dream-skin/tsconfig.json
Normal file
9
packages/dream-skin/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
1
packages/dream-skin/tsconfig.tsbuildinfo
Normal file
1
packages/dream-skin/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
15
packages/dsh-plugin/INSTALL.md
Normal file
15
packages/dsh-plugin/INSTALL.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# dsh-mind-map 本地安装到 DSH web profile(开发用)
|
||||
|
||||
```powershell
|
||||
$plugin = "d:\project\chatgpt\mind-map\packages\dsh-plugin"
|
||||
# 用 dsh plugin add 链到本地包,或在 profile 里 link
|
||||
dsh plugin --profile web add -w $plugin
|
||||
```
|
||||
|
||||
装好后:
|
||||
|
||||
1. 重启 `dsh web`,浏览器硬刷新(Ctrl+Shift+R)
|
||||
2. 右侧栏「+」选 **思维导图**,或 设置 → Mind Map → **打开思维导图 Tab**
|
||||
3. 拓扑占满右侧内容区;对话继续用中间 DSH 输入框
|
||||
|
||||
依赖:已安装 `dsh-better-sidebar`(提供 `ctx.betterSidebar.registerTab`)。
|
||||
11
packages/dsh-plugin/README.md
Normal file
11
packages/dsh-plugin/README.md
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# dsh-mind-map
|
||||
|
||||
DSH 插件:自建画布 Web;**对话 = DSH 会话镜像**(输入转发,历史由 DSH 管)。
|
||||
|
||||
## 架构
|
||||
|
||||
- Host:注册 `mindmap_*`、启停本机 Web、会话绑定
|
||||
- Client:设置卡(端口 / 打开画布 / 绑定)
|
||||
- Web 右栏:零持久化镜像,不做独立 chat
|
||||
|
||||
详见仓库 `docs/dsh-bridge.md` 与设计稿 v2.9。
|
||||
4
packages/dsh-plugin/cordis.patch.yml
Normal file
4
packages/dsh-plugin/cordis.patch.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Mind Map — DSH profile patch
|
||||
- insert:
|
||||
- id: dsh-mind-map
|
||||
name: dsh-mind-map
|
||||
1223
packages/dsh-plugin/lib/client.js
Normal file
1223
packages/dsh-plugin/lib/client.js
Normal file
File diff suppressed because it is too large
Load diff
380
packages/dsh-plugin/lib/index.js
Normal file
380
packages/dsh-plugin/lib/index.js
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
/**
|
||||
* dsh-mind-map — Host half.
|
||||
* Canvas Web is local; chat IS a DSH Session (mirror only on the Web).
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { homedir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createRuntime } from './runtime.js'
|
||||
import { createSessionBridge } from './session-bridge.js'
|
||||
import { makeMindmapSkill, registerMindmapTools } from './tools.js'
|
||||
|
||||
export const name = 'dsh-mind-map'
|
||||
export const NS = 'dsh-mind-map'
|
||||
export const inject = []
|
||||
|
||||
const API_PREFIX = '/dsh-mind-map'
|
||||
const DEFAULTS = {
|
||||
enabled: true,
|
||||
port: 17890,
|
||||
autoStart: true,
|
||||
}
|
||||
|
||||
function loadPkg(id) {
|
||||
const homes = [
|
||||
fileURLToPath(import.meta.url),
|
||||
join(process.cwd(), 'package.json'),
|
||||
join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'profiles', 'web', 'package.json'),
|
||||
]
|
||||
const errors = []
|
||||
for (const from of homes) {
|
||||
try {
|
||||
const require = createRequire(from)
|
||||
return require(id)
|
||||
} catch (e) {
|
||||
errors.push(e)
|
||||
}
|
||||
}
|
||||
const err = new Error(`cannot resolve ${id}`)
|
||||
err.cause = errors[0]
|
||||
throw err
|
||||
}
|
||||
|
||||
const SchemaMod = loadPkg('@deepseek-ai/schemastery')
|
||||
const Schema = SchemaMod.default || SchemaMod
|
||||
|
||||
export const Config = Schema.object({
|
||||
enabled: Schema.boolean().default(DEFAULTS.enabled),
|
||||
port: Schema.number().min(1024).max(65535).step(1).default(DEFAULTS.port),
|
||||
autoStart: Schema.boolean().default(DEFAULTS.autoStart),
|
||||
})
|
||||
|
||||
function resolveConfig(config = {}) {
|
||||
return {
|
||||
enabled: config.enabled !== false,
|
||||
port: Number(config.port) > 0 ? Number(config.port) : DEFAULTS.port,
|
||||
autoStart: config.autoStart !== false,
|
||||
}
|
||||
}
|
||||
|
||||
function tryGet(ctx, key) {
|
||||
try {
|
||||
return ctx.get?.(key) ?? ctx[key]
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(res, status, body) {
|
||||
if (res.headersSent || res.writableEnded) return
|
||||
res.writeHead(status, {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
})
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
async function readJson(req) {
|
||||
const chunks = []
|
||||
for await (const c of req) chunks.push(c)
|
||||
if (!chunks.length) return {}
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function registerWebRoute(ctx, handler) {
|
||||
const register = (webServer) => {
|
||||
if (!webServer || typeof webServer.register !== 'function') return () => {}
|
||||
return webServer.register({
|
||||
kind: 'prefix',
|
||||
path: API_PREFIX,
|
||||
handler,
|
||||
})
|
||||
}
|
||||
const present = tryGet(ctx, 'webServer')
|
||||
if (present !== undefined) return register(present)
|
||||
const disposers = []
|
||||
let registered = false
|
||||
const off = ctx.on('internal/service', (serviceName, value) => {
|
||||
if (serviceName !== 'webServer' || registered) return
|
||||
registered = true
|
||||
disposers.push(register(value))
|
||||
})
|
||||
return () => {
|
||||
off()
|
||||
for (const d of disposers) {
|
||||
if (typeof d === 'function') d()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx, config = {}) {
|
||||
const entry = resolveConfig(config)
|
||||
const runtime = createRuntime()
|
||||
const internalToken = randomBytes(16).toString('hex')
|
||||
let mindMapBase = `http://127.0.0.1:${entry.port}`
|
||||
let bridge = null
|
||||
|
||||
const getBase = () => mindMapBase
|
||||
const getToken = () => internalToken
|
||||
|
||||
bridge = createSessionBridge({
|
||||
ctx,
|
||||
getMindMapBase: getBase,
|
||||
getInternalToken: getToken,
|
||||
logger: ctx.logger,
|
||||
})
|
||||
|
||||
async function ensureServer(cfg = entry) {
|
||||
if (!cfg.enabled) {
|
||||
await runtime.stop()
|
||||
return runtime.status()
|
||||
}
|
||||
let dshPort = tryGet(ctx, 'webServer')?.port
|
||||
for (let i = 0; !dshPort && i < 40; i++) {
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
dshPort = tryGet(ctx, 'webServer')?.port
|
||||
}
|
||||
if (!dshPort) {
|
||||
throw new Error('DSH webServer.port unavailable; cannot wire session bridge')
|
||||
}
|
||||
const bridgeUrl = `http://127.0.0.1:${dshPort}${API_PREFIX}`
|
||||
const st = await runtime.start({
|
||||
port: cfg.port,
|
||||
bridgeUrl,
|
||||
internalToken,
|
||||
})
|
||||
mindMapBase = st.url
|
||||
return st
|
||||
}
|
||||
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
sctx.settings.register(NS, Config, { base: entry, applies: 'live' })
|
||||
})
|
||||
|
||||
ctx.inject(['tools'], (tctx) => {
|
||||
registerMindmapTools(
|
||||
tctx,
|
||||
getBase,
|
||||
(sessionId) => bridge.getActiveCanvas(sessionId),
|
||||
(sessionId, canvasId) => bridge.setActiveCanvas(sessionId, canvasId),
|
||||
(sessionId, info) => bridge.queueUiOpen(sessionId, info),
|
||||
)
|
||||
tctx.logger?.info?.('[dsh-mind-map] mindmap_* tools registered')
|
||||
})
|
||||
|
||||
ctx.inject(['skills'], (sctx) => {
|
||||
if (typeof sctx.skills?.register === 'function') {
|
||||
sctx.skills.register(makeMindmapSkill())
|
||||
}
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
const off = ctx.on('session/event', (session, event) => {
|
||||
bridge.onSessionEvent(session, event)
|
||||
})
|
||||
return () => {
|
||||
if (typeof off === 'function') off()
|
||||
}
|
||||
}, 'dsh-mind-map: session mirror')
|
||||
|
||||
ctx.effect(() => {
|
||||
return registerWebRoute(ctx, async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || '/', 'http://dsh.local')
|
||||
const path = url.pathname.replace(API_PREFIX, '') || '/'
|
||||
const method = (req.method || 'GET').toUpperCase()
|
||||
|
||||
if (path === '/health' && method === 'GET') {
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
plugin: name,
|
||||
server: runtime.status(),
|
||||
canvasUrl: mindMapBase,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/status' && method === 'GET') {
|
||||
const canvasId = url.searchParams.get('canvasId') || ''
|
||||
writeJson(res, 200, {
|
||||
...bridge.status(canvasId),
|
||||
server: runtime.status(),
|
||||
canvasUrl: mindMapBase,
|
||||
config: entry,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/prompt' && method === 'POST') {
|
||||
const body = await readJson(req)
|
||||
const result = await bridge.prompt(body.canvasId, body.text)
|
||||
writeJson(res, 200, result)
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/history' && method === 'GET') {
|
||||
const canvasId = url.searchParams.get('canvasId') || ''
|
||||
const hist = await bridge.history(canvasId)
|
||||
writeJson(res, 200, { ok: true, ...hist })
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/ensure' && method === 'POST') {
|
||||
const body = await readJson(req)
|
||||
const binding = await bridge.ensureBinding(body.canvasId)
|
||||
writeJson(res, 200, { ok: true, sessionId: binding.sessionId })
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/open' && method === 'POST') {
|
||||
await ensureServer(entry)
|
||||
writeJson(res, 200, { ok: true, url: mindMapBase })
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/canvases' && method === 'GET') {
|
||||
await ensureServer(entry)
|
||||
const r = await fetch(`${mindMapBase}/api/canvases`)
|
||||
const body = await r.json().catch(() => ({}))
|
||||
writeJson(res, r.status, body)
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/canvases' && method === 'POST') {
|
||||
await ensureServer(entry)
|
||||
const body = await readJson(req)
|
||||
const r = await fetch(`${mindMapBase}/api/canvases`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: body.title || '未命名画布',
|
||||
rootText: body.rootText,
|
||||
}),
|
||||
})
|
||||
const out = await r.json().catch(() => ({}))
|
||||
const sessionId = String(body.sessionId || '').trim()
|
||||
const canvasId = out?.canvas?.id
|
||||
if (r.ok && sessionId && canvasId) {
|
||||
bridge.setActiveCanvas(sessionId, canvasId)
|
||||
bridge.queueUiOpen(sessionId, {
|
||||
canvasId,
|
||||
title: out.canvas?.title || body.title || 'Mind Map',
|
||||
})
|
||||
}
|
||||
writeJson(res, r.status, { ...out, activeCanvasId: canvasId || null })
|
||||
return
|
||||
}
|
||||
|
||||
if (path.startsWith('/canvases/') && method === 'PATCH') {
|
||||
await ensureServer(entry)
|
||||
const id = path.slice('/canvases/'.length).split('/')[0]
|
||||
const body = await readJson(req)
|
||||
const r = await fetch(`${mindMapBase}/api/canvases/${encodeURIComponent(id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ title: body.title }),
|
||||
})
|
||||
const out = await r.json().catch(() => ({}))
|
||||
writeJson(res, r.status, out)
|
||||
return
|
||||
}
|
||||
|
||||
if (path.startsWith('/canvases/') && method === 'DELETE') {
|
||||
await ensureServer(entry)
|
||||
const id = path.slice('/canvases/'.length).split('/')[0]
|
||||
const r = await fetch(`${mindMapBase}/api/canvases/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const out = await r.json().catch(() => ({}))
|
||||
if (r.ok) bridge.clearActiveForCanvas(id)
|
||||
writeJson(res, r.status, out)
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/active' && method === 'GET') {
|
||||
const sessionId = url.searchParams.get('sessionId') || ''
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
sessionId,
|
||||
canvasId: bridge.getActiveCanvas(sessionId),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/active' && method === 'POST') {
|
||||
const body = await readJson(req)
|
||||
const sessionId = String(body.sessionId || '').trim()
|
||||
const canvasId = body.canvasId == null ? '' : String(body.canvasId).trim()
|
||||
if (!sessionId) {
|
||||
writeJson(res, 400, { ok: false, error: 'sessionId_required' })
|
||||
return
|
||||
}
|
||||
const bound = bridge.setActiveCanvas(sessionId, canvasId)
|
||||
writeJson(res, 200, { ok: true, sessionId, canvasId: bound })
|
||||
return
|
||||
}
|
||||
|
||||
if (path === '/ui-open' && method === 'GET') {
|
||||
const sessionId = url.searchParams.get('sessionId') || ''
|
||||
const cmd = bridge.consumeUiOpen(sessionId)
|
||||
writeJson(res, 200, { ok: true, ...(cmd || {}) })
|
||||
return
|
||||
}
|
||||
|
||||
writeJson(res, 404, { ok: false, error: 'not_found' })
|
||||
} catch (e) {
|
||||
ctx.logger?.error?.('[dsh-mind-map] route error %s', e)
|
||||
writeJson(res, 500, { ok: false, message: String(e?.message || e) })
|
||||
}
|
||||
})
|
||||
}, 'dsh-mind-map: web routes')
|
||||
|
||||
ctx.effect(() => {
|
||||
let stopped = false
|
||||
const boot = async () => {
|
||||
if (stopped) return
|
||||
if (entry.autoStart && entry.enabled) {
|
||||
try {
|
||||
await ensureServer(entry)
|
||||
ctx.logger?.info?.('[dsh-mind-map] canvas at %s', mindMapBase)
|
||||
} catch (e) {
|
||||
ctx.logger?.error?.('[dsh-mind-map] failed to start server: %s', e?.message || e)
|
||||
}
|
||||
}
|
||||
}
|
||||
void boot()
|
||||
|
||||
const settings = tryGet(ctx, 'settings')
|
||||
let unwatch = null
|
||||
if (settings?.watch) {
|
||||
try {
|
||||
unwatch = settings.watch(NS, (next) => {
|
||||
Object.assign(entry, resolveConfig(next))
|
||||
if (entry.autoStart && entry.enabled) void ensureServer(entry)
|
||||
else if (!entry.enabled) void runtime.stop()
|
||||
})
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
if (typeof unwatch === 'function') unwatch()
|
||||
bridge.dispose()
|
||||
// Sync kill so DSH process exit does not leave canvas server orphaned.
|
||||
try {
|
||||
runtime.stop()
|
||||
} catch (e) {
|
||||
ctx.logger?.warn?.('[dsh-mind-map] stop failed: %s', e?.message || e)
|
||||
}
|
||||
}
|
||||
}, 'dsh-mind-map: lifecycle')
|
||||
}
|
||||
284
packages/dsh-plugin/lib/runtime.js
Normal file
284
packages/dsh-plugin/lib/runtime.js
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
/**
|
||||
* Start / stop the mind-map Hono server as a child process.
|
||||
* Lifetime is tied to the DSH host: when the plugin disposes / process exits,
|
||||
* the canvas server is killed (including process tree on Windows).
|
||||
*/
|
||||
|
||||
import { spawn, execSync } from 'node:child_process'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { join, dirname, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
export const mindMapRoot = join(__dirname, '../../..')
|
||||
|
||||
function resolveTsxCliRel() {
|
||||
const candidates = [
|
||||
join(mindMapRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'),
|
||||
join(mindMapRoot, 'apps', 'server', 'node_modules', 'tsx', 'dist', 'cli.mjs'),
|
||||
]
|
||||
for (const abs of candidates) {
|
||||
if (existsSync(abs)) {
|
||||
return relative(mindMapRoot, abs).split('\\').join('/')
|
||||
}
|
||||
}
|
||||
try {
|
||||
const requireFromRoot = createRequire(join(mindMapRoot, 'package.json'))
|
||||
const abs = requireFromRoot.resolve('tsx/dist/cli.mjs')
|
||||
return relative(mindMapRoot, abs).split('\\').join('/')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWebDist() {
|
||||
const indexHtml = join(mindMapRoot, 'apps', 'web', 'dist', 'index.html')
|
||||
const watchSrc = [
|
||||
join(mindMapRoot, 'apps', 'web', 'src', 'TopologyCanvas.tsx'),
|
||||
join(mindMapRoot, 'apps', 'web', 'src', 'store.ts'),
|
||||
join(mindMapRoot, 'apps', 'web', 'src', 'App.tsx'),
|
||||
join(mindMapRoot, 'apps', 'web', 'src', 'drag-subtree.ts'),
|
||||
]
|
||||
let needsBuild = !existsSync(indexHtml)
|
||||
if (!needsBuild && existsSync(indexHtml)) {
|
||||
try {
|
||||
const distM = statSync(indexHtml).mtimeMs
|
||||
for (const src of watchSrc) {
|
||||
if (existsSync(src) && statSync(src).mtimeMs > distM) {
|
||||
needsBuild = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (!needsBuild) return
|
||||
console.log('[dsh-mind-map] building web UI (apps/web/dist missing or stale)…')
|
||||
try {
|
||||
execSync('pnpm --filter @mind-map/web build', {
|
||||
cwd: mindMapRoot,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
windowsHide: true,
|
||||
})
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`failed to build mind-map web UI: ${e?.message || e}. Run manually: pnpm --filter @mind-map/web build`,
|
||||
)
|
||||
}
|
||||
if (!existsSync(indexHtml)) {
|
||||
throw new Error('web build finished but apps/web/dist/index.html is still missing')
|
||||
}
|
||||
}
|
||||
|
||||
async function probeMindMap(url) {
|
||||
try {
|
||||
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(1500) })
|
||||
if (!res.ok) return null
|
||||
const body = await res.json().catch(() => null)
|
||||
if (body && body.ok === true) return body
|
||||
} catch {
|
||||
/* not up */
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Kill a PID and its children (Windows needs /T). */
|
||||
function killPidTree(pid) {
|
||||
if (!pid || !Number.isFinite(pid)) return
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' })
|
||||
} else {
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
/** Free listeners on bind:port so DSH can own a fresh child. */
|
||||
function killListenersOnPort(port) {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const out = execSync('netstat -ano', { encoding: 'utf8' })
|
||||
const pids = new Set()
|
||||
for (const line of out.split(/\r?\n/)) {
|
||||
if (!line.includes(`:${port}`) || !/LISTENING/i.test(line)) continue
|
||||
const parts = line.trim().split(/\s+/)
|
||||
const pid = Number(parts[parts.length - 1])
|
||||
if (pid > 0) pids.add(pid)
|
||||
}
|
||||
for (const pid of pids) {
|
||||
// Never kill ourselves
|
||||
if (pid === process.pid) continue
|
||||
console.log(`[dsh-mind-map] freeing port ${port} (pid ${pid})`)
|
||||
killPidTree(pid)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
execSync(`fuser -k ${port}/tcp`, { stdio: 'ignore' })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function createRuntime() {
|
||||
let child = null
|
||||
let last = { port: 17890, url: 'http://127.0.0.1:17890', running: false }
|
||||
let exitHooksInstalled = false
|
||||
|
||||
function killOwnedChildSync() {
|
||||
const c = child
|
||||
child = null
|
||||
if (!c) return
|
||||
killPidTree(c.pid)
|
||||
}
|
||||
|
||||
function installExitHooks() {
|
||||
if (exitHooksInstalled) return
|
||||
exitHooksInstalled = true
|
||||
const onExit = () => {
|
||||
killOwnedChildSync()
|
||||
}
|
||||
process.on('exit', onExit)
|
||||
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']) {
|
||||
try {
|
||||
process.on(sig, () => {
|
||||
killOwnedChildSync()
|
||||
})
|
||||
} catch {
|
||||
/* unsupported signal on this platform */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function start({ port = 17890, bridgeUrl, internalToken, dataDir } = {}) {
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
installExitHooks()
|
||||
|
||||
if (child && !child.killed) {
|
||||
last = { ...last, port, url, running: true }
|
||||
return last
|
||||
}
|
||||
|
||||
ensureWebDist()
|
||||
|
||||
// Stale orphan from a previous DSH run: kill it so we own the next child
|
||||
// and can tear it down when DSH exits.
|
||||
if (await probeMindMap(url)) {
|
||||
console.log(`[dsh-mind-map] stopping leftover server on ${url}`)
|
||||
killListenersOnPort(port)
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
}
|
||||
|
||||
const entryAbs = join(mindMapRoot, 'apps/server/src/index.ts')
|
||||
const distAbs = join(mindMapRoot, 'apps/server/dist/index.js')
|
||||
const useTs = existsSync(entryAbs)
|
||||
const scriptAbs = useTs ? entryAbs : distAbs
|
||||
if (!existsSync(scriptAbs)) {
|
||||
throw new Error(`mind-map server entry not found under ${mindMapRoot}`)
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
BIND: '127.0.0.1',
|
||||
MINDMAP_DSH_BRIDGE: bridgeUrl || '',
|
||||
MINDMAP_INTERNAL_TOKEN: internalToken || '',
|
||||
MINDMAP_LLM_SOURCE: 'dsh',
|
||||
}
|
||||
if (dataDir) env.DATA_DIR = dataDir
|
||||
|
||||
const scriptRel = useTs ? 'apps/server/src/index.ts' : 'apps/server/dist/index.js'
|
||||
let args
|
||||
if (useTs) {
|
||||
const tsxCli = resolveTsxCliRel()
|
||||
if (!tsxCli) throw new Error('tsx not found; run pnpm install in mind-map')
|
||||
args = [tsxCli, scriptRel]
|
||||
} else {
|
||||
args = [scriptRel]
|
||||
}
|
||||
|
||||
child = spawn(process.execPath, args, {
|
||||
cwd: mindMapRoot,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
// Keep attached to parent so closing DSH can reap it; do NOT detach.
|
||||
detached: false,
|
||||
})
|
||||
|
||||
let spawnError = null
|
||||
child.stdout?.on('data', (buf) => {
|
||||
const line = String(buf).trim()
|
||||
if (line) console.log(`[dsh-mind-map:server] ${line}`)
|
||||
})
|
||||
child.stderr?.on('data', (buf) => {
|
||||
const line = String(buf).trim()
|
||||
if (line) {
|
||||
console.error(`[dsh-mind-map:server] ${line}`)
|
||||
if (line.includes('EADDRINUSE')) spawnError = new Error(`port ${port} already in use`)
|
||||
}
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
console.warn(`[dsh-mind-map] server exited code=${code}`)
|
||||
if (child) child = null
|
||||
last = { ...last, running: false }
|
||||
})
|
||||
|
||||
const deadline = Date.now() + 20000
|
||||
let ok = false
|
||||
while (Date.now() < deadline) {
|
||||
if (!child) break
|
||||
if (await probeMindMap(url)) {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
killOwnedChildSync()
|
||||
killListenersOnPort(port)
|
||||
throw spawnError || new Error(`mind-map server failed to become healthy on ${url}`)
|
||||
}
|
||||
|
||||
last = { port, url, running: true }
|
||||
return last
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
const port = last.port
|
||||
killOwnedChildSync()
|
||||
// Belt-and-suspenders: anything still listening on our canvas port goes down with DSH.
|
||||
killListenersOnPort(port)
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
last = { ...last, running: false }
|
||||
}
|
||||
|
||||
function status() {
|
||||
return {
|
||||
...last,
|
||||
running: Boolean(child && !child.killed),
|
||||
pid: child?.pid || null,
|
||||
}
|
||||
}
|
||||
|
||||
return { start, stop, status, mindMapRoot }
|
||||
}
|
||||
444
packages/dsh-plugin/lib/session-bridge.js
Normal file
444
packages/dsh-plugin/lib/session-bridge.js
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
/**
|
||||
* Canvas ↔ DSH Session binding.
|
||||
* Web input → agent.followup; session/event → push to mind-map WS mirror.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const PLUGIN = 'dsh-mind-map'
|
||||
|
||||
function dshHome() {
|
||||
return process.env.DSH_HOME || join(homedir(), '.dsh')
|
||||
}
|
||||
|
||||
function textOf(message) {
|
||||
const content = message?.content
|
||||
if (typeof content === 'string') return content
|
||||
if (!Array.isArray(content)) return ''
|
||||
return content
|
||||
.filter((b) => b && b.type === 'text' && typeof b.text === 'string')
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function tryGet(ctx, name) {
|
||||
try {
|
||||
return ctx.get?.(name) ?? ctx[name]
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Same default model a New Session in DSH Web would use. */
|
||||
function currentDefaultModel(ctx) {
|
||||
try {
|
||||
const selection = tryGet(ctx, 'agentDefaultModel')?.currentSelection?.()
|
||||
const provider = typeof selection?.provider === 'string' ? selection.provider.trim() : ''
|
||||
const model = typeof selection?.model === 'string' ? selection.model.trim() : ''
|
||||
if (!provider || !model) return null
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
...(selection.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: selection.reasoningEffort }),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop harness context notices / tool rows — only real chat turns for the mirror. */
|
||||
function isMirrorableChatMessage(message) {
|
||||
if (!message || typeof message !== 'object') return false
|
||||
const source = message.source || {}
|
||||
const kind = source.kind
|
||||
const form = source.form
|
||||
if (form === 'snapshot' || form === 'notice' || form === 'instructions' || form === 'catalog') {
|
||||
return false
|
||||
}
|
||||
if (kind === 'tool') return false
|
||||
if (message.role === 'assistant') return kind === 'model' || kind == null || kind === undefined
|
||||
if (message.role === 'user') {
|
||||
return kind === 'user' || kind === 'plugin' || kind == null || kind === undefined
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isRuntimeContextText(text) {
|
||||
return (
|
||||
typeof text === 'string' &&
|
||||
(text.includes('Current runtime context') ||
|
||||
text.includes('Current DSH file policy') ||
|
||||
text.includes('This snapshot supersedes'))
|
||||
)
|
||||
}
|
||||
|
||||
export function createSessionBridge({ ctx, getMindMapBase, getInternalToken, logger }) {
|
||||
/** @type {Map<string, { sessionId: string, handle: any, agent: any }>} */
|
||||
const byCanvas = new Map()
|
||||
/** @type {Map<string, string>} mirror agent session → canvas */
|
||||
const canvasBySession = new Map()
|
||||
/** @type {Map<string, string>} DSH chat session → currently displayed canvas */
|
||||
const activeBySession = new Map()
|
||||
/** @type {Map<string, { canvasId: string, title: string, at: number }>} */
|
||||
const uiOpenBySession = new Map()
|
||||
|
||||
function setActiveCanvas(sessionId, canvasId) {
|
||||
const sid = String(sessionId || '').trim()
|
||||
if (!sid) return null
|
||||
const cid = String(canvasId || '').trim()
|
||||
if (!cid) {
|
||||
activeBySession.delete(sid)
|
||||
return null
|
||||
}
|
||||
activeBySession.set(sid, cid)
|
||||
return cid
|
||||
}
|
||||
|
||||
function getActiveCanvas(sessionId) {
|
||||
const sid = String(sessionId || '').trim()
|
||||
if (!sid) return null
|
||||
return activeBySession.get(sid) || canvasBySession.get(sid) || null
|
||||
}
|
||||
|
||||
function clearActiveCanvas(sessionId) {
|
||||
const sid = String(sessionId || '').trim()
|
||||
if (sid) activeBySession.delete(sid)
|
||||
}
|
||||
|
||||
function clearActiveForCanvas(canvasId) {
|
||||
const cid = String(canvasId || '')
|
||||
for (const [sid, id] of activeBySession.entries()) {
|
||||
if (id === cid) activeBySession.delete(sid)
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask the DSH client to open the 思维导图 tab on this canvas. */
|
||||
function queueUiOpen(sessionId, { canvasId, title } = {}) {
|
||||
const sid = String(sessionId || '').trim()
|
||||
const cid = String(canvasId || '').trim()
|
||||
if (!sid || !cid) return false
|
||||
setActiveCanvas(sid, cid)
|
||||
const prev = uiOpenBySession.get(sid)
|
||||
// Coalesce: same canvas already queued → just refresh title/time (don't churn).
|
||||
if (prev && prev.canvasId === cid) {
|
||||
prev.title = String(title || prev.title || 'Mind Map')
|
||||
prev.at = Date.now()
|
||||
return true
|
||||
}
|
||||
uiOpenBySession.set(sid, {
|
||||
canvasId: cid,
|
||||
title: String(title || 'Mind Map'),
|
||||
at: Date.now(),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function consumeUiOpen(sessionId) {
|
||||
const sid = String(sessionId || '').trim()
|
||||
if (!sid) return null
|
||||
const cmd = uiOpenBySession.get(sid) || null
|
||||
if (cmd) uiOpenBySession.delete(sid)
|
||||
return cmd
|
||||
}
|
||||
async function ensureBinding(canvasId) {
|
||||
const existing = byCanvas.get(canvasId)
|
||||
if (existing?.agent) return existing
|
||||
|
||||
const agents = tryGet(ctx, 'agents')
|
||||
if (!agents || typeof agents.create !== 'function') {
|
||||
throw new Error('DSH agents service unavailable')
|
||||
}
|
||||
|
||||
const selection = currentDefaultModel(ctx)
|
||||
if (!selection) {
|
||||
throw new Error(
|
||||
'DSH 尚未选择默认模型。请先在 DSH 设置 → Models 选好模型,再在画布对话。',
|
||||
)
|
||||
}
|
||||
|
||||
const sessionId = randomUUID()
|
||||
const cwd =
|
||||
process.env.MINDMAP_SESSION_CWD ||
|
||||
join(dshHome(), 'user-workspaces', 'administrator')
|
||||
|
||||
const handle = await agents.create({
|
||||
sessionId,
|
||||
agentOptions: {
|
||||
provider: selection.provider,
|
||||
model: selection.model,
|
||||
...selection.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: selection.reasoningEffort },
|
||||
},
|
||||
meta: {
|
||||
cwd,
|
||||
title: `Mind Map · ${String(canvasId).slice(0, 8)}`,
|
||||
},
|
||||
setup: (agentCtx) => {
|
||||
// Fill {{model}} / {{provider}} for persona assembly (same as dsh-ops-cron).
|
||||
if (!agentCtx || typeof agentCtx.on !== 'function') return
|
||||
const snapshot = {
|
||||
provider: selection.provider,
|
||||
model: selection.model,
|
||||
...selection.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: selection.reasoningEffort },
|
||||
}
|
||||
agentCtx.on('system-prompt/assemble', async (...args) => {
|
||||
const next = args.find((arg) => typeof arg === 'function')
|
||||
const assembled = next ? await next() : (args[0] || {})
|
||||
return {
|
||||
...assembled,
|
||||
variables: {
|
||||
...assembled.variables,
|
||||
provider: snapshot.provider,
|
||||
model: snapshot.model,
|
||||
},
|
||||
}
|
||||
})
|
||||
agentCtx.on('agent/request', async (...args) => {
|
||||
const next = args.find((arg) => typeof arg === 'function')
|
||||
const resolved = next ? await next() : (args[0] || {})
|
||||
const { reasoningEffort: _inherited, ...rest } = resolved || {}
|
||||
return {
|
||||
...rest,
|
||||
provider: snapshot.provider,
|
||||
model: snapshot.model,
|
||||
...snapshot.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: snapshot.reasoningEffort },
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
const agent = handle?.agent
|
||||
if (!agent || typeof agent.followup !== 'function') {
|
||||
try {
|
||||
handle?.dispose?.()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error('agents.create did not return a usable agent')
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof agent.session?.append === 'function') {
|
||||
agent.session.append('session/title', {
|
||||
title: `Mind Map · ${String(canvasId).slice(0, 8)}`,
|
||||
source: 'plugin',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
// Best-effort: show session in workspace list
|
||||
try {
|
||||
const registry = tryGet(ctx, 'workspaceRegistry')
|
||||
const list =
|
||||
typeof registry?.list === 'function'
|
||||
? registry.list()
|
||||
: typeof registry?.workspaces === 'function'
|
||||
? registry.workspaces()
|
||||
: []
|
||||
const rows = Array.isArray(list) ? list : []
|
||||
const ws =
|
||||
rows.find((w) => {
|
||||
const p = String(w?.path || w?.cwd || '')
|
||||
return p && p.replace(/\\/g, '/') === cwd.replace(/\\/g, '/')
|
||||
}) || rows[0]
|
||||
if (ws && typeof ws.attachSession === 'function') {
|
||||
await ws.attachSession(sessionId)
|
||||
}
|
||||
} catch (e) {
|
||||
logger?.warn?.('[dsh-mind-map] workspace attach skipped: %s', e?.message || e)
|
||||
}
|
||||
|
||||
const binding = { sessionId, handle, agent, model: selection }
|
||||
byCanvas.set(canvasId, binding)
|
||||
canvasBySession.set(sessionId, canvasId)
|
||||
logger?.info?.(
|
||||
'[dsh-mind-map] bound canvas %s → session %s (%s/%s)',
|
||||
canvasId,
|
||||
sessionId,
|
||||
selection.provider,
|
||||
selection.model,
|
||||
)
|
||||
return binding
|
||||
}
|
||||
|
||||
async function prompt(canvasId, text) {
|
||||
const trimmed = String(text || '').trim()
|
||||
if (!canvasId || !trimmed) throw new Error('canvasId and text required')
|
||||
const binding = await ensureBinding(canvasId)
|
||||
// Match dsh-ops-cron: plain message object + plugin source (reliable wake).
|
||||
const message = {
|
||||
id: randomUUID(),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: trimmed }],
|
||||
source: { kind: 'plugin', plugin: PLUGIN },
|
||||
}
|
||||
binding.agent.followup(message)
|
||||
return {
|
||||
ok: true,
|
||||
sessionId: binding.sessionId,
|
||||
model: binding.model || null,
|
||||
}
|
||||
}
|
||||
|
||||
async function history(canvasId) {
|
||||
const binding = byCanvas.get(canvasId)
|
||||
if (!binding) return { sessionId: null, messages: [], model: null }
|
||||
const session = binding.agent?.session
|
||||
let messages = []
|
||||
try {
|
||||
const derived =
|
||||
typeof session?.deriveMessages === 'function'
|
||||
? session.deriveMessages()
|
||||
: session?.messages || []
|
||||
const list = Array.isArray(derived) ? derived : []
|
||||
messages = list
|
||||
.filter(isMirrorableChatMessage)
|
||||
.map((m) => {
|
||||
const role = m?.role === 'assistant' ? 'assistant' : m?.role === 'user' ? 'user' : null
|
||||
if (!role) return null
|
||||
const content = textOf(m)
|
||||
if (!content || isRuntimeContextText(content)) return null
|
||||
// Skip our own plugin echoes if duplicated; keep user-visible text.
|
||||
return { role, content }
|
||||
})
|
||||
.filter(Boolean)
|
||||
} catch {
|
||||
messages = []
|
||||
}
|
||||
return { sessionId: binding.sessionId, messages, model: binding.model || null }
|
||||
}
|
||||
|
||||
function status(canvasId) {
|
||||
const binding = canvasId ? byCanvas.get(canvasId) : null
|
||||
const selection = currentDefaultModel(ctx)
|
||||
return {
|
||||
ok: true,
|
||||
mode: 'dsh-session',
|
||||
bound: Boolean(binding),
|
||||
sessionId: binding?.sessionId || null,
|
||||
agents: Boolean(tryGet(ctx, 'agents')),
|
||||
model: binding?.model || selection,
|
||||
hasDefaultModel: Boolean(selection),
|
||||
}
|
||||
}
|
||||
|
||||
async function pushMirror(canvasId, payload) {
|
||||
const base = getMindMapBase?.()
|
||||
if (!base) return
|
||||
const token = getInternalToken?.() || ''
|
||||
try {
|
||||
await fetch(`${base}/api/internal/session-event`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-mindmap-token': token,
|
||||
},
|
||||
body: JSON.stringify({ canvasId, ...payload }),
|
||||
})
|
||||
} catch (e) {
|
||||
logger?.warn?.('[dsh-mind-map] mirror push failed: %s', e?.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
function onSessionEvent(session, event) {
|
||||
const sessionId = session?.id || session?.header?.id
|
||||
if (!sessionId) return
|
||||
const canvasId = canvasBySession.get(sessionId)
|
||||
if (!canvasId) return
|
||||
|
||||
const type = event?.type
|
||||
if (type === 'assistant/chunk') {
|
||||
const chunk = event.data?.chunk
|
||||
if (chunk?.type === 'text-delta' && typeof chunk.text === 'string') {
|
||||
void pushMirror(canvasId, {
|
||||
mirror: { kind: 'text-delta', text: chunk.text, sessionId },
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (type === 'user/message') {
|
||||
const message = event.data?.message || event.data
|
||||
if (!isMirrorableChatMessage(message)) return
|
||||
const text = textOf(message)
|
||||
if (!text || isRuntimeContextText(text)) return
|
||||
// Don't mirror our own plugin-sourced prompt twice (UI already shows optimistic bubble).
|
||||
if (message?.source?.kind === 'plugin' && message?.source?.plugin === PLUGIN) return
|
||||
void pushMirror(canvasId, {
|
||||
mirror: { kind: 'user', text, sessionId },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (type === 'assistant/message') {
|
||||
const message = event.data?.message || event.data
|
||||
if (!isMirrorableChatMessage(message)) return
|
||||
const text = textOf(message)
|
||||
if (!text || isRuntimeContextText(text)) return
|
||||
void pushMirror(canvasId, {
|
||||
mirror: { kind: 'assistant', text, sessionId },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (type === 'tool/call') {
|
||||
void pushMirror(canvasId, {
|
||||
mirror: {
|
||||
kind: 'tool',
|
||||
name: event.data?.name || 'tool',
|
||||
callId: event.data?.callId,
|
||||
sessionId,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (type === 'turn/end') {
|
||||
void pushMirror(canvasId, {
|
||||
mirror: {
|
||||
kind: 'turn-end',
|
||||
reason: event.data?.reason,
|
||||
sessionId,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
for (const binding of byCanvas.values()) {
|
||||
try {
|
||||
binding.handle?.dispose?.()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
byCanvas.clear()
|
||||
canvasBySession.clear()
|
||||
activeBySession.clear()
|
||||
uiOpenBySession.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
ensureBinding,
|
||||
prompt,
|
||||
history,
|
||||
status,
|
||||
onSessionEvent,
|
||||
dispose,
|
||||
byCanvas,
|
||||
canvasBySession,
|
||||
activeBySession,
|
||||
setActiveCanvas,
|
||||
getActiveCanvas,
|
||||
clearActiveCanvas,
|
||||
clearActiveForCanvas,
|
||||
queueUiOpen,
|
||||
consumeUiOpen,
|
||||
}
|
||||
}
|
||||
717
packages/dsh-plugin/lib/tools.js
Normal file
717
packages/dsh-plugin/lib/tools.js
Normal file
|
|
@ -0,0 +1,717 @@
|
|||
/**
|
||||
* Register mindmap_* tools that call the local mind-map HTTP API.
|
||||
* Plain ToolDefinition objects (same style as dsh-ops-cron).
|
||||
*/
|
||||
|
||||
function text(value) {
|
||||
return [{ type: 'text', text: String(value ?? '') }]
|
||||
}
|
||||
|
||||
function jsonText(value) {
|
||||
return text(typeof value === 'string' ? value : JSON.stringify(value, null, 0))
|
||||
}
|
||||
|
||||
async function callTool(getBase, name, args) {
|
||||
const base = getBase?.()
|
||||
if (!base) throw new Error('mind-map server is not running')
|
||||
const res = await fetch(`${base}/api/tools/${name}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(args || {}),
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
const err = new Error(body.message || body.error || `HTTP ${res.status}`)
|
||||
err.code = body.error || 'TOOL_HTTP'
|
||||
throw err
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
const OBJECT = {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
}
|
||||
|
||||
const OP_ITEM = {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
description:
|
||||
'Graph op. REQUIRED discriminator field is `type` (NOT `op`). Examples: {"type":"create_node","text":"贾宝玉","parentId":"..."}, {"type":"link","from":"...","to":"...","kind":"relation","label":"木石前盟"}, {"type":"set_pinned","nodeId":"...","pinned":true,"pos":{"x":120,"y":-80}}',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Op kind: create_node | update_text | link | unlink | set_pinned | set_focus | set_collapsed | set_style_preset | set_edge_label | set_prefs | move_node | delete_node | …',
|
||||
},
|
||||
},
|
||||
required: ['type'],
|
||||
}
|
||||
|
||||
export function mindmapToolDefinitions(getBase, getCanvasIdForSession, setActiveCanvas, queueUiOpen) {
|
||||
/** Tools that must open/switch the right-panel canvas. */
|
||||
const HARD_OPEN_UI = new Set(['mindmap_create_canvas', 'mindmap_open_canvas'])
|
||||
/** Soft reveal: bind session + open tab only if needed (client dedupes). */
|
||||
const SOFT_REVEAL_UI = new Set([
|
||||
'mindmap_query',
|
||||
'mindmap_find',
|
||||
'mindmap_find_and_focus',
|
||||
'mindmap_get_node',
|
||||
'mindmap_get_edge',
|
||||
'mindmap_highlight',
|
||||
'mindmap_get_spatial_context',
|
||||
'mindmap_get_subgraph',
|
||||
'mindmap_focus',
|
||||
'mindmap_apply_direct',
|
||||
'mindmap_propose',
|
||||
'mindmap_accept_proposal',
|
||||
'mindmap_export',
|
||||
])
|
||||
|
||||
const run = (name) => async (args, exec) => {
|
||||
const sessionId = String(
|
||||
exec?.agent?.session?.id ||
|
||||
exec?.agent?.session?.header?.id ||
|
||||
exec?.sessionId ||
|
||||
'',
|
||||
).trim()
|
||||
const bound =
|
||||
typeof getCanvasIdForSession === 'function' && sessionId
|
||||
? getCanvasIdForSession(sessionId)
|
||||
: null
|
||||
const payload = {
|
||||
...(args || {}),
|
||||
canvasId: args?.canvasId || bound || undefined,
|
||||
sessionId: sessionId || undefined,
|
||||
}
|
||||
const body = await callTool(getBase, name, payload)
|
||||
|
||||
if (name === 'mindmap_delete_canvas' && sessionId && typeof setActiveCanvas === 'function') {
|
||||
const deleted = args?.canvasId || bound
|
||||
const still = getCanvasIdForSession?.(sessionId)
|
||||
if (deleted && still === deleted) setActiveCanvas(sessionId, null)
|
||||
}
|
||||
|
||||
const cid =
|
||||
body?.canvas?.id ||
|
||||
payload.canvasId ||
|
||||
(typeof getCanvasIdForSession === 'function' && sessionId
|
||||
? getCanvasIdForSession(sessionId)
|
||||
: null)
|
||||
|
||||
if (body && body.ok !== false && sessionId && cid) {
|
||||
if (HARD_OPEN_UI.has(name) && typeof queueUiOpen === 'function') {
|
||||
queueUiOpen(sessionId, {
|
||||
canvasId: cid,
|
||||
title: body?.canvas?.title || body?.title || args?.title || 'Mind Map',
|
||||
})
|
||||
} else if (SOFT_REVEAL_UI.has(name)) {
|
||||
// Bind session; open UI at most via queue (client skips if already live).
|
||||
if (typeof setActiveCanvas === 'function') setActiveCanvas(sessionId, cid)
|
||||
if (typeof queueUiOpen === 'function') {
|
||||
queueUiOpen(sessionId, {
|
||||
canvasId: cid,
|
||||
title: body?.canvas?.title || body?.title || args?.title || 'Mind Map',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'mindmap_get_schema',
|
||||
description:
|
||||
'Return mind-map op schema: valid `type` values, examples (create_node / link / set_pinned), and tool list. Call when unsure about op shape.',
|
||||
parameters: { type: 'object', additionalProperties: false, properties: {} },
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_get_schema'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_list_canvases',
|
||||
description: 'List mind-map canvases (id + title).',
|
||||
parameters: { type: 'object', additionalProperties: false, properties: {} },
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_list_canvases'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_create_canvas',
|
||||
description:
|
||||
'Create a brand-new mind-map canvas (new id; does NOT overwrite existing canvases), bind it to this chat session, and open it in the 思维导图 tab. Canvas starts visually empty. Top-level nodes: omit parentId.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
title: { type: 'string', description: 'Canvas title, e.g. 红楼梦人物关系' },
|
||||
rootText: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Optional internal label / title sync only — there is no visible root node. Prefer title alone.',
|
||||
},
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_create_canvas'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_open_canvas',
|
||||
description:
|
||||
'Open an existing canvas in the 思维导图 tab and bind it as the edit target for this session.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
canvasId: { type: 'string', description: 'Canvas id from mindmap_list_canvases' },
|
||||
},
|
||||
required: ['canvasId'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_open_canvas'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_delete_canvas',
|
||||
description:
|
||||
'Permanently delete a canvas (hard delete). Omit canvasId to delete the currently open canvas.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_delete_canvas'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_get_spatial_context',
|
||||
description:
|
||||
'Get spatial harness context for the active mind-map canvas. Always call before inventing node ids.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
canvasId: { type: 'string', description: 'Omit to use the open/bound canvas.' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_get_spatial_context'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_find',
|
||||
description:
|
||||
'Find nodes or edges. searchScope: text|note|tags|description|edge_label|edge_note|all (default text). Edge scopes return {kind:"edge", edgeId, fromText, toText, label}. If ambiguous, ask the user — do not pick silently.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
searchScope: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'text',
|
||||
'note',
|
||||
'tags',
|
||||
'description',
|
||||
'edge_label',
|
||||
'edge_note',
|
||||
'all',
|
||||
],
|
||||
description:
|
||||
'Default text. Use edge_label/edge_note for edges; all includes nodes + edges.',
|
||||
},
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_find'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_find_and_focus',
|
||||
description:
|
||||
'Find then focus+highlight the best unique match. Edge hits highlight endpoints + edge. If ambiguous, returns candidates without focusing.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
searchScope: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'text',
|
||||
'note',
|
||||
'tags',
|
||||
'description',
|
||||
'edge_label',
|
||||
'edge_note',
|
||||
'all',
|
||||
],
|
||||
},
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_find_and_focus'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_export',
|
||||
description:
|
||||
'Export canvas as json | md | svg. format=png also returns SVG (vector) for agents; use web UI for raster PNG.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
format: { type: 'string', enum: ['json', 'md', 'svg', 'png'] },
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_export'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_get_subgraph',
|
||||
description:
|
||||
'Read a subgraph around a node (depth <= 3). rootId: id|@alias|title. edgeKinds: hierarchy (default, tree children) | relation | all — same as mindmap_query. Use relation/all for relationship networks.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
rootId: {
|
||||
type: 'string',
|
||||
description: 'Node id, @alias, or unique title',
|
||||
},
|
||||
depth: { type: 'number' },
|
||||
edgeKinds: {
|
||||
type: 'string',
|
||||
enum: ['hierarchy', 'relation', 'all'],
|
||||
description:
|
||||
'hierarchy = primary-parent children (default); relation|all = undirected neighborhood',
|
||||
},
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_get_subgraph'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_query',
|
||||
description:
|
||||
'Deterministic graph Q&A primitives (no LLM inventing). op: neighbors|path|stats|orphans|missing_meta. Node refs: id | @alias | unique title. path returns paths[].edges with meta; stats includes longestPath + mostConnected. If empty, say the graph has no such info.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
op: {
|
||||
type: 'string',
|
||||
enum: ['neighbors', 'path', 'stats', 'orphans', 'missing_meta'],
|
||||
},
|
||||
node: { type: 'string', description: 'For neighbors: id|@alias|title' },
|
||||
nodeId: { type: 'string' },
|
||||
from: { type: 'string', description: 'For path: start node' },
|
||||
to: { type: 'string', description: 'For path: end node' },
|
||||
depth: { type: 'number' },
|
||||
maxDepth: { type: 'number' },
|
||||
maxHops: {
|
||||
type: 'number',
|
||||
description:
|
||||
'For path: max edges in a path (default 8). Do NOT pass depth:1 — that was neighbors-only.',
|
||||
},
|
||||
throughRelationNodes: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'For path: allow multi-hop via intermediate nodes (default true). false = direct edge only.',
|
||||
},
|
||||
maxPaths: { type: 'number' },
|
||||
direction: {
|
||||
type: 'string',
|
||||
enum: ['out', 'in', 'both'],
|
||||
description:
|
||||
'neighbors default both; path default both when throughRelationNodes, else out',
|
||||
},
|
||||
edgeKinds: {
|
||||
type: 'string',
|
||||
enum: ['all', 'hierarchy', 'relation'],
|
||||
},
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['op'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_query'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_get_node',
|
||||
description:
|
||||
'Read one node. Default returns id/text/alias/function + full description. Use after Q&A when user asks for details on a cited node.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
nodeId: { type: 'string', description: 'id | @alias | unique title' },
|
||||
node: { type: 'string' },
|
||||
fields: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: ['all', 'description', 'note', 'tags', 'links'],
|
||||
},
|
||||
},
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['nodeId'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_get_node'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_get_edge',
|
||||
description:
|
||||
'Read one edge by id. Returns from/to texts + label/direction/weight/lineStyle/note. fields: all|label|meta.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
edgeId: { type: 'string' },
|
||||
fields: {
|
||||
type: 'array',
|
||||
items: { type: 'string', enum: ['all', 'label', 'meta'] },
|
||||
},
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['edgeId'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_get_edge'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_highlight',
|
||||
description:
|
||||
'Flash nodes and/or edges on the open canvas (no graph mutation). Use after path/neighbors Q&A. Optional style + ttlMs (default 5000).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
nodeIds: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'id | @alias | title',
|
||||
},
|
||||
edgeIds: { type: 'array', items: { type: 'string' } },
|
||||
style: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
nodeColor: { type: 'string' },
|
||||
nodeGlow: { type: 'boolean' },
|
||||
edgeWidth: { type: 'number' },
|
||||
edgeColor: { type: 'string' },
|
||||
},
|
||||
},
|
||||
ttlMs: { type: 'number', description: 'Auto-clear ms (500–60000)' },
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_highlight'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_apply_direct',
|
||||
description:
|
||||
'Apply low-risk graph ops immediately. Prefer batch_create for trees; relations[] for inline edges; auto_fit after large builds. EACH op MUST have field `type`. Response includes nodeIdMap. Set preview:true for dry-run. High-risk delete_node/move_node need mindmap_propose.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
ops: { type: 'array', items: OP_ITEM },
|
||||
summary: { type: 'string' },
|
||||
canvasId: { type: 'string' },
|
||||
preview: {
|
||||
type: 'boolean',
|
||||
description: 'If true, simulate apply + layout without persisting',
|
||||
},
|
||||
},
|
||||
required: ['ops'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_apply_direct'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_propose',
|
||||
description:
|
||||
'Submit a validated proposal (ops must use `type`). Then mindmap_accept_proposal({proposalId}) to apply. Failures return invalid_op with details — not a silent stale.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
ops: { type: 'array', items: OP_ITEM },
|
||||
rationale: { type: 'string' },
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['ops', 'rationale'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_propose'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_accept_proposal',
|
||||
description: 'Accept a pending proposal by id returned from mindmap_propose.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
proposalId: { type: 'string' },
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['proposalId'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_accept_proposal'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_reject_proposal',
|
||||
description: 'Reject a pending proposal by id.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
proposalId: { type: 'string' },
|
||||
},
|
||||
required: ['proposalId'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_reject_proposal'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_focus',
|
||||
description: 'Move visual focus to a node.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
nodeId: { type: 'string' },
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['nodeId'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_focus'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_undo',
|
||||
description:
|
||||
'Undo latest change set (or specific changeSetId). Creates an undo companion that mindmap_redo can reverse.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
changeSetId: { type: 'string' },
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_undo'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_redo',
|
||||
description:
|
||||
'Redo after undo: undoes the latest undo-companion change set on the canvas.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_redo'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_history',
|
||||
description:
|
||||
'List recent change sets + named snapshots; shows latest undoable/redoable ids.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
canvasId: { type: 'string' },
|
||||
limit: { type: 'number' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_history'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_snapshot',
|
||||
description:
|
||||
'Create a named canvas snapshot, or action:"list" to list snapshots.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
canvasId: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
action: { type: 'string', enum: ['create', 'list'] },
|
||||
limit: { type: 'number' },
|
||||
},
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_snapshot'),
|
||||
},
|
||||
{
|
||||
name: 'mindmap_restore_snapshot',
|
||||
description: 'Restore canvas to a prior snapshot id from mindmap_snapshot / mindmap_history.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
snapshotId: { type: 'string' },
|
||||
canvasId: { type: 'string' },
|
||||
},
|
||||
required: ['snapshotId'],
|
||||
},
|
||||
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
|
||||
execute: run('mindmap_restore_snapshot'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function registerMindmapTools(
|
||||
ctx,
|
||||
getBase,
|
||||
getCanvasIdForSession,
|
||||
setActiveCanvas,
|
||||
queueUiOpen,
|
||||
) {
|
||||
const tools = ctx?.tools
|
||||
if (!tools || typeof tools.register !== 'function') return () => {}
|
||||
const offs = mindmapToolDefinitions(
|
||||
getBase,
|
||||
getCanvasIdForSession,
|
||||
setActiveCanvas,
|
||||
queueUiOpen,
|
||||
).map((def) => tools.register(def))
|
||||
return () => {
|
||||
for (const off of offs) {
|
||||
if (typeof off === 'function') off()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function makeMindmapSkill() {
|
||||
return {
|
||||
name: 'mind-map',
|
||||
description:
|
||||
'AI 无限画布思维导图:右侧「思维导图」Tab;用 mindmap_* 建图/改图。Ops 必须用 type 字段。',
|
||||
whenToUse:
|
||||
'User asks for 思维导图 / 关系图 / 梳理人物或家族关系 / Mind Map, OR asks questions about an existing map (路径/依赖/邻居/统计/缺描述).',
|
||||
source: 'runtime',
|
||||
provider: 'runtime',
|
||||
content: `Mind Map workflow (dsh-mind-map):
|
||||
|
||||
CRITICAL — op shape:
|
||||
- Every graph mutation item MUST use discriminator field **type** (NOT op / operation).
|
||||
- If unsure, call mindmap_get_schema first.
|
||||
- Prefer **batch_create** for trees (up to 200 nodes) instead of many create_node rounds.
|
||||
- Valid types include: batch_create, batch_link, create_node, update_text, link, unlink, set_pinned, set_focus, set_collapsed, auto_fit, set_style_preset, set_edge_label, delete_node, move_node, …
|
||||
|
||||
Examples:
|
||||
{"type":"batch_create","nodes":[{"text":"数通","alias":"datacom"},{"text":"交换机","alias":"sw","parentId":"@datacom"},{"text":"AIOps","alias":"aiops","parentId":"@datacom","relations":[{"to":"@restart","label":"触发","kind":"relation"}],"description":{"function":"根因诊断"}},{"text":"自动重启","alias":"restart","parentId":"@datacom"}]}
|
||||
{"type":"batch_link","edges":[{"from":"@daiyu","to":"@baoyu","kind":"relation","label":"知己"},{"from":"@baoyu","to":"@baochai","kind":"relation","label":"金玉良缘"}]}
|
||||
{"type":"auto_fit","padding":50}
|
||||
{"type":"create_node","text":"贾宝玉"} ← top-level (omit parentId); appears as independent node
|
||||
{"type":"create_node","text":"贾政","parentId":"<贾宝玉的可见父节点或主题id>"}
|
||||
{"type":"create_node","text":"AIOps","alias":"aiops","relations":[{"to":"@restart","kind":"relation","label":"触发"}]}
|
||||
{"type":"link","from":"<黛玉Id>","to":"<宝玉Id>","kind":"relation","label":"木石前盟"}
|
||||
{"type":"update_text","nodeId":"<id>","text":"新文案"}
|
||||
{"type":"set_pinned","nodeId":"<id>","pinned":true,"pos":{"x":120,"y":-80}}
|
||||
{"type":"update_node_meta","nodeId":"@aiops","patch":{"description":{"owner":"SRE","sla":"30s"},"note":"自由文本"}}
|
||||
{"type":"set_style_preset","nodeId":"@baoyu","stylePreset":"decision"} ← field is stylePreset (not preset)
|
||||
{"type":"unlink","from":"@daiyu","to":"@baoyu","kind":"relation"} ← or edgeId
|
||||
{"type":"restore_node","nodeId":"<softDeletedId>"} ← same id after delete_node; also @alias/title of deleted nodes
|
||||
|
||||
There is NO visible root node. Canvas title is metadata only. Internally top-level nodes hang under a hidden anchor — agents must never mention or target a root id.
|
||||
|
||||
When the user asks to build a map (e.g. 「输出红楼梦人物关系图」):
|
||||
1. mindmap_create_canvas({ title: "红楼梦人物关系" }) — ALWAYS creates a NEW canvas id (never overwrites an old one). Opens the right tab on that new canvas.
|
||||
2. mindmap_get_spatial_context — orient on the NEW empty canvas
|
||||
3. mindmap_apply_direct({ ops: [ {type:"batch_create", nodes:[...]}, {type:"auto_fit"} ] })
|
||||
4. Keep adding in small batches; never dump a full tree JSON overwrite
|
||||
|
||||
If the user asks for another topic later: call mindmap_create_canvas again with the new title. Do NOT reuse the previous canvas unless they explicitly say to continue the same map.
|
||||
mindmap_list_canvases shows all canvases; mindmap_open_canvas switches the edit target.
|
||||
|
||||
Hierarchy vs relation:
|
||||
- omit parentId = top-level theme/person (siblings look unrelated, which is intended)
|
||||
- parentId = nest under a visible node
|
||||
- link kind:"relation" = cross links (人物关系、姻亲等)
|
||||
- create_node.relations / batch_create[].relations = outgoing graph edges in the same op (not URL bookmarks; those are links[])
|
||||
|
||||
Layout:
|
||||
- Default positions come from auto layout.
|
||||
- After large batch_create, include {type:"auto_fit"} so the camera frames the map.
|
||||
- To place manually / avoid overwrite: set_pinned + pos, or create_node with pos (auto-pins).
|
||||
|
||||
Propose/accept:
|
||||
- mindmap_propose validates ops first; then mindmap_accept_proposal({proposalId}).
|
||||
- Apply failures return error invalid_op (proposal stays pending) — not a fake proposal_stale.
|
||||
- proposal_stale only means rev drifted > 50 or proposal already finished / missing after restart.
|
||||
|
||||
Other tools:
|
||||
- mindmap_list_canvases / mindmap_open_canvas / mindmap_delete_canvas
|
||||
- Query/find/highlight also auto-open the 思维导图 tab on the bound canvas (same as create/open)
|
||||
- mindmap_find — searchScope text|note|tags|description|edge_label|edge_note|all; if ambiguous, ask then mindmap_focus / mindmap_highlight with quickActions[i].args
|
||||
- mindmap_find_and_focus — unique hit → focus + highlight flash (edges → highlight endpoints)
|
||||
- mindmap_query — graph Q&A: neighbors|path|stats|orphans|missing_meta (deterministic; cite returned nodes)
|
||||
- mindmap_get_node / mindmap_get_edge — details for follow-up
|
||||
- mindmap_highlight — flash path (nodeIds + edgeIds + optional ttlMs/style)
|
||||
- mindmap_export — format json|md|svg (png→svg for agents; UI has raster PNG)
|
||||
- mindmap_focus, mindmap_undo, mindmap_redo, mindmap_history
|
||||
- mindmap_snapshot / mindmap_restore_snapshot — named checkpoints
|
||||
- mindmap_apply_direct({ preview:true }) — dry-run layout + overlaps, no persist
|
||||
- mindmap_get_subgraph, mindmap_get_schema
|
||||
- get_subgraph: edgeKinds hierarchy (default tree) | relation | all — use relation/all for 关系网络 around a leaf
|
||||
|
||||
Graph Q&A workflow:
|
||||
1. Resolve entities with mindmap_find or @alias (edge labels: searchScope edge_label)
|
||||
2. Call mindmap_query (never guess paths/relations from memory)
|
||||
3. If empty/message says no match → answer "图中没有相关信息" — do NOT invent
|
||||
4. Answer with node names + paths[].edges (label/weight/note); mention citedNodeIds
|
||||
5. Follow-up details → mindmap_get_node / mindmap_get_edge
|
||||
6. Visualize → mindmap_highlight({ nodeIds, edgeIds }) after path/neighbors
|
||||
|
||||
Examples:
|
||||
mindmap_find({ query:"高置信", searchScope:"edge_label" })
|
||||
mindmap_query({ op:"neighbors", node:"@aiops", direction:"out", depth:1 })
|
||||
mindmap_query({ op:"path", from:"@detect", to:"@knowledge_write", throughRelationNodes:true, maxHops:5, edgeKinds:"relation" })
|
||||
mindmap_query({ op:"path", from:"@detect", to:"@aiops", throughRelationNodes:false }) // direct edge only
|
||||
mindmap_query({ op:"stats" }) // connectedComponents + longestPath/mostConnected
|
||||
mindmap_query({ op:"stats", edgeKinds:"relation" }) // diameter on relation edges only
|
||||
mindmap_query({ op:"missing_meta" })
|
||||
mindmap_query({ op:"orphans" })
|
||||
mindmap_highlight({ nodeIds:["@detect","@aiops"], edgeIds:["e_…"], ttlMs:5000 })
|
||||
|
||||
Node refs (P0 — prefer these over raw ids):
|
||||
{"type":"create_node","text":"AIOps","alias":"aiops"}
|
||||
{"type":"create_node","text":"重启","parentId":"@aiops"}
|
||||
{"type":"link","from":"@aiops","to":"重启","kind":"relation","label":"诊断→重启"}
|
||||
{"type":"link","fromText":"AIOps","toText":"重启","kind":"relation"}
|
||||
{"type":"batch_link","edges":[{"from":"@daiyu","to":"@baoyu","kind":"relation","label":"知己"},{"from":"@baoyu","to":"@baochai","kind":"relation","label":"金玉良缘"}]}
|
||||
apply_direct returns nodeIdMap: { "AIOps":"n_…", "@aiops":"n_…" }
|
||||
|
||||
Node/edge meta extras:
|
||||
- update_node_meta patch: description (JSON object; put a short "function" field for Q&A), accentColor, icon, alias, note
|
||||
- update_edge_meta patch: weight 1-5, lineStyle solid|dashed|dotted, direction forward|both|none
|
||||
- Prefer batch_create for trees; relations[] for inline edges; batch_link for bulk edges later; auto_fit after big builds
|
||||
|
||||
Rules:
|
||||
1. Always create or open a canvas BEFORE apply/propose.
|
||||
2. Never invent node ids; use find or create_node results. Never ask for / use a root node id — there is no visible root.
|
||||
3. Prefer batch_create (or many small ops) over one giant rewrite of unrelated topics.
|
||||
4. Always use type, never op.
|
||||
5. Top-level siblings: omit parentId on each create_node.
|
||||
6. For questions about the map, use mindmap_query first; never fabricate graph facts.`,
|
||||
}
|
||||
}
|
||||
43
packages/dsh-plugin/package.json
Normal file
43
packages/dsh-plugin/package.json
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
{
|
||||
"name": "dsh-mind-map",
|
||||
"version": "0.1.0",
|
||||
"description": "AI 双向无限画布:DSH 右侧思维导图 Tab;对话用 DSH。",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
".": "./lib/index.js",
|
||||
"./client": "./lib/client.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"cordis.patch.yml",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.js"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"platform": "web",
|
||||
"immediately": true,
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-ui-settings-plugins",
|
||||
"@deepseek-ai/dsh-client-locale"
|
||||
]
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"dsh-plugin",
|
||||
"dsh",
|
||||
"mind-map",
|
||||
"canvas"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
25
packages/graph-core/package.json
Normal file
25
packages/graph-core/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "@mind-map/graph-core",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mind-map/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.9"
|
||||
}
|
||||
}
|
||||
242
packages/graph-core/src/index.test.ts
Normal file
242
packages/graph-core/src/index.test.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { applyOps, createEmptyGraph, GraphError } from "./index.js";
|
||||
|
||||
describe("graph-core", () => {
|
||||
it("creates empty graph with anchor", () => {
|
||||
const g = createEmptyGraph("Demo");
|
||||
expect(g.nodes.size).toBe(1);
|
||||
expect(g.canvas.focusNodeId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creates child and rejects hierarchy cycle", () => {
|
||||
const g = createEmptyGraph("Root");
|
||||
const rootId = g.canvas.focusNodeId!;
|
||||
const r1 = applyOps(g, [
|
||||
{ type: "create_node", text: "A", parentId: rootId, id: "n_a" },
|
||||
]);
|
||||
const r2 = applyOps(r1.graph, [
|
||||
{ type: "create_node", text: "B", parentId: "n_a", id: "n_b" },
|
||||
]);
|
||||
expect(() =>
|
||||
applyOps(r2.graph, [
|
||||
{ type: "link", from: "n_b", to: rootId, kind: "hierarchy" },
|
||||
]),
|
||||
).toThrow(GraphError);
|
||||
});
|
||||
|
||||
it("update_text is invertible", () => {
|
||||
const g = createEmptyGraph("Root");
|
||||
const id = g.canvas.focusNodeId!;
|
||||
const r = applyOps(g, [{ type: "update_text", nodeId: id, text: "Hello" }]);
|
||||
expect(r.graph.nodes.get(id)!.text).toBe("Hello");
|
||||
const back = applyOps(r.graph, r.inverseOps);
|
||||
expect(back.graph.nodes.get(id)!.text).toBe("Root");
|
||||
});
|
||||
|
||||
it("create_node inverse deletes the node (undo-safe)", () => {
|
||||
const g = createEmptyGraph("Root");
|
||||
const r = applyOps(g, [
|
||||
{ type: "create_node", text: "凤霞", id: "n_fx" },
|
||||
]);
|
||||
expect(r.graph.nodes.get("n_fx")?.deletedAt ?? null).toBeNull();
|
||||
const back = applyOps(r.graph, r.inverseOps);
|
||||
expect(back.graph.nodes.get("n_fx")!.deletedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reorder is invertible and clears pin", () => {
|
||||
const g = createEmptyGraph("Root");
|
||||
const rootId = g.canvas.focusNodeId!;
|
||||
let r = applyOps(g, [
|
||||
{ type: "create_node", text: "A", parentId: rootId, id: "n_a" },
|
||||
{ type: "create_node", text: "B", parentId: rootId, id: "n_b" },
|
||||
]);
|
||||
r = applyOps(r.graph, [
|
||||
{
|
||||
type: "set_pinned",
|
||||
nodeId: "n_b",
|
||||
pinned: true,
|
||||
pos: { x: 100, y: 50 },
|
||||
},
|
||||
]);
|
||||
const ordered = applyOps(r.graph, [
|
||||
{ type: "reorder", nodeId: "n_b", order: -1 },
|
||||
]);
|
||||
expect(ordered.graph.nodes.get("n_b")!.pinned).toBe(false);
|
||||
const edge = [...ordered.graph.edges.values()].find(
|
||||
(e) => e.to === "n_b" && e.isPrimaryParent && !e.deletedAt,
|
||||
);
|
||||
expect(edge!.order).toBe(-1);
|
||||
const back = applyOps(ordered.graph, ordered.inverseOps);
|
||||
expect(back.graph.nodes.get("n_b")!.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it("batch_create two-pass parents/relations and inverse", () => {
|
||||
const g = createEmptyGraph("Root");
|
||||
const r = applyOps(g, [
|
||||
{
|
||||
type: "batch_create",
|
||||
nodes: [
|
||||
{ text: "数通", alias: "datacom", id: "n_dc" },
|
||||
{
|
||||
text: "AIOps",
|
||||
alias: "aiops",
|
||||
id: "n_ai",
|
||||
parentId: "n_dc",
|
||||
description: { function: "诊断" },
|
||||
relations: [
|
||||
{ to: "n_rs", kind: "relation", label: "触发" },
|
||||
],
|
||||
},
|
||||
{ text: "重启", alias: "restart", id: "n_rs", parentId: "n_dc" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(r.graph.nodes.get("n_ai")!.description?.function).toBe("诊断");
|
||||
const hier = [...r.graph.edges.values()].filter(
|
||||
(e) => e.kind === "hierarchy" && !e.deletedAt && e.from === "n_dc",
|
||||
);
|
||||
expect(hier.length).toBe(2);
|
||||
const rel = [...r.graph.edges.values()].find(
|
||||
(e) =>
|
||||
e.kind === "relation" &&
|
||||
e.from === "n_ai" &&
|
||||
e.to === "n_rs" &&
|
||||
!e.deletedAt,
|
||||
);
|
||||
expect(rel?.label).toBe("触发");
|
||||
const back = applyOps(r.graph, r.inverseOps);
|
||||
expect(back.graph.nodes.get("n_ai")!.deletedAt).toBeTruthy();
|
||||
expect(back.graph.nodes.get("n_dc")!.deletedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("batch_link creates multiple relation edges", () => {
|
||||
const g = createEmptyGraph("Root");
|
||||
const seeded = applyOps(g, [
|
||||
{ type: "create_node", text: "A", id: "n_a", alias: "a" },
|
||||
{ type: "create_node", text: "B", id: "n_b", alias: "b" },
|
||||
{ type: "create_node", text: "C", id: "n_c", alias: "c" },
|
||||
]);
|
||||
const r = applyOps(seeded.graph, [
|
||||
{
|
||||
type: "batch_link",
|
||||
edges: [
|
||||
{ from: "n_a", to: "n_b", kind: "relation", label: "ab" },
|
||||
{ from: "n_a", to: "n_c", kind: "relation", label: "ac" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const rels = [...r.graph.edges.values()].filter(
|
||||
(e) => e.kind === "relation" && !e.deletedAt,
|
||||
);
|
||||
expect(rels).toHaveLength(2);
|
||||
expect(rels.map((e) => e.label).sort()).toEqual(["ab", "ac"]);
|
||||
const back = applyOps(r.graph, r.inverseOps);
|
||||
const left = [...back.graph.edges.values()].filter(
|
||||
(e) => e.kind === "relation" && !e.deletedAt,
|
||||
);
|
||||
expect(left).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("auto_fit marks padding for host", () => {
|
||||
const g = createEmptyGraph("Root");
|
||||
const r = applyOps(g, [{ type: "auto_fit", padding: 80 }]);
|
||||
expect(r.autoFitPadding).toBe(80);
|
||||
expect(r.inverseOps[0]?.type).toBe("set_viewport");
|
||||
});
|
||||
|
||||
it("delete_node + restore_node restores leaf and parent edge", () => {
|
||||
const g = createEmptyGraph("Root");
|
||||
const rootId = g.canvas.focusNodeId!;
|
||||
let r = applyOps(g, [
|
||||
{ type: "create_node", text: "贾母", id: "n_jm", parentId: rootId },
|
||||
{ type: "create_node", text: "黛玉", id: "n_dy", parentId: "n_jm" },
|
||||
{
|
||||
type: "link",
|
||||
from: "n_dy",
|
||||
to: "n_jm",
|
||||
kind: "relation",
|
||||
label: "外孙女",
|
||||
},
|
||||
]);
|
||||
r = applyOps(r.graph, [{ type: "delete_node", nodeId: "n_dy" }]);
|
||||
expect(r.graph.nodes.get("n_dy")!.deletedAt).toBeTruthy();
|
||||
expect(
|
||||
[...r.graph.edges.values()].filter(
|
||||
(e) =>
|
||||
!e.deletedAt &&
|
||||
(e.from === "n_dy" || e.to === "n_dy"),
|
||||
),
|
||||
).toHaveLength(0);
|
||||
|
||||
const restored = applyOps(r.graph, [
|
||||
{ type: "restore_node", nodeId: "n_dy" },
|
||||
]);
|
||||
expect(restored.graph.nodes.get("n_dy")!.deletedAt).toBeNull();
|
||||
const parentEdge = [...restored.graph.edges.values()].find(
|
||||
(e) =>
|
||||
!e.deletedAt &&
|
||||
e.kind === "hierarchy" &&
|
||||
e.isPrimaryParent &&
|
||||
e.from === "n_jm" &&
|
||||
e.to === "n_dy",
|
||||
);
|
||||
expect(parentEdge).toBeTruthy();
|
||||
const rel = [...restored.graph.edges.values()].find(
|
||||
(e) =>
|
||||
!e.deletedAt &&
|
||||
e.kind === "relation" &&
|
||||
e.from === "n_dy" &&
|
||||
e.to === "n_jm",
|
||||
);
|
||||
expect(rel?.label).toBe("外孙女");
|
||||
});
|
||||
|
||||
it("delete_node undo restores via restore_node without duplicate edges", () => {
|
||||
const g = createEmptyGraph("Root");
|
||||
const rootId = g.canvas.focusNodeId!;
|
||||
let r = applyOps(g, [
|
||||
{ type: "create_node", text: "A", id: "n_a", parentId: rootId },
|
||||
{ type: "create_node", text: "B", id: "n_b", parentId: "n_a" },
|
||||
]);
|
||||
const beforeEdges = [...r.graph.edges.values()].filter((e) => !e.deletedAt)
|
||||
.length;
|
||||
const deleted = applyOps(r.graph, [{ type: "delete_node", nodeId: "n_a" }]);
|
||||
// B promoted under root
|
||||
const promo = [...deleted.graph.edges.values()].find(
|
||||
(e) =>
|
||||
!e.deletedAt &&
|
||||
e.kind === "hierarchy" &&
|
||||
e.isPrimaryParent &&
|
||||
e.to === "n_b",
|
||||
);
|
||||
expect(promo?.from).toBe(rootId);
|
||||
|
||||
const undone = applyOps(deleted.graph, deleted.inverseOps);
|
||||
expect(undone.graph.nodes.get("n_a")!.deletedAt).toBeNull();
|
||||
const aParent = [...undone.graph.edges.values()].find(
|
||||
(e) =>
|
||||
!e.deletedAt &&
|
||||
e.kind === "hierarchy" &&
|
||||
e.isPrimaryParent &&
|
||||
e.to === "n_a",
|
||||
);
|
||||
expect(aParent?.from).toBe(rootId);
|
||||
const bParent = [...undone.graph.edges.values()].find(
|
||||
(e) =>
|
||||
!e.deletedAt &&
|
||||
e.kind === "hierarchy" &&
|
||||
e.isPrimaryParent &&
|
||||
e.to === "n_b",
|
||||
);
|
||||
expect(bParent?.from).toBe("n_a");
|
||||
const liveHier = [...undone.graph.edges.values()].filter(
|
||||
(e) => !e.deletedAt && e.kind === "hierarchy" && e.isPrimaryParent,
|
||||
);
|
||||
// root→a, a→b (plus possibly root title edges depending on create)
|
||||
expect(liveHier.filter((e) => e.to === "n_b")).toHaveLength(1);
|
||||
expect(liveHier.filter((e) => e.to === "n_a")).toHaveLength(1);
|
||||
expect(
|
||||
[...undone.graph.edges.values()].filter((e) => !e.deletedAt).length,
|
||||
).toBeGreaterThanOrEqual(beforeEdges);
|
||||
});
|
||||
});
|
||||
1017
packages/graph-core/src/index.ts
Normal file
1017
packages/graph-core/src/index.ts
Normal file
File diff suppressed because it is too large
Load diff
10
packages/graph-core/tsconfig.json
Normal file
10
packages/graph-core/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"references": [{ "path": "../shared" }]
|
||||
}
|
||||
1
packages/graph-core/tsconfig.tsbuildinfo
Normal file
1
packages/graph-core/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
27
packages/graph-store/package.json
Normal file
27
packages/graph-store/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "@mind-map/graph-store",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mind-map/graph-core": "workspace:*",
|
||||
"@mind-map/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.10",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.9"
|
||||
}
|
||||
}
|
||||
28
packages/graph-store/src/index.test.ts
Normal file
28
packages/graph-store/src/index.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SqliteGraphStore } from "./index.js";
|
||||
|
||||
describe("SqliteGraphStore undo", () => {
|
||||
it("undoes latest create without throwing", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-undo-"));
|
||||
const store = new SqliteGraphStore(path.join(dir, "g.sqlite"));
|
||||
const g = store.createCanvas("undo-test");
|
||||
const canvasId = g.canvas.id;
|
||||
const applied = store.applyChangeSet(
|
||||
canvasId,
|
||||
[{ type: "create_node", text: "临时节点", id: "n_tmp" }],
|
||||
{ origin: "user", summary: "create" },
|
||||
);
|
||||
expect(
|
||||
store.getSnapshot(canvasId).nodes.some((n) => n.id === "n_tmp" && !n.deletedAt),
|
||||
).toBe(true);
|
||||
const undone = store.undoChangeSet(applied.changeSetId);
|
||||
expect(undone.rev).toBeGreaterThan(applied.rev);
|
||||
const tmp = store.getSnapshot(canvasId).nodes.find((n) => n.id === "n_tmp");
|
||||
// Soft-deleted or fully absent both mean undo removed it from the live graph.
|
||||
expect(!tmp || tmp.deletedAt).toBe(true);
|
||||
expect(store.latestUndoableChangeSetId(canvasId)).toBe(undone.changeSetId);
|
||||
});
|
||||
});
|
||||
841
packages/graph-store/src/index.ts
Normal file
841
packages/graph-store/src/index.ts
Normal file
|
|
@ -0,0 +1,841 @@
|
|||
import { DatabaseSync } from "node:sqlite";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
applyOps,
|
||||
cloneGraph,
|
||||
createEmptyGraph,
|
||||
resolveAnchorNodeId,
|
||||
toSnapshot,
|
||||
type MutableGraph,
|
||||
} from "@mind-map/graph-core";
|
||||
import type {
|
||||
CanvasMeta,
|
||||
MindEdge,
|
||||
MindNode,
|
||||
Op,
|
||||
} from "@mind-map/shared";
|
||||
import { newId, now } from "@mind-map/shared";
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS canvases (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
focus_node_id TEXT,
|
||||
viewport_json TEXT NOT NULL,
|
||||
prefs_json TEXT NOT NULL,
|
||||
rev INTEGER NOT NULL DEFAULT 0,
|
||||
agent_mode TEXT NOT NULL DEFAULT 'edit',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
canvas_id TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
note TEXT,
|
||||
collapsed INTEGER NOT NULL DEFAULT 0,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
pos_json TEXT,
|
||||
side_pref INTEGER NOT NULL DEFAULT 0,
|
||||
style_preset TEXT NOT NULL DEFAULT 'default',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
links_json TEXT NOT NULL DEFAULT '[]',
|
||||
image_url TEXT,
|
||||
due_at INTEGER,
|
||||
start_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS edges (
|
||||
id TEXT PRIMARY KEY,
|
||||
canvas_id TEXT NOT NULL,
|
||||
"from" TEXT NOT NULL,
|
||||
"to" TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
is_primary_parent INTEGER NOT NULL DEFAULT 0,
|
||||
"order" REAL NOT NULL DEFAULT 0,
|
||||
label TEXT,
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
note TEXT,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
deleted_at INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
canvas_id TEXT NOT NULL,
|
||||
change_set_id TEXT NOT NULL,
|
||||
op_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS change_sets (
|
||||
id TEXT PRIMARY KEY,
|
||||
canvas_id TEXT NOT NULL,
|
||||
base_rev INTEGER NOT NULL,
|
||||
origin TEXT NOT NULL,
|
||||
summary TEXT NOT NULL,
|
||||
inverse_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
undone_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_nodes_canvas ON nodes(canvas_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_edges_canvas ON edges(canvas_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_canvas ON events(canvas_id);
|
||||
`;
|
||||
|
||||
function rowNode(r: Record<string, unknown>): MindNode {
|
||||
return {
|
||||
id: String(r.id),
|
||||
canvasId: String(r.canvas_id),
|
||||
text: String(r.text),
|
||||
note: r.note != null ? String(r.note) : undefined,
|
||||
collapsed: Boolean(r.collapsed),
|
||||
pinned: Boolean(r.pinned),
|
||||
pos: r.pos_json ? (JSON.parse(String(r.pos_json)) as { x: number; y: number }) : null,
|
||||
sidePref: Number(r.side_pref) as -1 | 0 | 1,
|
||||
stylePreset: String(r.style_preset) as MindNode["stylePreset"],
|
||||
version: Number(r.version),
|
||||
tags: JSON.parse(String(r.tags_json ?? "[]")) as string[],
|
||||
links: JSON.parse(String(r.links_json ?? "[]")) as MindNode["links"],
|
||||
imageUrl: r.image_url != null ? String(r.image_url) : undefined,
|
||||
dueAt: r.due_at != null ? Number(r.due_at) : undefined,
|
||||
startAt: r.start_at != null ? Number(r.start_at) : undefined,
|
||||
accentColor:
|
||||
r.accent_color != null ? (String(r.accent_color) as MindNode["accentColor"]) : undefined,
|
||||
icon: r.icon != null ? (String(r.icon) as MindNode["icon"]) : undefined,
|
||||
alias: r.alias != null ? String(r.alias) : undefined,
|
||||
description: r.description_json
|
||||
? (JSON.parse(String(r.description_json)) as MindNode["description"])
|
||||
: undefined,
|
||||
createdAt: Number(r.created_at),
|
||||
updatedAt: Number(r.updated_at),
|
||||
deletedAt: r.deleted_at != null ? Number(r.deleted_at) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function rowEdge(r: Record<string, unknown>): MindEdge {
|
||||
return {
|
||||
id: String(r.id),
|
||||
canvasId: String(r.canvas_id),
|
||||
from: String(r.from),
|
||||
to: String(r.to),
|
||||
kind: String(r.kind) as MindEdge["kind"],
|
||||
isPrimaryParent: Boolean(r.is_primary_parent),
|
||||
order: Number(r.order),
|
||||
label: r.label != null ? String(r.label) : undefined,
|
||||
tags: JSON.parse(String(r.tags_json ?? "[]")) as string[],
|
||||
note: r.note != null ? String(r.note) : undefined,
|
||||
weight: r.weight != null ? Number(r.weight) : undefined,
|
||||
lineStyle:
|
||||
r.line_style != null ? (String(r.line_style) as MindEdge["lineStyle"]) : undefined,
|
||||
direction:
|
||||
r.direction != null ? (String(r.direction) as MindEdge["direction"]) : undefined,
|
||||
version: Number(r.version),
|
||||
createdAt: Number(r.created_at),
|
||||
updatedAt: Number(r.updated_at),
|
||||
deletedAt: r.deleted_at != null ? Number(r.deleted_at) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export class SqliteGraphStore {
|
||||
readonly db: DatabaseSync;
|
||||
private cache = new Map<string, MutableGraph>();
|
||||
|
||||
constructor(dbPath: string) {
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
this.db = new DatabaseSync(dbPath);
|
||||
this.db.exec("PRAGMA journal_mode = WAL;");
|
||||
this.db.exec("PRAGMA foreign_keys = ON;");
|
||||
this.db.exec(SCHEMA);
|
||||
this.migrateSchema();
|
||||
}
|
||||
|
||||
private migrateSchema() {
|
||||
const verRow = this.db.prepare("SELECT value FROM meta WHERE key = ?").get("schema_version") as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
let ver = verRow ? Number(verRow.value) : 0;
|
||||
if (!verRow) {
|
||||
this.db.prepare("INSERT INTO meta(key, value) VALUES(?, ?)").run("schema_version", "1");
|
||||
ver = 1;
|
||||
}
|
||||
const tableInfo = (table: string) =>
|
||||
(this.db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map(
|
||||
(c) => c.name,
|
||||
);
|
||||
const ensureCol = (table: string, col: string, ddl: string) => {
|
||||
if (!tableInfo(table).includes(col)) {
|
||||
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
|
||||
}
|
||||
};
|
||||
if (ver < 2) {
|
||||
ensureCol("nodes", "links_json", `links_json TEXT NOT NULL DEFAULT '[]'`);
|
||||
ensureCol("nodes", "image_url", `image_url TEXT`);
|
||||
ensureCol("nodes", "due_at", `due_at INTEGER`);
|
||||
ensureCol("nodes", "start_at", `start_at INTEGER`);
|
||||
ensureCol("edges", "tags_json", `tags_json TEXT NOT NULL DEFAULT '[]'`);
|
||||
ensureCol("edges", "note", `note TEXT`);
|
||||
this.db
|
||||
.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
|
||||
.run("schema_version", "2");
|
||||
ver = 2;
|
||||
}
|
||||
if (ver < 3) {
|
||||
ensureCol("nodes", "accent_color", `accent_color TEXT`);
|
||||
ensureCol("nodes", "icon", `icon TEXT`);
|
||||
ensureCol("edges", "weight", `weight REAL`);
|
||||
ensureCol("edges", "line_style", `line_style TEXT`);
|
||||
this.db
|
||||
.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
|
||||
.run("schema_version", "3");
|
||||
ver = 3;
|
||||
}
|
||||
if (ver < 4) {
|
||||
ensureCol("nodes", "alias", `alias TEXT`);
|
||||
this.db
|
||||
.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
|
||||
.run("schema_version", "4");
|
||||
ver = 4;
|
||||
}
|
||||
if (ver < 5) {
|
||||
ensureCol("edges", "direction", `direction TEXT`);
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS snapshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
canvas_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_snapshots_canvas ON snapshots(canvas_id);
|
||||
`);
|
||||
this.db
|
||||
.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
|
||||
.run("schema_version", "5");
|
||||
ver = 5;
|
||||
}
|
||||
if (ver < 6) {
|
||||
ensureCol("nodes", "description_json", `description_json TEXT`);
|
||||
this.db
|
||||
.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`)
|
||||
.run("schema_version", "6");
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
listCanvases(): CanvasMeta[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM canvases WHERE deleted_at IS NULL ORDER BY updated_at DESC`,
|
||||
)
|
||||
.all() as Record<string, unknown>[];
|
||||
return rows.map((r) => this.rowCanvas(r));
|
||||
}
|
||||
|
||||
private rowCanvas(r: Record<string, unknown>): CanvasMeta {
|
||||
const rawPrefs = JSON.parse(String(r.prefs_json || "{}")) as Record<string, unknown>;
|
||||
const anchorNodeId =
|
||||
rawPrefs.anchorNodeId != null ? String(rawPrefs.anchorNodeId) : null;
|
||||
const prefs = {
|
||||
themeId: "abyss",
|
||||
branchColoring: true,
|
||||
density: "comfortable",
|
||||
riskProfile: "fast",
|
||||
showRelationEdges: true,
|
||||
nodeBadges: "icons",
|
||||
showHoverCard: true,
|
||||
inspectorMode: "selection",
|
||||
nodeViewMode: "card",
|
||||
...rawPrefs,
|
||||
} as CanvasMeta["prefs"];
|
||||
delete (prefs as Record<string, unknown>).anchorNodeId;
|
||||
return {
|
||||
id: String(r.id),
|
||||
title: String(r.title),
|
||||
focusNodeId: r.focus_node_id != null ? String(r.focus_node_id) : null,
|
||||
anchorNodeId,
|
||||
viewport: JSON.parse(String(r.viewport_json)),
|
||||
prefs,
|
||||
rev: Number(r.rev),
|
||||
agentMode: String(r.agent_mode) as CanvasMeta["agentMode"],
|
||||
createdAt: Number(r.created_at),
|
||||
updatedAt: Number(r.updated_at),
|
||||
deletedAt: r.deleted_at != null ? Number(r.deleted_at) : null,
|
||||
};
|
||||
}
|
||||
|
||||
createCanvas(title?: string, rootText?: string): MutableGraph {
|
||||
const g = createEmptyGraph(title ?? "未命名画布", rootText);
|
||||
this.persistGraph(g, true);
|
||||
this.cache.set(g.canvas.id, g);
|
||||
return g;
|
||||
}
|
||||
|
||||
loadCanvas(canvasId: string): MutableGraph {
|
||||
const cached = this.cache.get(canvasId);
|
||||
if (cached) return cloneGraph(cached);
|
||||
|
||||
const crow = this.db.prepare(`SELECT * FROM canvases WHERE id = ?`).get(canvasId) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (!crow || crow.deleted_at != null) throw new Error(`canvas not found: ${canvasId}`);
|
||||
|
||||
const nodes = (
|
||||
this.db.prepare(`SELECT * FROM nodes WHERE canvas_id = ?`).all(canvasId) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[]
|
||||
).map(rowNode);
|
||||
const edges = (
|
||||
this.db.prepare(`SELECT * FROM edges WHERE canvas_id = ?`).all(canvasId) as Record<
|
||||
string,
|
||||
unknown
|
||||
>[]
|
||||
).map(rowEdge);
|
||||
|
||||
const g: MutableGraph = {
|
||||
canvas: this.rowCanvas(crow),
|
||||
nodes: new Map(nodes.map((n) => [n.id, n])),
|
||||
edges: new Map(edges.map((e) => [e.id, e])),
|
||||
};
|
||||
if (!g.canvas.anchorNodeId) {
|
||||
resolveAnchorNodeId(g);
|
||||
}
|
||||
this.cache.set(canvasId, g);
|
||||
return cloneGraph(g);
|
||||
}
|
||||
|
||||
getSnapshot(canvasId: string) {
|
||||
return toSnapshot(this.loadCanvas(canvasId));
|
||||
}
|
||||
|
||||
applyChangeSet(
|
||||
canvasId: string,
|
||||
ops: Op[],
|
||||
opts: { origin: "user" | "ai"; summary?: string },
|
||||
) {
|
||||
const g = this.loadCanvas(canvasId);
|
||||
const baseRev = g.canvas.rev;
|
||||
const result = applyOps(g, ops);
|
||||
result.graph.canvas.rev = baseRev + 1;
|
||||
const changeSetId = newId("cs");
|
||||
|
||||
this.db.exec("BEGIN");
|
||||
try {
|
||||
this.persistGraph(result.graph, false);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO change_sets(id, canvas_id, base_rev, origin, summary, inverse_json, created_at)
|
||||
VALUES(?,?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(
|
||||
changeSetId,
|
||||
canvasId,
|
||||
baseRev,
|
||||
opts.origin,
|
||||
opts.summary ?? `${ops.length} ops`,
|
||||
JSON.stringify(result.inverseOps),
|
||||
now(),
|
||||
);
|
||||
const ins = this.db.prepare(
|
||||
`INSERT INTO events(canvas_id, change_set_id, op_json, created_at) VALUES(?,?,?,?)`,
|
||||
);
|
||||
for (const op of ops) {
|
||||
ins.run(canvasId, changeSetId, JSON.stringify(op), now());
|
||||
}
|
||||
this.db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
|
||||
this.cache.set(canvasId, result.graph);
|
||||
return {
|
||||
changeSetId,
|
||||
rev: result.graph.canvas.rev,
|
||||
snapshot: toSnapshot(result.graph),
|
||||
affectedNodeIds: result.affectedNodeIds,
|
||||
inverseOps: result.inverseOps,
|
||||
autoFitPadding: result.autoFitPadding,
|
||||
};
|
||||
}
|
||||
|
||||
undoChangeSet(changeSetId: string) {
|
||||
const row = this.db
|
||||
.prepare(`SELECT * FROM change_sets WHERE id = ?`)
|
||||
.get(changeSetId) as Record<string, unknown> | undefined;
|
||||
if (!row) throw new Error("changeset not found");
|
||||
if (row.undone_at != null) throw new Error("already undone");
|
||||
const canvasId = String(row.canvas_id);
|
||||
const inverse = JSON.parse(String(row.inverse_json)) as Op[];
|
||||
// Undo must not fail on stale expectedVersion from the forward pass.
|
||||
const cleaned = inverse.map((op) => {
|
||||
if (!op || typeof op !== "object") return op;
|
||||
const { expectedVersion: _ev, ...rest } = op as Op & {
|
||||
expectedVersion?: number;
|
||||
};
|
||||
return rest as Op;
|
||||
});
|
||||
const result = this.applyChangeSet(canvasId, cleaned, {
|
||||
origin: "user",
|
||||
summary: `undo ${changeSetId}`,
|
||||
});
|
||||
this.db
|
||||
.prepare(`UPDATE change_sets SET undone_at = ? WHERE id = ?`)
|
||||
.run(now(), changeSetId);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Most recent change set that can still be undone (for mindmap_undo without id). */
|
||||
latestUndoableChangeSetId(canvasId: string): string | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id FROM change_sets
|
||||
WHERE canvas_id = ? AND undone_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
)
|
||||
.get(canvasId) as { id: string } | undefined;
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
/** Latest undo companion (summary starts with "undo ") — redo by undoing it. */
|
||||
latestRedoableChangeSetId(canvasId: string): string | null {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
`SELECT id, summary FROM change_sets
|
||||
WHERE canvas_id = ? AND undone_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
)
|
||||
.get(canvasId) as { id: string; summary: string } | undefined;
|
||||
if (!row) return null;
|
||||
if (!String(row.summary).startsWith("undo ")) return null;
|
||||
return row.id;
|
||||
}
|
||||
|
||||
listChangeSets(canvasId: string, limit = 30) {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, base_rev, origin, summary, created_at, undone_at
|
||||
FROM change_sets WHERE canvas_id = ?
|
||||
ORDER BY created_at DESC LIMIT ?`,
|
||||
)
|
||||
.all(canvasId, limit) as Record<string, unknown>[];
|
||||
return rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
baseRev: Number(r.base_rev),
|
||||
origin: String(r.origin),
|
||||
summary: String(r.summary),
|
||||
createdAt: Number(r.created_at),
|
||||
undone: r.undone_at != null,
|
||||
isUndoCompanion: String(r.summary).startsWith("undo "),
|
||||
}));
|
||||
}
|
||||
|
||||
createSnapshot(canvasId: string, name: string) {
|
||||
const snap = this.getSnapshot(canvasId);
|
||||
const id = newId("snap");
|
||||
const t = now();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO snapshots(id, canvas_id, name, snapshot_json, created_at) VALUES(?,?,?,?,?)`,
|
||||
)
|
||||
.run(id, canvasId, name || `snapshot-${t}`, JSON.stringify(snap), t);
|
||||
return { id, name: name || `snapshot-${t}`, createdAt: t };
|
||||
}
|
||||
|
||||
listSnapshots(canvasId: string, limit = 20) {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, name, created_at FROM snapshots WHERE canvas_id = ?
|
||||
ORDER BY created_at DESC LIMIT ?`,
|
||||
)
|
||||
.all(canvasId, limit) as Record<string, unknown>[];
|
||||
return rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
name: String(r.name),
|
||||
createdAt: Number(r.created_at),
|
||||
}));
|
||||
}
|
||||
|
||||
restoreSnapshot(snapshotId: string) {
|
||||
const row = this.db
|
||||
.prepare(`SELECT * FROM snapshots WHERE id = ?`)
|
||||
.get(snapshotId) as Record<string, unknown> | undefined;
|
||||
if (!row) throw new Error("snapshot not found");
|
||||
const canvasId = String(row.canvas_id);
|
||||
const snap = JSON.parse(String(row.snapshot_json)) as {
|
||||
canvas: CanvasMeta;
|
||||
nodes: MindNode[];
|
||||
edges: MindEdge[];
|
||||
};
|
||||
const g = this.loadCanvas(canvasId);
|
||||
// Soft-delete current live nodes/edges then restore snapshot rows.
|
||||
const t = now();
|
||||
for (const n of g.nodes.values()) {
|
||||
if (!n.deletedAt) {
|
||||
n.deletedAt = t;
|
||||
n.version += 1;
|
||||
n.updatedAt = t;
|
||||
}
|
||||
}
|
||||
for (const e of g.edges.values()) {
|
||||
if (!e.deletedAt) {
|
||||
e.deletedAt = t;
|
||||
e.version += 1;
|
||||
e.updatedAt = t;
|
||||
}
|
||||
}
|
||||
for (const n of snap.nodes) {
|
||||
g.nodes.set(n.id, {
|
||||
...n,
|
||||
canvasId,
|
||||
deletedAt: n.deletedAt ?? null,
|
||||
updatedAt: t,
|
||||
});
|
||||
}
|
||||
for (const e of snap.edges) {
|
||||
g.edges.set(e.id, {
|
||||
...e,
|
||||
canvasId,
|
||||
deletedAt: e.deletedAt ?? null,
|
||||
updatedAt: t,
|
||||
});
|
||||
}
|
||||
g.canvas.title = snap.canvas.title;
|
||||
g.canvas.focusNodeId = snap.canvas.focusNodeId;
|
||||
g.canvas.anchorNodeId = snap.canvas.anchorNodeId;
|
||||
g.canvas.prefs = { ...g.canvas.prefs, ...snap.canvas.prefs };
|
||||
g.canvas.viewport = { ...snap.canvas.viewport };
|
||||
g.canvas.rev += 1;
|
||||
g.canvas.updatedAt = t;
|
||||
const changeSetId = newId("cs");
|
||||
this.db.exec("BEGIN");
|
||||
try {
|
||||
this.persistGraph(g, false);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO change_sets(id, canvas_id, base_rev, origin, summary, inverse_json, created_at)
|
||||
VALUES(?,?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(
|
||||
changeSetId,
|
||||
canvasId,
|
||||
g.canvas.rev - 1,
|
||||
"user",
|
||||
`restore snapshot ${snapshotId}`,
|
||||
JSON.stringify([]),
|
||||
t,
|
||||
);
|
||||
this.db.exec("COMMIT");
|
||||
} catch (e) {
|
||||
this.db.exec("ROLLBACK");
|
||||
throw e;
|
||||
}
|
||||
this.cache.set(canvasId, g);
|
||||
return {
|
||||
changeSetId,
|
||||
rev: g.canvas.rev,
|
||||
snapshot: toSnapshot(g),
|
||||
affectedNodeIds: [...g.nodes.keys()],
|
||||
};
|
||||
}
|
||||
|
||||
search(
|
||||
canvasId: string,
|
||||
query: string,
|
||||
limit = 20,
|
||||
scope:
|
||||
| "text"
|
||||
| "note"
|
||||
| "tags"
|
||||
| "description"
|
||||
| "edge_label"
|
||||
| "edge_note"
|
||||
| "all" = "text",
|
||||
) {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const g = this.loadCanvas(canvasId);
|
||||
type NodeHit = {
|
||||
kind: "node";
|
||||
id: string;
|
||||
text: string;
|
||||
score: number;
|
||||
matchIn: ("text" | "note" | "tags" | "description")[];
|
||||
};
|
||||
type EdgeHit = {
|
||||
kind: "edge";
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
fromText: string;
|
||||
toText: string;
|
||||
label?: string;
|
||||
note?: string;
|
||||
score: number;
|
||||
matchIn: ("edge_label" | "edge_note")[];
|
||||
};
|
||||
const hits: (NodeHit | EdgeHit)[] = [];
|
||||
|
||||
const wantNodes =
|
||||
scope === "text" ||
|
||||
scope === "note" ||
|
||||
scope === "tags" ||
|
||||
scope === "description" ||
|
||||
scope === "all";
|
||||
const wantEdgeLabel = scope === "edge_label" || scope === "all";
|
||||
const wantEdgeNote = scope === "edge_note" || scope === "all";
|
||||
|
||||
if (wantNodes) {
|
||||
for (const n of g.nodes.values()) {
|
||||
if (n.deletedAt) continue;
|
||||
const matchIn: ("text" | "note" | "tags" | "description")[] = [];
|
||||
const t = n.text.toLowerCase();
|
||||
const note = (n.note ?? "").toLowerCase();
|
||||
const tags = (n.tags ?? []).map((x) => x.toLowerCase());
|
||||
const desc = n.description
|
||||
? JSON.stringify(n.description).toLowerCase()
|
||||
: "";
|
||||
const wantText = scope === "text" || scope === "all";
|
||||
const wantNote = scope === "note" || scope === "all";
|
||||
const wantTags = scope === "tags" || scope === "all";
|
||||
const wantDesc = scope === "description" || scope === "all";
|
||||
if (wantText && t.includes(q)) matchIn.push("text");
|
||||
if (wantNote && note.includes(q)) matchIn.push("note");
|
||||
if (wantTags && tags.some((tag) => tag.includes(q))) matchIn.push("tags");
|
||||
if (wantDesc && desc.includes(q)) matchIn.push("description");
|
||||
if (!matchIn.length) continue;
|
||||
let score = 0.4;
|
||||
if (matchIn.includes("text")) score = t.startsWith(q) ? 1 : 0.75;
|
||||
else if (matchIn.includes("tags")) score = 0.55;
|
||||
else if (matchIn.includes("description")) score = 0.5;
|
||||
else score = 0.45;
|
||||
hits.push({ kind: "node", id: n.id, text: n.text, score, matchIn });
|
||||
}
|
||||
}
|
||||
|
||||
if (wantEdgeLabel || wantEdgeNote) {
|
||||
for (const e of g.edges.values()) {
|
||||
if (e.deletedAt) continue;
|
||||
const matchIn: ("edge_label" | "edge_note")[] = [];
|
||||
const label = (e.label ?? "").toLowerCase();
|
||||
const note = (e.note ?? "").toLowerCase();
|
||||
if (wantEdgeLabel && label.includes(q)) matchIn.push("edge_label");
|
||||
if (wantEdgeNote && note.includes(q)) matchIn.push("edge_note");
|
||||
if (!matchIn.length) continue;
|
||||
const fromN = g.nodes.get(e.from);
|
||||
const toN = g.nodes.get(e.to);
|
||||
let score = 0.5;
|
||||
if (matchIn.includes("edge_label")) {
|
||||
score = label.startsWith(q) ? 0.95 : 0.7;
|
||||
} else {
|
||||
score = 0.55;
|
||||
}
|
||||
hits.push({
|
||||
kind: "edge",
|
||||
id: e.id,
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
fromText: fromN?.text ?? e.from,
|
||||
toText: toN?.text ?? e.to,
|
||||
...(e.label ? { label: e.label } : {}),
|
||||
...(e.note ? { note: e.note } : {}),
|
||||
score,
|
||||
matchIn,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return hits.sort((a, b) => b.score - a.score).slice(0, limit);
|
||||
}
|
||||
|
||||
renameCanvas(canvasId: string, title: string) {
|
||||
const g = this.loadCanvas(canvasId);
|
||||
g.canvas.title = title;
|
||||
g.canvas.updatedAt = now();
|
||||
this.db
|
||||
.prepare(`UPDATE canvases SET title = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(title, g.canvas.updatedAt, canvasId);
|
||||
this.cache.set(canvasId, g);
|
||||
return g.canvas;
|
||||
}
|
||||
|
||||
softDeleteCanvas(canvasId: string) {
|
||||
const t = now();
|
||||
this.db.prepare(`UPDATE canvases SET deleted_at = ?, updated_at = ? WHERE id = ?`).run(t, t, canvasId);
|
||||
this.cache.delete(canvasId);
|
||||
}
|
||||
|
||||
/** Permanent delete of a canvas and all related rows (P17). */
|
||||
hardDeleteCanvas(canvasId: string) {
|
||||
this.db.prepare(`DELETE FROM events WHERE canvas_id = ?`).run(canvasId);
|
||||
this.db.prepare(`DELETE FROM change_sets WHERE canvas_id = ?`).run(canvasId);
|
||||
this.db.prepare(`DELETE FROM edges WHERE canvas_id = ?`).run(canvasId);
|
||||
this.db.prepare(`DELETE FROM nodes WHERE canvas_id = ?`).run(canvasId);
|
||||
this.db.prepare(`DELETE FROM canvases WHERE id = ?`).run(canvasId);
|
||||
this.cache.delete(canvasId);
|
||||
}
|
||||
|
||||
listDeletedNodes(canvasId: string): MindNode[] {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM nodes WHERE canvas_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC LIMIT 100`,
|
||||
)
|
||||
.all(canvasId) as Record<string, unknown>[];
|
||||
return rows.map(rowNode);
|
||||
}
|
||||
|
||||
restoreNode(canvasId: string, nodeId: string) {
|
||||
const g = this.loadCanvas(canvasId);
|
||||
const focus = g.canvas.focusNodeId;
|
||||
if (!g.nodes.get(nodeId)) throw new Error("not found");
|
||||
const ops: Op[] = [{ type: "restore_node", nodeId }];
|
||||
if (focus && focus !== nodeId) {
|
||||
ops.push({
|
||||
type: "link",
|
||||
from: focus,
|
||||
to: nodeId,
|
||||
kind: "hierarchy",
|
||||
asPrimary: true,
|
||||
});
|
||||
}
|
||||
return this.applyChangeSet(canvasId, ops, {
|
||||
origin: "user",
|
||||
summary: `restore ${nodeId}`,
|
||||
});
|
||||
}
|
||||
|
||||
updateViewport(canvasId: string, viewport: CanvasMeta["viewport"]) {
|
||||
const g = this.loadCanvas(canvasId);
|
||||
g.canvas.viewport = viewport;
|
||||
g.canvas.updatedAt = now();
|
||||
this.db
|
||||
.prepare(`UPDATE canvases SET viewport_json = ?, updated_at = ? WHERE id = ?`)
|
||||
.run(JSON.stringify(viewport), g.canvas.updatedAt, canvasId);
|
||||
this.cache.set(canvasId, g);
|
||||
}
|
||||
|
||||
private persistGraph(g: MutableGraph, isNew: boolean) {
|
||||
const c = g.canvas;
|
||||
if (!c.anchorNodeId) resolveAnchorNodeId(g);
|
||||
const prefsJson = JSON.stringify({
|
||||
...c.prefs,
|
||||
anchorNodeId: c.anchorNodeId ?? null,
|
||||
});
|
||||
if (isNew) {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO canvases(id, title, focus_node_id, viewport_json, prefs_json, rev, agent_mode, created_at, updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
.run(
|
||||
c.id,
|
||||
c.title,
|
||||
c.focusNodeId,
|
||||
JSON.stringify(c.viewport),
|
||||
prefsJson,
|
||||
c.rev,
|
||||
c.agentMode,
|
||||
c.createdAt,
|
||||
c.updatedAt,
|
||||
);
|
||||
} else {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE canvases SET title=?, focus_node_id=?, viewport_json=?, prefs_json=?, rev=?, agent_mode=?, updated_at=? WHERE id=?`,
|
||||
)
|
||||
.run(
|
||||
c.title,
|
||||
c.focusNodeId,
|
||||
JSON.stringify(c.viewport),
|
||||
prefsJson,
|
||||
c.rev,
|
||||
c.agentMode,
|
||||
c.updatedAt,
|
||||
c.id,
|
||||
);
|
||||
}
|
||||
|
||||
const upsertNode = this.db.prepare(
|
||||
`INSERT INTO nodes(id, canvas_id, text, note, collapsed, pinned, pos_json, side_pref, style_preset, version, tags_json, links_json, image_url, due_at, start_at, accent_color, icon, alias, description_json, created_at, updated_at, deleted_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
text=excluded.text, note=excluded.note, collapsed=excluded.collapsed, pinned=excluded.pinned,
|
||||
pos_json=excluded.pos_json, side_pref=excluded.side_pref, style_preset=excluded.style_preset,
|
||||
version=excluded.version, tags_json=excluded.tags_json, links_json=excluded.links_json,
|
||||
image_url=excluded.image_url, due_at=excluded.due_at, start_at=excluded.start_at,
|
||||
accent_color=excluded.accent_color, icon=excluded.icon, alias=excluded.alias,
|
||||
description_json=excluded.description_json,
|
||||
updated_at=excluded.updated_at, deleted_at=excluded.deleted_at`,
|
||||
);
|
||||
for (const n of g.nodes.values()) {
|
||||
upsertNode.run(
|
||||
n.id,
|
||||
n.canvasId,
|
||||
n.text,
|
||||
n.note ?? null,
|
||||
n.collapsed ? 1 : 0,
|
||||
n.pinned ? 1 : 0,
|
||||
n.pos ? JSON.stringify(n.pos) : null,
|
||||
n.sidePref,
|
||||
n.stylePreset,
|
||||
n.version,
|
||||
JSON.stringify(n.tags ?? []),
|
||||
JSON.stringify(n.links ?? []),
|
||||
n.imageUrl ?? null,
|
||||
n.dueAt ?? null,
|
||||
n.startAt ?? null,
|
||||
n.accentColor ?? null,
|
||||
n.icon ?? null,
|
||||
n.alias ?? null,
|
||||
n.description ? JSON.stringify(n.description) : null,
|
||||
n.createdAt,
|
||||
n.updatedAt,
|
||||
n.deletedAt ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
const upsertEdge = this.db.prepare(
|
||||
`INSERT INTO edges(id, canvas_id, "from", "to", kind, is_primary_parent, "order", label, tags_json, note, weight, line_style, direction, version, created_at, updated_at, deleted_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
"from"=excluded."from", "to"=excluded."to", kind=excluded.kind, is_primary_parent=excluded.is_primary_parent,
|
||||
"order"=excluded."order", label=excluded.label, tags_json=excluded.tags_json, note=excluded.note,
|
||||
weight=excluded.weight, line_style=excluded.line_style, direction=excluded.direction,
|
||||
version=excluded.version, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at`,
|
||||
);
|
||||
for (const e of g.edges.values()) {
|
||||
upsertEdge.run(
|
||||
e.id,
|
||||
e.canvasId,
|
||||
e.from,
|
||||
e.to,
|
||||
e.kind,
|
||||
e.isPrimaryParent ? 1 : 0,
|
||||
e.order,
|
||||
e.label ?? null,
|
||||
JSON.stringify(e.tags ?? []),
|
||||
e.note ?? null,
|
||||
e.weight ?? null,
|
||||
e.lineStyle ?? null,
|
||||
e.direction ?? null,
|
||||
e.version,
|
||||
e.createdAt,
|
||||
e.updatedAt,
|
||||
e.deletedAt ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
packages/graph-store/tsconfig.json
Normal file
11
packages/graph-store/tsconfig.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"references": [{ "path": "../shared" }, { "path": "../graph-core" }]
|
||||
}
|
||||
1
packages/graph-store/tsconfig.tsbuildinfo
Normal file
1
packages/graph-store/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
28
packages/layout-engine/package.json
Normal file
28
packages/layout-engine/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "@mind-map/layout-engine",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mind-map/shared": "workspace:*",
|
||||
"d3-flextree": "^2.1.2",
|
||||
"d3-hierarchy": "^3.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-hierarchy": "^3.1.7",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.9"
|
||||
}
|
||||
}
|
||||
19
packages/layout-engine/src/d3-flextree.d.ts
vendored
Normal file
19
packages/layout-engine/src/d3-flextree.d.ts
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
declare module "d3-flextree" {
|
||||
type Accessor<T, R> = ((node: T) => R) | R;
|
||||
|
||||
interface FlexLayout {
|
||||
(root: unknown): unknown;
|
||||
nodeSize(arg: Accessor<{ data: unknown }, [number, number]>): FlexLayout;
|
||||
spacing(arg: Accessor<unknown, number> | ((a: unknown, b: unknown) => number)): FlexLayout;
|
||||
children(arg: (d: unknown) => unknown[] | null | undefined): FlexLayout;
|
||||
hierarchy(data: unknown, children?: (d: unknown) => unknown[] | null | undefined): {
|
||||
each: (fn: (d: { depth: number; x: number; y: number; data: unknown }) => void) => void;
|
||||
x: number;
|
||||
y: number;
|
||||
depth: number;
|
||||
data: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function flextree(options?: Record<string, unknown>): FlexLayout;
|
||||
}
|
||||
236
packages/layout-engine/src/index.test.ts
Normal file
236
packages/layout-engine/src/index.test.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { hierarchyFingerprint, layout, nodeLayoutSize } from "./index.js";
|
||||
import type { MindEdge, MindNode } from "@mind-map/shared";
|
||||
|
||||
function node(id: string, text: string, sidePref: -1 | 0 | 1 = 0): MindNode {
|
||||
return {
|
||||
id,
|
||||
canvasId: "c",
|
||||
text,
|
||||
collapsed: false,
|
||||
pinned: false,
|
||||
sidePref,
|
||||
stylePreset: "default",
|
||||
version: 1,
|
||||
tags: [],
|
||||
links: [],
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function edge(from: string, to: string, order = 0): MindEdge {
|
||||
return {
|
||||
id: `e_${from}_${to}`,
|
||||
canvasId: "c",
|
||||
from,
|
||||
to,
|
||||
kind: "hierarchy",
|
||||
isPrimaryParent: true,
|
||||
order,
|
||||
tags: [],
|
||||
version: 1,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe("layout-engine", () => {
|
||||
it("places focus at origin and children left/right", () => {
|
||||
const nodes = [
|
||||
node("root", "R"),
|
||||
node("a", "A", -1),
|
||||
node("b", "B", 1),
|
||||
];
|
||||
const edges = [edge("root", "a", 0), edge("root", "b", 1)];
|
||||
const pos = layout({ nodes, edges, focusNodeId: "root" });
|
||||
expect(pos.root).toEqual({ x: 0, y: 0 });
|
||||
expect(pos.a!.x).toBeLessThan(0);
|
||||
expect(pos.b!.x).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("nested left subtree stays on negative x", () => {
|
||||
const nodes = [
|
||||
node("root", "R"),
|
||||
node("a", "A", -1),
|
||||
node("a1", "A1", 0),
|
||||
node("b", "B", 1),
|
||||
];
|
||||
const edges = [
|
||||
edge("root", "a", 0),
|
||||
edge("a", "a1", 0),
|
||||
edge("root", "b", 1),
|
||||
];
|
||||
const pos = layout({ nodes, edges, focusNodeId: "root" });
|
||||
expect(pos.a!.x).toBeLessThan(0);
|
||||
expect(pos.a1!.x).toBeLessThan(pos.a!.x);
|
||||
expect(pos.b!.x).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("respects pinned positions", () => {
|
||||
const n = node("a", "A", 1);
|
||||
n.pinned = true;
|
||||
n.pos = { x: 999, y: -50 };
|
||||
const nodes = [node("root", "R"), n];
|
||||
const edges = [edge("root", "a", 0)];
|
||||
const pos = layout({ nodes, edges, focusNodeId: "root" });
|
||||
expect(pos.a).toEqual({ x: 999, y: -50 });
|
||||
});
|
||||
|
||||
it("re-roots on focus so ancestors are not stacked as orphans", () => {
|
||||
const nodes = [
|
||||
node("root", "R"),
|
||||
node("a", "A", -1),
|
||||
node("a1", "A1"),
|
||||
node("b", "B", 1),
|
||||
];
|
||||
const edges = [
|
||||
edge("root", "a", 0),
|
||||
edge("a", "a1", 0),
|
||||
edge("root", "b", 1),
|
||||
];
|
||||
const pos = layout({ nodes, edges, focusNodeId: "a1" });
|
||||
expect(pos.a1).toEqual({ x: 0, y: 0 });
|
||||
// former parent `a` becomes outward neighbor — not at x=0 tower
|
||||
expect(pos.a).toBeTruthy();
|
||||
expect(Math.abs(pos.a!.x)).toBeGreaterThan(50);
|
||||
// siblings of the path should also be placed off-center
|
||||
expect(pos.root).toBeTruthy();
|
||||
expect(pos.b).toBeTruthy();
|
||||
expect(Math.abs(pos.b!.x) + Math.abs(pos.b!.y)).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("does not overlap sibling boxes after separation", () => {
|
||||
const kids = Array.from({ length: 10 }, (_, i) => node(`k${i}`, `很长的标题节点${i}`));
|
||||
const nodes = [node("root", "中心"), ...kids];
|
||||
const edges = kids.map((k, i) => edge("root", k.id, i));
|
||||
const pos = layout({ nodes, edges, focusNodeId: "root" });
|
||||
const box = { w: 200, h: 70 };
|
||||
const pad = 12;
|
||||
for (let i = 0; i < kids.length; i++) {
|
||||
for (let j = i + 1; j < kids.length; j++) {
|
||||
const a = pos[kids[i]!.id]!;
|
||||
const b = pos[kids[j]!.id]!;
|
||||
const ox = box.w + pad - Math.abs(b.x - a.x);
|
||||
const oy = box.h + pad - Math.abs(b.y - a.y);
|
||||
expect(ox <= 0 || oy <= 0).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("hides primary descendants of collapsed nodes and re-packs siblings", () => {
|
||||
const branch = node("core", "核心矛盾", -1);
|
||||
branch.collapsed = true;
|
||||
const nodes = [
|
||||
node("root", "雷雨"),
|
||||
branch,
|
||||
node("c1", "周萍↔繁漪"),
|
||||
node("c2", "矿工劳资矛盾"),
|
||||
node("era", "时代背景", 1),
|
||||
node("e1", "半封建半殖民地"),
|
||||
];
|
||||
const edges = [
|
||||
edge("root", "core", 0),
|
||||
edge("core", "c1", 0),
|
||||
edge("core", "c2", 1),
|
||||
edge("root", "era", 1),
|
||||
edge("era", "e1", 0),
|
||||
];
|
||||
const pos = layout({ nodes, edges, focusNodeId: "root" });
|
||||
expect(pos.core).toBeTruthy();
|
||||
expect(pos.c1).toBeUndefined();
|
||||
expect(pos.c2).toBeUndefined();
|
||||
expect(pos.era).toBeTruthy();
|
||||
expect(pos.e1).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides hierarchy subtree even without isPrimaryParent", () => {
|
||||
const branch = node("theme", "余华主题", -1);
|
||||
branch.collapsed = true;
|
||||
const child = node("c", "苦难");
|
||||
const nodes = [node("root", "活着"), branch, child];
|
||||
const edges: MindEdge[] = [
|
||||
{
|
||||
...edge("root", "theme", 0),
|
||||
isPrimaryParent: true,
|
||||
},
|
||||
{
|
||||
...edge("theme", "c", 0),
|
||||
isPrimaryParent: false,
|
||||
},
|
||||
];
|
||||
const pos = layout({ nodes, edges, focusNodeId: "root" });
|
||||
expect(pos.theme).toBeTruthy();
|
||||
expect(pos.c).toBeUndefined();
|
||||
});
|
||||
|
||||
it("pin-only with prevPositions keeps unrelated nodes stable", () => {
|
||||
const nodes = [
|
||||
node("root", "R"),
|
||||
node("a", "A", -1),
|
||||
node("a1", "A1"),
|
||||
node("b", "B", 1),
|
||||
];
|
||||
const edges = [
|
||||
edge("root", "a", 0),
|
||||
edge("a", "a1", 0),
|
||||
edge("root", "b", 1),
|
||||
];
|
||||
const first = layout({ nodes, edges, focusNodeId: "root" });
|
||||
const fp = hierarchyFingerprint(edges);
|
||||
const pinned = nodes.map((n) =>
|
||||
n.id === "a"
|
||||
? { ...n, pinned: true, pos: { x: -400, y: 120 } }
|
||||
: n,
|
||||
);
|
||||
const second = layout({
|
||||
nodes: pinned,
|
||||
edges,
|
||||
focusNodeId: "root",
|
||||
prevPositions: first,
|
||||
prevHierarchyFp: fp,
|
||||
});
|
||||
expect(second.a).toEqual({ x: -400, y: 120 });
|
||||
// sibling branch barely moves
|
||||
expect(Math.abs((second.b?.x ?? 0) - (first.b?.x ?? 0))).toBeLessThan(30);
|
||||
expect(Math.abs((second.b?.y ?? 0) - (first.b?.y ?? 0))).toBeLessThan(30);
|
||||
// single-node pin: child stays at previous layout pos (not dragged with parent)
|
||||
expect(second.a1!.x).toBeCloseTo(first.a1!.x, 0);
|
||||
expect(second.a1!.y).toBeCloseTo(first.a1!.y, 0);
|
||||
});
|
||||
|
||||
it("card preset height grows with visible fields", () => {
|
||||
const dens = { gapX: 72, gapY: 36, nodeW: 200, nodeH: 56, pad: 20 };
|
||||
const bare = nodeLayoutSize(
|
||||
{
|
||||
stylePreset: "default",
|
||||
imageUrl: undefined,
|
||||
tags: [],
|
||||
links: [],
|
||||
note: undefined,
|
||||
dueAt: undefined,
|
||||
startAt: undefined,
|
||||
pinned: false,
|
||||
},
|
||||
dens,
|
||||
{ asCard: true },
|
||||
);
|
||||
const rich = nodeLayoutSize(
|
||||
{
|
||||
stylePreset: "default",
|
||||
imageUrl: "https://example.com/a.jpg",
|
||||
tags: ["a"],
|
||||
links: [{ url: "https://ex.com" }],
|
||||
note: "hello",
|
||||
dueAt: 1,
|
||||
startAt: undefined,
|
||||
pinned: true,
|
||||
},
|
||||
dens,
|
||||
{ asCard: true },
|
||||
);
|
||||
expect(bare.w).toBe(288);
|
||||
expect(rich.w).toBe(288);
|
||||
expect(rich.h).toBeGreaterThan(bare.h);
|
||||
});
|
||||
});
|
||||
579
packages/layout-engine/src/index.ts
Normal file
579
packages/layout-engine/src/index.ts
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
import { flextree } from "d3-flextree";
|
||||
import type {
|
||||
Density,
|
||||
MindEdge,
|
||||
MindNode,
|
||||
NodeViewMode,
|
||||
StylePreset,
|
||||
} from "@mind-map/shared";
|
||||
|
||||
export type LayoutInput = {
|
||||
nodes: MindNode[];
|
||||
edges: MindEdge[];
|
||||
focusNodeId: string;
|
||||
density?: Density;
|
||||
/** Canvas-wide bubble vs card chrome (affects box sizes). */
|
||||
nodeViewMode?: NodeViewMode;
|
||||
/** Previous frame positions — reused when hierarchy structure is unchanged. */
|
||||
prevPositions?: PositionMap;
|
||||
/** Fingerprint of edges used to produce prevPositions. */
|
||||
prevHierarchyFp?: string;
|
||||
/** Force full flextree (e.g. after move_node / create / delete). */
|
||||
structureChanged?: boolean;
|
||||
};
|
||||
|
||||
export type PositionMap = Record<string, { x: number; y: number }>;
|
||||
|
||||
type Dens = {
|
||||
gapX: number;
|
||||
gapY: number;
|
||||
nodeW: number;
|
||||
nodeH: number;
|
||||
pad: number;
|
||||
};
|
||||
|
||||
const DENSITY: Record<Density, Dens> = {
|
||||
comfortable: { gapX: 80, gapY: 40, nodeW: 212, nodeH: 60, pad: 20 },
|
||||
compact: { gapX: 52, gapY: 28, nodeW: 168, nodeH: 48, pad: 12 },
|
||||
};
|
||||
|
||||
/** Actual layout box — must match canvas hit/draw sizing intent. */
|
||||
export function nodeLayoutSize(
|
||||
node: Pick<
|
||||
MindNode,
|
||||
| "stylePreset"
|
||||
| "imageUrl"
|
||||
| "tags"
|
||||
| "links"
|
||||
| "note"
|
||||
| "dueAt"
|
||||
| "startAt"
|
||||
| "pinned"
|
||||
>,
|
||||
dens: Dens,
|
||||
opts?: { asCard?: boolean },
|
||||
): { w: number; h: number } {
|
||||
let w = dens.nodeW;
|
||||
let h = dens.nodeH;
|
||||
// meta strip (badges / tags / due)
|
||||
const hasMeta =
|
||||
Boolean(node.tags?.length) ||
|
||||
Boolean(node.links?.length) ||
|
||||
Boolean(node.dueAt) ||
|
||||
Boolean(node.note);
|
||||
if (hasMeta) h += dens.nodeH * 0.4;
|
||||
else h += dens.nodeH * 0.25;
|
||||
|
||||
const preset: StylePreset = node.stylePreset ?? "default";
|
||||
const asCard = Boolean(opts?.asCard) || preset === "card";
|
||||
if (asCard) {
|
||||
// Keep in sync with apps/web/src/node-props/sizing.ts
|
||||
const w = dens.nodeW >= 200 ? 288 : 248;
|
||||
const gap = 10;
|
||||
let body = 0;
|
||||
if (node.note) body += 32 + gap;
|
||||
if (node.imageUrl) body += 68 + gap;
|
||||
if (node.tags?.length) body += 32 + gap;
|
||||
if (node.links?.length) body += 32 + gap;
|
||||
if (node.startAt != null) body += 32 + gap;
|
||||
if (node.dueAt != null) body += 32 + gap;
|
||||
if (node.pinned) body += 32 + gap;
|
||||
if (body > 0) body -= gap;
|
||||
const h = 36 + 24 + Math.max(body, 12);
|
||||
return { w, h: Math.max(h, dens.nodeW >= 200 ? 100 : 84) };
|
||||
}
|
||||
if (preset === "title") {
|
||||
w *= 1.2;
|
||||
h *= 1.2;
|
||||
} else if (preset === "decision") {
|
||||
w *= 1.1;
|
||||
h *= 1.15;
|
||||
} else if (preset === "note") {
|
||||
h *= 1.15;
|
||||
}
|
||||
if (node.imageUrl) {
|
||||
w = Math.max(w, dens.nodeW * 1.2);
|
||||
h += dens.nodeH * 1.7;
|
||||
}
|
||||
return { w, h };
|
||||
}
|
||||
|
||||
type FlexDatum = {
|
||||
id: string;
|
||||
size: [number, number];
|
||||
children?: FlexDatum[];
|
||||
};
|
||||
|
||||
function buildAdj(edges: MindEdge[]): {
|
||||
hierarchy: Map<string, { other: string; primaryTo: boolean; order: number }[]>;
|
||||
relation: Map<string, string[]>;
|
||||
} {
|
||||
const hierarchy = new Map<string, { other: string; primaryTo: boolean; order: number }[]>();
|
||||
const relation = new Map<string, string[]>();
|
||||
const addH = (from: string, to: string, primaryTo: boolean, order: number) => {
|
||||
const list = hierarchy.get(from) ?? [];
|
||||
list.push({ other: to, primaryTo, order });
|
||||
hierarchy.set(from, list);
|
||||
};
|
||||
const addR = (a: string, b: string) => {
|
||||
const la = relation.get(a) ?? [];
|
||||
la.push(b);
|
||||
relation.set(a, la);
|
||||
};
|
||||
for (const e of edges) {
|
||||
if (e.deletedAt) continue;
|
||||
if (e.kind === "hierarchy") {
|
||||
addH(e.from, e.to, e.isPrimaryParent, e.order);
|
||||
addH(e.to, e.from, false, e.order);
|
||||
} else if (e.kind === "relation") {
|
||||
addR(e.from, e.to);
|
||||
addR(e.to, e.from);
|
||||
}
|
||||
}
|
||||
for (const [, list] of hierarchy) {
|
||||
list.sort((a, b) => {
|
||||
if (a.primaryTo !== b.primaryTo) return a.primaryTo ? -1 : 1;
|
||||
return a.order - b.order;
|
||||
});
|
||||
}
|
||||
return { hierarchy, relation };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hierarchy subtree under any collapsed node — must not be laid out or rendered.
|
||||
* Walks all hierarchy edges (not only primary-parent), so AI-built trees still collapse.
|
||||
* The collapsed node itself stays visible.
|
||||
*/
|
||||
export function nodesHiddenByCollapse(
|
||||
nodes: MindNode[],
|
||||
edges: MindEdge[],
|
||||
): Set<string> {
|
||||
const children = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
if (e.deletedAt || e.kind !== "hierarchy") continue;
|
||||
const list = children.get(e.from) ?? [];
|
||||
list.push(e.to);
|
||||
children.set(e.from, list);
|
||||
}
|
||||
|
||||
const hidden = new Set<string>();
|
||||
const queue: string[] = [];
|
||||
for (const n of nodes) {
|
||||
if (n.deletedAt || !n.collapsed) continue;
|
||||
for (const c of children.get(n.id) ?? []) queue.push(c);
|
||||
}
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!;
|
||||
if (hidden.has(id)) continue;
|
||||
hidden.add(id);
|
||||
for (const c of children.get(id) ?? []) queue.push(c);
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
function spanningChildren(
|
||||
focusId: string,
|
||||
adj: ReturnType<typeof buildAdj>,
|
||||
nodesById: Map<string, MindNode>,
|
||||
hidden: Set<string>,
|
||||
): Map<string, string[]> {
|
||||
const children = new Map<string, string[]>();
|
||||
const seen = new Set<string>([focusId]);
|
||||
const queue = [focusId];
|
||||
|
||||
const tryEnqueue = (other: string, kids: string[]) => {
|
||||
if (seen.has(other) || hidden.has(other)) return;
|
||||
const node = nodesById.get(other);
|
||||
if (!node || node.deletedAt) return;
|
||||
seen.add(other);
|
||||
kids.push(other);
|
||||
queue.push(other);
|
||||
};
|
||||
|
||||
while (queue.length) {
|
||||
const cur = queue.shift()!;
|
||||
const curNode = nodesById.get(cur);
|
||||
// Collapsed: keep the node, do not expand its neighborhood into the tree.
|
||||
if (curNode?.collapsed) continue;
|
||||
const kids: string[] = [];
|
||||
for (const n of adj.hierarchy.get(cur) ?? []) tryEnqueue(n.other, kids);
|
||||
for (const other of adj.relation.get(cur) ?? []) tryEnqueue(other, kids);
|
||||
if (kids.length) children.set(cur, kids);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function assignSides(
|
||||
focusId: string,
|
||||
nodesById: Map<string, MindNode>,
|
||||
children: Map<string, string[]>,
|
||||
): Map<string, -1 | 1> {
|
||||
const sideOf = new Map<string, -1 | 1>();
|
||||
let left = 0;
|
||||
let right = 0;
|
||||
for (const id of children.get(focusId) ?? []) {
|
||||
const node = nodesById.get(id);
|
||||
if (!node || node.deletedAt) continue;
|
||||
let side: -1 | 1;
|
||||
if (node.sidePref === -1) side = -1;
|
||||
else if (node.sidePref === 1) side = 1;
|
||||
else side = left <= right ? -1 : 1;
|
||||
sideOf.set(id, side);
|
||||
if (side === -1) left++;
|
||||
else right++;
|
||||
}
|
||||
return sideOf;
|
||||
}
|
||||
|
||||
function buildSubtree(
|
||||
id: string,
|
||||
nodesById: Map<string, MindNode>,
|
||||
children: Map<string, string[]>,
|
||||
dens: Dens,
|
||||
path: Set<string>,
|
||||
asCard: boolean,
|
||||
childFilter?: (childId: string) => boolean,
|
||||
): FlexDatum | null {
|
||||
if (path.has(id)) return null;
|
||||
const node = nodesById.get(id);
|
||||
if (!node || node.deletedAt) return null;
|
||||
const nextPath = new Set(path);
|
||||
nextPath.add(id);
|
||||
const kids: FlexDatum[] = [];
|
||||
if (!node.collapsed) {
|
||||
for (const cid of children.get(id) ?? []) {
|
||||
if (childFilter && !childFilter(cid)) continue;
|
||||
const child = buildSubtree(
|
||||
cid,
|
||||
nodesById,
|
||||
children,
|
||||
dens,
|
||||
nextPath,
|
||||
asCard,
|
||||
);
|
||||
if (child) kids.push(child);
|
||||
}
|
||||
}
|
||||
const box = nodeLayoutSize(node, dens, { asCard });
|
||||
return {
|
||||
id,
|
||||
// flextree size = full box + gutters (claim real space, not a rigid grid cell)
|
||||
size: [box.w + dens.gapX, box.h + dens.gapY],
|
||||
children: kids.length ? kids : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* flextree: y = depth, x = sibling.
|
||||
* Mind map: depth → ±X, sibling → Y.
|
||||
* nodeSize [xSize,ySize] = [sibling span ≈ height, depth step ≈ width].
|
||||
*/
|
||||
function layoutSideTree(
|
||||
tree: FlexDatum,
|
||||
dens: Dens,
|
||||
side: -1 | 1,
|
||||
positions: PositionMap,
|
||||
) {
|
||||
const lay = flextree()
|
||||
.children((d) => (d as FlexDatum).children ?? null)
|
||||
.nodeSize((d) => {
|
||||
const [boxW, boxH] = (d as { data: FlexDatum }).data.size;
|
||||
// data.size already includes gutters as [w+gapX, h+gapY]
|
||||
return [boxH, boxW];
|
||||
})
|
||||
.spacing(() => dens.gapY * 0.5);
|
||||
|
||||
const root = lay.hierarchy(tree);
|
||||
lay(root);
|
||||
|
||||
root.each((d) => {
|
||||
if (d.depth === 0) return;
|
||||
const data = d.data as FlexDatum;
|
||||
positions[data.id] = {
|
||||
x: side * d.y,
|
||||
y: d.x,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterative AABB separation: nodes push apart until boxes (+pad) no longer overlap.
|
||||
* Pinned / focus stay fixed; others move. This is soft — not a locked grid.
|
||||
*/
|
||||
function resolveOverlaps(
|
||||
positions: PositionMap,
|
||||
sizes: Map<string, { w: number; h: number }>,
|
||||
fixed: Set<string>,
|
||||
pad: number,
|
||||
iterations = 60,
|
||||
/** If set, only pairs involving at least one of these ids are separated. */
|
||||
active?: Set<string>,
|
||||
) {
|
||||
const ids = Object.keys(positions).filter((id) => sizes.has(id));
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
let moved = false;
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
const a = ids[i]!;
|
||||
const pa = positions[a]!;
|
||||
const sa = sizes.get(a)!;
|
||||
const aw = sa.w / 2 + pad;
|
||||
const ah = sa.h / 2 + pad;
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const b = ids[j]!;
|
||||
if (active && !active.has(a) && !active.has(b)) continue;
|
||||
const pb = positions[b]!;
|
||||
const sb = sizes.get(b)!;
|
||||
const bw = sb.w / 2 + pad;
|
||||
const bh = sb.h / 2 + pad;
|
||||
const dx = pb.x - pa.x;
|
||||
const dy = pb.y - pa.y;
|
||||
const ox = aw + bw - Math.abs(dx);
|
||||
const oy = ah + bh - Math.abs(dy);
|
||||
if (ox <= 0 || oy <= 0) continue;
|
||||
|
||||
// Push along the axis of least penetration
|
||||
const aFixed = fixed.has(a);
|
||||
const bFixed = fixed.has(b);
|
||||
if (aFixed && bFixed) continue;
|
||||
|
||||
if (ox < oy) {
|
||||
const push = (ox / 2) * (dx === 0 ? (a < b ? 1 : -1) : Math.sign(dx) || 1);
|
||||
if (!aFixed && !bFixed) {
|
||||
pa.x -= push;
|
||||
pb.x += push;
|
||||
} else if (aFixed) {
|
||||
pb.x += push * 2;
|
||||
} else {
|
||||
pa.x -= push * 2;
|
||||
}
|
||||
} else {
|
||||
const push = (oy / 2) * (dy === 0 ? (a < b ? 1 : -1) : Math.sign(dy) || 1);
|
||||
if (!aFixed && !bFixed) {
|
||||
pa.y -= push;
|
||||
pb.y += push;
|
||||
} else if (aFixed) {
|
||||
pb.y += push * 2;
|
||||
} else {
|
||||
pa.y -= push * 2;
|
||||
}
|
||||
}
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable fingerprint of hierarchy topology + sibling order (ignores relation edges). */
|
||||
export function hierarchyFingerprint(edges: MindEdge[]): string {
|
||||
const parts: string[] = [];
|
||||
for (const e of edges) {
|
||||
if (e.deletedAt || e.kind !== "hierarchy") continue;
|
||||
parts.push(
|
||||
`${e.from}>${e.to}:${e.isPrimaryParent ? 1 : 0}:${e.order}`,
|
||||
);
|
||||
}
|
||||
parts.sort();
|
||||
return parts.join("|");
|
||||
}
|
||||
|
||||
export function layout(input: LayoutInput): PositionMap {
|
||||
const dens = DENSITY[input.density ?? "comfortable"];
|
||||
const asCard = input.nodeViewMode === "card";
|
||||
const nodesById = new Map(input.nodes.map((n) => [n.id, n]));
|
||||
if (!nodesById.has(input.focusNodeId)) return {};
|
||||
|
||||
const hidden = nodesHiddenByCollapse(input.nodes, input.edges);
|
||||
// Focus inside a collapsed branch: still show the focus path (rare UI case).
|
||||
hidden.delete(input.focusNodeId);
|
||||
|
||||
const sizes = new Map<string, { w: number; h: number }>();
|
||||
for (const n of input.nodes) {
|
||||
if (n.deletedAt || hidden.has(n.id)) continue;
|
||||
sizes.set(n.id, nodeLayoutSize(n, dens, { asCard }));
|
||||
}
|
||||
|
||||
const prev = input.prevPositions;
|
||||
const fp = hierarchyFingerprint(input.edges);
|
||||
const structureUnchanged =
|
||||
!input.structureChanged &&
|
||||
Boolean(prev) &&
|
||||
Boolean(input.prevHierarchyFp) &&
|
||||
input.prevHierarchyFp === fp;
|
||||
|
||||
const canReusePrev =
|
||||
structureUnchanged &&
|
||||
input.nodes.some((n) => !n.deletedAt && !hidden.has(n.id) && prev![n.id]);
|
||||
|
||||
if (canReusePrev && prev) {
|
||||
const positions: PositionMap = {};
|
||||
for (const n of input.nodes) {
|
||||
if (n.deletedAt || hidden.has(n.id)) continue;
|
||||
const p = prev[n.id];
|
||||
if (p) positions[n.id] = { ...p };
|
||||
}
|
||||
|
||||
const fixed = new Set<string>();
|
||||
const active = new Set<string>();
|
||||
for (const n of input.nodes) {
|
||||
if (hidden.has(n.id) || n.deletedAt) continue;
|
||||
if (!n.pinned) continue;
|
||||
const pinPos = n.pos ?? prev[n.id];
|
||||
if (!pinPos) continue;
|
||||
const before = positions[n.id];
|
||||
positions[n.id] = { ...pinPos };
|
||||
fixed.add(n.id);
|
||||
if (
|
||||
!before ||
|
||||
Math.abs(before.x - pinPos.x) > 0.5 ||
|
||||
Math.abs(before.y - pinPos.y) > 0.5
|
||||
) {
|
||||
active.add(n.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Focus stays centered unless it is pinned (pinned never moves).
|
||||
const focus = nodesById.get(input.focusNodeId);
|
||||
if (!(focus?.pinned && positions[input.focusNodeId])) {
|
||||
positions[input.focusNodeId] = { x: 0, y: 0 };
|
||||
fixed.add(input.focusNodeId);
|
||||
} else {
|
||||
fixed.add(input.focusNodeId);
|
||||
}
|
||||
|
||||
// Place brand-new nodes (no prev) near focus, then local-separate only.
|
||||
const orphans = input.nodes.filter(
|
||||
(n) => !n.deletedAt && !hidden.has(n.id) && !positions[n.id],
|
||||
);
|
||||
orphans.forEach((n, i) => {
|
||||
const angle = (i / Math.max(1, orphans.length)) * Math.PI * 2;
|
||||
const r = dens.nodeW + dens.gapX * 2;
|
||||
positions[n.id] = {
|
||||
x: Math.cos(angle) * r,
|
||||
y: Math.sin(angle) * r,
|
||||
};
|
||||
active.add(n.id);
|
||||
});
|
||||
|
||||
if (active.size) {
|
||||
resolveOverlaps(positions, sizes, fixed, dens.pad, 24, active);
|
||||
if (!(focus?.pinned && positions[input.focusNodeId])) {
|
||||
positions[input.focusNodeId] = { x: 0, y: 0 };
|
||||
}
|
||||
resolveOverlaps(positions, sizes, fixed, dens.pad, 12, active);
|
||||
} else if (!(focus?.pinned && positions[input.focusNodeId])) {
|
||||
positions[input.focusNodeId] = { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
for (const id of hidden) delete positions[id];
|
||||
return positions;
|
||||
}
|
||||
|
||||
// Full flextree path (structure changed or first layout).
|
||||
const adj = buildAdj(input.edges);
|
||||
const children = spanningChildren(input.focusNodeId, adj, nodesById, hidden);
|
||||
const sideOf = assignSides(input.focusNodeId, nodesById, children);
|
||||
const positions: PositionMap = {
|
||||
[input.focusNodeId]: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
for (const side of [-1, 1] as const) {
|
||||
const tree = buildSubtree(
|
||||
input.focusNodeId,
|
||||
nodesById,
|
||||
children,
|
||||
dens,
|
||||
new Set(),
|
||||
asCard,
|
||||
(childId) => sideOf.get(childId) === side,
|
||||
);
|
||||
if (!tree?.children?.length) continue;
|
||||
layoutSideTree(tree, dens, side, positions);
|
||||
}
|
||||
|
||||
positions[input.focusNodeId] = { x: 0, y: 0 };
|
||||
|
||||
// Soft blend from prev — never override pinned nodes.
|
||||
if (prev) {
|
||||
for (const [id, p] of Object.entries(prev)) {
|
||||
if (hidden.has(id) || id === input.focusNodeId) continue;
|
||||
if (!positions[id]) continue;
|
||||
const node = nodesById.get(id);
|
||||
if (node?.pinned) continue;
|
||||
const sug = positions[id]!;
|
||||
const dist = Math.hypot(p.x - sug.x, p.y - sug.y);
|
||||
if (dist < dens.nodeW * 4) {
|
||||
positions[id] = { ...p };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fixed = new Set<string>();
|
||||
for (const n of input.nodes) {
|
||||
if (hidden.has(n.id) || n.deletedAt) continue;
|
||||
if (!n.pinned) continue;
|
||||
const pinPos = n.pos ?? prev?.[n.id] ?? positions[n.id];
|
||||
if (!pinPos) continue;
|
||||
positions[n.id] = { ...pinPos };
|
||||
fixed.add(n.id);
|
||||
}
|
||||
|
||||
const focusNode = nodesById.get(input.focusNodeId);
|
||||
if (!(focusNode?.pinned && positions[input.focusNodeId])) {
|
||||
positions[input.focusNodeId] = { x: 0, y: 0 };
|
||||
}
|
||||
fixed.add(input.focusNodeId);
|
||||
|
||||
// Disconnected visible nodes only — never re-seed collapsed descendants.
|
||||
const placed = new Set(Object.keys(positions));
|
||||
const orphans = input.nodes.filter(
|
||||
(n) => !n.deletedAt && !hidden.has(n.id) && !placed.has(n.id),
|
||||
);
|
||||
orphans.forEach((n, i) => {
|
||||
if (prev?.[n.id]) {
|
||||
positions[n.id] = { ...prev[n.id]! };
|
||||
return;
|
||||
}
|
||||
const angle = (i / Math.max(1, orphans.length)) * Math.PI * 2;
|
||||
const r = dens.nodeW + dens.gapX * 2 + Math.floor(i / 8) * dens.nodeW;
|
||||
positions[n.id] = {
|
||||
x: Math.cos(angle) * r,
|
||||
y: Math.sin(angle) * r,
|
||||
};
|
||||
});
|
||||
|
||||
resolveOverlaps(positions, sizes, fixed, dens.pad);
|
||||
|
||||
// Keep focus centered after pushes — but never yank a pinned focus off its pos.
|
||||
if (!(focusNode?.pinned && (focusNode.pos || prev?.[focusNode.id]))) {
|
||||
positions[input.focusNodeId] = { x: 0, y: 0 };
|
||||
}
|
||||
resolveOverlaps(positions, sizes, fixed, dens.pad, 20);
|
||||
|
||||
// Ensure collapsed descendants never leak into the position map.
|
||||
for (const id of hidden) delete positions[id];
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function layoutDiff(
|
||||
prev: PositionMap,
|
||||
next: PositionMap,
|
||||
): { id: string; x: number; y: number }[] {
|
||||
const out: { id: string; x: number; y: number }[] = [];
|
||||
for (const [id, p] of Object.entries(next)) {
|
||||
const o = prev[id];
|
||||
if (!o || o.x !== p.x || o.y !== p.y) out.push({ id, x: p.x, y: p.y });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function primaryParentMap(edges: MindEdge[]): Map<string, string> {
|
||||
const m = new Map<string, string>();
|
||||
for (const e of edges) {
|
||||
if (e.kind === "hierarchy" && e.isPrimaryParent && !e.deletedAt) {
|
||||
m.set(e.to, e.from);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
10
packages/layout-engine/tsconfig.json
Normal file
10
packages/layout-engine/tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"references": [{ "path": "../shared" }]
|
||||
}
|
||||
1
packages/layout-engine/tsconfig.tsbuildinfo
Normal file
1
packages/layout-engine/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
25
packages/shared/package.json
Normal file
25
packages/shared/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "@mind-map/shared",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.9"
|
||||
}
|
||||
}
|
||||
212
packages/shared/src/graph-query.test.ts
Normal file
212
packages/shared/src/graph-query.test.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
queryMissingMeta,
|
||||
queryNeighbors,
|
||||
queryOrphans,
|
||||
queryPaths,
|
||||
queryStats,
|
||||
resolveQueryNodeRef,
|
||||
type MindEdge,
|
||||
type MindNode,
|
||||
} from "./index.js";
|
||||
|
||||
function node(
|
||||
partial: Partial<MindNode> & { id: string; text: string },
|
||||
): MindNode {
|
||||
const t = 1;
|
||||
return {
|
||||
canvasId: "c1",
|
||||
collapsed: false,
|
||||
pinned: false,
|
||||
sidePref: 0,
|
||||
stylePreset: "default",
|
||||
version: 1,
|
||||
tags: [],
|
||||
links: [],
|
||||
createdAt: t,
|
||||
updatedAt: t,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function edge(
|
||||
partial: Partial<MindEdge> & {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
kind: "hierarchy" | "relation";
|
||||
},
|
||||
): MindEdge {
|
||||
const t = 1;
|
||||
return {
|
||||
canvasId: "c1",
|
||||
isPrimaryParent: partial.kind === "hierarchy",
|
||||
order: 0,
|
||||
tags: [],
|
||||
version: 1,
|
||||
createdAt: t,
|
||||
updatedAt: t,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("graph-query", () => {
|
||||
const nodes = [
|
||||
node({
|
||||
id: "a",
|
||||
text: "AIOps",
|
||||
alias: "aiops",
|
||||
description: { function: "根因诊断", sla: "30s" },
|
||||
}),
|
||||
node({ id: "b", text: "自动重启", alias: "restart" }),
|
||||
node({ id: "c", text: "工单", alias: "ticket" }),
|
||||
node({ id: "d", text: "孤立点" }),
|
||||
];
|
||||
const edges = [
|
||||
edge({
|
||||
id: "e1",
|
||||
from: "a",
|
||||
to: "b",
|
||||
kind: "relation",
|
||||
label: "高置信→重启",
|
||||
}),
|
||||
edge({
|
||||
id: "e2",
|
||||
from: "a",
|
||||
to: "c",
|
||||
kind: "relation",
|
||||
label: "低置信→工单",
|
||||
}),
|
||||
edge({ id: "e3", from: "c", to: "b", kind: "relation", label: "也可能重启" }),
|
||||
];
|
||||
|
||||
it("neighbors out depth 1", () => {
|
||||
const r = queryNeighbors({
|
||||
nodes,
|
||||
edges,
|
||||
nodeId: "a",
|
||||
depth: 1,
|
||||
direction: "out",
|
||||
});
|
||||
expect(r.citedNodeIds.sort()).toEqual(["a", "b", "c"]);
|
||||
expect(r.nodes.find((n) => n.id === "a")?.function).toBe("根因诊断");
|
||||
});
|
||||
|
||||
it("paths finds both branches", () => {
|
||||
const r = queryPaths({
|
||||
nodes,
|
||||
edges,
|
||||
fromId: "a",
|
||||
toId: "b",
|
||||
direction: "out",
|
||||
maxPaths: 5,
|
||||
});
|
||||
expect(r.paths.length).toBe(2);
|
||||
const texts = r.paths.map((p) => p.texts.join("→")).sort();
|
||||
expect(texts).toContain("AIOps→自动重启");
|
||||
expect(texts).toContain("AIOps→工单→自动重启");
|
||||
expect(r.paths.find((p) => p.hops === 2)?.hops).toBe(2);
|
||||
const direct = r.paths.find((p) => p.hops === 1)!;
|
||||
expect(direct.edges[0]?.id).toBe("e1");
|
||||
expect(direct.edges[0]?.label).toBe("高置信→重启");
|
||||
expect(direct.edgeLabels[0]).toBe("高置信→重启");
|
||||
expect(r.truncated).toBe(false);
|
||||
expect(r.pathsReturned).toBe(2);
|
||||
expect(r.totalPathsFound).toBe(2);
|
||||
expect(r.omittedPaths).toBe(0);
|
||||
expect(r.maxPaths).toBe(5);
|
||||
});
|
||||
|
||||
it("path truncation reports omitted count", () => {
|
||||
const r = queryPaths({
|
||||
nodes,
|
||||
edges,
|
||||
fromId: "a",
|
||||
toId: "b",
|
||||
direction: "out",
|
||||
maxPaths: 1,
|
||||
});
|
||||
expect(r.pathsReturned).toBe(1);
|
||||
expect(r.truncated).toBe(true);
|
||||
expect(r.totalPathsFound).toBeGreaterThan(1);
|
||||
expect(r.omittedPaths).toBe(r.totalPathsFound - 1);
|
||||
expect(r.maxPaths).toBe(1);
|
||||
});
|
||||
|
||||
it("multi-hop through intermediate relation node (BFS)", () => {
|
||||
const n = [
|
||||
node({ id: "detect", text: "检测", alias: "detect" }),
|
||||
node({ id: "trig", text: "诊断触发", alias: "diag_trigger" }),
|
||||
node({ id: "aiops", text: "AIOps", alias: "aiops" }),
|
||||
node({ id: "kb", text: "知识入库", alias: "knowledge_write" }),
|
||||
];
|
||||
const e = [
|
||||
edge({ id: "1", from: "detect", to: "trig", kind: "relation", label: "触发" }),
|
||||
edge({ id: "2", from: "trig", to: "aiops", kind: "relation", label: "进入诊断" }),
|
||||
edge({ id: "3", from: "aiops", to: "kb", kind: "relation", label: "沉淀" }),
|
||||
];
|
||||
const r = queryPaths({
|
||||
nodes: n,
|
||||
edges: e,
|
||||
fromId: "detect",
|
||||
toId: "kb",
|
||||
throughRelationNodes: true,
|
||||
maxHops: 5,
|
||||
direction: "out",
|
||||
});
|
||||
expect(r.paths.length).toBe(1);
|
||||
expect(r.paths[0]!.texts).toEqual([
|
||||
"检测",
|
||||
"诊断触发",
|
||||
"AIOps",
|
||||
"知识入库",
|
||||
]);
|
||||
expect(r.paths[0]!.hops).toBe(3);
|
||||
expect(r.paths[0]!.edges.map((x) => x.id)).toEqual(["1", "2", "3"]);
|
||||
});
|
||||
|
||||
it("throughRelationNodes false forces 1 hop", () => {
|
||||
const r = queryPaths({
|
||||
nodes,
|
||||
edges,
|
||||
fromId: "a",
|
||||
toId: "b",
|
||||
throughRelationNodes: false,
|
||||
direction: "out",
|
||||
});
|
||||
expect(r.maxHops).toBe(1);
|
||||
expect(r.paths.every((p) => p.hops === 1)).toBe(true);
|
||||
expect(r.paths.map((p) => p.texts.join("→"))).toEqual(["AIOps→自动重启"]);
|
||||
});
|
||||
|
||||
it("stats and orphans / missing meta", () => {
|
||||
const s = queryStats({ nodes, edges });
|
||||
expect(s.nodeCount).toBe(4);
|
||||
expect(s.orphanNodes).toBe(1);
|
||||
// empty description → withoutDescription; withoutFunction only if description present
|
||||
expect(s.nodesWithoutDescription).toBe(3);
|
||||
expect(s.nodesWithoutFunction).toBe(0);
|
||||
expect(s.connectedComponents.count).toBe(s.islands);
|
||||
expect(s.orphanDef).toMatch(/full graph/i);
|
||||
expect(s.nodesWithoutRelation).toBeUndefined();
|
||||
expect(s.mostConnected?.nodeId).toBe("a");
|
||||
expect(s.mostConnected?.degree).toBe(2);
|
||||
expect(s.longestPath?.hops).toBeGreaterThanOrEqual(1);
|
||||
const rel = queryStats({ nodes, edges, edgeKinds: "relation" });
|
||||
expect(rel.longestPath?.edgeKinds).toBe("relation");
|
||||
expect(rel.nodesWithoutRelation).toBe(rel.orphanNodes);
|
||||
expect(rel.orphanDef).toMatch(/relation/i);
|
||||
const o = queryOrphans({ nodes, edges });
|
||||
expect(o.citedNodeIds).toEqual(["d"]);
|
||||
const m = queryMissingMeta({ nodes, edges });
|
||||
expect(m.nodesWithoutDescription.some((n) => n.id === "b")).toBe(true);
|
||||
expect(m.nodesWithoutFunction.length).toBe(0);
|
||||
});
|
||||
|
||||
it("resolveQueryNodeRef alias and title", () => {
|
||||
expect(resolveQueryNodeRef(nodes, "@aiops").ok).toBe(true);
|
||||
expect(resolveQueryNodeRef(nodes, "自动重启").ok).toBe(true);
|
||||
const miss = resolveQueryNodeRef(nodes, "不存在");
|
||||
expect(miss.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
679
packages/shared/src/graph-query.ts
Normal file
679
packages/shared/src/graph-query.ts
Normal file
|
|
@ -0,0 +1,679 @@
|
|||
import type { MindEdge, MindNode } from "./types.js";
|
||||
|
||||
export type QueryEdgeKind = "hierarchy" | "relation" | "all";
|
||||
export type QueryDirection = "out" | "in" | "both";
|
||||
|
||||
export type CompactNode = {
|
||||
id: string;
|
||||
text: string;
|
||||
alias?: string;
|
||||
/** description.function or description.summary — token-cheap for QA. */
|
||||
function?: string;
|
||||
};
|
||||
|
||||
export type CompactEdge = {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
fromText: string;
|
||||
toText: string;
|
||||
kind: MindEdge["kind"];
|
||||
label?: string;
|
||||
direction?: MindEdge["direction"];
|
||||
weight?: number;
|
||||
lineStyle?: MindEdge["lineStyle"];
|
||||
note?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
/** Path-local edge payload (keeps edgeLabels for backward compat). */
|
||||
export type PathEdge = {
|
||||
id: string;
|
||||
label?: string | null;
|
||||
direction?: MindEdge["direction"];
|
||||
weight?: number;
|
||||
lineStyle?: MindEdge["lineStyle"];
|
||||
note?: string;
|
||||
};
|
||||
|
||||
/** Prefer description.function, then summary. */
|
||||
export function nodeFunction(n: MindNode): string | undefined {
|
||||
const d = n.description;
|
||||
if (!d || typeof d !== "object") return undefined;
|
||||
if (typeof d.function === "string" && d.function.trim()) return d.function.trim();
|
||||
if (typeof d.summary === "string" && d.summary.trim()) return d.summary.trim();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function toCompactNode(n: MindNode): CompactNode {
|
||||
const fn = nodeFunction(n);
|
||||
return {
|
||||
id: n.id,
|
||||
text: n.text,
|
||||
...(n.alias ? { alias: n.alias } : {}),
|
||||
...(fn ? { function: fn } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function textOf(byId: Map<string, MindNode>, id: string): string {
|
||||
return byId.get(id)?.text ?? id;
|
||||
}
|
||||
|
||||
export function toCompactEdge(
|
||||
e: MindEdge,
|
||||
byId: Map<string, MindNode>,
|
||||
): CompactEdge {
|
||||
return {
|
||||
id: e.id,
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
fromText: textOf(byId, e.from),
|
||||
toText: textOf(byId, e.to),
|
||||
kind: e.kind,
|
||||
...(e.label ? { label: e.label } : {}),
|
||||
...(e.direction ? { direction: e.direction } : {}),
|
||||
...(e.weight != null ? { weight: e.weight } : {}),
|
||||
...(e.lineStyle ? { lineStyle: e.lineStyle } : {}),
|
||||
...(e.note ? { note: e.note } : {}),
|
||||
...(e.tags?.length ? { tags: e.tags } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function toPathEdge(e: MindEdge): PathEdge {
|
||||
return {
|
||||
id: e.id,
|
||||
label: e.label ?? null,
|
||||
...(e.direction ? { direction: e.direction } : {}),
|
||||
...(e.weight != null ? { weight: e.weight } : {}),
|
||||
...(e.lineStyle ? { lineStyle: e.lineStyle } : {}),
|
||||
...(e.note ? { note: e.note } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function filterEdges(
|
||||
edges: MindEdge[],
|
||||
edgeKinds: QueryEdgeKind = "all",
|
||||
): MindEdge[] {
|
||||
if (edgeKinds === "all") return edges.filter((e) => !e.deletedAt);
|
||||
return edges.filter((e) => !e.deletedAt && e.kind === edgeKinds);
|
||||
}
|
||||
|
||||
function buildAdj(
|
||||
edges: MindEdge[],
|
||||
direction: QueryDirection,
|
||||
): Map<string, { to: string; edge: MindEdge }[]> {
|
||||
const adj = new Map<string, { to: string; edge: MindEdge }[]>();
|
||||
const add = (from: string, to: string, edge: MindEdge) => {
|
||||
const list = adj.get(from) ?? [];
|
||||
list.push({ to, edge });
|
||||
adj.set(from, list);
|
||||
};
|
||||
for (const e of edges) {
|
||||
if (direction === "out" || direction === "both") add(e.from, e.to, e);
|
||||
if (direction === "in" || direction === "both") add(e.to, e.from, e);
|
||||
}
|
||||
return adj;
|
||||
}
|
||||
|
||||
export function queryNeighbors(input: {
|
||||
nodes: MindNode[];
|
||||
edges: MindEdge[];
|
||||
nodeId: string;
|
||||
depth?: number;
|
||||
direction?: QueryDirection;
|
||||
edgeKinds?: QueryEdgeKind;
|
||||
}): {
|
||||
rootId: string;
|
||||
nodes: CompactNode[];
|
||||
edges: CompactEdge[];
|
||||
citedNodeIds: string[];
|
||||
} {
|
||||
const depth = Math.max(1, Math.min(6, input.depth ?? 1));
|
||||
const direction = input.direction ?? "both";
|
||||
const byId = new Map(input.nodes.map((n) => [n.id, n]));
|
||||
const live = filterEdges(input.edges, input.edgeKinds ?? "all");
|
||||
const adj = buildAdj(live, direction);
|
||||
|
||||
const keep = new Set<string>([input.nodeId]);
|
||||
const usedEdges = new Set<string>();
|
||||
let frontier = [input.nodeId];
|
||||
for (let d = 0; d < depth; d++) {
|
||||
const next: string[] = [];
|
||||
for (const id of frontier) {
|
||||
for (const { to, edge } of adj.get(id) ?? []) {
|
||||
usedEdges.add(edge.id);
|
||||
if (!keep.has(to)) {
|
||||
keep.add(to);
|
||||
next.push(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
|
||||
const nodes = [...keep]
|
||||
.map((id) => byId.get(id))
|
||||
.filter((n): n is MindNode => n != null && !n.deletedAt)
|
||||
.map(toCompactNode);
|
||||
const edges = live
|
||||
.filter((e) => usedEdges.has(e.id) && keep.has(e.from) && keep.has(e.to))
|
||||
.map((e) => toCompactEdge(e, byId));
|
||||
|
||||
return {
|
||||
rootId: input.nodeId,
|
||||
nodes,
|
||||
edges,
|
||||
citedNodeIds: [...keep],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* All simple paths from→to (directed by `direction`), capped by edge hops.
|
||||
* `maxHops` = number of edges (so node count along a path is hops+1).
|
||||
* Intermediate nodes are always allowed when maxHops > 1 (e.g. detect→diag_trigger→aiops).
|
||||
*/
|
||||
export function queryPaths(input: {
|
||||
nodes: MindNode[];
|
||||
edges: MindEdge[];
|
||||
fromId: string;
|
||||
toId: string;
|
||||
/** Max edges in a path (default 8). */
|
||||
maxHops?: number;
|
||||
/** @deprecated use maxHops — treated as maxHops when maxHops omitted. */
|
||||
maxDepth?: number;
|
||||
maxPaths?: number;
|
||||
direction?: QueryDirection;
|
||||
edgeKinds?: QueryEdgeKind;
|
||||
/**
|
||||
* When false, only direct edges (maxHops forced to 1).
|
||||
* When true (default), allow multi-hop via intermediate nodes.
|
||||
*/
|
||||
throughRelationNodes?: boolean;
|
||||
}): {
|
||||
paths: {
|
||||
nodeIds: string[];
|
||||
texts: string[];
|
||||
/** @deprecated prefer paths[].edges — kept for older clients */
|
||||
edgeLabels: (string | null)[];
|
||||
edges: PathEdge[];
|
||||
hops: number;
|
||||
}[];
|
||||
nodes: CompactNode[];
|
||||
edges: CompactEdge[];
|
||||
citedNodeIds: string[];
|
||||
truncated: boolean;
|
||||
/** Cap used while collecting paths (default 5, max 20). */
|
||||
maxPaths: number;
|
||||
/** How many paths are included in `paths`. */
|
||||
pathsReturned: number;
|
||||
/** How many simple paths were found before hop/budget stop (may equal pathsReturned). */
|
||||
totalPathsFound: number;
|
||||
/** totalPathsFound - pathsReturned when truncated. */
|
||||
omittedPaths: number;
|
||||
maxHops: number;
|
||||
throughRelationNodes: boolean;
|
||||
} {
|
||||
const throughRelationNodes = input.throughRelationNodes !== false;
|
||||
let maxHops = Math.max(
|
||||
1,
|
||||
Math.min(12, input.maxHops ?? input.maxDepth ?? 8),
|
||||
);
|
||||
if (!throughRelationNodes) maxHops = 1;
|
||||
const maxPaths = Math.max(1, Math.min(20, input.maxPaths ?? 5));
|
||||
const direction = input.direction ?? (throughRelationNodes ? "both" : "out");
|
||||
const byId = new Map(input.nodes.map((n) => [n.id, n]));
|
||||
const live = filterEdges(input.edges, input.edgeKinds ?? "all");
|
||||
const adj = buildAdj(live, direction);
|
||||
|
||||
const paths: {
|
||||
nodeIds: string[];
|
||||
texts: string[];
|
||||
edgeLabels: (string | null)[];
|
||||
edges: PathEdge[];
|
||||
hops: number;
|
||||
}[] = [];
|
||||
let totalPathsFound = 0;
|
||||
// Soft cap on enumeration so huge DAGs don't hang (still report totalPathsFound up to this).
|
||||
const enumerateCap = Math.max(maxPaths, Math.min(200, maxPaths * 20));
|
||||
|
||||
type Frame = {
|
||||
node: string;
|
||||
path: string[];
|
||||
pathEdges: MindEdge[];
|
||||
visited: Set<string>;
|
||||
};
|
||||
// BFS so shorter paths surface first
|
||||
const queue: Frame[] = [
|
||||
{
|
||||
node: input.fromId,
|
||||
path: [input.fromId],
|
||||
pathEdges: [],
|
||||
visited: new Set([input.fromId]),
|
||||
},
|
||||
];
|
||||
|
||||
while (queue.length) {
|
||||
const cur = queue.shift()!;
|
||||
const hops = cur.pathEdges.length;
|
||||
if (cur.node === input.toId && hops > 0) {
|
||||
totalPathsFound += 1;
|
||||
if (paths.length < maxPaths) {
|
||||
paths.push({
|
||||
nodeIds: cur.path,
|
||||
texts: cur.path.map((id) => textOf(byId, id)),
|
||||
edgeLabels: cur.pathEdges.map((e) => e.label ?? null),
|
||||
edges: cur.pathEdges.map(toPathEdge),
|
||||
hops,
|
||||
});
|
||||
}
|
||||
if (totalPathsFound >= enumerateCap) break;
|
||||
continue;
|
||||
}
|
||||
if (hops >= maxHops) continue;
|
||||
for (const { to, edge } of adj.get(cur.node) ?? []) {
|
||||
if (cur.visited.has(to)) continue;
|
||||
const visited = new Set(cur.visited);
|
||||
visited.add(to);
|
||||
queue.push({
|
||||
node: to,
|
||||
path: [...cur.path, to],
|
||||
pathEdges: [...cur.pathEdges, edge],
|
||||
visited,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = totalPathsFound > paths.length;
|
||||
const cited = new Set<string>();
|
||||
const edgeIds = new Set<string>();
|
||||
for (const p of paths) {
|
||||
for (const id of p.nodeIds) cited.add(id);
|
||||
for (const e of p.edges) edgeIds.add(e.id);
|
||||
}
|
||||
|
||||
return {
|
||||
paths,
|
||||
nodes: [...cited]
|
||||
.map((id) => byId.get(id))
|
||||
.filter((n): n is MindNode => n != null)
|
||||
.map(toCompactNode),
|
||||
edges: live
|
||||
.filter((e) => edgeIds.has(e.id))
|
||||
.map((e) => toCompactEdge(e, byId)),
|
||||
citedNodeIds: [...cited],
|
||||
truncated,
|
||||
maxPaths,
|
||||
pathsReturned: paths.length,
|
||||
totalPathsFound,
|
||||
omittedPaths: Math.max(0, totalPathsFound - paths.length),
|
||||
maxHops,
|
||||
throughRelationNodes,
|
||||
};
|
||||
}
|
||||
|
||||
export function queryStats(input: {
|
||||
nodes: MindNode[];
|
||||
edges: MindEdge[];
|
||||
/** Limit longestPath / degree / components to these edge kinds (default all). */
|
||||
edgeKinds?: QueryEdgeKind;
|
||||
}): {
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
hierarchyEdgeCount: number;
|
||||
relationEdgeCount: number;
|
||||
orphanNodes: number;
|
||||
/**
|
||||
* What orphanNodes means under the current edgeKinds filter.
|
||||
* When edgeKinds=relation, these are nodes with no relation edges (not global isolates).
|
||||
*/
|
||||
orphanDef: string;
|
||||
/** Alias of orphanNodes when edgeKinds=relation — clearer name for that view. */
|
||||
nodesWithoutRelation?: number;
|
||||
/** Alias of orphanNodes when edgeKinds=hierarchy. */
|
||||
nodesWithoutHierarchy?: number;
|
||||
/** @deprecated use connectedComponents.count — undirected component count over edgeKinds. */
|
||||
islands: number;
|
||||
/** Undirected connected components (filtered by edgeKinds). */
|
||||
connectedComponents: {
|
||||
count: number;
|
||||
edgeKinds: QueryEdgeKind;
|
||||
components: {
|
||||
size: number;
|
||||
nodeIds: string[];
|
||||
texts: string[];
|
||||
}[];
|
||||
};
|
||||
avgChildrenPerNode: number;
|
||||
deepestDepth: number;
|
||||
nodesWithoutDescription: number;
|
||||
nodesWithoutFunction: number;
|
||||
edgesWithoutLabel: number;
|
||||
/** Graph diameter on edgeKinds subgraph (undirected shortest-path hops). */
|
||||
longestPath: {
|
||||
hops: number;
|
||||
from: string;
|
||||
to: string;
|
||||
fromText: string;
|
||||
toText: string;
|
||||
edgeKinds: QueryEdgeKind;
|
||||
} | null;
|
||||
/** Highest undirected degree on edgeKinds subgraph. */
|
||||
mostConnected: {
|
||||
nodeId: string;
|
||||
text: string;
|
||||
degree: number;
|
||||
edgeKinds: QueryEdgeKind;
|
||||
} | null;
|
||||
} {
|
||||
const edgeKinds: QueryEdgeKind = input.edgeKinds ?? "all";
|
||||
const nodes = input.nodes.filter((n) => !n.deletedAt);
|
||||
const allEdges = input.edges.filter((e) => !e.deletedAt);
|
||||
const edges = filterEdges(input.edges, edgeKinds);
|
||||
const nodeIds = new Set(nodes.map((n) => n.id));
|
||||
const byId = new Map(nodes.map((n) => [n.id, n]));
|
||||
const connected = new Set<string>();
|
||||
for (const e of edges) {
|
||||
if (nodeIds.has(e.from)) connected.add(e.from);
|
||||
if (nodeIds.has(e.to)) connected.add(e.to);
|
||||
}
|
||||
const orphans = nodes.filter((n) => !connected.has(n.id)).length;
|
||||
const orphanDef =
|
||||
edgeKinds === "relation"
|
||||
? "Nodes with degree 0 on the relation-only subgraph (no relation edges). Not global isolates — hierarchy links are ignored in this view."
|
||||
: edgeKinds === "hierarchy"
|
||||
? "Nodes with degree 0 on the hierarchy-only subgraph (no hierarchy edges). Relation-only nodes count here."
|
||||
: "Nodes with degree 0 on the full graph (no hierarchy or relation edges).";
|
||||
const orphanAliases =
|
||||
edgeKinds === "relation"
|
||||
? { nodesWithoutRelation: orphans }
|
||||
: edgeKinds === "hierarchy"
|
||||
? { nodesWithoutHierarchy: orphans }
|
||||
: {};
|
||||
|
||||
// Undirected components
|
||||
const adj = new Map<string, string[]>();
|
||||
for (const id of nodeIds) adj.set(id, []);
|
||||
for (const e of edges) {
|
||||
if (!nodeIds.has(e.from) || !nodeIds.has(e.to)) continue;
|
||||
adj.get(e.from)!.push(e.to);
|
||||
adj.get(e.to)!.push(e.from);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const componentLists: string[][] = [];
|
||||
for (const id of nodeIds) {
|
||||
if (seen.has(id)) continue;
|
||||
const q = [id];
|
||||
seen.add(id);
|
||||
const members: string[] = [];
|
||||
while (q.length) {
|
||||
const cur = q.pop()!;
|
||||
members.push(cur);
|
||||
for (const nxt of adj.get(cur) ?? []) {
|
||||
if (seen.has(nxt)) continue;
|
||||
seen.add(nxt);
|
||||
q.push(nxt);
|
||||
}
|
||||
}
|
||||
members.sort();
|
||||
componentLists.push(members);
|
||||
}
|
||||
componentLists.sort((a, b) => b.length - a.length || a[0]!.localeCompare(b[0]!));
|
||||
const connectedComponents = {
|
||||
count: componentLists.length,
|
||||
edgeKinds,
|
||||
components: componentLists.slice(0, 40).map((ids) => ({
|
||||
size: ids.length,
|
||||
nodeIds: ids.slice(0, 24),
|
||||
texts: ids.slice(0, 24).map((id) => byId.get(id)?.text ?? id),
|
||||
})),
|
||||
};
|
||||
const islands = connectedComponents.count;
|
||||
|
||||
const children = new Map<string, string[]>();
|
||||
for (const e of allEdges) {
|
||||
if (e.kind !== "hierarchy" || !e.isPrimaryParent) continue;
|
||||
const list = children.get(e.from) ?? [];
|
||||
list.push(e.to);
|
||||
children.set(e.from, list);
|
||||
}
|
||||
let childSum = 0;
|
||||
let parents = 0;
|
||||
for (const [, list] of children) {
|
||||
parents += 1;
|
||||
childSum += list.length;
|
||||
}
|
||||
|
||||
let deepestDepth = 0;
|
||||
const roots = [...nodeIds].filter((id) => {
|
||||
return !allEdges.some(
|
||||
(e) =>
|
||||
e.kind === "hierarchy" &&
|
||||
e.isPrimaryParent &&
|
||||
e.to === id &&
|
||||
nodeIds.has(e.from),
|
||||
);
|
||||
});
|
||||
for (const root of roots) {
|
||||
const q: { id: string; d: number }[] = [{ id: root, d: 0 }];
|
||||
const vis = new Set<string>([root]);
|
||||
while (q.length) {
|
||||
const { id, d } = q.shift()!;
|
||||
deepestDepth = Math.max(deepestDepth, d);
|
||||
for (const c of children.get(id) ?? []) {
|
||||
if (vis.has(c)) continue;
|
||||
vis.add(c);
|
||||
q.push({ id: c, d: d + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const degree = new Map<string, number>();
|
||||
for (const id of nodeIds) degree.set(id, 0);
|
||||
for (const e of edges) {
|
||||
if (!nodeIds.has(e.from) || !nodeIds.has(e.to)) continue;
|
||||
degree.set(e.from, (degree.get(e.from) ?? 0) + 1);
|
||||
degree.set(e.to, (degree.get(e.to) ?? 0) + 1);
|
||||
}
|
||||
let mostConnected: {
|
||||
nodeId: string;
|
||||
text: string;
|
||||
degree: number;
|
||||
edgeKinds: QueryEdgeKind;
|
||||
} | null = null;
|
||||
for (const [id, deg] of degree) {
|
||||
if (!mostConnected || deg > mostConnected.degree) {
|
||||
mostConnected = {
|
||||
nodeId: id,
|
||||
text: byId.get(id)?.text ?? id,
|
||||
degree: deg,
|
||||
edgeKinds,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (mostConnected && mostConnected.degree === 0) mostConnected = null;
|
||||
|
||||
let longestPath: {
|
||||
hops: number;
|
||||
from: string;
|
||||
to: string;
|
||||
fromText: string;
|
||||
toText: string;
|
||||
edgeKinds: QueryEdgeKind;
|
||||
} | null = null;
|
||||
const bfsFarthest = (start: string) => {
|
||||
const dist = new Map<string, number>([[start, 0]]);
|
||||
const q = [start];
|
||||
let far = start;
|
||||
while (q.length) {
|
||||
const cur = q.shift()!;
|
||||
const d = dist.get(cur)!;
|
||||
if (d > (dist.get(far) ?? 0)) far = cur;
|
||||
for (const nxt of adj.get(cur) ?? []) {
|
||||
if (dist.has(nxt)) continue;
|
||||
dist.set(nxt, d + 1);
|
||||
q.push(nxt);
|
||||
}
|
||||
}
|
||||
return { far, hops: dist.get(far) ?? 0 };
|
||||
};
|
||||
const starts = nodes.slice(0, 200).map((n) => n.id);
|
||||
for (const start of starts) {
|
||||
const { far, hops } = bfsFarthest(start);
|
||||
if (hops > 0 && (!longestPath || hops > longestPath.hops)) {
|
||||
longestPath = {
|
||||
hops,
|
||||
from: start,
|
||||
to: far,
|
||||
fromText: byId.get(start)?.text ?? start,
|
||||
toText: byId.get(far)?.text ?? far,
|
||||
edgeKinds,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodeCount: nodes.length,
|
||||
edgeCount: allEdges.length,
|
||||
hierarchyEdgeCount: allEdges.filter((e) => e.kind === "hierarchy").length,
|
||||
relationEdgeCount: allEdges.filter((e) => e.kind === "relation").length,
|
||||
orphanNodes: orphans,
|
||||
orphanDef,
|
||||
...orphanAliases,
|
||||
islands,
|
||||
connectedComponents,
|
||||
avgChildrenPerNode: parents ? Math.round((childSum / parents) * 10) / 10 : 0,
|
||||
deepestDepth,
|
||||
nodesWithoutDescription: nodes.filter(
|
||||
(n) => !n.description || Object.keys(n.description).length === 0,
|
||||
).length,
|
||||
nodesWithoutFunction: nodes.filter((n) => {
|
||||
if (!n.description || Object.keys(n.description).length === 0) return false;
|
||||
return !nodeFunction(n);
|
||||
}).length,
|
||||
edgesWithoutLabel: allEdges.filter((e) => !e.label?.trim()).length,
|
||||
longestPath,
|
||||
mostConnected,
|
||||
};
|
||||
}
|
||||
|
||||
export function queryOrphans(input: {
|
||||
nodes: MindNode[];
|
||||
edges: MindEdge[];
|
||||
}): { nodes: CompactNode[]; citedNodeIds: string[] } {
|
||||
const nodes = input.nodes.filter((n) => !n.deletedAt);
|
||||
const edges = input.edges.filter((e) => !e.deletedAt);
|
||||
const connected = new Set<string>();
|
||||
for (const e of edges) {
|
||||
connected.add(e.from);
|
||||
connected.add(e.to);
|
||||
}
|
||||
const orphans = nodes.filter((n) => !connected.has(n.id));
|
||||
return {
|
||||
nodes: orphans.map(toCompactNode),
|
||||
citedNodeIds: orphans.map((n) => n.id),
|
||||
};
|
||||
}
|
||||
|
||||
export function queryMissingMeta(input: {
|
||||
nodes: MindNode[];
|
||||
edges: MindEdge[];
|
||||
}): {
|
||||
nodesWithoutDescription: CompactNode[];
|
||||
/**
|
||||
* Nodes that already have a description object but lack function/summary.
|
||||
* Complementary to nodesWithoutDescription (not a duplicate of empty description).
|
||||
*/
|
||||
nodesWithoutFunction: CompactNode[];
|
||||
edgesWithoutLabel: CompactEdge[];
|
||||
citedNodeIds: string[];
|
||||
} {
|
||||
const byId = new Map(input.nodes.map((n) => [n.id, n]));
|
||||
const nodes = input.nodes.filter((n) => !n.deletedAt);
|
||||
const edges = input.edges.filter((e) => !e.deletedAt);
|
||||
const withoutDesc = nodes.filter(
|
||||
(n) => !n.description || Object.keys(n.description).length === 0,
|
||||
);
|
||||
const withoutFn = nodes.filter((n) => {
|
||||
if (!n.description || Object.keys(n.description).length === 0) return false;
|
||||
return !nodeFunction(n);
|
||||
});
|
||||
const withoutLabel = edges.filter((e) => !e.label?.trim());
|
||||
const cited = new Set<string>([
|
||||
...withoutDesc.map((n) => n.id),
|
||||
...withoutFn.map((n) => n.id),
|
||||
...withoutLabel.flatMap((e) => [e.from, e.to]),
|
||||
]);
|
||||
return {
|
||||
nodesWithoutDescription: withoutDesc.map(toCompactNode),
|
||||
nodesWithoutFunction: withoutFn.map(toCompactNode),
|
||||
edgesWithoutLabel: withoutLabel.map((e) => toCompactEdge(e, byId)),
|
||||
citedNodeIds: [...cited],
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve id / @alias / unique title against a node list. */
|
||||
export function resolveQueryNodeRef(
|
||||
nodes: MindNode[],
|
||||
token: string,
|
||||
opts?: { anchorId?: string | null },
|
||||
):
|
||||
| { ok: true; id: string }
|
||||
| {
|
||||
ok: false;
|
||||
error: "not_found" | "ambiguous";
|
||||
message: string;
|
||||
candidates?: { id: string; text: string }[];
|
||||
} {
|
||||
const raw = token.trim();
|
||||
if (!raw) {
|
||||
return { ok: false, error: "not_found", message: "empty node ref" };
|
||||
}
|
||||
const anchorId = opts?.anchorId ?? null;
|
||||
const live = nodes.filter((n) => !n.deletedAt && n.id !== anchorId);
|
||||
if (live.some((n) => n.id === raw)) return { ok: true, id: raw };
|
||||
|
||||
const aliasKey = raw.replace(/^@/, "").toLowerCase();
|
||||
const byAlias = live.filter((n) => n.alias === aliasKey);
|
||||
if (byAlias.length === 1) return { ok: true, id: byAlias[0]!.id };
|
||||
if (byAlias.length > 1) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "ambiguous",
|
||||
message: `alias @${aliasKey} matches multiple nodes`,
|
||||
candidates: byAlias.slice(0, 8).map((n) => ({ id: n.id, text: n.text })),
|
||||
};
|
||||
}
|
||||
|
||||
const key = raw.toLowerCase();
|
||||
const exact = live.filter((n) => n.text.trim().toLowerCase() === key);
|
||||
if (exact.length === 1) return { ok: true, id: exact[0]!.id };
|
||||
if (exact.length > 1) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "ambiguous",
|
||||
message: `title "${raw}" matches ${exact.length} nodes; use @alias`,
|
||||
candidates: exact.slice(0, 8).map((n) => ({ id: n.id, text: n.text })),
|
||||
};
|
||||
}
|
||||
|
||||
const soft = live.filter(
|
||||
(n) =>
|
||||
n.text.toLowerCase().includes(key) ||
|
||||
key.includes(n.text.toLowerCase()) ||
|
||||
(n.alias && n.alias.includes(aliasKey)),
|
||||
);
|
||||
if (soft.length === 1) return { ok: true, id: soft[0]!.id };
|
||||
if (soft.length > 1) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "ambiguous",
|
||||
message: `"${raw}" is ambiguous`,
|
||||
candidates: soft.slice(0, 8).map((n) => ({ id: n.id, text: n.text })),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: "not_found",
|
||||
message: `No node matched "${raw}". Use mindmap_find or create it first.`,
|
||||
};
|
||||
}
|
||||
55
packages/shared/src/index.ts
Normal file
55
packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
export * from "./types.js";
|
||||
export * from "./ops.js";
|
||||
export * from "./resolve-ops.js";
|
||||
export * from "./graph-query.js";
|
||||
|
||||
export function now(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export function newId(prefix = "n"): string {
|
||||
return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
||||
}
|
||||
|
||||
/** Fit camera so content bbox fills a virtual viewport (server-side auto_fit). */
|
||||
export function computeFitViewport(
|
||||
positions: Record<string, { x: number; y: number }>,
|
||||
opts?: {
|
||||
padding?: number;
|
||||
viewW?: number;
|
||||
viewH?: number;
|
||||
nodeW?: number;
|
||||
nodeH?: number;
|
||||
},
|
||||
): { x: number; y: number; zoom: number } {
|
||||
const pad = opts?.padding ?? 50;
|
||||
const viewW = opts?.viewW ?? 1280;
|
||||
const viewH = opts?.viewH ?? 800;
|
||||
const nodeW = opts?.nodeW ?? 160;
|
||||
const nodeH = opts?.nodeH ?? 44;
|
||||
const pts = Object.values(positions);
|
||||
if (!pts.length) return { x: 0, y: 0, zoom: 1 };
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minY = Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const p of pts) {
|
||||
minX = Math.min(minX, p.x - nodeW / 2);
|
||||
maxX = Math.max(maxX, p.x + nodeW / 2);
|
||||
minY = Math.min(minY, p.y - nodeH / 2);
|
||||
maxY = Math.max(maxY, p.y + nodeH / 2);
|
||||
}
|
||||
minX -= pad;
|
||||
maxX += pad;
|
||||
minY -= pad;
|
||||
maxY += pad;
|
||||
const bw = Math.max(40, maxX - minX);
|
||||
const bh = Math.max(40, maxY - minY);
|
||||
const zoom = Math.min(
|
||||
2.5,
|
||||
Math.max(0.12, Math.min((viewW * 0.86) / bw, (viewH * 0.86) / bh)),
|
||||
);
|
||||
const cx = (minX + maxX) / 2;
|
||||
const cy = (minY + maxY) / 2;
|
||||
return { x: -cx * zoom, y: -cy * zoom, zoom };
|
||||
}
|
||||
518
packages/shared/src/ops.ts
Normal file
518
packages/shared/src/ops.ts
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
AccentColorSchema,
|
||||
DescriptionSchema,
|
||||
EdgeDirectionSchema,
|
||||
EdgeLineStyleSchema,
|
||||
LinkRefSchema,
|
||||
NodeIconSchema,
|
||||
StylePresetSchema,
|
||||
ViewportSchema,
|
||||
} from "./types.js";
|
||||
|
||||
export const OP_TYPES = [
|
||||
"create_node",
|
||||
"batch_create",
|
||||
"batch_link",
|
||||
"update_text",
|
||||
"update_node_meta",
|
||||
"move_node",
|
||||
"reorder",
|
||||
"set_primary_parent",
|
||||
"link",
|
||||
"unlink",
|
||||
"delete_node",
|
||||
"restore_node",
|
||||
"set_pinned",
|
||||
"set_collapsed",
|
||||
"set_style_preset",
|
||||
"set_edge_label",
|
||||
"update_edge_meta",
|
||||
"set_prefs",
|
||||
"set_focus",
|
||||
"set_viewport",
|
||||
"auto_fit",
|
||||
] as const;
|
||||
|
||||
export type OpType = (typeof OP_TYPES)[number];
|
||||
|
||||
const PosSchema = z.object({ x: z.number(), y: z.number() });
|
||||
|
||||
/** Outgoing graph edges declared on create / batch_create (not URL bookmarks). */
|
||||
export const InlineRelationSchema = z.object({
|
||||
to: z.string(),
|
||||
kind: z.enum(["hierarchy", "relation"]).default("relation"),
|
||||
label: z.string().optional(),
|
||||
weight: z.number().min(1).max(5).optional(),
|
||||
lineStyle: EdgeLineStyleSchema.optional(),
|
||||
direction: EdgeDirectionSchema.optional(),
|
||||
});
|
||||
export type InlineRelation = z.infer<typeof InlineRelationSchema>;
|
||||
|
||||
const NodeMetaPatchSchema = z.object({
|
||||
note: z.string().nullable().optional(),
|
||||
description: DescriptionSchema.nullable().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
links: z.array(LinkRefSchema).optional(),
|
||||
imageUrl: z.string().nullable().optional(),
|
||||
dueAt: z.number().nullable().optional(),
|
||||
startAt: z.number().nullable().optional(),
|
||||
accentColor: AccentColorSchema.nullable().optional(),
|
||||
icon: NodeIconSchema.nullable().optional(),
|
||||
alias: z.string().min(1).max(64).nullable().optional(),
|
||||
});
|
||||
|
||||
const EdgeMetaPatchSchema = z.object({
|
||||
label: z.string().nullable().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
weight: z.number().min(1).max(5).nullable().optional(),
|
||||
lineStyle: EdgeLineStyleSchema.nullable().optional(),
|
||||
direction: EdgeDirectionSchema.nullable().optional(),
|
||||
});
|
||||
|
||||
const CreateNodeFieldsSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
text: z.string(),
|
||||
parentId: z.string().nullable().optional(),
|
||||
sidePref: z.union([z.literal(-1), z.literal(0), z.literal(1)]).optional(),
|
||||
stylePreset: StylePresetSchema.optional(),
|
||||
/** Optional absolute position; implies pinned=true when set. */
|
||||
pos: PosSchema.optional(),
|
||||
pinned: z.boolean().optional(),
|
||||
note: z.string().optional(),
|
||||
description: DescriptionSchema.optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
/** URL bookmarks (not graph edges). */
|
||||
links: z.array(LinkRefSchema).optional(),
|
||||
/** Outgoing graph edges created with this node. */
|
||||
relations: z.array(InlineRelationSchema).optional(),
|
||||
imageUrl: z.string().optional(),
|
||||
dueAt: z.number().optional(),
|
||||
startAt: z.number().optional(),
|
||||
accentColor: AccentColorSchema.optional(),
|
||||
icon: NodeIconSchema.optional(),
|
||||
/** Agent-friendly handle; later refs use @alias or bare alias. */
|
||||
alias: z.string().min(1).max(64).optional(),
|
||||
});
|
||||
|
||||
export const OpSchema = z.discriminatedUnion("type", [
|
||||
CreateNodeFieldsSchema.extend({
|
||||
type: z.literal("create_node"),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("batch_create"),
|
||||
nodes: z.array(CreateNodeFieldsSchema).min(1).max(200),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("batch_link"),
|
||||
edges: z
|
||||
.array(
|
||||
z.object({
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
kind: z.enum(["hierarchy", "relation"]).default("relation"),
|
||||
label: z.string().optional(),
|
||||
asPrimary: z.boolean().optional(),
|
||||
weight: z.number().min(1).max(5).optional(),
|
||||
lineStyle: EdgeLineStyleSchema.optional(),
|
||||
direction: EdgeDirectionSchema.optional(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("update_text"),
|
||||
nodeId: z.string(),
|
||||
text: z.string(),
|
||||
expectedVersion: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("update_node_meta"),
|
||||
nodeId: z.string(),
|
||||
patch: NodeMetaPatchSchema,
|
||||
expectedVersion: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("move_node"),
|
||||
nodeId: z.string(),
|
||||
newParentId: z.string().nullable(),
|
||||
sidePref: z.union([z.literal(-1), z.literal(0), z.literal(1)]).optional(),
|
||||
order: z.number().optional(),
|
||||
expectedVersion: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("reorder"),
|
||||
nodeId: z.string(),
|
||||
/** Numeric sibling sort key on the primary hierarchy edge (preferred). */
|
||||
order: z.number().optional(),
|
||||
/**
|
||||
* Convenience alias: place after this sibling under the same parent.
|
||||
* `null` = move to front. Server resolves to `order` when applying.
|
||||
*/
|
||||
afterSiblingId: z.string().nullable().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("set_primary_parent"),
|
||||
nodeId: z.string(),
|
||||
edgeId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("link"),
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
kind: z.enum(["hierarchy", "relation"]),
|
||||
label: z.string().optional(),
|
||||
asPrimary: z.boolean().optional(),
|
||||
weight: z.number().min(1).max(5).optional(),
|
||||
lineStyle: EdgeLineStyleSchema.optional(),
|
||||
direction: EdgeDirectionSchema.optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("unlink"),
|
||||
edgeId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("delete_node"),
|
||||
nodeId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("restore_node"),
|
||||
nodeId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("set_pinned"),
|
||||
nodeId: z.string(),
|
||||
pinned: z.boolean(),
|
||||
pos: PosSchema.optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("set_collapsed"),
|
||||
nodeId: z.string(),
|
||||
collapsed: z.boolean(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("set_style_preset"),
|
||||
nodeId: z.string(),
|
||||
stylePreset: StylePresetSchema,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("set_edge_label"),
|
||||
edgeId: z.string(),
|
||||
label: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("update_edge_meta"),
|
||||
edgeId: z.string(),
|
||||
patch: EdgeMetaPatchSchema,
|
||||
expectedVersion: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("set_prefs"),
|
||||
prefs: z.record(z.unknown()),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("set_focus"),
|
||||
nodeId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("set_viewport"),
|
||||
viewport: ViewportSchema,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("auto_fit"),
|
||||
/** Extra world-space padding around content bbox (default 50). */
|
||||
padding: z.number().min(0).max(400).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type Op = z.infer<typeof OpSchema>;
|
||||
|
||||
/** Accept agent typos: op / operation → type. */
|
||||
export function coerceOpInput(raw: unknown): unknown {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw;
|
||||
const o = { ...(raw as Record<string, unknown>) };
|
||||
if (o.type == null) {
|
||||
if (typeof o.op === "string") o.type = o.op;
|
||||
else if (typeof o.operation === "string") o.type = o.operation;
|
||||
}
|
||||
delete o.op;
|
||||
delete o.operation;
|
||||
return o;
|
||||
}
|
||||
|
||||
export function parseOps(
|
||||
raw: unknown,
|
||||
):
|
||||
| { ok: true; ops: Op[] }
|
||||
| {
|
||||
ok: false;
|
||||
error: "invalid_op";
|
||||
message: string;
|
||||
validTypes: readonly string[];
|
||||
issues?: unknown;
|
||||
} {
|
||||
if (!Array.isArray(raw)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: "ops must be an array of objects with discriminator field `type` (not `op`).",
|
||||
validTypes: OP_TYPES,
|
||||
};
|
||||
}
|
||||
if (raw.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: "ops array is empty",
|
||||
validTypes: OP_TYPES,
|
||||
};
|
||||
}
|
||||
const coerced = raw.map(coerceOpInput);
|
||||
const parsed = z.array(OpSchema).safeParse(coerced);
|
||||
if (!parsed.success) {
|
||||
const first = parsed.error.issues[0];
|
||||
const hint =
|
||||
first?.path?.join(".") === "0.type" || String(first?.message || "").includes("discriminator")
|
||||
? " Each op MUST use field `type` (e.g. {\"type\":\"create_node\",\"text\":\"...\",\"parentId\":\"...\"}). Do not use `op`."
|
||||
: "";
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `Invalid ops: ${first?.message ?? "schema error"}.${hint}`,
|
||||
validTypes: OP_TYPES,
|
||||
issues: parsed.error.issues.slice(0, 8),
|
||||
};
|
||||
}
|
||||
for (const op of parsed.data) {
|
||||
if (
|
||||
op.type === "reorder" &&
|
||||
op.order == null &&
|
||||
op.afterSiblingId === undefined
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message:
|
||||
'reorder requires `order` (number) or `afterSiblingId` (sibling id | null for first). Example: {"type":"reorder","nodeId":"...","order":10}',
|
||||
validTypes: OP_TYPES,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true, ops: parsed.data };
|
||||
}
|
||||
|
||||
export const OP_SCHEMA_DOC = {
|
||||
discriminator: "type",
|
||||
note: "Use `type`, never `op` / `operation`. Prefer batch_create for trees. There is no visible root node — omit parentId for top-level creates.",
|
||||
validTypes: OP_TYPES,
|
||||
examples: [
|
||||
{
|
||||
type: "batch_create",
|
||||
nodes: [
|
||||
{ text: "数通产品线", alias: "datacom" },
|
||||
{
|
||||
text: "数据中心交换机",
|
||||
alias: "dc_switch",
|
||||
parentId: "@datacom",
|
||||
},
|
||||
{
|
||||
text: "AIOps诊断",
|
||||
alias: "aiops",
|
||||
parentId: "@datacom",
|
||||
description: {
|
||||
function: "故障根因诊断",
|
||||
inputs: ["指标", "日志"],
|
||||
sla: "30秒内输出",
|
||||
},
|
||||
relations: [
|
||||
{ to: "@restart", label: "触发重启", kind: "relation" },
|
||||
],
|
||||
},
|
||||
{ text: "自动重启", alias: "restart", parentId: "@datacom" },
|
||||
],
|
||||
_comment: "Two-pass: all nodes first, then parents/relations. Max 200.",
|
||||
},
|
||||
{
|
||||
type: "batch_link",
|
||||
edges: [
|
||||
{ from: "@daiyu", to: "@baoyu", kind: "relation", label: "知己" },
|
||||
{ from: "@baoyu", to: "@baochai", kind: "relation", label: "金玉良缘" },
|
||||
],
|
||||
_comment: "Bulk relation/hierarchy links (max 100). Prefer after nodes exist.",
|
||||
},
|
||||
{
|
||||
type: "create_node",
|
||||
text: "AIOps诊断",
|
||||
alias: "aiops",
|
||||
relations: [{ to: "@restart", label: "触发重启", kind: "relation" }],
|
||||
_comment: "relations = graph edges; links = URL bookmarks",
|
||||
},
|
||||
{ type: "create_node", text: "贾宝玉" },
|
||||
{
|
||||
type: "create_node",
|
||||
text: "贾政",
|
||||
parentId: "<visibleParentId>",
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
from: "<fromNodeId>",
|
||||
to: "<toNodeId>",
|
||||
kind: "relation",
|
||||
label: "木石前盟",
|
||||
},
|
||||
{
|
||||
type: "create_node",
|
||||
text: "AIOps",
|
||||
alias: "aiops",
|
||||
_comment: "Later refs: parentId:'@aiops' or parentText:'AIOps'",
|
||||
},
|
||||
{
|
||||
type: "create_node",
|
||||
text: "自动重启",
|
||||
parentId: "@aiops",
|
||||
},
|
||||
{
|
||||
type: "link",
|
||||
from: "@aiops",
|
||||
to: "自动重启",
|
||||
kind: "relation",
|
||||
label: "诊断→重启",
|
||||
_comment:
|
||||
"from/to accept id, @alias, or unique title. Or use fromText/toText before resolve.",
|
||||
},
|
||||
{ type: "update_text", nodeId: "<id>", text: "新文案" },
|
||||
{
|
||||
type: "update_node_meta",
|
||||
nodeId: "@aiops",
|
||||
patch: {
|
||||
note: "自由文本备注",
|
||||
description: {
|
||||
function: "故障根因诊断",
|
||||
owner: "SRE团队",
|
||||
},
|
||||
tags: ["人物", "贾府"],
|
||||
links: [{ url: "https://example.com", title: "百科" }],
|
||||
accentColor: "#e74c3c",
|
||||
icon: "person",
|
||||
alias: "aiops",
|
||||
dueAt: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "update_edge_meta",
|
||||
edgeId: "<edgeId>",
|
||||
patch: {
|
||||
label: "配偶",
|
||||
tags: ["亲属"],
|
||||
weight: 3,
|
||||
lineStyle: "dashed",
|
||||
direction: "both",
|
||||
note: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "set_pinned",
|
||||
nodeId: "<id>",
|
||||
pinned: true,
|
||||
pos: { x: 120, y: -80 },
|
||||
_comment: "Pure position move / lock — prefer this over move_node",
|
||||
},
|
||||
{
|
||||
type: "move_node",
|
||||
nodeId: "<id>",
|
||||
newParentId: "<visibleParentId or null for top-level>",
|
||||
_comment:
|
||||
"Reparent only (change hierarchy parent). To change x/y only, use set_pinned + pos instead.",
|
||||
},
|
||||
{ type: "set_edge_label", edgeId: "<edgeId>", label: "配偶" },
|
||||
{
|
||||
type: "reorder",
|
||||
nodeId: "<id>",
|
||||
order: 10,
|
||||
_comment:
|
||||
"Changes sibling sort; also unpins so auto-layout can move the node visually.",
|
||||
},
|
||||
{
|
||||
type: "reorder",
|
||||
nodeId: "<id>",
|
||||
afterSiblingId: "<siblingId or null for first>",
|
||||
_comment: "Optional alias; server resolves to order",
|
||||
},
|
||||
{
|
||||
type: "set_collapsed",
|
||||
nodeId: "<id>",
|
||||
collapsed: true,
|
||||
_comment: "Hides hierarchy descendants from layout/viewport until expanded",
|
||||
},
|
||||
{
|
||||
type: "set_style_preset",
|
||||
nodeId: "@baoyu",
|
||||
stylePreset: "decision",
|
||||
_comment: "Field is stylePreset (not preset). Values: default|title|decision|risk|note|muted|card",
|
||||
},
|
||||
{
|
||||
type: "unlink",
|
||||
edgeId: "<edgeId>",
|
||||
_comment:
|
||||
"Prefer edgeId when multiple edges exist between the same pair. Sugar from+to(+kind) only works if exactly one live match — label is ignored.",
|
||||
},
|
||||
{
|
||||
type: "unlink",
|
||||
from: "@daiyu",
|
||||
to: "@baoyu",
|
||||
kind: "relation",
|
||||
_comment:
|
||||
"Sugar form; fails with ambiguous if >1 edge of that kind between the pair — then use edgeId.",
|
||||
},
|
||||
{
|
||||
type: "restore_node",
|
||||
nodeId: "<softDeletedNodeId>",
|
||||
_comment:
|
||||
"Restores a soft-deleted node (+ compatible incident edges). Use the same id returned by delete_node; @alias/title of deleted nodes also work.",
|
||||
},
|
||||
{
|
||||
type: "auto_fit",
|
||||
padding: 50,
|
||||
_comment: "Fit camera to content bbox after layout; pair with batch_create",
|
||||
},
|
||||
],
|
||||
opNotes: {
|
||||
batch_create:
|
||||
"Create up to 200 nodes in one op. Two-pass (nodes then parents/relations) so forward @alias refs work. Prefer this over many create_node calls.",
|
||||
batch_link:
|
||||
"Create up to 100 edges in one op: {type:'batch_link', edges:[{from,to,kind,label,...}]}. from/to accept id|@alias|title.",
|
||||
move_node:
|
||||
"Requires newParentId (visible parent id, or null for top-level). Changes hierarchy parent only — NOT free-drag. Position → set_pinned + pos.",
|
||||
link:
|
||||
"kind: hierarchy | relation. from/to: node id, @alias, or unique title. Wire sugar: fromText/toText (resolved server-side before apply).",
|
||||
unlink:
|
||||
"Remove an edge. Prefer {type:'unlink', edgeId} (exact). Sugar: {from, to, kind?} with id|@alias|title — server resolves to edgeId when unique. If multiple edges share the same from+to(+kind), sugar is ambiguous → must use edgeId (label is NOT a disambiguator).",
|
||||
set_style_preset:
|
||||
"Field name is stylePreset (not preset). Example: {type:'set_style_preset', nodeId:'@x', stylePreset:'risk'}.",
|
||||
restore_node:
|
||||
"Soft-deleted nodes keep their id. Pass that id (or deleted @alias / unique title). Restores compatible incident edges; children already promoted stay under the new parent.",
|
||||
create_node:
|
||||
"Omit parentId for top-level. Optional pos pins immediately. Optional alias. Optional relations[] for outgoing graph edges (not URL links). Optional description object.",
|
||||
update_node_meta:
|
||||
"Patch note/description/tags/links/imageUrl/dueAt/startAt/accentColor/icon/alias. description is a JSON object (knowledge fields). Null clears scalar fields. links = URL bookmarks.",
|
||||
update_edge_meta:
|
||||
"Patch label/tags/note/weight(1-5)/lineStyle(solid|dashed|dotted)/direction(forward|both|none) on an edge.",
|
||||
set_edge_label: "Need edgeId from spatial_context.edges or get_subgraph.",
|
||||
reorder:
|
||||
"Sibling order on primary hierarchy edge. Also unpins the node so auto-layout can move it. Example: {\"type\":\"reorder\",\"nodeId\":\"…\",\"order\":0}. Optional afterSiblingId (null=first).",
|
||||
set_collapsed:
|
||||
"Recursively hides ALL hierarchy descendants (via primary parent edges) from viewport/layout. Relation-only neighbors stay. Coordinates of collapsed nodes are preserved; expand restores them in place. Does not delete data.",
|
||||
auto_fit:
|
||||
"Camera fit to laid-out content. Does not reflow flextree sides. Optional padding (world units, default 50).",
|
||||
},
|
||||
highRisk: ["delete_node", "move_node", "set_primary_parent"],
|
||||
layout:
|
||||
"No visible root. Prefer batch_create for trees. Pin with set_pinned + pos. After big creates, call auto_fit. Agent refs: prefer alias over raw ids.",
|
||||
refs: {
|
||||
alias: "create_node.alias / batch_create → reference as @alias in parentId/nodeId/from/to/relations.to / get_subgraph.rootId",
|
||||
text: "Unique exact title also works as from/to/parentId; duplicates → ambiguous error",
|
||||
applyResponse: "mindmap_apply_direct returns nodeIdMap { text|@alias → id }",
|
||||
},
|
||||
};
|
||||
198
packages/shared/src/resolve-ops.test.ts
Normal file
198
packages/shared/src/resolve-ops.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveWireOps, type MindNode } from "./index.js";
|
||||
|
||||
function node(partial: Partial<MindNode> & { id: string; text: string }): MindNode {
|
||||
const t = Date.now();
|
||||
return {
|
||||
canvasId: "c1",
|
||||
collapsed: false,
|
||||
pinned: false,
|
||||
sidePref: 0,
|
||||
stylePreset: "default",
|
||||
version: 1,
|
||||
tags: [],
|
||||
links: [],
|
||||
createdAt: t,
|
||||
updatedAt: t,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveWireOps", () => {
|
||||
it("resolves @alias within the same batch", () => {
|
||||
const r = resolveWireOps(
|
||||
[
|
||||
{ type: "create_node", text: "AIOps", alias: "aiops" },
|
||||
{ type: "create_node", text: "重启", parentId: "@aiops" },
|
||||
{
|
||||
type: "link",
|
||||
from: "@aiops",
|
||||
to: "重启",
|
||||
kind: "relation",
|
||||
label: "诊断→重启",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const [a, b, link] = r.ops as Array<Record<string, unknown>>;
|
||||
expect(a?.id).toBeTruthy();
|
||||
expect(b?.parentId).toBe(a?.id);
|
||||
expect(link?.from).toBe(a?.id);
|
||||
expect(link?.to).toBe(b?.id);
|
||||
expect(r.nodeIdMap["@aiops"]).toBe(a?.id);
|
||||
expect(r.nodeIdMap.AIOps).toBe(a?.id);
|
||||
});
|
||||
|
||||
it("resolves fromText/toText against existing nodes", () => {
|
||||
const live = [
|
||||
node({ id: "n1", text: "诊断" }),
|
||||
node({ id: "n2", text: "重启" }),
|
||||
];
|
||||
const r = resolveWireOps(
|
||||
[
|
||||
{
|
||||
type: "link",
|
||||
fromText: "诊断",
|
||||
toText: "重启",
|
||||
kind: "relation",
|
||||
},
|
||||
],
|
||||
live,
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const link = r.ops[0] as Record<string, unknown>;
|
||||
expect(link.from).toBe("n1");
|
||||
expect(link.to).toBe("n2");
|
||||
expect(link.fromText).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns ambiguous when titles collide", () => {
|
||||
const live = [
|
||||
node({ id: "n1", text: "同名" }),
|
||||
node({ id: "n2", text: "同名" }),
|
||||
];
|
||||
const r = resolveWireOps(
|
||||
[{ type: "link", fromText: "同名", toText: "同名", kind: "relation" }],
|
||||
live,
|
||||
);
|
||||
expect(r.ok).toBe(false);
|
||||
if (r.ok) return;
|
||||
expect(r.error).toBe("ambiguous");
|
||||
});
|
||||
|
||||
it("resolves batch_create with forward @alias parents and relations", () => {
|
||||
const r = resolveWireOps(
|
||||
[
|
||||
{
|
||||
type: "batch_create",
|
||||
nodes: [
|
||||
{ text: "数通", alias: "datacom" },
|
||||
{
|
||||
text: "AIOps",
|
||||
alias: "aiops",
|
||||
parentId: "@datacom",
|
||||
relations: [{ to: "@restart", kind: "relation", label: "触发" }],
|
||||
},
|
||||
{ text: "重启", alias: "restart", parentId: "@datacom" },
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const batch = r.ops[0] as {
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
parentId?: string;
|
||||
relations?: Array<{ to: string }>;
|
||||
}>;
|
||||
};
|
||||
const [a, b, c] = batch.nodes;
|
||||
expect(b?.parentId).toBe(a?.id);
|
||||
expect(c?.parentId).toBe(a?.id);
|
||||
expect(b?.relations?.[0]?.to).toBe(c?.id);
|
||||
expect(r.nodeIdMap["@aiops"]).toBe(b?.id);
|
||||
});
|
||||
|
||||
it("resolves restore_node against soft-deleted id/alias/title", () => {
|
||||
const live = [
|
||||
node({ id: "n_live", text: "贾母", alias: "jiamu" }),
|
||||
node({
|
||||
id: "n_gone",
|
||||
text: "林黛玉",
|
||||
alias: "daiyu",
|
||||
deletedAt: Date.now(),
|
||||
}),
|
||||
];
|
||||
const byId = resolveWireOps(
|
||||
[{ type: "restore_node", nodeId: "n_gone" }],
|
||||
live,
|
||||
);
|
||||
expect(byId.ok).toBe(true);
|
||||
if (byId.ok) {
|
||||
expect((byId.ops[0] as { nodeId: string }).nodeId).toBe("n_gone");
|
||||
}
|
||||
|
||||
const byAlias = resolveWireOps(
|
||||
[{ type: "restore_node", nodeId: "@daiyu" }],
|
||||
live,
|
||||
);
|
||||
expect(byAlias.ok).toBe(true);
|
||||
if (byAlias.ok) {
|
||||
expect((byAlias.ops[0] as { nodeId: string }).nodeId).toBe("n_gone");
|
||||
}
|
||||
|
||||
const byText = resolveWireOps(
|
||||
[{ type: "restore_node", nodeText: "林黛玉" }],
|
||||
live,
|
||||
);
|
||||
expect(byText.ok).toBe(true);
|
||||
if (byText.ok) {
|
||||
expect((byText.ops[0] as { nodeId: string }).nodeId).toBe("n_gone");
|
||||
}
|
||||
|
||||
const liveReject = resolveWireOps(
|
||||
[{ type: "restore_node", nodeId: "n_live" }],
|
||||
live,
|
||||
);
|
||||
expect(liveReject.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves unlink from+to to edgeId", () => {
|
||||
const live = [
|
||||
node({ id: "n1", text: "黛玉", alias: "daiyu" }),
|
||||
node({ id: "n2", text: "宝玉", alias: "baoyu" }),
|
||||
];
|
||||
const edges = [
|
||||
{
|
||||
id: "e1",
|
||||
canvasId: "c1",
|
||||
from: "n1",
|
||||
to: "n2",
|
||||
kind: "relation" as const,
|
||||
isPrimaryParent: false,
|
||||
order: 0,
|
||||
label: "知己",
|
||||
tags: [],
|
||||
version: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
const r = resolveWireOps(
|
||||
[{ type: "unlink", from: "@daiyu", to: "@baoyu", kind: "relation" }],
|
||||
live,
|
||||
{ edges },
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const op = r.ops[0] as Record<string, unknown>;
|
||||
expect(op.edgeId).toBe("e1");
|
||||
expect(op.from).toBeUndefined();
|
||||
expect(op.to).toBeUndefined();
|
||||
});
|
||||
});
|
||||
597
packages/shared/src/resolve-ops.ts
Normal file
597
packages/shared/src/resolve-ops.ts
Normal file
|
|
@ -0,0 +1,597 @@
|
|||
import type { MindEdge, MindNode } from "./types.js";
|
||||
|
||||
export type WireOp = Record<string, unknown> & { type?: string };
|
||||
|
||||
export type ResolveOpsResult =
|
||||
| {
|
||||
ok: true;
|
||||
ops: unknown[];
|
||||
/** alias / exact text → nodeId (batch + existing). */
|
||||
nodeIdMap: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: "invalid_op" | "ambiguous";
|
||||
message: string;
|
||||
candidates?: { id: string; text: string }[];
|
||||
};
|
||||
|
||||
function newId(prefix = "n"): string {
|
||||
return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
||||
}
|
||||
|
||||
function isRefToken(v: unknown): v is string {
|
||||
return typeof v === "string" && v.length > 0;
|
||||
}
|
||||
|
||||
function normalizeAlias(raw: string): string {
|
||||
return raw.trim().replace(/^@/, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand agent-friendly refs before parseOps:
|
||||
* - create_node / batch_create.alias → @alias resolvable in same batch / later
|
||||
* - parentText / fromText / toText / nodeText → unique text match
|
||||
* - nodeId/parentId/from/to/relations.to starting with @ → alias
|
||||
* - restore_node resolves against soft-deleted nodes (id / @alias / unique title)
|
||||
* - unlink accepts edgeId or from+to (optional kind)
|
||||
* Assigns create_node.id early so later ops in the same batch can reference it.
|
||||
* batch_create: two-pass register all ids/aliases, then resolve parents/relations.
|
||||
*/
|
||||
export function resolveWireOps(
|
||||
rawOps: unknown[],
|
||||
liveNodes: MindNode[],
|
||||
opts?: { anchorId?: string | null; edges?: MindEdge[] },
|
||||
): ResolveOpsResult {
|
||||
const anchorId = opts?.anchorId ?? null;
|
||||
const edges = opts?.edges ?? [];
|
||||
const aliasToId = new Map<string, string>();
|
||||
const textToIds = new Map<string, string[]>();
|
||||
const deletedAliasToId = new Map<string, string>();
|
||||
const deletedTextToIds = new Map<string, string[]>();
|
||||
const knownIds = new Set(liveNodes.filter((n) => !n.deletedAt).map((n) => n.id));
|
||||
const deletedIds = new Set(
|
||||
liveNodes
|
||||
.filter((n) => n.deletedAt && n.id !== anchorId)
|
||||
.map((n) => n.id),
|
||||
);
|
||||
|
||||
const addText = (
|
||||
map: Map<string, string[]>,
|
||||
text: string,
|
||||
id: string,
|
||||
) => {
|
||||
const key = text.trim().toLowerCase();
|
||||
if (!key) return;
|
||||
const list = map.get(key) ?? [];
|
||||
if (!list.includes(id)) list.push(id);
|
||||
map.set(key, list);
|
||||
};
|
||||
|
||||
for (const n of liveNodes) {
|
||||
if (n.id === anchorId) continue;
|
||||
if (n.deletedAt) {
|
||||
addText(deletedTextToIds, n.text, n.id);
|
||||
if (n.alias) deletedAliasToId.set(normalizeAlias(n.alias), n.id);
|
||||
continue;
|
||||
}
|
||||
addText(textToIds, n.text, n.id);
|
||||
if (n.alias) aliasToId.set(normalizeAlias(n.alias), n.id);
|
||||
}
|
||||
|
||||
const resolveAlias = (token: string): ResolveOpsResult | string => {
|
||||
const key = normalizeAlias(token);
|
||||
const id = aliasToId.get(key);
|
||||
if (!id) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `Unknown alias @${key}. Create with alias first, or use mindmap_find.`,
|
||||
};
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
const resolveDeletedAlias = (token: string): ResolveOpsResult | string => {
|
||||
const key = normalizeAlias(token);
|
||||
const id = deletedAliasToId.get(key);
|
||||
if (!id) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `Unknown deleted alias @${key}. Pass the soft-deleted node id from delete_node / history.`,
|
||||
};
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
const resolveDeletedText = (
|
||||
text: string,
|
||||
field: string,
|
||||
): ResolveOpsResult | string => {
|
||||
const key = text.trim().toLowerCase();
|
||||
const ids = deletedTextToIds.get(key) ?? [];
|
||||
if (ids.length === 1) return ids[0]!;
|
||||
if (ids.length > 1) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "ambiguous",
|
||||
message: `${field} "${text}" matches ${ids.length} deleted nodes; use node id`,
|
||||
candidates: ids.slice(0, 8).map((id) => {
|
||||
const n = liveNodes.find((x) => x.id === id);
|
||||
return { id, text: n?.text ?? id };
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `${field} "${text}" matched no deleted node. Soft-deleted ids stay valid for restore_node.`,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveText = (
|
||||
text: string,
|
||||
field: string,
|
||||
): ResolveOpsResult | string => {
|
||||
const key = text.trim().toLowerCase();
|
||||
const ids = textToIds.get(key) ?? [];
|
||||
if (ids.length === 1) return ids[0]!;
|
||||
if (ids.length === 0) {
|
||||
const soft: { id: string; text: string }[] = [];
|
||||
for (const [t, list] of textToIds) {
|
||||
if (t.includes(key) || key.includes(t)) {
|
||||
for (const id of list) {
|
||||
const n = liveNodes.find((x) => x.id === id);
|
||||
soft.push({
|
||||
id,
|
||||
text: n?.text ?? [...textToIds.entries()].find(([, v]) => v.includes(id))?.[0] ?? t,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const uniq = [...new Map(soft.map((s) => [s.id, s])).values()];
|
||||
if (uniq.length === 1) return uniq[0]!.id;
|
||||
if (uniq.length > 1) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "ambiguous",
|
||||
message: `${field}="${text}" matches multiple nodes; use @alias or exact id`,
|
||||
candidates: uniq.slice(0, 8),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `${field}="${text}" matched no node. Create it first or check spelling.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: "ambiguous",
|
||||
message: `${field}="${text}" matches ${ids.length} nodes with the same title; use @alias`,
|
||||
candidates: ids.slice(0, 8).map((id) => ({
|
||||
id,
|
||||
text: liveNodes.find((n) => n.id === id)?.text ?? text,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveNodeRef = (
|
||||
value: unknown,
|
||||
field: string,
|
||||
mode: "live" | "deleted" = "live",
|
||||
): ResolveOpsResult | string | null | undefined => {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
if (!isRefToken(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `${field} must be a string id, @alias, or omitted`,
|
||||
};
|
||||
}
|
||||
if (mode === "deleted") {
|
||||
if (value.startsWith("@")) return resolveDeletedAlias(value);
|
||||
if (deletedIds.has(value)) return value;
|
||||
if (deletedAliasToId.has(normalizeAlias(value))) {
|
||||
return deletedAliasToId.get(normalizeAlias(value))!;
|
||||
}
|
||||
// Exact live id that isn't deleted → clearer error than text miss.
|
||||
if (knownIds.has(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `${field} ${value} is not deleted; restore_node only targets soft-deleted nodes`,
|
||||
};
|
||||
}
|
||||
return resolveDeletedText(value, field);
|
||||
}
|
||||
if (value.startsWith("@")) return resolveAlias(value);
|
||||
if (knownIds.has(value)) return value;
|
||||
if (aliasToId.has(normalizeAlias(value))) {
|
||||
return aliasToId.get(normalizeAlias(value))!;
|
||||
}
|
||||
return resolveText(value, field);
|
||||
};
|
||||
|
||||
const registerCreateFields = (
|
||||
fields: Record<string, unknown>,
|
||||
path: string,
|
||||
): ResolveOpsResult | Record<string, unknown> => {
|
||||
const id =
|
||||
typeof fields.id === "string" && fields.id.length
|
||||
? fields.id
|
||||
: newId("n");
|
||||
fields.id = id;
|
||||
knownIds.add(id);
|
||||
const text = String(fields.text ?? "");
|
||||
const aliasRaw =
|
||||
typeof fields.alias === "string" && fields.alias.trim()
|
||||
? normalizeAlias(fields.alias)
|
||||
: null;
|
||||
if (aliasRaw) {
|
||||
if (aliasToId.has(aliasRaw) && aliasToId.get(aliasRaw) !== id) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `alias "${aliasRaw}" already used by ${aliasToId.get(aliasRaw)} (${path})`,
|
||||
};
|
||||
}
|
||||
fields.alias = aliasRaw;
|
||||
aliasToId.set(aliasRaw, id);
|
||||
}
|
||||
addText(textToIds, text, id);
|
||||
return fields;
|
||||
};
|
||||
|
||||
const resolveCreateParentsAndRelations = (
|
||||
fields: Record<string, unknown>,
|
||||
path: string,
|
||||
): ResolveOpsResult | Record<string, unknown> => {
|
||||
if (fields.parentText != null && fields.parentId == null) {
|
||||
const r = resolveText(String(fields.parentText), `${path}.parentText`);
|
||||
if (typeof r !== "string") return r;
|
||||
fields.parentId = r;
|
||||
}
|
||||
delete fields.parentText;
|
||||
|
||||
const parentResolved = resolveNodeRef(fields.parentId, `${path}.parentId`);
|
||||
if (
|
||||
parentResolved &&
|
||||
typeof parentResolved === "object" &&
|
||||
"ok" in parentResolved
|
||||
) {
|
||||
return parentResolved;
|
||||
}
|
||||
if (parentResolved !== undefined) fields.parentId = parentResolved;
|
||||
|
||||
if (Array.isArray(fields.relations)) {
|
||||
const nextRels: unknown[] = [];
|
||||
for (let ri = 0; ri < fields.relations.length; ri++) {
|
||||
const rel = fields.relations[ri];
|
||||
if (!rel || typeof rel !== "object" || Array.isArray(rel)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `${path}.relations[${ri}] must be an object`,
|
||||
};
|
||||
}
|
||||
const r = { ...(rel as Record<string, unknown>) };
|
||||
if (r.toText != null && r.to == null) {
|
||||
const tr = resolveText(String(r.toText), `${path}.relations[${ri}].toText`);
|
||||
if (typeof tr !== "string") return tr;
|
||||
r.to = tr;
|
||||
}
|
||||
delete r.toText;
|
||||
const toR = resolveNodeRef(r.to, `${path}.relations[${ri}].to`);
|
||||
if (toR && typeof toR === "object" && "ok" in toR) return toR;
|
||||
if (typeof toR === "string") r.to = toR;
|
||||
nextRels.push(r);
|
||||
}
|
||||
fields.relations = nextRels;
|
||||
}
|
||||
return fields;
|
||||
};
|
||||
|
||||
const out: unknown[] = [];
|
||||
const batchTextById = new Map<string, string>();
|
||||
|
||||
for (let i = 0; i < rawOps.length; i++) {
|
||||
const raw = rawOps[i];
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `ops[${i}] must be an object`,
|
||||
};
|
||||
}
|
||||
const op: WireOp = { ...(raw as WireOp) };
|
||||
const type = String(op.type ?? op.op ?? op.operation ?? "");
|
||||
|
||||
if (type === "batch_create") {
|
||||
if (!Array.isArray(op.nodes) || op.nodes.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `ops[${i}].nodes must be a non-empty array`,
|
||||
};
|
||||
}
|
||||
if (op.nodes.length > 200) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `batch_create supports at most 200 nodes (got ${op.nodes.length})`,
|
||||
};
|
||||
}
|
||||
const registered: Record<string, unknown>[] = [];
|
||||
for (let ni = 0; ni < op.nodes.length; ni++) {
|
||||
const nraw = op.nodes[ni];
|
||||
if (!nraw || typeof nraw !== "object" || Array.isArray(nraw)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `ops[${i}].nodes[${ni}] must be an object`,
|
||||
};
|
||||
}
|
||||
const fields = { ...(nraw as Record<string, unknown>) };
|
||||
const reg = registerCreateFields(fields, `ops[${i}].nodes[${ni}]`);
|
||||
if ("ok" in reg && reg.ok === false) return reg as ResolveOpsResult;
|
||||
const f = reg as Record<string, unknown>;
|
||||
batchTextById.set(String(f.id), String(f.text ?? ""));
|
||||
registered.push(f);
|
||||
}
|
||||
const resolvedNodes: Record<string, unknown>[] = [];
|
||||
for (let ni = 0; ni < registered.length; ni++) {
|
||||
const r = resolveCreateParentsAndRelations(
|
||||
registered[ni]!,
|
||||
`ops[${i}].nodes[${ni}]`,
|
||||
);
|
||||
if ("ok" in r && r.ok === false) return r as ResolveOpsResult;
|
||||
resolvedNodes.push(r as Record<string, unknown>);
|
||||
}
|
||||
op.nodes = resolvedNodes;
|
||||
out.push(op);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "batch_link") {
|
||||
if (!Array.isArray(op.edges) || op.edges.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `ops[${i}].edges must be a non-empty array`,
|
||||
};
|
||||
}
|
||||
if (op.edges.length > 100) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `batch_link supports at most 100 edges (got ${op.edges.length})`,
|
||||
};
|
||||
}
|
||||
const resolvedEdges: Record<string, unknown>[] = [];
|
||||
for (let ei = 0; ei < op.edges.length; ei++) {
|
||||
const eraw = op.edges[ei];
|
||||
if (!eraw || typeof eraw !== "object" || Array.isArray(eraw)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `ops[${i}].edges[${ei}] must be an object`,
|
||||
};
|
||||
}
|
||||
const e: Record<string, unknown> = { ...(eraw as Record<string, unknown>) };
|
||||
if (e.fromText != null && e.from == null) {
|
||||
const r = resolveText(String(e.fromText), `edges[${ei}].fromText`);
|
||||
if (typeof r !== "string") return r;
|
||||
e.from = r;
|
||||
}
|
||||
if (e.toText != null && e.to == null) {
|
||||
const r = resolveText(String(e.toText), `edges[${ei}].toText`);
|
||||
if (typeof r !== "string") return r;
|
||||
e.to = r;
|
||||
}
|
||||
delete e.fromText;
|
||||
delete e.toText;
|
||||
const fromR = resolveNodeRef(e.from, `edges[${ei}].from`);
|
||||
if (fromR && typeof fromR === "object" && "ok" in fromR) return fromR;
|
||||
if (typeof fromR === "string") e.from = fromR;
|
||||
const toR = resolveNodeRef(e.to, `edges[${ei}].to`);
|
||||
if (toR && typeof toR === "object" && "ok" in toR) return toR;
|
||||
if (typeof toR === "string") e.to = toR;
|
||||
if (!e.kind) e.kind = "relation";
|
||||
resolvedEdges.push(e);
|
||||
}
|
||||
op.edges = resolvedEdges;
|
||||
out.push(op);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "create_node") {
|
||||
const reg = registerCreateFields(op, `ops[${i}]`);
|
||||
if ("ok" in reg && reg.ok === false) return reg as ResolveOpsResult;
|
||||
Object.assign(op, reg);
|
||||
batchTextById.set(String(op.id), String(op.text ?? ""));
|
||||
const resolved = resolveCreateParentsAndRelations(op, `ops[${i}]`);
|
||||
if ("ok" in resolved && resolved.ok === false) {
|
||||
return resolved as ResolveOpsResult;
|
||||
}
|
||||
Object.assign(op, resolved);
|
||||
out.push(op);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "link") {
|
||||
if (op.fromText != null && op.from == null) {
|
||||
const r = resolveText(String(op.fromText), "fromText");
|
||||
if (typeof r !== "string") return r;
|
||||
op.from = r;
|
||||
}
|
||||
if (op.toText != null && op.to == null) {
|
||||
const r = resolveText(String(op.toText), "toText");
|
||||
if (typeof r !== "string") return r;
|
||||
op.to = r;
|
||||
}
|
||||
delete op.fromText;
|
||||
delete op.toText;
|
||||
|
||||
const fromR = resolveNodeRef(op.from, "from");
|
||||
if (fromR && typeof fromR === "object" && "ok" in fromR) return fromR;
|
||||
if (typeof fromR === "string") op.from = fromR;
|
||||
|
||||
const toR = resolveNodeRef(op.to, "to");
|
||||
if (toR && typeof toR === "object" && "ok" in toR) return toR;
|
||||
if (typeof toR === "string") op.to = toR;
|
||||
out.push(op);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "restore_node") {
|
||||
if (op.nodeText != null && op.nodeId == null) {
|
||||
const r = resolveDeletedText(String(op.nodeText), "nodeText");
|
||||
if (typeof r !== "string") return r;
|
||||
op.nodeId = r;
|
||||
}
|
||||
delete op.nodeText;
|
||||
const r = resolveNodeRef(op.nodeId, "nodeId", "deleted");
|
||||
if (r && typeof r === "object" && "ok" in r) return r;
|
||||
if (typeof r === "string") op.nodeId = r;
|
||||
out.push(op);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "unlink") {
|
||||
if (op.fromText != null && op.from == null) {
|
||||
const r = resolveText(String(op.fromText), "fromText");
|
||||
if (typeof r !== "string") return r;
|
||||
op.from = r;
|
||||
}
|
||||
if (op.toText != null && op.to == null) {
|
||||
const r = resolveText(String(op.toText), "toText");
|
||||
if (typeof r !== "string") return r;
|
||||
op.to = r;
|
||||
}
|
||||
delete op.fromText;
|
||||
delete op.toText;
|
||||
|
||||
if (!op.edgeId) {
|
||||
const fromR = resolveNodeRef(op.from, "from");
|
||||
if (fromR && typeof fromR === "object" && "ok" in fromR) return fromR;
|
||||
if (typeof fromR === "string") op.from = fromR;
|
||||
|
||||
const toR = resolveNodeRef(op.to, "to");
|
||||
if (toR && typeof toR === "object" && "ok" in toR) return toR;
|
||||
if (typeof toR === "string") op.to = toR;
|
||||
|
||||
const from = typeof op.from === "string" ? op.from : null;
|
||||
const to = typeof op.to === "string" ? op.to : null;
|
||||
if (!from || !to) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message:
|
||||
"unlink needs edgeId, or from+to (id|@alias|title). Optional kind: hierarchy|relation.",
|
||||
};
|
||||
}
|
||||
const kind =
|
||||
op.kind === "hierarchy" || op.kind === "relation"
|
||||
? op.kind
|
||||
: undefined;
|
||||
const match = (a: string, b: string) =>
|
||||
edges.filter(
|
||||
(e) =>
|
||||
!e.deletedAt &&
|
||||
e.from === a &&
|
||||
e.to === b &&
|
||||
(kind == null || e.kind === kind),
|
||||
);
|
||||
let hits = match(from, to);
|
||||
if (!hits.length) hits = match(to, from);
|
||||
if (!hits.length) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "invalid_op",
|
||||
message: `No live edge between ${from} and ${to}${kind ? ` (kind=${kind})` : ""}. Use mindmap_get_edge / spatial_context.edges for edgeId.`,
|
||||
};
|
||||
}
|
||||
if (hits.length > 1) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "ambiguous",
|
||||
message: `${hits.length} edges between those nodes; pass edgeId or kind`,
|
||||
candidates: hits.slice(0, 8).map((e) => ({
|
||||
id: e.id,
|
||||
text: `${e.kind}${e.label ? `:${e.label}` : ""} ${e.from}→${e.to}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
op.edgeId = hits[0]!.id;
|
||||
}
|
||||
delete op.from;
|
||||
delete op.to;
|
||||
delete op.kind;
|
||||
out.push(op);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op.nodeText != null && op.nodeId == null) {
|
||||
const r = resolveText(String(op.nodeText), "nodeText");
|
||||
if (typeof r !== "string") return r;
|
||||
op.nodeId = r;
|
||||
}
|
||||
delete op.nodeText;
|
||||
|
||||
for (const field of [
|
||||
"nodeId",
|
||||
"newParentId",
|
||||
"afterSiblingId",
|
||||
"from",
|
||||
"to",
|
||||
"parentId",
|
||||
] as const) {
|
||||
if (op[field] === undefined) continue;
|
||||
const r = resolveNodeRef(op[field], field);
|
||||
if (r && typeof r === "object" && "ok" in r) return r;
|
||||
if (r !== undefined) op[field] = r;
|
||||
}
|
||||
|
||||
out.push(op);
|
||||
}
|
||||
|
||||
const nodeIdMap: Record<string, string> = {};
|
||||
for (const [alias, id] of aliasToId) {
|
||||
nodeIdMap[`@${alias}`] = id;
|
||||
nodeIdMap[alias] = id;
|
||||
}
|
||||
for (const [text, ids] of textToIds) {
|
||||
if (ids.length === 1) {
|
||||
const id = ids[0]!;
|
||||
const label =
|
||||
batchTextById.get(id) ??
|
||||
liveNodes.find((n) => n.id === id)?.text ??
|
||||
text;
|
||||
nodeIdMap[label] = id;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, ops: out, nodeIdMap };
|
||||
}
|
||||
|
||||
/** Build text/alias → id map from a snapshot (for responses). */
|
||||
export function buildNodeIdMap(
|
||||
nodes: MindNode[],
|
||||
opts?: { anchorId?: string | null; onlyIds?: Set<string> },
|
||||
): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
const anchorId = opts?.anchorId ?? null;
|
||||
for (const n of nodes) {
|
||||
if (n.deletedAt || n.id === anchorId) continue;
|
||||
if (opts?.onlyIds && !opts.onlyIds.has(n.id)) continue;
|
||||
map[n.text] = n.id;
|
||||
if (n.alias) {
|
||||
map[n.alias] = n.id;
|
||||
map[`@${n.alias}`] = n.id;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
219
packages/shared/src/types.ts
Normal file
219
packages/shared/src/types.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const StylePresetSchema = z.enum([
|
||||
"default",
|
||||
"title",
|
||||
"decision",
|
||||
"risk",
|
||||
"note",
|
||||
"muted",
|
||||
"card",
|
||||
]);
|
||||
export type StylePreset = z.infer<typeof StylePresetSchema>;
|
||||
|
||||
export const EdgeKindSchema = z.enum(["hierarchy", "relation"]);
|
||||
export type EdgeKind = z.infer<typeof EdgeKindSchema>;
|
||||
|
||||
export const DensitySchema = z.enum(["comfortable", "compact"]);
|
||||
export type Density = z.infer<typeof DensitySchema>;
|
||||
|
||||
export const RiskProfileSchema = z.enum(["fast", "strict"]);
|
||||
export type RiskProfile = z.infer<typeof RiskProfileSchema>;
|
||||
|
||||
export const AgentModeSchema = z.enum(["explore", "edit", "critique"]);
|
||||
export type AgentMode = z.infer<typeof AgentModeSchema>;
|
||||
|
||||
export const NodeBadgesSchema = z.enum(["off", "icons", "tags"]);
|
||||
export type NodeBadges = z.infer<typeof NodeBadgesSchema>;
|
||||
|
||||
export const InspectorModeSchema = z.enum(["selection", "always"]);
|
||||
export type InspectorMode = z.infer<typeof InspectorModeSchema>;
|
||||
|
||||
/** Whole-canvas node chrome: bubble titles vs property cards. */
|
||||
export const NodeViewModeSchema = z.enum(["bubble", "card"]);
|
||||
export type NodeViewMode = z.infer<typeof NodeViewModeSchema>;
|
||||
|
||||
/** Controlled node icon set (agent/UI). */
|
||||
export const NodeIconSchema = z.enum([
|
||||
"server",
|
||||
"database",
|
||||
"cloud",
|
||||
"person",
|
||||
"folder",
|
||||
"doc",
|
||||
"link",
|
||||
"warning",
|
||||
"check",
|
||||
"star",
|
||||
"gear",
|
||||
"globe",
|
||||
]);
|
||||
export type NodeIcon = z.infer<typeof NodeIconSchema>;
|
||||
|
||||
export const EdgeLineStyleSchema = z.enum(["solid", "dashed", "dotted"]);
|
||||
export type EdgeLineStyle = z.infer<typeof EdgeLineStyleSchema>;
|
||||
|
||||
/** Arrowheads: forward = to only, both = ends, none = no arrows. */
|
||||
export const EdgeDirectionSchema = z.enum(["forward", "both", "none"]);
|
||||
export type EdgeDirection = z.infer<typeof EdgeDirectionSchema>;
|
||||
|
||||
export const SearchScopeSchema = z.enum([
|
||||
"text",
|
||||
"note",
|
||||
"tags",
|
||||
"description",
|
||||
"edge_label",
|
||||
"edge_note",
|
||||
"all",
|
||||
]);
|
||||
export type SearchScope = z.infer<typeof SearchScopeSchema>;
|
||||
|
||||
/** Free-form structured knowledge blob (JSON object). */
|
||||
export const DescriptionSchema = z.record(z.string(), z.unknown());
|
||||
export type NodeDescription = z.infer<typeof DescriptionSchema>;
|
||||
|
||||
/** #RGB or #RRGGBB */
|
||||
export const AccentColorSchema = z
|
||||
.string()
|
||||
.regex(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/);
|
||||
export type AccentColor = z.infer<typeof AccentColorSchema>;
|
||||
|
||||
export const ViewportSchema = z.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
zoom: z.number(),
|
||||
});
|
||||
export type Viewport = z.infer<typeof ViewportSchema>;
|
||||
|
||||
export const LinkRefSchema = z.object({
|
||||
url: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
});
|
||||
export type LinkRef = z.infer<typeof LinkRefSchema>;
|
||||
|
||||
export const CanvasPrefsSchema = z.object({
|
||||
themeId: z.string().default("abyss"),
|
||||
branchColoring: z.boolean().default(true),
|
||||
density: DensitySchema.default("comfortable"),
|
||||
riskProfile: RiskProfileSchema.default("fast"),
|
||||
showRelationEdges: z.boolean().default(true),
|
||||
nodeBadges: NodeBadgesSchema.default("icons"),
|
||||
showHoverCard: z.boolean().default(true),
|
||||
inspectorMode: InspectorModeSchema.default("selection"),
|
||||
nodeViewMode: NodeViewModeSchema.default("card"),
|
||||
});
|
||||
export type CanvasPrefs = z.infer<typeof CanvasPrefsSchema>;
|
||||
|
||||
export const NodeSchema = z.object({
|
||||
id: z.string(),
|
||||
canvasId: z.string(),
|
||||
text: z.string(),
|
||||
/** Longer free-text body. */
|
||||
note: z.string().optional(),
|
||||
/** Structured knowledge fields (JSON object; keys free-form). */
|
||||
description: DescriptionSchema.optional(),
|
||||
collapsed: z.boolean().default(false),
|
||||
pinned: z.boolean().default(false),
|
||||
pos: z.object({ x: z.number(), y: z.number() }).nullable().optional(),
|
||||
sidePref: z.union([z.literal(-1), z.literal(0), z.literal(1)]).default(0),
|
||||
stylePreset: StylePresetSchema.default("default"),
|
||||
version: z.number().int().nonnegative(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
links: z.array(LinkRefSchema).default([]),
|
||||
imageUrl: z.string().optional(),
|
||||
dueAt: z.number().optional(),
|
||||
startAt: z.number().optional(),
|
||||
/** Optional accent stroke/fill override (#RGB / #RRGGBB). */
|
||||
accentColor: AccentColorSchema.optional(),
|
||||
/** Optional controlled icon id. */
|
||||
icon: NodeIconSchema.optional(),
|
||||
/** Stable agent-facing alias (unique per canvas, lowercase). */
|
||||
alias: z.string().min(1).max(64).optional(),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
deletedAt: z.number().nullable().optional(),
|
||||
});
|
||||
export type MindNode = z.infer<typeof NodeSchema>;
|
||||
|
||||
export const EdgeSchema = z.object({
|
||||
id: z.string(),
|
||||
canvasId: z.string(),
|
||||
from: z.string(),
|
||||
to: z.string(),
|
||||
kind: EdgeKindSchema,
|
||||
isPrimaryParent: z.boolean().default(false),
|
||||
order: z.number().default(0),
|
||||
label: z.string().optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
note: z.string().optional(),
|
||||
/** Relative stroke weight 1–5 (default 1). */
|
||||
weight: z.number().min(1).max(5).optional(),
|
||||
lineStyle: EdgeLineStyleSchema.optional(),
|
||||
direction: EdgeDirectionSchema.optional(),
|
||||
version: z.number().int().nonnegative(),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
deletedAt: z.number().nullable().optional(),
|
||||
});
|
||||
export type MindEdge = z.infer<typeof EdgeSchema>;
|
||||
|
||||
export const CanvasMetaSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
focusNodeId: z.string().nullable(),
|
||||
/** Hidden system root — not shown to users/agents; top-level nodes hang under it. */
|
||||
anchorNodeId: z.string().nullable().optional(),
|
||||
viewport: ViewportSchema,
|
||||
rev: z.number().int().nonnegative(),
|
||||
prefs: CanvasPrefsSchema,
|
||||
agentMode: AgentModeSchema.default("edit"),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
deletedAt: z.number().nullable().optional(),
|
||||
});
|
||||
export type CanvasMeta = z.infer<typeof CanvasMetaSchema>;
|
||||
|
||||
export type GraphSnapshot = {
|
||||
canvas: CanvasMeta;
|
||||
nodes: MindNode[];
|
||||
edges: MindEdge[];
|
||||
};
|
||||
|
||||
export const ErrorCodeSchema = z.enum([
|
||||
"not_found",
|
||||
"version_conflict",
|
||||
"ambiguous",
|
||||
"budget_exceeded",
|
||||
"invalid_op",
|
||||
"proposal_stale",
|
||||
"cycle_rejected",
|
||||
"permission_denied",
|
||||
"mode_denied",
|
||||
]);
|
||||
export type ErrorCode = z.infer<typeof ErrorCodeSchema>;
|
||||
|
||||
export const TOOL_NAMES = [
|
||||
"mindmap_list_canvases",
|
||||
"mindmap_get_context",
|
||||
"mindmap_get_spatial_context",
|
||||
"mindmap_get_subgraph",
|
||||
"mindmap_find",
|
||||
"mindmap_find_and_focus",
|
||||
"mindmap_query",
|
||||
"mindmap_get_node",
|
||||
"mindmap_export",
|
||||
"mindmap_history",
|
||||
"mindmap_redo",
|
||||
"mindmap_snapshot",
|
||||
"mindmap_restore_snapshot",
|
||||
"mindmap_propose",
|
||||
"mindmap_revise_proposal",
|
||||
"mindmap_accept_proposal",
|
||||
"mindmap_reject_proposal",
|
||||
"mindmap_apply_direct",
|
||||
"mindmap_undo",
|
||||
"mindmap_recent_changes",
|
||||
"mindmap_focus",
|
||||
"mindmap_set_mode",
|
||||
] as const;
|
||||
export type ToolName = (typeof TOOL_NAMES)[number];
|
||||
9
packages/shared/tsconfig.json
Normal file
9
packages/shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
1
packages/shared/tsconfig.tsbuildinfo
Normal file
1
packages/shared/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue