Initial commit: DSH mind-map canvas monorepo

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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