27.2 控制台 UI 架构

生成模型:Claude Opus 4.6 (anthropic/claude-opus-4-6) Token 消耗:输入 ~140k tokens,输出 ~8k tokens(本节)


上一节介绍了控制台 UI 的技术选型(Lit + Vite + Legacy Decorators)。本节从架构层面展开:Gateway 如何将这个 SPA 嵌入自身的 HTTP 服务、前端源码如何组织、以及浏览器端的 WebSocket 客户端如何与 Gateway 通信。

27.2.1 Gateway 内嵌 UI 服务

控制台 UI 的构建产物(HTML、JS、CSS、SVG)打包后存放在 dist/control-ui/ 目录下。Gateway 进程启动时,不需要额外的 Nginx 或 CDN——它自己就是 UI 的 HTTP 服务器。核心逻辑在 src/gateway/control-ui.ts

静态文件服务与 SPA 回退

handleControlUiHttpRequest 函数处理所有 UI 相关的 HTTP 请求,其流程如下:

// src/gateway/control-ui.ts(简化)
export function handleControlUiHttpRequest(
  req: IncomingMessage,
  res: ServerResponse,
  opts?: ControlUiRequestOptions,
): boolean {
  // 1. 仅接受 GET / HEAD
  if (req.method !== "GET" && req.method !== "HEAD") {
    res.statusCode = 405;
    res.end("Method Not Allowed");
    return true;
  }

  // 2. 解析 basePath(支持反向代理挂载到子路径)
  const basePath = normalizeControlUiBasePath(opts?.basePath);
  
  // 3. 定位静态资源根目录
  const root = resolveControlUiRootSync({ ... });
  if (!root) {
    res.statusCode = 503;
    res.end("Control UI assets not found...");
    return true;
  }

  // 4. 尝试匹配文件;若文件存在则直接返回
  const filePath = path.join(root, fileRel);
  if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
    serveFile(res, filePath);
    return true;
  }

  // 5. SPA 回退:所有未匹配路径返回 index.html
  const indexPath = path.join(root, "index.html");
  serveIndexHtml(res, indexPath, { basePath, config, agentId });
  return true;
}

这是经典的 SPA 回退模式:前端使用 History API 进行客户端路由(如 /chat/agents),但这些路径在服务端并没有对应的物理文件。Gateway 将所有未匹配的 GET 请求重定向到 index.html,由前端 JavaScript 根据 URL 决定显示哪个页面。

衍生解释 — SPA 回退(History API Fallback)

单页应用(SPA)使用浏览器的 History API(pushState / popstate)实现无刷新页面切换。当用户直接访问 /agents 或刷新页面时,浏览器会向服务器请求 /agents 路径。如果服务器没有对应文件,就需要"回退"到 index.html,让前端路由接管。这就是为什么 Nginx 配置 SPA 时常见 try_files $uri /index.html; 规则。

安全头注入

每个响应都附带安全头:

安全头
作用

X-Frame-Options

DENY

禁止被嵌入到 <iframe> 中,防止点击劫持

Content-Security-Policy

frame-ancestors 'none'

CSP 级别的 iframe 嵌入禁止

X-Content-Type-Options

nosniff

阻止浏览器对 MIME 类型的嗅探猜测

配置注入:index.html 动态写入

Gateway 在返回 index.html 前会动态注入一段 <script> 标签,将运行时配置传递给前端:

这种"HTML 注入"模式的优势是:构建产物是静态的,但每个 Gateway 实例可以有不同的助手名称和头像,无需重新构建。前端通过 window.__OPENCLAW_CONTROL_UI_BASE_PATH__ 等全局变量读取这些值。

头像端点

除了静态文件服务,Gateway 还提供专用的头像端点 /_avatar/{agentId}handleControlUiAvatarRequest 支持两种模式:

  1. 图片直出(默认):如果头像是本地文件,直接返回二进制内容

  2. 元数据查询?meta=1):返回 JSON { avatarUrl } 供前端决策——头像可能是本地文件、远程 URL 或 data URI

27.2.2 UI 源码结构

前端代码位于 ui/src/ui/ 目录下,约 50 个文件,采用"单组件 + 模块分解"的架构。

整体文件组织

单组件架构

整个 UI 只有一个 Lit 组件 OpenClawApp<openclaw-app> 标签),包含 100+ 个 @state() 响应式字段。这不是"组件化"的教科书做法,但对于管理面板类应用来说,有其合理性:

  1. 状态集中管理:所有状态在一处,不需要 Context/Store 等状态管理方案

  2. 避免组件间通信开销:父子组件、兄弟组件之间的 props 传递和事件冒泡在只有一个组件时完全消除

  3. 降低 Lit 特有的序列化成本:Custom Elements 的 attribute 传值只支持字符串,复杂对象需要 property 传递

代价是这个组件非常庞大。OpenClaw 的应对策略是 模块分解——将逻辑拆到 20+ 个独立文件中:

注意 createRenderRoot() { return this; } 这行——它跳过了 Shadow DOM,让组件直接渲染到 Light DOM 中。这意味着全局 CSS(styles.css)可以直接作用于组件内部元素,无需 CSS Modules 或 ::part() 伪元素。

衍生解释 — Light DOM vs Shadow DOM

Shadow DOM 是 Web Components 的核心特性之一,它为组件创建一个隔离的 DOM 子树,外部 CSS 无法穿透进去。这对于通用组件库(如 Material Design 组件)很有价值,但对于应用级组件来说,样式隔离反而是障碍——你需要额外的机制来共享主题变量。OpenClaw 选择 Light DOM 是务实之举。

渲染模型:AppViewState 契约

OpenClawApp 类和 renderApp() 函数之间通过 AppViewState 类型建立契约:

AppViewState 既包含数据字段,也包含行为方法。renderApp() 接收这个类型而非 OpenClawApp 类本身——这意味着渲染函数只依赖接口,而非具体实现,便于测试和重构。

视图分发

renderApp() 函数根据当前 Tab 选择性渲染对应视图:

这里使用了 Lit 的 nothing 特殊值——当条件不满足时,Lit 不会为该位置生成任何 DOM 节点,实现了懒渲染。

导航系统

12 个 Tab 分为 4 组:

Tab
说明

Chat

chat

WebChat 聊天界面

Control

overview, channels, instances, sessions, usage, cron

运维管理

Agent

agents, skills, nodes

代理配置

Settings

config, debug, logs

系统设置

路由机制基于浏览器 History API,每个 Tab 对应一个路径(如 /agents):

inferBasePathFromPathname 函数支持自动推断 basePath——当 Gateway 被反向代理挂载到 /admin/ui/ 等子路径时,前端能自动感知并正确生成链接。

控制器层

controllers/ 目录包含 23 个文件,每个文件封装一个功能域的数据操作。控制器不是类,而是纯函数,接收 OpenClawApp 实例(或其类型投影)作为参数:

所有控制器通过 host.client.request(method, params) 与 Gateway WebSocket 通信。例如加载代理列表:

这种模式的特点是:控制器直接修改宿主组件的 @state() 字段。由于 @state() 字段的变更会自动触发 Lit 的重渲染,控制器无需手动通知 UI 更新。

27.2.3 Gateway WebSocket 客户端

前端与 Gateway 的所有实时通信都通过一个 WebSocket 连接完成,封装在 GatewayBrowserClient 类中。

连接协议

连接建立的流程是:

WebSocket 打开后,Gateway 先发送 connect.challenge 事件携带一个 nonce(随机数),客户端用它进行签名认证后发送 connect 请求。认证通过后 Gateway 返回 hello-ok

帧格式

通信使用三种 JSON 帧:

事件帧是服务端主动推送,请求/响应帧是客户端发起的 RPC 调用。序列号 seq 用于检测事件是否丢失——如果收到的 seq 不是上次 +1,客户端会触发 onGap 回调提示用户。

设备身份认证

GatewayBrowserClient 支持 Ed25519 设备身份认证。首次连接时,浏览器使用 Web Crypto API 生成密钥对,存入 IndexedDB。后续连接时用私钥对 {deviceId, clientId, nonce, signedAtMs, ...} 进行签名:

衍生解释 — Ed25519

Ed25519 是一种椭圆曲线数字签名算法,属于 Edwards-curve Digital Signature Algorithm (EdDSA) 家族。它的特点是:密钥短(公钥 32 字节)、签名快、验证快,且没有像 ECDSA 那样的随机数陷阱(确定性签名)。Web Crypto API 在安全上下文(HTTPS 或 localhost)中提供了对 Ed25519 的原生支持。OpenClaw 使用 @noble/ed25519 作为兼容层。

在非安全上下文(如 HTTP 明文访问)中,crypto.subtle 不可用,客户端会退回到纯 token 认证模式。

自动重连与退避

连接断开后,客户端自动按指数退避策略重连:

重连序列:800ms → 1360ms → 2312ms → 3930ms → ... → 15000ms(封顶)。连接成功后 backoffMs 重置为 800ms。特殊情况:WebSocket 关闭码 1012(Service Restart)不会显示错误信息——这通常发生在用户通过 UI 保存配置后 Gateway 重启的场景。

事件分发

连接建立后,Gateway 持续推送事件。handleGatewayEvent 函数根据事件类型分发到不同处理器:

特别值得注意的是执行审批事件的处理:当 Agent 请求执行某个需要批准的操作时,Gateway 推送 exec.approval.requested 事件,UI 将其加入队列并显示审批弹窗。审批条目有过期时间,到期后自动从队列中移除。

初始快照

hello-ok 响应中可能携带 snapshot 字段,包含在线实例列表和健康状态:

这避免了连接后立即发起多个 RPC 请求来获取初始数据——Gateway 在握手时就将关键状态一次性推送过来。

生命周期编排

app-lifecycle.ts 将 Lit 组件的生命周期钩子组织成清晰的启动/更新/销毁流程:

Lit 钩子
操作

connectedCallback

推断 basePath → 从 URL 读取设置 → 同步 Tab → 同步主题 → 监听 popstate → 连接 Gateway → 启动 Nodes 轮询

firstUpdated

监听 topbar 元素尺寸(ResizeObserver)

updated(changed)

聊天内容变化时自动滚动到底部;日志内容变化时跟踪滚动

disconnectedCallback

清理事件监听、停止轮询、断开 Gateway

handleUpdated 中的自动滚动逻辑体现了细致的 UX 考量:只有在用户没有手动上滚时才自动滚动到底部(chatHasAutoScrolled 标记),切换到 Chat Tab 时强制滚动。


本节小结

  1. Gateway 自身就是 UI 的 HTTP 服务器,通过静态文件服务 + SPA 回退 + HTML 配置注入的方式提供控制台界面,无需额外的前端部署。

  2. 单组件 + 模块分解是核心架构:一个 100+ 字段的 OpenClawApp 组件承载全部状态,但逻辑分散到 app-*.ts(行为)、views/(渲染)、controllers/(数据)三层。

  3. AppViewState 类型定义了组件到渲染函数的契约,包含数据字段和行为方法,实现了渲染逻辑与组件实现的解耦。

  4. GatewayBrowserClient 封装了 WebSocket 通信,支持三种帧格式(事件/请求/响应)、Ed25519 设备身份认证、指数退避重连、序列号间隙检测。

  5. 事件分发器根据事件类型路由到对应处理器,支持聊天、工具流、在线状态、定时任务、设备配对、执行审批等事件类型。

  6. 初始快照机制在握手时就推送关键状态,减少连接后的请求往返。

Last updated