把 drawio 二开成 AI 思维导图工具:架构设计与踩坑实录

最近完成了一个对个人意义比较大的项目:MindmapAI——基于 drawio 二次开发的
AI 思维导图/思考图工具。给它一个主题、一篇文章、一个链接甚至一份 Word/PDF
文档,它能提炼要点、自我评审,然后在画布上自动画出一张结构化的图。
这篇文章记录整个项目的架构取舍和踩坑过程,代码已全部开源。

MindmapAI 生成的思维导图

一、为什么不直接 fork 改源码

二开 drawio 最容易想到的路是 fork 仓库直接改。我没走这条路,原因是两个:

  1. 合规。drawio 是 Apache-2.0 项目,直接在其源码里掺杂自己的代码再发布,
    代码边界和声明都会变得模糊。我想让仓库里 100% 是自己的代码。
  2. 可升级。源码混改之后,上游每升一个版本都是一次痛苦的 merge。

最后的方案是「运行时拉取 + 补丁注入」:

  • 仓库只放原创代码和一个 patches/ 目录(要注入上游文件的片段、资源、
    pinned 的上游 commit hash);
  • scripts/setup-upstream.mjs 首次运行时按 pinned 提交浅克隆上游
    drawio / drawio-desktop,然后把接入点(菜单、插件 script 标签、i18n 词条、
    品牌资源)幂等地注入进去——注入用标记注释识别,重复执行不会重复插;
  • webapp/ 在 .gitignore 里,随时删掉重拉,上游升级就是改一行 hash
    再跑一次脚本。

这个结构让「我的代码」和「上游代码」在物理上完全分离,NOTICE.md 里把每项
注入逐条声明,Apache-2.0 的义务履行得很干净。

二、总体架构

┌─────────────────────────────────────────────────┐
│  webapp/ (上游 drawio, pinned commit, 运行时拉取) │
│  ┌───────────────────────────────────────────┐  │
│  │ js/ai/mindmap-ai.js  ← ai/ 模块打包的 IIFE │  │
│  └───────────────────────────────────────────┘  │
├─────────────────────────────────────────────────┤
│  ai/ (TypeScript 源码, esbuild → IIFE 插件)      │
│  ├─ ai/       OpenAI 兼容客户端 + prompt + schema│
│  ├─ agent/    生成管线 / Agent 工具循环 / 抓取    │
│  ├─ charts/   12 种思考图: 槽位协议→确定性布局    │
│  ├─ mindmap/  树模型 → 画布装配                  │
│  └─ ui/       对话框 / Agent 面板 / 菜单          │
├─────────────────────────────────────────────────┤
│  scripts/start.mjs  零依赖静态服务器(含抓取中继)  │
│  desktop/           Electron 壳(上游 desktop 注入)│
└─────────────────────────────────────────────────┘

核心 AI 代码都在 ai/ 目录的 TypeScript 模块里,esbuild 打成单文件 IIFE,
通过注入的 <script src="js/ai/mindmap-ai.js?v=N"> 挂进 drawio。
这样 AI 层完全独立:自己的单测(vitest)、自己的类型检查(tsc)、自己的构建,
不污染 drawio 的构建链。

三、核心设计:决策在模型,几何在代码

这是整个项目最重要的一条架构原则。让 LLM 直接输出画布坐标(x/y/w/h)几乎
必然得到一坨重叠错位的东西,所以我把职责切成两半:

  • 模型只做内容和结构决策:12 种思考图各定义一个「槽位协议」
    (一份 JSON schema 描述 + 示例),模型按协议填 JSON;
  • 代码做全部几何:每个图型一个确定性布局函数,输入槽位 JSON,输出带
    坐标/样式/连线的中间表示,再统一装配到 mxGraph 画布。

以流程图为例,槽位协议长这样(节选):

{
  "title": "AI Agent 工作流程",
  "lanes": ["用户", "Agent", "工具"],
  "steps": [
    {"id": "s1", "label": "提出目标", "shape": "terminator", "lane": 0},
    {"id": "s4", "label": "目标达成?", "shape": "decision", "lane": 1},
    {"id": "s5", "label": "输出结果", "shape": "terminator", "lane": 1}
  ],
  "edges": [
    {"from": "s4", "to": "s5", "label": "是"},
    {"from": "s4", "to": "s2", "label": "否", "dashed": true}
  ]
}

早期版本流程图只支持「步骤 i 连 i+1」的线性链,用户实测后直接吐槽
「连线一点也不规范」——decision 分支根本画不出来。于是协议加了显式
edges(分支/回退都能表达),布局层把所有连线统一成 drawio 标准的
正交路由,按两端几何关系自动选连接点:顺流沿流向、回退侧面绕行、
跨泳道侧向一跃。几何规则全部有单测固化(节点不许越出泳道、连线必须有
定向连接点、禁止斜直线)。

12 种思考图之一:树形图

四、来源驱动的生成管线:每一步都有据可查

「贴一篇文章生成导图」的管线不是一次 LLM 调用,而是四段:

  1. 分块:文档按 Markdown 标题切章节,超长章节按段落装箱(纯确定性代码);
  2. 提炼:逐块提炼要点,必须带原文引文(quote 字段);
  3. 架构:基于全部要点生成导图骨架,每个节点回填支撑它的要点 id;
  4. 评审:grounding / coverage / specificity / structure 四维打分,
    不达标自动回炉重构一次。

引文不是装饰品:它跟着节点存进 .drawio 文件(节点 value 上的 mm-*
属性),重开文件悬停节点还能看到「这句话是从原文哪句来的」。来源驱动的
价值就在这里——图不是模型凭空编的,每个节点都有出处。

五、Agent 模式:把控制流真正交给模型

管线模式之外还有一个 Agent 模式(生成对话框里勾选):模型不再是被动的
JSON 生成器,而是拿到一组工具自主工作:

  • 思考图:read_notes(查要点)/ fetch_url(抓网页)/ submit_chart
    (提交槽位,代码校验不过就把错误喂回去让它自己修,最多 3 次提交);
  • 思维导图:add_node / update_node / finish_changes 等 7 个工具,
    模型逐个节点放置,放完自我检查还能再修一轮;
  • 生成完成后,Agent 面板的输入框还能继续下指令(「把术语统一为 AI
    Agent」),Agent 用工具改完自动重排画布。

侧边栏的 Agent 面板把这一切实时播出来:哪条要点被提炼、评审打了多少分、
每次工具调用的参数摘要——不是黑盒转圈,是真的能看见它在干什么。

真实模型全流程运行(DeepSeek)

六、踩坑实录

开发中踩的坑比写的代码更有记忆点,挑六个有代表性的:

1. 浏览器 CORS:抓取功能在浏览器里必然残废

链接模式要抓第三方网页,浏览器直连被 CORS 拦截,公共代理 r.jina.ai 在
国内网络直接超时。解法:抓取本来就不该在浏览器里做——应用本来就靠
start.mjs 这个本地服务器跑,给它内置一个同源中继
GET /mm-fetch-proxy?url=<目标>,服务端发起请求(无 CORS)原样回传。
抓取链变成:直连 → 同源中继 → 公共代理,浏览器模式开箱即用,不用配任何东西。

2. URL 拼接吃掉端口号

中继联调时一度怎么都 404:拼出来的请求路径是 /?url=... 而不是
/mm-fetch-proxy?url=...。原因是我写了 ${origin}?url=${target}——
origin 没有尾斜杠时,http://127.0.0.1:8000?url=x 里的 ? 被 URL 解析器
当成查询串起点,路径部分直接没了。教训:URL 拼接永远用构造器或显式路径,
别拼字符串。

3. PDF 的 FlateDecode 不是裸 deflate

文档转导图功能要零依赖解析 PDF(不引 pdf.js):解压内容流再按 Tj/TJ
文本操作符拼文本。第一版用 DecompressionStream('deflate-raw') 死活解不出,
查了 PDF 规范才发现 FlateDecode 是 zlib 封装(RFC 1950),要带头部和
校验和,对应 'deflate';少数生成器才写裸流。现在两种都试,zlib 优先。
(顺带:docx 解析也是零依赖——中央目录定位 word/document.xml,
裸 deflate 解压,XML 粗提取。)

4. sed 的 & 展开

批量往 i18n 资源文件插词条时用了 sed,替换文本里的 &
(”Summarize & suggestions”)被展开成「整个匹配行」,菜单标签变成了
Summarize aiChartTooSmall=Chart content is too thin. suggestions…
这种缝合怪。资源文件批量改动,用 perl 或 Node 脚本,别用 sed。

5. drawio 的暗色主题信号藏在哪

Agent 面板要跟随编辑器暗色主题。先猜 ui.theme,是 null;再猜
Editor.currentTheme,是 ‘kennedy’(那是界面布局变体,不是明暗)。
翻上游源码才找到真信号:Editor.darkMode 布尔值 + darkModeChanged
事件——新版 drawio 把明暗和布局拆成了两套系统。顺着这个事件做重渲染,
切主题时面板按事件历史重播卡片,会话内容不丢。

6. 首次点击菜单,面板反而消失

「Agent 面板」菜单项的行为是 ensureAgentPanel(ui).toggle():面板首次
创建时默认可见,toggle() 立刻把它藏了——用户第一次点击什么都看不到,
要点第二次。一行修复:初始 display:none。这种 bug 单测抓不到,
只有真点一遍才会发现,也是后来补浏览器 E2E 的直接动机。

七、测试与工程化

  • 单元测试:vitest,21 个文件 162 个用例。布局几何(零重叠/泳道不越界)、
    抓取回退链、文档提取(手搓 zip/pdf fixtures)、槽位校验、主题/语言切换
    重渲染都有覆盖;
  • Mock LLM 服务器(ai/dev-mock-server.mjs):OpenAI 兼容端点,按
    system prompt 里的 [MM-STAGE:xxx] 标记路由到固定脚本响应,连
    function calling 的 tool_calls 都能演。浏览器里把 Base URL 指到它,
    不烧一分钱 quota 就能跑完整生成链路的 E2E;
  • 浏览器 E2E:用浏览器自动化真实点击 UI(开对话框、填链接、注入文件、
    看画布单元格),控制台报错和菜单结构都能断言;
  • 打包链路:esbuild 出 IIFE → 注入上游 bootstrap(?v=N 版本号防缓存)→
    Electron 壳走 mmAiFetch 主进程通道抓网页(天然无 CORS)。

八、路线图与链接

已完成:三来源生成(主题/粘贴/链接)、12 种思考图、评审管线、Agent 模式、
桌面版打包、文档转导图(Markdown/Word/PDF)、总结与优化建议、面板暗色主题、
流程图分支连线。

待办:Markdown 双向编辑、XMind/Freeplane/OPML 导入、macOS/Linux 打包与签名。

如果你对「LLM + 图形编辑器」这个方向感兴趣,欢迎来仓库看看:
github.com/Bogucheese/MindmapAI。
本地跑起来只要一句 node scripts/start.mjs(首次自动拉上游),
配上任何 OpenAI 兼容端点就能用。