Skip to content

OpenClaw 源码解读(十五)Web UI 控制台

一、导读

OpenClaw Control UI 是一个运行在浏览器中的 单页应用(SPA),让用户通过可视化界面管理 Gateway 的方方面面——聊天、配置、渠道、定时任务、日志……一切尽在掌控。

它的技术栈非常精炼:Lit Web Components + Vite,没有 React/Vue/Svelte,没有任何重量级框架。整个前端就是一棵自定义元素树,通过 WebSocket 直连 Gateway。

本文沿着"从外到内"的主线来拆解这套系统:

浏览器
 ├── <openclaw-app>  (Lit Web Component, SPA 根节点)
 │    ├── Navigation     (12 个 Tab 页)
 │    ├── Gateway Client (WebSocket JSON-RPC)
 │    ├── Device Auth    (Ed25519 密钥对)
 │    └── i18n           (5 种语言)

Gateway HTTP Server
 ├── control-ui.ts       (静态资源服务)
 ├── control-ui-routing.ts (请求分类)
 ├── control-ui-csp.ts   (安全头)
 └── control-ui-contract.ts (Bootstrap 配置)

前端代码位于 ui/(独立 Vite 项目),后端托管代码位于 src/gateway/control-ui*.ts

![Control UI 系统架构总览](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十五)Web UI 控制台/01-infographic-system-architecture-1775150777504.png)


二、项目结构总览

ui/
├── index.html              ← HTML 入口
├── package.json            ← openclaw-control-ui,Lit + Vite
├── vite.config.ts          ← 构建配置
├── src/
│   ├── main.ts             ← JS 入口
│   ├── styles/             ← CSS 文件(base, chat, layout, config...)
│   ├── i18n/               ← 国际化(en, zh-CN, zh-TW, de, pt-BR)
│   └── ui/
│       ├── app.ts          ← <openclaw-app> 根组件(~620 行)
│       ├── app-gateway.ts  ← Gateway WebSocket 连接
│       ├── app-lifecycle.ts ← 组件生命周期
│       ├── app-render.ts   ← 主渲染函数
│       ├── app-chat.ts     ← 聊天业务逻辑
│       ├── app-scroll.ts   ← 滚动控制
│       ├── app-settings.ts ← 设置管理
│       ├── app-tool-stream.ts ← 工具调用流
│       ├── navigation.ts   ← 路由系统
│       ├── gateway.ts      ← WebSocket 客户端
│       ├── device-auth.ts  ← 设备认证令牌
│       ├── device-identity.ts ← Ed25519 设备身份
│       ├── theme.ts        ← 深色/浅色主题
│       ├── storage.ts      ← localStorage 持久化
│       ├── controllers/    ← 业务控制器(15+ 个)
│       └── views/          ← 页面视图(12 个 Tab 的渲染)

三、根组件 —— <openclaw-app>

app.ts 中的 OpenClawApp 是整个 UI 的根 Web Component,继承自 LitElement

3.1 状态管理

这个组件用 Lit 的 @state() 装饰器声明了 超过 120 个响应式状态变量,覆盖了 UI 的所有功能面板:

状态类别典型变量数量
连接状态connected, hello, lastError~5
聊天chatMessages, chatStream, chatRunId, chatAttachments~18
配置configRaw, configForm, configSnapshot, configSchema~15
渠道channelsSnapshot, whatsappLoginQrDataUrl~10
定时任务cronJobs, cronForm, cronRuns~25
会话sessionsResult, sessionsFilterActive~8
AgentagentsList, agentsSelectedId, agentFilesContent~15
用量统计usageResult, usageCostSummary, usageTimeSeries~30
日志logsEntries, logsLevelFilters, logsAutoFollow~12
调试debugStatus, debugHealth, debugModels~8
SkillsskillsReport, skillEdits, skillsBusyKey~5

为什么不用状态管理库(Redux/MobX/Zustand)?因为 Lit 的 @state() 自带变更检测和按需渲染,对于单组件应用来说已经足够。所有状态集中在根组件,通过方法调用向下传递——这是一种 "提升状态到根" 的朴素模式。

![根组件内部结构](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十五)Web UI 控制台/02-infographic-root-component-1775150778492.png)

3.2 行为代理模式

OpenClawApp 大量使用了"方法代理"模式——组件自身的方法只是对外部模块函数的薄包装:

typescript
connect() {
  connectGatewayInternal(this as unknown as Parameters<typeof connectGatewayInternal>[0]);
}

async handleSendChat(messageOverride?, opts?) {
  await handleSendChatInternal(this as unknown as ..., messageOverride, opts);
}

setTab(next: Tab) {
  setTabInternal(this as unknown as ..., next);
}

这种设计的意图是 解耦状态容器和业务逻辑app.ts 只负责声明状态和代理调用,具体逻辑分散到 app-gateway.tsapp-chat.tsapp-settings.ts 等模块中。每个模块通过 host 参数读写根组件的状态——本质上是一种手动的"依赖注入"。

3.3 渲染分离

渲染逻辑完全独立于组件定义:

typescript
render() {
  return renderApp(this as unknown as AppViewState);
}

renderApp()app-render.ts 中实现,返回 Lit 的 html 模板标签字面量。它将根组件的状态映射为 AppViewState 接口,然后按 Tab 分发到不同的 renderXxx() 视图函数。

3.4 无 Shadow DOM

typescript
createRenderRoot() {
  return this;
}

这行看似平凡的代码实际上很关键——它跳过了 Lit 默认的 Shadow DOM,让组件直接渲染到 Light DOM 中。这样做的好处是全局 CSS 可以直接生效,不需要处理 Shadow DOM 的样式隔离问题。


四、导航系统 —— 12 个 Tab

navigation.ts 定义了 Control UI 的完整路由体系:

4.1 Tab 分组

typescript
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"] },
];

4 个分组,12 个 Tab,覆盖了 Gateway 管理的所有维度。

4.2 路由映射

路由采用简单的双向映射:

typescript
const TAB_PATHS: Record<Tab, string> = {
  chat: "/chat",
  overview: "/overview",
  channels: "/channels",
  // ... 每个 Tab 对应一个路径
};

const PATH_TO_TAB = new Map(
  Object.entries(TAB_PATHS).map(([tab, path]) => [path, tab as Tab])
);

tabFromPath() 支持 basePath 前缀剥离——当 Gateway 不在根路径时(比如 /my-gateway/chat),能正确解析 Tab。

4.3 BasePath 推断

inferBasePathFromPathname() 是一段精巧的自适应逻辑:

typescript
// 对 pathname 按 "/" 分段
// 从后向前扫描,找到第一个已知 Tab 路径
// Tab 之前的部分就是 basePath

这意味着部署在任意路径下的 Control UI 都能自动推断出正确的 basePath,无需手动配置。

![导航系统与路由体系](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十五)Web UI 控制台/03-infographic-navigation-system-1775150779310.png)


五、Gateway WebSocket 客户端

gateway.ts 中的 GatewayBrowserClient 是前端与 Gateway 通信的核心。

5.1 协议帧

三种帧类型:

type方向说明
reqUI → Gateway请求帧(method + params)
resGateway → UI响应帧(ok/error + payload)
eventGateway → UI事件帧(event + payload + seq)

5.2 连接流程

1. new WebSocket(url)
2. ws.open → queueConnect()(延迟 750ms)
3. Gateway 发送 connect.challenge 事件(含 nonce)
4. sendConnect():构造 connect 请求
   - 设备身份签名(Ed25519)
   - 认证信息(token/password)
   - 客户端元数据(platform, mode, version)
5. 等待 hello-ok 响应
   - 存储 deviceToken(如果返回了)
   - 应用 snapshot(presence, health, sessionDefaults)
   - 触发初始数据加载

Challenge-Response 机制值得注意——Gateway 先发一个 nonce,客户端用设备私钥签名后回传,这是为了防止重放攻击。

5.3 自动重连

typescript
private scheduleReconnect() {
  if (this.closed) return;
  const delay = this.backoffMs;
  this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);
  window.setTimeout(() => this.connect(), delay);
}

指数退避重连:800ms → 1360ms → 2312ms → ... → 最大 15s。每次连接成功后重置为 800ms。

5.4 序列号 Gap 检测

typescript
if (seq !== null) {
  if (this.lastSeq !== null && seq > this.lastSeq + 1) {
    this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq });
  }
  this.lastSeq = seq;
}

每个事件帧带递增的 seq 号,如果检测到不连续就触发 onGap 回调,提示用户刷新。

5.5 请求-响应匹配

使用 UUID 作为请求 ID,通过 Map 追踪 pending 请求:

typescript
request<T>(method: string, params?: unknown): Promise<T> {
  const id = generateUUID();
  const frame = { type: "req", id, method, params };
  const p = new Promise<T>((resolve, reject) => {
    this.pending.set(id, { resolve, reject });
  });
  this.ws.send(JSON.stringify(frame));
  return p;
}

断连时自动拒绝所有 pending 请求(flushPending)。

![WebSocket 客户端协议与连接](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十五)Web UI 控制台/04-infographic-websocket-client-1775150780177.png)


六、设备认证系统

6.1 设备身份 —— Ed25519 密钥对

device-identity.ts 为每个浏览器实例生成唯一的 Ed25519 密钥对:

typescript
async function generateIdentity() {
  const privateKey = utils.randomSecretKey();         // 32 字节随机数
  const publicKey = await getPublicKeyAsync(privateKey); // 计算公钥
  const deviceId = await fingerprintPublicKey(publicKey);  // SHA-256 哈希作为 ID
  return { deviceId, publicKey: base64UrlEncode(publicKey), privateKey: base64UrlEncode(privateKey) };
}

使用 @noble/ed25519 库(纯 JavaScript 实现,不依赖 Web Crypto API 的 ECDSA),密钥持久化在 localStorageopenclaw-device-identity-v1 键下。

关键细节:fingerprintPublicKey() 使用 crypto.subtle.digest("SHA-256", ...) 计算公钥指纹,作为 deviceId——这保证了 deviceId 的唯一性直接来自密钥本身。

6.2 设备令牌 —— device-auth.ts

设备令牌是 Gateway 颁发的短期凭证,存储在 localStorageopenclaw.device.auth.v1 键下:

typescript
type DeviceAuthStore = {
  version: 1;
  deviceId: string;
  tokens: Record<string, DeviceAuthEntry>;
};

连接 Gateway 时的认证策略:

1. 有 Secure Context(HTTPS/localhost)?
   → 加载或创建设备身份
   → 优先使用已存储的 deviceToken
   → fallback 到用户手动配置的 token
2. 无 Secure Context(纯 HTTP)?
   → 跳过设备身份,仅使用 token/password

当使用存储的 deviceToken 连接失败时,会自动清除它并回退到共享 token——这是一个优雅的降级策略。

![设备认证系统](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十五)Web UI 控制台/05-infographic-device-auth-1775150780885.png)


七、生命周期管理

app-lifecycle.ts 管理了 Web Component 的完整生命周期:

7.1 connectedCallback — 挂载

1. inferBasePath()              ← 推断 basePath
2. loadControlUiBootstrapConfig() ← 加载启动配置(助手名/头像)
3. applySettingsFromUrl()       ← URL 参数覆盖设置
4. syncTabWithLocation()        ← 同步 Tab 到当前 URL
5. syncThemeWithSettings()      ← 应用主题
6. attachThemeListener()        ← 监听系统深浅模式变化
7. addEventListener("popstate") ← 监听浏览器前进/后退
8. connectGateway()             ← 建立 WebSocket 连接
9. startNodesPolling()          ← 启动节点轮询
10. startLogsPolling()          ← 如果在日志页,启动日志轮询

7.2 handleUpdated — 属性变更

如果当前 Tab 是 chat:
  chatMessages/chatStream/chatLoading 变更 → scheduleChatScroll()
如果当前 Tab 是 logs:
  logsEntries 变更 + autoFollow 开启 → scheduleLogsScroll()

这是一种响应式的"滚动到底部"策略——只有内容真正变化时才触发滚动。

7.3 disconnectedCallback — 卸载

1. 移除 popstate 监听
2. 停止所有轮询(nodes, logs, debug)
3. 关闭 WebSocket 客户端
4. 卸载主题监听
5. 断开 ResizeObserver

八、Gateway 连接管理 —— app-gateway.ts

connectGateway() 是连接建立的入口函数。

8.1 Hello 快照应用

连接成功后,applySnapshot()hello-ok 消息中提取初始数据:

typescript
function applySnapshot(host, hello) {
  const snapshot = hello.snapshot;
  if (snapshot?.presence) host.presenceEntries = snapshot.presence;
  if (snapshot?.health) host.debugHealth = snapshot.health;
  if (snapshot?.sessionDefaults) applySessionDefaults(host, snapshot.sessionDefaults);
  host.updateAvailable = snapshot?.updateAvailable ?? null;
}

sessionDefaults 的处理尤其精巧——它把"main"这样的别名解析为实际的 mainSessionKey,支持多种别名格式(mainagent:<id>:main 等)。

8.2 事件路由

handleGatewayEventUnsafe() 是事件分发的总枢纽:

事件处理
agenthandleAgentEvent()(工具调用流)
chathandleChatGatewayEvent()(聊天状态机)
presence→ 更新 presenceEntries
cron→ 如果在 cron 页,触发重新加载
device.pair.*→ 重新加载设备列表
exec.approval.requested→ 加入审批队列 + 定时过期清理
exec.approval.resolved→ 从审批队列移除
update_available→ 更新可用更新提示

事件日志缓冲区最多保留 250 条,仅在 debug Tab 活跃时同步到显示状态——避免无用的渲染开销。

![生命周期与事件路由](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十五)Web UI 控制台/06-infographic-lifecycle-events-1775150781693.png)


九、聊天系统 —— Controller + 状态机

9.1 发送消息

controllers/chat.ts 中的 sendChatMessage() 流程:

1. 构造 user 消息(文本 + 图片附件)
2. 乐观更新 chatMessages(立即显示用户消息)
3. 设置 chatSending = true
4. 生成 runId(UUID)
5. 调用 client.request("chat.send", {
     sessionKey, message, deliver: false,
     idempotencyKey: runId, attachments
   })
6. 成功 → 返回 runId
7. 失败 → 回滚(添加错误消息)

deliver: false 表示只在当前 WebSocket 连接上接收流式事件,不通过其他渠道(比如 Telegram)推送。

9.2 聊天事件状态机

handleChatEvent() 处理 4 种状态:

delta   → 更新 chatStream(累积文本)
final   → 消息加入 chatMessages,清除 chatStream/chatRunId
aborted → 保存已有流式文本(如果有内容),清除状态
error   → 清除状态 + 设置 lastError

有一个巧妙的边界处理——当收到来自其他 runId 的 final 事件(比如 sub-agent 的完成通知),会直接追加到消息列表而不影响当前 run 的状态。

9.3 NO_REPLY 过滤

typescript
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;

Agent 可以通过返回 NO_REPLY 来表示"不需要回复"。UI 在多个环节过滤这种静默消息——加载历史时、处理 delta 时、处理 final 时。这是一种客户端的 defense-in-depth(纵深防御),即使 Gateway 侧的过滤遗漏了,UI 也不会显示 NO_REPLY。

![聊天系统状态机](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十五)Web UI 控制台/07-infographic-chat-system-1775150782552.png)


十、工具调用流 —— app-tool-stream.ts

10.1 实时工具调用追踪

当 Agent 执行工具调用时,Gateway 通过 agent 事件流推送工具调用的开始和结果。app-tool-stream.ts 管理这些实时数据:

typescript
type ToolStreamEntry = {
  toolCallId: string;
  runId: string;
  name: string;          // 工具名(如 web_search)
  args?: unknown;        // 调用参数
  output?: string;       // 输出结果(可能很长)
  startedAt: number;
  updatedAt: number;
  message: Record<string, unknown>;  // 构造的消息对象
};

10.2 性能控制

三个关键限制常量:

常量作用
TOOL_STREAM_LIMIT50最多保留 50 个工具调用
TOOL_STREAM_THROTTLE_MS80ms消息同步节流间隔
TOOL_OUTPUT_CHAR_LIMIT120,000单个工具输出最大字符数

当工具调用数超过 50 个时,自动丢弃最早的条目(trimToolStream)。同步到渲染层时使用 80ms 节流——避免高频事件导致的渲染抖动。

10.3 Fallback 与 Compaction 状态

app-tool-stream.ts 还追踪两种特殊状态:

  • CompactionStatus:会话上下文压缩状态(active/startedAt/completedAt)
  • FallbackStatus:模型降级状态(selected/active/previous + attempts 列表)

这些信息会在 UI 上以状态条的形式提示用户。

![工具调用流与性能控制](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十五)Web UI 控制台/08-infographic-tool-stream-1775150783351.png)


十一、后端托管 —— src/gateway/control-ui.ts

Gateway 的 HTTP 服务器负责托管 Control UI 的构建产物。

11.1 请求分类

control-ui-routing.tsclassifyControlUiRequest() 返回四种分类:

kind含义行为
not-control-ui非 UI 请求透传给其他处理器
not-foundUI 路径但不存在返回 404
redirectbasePath 缺少尾斜杠302 重定向
serve正常 UI 请求提供静态文件

特别注意的排除规则:

typescript
// 插件路由不属于 UI
if (pathname === "/plugins" || pathname.startsWith("/plugins/")) return "not-control-ui";
// API 路由不属于 UI
if (pathname === "/api" || pathname.startsWith("/api/")) return "not-control-ui";
// 非读方法不属于 UI
if (!isReadHttpMethod(method)) return "not-control-ui";

11.2 静态资源服务

handleControlUiHttpRequest() 的完整流程:

1. URL 解析 + basePath 剥离
2. 安全头注入(CSP + X-Frame-Options + X-Content-Type-Options + Referrer-Policy)
3. Bootstrap 配置端点? → 返回 JSON(助手名/头像/agentId)
4. 资产目录不存在? → 503(提示 `pnpm ui:build`)
5. 路径安全检查(isSafeRelativePath + isWithinDir)
6. 文件存在? → 直接提供
7. 静态资源扩展名(.js/.css/.json...)? → 404
8. 其他路径 → SPA fallback(返回 index.html)

SPA fallback 是关键——所有非静态资源路径都回退到 index.html,让客户端路由器处理。但 .html 扩展名故意不在静态资源列表中——因为客户端路由器可能使用 .html 后缀的路由。

11.3 安全文件读取

typescript
function resolveSafeControlUiFile(rootReal, filePath) {
  const opened = openBoundaryFileSync({
    absolutePath: filePath,
    rootPath: rootReal,
    rootRealPath: rootReal,
    boundaryLabel: "control ui root",
    skipLexicalRootCheck: true,
  });
  // ...
}

使用 openBoundaryFileSync() 确保文件读取不会越过 UI 根目录的边界——即使请求路径包含 .. 或符号链接也无法逃逸。

11.4 Bootstrap 配置

typescript
const CONTROL_UI_BOOTSTRAP_CONFIG_PATH = "/__openclaw/control-ui-config.json";

type ControlUiBootstrapConfig = {
  basePath: string;
  assistantName: string;
  assistantAvatar: string;
  assistantAgentId: string;
};

UI 启动时通过 HTTP 请求加载这个配置,获取助手的名称和头像——这样在 WebSocket 连接建立前就能显示正确的品牌信息。


十二、CSP 安全策略

typescript
function buildControlUiCspHeader(): string {
  return [
    "default-src 'self'",
    "base-uri 'none'",
    "object-src 'none'",
    "frame-ancestors 'none'",       // 禁止被 iframe 嵌入
    "script-src 'self'",            // 只允许同源脚本
    "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
    "img-src 'self' data: https:",  // 允许 data URI 图片
    "font-src 'self' https://fonts.gstatic.com",
    "connect-src 'self' ws: wss:",  // 允许 WebSocket
  ].join("; ");
}

7 条 CSP 规则的安全含义:

规则防御目标
frame-ancestors 'none'点击劫持(替代 X-Frame-Options)
script-src 'self'XSS(禁止内联脚本和外部脚本)
base-uri 'none'<base> 标签注入
object-src 'none'Flash/插件类攻击
connect-src 'self' ws: wss:允许 WebSocket(ws/wss)
style-src 'unsafe-inline'Lit 模板大量使用内联样式属性
img-src data: https:允许 data URI(头像)和 HTTPS 图片

![后端托管与 CSP 安全](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十五)Web UI 控制台/09-infographic-backend-security-1775150784113.png)


十三、国际化(i18n)

13.1 架构

ui/src/i18n/
├── lib/
│   ├── translate.ts     ← I18nManager 单例
│   ├── registry.ts      ← 语言注册表
│   ├── lit-controller.ts ← Lit 响应式控制器
│   └── types.ts         ← 类型定义
├── locales/
│   ├── en.ts            ← 英语(默认)
│   ├── zh-CN.ts         ← 简体中文
│   ├── zh-TW.ts         ← 繁体中文
│   ├── de.ts            ← 德语
│   └── pt-BR.ts         ← 巴西葡萄牙语
└── index.ts

13.2 翻译加载策略

typescript
class I18nManager {
  private translations: Partial<Record<Locale, TranslationMap>> = { [DEFAULT_LOCALE]: en };

  async setLocale(locale: Locale) {
    if (needsTranslationLoad) {
      const translation = await loadLazyLocaleTranslation(locale);
      this.translations[locale] = translation;
    }
    this.locale = locale;
    this.notify();
  }
}

英语翻译直接内联(零延迟),其他语言按需懒加载。

13.3 翻译查找

typescript
t(key: string, params?: Record<string, string>): string {
  // 1. 在当前语言中查找
  // 2. 找不到 → fallback 到英语
  // 3. 都找不到 → 返回 key 本身
  // 4. 支持 {param} 模板替换
}

三级 fallback 确保永远不会显示空白。

13.4 Lit 集成

I18nController 实现了 Lit 的 ReactiveController 接口——当语言切换时自动触发组件重渲染。


十四、持久化存储

storage.ts 管理 UI 设置的 localStorage 持久化:

typescript
type UiSettings = {
  gatewayUrl: string;         // WebSocket URL
  token: string;              // 认证令牌
  sessionKey: string;         // 当前会话 Key
  lastActiveSessionKey: string; // 上次活跃会话
  theme: ThemeMode;           // 深色/浅色/系统
  chatFocusMode: boolean;     // 聚焦模式
  chatShowThinking: boolean;  // 显示思考过程
  splitRatio: number;         // 侧边栏分割比例(0.4~0.7)
  navCollapsed: boolean;      // 导航栏折叠
  navGroupsCollapsed: Record<string, boolean>;
  locale?: string;
};

loadSettings() 的防御性读取值得学习——每个字段都有独立的类型检查和默认值回退,确保 localStorage 被篡改或格式不兼容时不会崩溃。

Gateway URL 的默认值是自动推断的:

typescript
const defaultUrl = (() => {
  const proto = location.protocol === "https:" ? "wss" : "ws";
  const basePath = inferBasePathFromPathname(location.pathname);
  return `${proto}://${location.host}${basePath}`;
})();

![国际化与持久化存储](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十五)Web UI 控制台/10-infographic-i18n-storage-1775150784973.png)


十五、资产构建与发现

src/infra/control-ui-assets.ts 负责在各种部署形态下找到 UI 构建产物。

15.1 多路径探测

typescript
function resolveControlUiRootSync(opts) {
  const candidates = new Set<string>();
  // 1. 可执行文件旁边的 control-ui/
  addCandidate(candidates, path.join(execDir, "control-ui"));
  // 2. dist/<bundle>.js → dist/control-ui/
  addCandidate(candidates, path.join(moduleDir, "control-ui"));
  // 3. dist/gateway/control-ui.js → dist/control-ui/
  addCandidate(candidates, path.join(moduleDir, "../control-ui"));
  // 4. src/gateway/control-ui.ts → dist/control-ui/
  addCandidate(candidates, path.join(moduleDir, "../../dist/control-ui"));
  // 5. package root → dist/control-ui/
  addCandidate(candidates, path.join(packageRoot, "dist", "control-ui"));
  // 6. cwd → dist/control-ui/
  addCandidate(candidates, path.join(cwd, "dist", "control-ui"));

  for (const dir of candidates) {
    if (fs.existsSync(path.join(dir, "index.html"))) return dir;
  }
  return null;
}

6 种路径候选,覆盖开发、npm 全局安装、打包应用等所有部署形态。

15.2 自动构建

typescript
async function ensureControlUiAssetsBuilt() {
  // 1. 检查构建产物是否存在
  // 2. 不存在 → 找到 repo root
  // 3. 执行 scripts/ui.js build
  // 4. 超时 10 分钟
}

开发环境下,Gateway 启动时如果发现 UI 资产缺失,会自动触发构建。


十六、控制器层

controllers/ 目录包含 15+ 个控制器,每个对应一个功能模块:

控制器文件核心方法
聊天chat.tsloadChatHistory, sendChatMessage, handleChatEvent
配置config.tsloadConfig, saveConfig, applyConfig
Agentagents.tsloadAgents, loadToolsCatalog
渠道channels.tsloadChannels
会话sessions.tsloadSessions, deleteSessionAndRefresh, patchSession
定时任务cron.tsreloadCronJobs, addCronJob, toggleCronJob
日志logs.tsloadLogs
节点nodes.tsloadNodes
技能skills.tsloadSkills, installSkill, updateSkillEnabled
设备devices.tsloadDevices, approveDevicePairing
调试debug.tsloadDebug, callDebugMethod
用量usage.ts用量统计加载与过滤
审批exec-approval.ts工具执行审批请求处理
Bootstrapcontrol-ui-bootstrap.ts加载启动配置
助手身份assistant-identity.ts加载助手名称/头像

每个控制器都遵循同一模式:

  1. 接收 state 参数(根组件的引用)
  2. 设置 loading 状态
  3. 调用 client.request(method, params) RPC
  4. 更新 state 中的数据
  5. 处理错误

![资产发现与控制器层](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十五)Web UI 控制台/11-infographic-assets-controllers-1775150785700.png)


十七、头像系统

17.1 后端头像解析

handleControlUiAvatarRequest() 处理 /__openclaw/avatar/{agentId} 路径:

1. agentId 格式验证(/^[a-z0-9][a-z0-9_-]{0,63}$/i)
2. ?meta=1 → 返回 JSON { avatarUrl }
3. 否则 → 返回图片文件
4. 支持 local/remote/data 三种头像源

17.2 前端头像解析

前端 resolveAssistantAvatarUrl() 按优先级查找:

1. 当前 session 的 agentId → 在 agentsList 中查找
2. 默认 agentId → 在 agentsList 中查找
3. 没找到 → 使用 bootstrap 配置的头像

支持 data URI(base64 内联)、HTTP URL、本地路径三种格式。


十八、主题系统

typescript
type ThemeMode = "system" | "light" | "dark";
type ResolvedTheme = "light" | "dark";

function resolveTheme(mode: ThemeMode): ResolvedTheme {
  if (mode === "system") return getSystemTheme();
  return mode;
}

function getSystemTheme(): ResolvedTheme {
  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}

三态输入(system/light/dark),二态输出(light/dark)。通过 matchMedia 监听系统深浅模式变化,实时切换。


十九、设计模式总结

模式应用位置效果
State HoistingOpenClawApp120+ 状态变量集中管理
Behavior Proxy组件方法 → 外部模块解耦状态容器和业务逻辑
SPA Fallbackcontrol-ui.ts非静态路径回退到 index.html
Challenge-ResponseWebSocket connectnonce + Ed25519 签名防重放
Exponential BackoffscheduleReconnect()自动重连(800ms ~ 15s)
Optimistic UpdatesendChatMessage()用户消息先显示,后发送
ThrottleToolStreamSync80ms 节流避免高频渲染
Defense-in-DepthNO_REPLY 过滤多层级静默消息过滤
Lazy Loadingi18n 翻译非默认语言按需加载
Boundary File Read静态文件服务路径遍历防护

![设计模式总览](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十五)Web UI 控制台/12-infographic-design-patterns-1775150786572.png)


二十、推荐阅读顺序

  1. ui/src/ui/navigation.ts — 路由系统(理解 Tab 结构)
  2. ui/src/ui/storage.ts — 设置持久化
  3. ui/src/ui/theme.ts — 主题系统
  4. ui/src/ui/gateway.ts — WebSocket 客户端
  5. ui/src/ui/device-identity.ts — Ed25519 设备身份
  6. ui/src/ui/device-auth.ts — 设备认证令牌
  7. ui/src/ui/app.ts — 根组件(120+ 状态声明)
  8. ui/src/ui/app-lifecycle.ts — 生命周期
  9. ui/src/ui/app-gateway.ts — 连接管理 + 事件路由
  10. ui/src/ui/controllers/chat.ts — 聊天控制器
  11. ui/src/ui/app-tool-stream.ts — 工具调用流
  12. ui/src/ui/app-render.ts — 主渲染函数
  13. ui/src/i18n/lib/translate.ts — 国际化
  14. src/gateway/control-ui-routing.ts — 后端请求分类
  15. src/gateway/control-ui-csp.ts — CSP 安全策略
  16. src/gateway/control-ui.ts — 后端静态资源服务
  17. src/infra/control-ui-assets.ts — 资产发现与自动构建

二十一、思考题

  1. 为什么选择 Lit 而不是 React/Vue? 考虑 Control UI 的使用场景(Gateway 内嵌、无需 SSR、体积敏感),这个选择的 trade-off 是什么?

  2. 120+ 个 @state() 变量集中在一个组件中,这是好的设计吗? 如果要拆分,你会怎么设计状态管理架构?

  3. Ed25519 设备身份存在 localStorage 中是否安全? 如果用户清除浏览器数据,会发生什么?Gateway 如何处理设备身份丢失的情况?

  4. SPA fallback 策略(非静态路径都返回 index.html)有什么安全风险? 为什么 .html 扩展名故意不在静态资源列表中?

  5. WebSocket 的 seq 号 gap 检测只是提示用户刷新——有没有更好的恢复策略? 比如自动重新同步缺失的事件。

读文档、看源码、写代码,理解 AI Agent 本质 🤖