feat: add topology view mode with router icons and link-style edges

Canvas-global nodeViewMode (bubble/card/topology) can be set at create or via prefs; topology draws NE routers with straight blue center links and exposes router icon to agents.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-12 22:35:53 +08:00
parent 200c8bd450
commit 74f0d8e78a
15 changed files with 322 additions and 103 deletions

View file

@ -139,7 +139,7 @@ export function mindmapToolDefinitions(getBase, getCanvasIdForSession, setActive
{
name: 'mindmap_create_canvas',
description:
'Create a brand-new mind-map canvas (new id; does NOT overwrite existing canvases), bind it to this chat session, and open it in the 思维导图 tab. Canvas starts visually empty. Top-level nodes: omit parentId.',
'Create a brand-new mind-map canvas (new id; does NOT overwrite existing canvases), bind it to this chat session, and open it in the 思维导图 tab. Canvas starts visually empty. Top-level nodes: omit parentId. Optional nodeViewMode (bubble|card|topology) is a canvas-global preference (can also be changed later via set_prefs).',
parameters: {
type: 'object',
additionalProperties: false,
@ -150,6 +150,12 @@ export function mindmapToolDefinitions(getBase, getCanvasIdForSession, setActive
description:
'Optional internal label / title sync only — there is no visible root node. Prefer title alone.',
},
nodeViewMode: {
type: 'string',
enum: ['bubble', 'card', 'topology'],
description:
'Canvas-global chrome (bubble|card|topology). Prefer setting when creating a topology map; default card. topology = router icons + straight blue links.',
},
},
},
output: { schema: OBJECT, render: (_a, v) => jsonText(v) },
@ -636,6 +642,7 @@ There is NO visible root node. Canvas title is metadata only. Internally top-lev
When the user asks to build a map (e.g. 「输出红楼梦人物关系图」):
1. mindmap_create_canvas({ title: "红楼梦人物关系" }) — ALWAYS creates a NEW canvas id (never overwrites an old one). Opens the right tab on that new canvas.
Topology diagrams: mindmap_create_canvas({ title: "…拓扑", nodeViewMode: "topology" }) then nodes with icon:"router".
2. mindmap_get_spatial_context — orient on the NEW empty canvas
3. mindmap_apply_direct({ ops: [ {type:"batch_create", nodes:[...]}, {type:"auto_fit"} ] })
4. Keep adding in small batches; never dump a full tree JSON overwrite

View file

@ -123,6 +123,7 @@ function promoteChildrenOnDelete(g: MutableGraph, nodeId: string): Op[] {
export function createEmptyGraph(
title = "未命名画布",
rootText?: string,
prefsOverride?: Partial<CanvasPrefs>,
): MutableGraph {
const canvasId = newId("c");
const nodeId = newId("n");
@ -137,6 +138,7 @@ export function createEmptyGraph(
showHoverCard: true,
inspectorMode: "selection",
nodeViewMode: "card",
...prefsOverride,
};
const canvas: CanvasMeta = {
id: canvasId,

View file

@ -276,8 +276,12 @@ CREATE INDEX IF NOT EXISTS idx_snapshots_canvas ON snapshots(canvas_id);
};
}
createCanvas(title?: string, rootText?: string): MutableGraph {
const g = createEmptyGraph(title ?? "未命名画布", rootText);
createCanvas(
title?: string,
rootText?: string,
prefsOverride?: Partial<CanvasMeta["prefs"]>,
): MutableGraph {
const g = createEmptyGraph(title ?? "未命名画布", rootText, prefsOverride);
this.persistGraph(g, true);
this.cache.set(g.canvas.id, g);
return g;

View file

@ -12,7 +12,7 @@ export type LayoutInput = {
edges: MindEdge[];
focusNodeId: string;
density?: Density;
/** Canvas-wide bubble vs card chrome (affects box sizes). */
/** Canvas-wide bubble vs card vs topology chrome (affects box sizes). */
nodeViewMode?: NodeViewMode;
/** Previous frame positions — reused when hierarchy structure is unchanged. */
prevPositions?: PositionMap;
@ -52,8 +52,18 @@ export function nodeLayoutSize(
| "pinned"
>,
dens: Dens,
opts?: { asCard?: boolean },
opts?: { asCard?: boolean; asTopology?: boolean },
): { w: number; h: number } {
if (opts?.asTopology) {
const icon = dens.nodeW >= 200 ? 36 : 30;
const caption = dens.nodeW >= 200 ? 20 : 18;
const gap = 6;
const pad = dens.nodeW >= 200 ? 8 : 6;
return {
w: Math.max(dens.nodeW >= 200 ? 88 : 72, icon + pad * 2),
h: icon + gap + caption + pad,
};
}
let w = dens.nodeW;
let h = dens.nodeH;
// meta strip (badges / tags / due)
@ -248,7 +258,7 @@ function buildSubtree(
children: Map<string, string[]>,
dens: Dens,
path: Set<string>,
asCard: boolean,
sizeOpts: { asCard?: boolean; asTopology?: boolean },
childFilter?: (childId: string) => boolean,
): FlexDatum | null {
if (path.has(id)) return null;
@ -266,12 +276,12 @@ function buildSubtree(
children,
dens,
nextPath,
asCard,
sizeOpts,
);
if (child) kids.push(child);
}
}
const box = nodeLayoutSize(node, dens, { asCard });
const box = nodeLayoutSize(node, dens, sizeOpts);
return {
id,
// flextree size = full box + gutters (claim real space, not a rigid grid cell)
@ -397,6 +407,7 @@ export function hierarchyFingerprint(edges: MindEdge[]): string {
export function layout(input: LayoutInput): PositionMap {
const dens = DENSITY[input.density ?? "comfortable"];
const asCard = input.nodeViewMode === "card";
const asTopology = input.nodeViewMode === "topology";
const nodesById = new Map(input.nodes.map((n) => [n.id, n]));
if (!nodesById.has(input.focusNodeId)) return {};
@ -407,7 +418,7 @@ export function layout(input: LayoutInput): PositionMap {
const sizes = new Map<string, { w: number; h: number }>();
for (const n of input.nodes) {
if (n.deletedAt || hidden.has(n.id)) continue;
sizes.set(n.id, nodeLayoutSize(n, dens, { asCard }));
sizes.set(n.id, nodeLayoutSize(n, dens, { asCard, asTopology }));
}
const prev = input.prevPositions;
@ -501,7 +512,7 @@ export function layout(input: LayoutInput): PositionMap {
children,
dens,
new Set(),
asCard,
{ asCard, asTopology },
(childId) => sideOf.get(childId) === side,
);
if (!tree?.children?.length) continue;

View file

@ -342,6 +342,12 @@ export const OP_SCHEMA_DOC = {
],
_comment: "Bulk relation/hierarchy links (max 100). Prefer after nodes exist.",
},
{
type: "create_node",
text: "核心交换机",
alias: "core_sw",
icon: "router",
},
{
type: "create_node",
text: "AIOps诊断",
@ -494,9 +500,11 @@ export const OP_SCHEMA_DOC = {
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.",
"Omit parentId for top-level. Optional pos pins immediately. Optional alias. Optional icon (server|database|cloud|person|folder|doc|link|warning|check|star|gear|globe|router). 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.",
"Patch note/description/tags/links/imageUrl/dueAt/startAt/accentColor/icon/alias. icon enum same as create_node. description is a JSON object (knowledge fields). Null clears scalar fields. links = URL bookmarks.",
set_prefs:
"Partial canvas prefs: density, branchColoring, nodeViewMode (bubble|card|topology; canvas-global), showHoverCard, nodeBadges, showRelationEdges, riskProfile, themeId, inspectorMode.",
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.",

View file

@ -29,8 +29,8 @@ 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"]);
/** Whole-canvas node chrome: bubble titles vs property cards vs topology icons. */
export const NodeViewModeSchema = z.enum(["bubble", "card", "topology"]);
export type NodeViewMode = z.infer<typeof NodeViewModeSchema>;
/** Controlled node icon set (agent/UI). */
@ -47,6 +47,7 @@ export const NodeIconSchema = z.enum([
"star",
"gear",
"globe",
"router",
]);
export type NodeIcon = z.infer<typeof NodeIconSchema>;