OpenClaw源码解读之Web 控制台

OpenClaw 的 Web 控制台是一个基于 Lit 的单页应用(SPA),与 Gateway 共享同一端口。用户通过浏览器访问 `/ui` 路径即可打开控制面板,实时管理 Agent、通道、会话和配置。本文从静态资源服务、WebSocket 通信、设备认证到前端组件架构,逐层剖析这套系统的实现。

一、服务端:静态文件与 SPA 回退
Gateway 的 HTTP 请求处理链中,Control UI 被放在最后——前面的 Hooks、Plugin、OpenAI 兼容等路由未命中后,请求才会到达 `handleControlUiHttpRequest`:
// src/gateway/control-ui.tsexport function handleControlUiHttpRequest(req: IncomingMessage, res: ServerResponse, opts?: ControlUiRequestOptions,): boolean {const basePath = normalizeControlUiBasePath(opts?.basePath);const pathname = url.pathname;if (basePath && !pathname.startsWith(`${basePath}/`)) {return false; // 不是 UI 路径,交给下一个处理器}applyControlUiSecurityHeaders(res);const root = resolveControlUiRootSync({ ... });if (!root) {res.statusCode = 503;res.end("Control UI assets not found. Build them with `pnpm ui:build`...");return true;}const filePath = path.join(root, fileRel);if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {if (path.basename(filePath) === "index.html") {serveIndexHtml(res, filePath, { basePath, config: opts?.config });return true;}serveFile(res, filePath);return true;}// SPA fallback:未知路径一律返回 index.htmlconst indexPath = path.join(root, "index.html");if (fs.existsSync(indexPath)) {serveIndexHtml(res, indexPath, { basePath, config: opts?.config });return true;}respondNotFound(res);return true;}
几个关键设计点:
-
basePath 隔离:`gateway.controlUi.basePath` 配置项允许自定义 UI 挂载路径(默认 `/ui`),所有路径匹配都基于 basePath 做前缀剥离
-
安全头注入:`applyControlUiSecurityHeaders` 设置 `X-Frame-Options`、CSP、`X-Content-Type-Options` 等头,防止点击劫持和内容嗅探
-
路径遍历防护:`isSafeRelativePath` 拒绝含 `../` 的路径,`filePath.startsWith(root)` 二次校验确保不会逃出资源根目录
-
SPA 回退:对于不存在的静态文件路径,一律返回 `index.html`,由前端路由器接管
-
配置注入:`serveIndexHtml` 在返回 HTML 前将 `basePath` 和 assistant identity 信息注入到全局变量 `__OPENCLAW_CONTROL_UI_BASE_PATH__` 中
资源文件由 `resolveControlUiRootSync` 定位,它依次检查配置指定路径、`dist/control-ui` 目录和开发模式下的 `ui/dist` 路径。
二、WebSocket 客户端:连接、认证与重连
前端通过 `GatewayBrowserClient` 与 Gateway 建立 WebSocket 长连接,这是所有实时数据的传输通道:
// ui/src/ui/gateway.tsexport class GatewayBrowserClient {private ws: WebSocket | null = null;private pending = new Map<string, Pending>();private lastSeq: number | null = null;private backoffMs = 800;private connect() {this.ws = new WebSocket(this.opts.url);this.ws.addEventListener("open", () => this.queueConnect());this.ws.addEventListener("message", (ev) => this.handleMessage(String(ev.data)));this.ws.addEventListener("close", (ev) => {this.flushPending(new Error(`gateway closed (${ev.code})`));this.scheduleReconnect();});}private scheduleReconnect() {const delay = this.backoffMs;this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);window.setTimeout(() => this.connect(), delay);}}
连接建立后立即发送 `connect` 握手请求,流程涉及设备身份认证。WebSocket 断开时自动重连,退避因子 1.7x,上限 15 秒。
三、设备身份认证:Ed25519 签名
Web 控制台使用一套基于 Ed25519 的设备身份体系。首次访问时 `loadOrCreateDeviceIdentity` 生成密钥对并持久化到 localStorage:
// ui/src/ui/device-identity.tsexport async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {const raw = localStorage.getItem(STORAGE_KEY);if (raw) {const parsed = JSON.parse(raw);// 验证结构完整性,重新计算 deviceId(公钥指纹)const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey));return { deviceId: derivedId, publicKey: parsed.publicKey, privateKey: parsed.privateKey };}// 首次:生成新密钥对const identity = await generateIdentity();localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: 1, ...identity }));return identity;}
`sendConnect` 握手时,客户端用私钥签署一个包含 `deviceId`、`clientId`、`role`、`scopes`、`signedAtMs` 和可选 `nonce` 的 payload:
const payload = buildDeviceAuthPayload({ deviceId, clientId, role, scopes, signedAtMs, nonce });const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
Gateway 收到后验证签名,如果通过则返回一个 `deviceToken`,客户端将其缓存到 localStorage。后续重连时优先使用 deviceToken,避免每次都做签名验证。这个设计类似 OAuth 的 token 刷新机制——首次用强凭据换 token,后续复用 token 直到失效。
注意:Ed25519 需要 `crypto.subtle` API,只在安全上下文(HTTPS 或 localhost)中可用。非安全环境下回退到纯 token/password 认证。
四、前端架构:Lit + 控制器模式
整个 UI 是一个 Lit Web Component `<openclaw-app>`,通过 Vite 构建。应用组件定义了超过 100 个 `@state()` 响应式属性,覆盖了所有页面的数据状态:
// ui/src/ui/app.ts@customElement("openclaw-app")export class OpenClawApp extends LitElement {@state() connected = false;@state() tab: Tab = "chat";@state() chatMessages: unknown[] = [];@state() configSnapshot: ConfigSnapshot | null = null;@state() channelsSnapshot: ChannelsStatusSnapshot | null = null;@state() agentsList: AgentsListResult | null = null;@state() sessionsResult: SessionsListResult | null = null;// ... 100+ 更多状态}
Lit 的 `@state()` 装饰器让每个属性变更自动触发重渲染。为了避免单文件过于庞大,逻辑被拆分到多个 mixin 式文件中:
-
`app-lifecycle.ts`:组件生命周期(`connectedCallback`、`firstUpdated`、`disconnectedCallback`)
-
`app-gateway.ts`:WebSocket 连接管理和事件分发
-
`app-render.ts`:主渲染函数,根据当前 tab 路由到对应视图
-
`app-settings.ts`:设置持久化、主题切换、URL 同步
-
`app-scroll.ts`:Chat 和 Logs 的滚动行为管理
-
`app-chat.ts`:聊天消息发送和中断
五、导航与路由
导航系统将页面组织为四个分组共 12 个 tab:
// ui/src/ui/navigation.tsexport const TAB_GROUPS = [{ label: "Chat", tabs: ["chat"] },{ label: "Control", tabs: ["overview", "channels", "instances", "sessions", "usage", "cron"] },{ label: "Agent", tabs: ["agents", "skills", "nodes"] },{ label: "Settings", tabs: ["config", "debug", "logs"] },];
路由是纯客户端实现。`pathForTab` 将 tab 映射为 URL 路径(如 `agents` → `/ui/agents`),`tabFromPath` 做反向解析。根路径 `/` 默认映射到 `chat`。用户切换 tab 时通过 `history.pushState` 更新浏览器地址栏,`popstate` 事件监听器处理前进/后退导航。
六、控制器层:RPC 封装
每个功能模块都有对应的控制器文件,负责通过 WebSocket 发送 RPC 请求并更新应用状态。控制器本质上是函数集合,接收 `OpenClawApp` 实例的引用来读写状态。
以 Channels 控制器为例,它会调用 `gateway.request(“channels.status”)` 获取通道状态快照,然后设置 `channelsSnapshot` 触发视图更新。Config 控制器处理配置加载、表单编辑和保存(通过 `config.get`、`config.validate`、`config.set` RPC 方法)。Chat 控制器管理消息发送(`chat.send`)、流式接收和消息队列。
`request` 方法实现了 RPC 的请求-响应匹配:
// ui/src/ui/gateway.tsasync request<T>(method: string, params?: unknown): Promise<T> {const id = crypto.randomUUID();const frame = JSON.stringify({ type: "request", id, method, params });this.ws.send(frame);return new Promise((resolve, reject) => {this.pending.set(id, { resolve, reject });});}
每个请求生成一个 UUID 作为 correlation ID,放入 `pending` Map。当 `handleMessage` 收到匹配 ID 的响应时,从 Map 中取出 Promise 并 resolve。如果连接断开,`flushPending` 会 reject 所有待处理的请求。
七、事件流与实时更新
Gateway 主动推送的事件通过 `handleGatewayEvent` 分发:
// ui/src/ui/app-gateway.tsfunction handleGatewayEvent(app: OpenClawApp, event: string, payload: unknown) {if (event === "agent") { /* 更新 Agent 运行状态 */ }if (event === "chat") { /* 更新聊天流 */ }if (event === "presence") { /* 更新在线状态 */ }if (event === "cron") { /* 更新定时任务 */ }if (event === "device.pair.*") { /* 设备配对流程 */ }if (event === "exec.approval.*") { /* 执行审批提示 */ }}
事件帧包含递增的序列号 `seq`。客户端追踪 `lastSeq`,如果检测到序列号间隙(gap),说明有事件丢失(可能因短暂断连),此时触发全量状态重新加载。
连接成功后 Gateway 返回的 `hello` 消息携带初始快照(`applySnapshot`),包含通道状态、Agent 列表、配置等,避免前端逐个请求初始数据。
八、视图层:声明式渲染
视图文件位于 `ui/src/ui/views/`,共约 39 个文件,每个文件导出一个渲染函数,接收应用状态返回 Lit `html` 模板。主渲染函数 `renderApp` 根据当前 tab 选择对应视图:
-
Chat 视图:消息列表、输入框、流式输出、工具卡片、thinking 展示
-
Channels 视图:每个通道(Telegram、Discord、Slack 等)有独立的配置表单
-
Config 视图:支持 form 模式和 raw JSON 模式切换
-
Usage 视图:token 用量统计、成本分析、时间序列图表
-
Sessions 视图:会话列表、过滤、排序
样式系统通过 CSS 模块化组织(`ui/src/styles/`),支持 light/dark/system 三种主题模式,移动端有独立的布局样式。
小结
OpenClaw 的 Web 控制台采用了一种”Gateway 一体化”架构——前端资源作为静态文件嵌入 Gateway 进程,共享同一端口和认证体系。WebSocket 长连接提供实时数据推送,Ed25519 设备身份实现了无需手动输入密码的安全认证。前端用 Lit Web Components 构建,通过控制器模式分离了 RPC 通信和 UI 渲染,事件序列号保证了状态一致性。整套系统无需独立部署前端服务器,一个 Gateway 进程即可提供完整的管理界面。
下面是讲解项目的基本信息:
-
项目地址:https://github.com/openclaw/openclaw
-
使用的项目分支是:main
-
commit版本是:f5160ca6becaeeb6a4dfd892fffd2130a696f766
讲解模块和顺序如下:
1. CLI 框架与进程模型
2. 配置系统
3. Gateway 核心
4. 通道与路由
5. Agent 引擎
6. 自动回复管线
7. 插件系统
8. 记忆系统
9. Web 控制台(今日讲解)
10. 原生客户端
11. 浏览器自动化
12. 运维与测试
夜雨聆风
