mirror of
https://github.com/hansjone/mind-map.git
synced 2026-10-10 05:03:17 +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
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