跳到主要内容

记忆图可视化:力导向布局 + 版本链 + 增量渲染

30 秒导读: @supermemory/memory-graph 是 Supermemory 里少见的、自带非平凡算法的一章——仓库其余部分多是 API 编排,这里却要在 canvas 上把上万个「文档 / 记忆 / 版本」节点排布成一张会呼吸的力导向图。它解决三件难事:用物理引力自动摆节点把同一事实的历次版本串成一条时间线、以及在几万节点下仍不掉帧


1. 这是什么(零基础也能懂)

  • 一句话定义: 一个纯 canvas 的力导向图组件,把 Supermemory 的记忆数据画成一张可缩放、可拖拽、可点选的「知识星系」。

  • 它画的是什么数据: 上游返回的是「文档」和挂在文档下的「记忆」,以及记忆之间的关系(见 01-data-model.md 里的 GraphApiDocument / GraphApiMemory)。本章把这些数据字段视为已知,只讲怎么画、怎么排、怎么导航

  • 给谁用:

    • Supermemory 的 web 控制台(apps/web)直接 import { MemoryGraph } from "@supermemory/memory-graph"
    • MCP 服务器把它包成一个 App UI 工具,让任意 AI 助手弹出这张图(见 §7,回指 02-mcp-server.md)。
  • 用起来什么样: 一个 React 组件,喂一份 documents 就能跑——

// 示意,非源码:最小用法
import { MemoryGraph } from "@supermemory/memory-graph"

<MemoryGraph
documents={documents} // GraphApiDocument[],见 01 章
variant="console" // 控制台样式(另有 consumer)
onOpenDocument={(id) => openModal(id)}
/>
  • 一句话直觉/类比: 把它想成太阳系:每个文档是一颗恒星,它抽出的记忆是绕它转的行星;引力(力导向)让恒星彼此推开、行星各就各位;而「记忆被更新」这件事,画成一条从旧行星指向新行星的箭头——一整条箭头链就是这条事实的「进化史」。

本节到此不碰代码。下面开始拆内部。


2. 顶层全景(它大概怎么转)

2.1 一条数据从「文档数组」到「屏幕像素」

图默认从左读到右,命中即进入下一环:

documents: GraphApiDocument[]


① 装配 useGraphData ──► nodes[] + edges[]
· 螺旋铺初始坐标、记忆绕文档做轨道
· BFS 连通分量 → 聚类上色
· 增量:复用上一帧已在的节点坐标


② 布局 ForceSimulation ──► 每帧微调 node.x / node.y
· d3-force:只喂「结构边」产生引力
· 节点 >6000 → 静态布局(算一次就停)


③ 渲染 GraphCanvas(单条 rAF 循环)
· viewport 世界→屏幕变换
· renderFrame:按颜色批处理 + LOD 采样
· 无变化就跳帧


④ 交互 InputHandler + SpatialIndex
· 网格空间命中 → hover / click / drag
· 拖拽给节点钉住 fx/fy,反哺 ①②

2.2 部件一句话职责

部件干什么文件
useGraphData把 documents 装配成 nodes[]/edges[],含初始布局与聚类hooks/use-graph-data.ts
ForceSimulationd3-force 力导向布局引擎canvas/simulation.ts
VersionChainIndexparentMemoryId 把版本串成链canvas/version-chain.ts
ViewportState平移 / 缩放 / 惯性 / 世界↔屏幕坐标canvas/viewport.ts
SpatialIndex网格空间索引,做鼠标命中canvas/hit-test.ts
InputHandler鼠标 / 触摸 / 滚轮事件 → 回调canvas/input-handler.ts
renderFrame逐帧把节点 / 边画到 canvascanvas/renderer.ts
drawDocIconcanvas 原生矢量文档图标canvas/document-icons.ts
GraphCanvasReact 壳:持有引擎实例 + rAF 循环components/graph-canvas.tsx
MemoryGraph顶层 React 壳:编排数据 / 布局 / 交互 / 弹窗components/memory-graph.tsx

关键分工原则(整章的暗线): React 只管会影响 DOM 的状态(hover、选中、缩放数字);节点坐标这类每帧都变的东西全走 useRef + rAF,绝不进 React state,否则每帧一次 setState 会把浏览器拖垮(graph-canvas.tsx:58-70 把所有可变渲染态塞进一个 ref)。


3. 核心原理之一:力导向布局(ForceSimulation)

3.1 它要解决的小问题

给你一堆节点和「谁连谁」的边,没有人告诉你每个节点该放哪。力导向布局的思路:把它当物理系统——节点互相排斥(像同极磁铁),边像弹簧把两端拉近,再加一点向心力别让它们飘走。迭代若干轮,系统自然「松弛」到一个好看又不重叠的布局。这里直接用 d3 的 d3-force

3.2 精华一:只有「结构边」参与引力,extends 边纯视觉

这是全章最关键的一个设计决策。图里有三类边(边的语义见 01-data-model.md):

边类型含义是否产生引力
derives文档 → 它抽出的记忆(结构)
updates旧记忆 → 新版本记忆(结构)
extends同一 spaceId 下文档互相关联否,纯视觉

为什么要把 extends 排除?因为 extends 往往把一大票文档两两相连,若让它也产生引力,会把整片文档吸成一坨,图就糊了。所以布局初始化时直接过滤掉:

// simulation.ts:17 —— 只用结构边参与力布局
const structuralEdges = edges.filter((e) => e.edgeType !== "extends")

ForceSimulation.init(canvas/simulation.ts:10-81)在 structuralEdges 上装配四种力,参数集中在 FORCE_CONFIG(constants.ts:10-31):

作用关键参数
forceLink边=弹簧,按边类型给不同距离/强度updates 强度 0.6、derives 0.35、其它 0.05
forceManyBody节点互相排斥chargeStrength: -2400
forceCollide防重叠的硬碰撞半径文档 80 / 记忆 48
forceX / forceY轻微向心,别飘出画面centeringStrength: 0.06

derives 边的弹簧长度还会随文档下记忆数量变长(记忆多的文档需要更大轨道),用 sqrt(memoryCount) 缩放并封顶(getDocMemoryDistance,simulation.ts:115-130)。

3.3 精华二:DENSE_GRAPH_STATIC_THRESHOLD —— 上万节点就「算一次就停」

力导向每帧都要 O(n log n) 迭代,节点一多就必然掉帧。这里有个直白的性能开关:节点数超过 6000 就放弃「持续物理动画」,改成静态布局——同步预跑几帧、算出坐标、然后彻底停住。

// simulation.ts:5
export const DENSE_GRAPH_STATIC_THRESHOLD = 6000

init 里的分支(simulation.ts:64-76):先 stop() 再手动 tick 若干次(密集图只跑 densePreSettleTicks = 12,普通图跑 preSettleTicks = 150),然后——

预跑 preSettleTicks 帧(同步 tick)

节点数 > 6000 ?
┌────────────┴────────────┐
是 否
this.stop() alphaTarget(0).restart()
(坐标定死,不再动) (交给 rAF 继续松弛,自然冷却)

再往上一层,React 壳里节点超阈值时根本不创建 simulation 实例(memory-graph.tsx:137-143):直接用 useGraphData 算出的螺旋初始坐标当最终坐标,连一次力都不跑。渲染层也配套降级——renderer.ts:439-441densePointMode 在节点 >25000 且缩得很小时,把记忆退化成纯色小圆点批量画。

3.4 精华三:增量热更新,不重排已有节点

分页加载「再来一页文档」时,如果每次都 init 重跑力导向,已经摆好的节点会整体乱抖,体验极差。所以区分三种情形(memory-graph.tsx:145-168):

情形判定动作
首次加载 / 节点集大改无历史,或旧 id 不再是新集子集sim.init() 全量重排
纯追加(append-only)旧 id 全部仍在,只是多了新 idsim.update() 热插入 + stop(),老坐标不动
数据同引用id 没变sim.update() 走个过场

ForceSimulation.update(simulation.ts:83-89)只是 sim.nodes(...) + 换掉 link 力的边集(同样过滤 extends),不重置 alpha,所以老节点不会被重新加热甩飞。新节点的落点由 useGraphDatagetAppendPosition 在已有节点外围找空位(use-graph-data.ts:297-343,金角螺旋 + 网格避让)。

坑: 拖拽时 InputHandler 会给节点钉上 fx/fy(固定坐标),松手才置回 null(input-handler.ts:124-128177-184)。d3-force 见到 fx/fy 就把该节点当锚点——这正是「拖一个节点、其它节点让位」效果的来源。


4. 核心原理之二:版本链索引(VersionChainIndex)

4.1 它要解决的小问题

Supermemory 会自动遗忘、矛盾更新记忆:同一个事实(「我住在北京」→「我搬到上海了」)会产生多条记忆,新的用 parentMemoryId 指向旧的。UI 要能把这一整条进化史摆出来,让用户点一个节点就看到「它是 v3,前面还有 v1、v2」。这就是 VersionChainIndex 干的活——它是「自动遗忘 / 矛盾更新」在界面上的呈现。

4.2 数据结构:三张表 + 增量重建

VersionChainIndex(canvas/version-chain.ts:11-106)维护三张表:

类型用途
memoryMapMap<id, GraphApiMemory>id → 记忆本体
childrenMapMap<parentId, childId[]>反向索引:谁的孩子是谁
cacheMap<id, ChainEntry[]>已算好的链,链上每个 id 都指向同一份

增量精华:引用相等短路。 rebuild 第一行就是——

// version-chain.ts:18 —— documents 引用没变就直接返回,零开销
if (documents === this.lastDocs) return

这让上层可以在每次 render 里无脑调用 chainIndex.current.rebuild(limitedDocuments)(memory-graph.tsx:122),数据没变时它就是个 no-op——省去了放进 useEffect 的时序麻烦(注释见 memory-graph.tsx:118-121)。

4.3 算法:先回溯到根,再前探到最新

getChain(memoryId)(version-chain.ts:39-105)分两步走,产出一条从 v1 到最新的有序链:

选中节点 mem

① 向后回溯到根(沿 parentMemoryId)
mem → parent → parent ... → root
收集后 reverse ⇒ [root .. selected]

② 向前前探到最新(沿 childrenMap 第一个孩子)
selected → child → child ... → latest

拼接 [root..selected] + [selected+1..latest]

长度 ≤ 1 ⇒ 返回 null(孤立 v1 不算链)

两处细节值得记:

  • 前探只走「第一个孩子」(version-chain.ts:63-75):版本链设计上是线性的(一父一子);万一数据分叉,只取文档顺序里的第一支。visited 集合同时防成环(version-chain.ts:5071)。

  • 版本号会兜底修复(version-chain.ts:83-98):后端有时把整条链的 version 都写成 1。这里用「单调递增」规则重算显示版本号——只在后端值不可信(不递增)时才覆盖,合法值原样保留。测试 version-chain.test.ts 明确验证了这两种情况:「后端整条 v1」被修成 [1,2],而「合法的 v1/v5/v5」只修坏的那一格。

产出的每条 ChainEntry(version-chain.ts:3-9)带 { version, isForgotten, isLatest },直接喂给弹窗里的版本时间线渲染(node-hover-popover.tsx:226-302VersionTimeline,遗忘的版本号标红、当前项高亮)。方向键上下浏览版本链也走它(memory-graph.tsx:466-507navigateUp/navigateDown)。

4.4 版本状态如何画出来

链里的状态在 canvas 上有专门的视觉语言(renderer.ts:763-858drawMemoryNode):

记忆状态判定字段画法
已被取代(非最新)isLatest === false半透明 + 虚线边 + 一道斜划线(strikethrough)
已遗忘isForgotten红色 X 图标
在更新链里parentMemoryId 或关系含 updates右上角一个紫色「更新」小徽标(drawUpdateMarker)

isMemoryInUpdateChain(renderer.ts:860-866)判定一个记忆是否属于更新链——这让「更新链成员」即使没被选中也带上视觉标记。


5. 深入实现:渲染与交互引擎

这一节是给要读源码的人的密集走读。核心是:一条 rAF 循环 + 全 ref 驱动 + 能跳帧就跳帧

5.1 单条渲染循环 + 跳帧

GraphCanvas 挂载时启动唯一一条 requestAnimationFrame 循环,跑完组件生命周期(graph-canvas.tsx:203-318)。每帧顺序:

tick():
1. viewport.tick() —— 惯性平移 / 弹簧缩放 / lerp 平移,返回是否在动
2. dim 动画缓动 —— 选中时非选中节点渐暗
3. simulation.isActive() —— 力导向还在跑吗
4. 位置变了才重建空间索引
── 若以上全 false 且无 renderNeeded ⇒ return(跳过整帧)
5. renderFrame(...)

跳帧是最关键的省电优化(graph-canvas.tsx:242-251):静止状态下(没在平移、力已停、没在渐暗、空间索引没变、没有外部请求重绘),tick 直接 return,一根手指没动就一帧都不画。renderNeeded 这个 ref 由 hover / 选中 / resize / 数据变更等各处置 true。

高 DPI 处理:canvas 物理像素按 devicePixelRatio 放大,但封顶 16384 防超大屏爆显存(graph-canvas.tsx:181-199)。

5.2 视口:世界坐标 ↔ 屏幕坐标

ViewportState(canvas/viewport.ts)是一层仿射变换 + 一堆动画状态。核心两个函数就是简单的缩放平移:

// viewport.ts:31-43 —— 世界↔屏幕互转
worldToScreen(wx, wy) { return { x: wx*zoom + panX, y: wy*zoom + panY } }
screenToWorld(sx, sy) { return { x: (sx-panX)/zoom, y: (sy-panY)/zoom } }

tick()(viewport.ts:120-159)每帧推进三种动画,任一在动就返回 true驱动重绘:

  • 惯性平移:松手后按 friction = 0.92 衰减速度(甩一下会滑行)。
  • 弹簧缩放:zoomtargetZoomzoomSpring = 0.15 逼近,并保持鼠标锚点不动(zoomImmediate/tick 都在缩放后重算 pan 以锚定 screenToWorld(anchor))。
  • lerp 平移:centerOn / fitToNodes 设的目标位,按 panLerp = 0.12 平滑靠近。

fitToNodes(viewport.ts:71-86)算出所有节点包围盒,反解出「刚好铺满 + 10% 边距」的缩放与居中;setMinZoomForNodes 据此把最小缩放放宽到能看全整图。

5.3 空间命中:网格哈希 + 3×3 邻域

鼠标落在哪个节点上?逐个节点算距离是 O(n),这里用固定网格空间索引(SpatialIndex,canvas/hit-test.ts)。世界被切成 cellSize = 200 的格子,节点按坐标丢进格子。查询时只看当前格 + 周围 8 格(hit-test.ts:26-42):

// hit-test.ts:30-40 —— 只扫 3×3 邻域,倒序让上层节点先命中
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++) {
const cell = this.grid.get(`${cx+dx},${cy+dy}`)
for (let i = cell.length-1; i >= 0; i--) // 后画的在上,倒序优先
if (hitTest(node, wx, wy)) return node
}

命中形状按节点类型区分:文档是方块(切比雪夫距离),记忆是圆(欧氏距离平方,免开方)。rebuild 也有哈希短路(hit-test.ts:8-24):把节点数、id、坐标(×10 提精度)揉成一个哈希,和上次相同就不重建——所以静止帧里空间索引零开销。

5.4 边渲染:按颜色批处理 + LOD 采样

一次 beginPath 里画尽可能多同色线,是 canvas 批量绘制的关键。drawEdges(renderer.ts:203-404)把边按 颜色|宽度|透明度|类型 分桶(edgeBatches,模块级复用、每帧 clear 不重新分配,renderer.ts:20334),每桶一次 path 一次 stroke。

LOD(细节层级)采样是密集图的救命稻草:缩得很小、边又多时,按边 id 的哈希取模抽样只画一部分(renderer.ts:117-128):

// renderer.ts:126-128 —— 用确定性哈希抽样,stride 越大画得越少
function shouldDrawSampledEdge(edgeId, stride) {
return stride <= 1 || hashString(edgeId) % stride === 0
}

stride 由边数和缩放共同决定(getRelationEdgeStride / getDerivesEdgeStride):关系边超 260、derives 边超 3200 且缩得够小才开始抽稀(renderer.ts:22-25)。抽样用哈希而非随机,保证同一条边每帧要么一直画要么一直不画,不会闪烁。选中 / hover 相关的 updates 边则强制画出(renderer.ts:236-237),保证交互焦点永远可见。

5.5 节点渲染:文档方块 / 记忆六边形

  • 文档:圆角方块 + 渐变填充 + 内框 + 中央矢量图标(drawDocumentNode,renderer.ts:700-761)。图标由 drawDocIcon(canvas/document-icons.ts:37-122)按文档类型 switch 出——网页画地球、PDF 画文字标签、Notion / Google Doc / 推特 / 视频各有手绘矢量,全部用 canvas 路径实时画,不依赖任何图片资源。
  • 记忆:六边形(drawHexagon,renderer.ts:944-958),颜色由聚类色 + 语义边框色混合。小到 8px 以下就退化成批量圆点(renderer.ts:485-509),几万个点也能一次 path 画完。

节点边框色带语义(use-graph-data.ts:43-55getMemoryBorderColor):已遗忘=红,7 天内将遗忘=橙,24 小时内新建=绿,否则用聚类色。

5.6 聚类上色:BFS 连通分量

同一簇相关记忆应该同色。computeClusterAssignments(use-graph-data.ts:114-191)建一张无向邻接图(同文档的记忆互连、有关系的记忆互连),再用 BFS 求连通分量,每个分量分一个稳定颜色(按 key 哈希取色,getClusterColor,use-graph-data.ts:110-112)。文档节点则取「它旗下记忆里占多数的那个簇色」(getDocumentClusterAssignment)。

5.7 主题:读 CSS 变量 + 监听切换

useGraphTheme(hooks/use-graph-theme.ts)从 document.documentElement 上读一串 --graph-* CSS 变量(读不到就回退 DEFAULT_COLORS,constants.ts:42-69),并用 MutationObserver 监听 class 变化、matchMedia 监听系统深浅色——所以宿主一切换主题,图立刻跟着变色。调用方还能用 colors prop 覆盖任意字段。


6. 巧妙之处(可带走的技术)

  • 物理语义的取舍:把 extends 边踢出引力(simulation.ts:17)。「哪些关系该影响布局、哪些只是装饰」是力导向图最容易翻车的地方;这里明确区分结构边视觉边,一行 filter 避免了稠密图糊成一团。

  • 一个阈值管三层降级:6000。同一个 DENSE_GRAPH_STATIC_THRESHOLD 同时决定「跑不跑物理动画」(simulation.ts:66-76)和「建不建 simulation 实例」(memory-graph.tsx:137-143),渲染层再叠加 25000 的纯点模式——分级降级而非一刀切。

  • 引用相等短路 + render 里直接调用(version-chain.ts:18)。把「幂等 + 引用相等早退」做进 rebuild,上层就能在 render body 里放心调,绕开 useEffect 的时序坑。这是个很值得抄的模式。

  • 确定性哈希抽样做 LOD(renderer.ts:126-128)。用 hashString(id) % stride 而非 Math.random() 决定画不画一条边,天然消除帧间闪烁——随机抽样在动画里是大忌。

  • 全 ref + 单 rAF + 跳帧(graph-canvas.tsx:58-70242-251)。渲染态一律不进 React state,静止帧直接不画。这是「canvas 高性能可视化」在 React 里的标准姿势。

  • 增量追加不抖动(memory-graph.tsx:145-168)。分页加载区分「全量重排 / 纯追加 / 无变化」,纯追加时老坐标定死、只在外围塞新节点——分页体验平滑的关键。


7. 该包如何被 MCP 当 App UI 复用

MCP 服务器把这张图暴露成一个可弹出的可视化工具(细节见 02-mcp-server.md,这里只讲连接点)。apps/mcp/src/server.ts:294-437 做三件事:

  1. 注册工具 memory-graph(server.ts:303-361):调用后端拉一页文档,把 { documents, totalCount } 塞进 structuredContent,并在 _meta.ui.resourceUri 挂上 UI 资源地址 ui://memory-graph/mcp-app.html
  2. 注册分页工具 fetch-graph-data(server.ts:364-420,visibility: ["app"]):供 UI 内部翻页取更多文档。
  3. 注册 HTML 资源 registerAppResource(...)(server.ts:423-437):把打包好的 App UI HTML 作为 MCP 资源返回。

一个要诚实说清的边界: MCP 的这份 App UI(apps/mcp/src/ui/mcp-app.ts)并不 import 本包,而是一份独立的 force-graph + d3-force-3d 重新实现,只是沿用了同一套数据模型(文档 / 记忆 / 三类边 / parentMemoryId 版本关系)。它的边装配逻辑是手工与本包保持一致的——源码里就挂着一句注释:

// apps/mcp/src/ui/mcp-app.ts:246
// Keep in sync with packages/memory-graph/src/hooks/use-graph-data.ts

换句话说:本包(@supermemory/memory-graph)真正被 apps/web 直接消费(apps/web 多处 import { MemoryGraph } from "@supermemory/memory-graph");而 MCP 端是「同一心智模型的第二实现」,靠约定同步而非代码复用。理解这一点能避免误以为两处是同一份渲染代码。


8. 边界与局限

  • 版本链假定线性:分叉时前探只取第一支(version-chain.ts:63-75),多分支历史无法完整展示。
  • 孤立 v1 不成链:单条无父无子的记忆 getChain 返回 null(version-chain.ts:81),弹窗不显示版本时间线。
  • 6000+ 节点无物理动画:超阈值即静态布局,拖拽单点不会带动邻居重新松弛(力已停)。
  • 纯点模式牺牲细节:25000+ 且缩得很小时记忆退化成色点(renderer.ts:439-441),看不出形状/状态图标。
  • MCP 与本包需手工同步:边逻辑是两份实现(§7),上游改了 use-graph-data.ts 的关系装配而忘了改 mcp-app.ts,两端会不一致。
  • 主题依赖 DOM:useGraphThemedocument,SSR 下回退默认色(use-graph-theme.ts:5-11)。

9. 代码地图(导航索引)

主题文件路径符号名
力导向布局引擎packages/memory-graph/src/canvas/simulation.tsForceSimulation
结构边过滤(排除 extends)packages/memory-graph/src/canvas/simulation.ts:17structuralEdges
密集图静态阈值packages/memory-graph/src/canvas/simulation.ts:5DENSE_GRAPH_STATIC_THRESHOLD
文档-记忆弹簧长度缩放packages/memory-graph/src/canvas/simulation.ts:115getDocMemoryDistance
力参数常量packages/memory-graph/src/constants.ts:10FORCE_CONFIG
版本链索引packages/memory-graph/src/canvas/version-chain.tsVersionChainIndex
增量重建短路packages/memory-graph/src/canvas/version-chain.ts:17rebuild
回溯+前探取链packages/memory-graph/src/canvas/version-chain.ts:39getChain
链条目类型packages/memory-graph/src/canvas/version-chain.ts:3ChainEntry
节点/边装配 + 初始布局packages/memory-graph/src/hooks/use-graph-data.ts:458useGraphData
边计算(三类边)packages/memory-graph/src/hooks/use-graph-data.ts:406computeEdges
BFS 聚类上色packages/memory-graph/src/hooks/use-graph-data.ts:114computeClusterAssignments
追加落点避让packages/memory-graph/src/hooks/use-graph-data.ts:297getAppendPosition
记忆语义边框色packages/memory-graph/src/hooks/use-graph-data.ts:43getMemoryBorderColor
逐帧渲染入口packages/memory-graph/src/canvas/renderer.ts:66renderFrame
LOD 哈希抽样packages/memory-graph/src/canvas/renderer.ts:126shouldDrawSampledEdge
记忆节点(版本状态画法)packages/memory-graph/src/canvas/renderer.ts:763drawMemoryNode
视口变换与动画packages/memory-graph/src/canvas/viewport.tsViewportState
缩放到全图packages/memory-graph/src/canvas/viewport.ts:71fitToNodes
网格空间命中packages/memory-graph/src/canvas/hit-test.tsSpatialIndex
3×3 邻域查询packages/memory-graph/src/canvas/hit-test.ts:26queryPoint
输入事件与拖拽钉点packages/memory-graph/src/canvas/input-handler.ts:114onMouseDown
文档类型矢量图标packages/memory-graph/src/canvas/document-icons.ts:37drawDocIcon
React 壳 + rAF 循环packages/memory-graph/src/components/graph-canvas.tsx:203GraphCanvas
顶层编排壳packages/memory-graph/src/components/memory-graph.tsx:21MemoryGraph
simulation 增量决策packages/memory-graph/src/components/memory-graph.tsx:145useEffect
版本时间线弹窗packages/memory-graph/src/components/node-hover-popover.tsx:226VersionTimeline
主题(CSS 变量)packages/memory-graph/src/hooks/use-graph-theme.ts:77useGraphTheme
MCP 工具/资源注册apps/mcp/src/server.ts:294registerAppTool / registerAppResource
MCP 独立 UI(需手工同步)apps/mcp/src/ui/mcp-app.ts:246buildGraph(sync 注释)

同组其它章: index.md · 01-data-model.md · 02-mcp-server.md · 03-framework-middleware.md · 04-claude-memory-tool.md