生成模型 :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。
handleControlUiHttpRequest 函数处理所有 UI 相关的 HTTP 请求,其流程如下:
Copy // 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; 规则。
每个响应都附带安全头:
配置注入:index.html 动态写入
Gateway 在返回 index.html 前会动态注入一段 <script> 标签,将运行时配置传递给前端:
这种"HTML 注入"模式的优势是:构建产物是静态的,但每个 Gateway 实例可以有不同的助手名称和头像,无需重新构建。前端通过 window.__OPENCLAW_CONTROL_UI_BASE_PATH__ 等全局变量读取这些值。
除了静态文件服务,Gateway 还提供专用的头像端点 /_avatar/{agentId}。handleControlUiAvatarRequest 支持两种模式:
图片直出 (默认):如果头像是本地文件,直接返回二进制内容
元数据查询 (?meta=1):返回 JSON { avatarUrl } 供前端决策——头像可能是本地文件、远程 URL 或 data URI
前端代码位于 ui/src/ui/ 目录下,约 50 个文件,采用"单组件 + 模块分解"的架构。
整个 UI 只有一个 Lit 组件 OpenClawApp(<openclaw-app> 标签),包含 100+ 个 @state() 响应式字段 。这不是"组件化"的教科书做法,但对于管理面板类应用来说,有其合理性:
状态集中管理 :所有状态在一处,不需要 Context/Store 等状态管理方案
避免组件间通信开销 :父子组件、兄弟组件之间的 props 传递和事件冒泡在只有一个组件时完全消除
降低 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 组:
overview, channels, instances, sessions, usage, cron
路由机制基于浏览器 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 组件的生命周期钩子组织成清晰的启动/更新/销毁流程:
推断 basePath → 从 URL 读取设置 → 同步 Tab → 同步主题 → 监听 popstate → 连接 Gateway → 启动 Nodes 轮询
监听 topbar 元素尺寸(ResizeObserver)
聊天内容变化时自动滚动到底部;日志内容变化时跟踪滚动
handleUpdated 中的自动滚动逻辑体现了细致的 UX 考量:只有在用户没有手动上滚时才自动滚动到底部(chatHasAutoScrolled 标记),切换到 Chat Tab 时强制滚动。
Gateway 自身就是 UI 的 HTTP 服务器 ,通过静态文件服务 + SPA 回退 + HTML 配置注入的方式提供控制台界面,无需额外的前端部署。
单组件 + 模块分解 是核心架构:一个 100+ 字段的 OpenClawApp 组件承载全部状态,但逻辑分散到 app-*.ts(行为)、views/(渲染)、controllers/(数据)三层。
AppViewState 类型 定义了组件到渲染函数的契约,包含数据字段和行为方法,实现了渲染逻辑与组件实现的解耦。
GatewayBrowserClient 封装了 WebSocket 通信,支持三种帧格式(事件/请求/响应)、Ed25519 设备身份认证、指数退避重连、序列号间隙检测。
事件分发器 根据事件类型路由到对应处理器,支持聊天、工具流、在线状态、定时任务、设备配对、执行审批等事件类型。
初始快照机制 在握手时就推送关键状态,减少连接后的请求往返。