为了把 AI 对话留给自己,我把 7MB 的 Tauri 应用重写成了 Electron
为了把 AI 对话留给自己,我把 7MB 的 Tauri 应用重写成了 Electron#
引子#
深夜十一点,你终于和 ChatGPT 聊通了一个卡了很久的问题——某个产品设计上的矛盾,某段代码里的架构决策,或者一个困扰了你好几周的思路。你关掉对话窗口,满足地去睡觉。
第二天,你想找回那段对话。滚动列表,全是「New chat」或者意义模糊的自动生成标题。你试着搜关键词,搜索结果答非所问。半小时过去,那段灵光就这样消失在几百条对话的海里。
这还不是最糟的情况。
更糟的是:你的账号某天因为某个莫名其妙的原因被封了,或者某家 AI 公司停止了服务,或者你换了台电脑、换了个账号——那些沉淀了你几百个小时思考的对话,就这样永远消失了。它们存在那家公司的服务器上,你从来不知道备份在哪,甚至不知道能不能导出。
这是 2024 年末的我面对的问题。
我是一个多 AI 重度用户。ChatGPT、Claude、Gemini、DeepSeek,我同时使用多个,遇到重要问题会开几个窗口对比输出,互相印证。这是我工作流的一部分,但它有个越来越明显的摩擦:每次都要在浏览器里找对应的标签页,不同 AI 各自维护各自的历史,数据散落在每家公司的服务器里,没有统一的检索方式,也没有数据导出。
AmberKeeper 是我给自己的第二次答卷,第一次叫 anyChat。
一、第一次答卷:anyChat 的「够用主义」#
anyChat 是我在 2024 年做的第一个多 AI 聚合工具,基于 Tauri 2 + React 19,macOS 安装包只有 7MB。我现在仍然在用它。
为什么做这个#
多 AI 并用这件事,在浏览器里做其实很痛苦。你需要维护一堆标签页,每次切换都得眼睛扫一遍标签栏;如果你有屏蔽广告的习惯,登录流程会时不时出 bug;浏览器本身的内存占用也是个问题。我需要一个单独的、干净的入口,能把所有 AI 聚在一个窗口里,键盘切换,低干扰。
需求很清晰,选什么技术框架呢?
为什么选 Tauri#
当时考虑过 Electron,但 Electron 的问题很明显:它必须打包一个完整的 Chromium 内核进去,安装包动辄 80–150MB,启动也慢。
Tauri 的思路不同——它用操作系统自带的 WebView(macOS 上是 WKWebView,Windows 上是内置的 WebView2,Linux 上是 WebKitGTK),只需要打包前端静态资源和一个小的 Rust 宿主二进制,安装包结果就是 7MB。这是同一硬币的两面:你得到了极致的体积,代价是你没有”自己的浏览器内核”,只能依赖系统的 WebView 能力。
但对当时的需求来说,这完全够用。
关键技术选择#
嵌入 AI 站点这件事有个直觉上的误解:用 iframe 不就行了?
不行。各家 AI 站点都设置了 X-Frame-Options: SAMEORIGIN 或相应的 CSP(Content Security Policy)规则,iframe 嵌入会直接被浏览器拒绝,页面根本加载不出来。
正确的方式是原生多 WebView:在主窗口内创建多个子 WebView 实例,每个 WebView 加载一个 AI 站点,通过切换显示/隐藏来模拟标签页效果。
Tauri 在 macOS 和 Linux 上支持主窗口 + 多子 WebView 的架构。Windows 有个特殊情况:多个 WebView 挂在同一个原生窗口下时,Tauri 的 IPC 通信(JavaScript 与 Rust 主进程之间的消息通道)会变得不稳定。为了绕过这个问题,Windows 版本改用另一种方式:给每个 AI 站点创建一个独立的无边框窗口,再把它们 dock 到主窗口边缘,视觉上仍然是”一个窗口”,底层实现却是多个窗口协同。这是同一架构思路在不同平台上的不同实现。
除此之外,anyChat 在每个页面里注入了一个最小化的兼容脚本,只干两件事:屏蔽 WebAuthn / Passkey 弹窗(避免某些 AI 站点触发意外的密钥登录提示),以及识别 OAuth 登录弹窗并正确放行(让 Google 账号登录能在独立窗口里完成)。除此之外没有任何采集逻辑,没有任何数据拦截。
它现在还够用#
anyChat 目前支持 12 个内建 AI 服务:ChatGPT、Gemini、Claude、Grok、Copilot、Perplexity、Poe、DeepSeek、通义千问、Kimi、豆包、智谱,用户也可以自定义添加任意 URL。我自己用了将近一年,日常多 AI 切换这个场景,它完全满足。
体积小、启动快、界面干净,对「我只想在一个窗口里切几个 AI 网站」的需求来说,它仍然是最合适的工具。
我说这些,是因为接下来要讲它做不到的事情——但这不代表它失败了,它只是有它自己的边界。
二、痒处变成痛处#
用了 anyChat 几个月之后,我意识到自己真正缺的不是「切换」,而是「留住」。
每次深聊之后,那段对话就停在那家 AI 公司的服务器上了。我没有副本,我没有索引,我没有任何方式跨 AI 检索一个我之前探讨过的话题。时间一长,就好像什么都没发生过一样。
具体的痛点是这样的:
散落。ChatGPT 的历史在 ChatGPT,Claude 的历史在 Claude,各自管各自。想找一个话题之前和哪个 AI 聊过,只能逐个去翻。不同 AI 的搜索体验也参差不齐,有的支持全文检索,有的只能搜标题。
风险。账号是脆弱的。OpenAI 封号不是新鲜事,很多人因为「可疑的账号活动」或者地区政策突然失去了自己的账号——ChatGPT Pro 续了一年,几百小时的对话史,一夜清零。更不用说产品层面的风险:万一某家 AI 公司停服、被收购、或者像早期很多工具一样悄悄下线,你的数据怎么办?
不可带走。各家 AI 对数据导出的支持参差不齐。有的支持导出 JSON,有的支持导出 PDF,很多干脆不提供导出。即便有导出功能,也是一次性的手动操作,不是实时的持续备份。
沉没在其中的价值。和 AI 的深聊,不是消遣。那是你真实的思考过程——你提出的问题、你推翻的假设、你接受的建议、你最终推导出的结论。这些东西是你的,但你拿不走。
我想到的解法是:在聊天的同时,把每一轮对话实时、静默、自动地落到本地。用户体验上什么都不变,照常在官方网页聊,但对话数据会同步沉淀在你自己的电脑上。
然后我去看能不能在 anyChat 里做到这件事。
三、在 Tauri 上能做到吗?——尝试与放弃#
能,我试过;但最后选择了放弃。
走过的弯路#
Tauri 的系统 WebView 没有像 Electron 那样的 session.webRequest API,也没有 CDP(Chrome DevTools Protocol)这类调试器级别的拦截能力。你能做的,是在页面里注入 JavaScript。
第一个想法是:在每个 AI 站点里注入 fetch hook,拦截 window.fetch 调用,抓到响应体后传回主进程。
问题随之而来。AI 站点的 CSP 和同源策略不让你随便跨域通信;preload 脚本注入的代码在页面沙箱里,和 Tauri 主进程通信的 IPC 在多 WebView 场景下不稳定(正是前面提到的 Windows 已知问题,macOS 上也有类似的边界情况)。
为了绕过这些限制,方案变得越来越复杂:
- 注入 fetch hook + DOM fallback 双路兜底(fetch 失败了用 DOM 快照补救)
- 建一个跑在本地的 HTTP 中继服务(注入脚本把抓到的数据 POST 到这个中继,绕过 Tauri IPC 的限制)
- 再加一个自定义 URL scheme,作为另一条数据回传通道
这条链路最终变成了三段式:浏览器注入脚本 → 本地 HTTP 中继 → 自定义协议接收器 → 主进程。
每一段都是临时补丁,整体维护成本极高。不同 AI 站点的 CSP 各不相同,SSE 流式响应(ChatGPT 返回 token 流就是用 SSE 实现的)特别难拦——fetch hook 并不总能稳定地抓到完整的流式响应体,需要大量边界处理,且各家站点的前端一改,采集逻辑就可能跟着失效。
放弃的决定#
在深入研究之后,我得出了一个清晰的结论:
Tauri 缺一个稳定的「响应体级请求拦截」原语。
WKWebView、WebView2、WebKitGTK 都没有提供这个原语。你可以知道”发了一个请求”,但你拿不到完整的响应体,尤其是 SSE 这种流式格式。要补这块能力,就只能靠注入 JS hook,而注入 JS 又在 CSP 和同源策略的夹击下步步受限。
要么接受这个边界,要么换工具。
我选择了换工具。同时,anyChat 里所有和数据捕获相关的代码也被主动清理掉了——三段式链路、本地 HTTP 中继、自定义协议接收器,全部移除。anyChat 回归了它本来的定位:一个轻量、干净的多 AI 切换壳。
四、第二次答卷:AmberKeeper 的关键设计#
AmberKeeper 是我在 anyChat 之外单独立项的 Electron 应用。它们的定位是互补的,不是替代关系。
不夸 Electron——它的体积代价是真实的,发行包在 80–150MB 之间,比 anyChat 的 7MB 大了二十倍有余。接受这个代价,是因为 Electron 提供了两个 Tauri 给不了的核心能力:
- WebContentsView:基于完整 Chromium 内核的多实例嵌入,不依赖系统 WebView
webContents.debugger.attach:即 CDP 入口,Chromium 内核级别的调试器接口
4.1 多 AI 共存:WebContentsView + 独立 partition#
每个 AI provider 对应一个独立的 WebContentsView 实例,各自有独立的 session partition,可以理解为独立的 cookie 和 localStorage 空间。这样 ChatGPT 的登录态不会和 Claude 相互影响,每个 AI 站点都有自己完全隔离的”浏览器身份”。
有一个细节值得说明:partition 的命名刻意沿用了 anyChat 时期的旧格式。这是一个有意的兼容决策——从 anyChat 迁移到 AmberKeeper 的用户,不需要重新登录每个 AI 站点,已有的登录态会被自动继承。
切换 AI 时,AmberKeeper 不会销毁和重建 WebContentsView,而是只移动 bounds——把当前不需要显示的 view 推到屏幕外的坐标区域,激活的 view 移到主显示区域。
为什么不销毁?销毁会让那个 AI 站点的整个页面状态消失,包括登录态、滚动位置、内存中的页面 context。下次激活时需要重新加载页面,如果 session 也销毁的话还要重新登录,用户体验非常差。推到屏幕外的代价只是一点内存,换来的是切换时的即时响应——这个取舍是值得的。
4.2 数据捕获:CDP 才是关键#
这是 AmberKeeper 在技术上最值得展开的部分。
在研究数据捕获方案时,我比较了三个选项:
| 方案 | 能拿到什么 | 局限 |
|---|---|---|
webRequest.onResponseStarted | 请求/响应头,不含 body | SSE 流式响应完全抓不到内容 |
| 注入 fetch hook | 可以拿 body,但需要绕 CSP,跟着站点改 | 维护成本高,和 anyChat 走过的坑完全一样 |
CDP Network.* | 完整请求/响应体,含 SSE,走 Chromium 内核侧 | 仅限 Chromium 系,需要 attach debugger |
webRequest 是 Electron 提供的 session 级别 API,能拦截请求,但只能拿到响应头,拿不到响应体——这对大多数场景够用,但对聊天数据采集来说致命,因为 AI 的回复内容就在响应体里,而且通常是 SSE 流式格式(Server-Sent Events,服务器主动逐步推送 token)。
注入 fetch hook 的问题前面已经说过,和 Tauri 上走过的路没有本质区别——它依赖页面侧 JS 的合作,而各家 AI 站点的 CSP 会主动阻止外部脚本的网络拦截行为。
CDP 就不同了。
Chrome DevTools Protocol 是 Chromium 内置的调试器接口,浏览器开发者工具里的网络面板、断点调试、内存分析,背后全是 CDP。Electron 提供了 webContents.debugger.attach API,允许主进程直接 attach 到某个页面的 debugger 上,监听 CDP 事件。
一旦 attach 成功,可以启用 Network 域,监听:
Network.requestWillBeSent:捕获每一个网络请求的 URL、Method、请求体Network.responseReceived:捕获响应头(状态码、MIME 类型)Network.loadingFinished:请求完成信号- 随后调用
Network.getResponseBody:取完整的响应体,含 SSE 流式内容
关键在于:CDP 是 Chromium 内核侧的能力,完全不受页面 CSP 约束。页面的 CSP 是告诉浏览器「这个页面加载资源的限制」,不是告诉调试器「你能看什么」。调试器有更高的权限层级,页面层的任何 CSP 设置对它都没有效果。
这就是 Electron + CDP 的组合为什么是唯一干净的解法:它不需要在页面里注入任何 hook,不需要绕任何 CSP,不会随 AI 站点的前端改动而失效(只要它的 API 接口没变)。
4.3 preload + isolated world:DOM 兜底与 locale 对齐#
除了 CDP 抓网络,AmberKeeper 还在每个页面里运行了一段 preload 脚本,做两件事。
DOM 快照兜底。极少数情况下,某个 AI 站点的实现方式比较特殊,通过 CDP 拿到的网络数据不够结构化,或者需要结合 DOM 结构才能正确还原一条完整消息。这时 preload 脚本可以按需抓取 DOM 快照作为网络层采集的补充。
这段 preload 脚本运行在 isolated world(隔离世界)里,而不是页面默认的 JS 执行环境里。Electron 支持把 preload 脚本注入一个和主页面 JS 完全隔离的执行上下文:两者共享同一个 DOM,但 JS 变量和原型链互不可见。这样做是为了防止页面本身的 JS 代码(或者站点层面的脚本检测)篡改采集 bridge——你的采集代码跑在一个”平行宇宙”里,站点代码根本摸不到它。
locale 强制对齐。各 AI 站点会根据浏览器暴露的语言信息来决定界面语言。如果系统语言和你希望 AI 用的语言不一致,体验会很割裂。preload 脚本会改写页面通过 Navigator.language、Intl.DateTimeFormat.resolvedOptions 等 API 拿到的返回值,让每个 AI 站点认为自己面对的浏览器语言环境,就是用户在 AmberKeeper 设置里选择的那个。
4.4 五层捕获链路#
把以上所有机制拼在一起,是一条五层的捕获管线:
┌─────────────────────────────────────────────────────────┐
│ ① Chromium 页面(用户在 ChatGPT/Claude/... 里聊天) │
└─────────────────────────────────────────────────────────┘
│ SSE / fetch │ DOM mutation
▼ ▼
┌────────────────────────┐ ┌────────────────────────────┐
│ ② CDP Observer │ │ ② DOM Snapshot Bridge │
│ 主进程侧 │ │ preload isolated world │
│ attach debugger │ │ 按需抓结构 │
│ 抓网络信号 │ │ │
└────────────────────────┘ └────────────────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────┐
│ ③ Provider Adapter(每家 AI 一个独立包) │
│ 把原始网络/DOM信号翻译成 │
│ 该 provider 的语义:这是谁说的,说了啥 │
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ ④ Capture Orchestrator + TurnState │
│ 把碎片信号聚合成一轮完整的 Q&A (Turn) │
│ 只有 Turn "就绪"时才推进持久化 │
└──────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────┐
│ ⑤ Turn Persistence → 本地 SQLite │
└──────────────────────────────────────────┘
Turn(轮次)是整个系统的核心聚合单元:一个 Turn = 一次用户提问 + AI 的完整回复。
AI 的回复不是一次性到达的,它是逐步流式推送的——CDP 会收到一个个网络事件,preload 脚本也会感知到 DOM 的逐步变化。Capture Orchestrator 的工作就是维护每个进行中的 Turn 的状态机,收集这些碎片,判断什么时候 Turn “完整了”——即请求体和响应体都到位,可以安全地写入数据库。这避免了写入不完整的对话片段,也处理了请求失败、用户中途打断等边界情况。
每个 provider 有自己独立的 Adapter 包,负责理解那家 AI 的网络协议和 DOM 结构,把原始信号翻译成统一的语义格式。Adapter 不依赖 Electron API,也不直接写数据库,它只做翻译——这个单一职责的设计让每个 Adapter 都可以独立测试,用真实抓包数据(脱敏后)做集成回归验证。
五、让对话「凝成琥珀」——本地存储#
数据持久化层用的是 Node.js 内置的 SQLite 支持,不依赖任何原生 addon。这个选择是工程上的务实决定:原生 addon 需要在不同平台上各自编译,分发时的 CI 配置和用户安装体验都更复杂;Node 内置的 sqlite 模块直接可用,省去了这部分麻烦。
数据库是三层结构:
业务层:conversations 表存对话元数据(provider 来源、标题、标题来源、消息数量),messages 表存每一条消息(角色、内容、采集时间)。每条消息有一个基于内容的哈希值,用于在重复采集时自动去重——如果同一条消息因为某些边界情况被采集了两次,只会保留一条记录。
证据层:capture_events 表存原始事件的 payload JSON。这一层是给调试和回放用的——如果某个 provider 的 Adapter 有 bug,可以用历史 payload 在本地重跑解析逻辑,不需要真的再聊一遍。这也让 Adapter 的迭代可以在离线环境下测试,不依赖实时网络。
诊断层:capture_attempt_logs 表记录每次采集尝试的结果,包括那些没有成功落库的 Turn。这样你能知道「我聊了这段话,但没被采集到,是为什么」,是 Adapter 的解析问题,还是那次响应异常,或者网络超时。
数据导出支持两种粒度:以单次对话 session 为单位导出,或以整个 provider 为单位批量导出。格式支持 JSON(保留完整结构,方便程序处理)和 Markdown(方便阅读和检索)。
这里有个设计取向值得明确:AmberKeeper 没有云同步,也没有计划做云同步。这是本地优先(local-first)设计的刻意选择,不是功能缺失。你的对话在你自己的硬盘上,不在任何人的服务器上。AmberKeeper 自己也不知道你聊了什么。
六、Provider 是怎么扩展的#
AmberKeeper 现在内建支持 9 个 provider:ChatGPT、Claude、Gemini、DeepSeek、Grok、Kimi、通义千问、豆包、小米 AI Studio。
每个 provider 是一个独立的 npm 包,结构相同:
- Adapter:负责 request classification(判断 CDP 捕获到的这个请求是否是我需要的聊天请求)和 response normalization(把响应体解析成统一的 turn 格式)
- DOM Collector:用于 DOM 快照路径的结构解析,在网络层不足以独立完成解析时提供补充
- Contract Tests + Fixtures:用真实抓包数据(脱敏后)做集成验证,确保 Adapter 的解析逻辑在这个 provider 的实际请求格式下是正确的
新增一个 provider 需要三步:建一个符合上述结构的新包,在 browser session 配置里注册它的 home URL 和 session partition,在捕获层注册它的 DOM driver。
强约束:provider 包不能依赖 Electron API,不能直接写数据库。它只做信号的理解和翻译,持久化的决定权在 Capture Orchestrator。这个约束让 provider 包可以在纯 Node 环境下独立测试,也让不同 provider 的适配工作可以相对独立地进行,互不影响。
七、取舍、局限与未来#
代价是真实的#
体积。Electron 的安装包比 anyChat 大 20 倍。如果你只是想在一个窗口里切换几个 AI 网站,anyChat 才是正确选择,不要为了数据采集功能付出这个体积代价。
脆弱性。采集能力依赖每家 AI 站点的实现细节。他们改了 API 路径、换了响应格式、调整了 DOM 结构,适配层就需要更新。这不是「一次实现永远有效」的那种工程,它是持续的适配投入。
维护成本。9 个 provider = 9 套 Adapter + Fixtures。每家更新都要回归验证。这是持续的成本,不是一次性投入。
不替代 API。如果你是开发者,需要 token-by-token 的流式回调、function calling、精确的 token 计量、批量处理——你需要的是官方 API,不是这个工具。AmberKeeper 解决的是「人类用户聊天时的数据归属」问题,不是「工程集成」问题。
没有云同步。这是设计边界,不打算改变。
还在演进的方向#
采集是第一步,沉淀是第二步,真正有价值的是用起来——让这些落库的对话变成可检索、可总结、可关联的个人知识资产。
下一个阶段的方向是在现有采集基础上加一层选择性 Memory:让你能标记重要的对话轮次,在未来的 AI 对话里召回它们,或者做跨时间线的主题聚合和总结。这是 AmberKeeper 长期想做的事,也是它名字里「Keeper」的真正含义。
结语#
回到名字本身。
Amber(琥珀) 是树脂的结晶。一滴松脂偶然落下,裹住一只虫子、一片叶子,然后在地底沉睡上千万年,再被挖出来时依然晶莹剔透,连被封存那一刻的光都还在。
Keeper 是留存的人,守住那些本来会流走的东西。
我们和 AI 的每次深聊,本来都会随风消散。这个工具想做的,就是像松脂落下一样,静静地,把它们封住。
项目地址#
AmberKeeper(Electron,本地优先,带数据采集与管理)
- GitHub:github.com/JS-banana/amberkeeper
- 下载:见 GitHub Releases 页面
anyChat(Tauri,~7MB,轻量多 AI 切换)
- GitHub:github.com/js-banana/anychat
- 下载:见 GitHub Releases 页面(macOS)
如果觉得有用,欢迎 Star 或在 GitHub Issues 里反馈。