Electron 应用不是一个进程里加载一切。Chatbox 至少有三个运行上下文:
sequenceDiagram
participant OS as 操作系统
participant Main as Electron Main
participant Preload as Preload
participant Renderer as React Renderer
OS->>Main: 启动 electron
Main->>Main: pdfjs globals / legacy migration
Main->>Main: 创建窗口、注册 IPC、初始化服务
Main->>Preload: 为 BrowserWindow 注入 preload
Preload->>Renderer: 暴露 window.electronAPI
Renderer->>Renderer: migration / settings / stores
Renderer->>Main: 通过 IPC 读取配置、文件和平台能力
Renderer->>Renderer: RouterProvider 渲染 UI
在 src/main/main.ts 的最顶部,两个 import 有意放在 Electron app 之前:
pdfjs-globals:为pdfjs-dist可能在顶层访问的DOMMatrix、Path2D、ImageData提供兼容对象。legacy-database-migration:处理 Electron 数据目录行为变化,避免旧数据库被错误删除。
这不是风格问题,而是模块顶层副作用的时序约束:如果依赖在 import 时就读取全局对象,等 app.whenReady() 后再补救已经晚了。
main.ts 的职责可以按生命周期分成四组:
| 时机 | 工作 | 典型文件 |
|---|---|---|
| 模块加载 | polyfill、迁移、错误捕获、容器/GPU flags | main.ts、pdfjs-globals.ts |
| app ready 前 | Linux runtime flags、协议注册、单实例准备 | main.ts、deeplinks.ts |
| app ready 后 | 窗口、菜单、托盘、IPC handlers、服务初始化 | main.ts、menu.ts |
| 运行中 | 更新、快捷键、窗口事件、深链、退出清理 | app-updater.ts、window_state.ts |
Main 把知识库和 Session Attachment RAG 初始化做成异步 promise,并在启动早期触发但不阻塞所有窗口逻辑。这种方式兼顾了“尽早准备”和“某个索引服务失败不让整个 UI 永久卡住”。
Preload 中的 electronHandler 是 Renderer 看到的唯一桌面能力入口。它包括:
invoke:通用的 typed IPC 请求入口。- 文件路径、主题、窗口焦点/显示/最大化事件。
- updater 状态监听。
- MCP stdio transport 事件。
- 内置 Skills 更新通知和导航通知。
createListener(channel) 把 Electron 的 (event, ...args) 监听器转成业务回调,并返回取消订阅函数。这个细节很重要:React effect 可以在卸载时解除监听,避免重复订阅和窗口生命周期泄漏。
直接暴露 ipcRenderer 会让页面代码可以任意构造 channel 和参数,扩大信任边界。当前做法把:
- 可调用的 channel 集中在 preload。
- 输入和输出类型 集中在
shared/electron-types与 Platform 实现。 - 权限判断 留在 Main handler,而不是信任 UI 自己声明“我有权限”。
src/renderer/platform/index.ts 根据构建目标和运行环境选择:
if (process.env.NODE_ENV === 'test') return new TestPlatform()
if (CHATBOX_BUILD_TARGET === 'mobile_app') return new MobilePlatform()
if (typeof window !== 'undefined' && window.electronAPI) return new DesktopPlatform(window.electronAPI)
return new WebPlatform()业务代码拿到的是 Platform 接口,而不是 if (isElectron) 到处散落。接口同时承载:
- Storage:key/value、blob、枚举和清理。
- 系统:主题、版本、设备名、窗口、自动启动、深链。
- 文件:本地解析、读取、写入、搜索、目录选择。
- Agent:Sandbox、产物导出、HTML 预览。
- 领域服务:Knowledge Base、Session Attachment RAG、Image Generation Storage、Session Meta Storage。
这使得同一个 Session Store 可以在 Web 测试里注入 TestPlatform,也可以在移动端走 Capacitor、桌面端走 IPC。
以 sandboxRead 为例:
flowchart LR
Tool[Renderer Tool\nread_file]
Platform[DesktopPlatform.sandboxRead]
Bridge[window.electronAPI.invoke]
Handler[main/sandbox/ipc-handlers.ts]
Manager[main/sandbox/manager.ts]
FS[受限文件系统]
Tool --> Platform --> Bridge --> Handler --> Manager --> FS
FS --> Manager --> Handler --> Bridge --> Platform --> Tool
这里有三个可替换点:
- Tool 不知道它运行在 Electron 还是 Web。
- Platform 不知道具体 Sandbox 如何实现,只把参数映射成 IPC。
- Manager 负责真实路径校验、工作目录、超时和输出截断。
Main 使用 process.on('uncaughtException') 兜底,记录 Sentry 上下文后 flush 并退出;Renderer 在 index.tsx 中通过 ErrorBoundary 保护 UI,并在初始化、迁移和生成链路上用领域错误上报。
这不是“所有错误都吞掉”:
- 可以恢复的错误变成消息上的
errorCode、error、errorExtra。 - 不可恢复的进程错误仍然终止,避免应用处于半初始化状态。
- 预期错误(配额、取消、用户拒绝)和异常错误使用不同的 Sentry 分类。
把 prompt、ToolSet、Session 状态留在 Renderer,减少 IPC 往返和序列化,也让 Web 版本可以复用。代价是 Renderer 拥有很多业务复杂度,因此需要 Shared 类型和清晰的 Store 模块边界。
Platform 同时包含 UI、存储、Sandbox、RAG 等能力,看起来不像最小接口;但它把三套平台实现的差异集中到一个面上,避免业务层到处探测能力。通过可选方法和 scope 门控,Web/Mobile 可以拒绝桌面能力而不破坏类型契约。
Main 的 electron-store 管理配置和周期备份;会话走 Renderer Storage。这样配置容易备份和恢复,会话则可以容纳大量消息而不把 config.json 变成巨型文件。
Chatbox 的运行时边界由“Main 可信系统层、Preload 窄桥、Renderer 产品层、Platform 跨平台门面、Shared 领域契约”共同构成。下一章把注意力转向最重要的领域对象:Session、Message、Thread 和 Fork。