跳转到内容

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 → WsRpcClient

WebUI 在加载 Electron App 前创建 adapter,并把它赋到 window.electronAPI。因此上层组件不必知道调用是在本机 main process、远端 server,还是客户端 capability 上完成。入口明确写出了五步启动过程,见 apps/webui/src/App.tsx

设计判断:这是高收益的兼容层,但名称已经落后于职责。若继续扩展到移动端,适合逐步改成 ClientAPI/RuntimeAPI,并让 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: truenodeIntegration: false,preload 再通过 contextBridge.exposeInMainWorld('electronAPI', api) 暴露受控表面。源码:window-manager.tspreload/bootstrap.ts

preload 不是简单 IPC 转发器。它负责:

  • 读取窗口、workspace、本地 WS 端口/token 等 bootstrap 信息;
  • 构造 WsRpcClientRoutedClient
  • 注册 open URL/path、文件对话框、确认框、BrowserPane 等 client capability;
  • 把 channel map 生成为 ElectronAPI
  • 暴露连接状态、重连和少数纯客户端 helper。

远程 thin-client 会拒绝向非 localhost 的明文 ws:// 发送 token,要求 wss://,见 preload/bootstrap.ts

远程 workspace client 构造中存在 tlsRejectUnauthorized: false。这可能是为了自签名证书,但也会弱化证书校验。它不是 renderer 层能补救的问题;生产远程部署应提供显式 trust/pinning 配置,不能把“有 TLS”误当成“已验证远端身份”。

16.4 renderer 启动不是一次性 fetch all

Section titled “16.4 renderer 启动不是一次性 fetch all”

App.tsx 的启动大致分为:

  1. 读取 setup/auth/workspace 状态;
  2. 加载 workspace、LLM connections、projects/sources/skills/automations 等目录数据;
  3. getSessions() 初始化 session metadata 与轻量 session;
  4. 订阅 session/global push events;
  5. 选中 session 时才加载完整 messages;
  6. 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.tsAppShell.tsx

16.5 Session 状态为什么拆成 metadata 与 atom family

Section titled “16.5 Session 状态为什么拆成 metadata 与 atom family”

核心状态不是一个巨大的 sessions[]

sessionIdsAtom 排序/存在性
sessionMetaMapAtom 侧栏需要的轻量字段
sessionAtomFamily(sessionId) 单会话完整内容
loadedSessionsAtom 哪些已读完整 messages
activeSessionIdAtom 用户当前活动视图

这样设计解决三个前端成本:

  • 某个 session 来一个 token,不必让全部 session card 重渲染;
  • 列表过滤/未读统计不必闭包捕获几十 MB message history;
  • 可以独立丢弃或重载某个 session 的完整内容。

extractSessionMeta() 明确把 session 投影成列表字段;删除确认也只读 metadata,注释直接指出避免 closure 保留完整 sessions。源码:atoms/sessions.tsApp.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 漏更新;
  • 未知事件由 never exhaustive 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”判断未读,在并排查看或多窗口中会不断把用户眼前的新消息标成未读。

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 把“当前页”升级成“工作面””

atoms/panel-stack.ts 定义:

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行为。

AppShell 通过容器宽度计算 isAutoCompact;小于阈值时自动隐藏 sidebar/navigator、panel 宽度变全屏,并在 session list显示移动端 FAB。源码:AppShell.tsxAppShell.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/uiSessionViewer/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,输入层必须把用户意图映射成正确动作。

共享 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边界。

apps/webui/src/App.tsx 的时序:

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 mount

Lazy import是必要的:有些 renderer module在初始化/挂载时就读取全局 API;先静态 import再赋值会产生时序 race。

WebUI 认证用 HTTP-only session cookie保护 config/login,WebSocket再使用服务端给出的连接语义。401 会跳回 /login;retry前销毁旧 client,避免重复订阅。

复用完整 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。

Viewer 点击 Edit/Write activity时构造 diff:

filePath = input.file_path || input.path
old = input.old_string || input.oldText
new = input.new_string || input.newText

Claude字段为主,Pi字段为 fallback,见 viewer/App.tsx。这说明持久 transcript的 tool payload还没有完全 canonicalize。

短期这种 additive fallback保留历史兼容;长期更好的位置是 shared normalizer,而不是每个 surface重复 provider字段判断。

  • Electron/WebUI共享 renderer主题、CSS variables和 i18next resources;
  • Viewer根据系统 prefers-color-scheme 初始化,并允许本地切换;
  • WebUI loading/error页面自身也走翻译;
  • CI校验 i18n parity、排序和 coverage,避免某表面静默回退 key。

视觉 token放共享 styles有利于一致性,但宿主行为仍由 PlatformContext/API决定。主题共享不等于能力共享。

核心文件规模:

文件 约行数 混合职责
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大小机械拆文件:

  1. useSessionEventProjection:订阅、replay、snapshot reconcile;
  2. useSessionCommands:create/delete/send/branch/share等应用服务;
  3. NavigatorModel:route→filter/count/tree纯投影;
  4. ComposerController:draft/attachment/send intent状态机;
  5. ClientCapabilities:统一 Electron/Web可用能力;
  6. provider-neutral tool payload normalizer。

16.23 新增一个 UI 能力的正确路径

Section titled “16.23 新增一个 UI 能力的正确路径”

例如加入“打开生成的 spreadsheet”:

  1. 先确定事实数据是否已存在于 tool result/session;
  2. @craft-agent/ui 加纯 parser 与展示组件;
  3. 若需要宿主动作,扩 PlatformActions 的可选方法;
  4. Electron实现 native/open path;Web实现下载或隐藏;Viewer实现内联预览;
  5. 若需要服务端数据,增加协议 channel和 handler,而不是 renderer直接读磁盘;
  6. 给纯 parser、event handler与至少两个 surface加测试;
  7. 验证 remote workspace capability unavailable时能清晰退化。
  1. 复用应用先统一 transport contract,而不是复制页面。
  2. 只读 Viewer 应复用数据模型和展示组件,不复用 mutation shell。
  3. 实时 UI需要 event projection,也需要 snapshot repair。
  4. 大对象列表要把 metadata与完整实体分开。
  5. 平台差异用 capability injection表达,比全局 isElectron 分支更稳。
  6. split view后,“active”必须升级为“visible set”。
  7. 模型生成的 Markdown、HTML、URL、路径都要跨表面保持同一不可信输入假设。

下一章把 UI之外的另一个“客户端”接进来:Telegram、WhatsApp 与 Lark 并不消费 React 状态,而是消费同一 SessionManager 事件流,再将聊天动作安全地路由回会话。