16. Electron、WebUI 与 Viewer
Craft 没有为桌面、远程 Web 和公开分享分别开发三套产品。它把 UI 分成三个层次:Electron renderer 是完整交互应用,WebUI 用兼容适配器复用 renderer,Viewer 只复用展示组件并严格收窄能力。
这种分层的关键不在 React,而在于:同一份会话状态怎样跨进程、跨网络和跨安全域保持相同语义。
16.1 三个表面不是三个同等能力的客户端
Section titled “16.1 三个表面不是三个同等能力的客户端”| 表面 | 主要入口 | 数据来源 | 可变更能力 | 平台能力 |
|---|---|---|---|---|
| Electron | renderer/App.tsx |
preload 暴露的 RPC API + push event | 完整 | 文件、窗口、对话框、浏览器 pane、通知 |
| WebUI | webui/App.tsx |
cookie auth 后的 WebSocket RPC | 接近完整,受 server/client capability 限制 | 浏览器可实现子集 |
| Viewer | viewer/App.tsx |
上传的 JSON 或 /s/api/:id 快照 |
只读 | URL、剪贴板、内联 overlay |
边界十分重要:
- WebUI 是“完整 renderer 的另一种 transport”;
- Viewer 是“
StoredSession的展示器”; - Viewer 不应为了复用方便而获得 session mutation RPC;
- Electron 独有能力不能隐式散落到共享组件中。
16.2 UI 复用的真正接口是 ElectronAPI
Section titled “16.2 UI 复用的真正接口是 ElectronAPI”renderer 大量代码调用:
window.electronAPI.getSessions()window.electronAPI.createSession(...)window.electronAPI.onSessionEvent(...)window.electronAPI.openPath(...)名字仍叫 electronAPI,但它本质上已经是客户端端口:
React renderer │ ▼window.electronAPI │ ├── Electron preload → RoutedClient/WsRpcClient └── Web adapter → WsRpcClientWebUI 在加载 Electron App 前创建 adapter,并把它赋到 window.electronAPI。因此上层组件不必知道调用是在本机 main process、远端 server,还是客户端 capability 上完成。入口明确写出了五步启动过程,见 apps/webui/src/App.tsx。
设计判断:这是高收益的兼容层,但名称已经落后于职责。若继续扩展到移动端,适合逐步改成 ClientAPI/RuntimeAPI,并让 Electron 只是一个实现。
16.3 Electron 的三层安全边界
Section titled “16.3 Electron 的三层安全边界”flowchart LR R["Renderer: untrusted web UI"] P["Preload: typed bridge + RPC client"] M["Main: windows/native capabilities"] S["Local or remote server"]
R -->|"window.electronAPI"| P P -->|"small sync bootstrap IPC"| M P <-->|"authenticated WebSocket RPC"| S S -->|"invoke client capability"| P P -->|"allowlisted native operation"| M主窗口配置使用 contextIsolation: true、nodeIntegration: false,preload 再通过 contextBridge.exposeInMainWorld('electronAPI', api) 暴露受控表面。源码:window-manager.ts、preload/bootstrap.ts。
preload 不是简单 IPC 转发器。它负责:
- 读取窗口、workspace、本地 WS 端口/token 等 bootstrap 信息;
- 构造
WsRpcClient或RoutedClient; - 注册 open URL/path、文件对话框、确认框、BrowserPane 等 client capability;
- 把 channel map 生成为
ElectronAPI; - 暴露连接状态、重连和少数纯客户端 helper。
远程 thin-client 会拒绝向非 localhost 的明文 ws:// 发送 token,要求 wss://,见 preload/bootstrap.ts。
一个需要特别审计的例外
Section titled “一个需要特别审计的例外”远程 workspace client 构造中存在 tlsRejectUnauthorized: false。这可能是为了自签名证书,但也会弱化证书校验。它不是 renderer 层能补救的问题;生产远程部署应提供显式 trust/pinning 配置,不能把“有 TLS”误当成“已验证远端身份”。
16.4 renderer 启动不是一次性 fetch all
Section titled “16.4 renderer 启动不是一次性 fetch all”App.tsx 的启动大致分为:
- 读取 setup/auth/workspace 状态;
- 加载 workspace、LLM connections、projects/sources/skills/automations 等目录数据;
- 调
getSessions()初始化 session metadata 与轻量 session; - 订阅 session/global push events;
- 选中 session 时才加载完整 messages;
- transport stale reconnect 时做有选择的 snapshot 修复。
sequenceDiagram participant UI as Renderer App participant API as electronAPI participant J as Jotai store
UI->>API: getSessions() API-->>UI: session summaries/light sessions UI->>J: initializeSessions() UI->>API: subscribe session events UI->>API: getSessionMessages(selected) API-->>UI: full StoredSession UI->>J: replaceLoadedSession() API-->>UI: push text/tool/session event UI->>J: pure project + atomic update选中后延迟读取 messages,避免侧栏每个 session 都把完整历史带进 renderer 内存。相关 atom 在 atoms/sessions.ts 与 AppShell.tsx。
16.5 Session 状态为什么拆成 metadata 与 atom family
Section titled “16.5 Session 状态为什么拆成 metadata 与 atom family”核心状态不是一个巨大的 sessions[]:
sessionIdsAtom 排序/存在性sessionMetaMapAtom 侧栏需要的轻量字段sessionAtomFamily(sessionId) 单会话完整内容loadedSessionsAtom 哪些已读完整 messagesactiveSessionIdAtom 用户当前活动视图这样设计解决三个前端成本:
- 某个 session 来一个 token,不必让全部 session card 重渲染;
- 列表过滤/未读统计不必闭包捕获几十 MB message history;
- 可以独立丢弃或重载某个 session 的完整内容。
extractSessionMeta() 明确把 session 投影成列表字段;删除确认也只读 metadata,注释直接指出避免 closure 保留完整 sessions。源码:atoms/sessions.ts、App.tsx。
除了 sessions,Jotai 还管理:
- source、project、skill、automation 目录;
- browser instances;
- messaging bindings/dialog;
- Kanban filters/editor;
- background-finished 通知;
- fullscreen overlay;
- panel stack/focus。
React local state仍用于短生命周期 UI,如输入框、resize drag、dialog、搜索文本。两类状态并存不是问题,问题是同一事实被重复拥有;Craft 在 session 主链上倾向让 atom成为事实源。
16.6 Push event 不直接散落修改组件状态
Section titled “16.6 Push event 不直接散落修改组件状态”所有 Agent 事件先进入 processEvent():
type ProcessResult = { state: { session: Session; streaming: StreamingState | null } effects: Effect[]}它是纯函数 switch:text delta/complete、tool start/result、permission、plan、error、metadata、sharing、usage 等各有 handler。副作用被返回为 effect,再由 App 执行 toast/notification/reload 等。
收益:
- 同一事件只有一个状态转移定义;
- handler 可用普通对象单测;
- message 用 id定位,不依赖“数组最后一个”;
- 每次返回新引用,Jotai/React 不会因原地 mutation 漏更新;
- 未知事件由
neverexhaustive check 暴露协议漂移。
useEventProcessor() 只额外持有每 session 的 streaming accumulator,并把 Sentry 上报放到纯函数之外,见 useEventProcessor.ts。
16.7 流式状态为什么放 ref 而不是持久 atom
Section titled “16.7 流式状态为什么放 ref 而不是持久 atom”text_delta 的中间累积不是领域事实:最终 message才需要持久化。Hook 使用:
useRef<Map<sessionId, StreamingState>>()每次 event 将当前 session + streaming state送进 reducer,拿到新 session与下一 streaming state。优点是高频 delta 不引起独立 React render;缺点是页面 reload 后丢失。因此 reconnect 恢复不能只依赖 ref,必须从服务端 snapshot/replay修正。
16.8 Event replay 与 snapshot recovery 是互补关系
Section titled “16.8 Event replay 与 snapshot recovery 是互补关系”连接恢复有两种情况:
reconnect with buffered replay → 不拉全量,按事件追平
stale reconnect / replay gap → 刷 session metadata → 刷 active + processing sessions 的完整 messages → 必要时延迟 2s/4s 重试 → active 仍空则 force reload见 App.tsx。这比“每次重连拉所有会话”省资源,也比“永远相信 event buffer”可靠。
另一个细节是非权威 refresh 的 removeMissing: false:远端切 workspace、部分响应或恢复窗口中,暂时没返回的 session 不应立即从 UI 删除。它体现了分布式 UI 的原则:缺席不一定等于删除,删除需要更强证据。
16.9 未读状态是可见性状态机,不只是 badge
Section titled “16.9 未读状态是可见性状态机,不只是 badge”SessionMeta 同时保存:
lastFinalMessageId;hasUnread;- processing/status/flag/archive 等列表属性。
UI 会告诉服务端/状态层哪些 session 正在被看;split panel 中只要 session 在任一 panel 可见,就不应简单视作后台。visibleSessionIdsAtom 从 panel routes推导所有可见 session,见 panel-stack.ts。
这避免一个常见错误:只用“当前选中 id”判断未读,在并排查看或多窗口中会不断把用户眼前的新消息标成未读。
16.10 AppShell 是一个桌面工作台
Section titled “16.10 AppShell 是一个桌面工作台”AppShell.tsx 组合:
TopBar└── outer layout ├── workspace/sidebar navigation ├── navigator │ ├── session list │ ├── sources │ ├── projects │ ├── skills │ ├── automations │ └── settings ├── PanelStackContainer │ ├── chat/session panel(s) │ └── source/settings/other panel(s) └── contextual overlays/dialogs路由不是只控制页面,也编码 filter/detail:all sessions、flagged、label、state、project、source detail、settings subpage 等。侧栏状态与 deep link共享 route parser,避免“URL 指向 A、组件内部 selected 指向 B”。
16.11 Panel stack 把“当前页”升级成“工作面”
Section titled “16.11 Panel stack 把“当前页”升级成“工作面””interface PanelStackEntry { id: string route: ViewRoute proportion: number panelType: 'session' | 'source' | 'settings' | 'skills' | 'other' laneId: 'main'}push/close/reconcile/resize 都归一化 proportions;focused panel与 visible sessions 都从同一 stack派生。当前只有 main lane,但 policy 类型为未来多 lane/固定 inspector留了演进点。
这比在每个组件里维护 leftOpen/rightOpen/currentId 更可扩展,因为 panel 是统一数据结构,不同类型页面共享 focus、resize、history 和 split-view行为。
16.12 响应式不是 WebUI 补丁层
Section titled “16.12 响应式不是 WebUI 补丁层”AppShell 通过容器宽度计算 isAutoCompact;小于阈值时自动隐藏 sidebar/navigator、panel 宽度变全屏,并在 session list显示移动端 FAB。源码:AppShell.tsx、AppShell.tsx。
WebUI 的 responsive.ts 只用 viewport判断触摸/键盘等环境;布局主要由共享 renderer的 container query/effective compact mode负责。
设计收益:同一个 Electron 窗口缩窄时也能得到相同行为;响应式不是“浏览器专属分叉”。
16.13 ChatDisplay 展示的是 turn,而不是裸消息数组
Section titled “16.13 ChatDisplay 展示的是 turn,而不是裸消息数组”服务端事件最终形成 Session.messages,UI 再把它们组织为:
- 用户消息;
- assistant 文本;
- tool activity cards;
- thinking/streaming/interrupted/error状态;
- background task progress;
- permission/credential/plan UI;
- annotations、follow-ups 与 artifacts。
ChatDisplay.tsx 与 @craft-agent/ui 的 SessionViewer/activity components共享呈现语义。工具结果先经 tool-parsers.ts 变成 read/bash/grep/diff/preview等结构,而不是把所有 JSON 当 code block。
重要边界:parser是“展示推断”,持久层仍保存原始 tool input/result。UI parser失败应退化到 GenericOverlay,不应损坏事实数据。
16.14 输入框是一次 turn 的控制台
Section titled “16.14 输入框是一次 turn 的控制台”FreeFormInput.tsx 不只是 textarea,它组合:
- 文本草稿与 500ms debounce持久化;
- 文件/图片/PDF attachment;
@context/source/skill 等引用;- model/connection选择;
- thinking level;
- permission mode;
- send、queue、steer、stop;
- compact/plan等 session command;
- IME、快捷键和移动端行为。
因此“发送按钮”不能简单以 isProcessing 禁用。第 05/09 章提到 SessionManager支持 mid-stream queue/steer,输入层必须把用户意图映射成正确动作。
16.15 @craft-agent/ui 的职责
Section titled “16.15 @craft-agent/ui 的职责”共享 UI package提供:
SessionViewer、message/activity cards;- Markdown、code、terminal、diff渲染;
- HTML/PDF/JSON/document/mermaid 等 overlay;
- annotation组件;
- 基础 UI primitives、布局 token;
- tool result parser;
PlatformContext。
它不拥有 session runtime、RPC或 workspace状态。平台动作通过可选依赖注入:
interface PlatformActions { onOpenFile?() onOpenUrl?() onReadFile?() onRevealInFinder?() onSetTrafficLightsVisible?() // ...}见 PlatformContext.tsx。共享组件检查能力是否存在;Viewer不提供 onOpenFile,对应菜单就隐藏或退化。
这是比 if (isElectron) 更健康的复用:判断能力,不判断品牌。
16.16 Overlay 是跨表面的 artifact viewer
Section titled “16.16 Overlay 是跨表面的 artifact viewer”工具 activity点击后可进入:
- code preview;
- terminal output;
- multi-file diff;
- JSON/HTML/PDF/image;
- formatted Markdown/document;
- Mermaid;
- generic raw result。
overlay复用 PlatformContext 执行打开文件、Reveal、复制等;Electron还会在 fullscreen overlay时隐藏 macOS traffic lights。Overlay stack/escape处理集中,避免多个 modal各自抢 Escape。
安全上要区分:
- Markdown text渲染;
- HTML artifact预览;
- 外部 URL;
- 本地文件内容。
它们来自模型/工具,均不是可信 UI 源。外链先经过 open-external-url.ts 的 protocol判断;HTML/iframe能力应继续维持 sandbox/CSP边界。
16.17 WebUI 如何启动完整 renderer
Section titled “16.17 WebUI 如何启动完整 renderer”sequenceDiagram participant B as Browser participant H as HTTP server participant W as WsRpcClient participant A as Electron Renderer App
B->>H: GET /api/config (cookie) H-->>B: wsUrl / 401 B->>H: GET /api/config/workspaces H-->>B: defaultWorkspaceId B->>W: createWebApi + connect B->>B: window.electronAPI = api B->>A: lazy import and mountLazy import是必要的:有些 renderer module在初始化/挂载时就读取全局 API;先静态 import再赋值会产生时序 race。
WebUI 认证用 HTTP-only session cookie保护 config/login,WebSocket再使用服务端给出的连接语义。401 会跳回 /login;retry前销毁旧 client,避免重复订阅。
16.18 Web adapter 的兼容成本
Section titled “16.18 Web adapter 的兼容成本”复用完整 renderer意味着 adapter必须为 Electron-only API定义行为:
| Electron 能力 | Web 选择 |
|---|---|
| RPC request/push | 直接映射 WebSocket |
| open URL | window.open/安全 helper |
| clipboard | Web Clipboard API |
| local file path | 通常不可用或走 server API |
| native file dialog | <input type=file> 或 capability unavailable |
| BrowserPane | 需要在线 Electron capability,否则不可用 |
| native menu/window | no-op、隐藏或 Web替代 |
风险是“方法存在但语义为空”的假兼容。更稳健的协议应同时暴露 isChannelAvailable/capability set,让 UI不渲染不可完成的操作,而不是点击后才发现 no-op。
16.19 Viewer 为什么没有复用完整 App
Section titled “16.19 Viewer 为什么没有复用完整 App”Viewer 的输入只有 StoredSession:
/ upload JSON/s/{publicId} GET /s/api/{publicId} │ ▼SessionViewer mode=readonly │ └── local overlay state它没有 WebSocket、workspace选择、实时 event、send/permission/source API。见 apps/viewer/src/App.tsx。
这不是功能不足,而是分享安全边界:公开 URL泄露的能力应止于读取一个经过服务端选择的 snapshot,不能成为远程控制原 session 的 bearer capability。
16.20 Viewer 的 provider兼容细节
Section titled “16.20 Viewer 的 provider兼容细节”Viewer 点击 Edit/Write activity时构造 diff:
filePath = input.file_path || input.pathold = input.old_string || input.oldTextnew = input.new_string || input.newTextClaude字段为主,Pi字段为 fallback,见 viewer/App.tsx。这说明持久 transcript的 tool payload还没有完全 canonicalize。
短期这种 additive fallback保留历史兼容;长期更好的位置是 shared normalizer,而不是每个 surface重复 provider字段判断。
16.21 Theme、i18n 与宿主差异
Section titled “16.21 Theme、i18n 与宿主差异”- Electron/WebUI共享 renderer主题、CSS variables和 i18next resources;
- Viewer根据系统
prefers-color-scheme初始化,并允许本地切换; - WebUI loading/error页面自身也走翻译;
- CI校验 i18n parity、排序和 coverage,避免某表面静默回退 key。
视觉 token放共享 styles有利于一致性,但宿主行为仍由 PlatformContext/API决定。主题共享不等于能力共享。
16.22 当前最明显的前端结构债
Section titled “16.22 当前最明显的前端结构债”核心文件规模:
| 文件 | 约行数 | 混合职责 |
|---|---|---|
AppShell.tsx |
3,911 | layout、导航、筛选、数据加载、dialog、resize |
FreeFormInput.tsx |
2,537 | editor、附件、mode、model、命令、快捷键 |
ChatDisplay.tsx |
2,383 | turn组织、滚动、活动、搜索、交互 |
App.tsx |
2,270 | bootstrap、RPC订阅、effect、session command |
main/index.ts |
1,292 | app bootstrap 与多个基础设施 wiring |
大文件不自动等于坏代码,但这里已经形成修改热点。风险包括:
- hook依赖与 stale closure难审计;
- renderer生命周期与领域命令耦合;
- Web/Electron差异可能靠局部条件继续增长;
- 测试必须构造过大的组件上下文;
- 同一 refresh/reconcile策略容易出现多个近似版本。
建议优先按稳定边界抽取,而不是按 JSX大小机械拆文件:
useSessionEventProjection:订阅、replay、snapshot reconcile;useSessionCommands:create/delete/send/branch/share等应用服务;NavigatorModel:route→filter/count/tree纯投影;ComposerController:draft/attachment/send intent状态机;ClientCapabilities:统一 Electron/Web可用能力;- provider-neutral tool payload normalizer。
16.23 新增一个 UI 能力的正确路径
Section titled “16.23 新增一个 UI 能力的正确路径”例如加入“打开生成的 spreadsheet”:
- 先确定事实数据是否已存在于 tool result/session;
- 在
@craft-agent/ui加纯 parser 与展示组件; - 若需要宿主动作,扩
PlatformActions的可选方法; - Electron实现 native/open path;Web实现下载或隐藏;Viewer实现内联预览;
- 若需要服务端数据,增加协议 channel和 handler,而不是 renderer直接读磁盘;
- 给纯 parser、event handler与至少两个 surface加测试;
- 验证 remote workspace capability unavailable时能清晰退化。
16.24 可迁移结论
Section titled “16.24 可迁移结论”- 复用应用先统一 transport contract,而不是复制页面。
- 只读 Viewer 应复用数据模型和展示组件,不复用 mutation shell。
- 实时 UI需要 event projection,也需要 snapshot repair。
- 大对象列表要把 metadata与完整实体分开。
- 平台差异用 capability injection表达,比全局
isElectron分支更稳。 - split view后,“active”必须升级为“visible set”。
- 模型生成的 Markdown、HTML、URL、路径都要跨表面保持同一不可信输入假设。
下一章把 UI之外的另一个“客户端”接进来:Telegram、WhatsApp 与 Lark 并不消费 React 状态,而是消费同一 SessionManager 事件流,再将聊天动作安全地路由回会话。