主题
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。
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 |
| Agent | agentsList, agentsSelectedId, agentFilesContent | ~15 |
| 用量统计 | usageResult, usageCostSummary, usageTimeSeries | ~30 |
| 日志 | logsEntries, logsLevelFilters, logsAutoFollow | ~12 |
| 调试 | debugStatus, debugHealth, debugModels | ~8 |
| Skills | skillsReport, skillEdits, skillsBusyKey | ~5 |
为什么不用状态管理库(Redux/MobX/Zustand)?因为 Lit 的 @state() 自带变更检测和按需渲染,对于单组件应用来说已经足够。所有状态集中在根组件,通过方法调用向下传递——这是一种 "提升状态到根" 的朴素模式。
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.ts、app-chat.ts、app-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,无需手动配置。
Web UI 控制台/03-infographic-navigation-system-1775150779310.png)
五、Gateway WebSocket 客户端
gateway.ts 中的 GatewayBrowserClient 是前端与 Gateway 通信的核心。
5.1 协议帧
三种帧类型:
| type | 方向 | 说明 |
|---|---|---|
req | UI → Gateway | 请求帧(method + params) |
res | Gateway → UI | 响应帧(ok/error + payload) |
event | Gateway → 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)。
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),密钥持久化在 localStorage 的 openclaw-device-identity-v1 键下。
关键细节:fingerprintPublicKey() 使用 crypto.subtle.digest("SHA-256", ...) 计算公钥指纹,作为 deviceId——这保证了 deviceId 的唯一性直接来自密钥本身。
6.2 设备令牌 —— device-auth.ts
设备令牌是 Gateway 颁发的短期凭证,存储在 localStorage 的 openclaw.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——这是一个优雅的降级策略。
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,支持多种别名格式(main、agent:<id>:main 等)。
8.2 事件路由
handleGatewayEventUnsafe() 是事件分发的总枢纽:
| 事件 | 处理 |
|---|---|
agent | → handleAgentEvent()(工具调用流) |
chat | → handleChatGatewayEvent()(聊天状态机) |
presence | → 更新 presenceEntries |
cron | → 如果在 cron 页,触发重新加载 |
device.pair.* | → 重新加载设备列表 |
exec.approval.requested | → 加入审批队列 + 定时过期清理 |
exec.approval.resolved | → 从审批队列移除 |
update_available | → 更新可用更新提示 |
事件日志缓冲区最多保留 250 条,仅在 debug Tab 活跃时同步到显示状态——避免无用的渲染开销。
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。
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_LIMIT | 50 | 最多保留 50 个工具调用 |
TOOL_STREAM_THROTTLE_MS | 80ms | 消息同步节流间隔 |
TOOL_OUTPUT_CHAR_LIMIT | 120,000 | 单个工具输出最大字符数 |
当工具调用数超过 50 个时,自动丢弃最早的条目(trimToolStream)。同步到渲染层时使用 80ms 节流——避免高频事件导致的渲染抖动。
10.3 Fallback 与 Compaction 状态
app-tool-stream.ts 还追踪两种特殊状态:
- CompactionStatus:会话上下文压缩状态(active/startedAt/completedAt)
- FallbackStatus:模型降级状态(selected/active/previous + attempts 列表)
这些信息会在 UI 上以状态条的形式提示用户。
Web UI 控制台/08-infographic-tool-stream-1775150783351.png)
十一、后端托管 —— src/gateway/control-ui.ts
Gateway 的 HTTP 服务器负责托管 Control UI 的构建产物。
11.1 请求分类
control-ui-routing.ts 的 classifyControlUiRequest() 返回四种分类:
| kind | 含义 | 行为 |
|---|---|---|
not-control-ui | 非 UI 请求 | 透传给其他处理器 |
not-found | UI 路径但不存在 | 返回 404 |
redirect | basePath 缺少尾斜杠 | 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 图片 |
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.ts13.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}`;
})();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.ts | loadChatHistory, sendChatMessage, handleChatEvent |
| 配置 | config.ts | loadConfig, saveConfig, applyConfig |
| Agent | agents.ts | loadAgents, loadToolsCatalog |
| 渠道 | channels.ts | loadChannels |
| 会话 | sessions.ts | loadSessions, deleteSessionAndRefresh, patchSession |
| 定时任务 | cron.ts | reloadCronJobs, addCronJob, toggleCronJob |
| 日志 | logs.ts | loadLogs |
| 节点 | nodes.ts | loadNodes |
| 技能 | skills.ts | loadSkills, installSkill, updateSkillEnabled |
| 设备 | devices.ts | loadDevices, approveDevicePairing |
| 调试 | debug.ts | loadDebug, callDebugMethod |
| 用量 | usage.ts | 用量统计加载与过滤 |
| 审批 | exec-approval.ts | 工具执行审批请求处理 |
| Bootstrap | control-ui-bootstrap.ts | 加载启动配置 |
| 助手身份 | assistant-identity.ts | 加载助手名称/头像 |
每个控制器都遵循同一模式:
- 接收
state参数(根组件的引用) - 设置 loading 状态
- 调用
client.request(method, params)RPC - 更新 state 中的数据
- 处理错误
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 Hoisting | OpenClawApp | 120+ 状态变量集中管理 |
| Behavior Proxy | 组件方法 → 外部模块 | 解耦状态容器和业务逻辑 |
| SPA Fallback | control-ui.ts | 非静态路径回退到 index.html |
| Challenge-Response | WebSocket connect | nonce + Ed25519 签名防重放 |
| Exponential Backoff | scheduleReconnect() | 自动重连(800ms ~ 15s) |
| Optimistic Update | sendChatMessage() | 用户消息先显示,后发送 |
| Throttle | ToolStreamSync | 80ms 节流避免高频渲染 |
| Defense-in-Depth | NO_REPLY 过滤 | 多层级静默消息过滤 |
| Lazy Loading | i18n 翻译 | 非默认语言按需加载 |
| Boundary File Read | 静态文件服务 | 路径遍历防护 |
Web UI 控制台/12-infographic-design-patterns-1775150786572.png)
二十、推荐阅读顺序
ui/src/ui/navigation.ts— 路由系统(理解 Tab 结构)ui/src/ui/storage.ts— 设置持久化ui/src/ui/theme.ts— 主题系统ui/src/ui/gateway.ts— WebSocket 客户端ui/src/ui/device-identity.ts— Ed25519 设备身份ui/src/ui/device-auth.ts— 设备认证令牌ui/src/ui/app.ts— 根组件(120+ 状态声明)ui/src/ui/app-lifecycle.ts— 生命周期ui/src/ui/app-gateway.ts— 连接管理 + 事件路由ui/src/ui/controllers/chat.ts— 聊天控制器ui/src/ui/app-tool-stream.ts— 工具调用流ui/src/ui/app-render.ts— 主渲染函数ui/src/i18n/lib/translate.ts— 国际化src/gateway/control-ui-routing.ts— 后端请求分类src/gateway/control-ui-csp.ts— CSP 安全策略src/gateway/control-ui.ts— 后端静态资源服务src/infra/control-ui-assets.ts— 资产发现与自动构建
二十一、思考题
为什么选择 Lit 而不是 React/Vue? 考虑 Control UI 的使用场景(Gateway 内嵌、无需 SSR、体积敏感),这个选择的 trade-off 是什么?
120+ 个
@state()变量集中在一个组件中,这是好的设计吗? 如果要拆分,你会怎么设计状态管理架构?Ed25519 设备身份存在 localStorage 中是否安全? 如果用户清除浏览器数据,会发生什么?Gateway 如何处理设备身份丢失的情况?
SPA fallback 策略(非静态路径都返回 index.html)有什么安全风险? 为什么
.html扩展名故意不在静态资源列表中?WebSocket 的 seq 号 gap 检测只是提示用户刷新——有没有更好的恢复策略? 比如自动重新同步缺失的事件。