鼎享会 | OpenClaw Control UI 前端架构全解析:自研 UI 对接 Server 实操指南

这份文档的目标,是把当前 OpenClaw Control UI 的前端结构讲清楚,并结合鼎道智联 DingVerse 服务的实践场景,回答核心问题:
DingOS 部分产品页面已尝试基于该架构 思路开发,并应用于 DingVerse 服务中。在此背景下,若为 DingVerse 服务开发自研 UI 页面、后端仍对接 OpenClaw Server,前端应从哪里接入、哪些层可复用、哪些层需适配改造。
这份文档重点解决以下问题:
-
当前 OpenClaw 前端主架构是什么?
-
用户操作如何从页面流转至后端?
-
WebSocket 客户端如何与页面状态联动?
-
鼎道智联 DingVerse 服务自研 UI 对接 OpenClaw Server,需完成哪些适配与开发工作?
01
架构图
当前前端可以先粗略理解成下面这张图:
浏览器入口-> ui/src/main.ts-> ui/src/ui/app.ts (根组件 / 全局状态容器)-> ui/src/ui/app-render.ts (把状态装配成各个 view 的 props)-> ui/src/ui/views/*.ts (页面/视图)-> 调用 props 回调-> ui/src/ui/app-*.ts (功能逻辑层)-> 编排状态、队列、连接、生命周期行为-> ui/src/ui/controllers/*.ts (请求/响应动作层)-> 调用 client.request(...)-> ui/src/ui/gateway.ts (GatewayBrowserClient)-> WebSocket-> OpenClaw Server
如果想把它看成更清晰的分层关系,可以用下面这张图理解:

再换一种方式,按“谁负责什么”来看:
view负责:展示 UI、绑定点击事件、触发 props 回调app-render负责:把根状态装配成视图 propsapp-*负责:功能逻辑、队列、连接、状态编排controllers负责:调用后端方法、组织请求参数gateway client负责:WebSocket、握手、req/res/event 协议
02
架构分析
入口层
-
ui/src/main.ts
这个文件非常薄,只做两件事:
-
引入全局样式
-
引入根组件 openclaw-app
这说明真正的应用不是从main.ts开始写逻辑的,而是从根组件开始。在 DingVerse 服务中的部分页面和交互已沿用该轻量入口设计,降低初始包体积,适配产品迭代期的快速调整需求。
根组件层
-
ui/src/ui/app.ts
这是整个前端最核心的对象。它的职责是:
-
持有大量
@state()状态 -
暴露应用级方法,比如
handleSendChat()、connect()、handleAbortChat() -
在生命周期里做初始化、连接、同步
-
把自身状态交给渲染层
重要特点:
-
它不是只负责“渲染”
-
它更像“全局应用实例 + 状态容器 + 方法门面”
在这个项目里,很多地方的:
thishoststate
本质上都经常指向这个根组件实例,只是不同文件里的命名不同。
渲染装配层
ui/src/ui/app-render.ts
这个文件的职责是把根状态翻译成 UI。它主要做:
它不是某一个页面,而是整个页面结构的装配中心。
比如聊天页的onSend、onAbort、draft、queue、messages等,都是在这里传给 chat view 的。
视图层
ui/src/ui/views/chat.ts
ui/src/ui/views/overview.ts
ui/src/ui/views/config.ts
ui/src/ui/views/agents.ts
-
以及 ui/src/ui/views/下的其他文件
这些文件主要负责:
-
视图结构 -
页面布局 -
按钮、输入框、列表等 UI 细节 -
调用 props 回调
它们通常不直接承载复杂业务逻辑。
例如聊天发送按钮只会调用props.onSend(),它不会自己判断:
-
当前是否 busy
-
该直接发送还是入队
-
是否是 slash command
-
发送失败后是否恢复草稿
这些都不是 view 层该做的。
功能逻辑层
ui/src/ui/app-chat.tsui/src/ui/app-gateway.tsui/src/ui/app-settings.tsui/src/ui/app-scroll.tsui/src/ui/app-tool-stream.ts
这层是当前 UI 很关键的一层。可以把它理解成:
-
某个功能域的应用逻辑
-
对根状态对象的读写编排
-
视图层和 controller 层之间的“中间大脑”
举例:
app-chat.ts负责聊天发送、排队、出队、abort、slash command-
app-gateway.ts负责连接 Gateway、处理 hello / event / close 回调,并把事件同步回 app 状态 - app-settings.ts 负责 settings、tab 切换和相关加载
所以 app-* 不是“页面本身”,而是“页面的功能逻辑”。
Controller层
-
ui/src/ui/controllers/chat.ts
-
ui/src/ui/controllers/config.ts
-
ui/src/ui/controllers/agents.ts
-
ui/src/ui/controllers/channels.ts
-
等等
这层主要负责:
-
和后端通信
-
组织请求参数
-
调用 client.request(…)
-
将返回结果映射到前端状态变化
举例:
-
聊天真正发给后端是在 controllers/chat.ts
-
配置的读取和保存是在 controllers/config.ts
所以它更接近“数据动作层”。
WS封装层
-
ui/src/ui/gateway.ts
这个文件里有当前前端最重要的 WebSocket 客户端封装:
-
GatewayBrowserClient
它主要负责:
-
建立 WebSocket 连接
-
处理 open/message/close/error
-
connect 握手
-
req/res/event 帧收发
-
pending 请求 promise 管理
-
重连和回退
这是当前前端和 Gateway 协议绑定最深的地方之一,同时 WS Client 可参考该封装逻辑,预留协议扩展空间,适配产品后续优化需求。
03
用 Chat 发送流程举例
聊天发送是理解整个架构最好的例子。先看一张完整时序图:

第一步:根组件开始渲染
在 ui/src/ui/app.ts 里:
-
render() 调用 renderApp(this as AppViewState)
这里把根组件当前状态交给渲染层。
第二步:渲染层把回调传给聊天视图
在 ui/src/ui/app-render.ts 里,渲染 chat view 时会传:
-
onSend: () => state.handleSendChat()
这里的 state 实际就是 OpenClawApp 实例。所以聊天页拿到的 props.onSend,本质上就是根组件方法。
第三步:聊天视图点击按钮
在 ui/src/ui/views/chat.ts 里,发送按钮点击时会调用:
-
props.onSend()
这一步只是“触发动作”,不是“处理发送逻辑”。也就是说:
-
点击按钮时,view 调的是 prop
-
但这个 prop 是上层传下来的
第四步:进入根组件方法
在 ui/src/ui/app.ts 里:
-
handleSendChat(…)
它内部再调用:
handleSendChatInternal(…)
这个 handleSendChatInternal 实际来自 ui/src/ui/app-chat.ts。
所以 app.ts 在这里扮演的是方法门面角色。
第五步:进入 app-chat.ts
ui/src/ui/app-chat.ts 是聊天功能真正的应用逻辑层。这里会决定:
-
是否已连接
-
是否为空消息
-
是否是 stop
-
是否是 slash command
-
当前是否 busy
-
是直接发送还是排队
-
发送完成后是否继续 flush 队列
因此更准确地说:
-
view 负责“点发送”
-
app-chat.ts 负责“怎么发送”
第六步:如果 busy,则先入队
app-chat.ts 里有:
-
isChatBusy(host)
-
enqueueChatMessage(…)
如果当前有正在执行的 run,发送逻辑不会直接继续发,而是把消息写进:
-
host.chatQueue
这个队列是前端内存状态,不是本地持久化存储。
第七步:如果不 busy,则立即发送
app-chat.ts 会走:
-
sendChatMessageNow(…)
这个函数会先处理一些发送前动作,比如:
-
reset tool stream
-
reset scroll
-
清理/恢复草稿与附件的状态逻辑
然后调用:
-
sendChatMessage(…)
第八步:controller 真正发请求
在 ui/src/ui/controllers/chat.ts 里,真正会调用:
-
state.client.request(“chat.send”, …)
这说明:
-
app-chat.ts 负责发送决策和流程编排
-
controllers/chat.ts 负责真正发给后端
同时 controller 会更新前端状态,例如:
-
立即插入一条 user message
-
设定 chatSending = true
-
生成并写入 chatRunId
-
初始化 chatStream
所以用户在 UI 上会立刻看到“我发出去了”,即使后端结果还没回来。
第九步:后端事件通过 WS 回流
后端返回的运行中事件、完成事件、错误事件,不是通过按钮链路回来的,而是通过 WebSocket 回流到前端。
这些事件会先经过:
-
GatewayBrowserClient.handleMessage(...)
再通过回调交给:
-
app-gateway.ts
第十步:终态事件触发队列继续发送
当一个 run 到达终态后,app-gateway.ts 会:
-
清理该 run 的 pending queue items
-
调用 flushChatQueue(…)
这个函数在 app-chat.ts 中,会把队列里的下一条拿出来继续发送。这样队列机制才能闭环。
这一段也可以单独看成“回流图”:

04
host、state、this 到底是什么
这是这个前端特别容易绕的一个点。在很多地方:
-
this 指向 OpenClawApp 根组件实例
-
host 是把这个根组件实例作为参数传给 app-* 模块时的命名
-
state 是渲染层或 controller 层里对这个状态对象的命名
所以从工程上讲,它们常常是“同一个根对象”的不同叫法。这意味着当前前端没有单独再做一个 Redux 风格的 store。它的“共享状态容器”就是根组件实例本身。
05
WebSocket 客户端在哪里封装
WebSocket 客户端封装在:
-
ui/src/ui/gateway.ts
核心类是:
-
GatewayBrowserClient
它内部维护:
-
ws
-
pending 请求映射
-
connect 握手状态
-
seq 状态
-
重连状态
它对外最关键的方法是:
-
start()
-
stop()
-
request<T>(method, params)
其中:
-
request(…) 会发送 type: “req” 帧
-
handleMessage(…) 会解析 event 和 res 帧
06
handleMessage() 怎么和 app-gateway.ts 关联
这不是直接 import 关联,而是通过“构造时注入回调”关联的。流程是:
-
app-gateway.ts 创建客户端:
new GatewayBrowserClient({ onHello, onEvent, onClose, onGap, … })
-
gateway.ts 构造函数把这些回调保存到 this.opts
-
handleMessage() 收到消息后,根据帧类型执行:
-
this.opts.onEvent?.(evt)
-
this.opts.onHello?.(hello)
-
this.opts.onClose?.(…)
-
this.opts.onGap?.(…)
所以关系不是:
-
gateway.ts 知道 app-gateway.ts
而是:
-
app-gateway.ts 在创建 client 时把回调注入进去
-
gateway.ts 在合适的时机调用这些回调
这是一种典型的“底层传输层回调上浮到应用层”的设计。如果只看这部分关系,可以把它简化成:

07
client 是怎么注入到全局状态里的
这里的“全局状态”并不是单独的 store,而是根组件实例。在 app.ts 里,this.connect() 会调用:
-
connectGatewayInternal(this)
也就是说,整个 OpenClawApp 实例被作为 host 传给了 app-gateway.ts。然后 app-gateway.ts 里会:
-
new GatewayBrowserClient(…)
-
把它挂回 host.client
-
用这个实例继续维护连接和事件
所以 controller 后面才能直接使用:
-
state.client.request(…)
因为这个 client 已经被绑定到根 app 状态对象上了。
08
为什么要这样分层
从工程角度,这样拆有几个好处。
1. view 层变轻
如果 view 直接做聊天发送逻辑,那 views/chat.ts 就必须自己处理:
-
busy 判断
-
stop 逻辑
-
slash command
-
queue
-
草稿恢复
-
网络失败恢复
这样页面会非常重,也很难维护。
2. 功能逻辑集中
把聊天行为放在 app-chat.ts,就能让“聊天这个功能”的逻辑集中在一个地方。这比散落在下面这些更容易维护:
-
view
-
app.ts
-
controller
3. 传输层可替换
把 WS 封装在 gateway.ts,可以让应用层不直接依赖原生 WebSocket。虽然当前实现仍然与 OpenClaw Gateway 协议强耦合,但至少耦合点集中在少数文件中。
4. 根对象作为共享状态容器,开发速度快
虽然这种方式没有独立 store 那么严格,但对当前这类工具型控制台前端来说,开发和改动会更直接。
如果我们做自己的 UI,但仍然接 OpenClaw Server,需要做什么
这里的前提要说清楚:
-
页面是我们自己的
-
后端仍然是 OpenClaw Server
-
前端不复用 OpenClaw 现有 UI 代码
-
我们会自己写 client
在这个前提下,这一节的重点就不是“复用哪些前端文件”,而是“我们自己需要补齐哪些能力,才能和 OpenClaw Server 正常通信”。
换句话说,现有前端代码在这里更多是参考实现,而不是直接复用对象。如果把“我们自己的实现”画成结构图,大概应该长这样:

1. 首先要明确:我们要对接的是 OpenClaw Gateway 协议
既然后端还是 OpenClaw Server,那么我们自己的前端无论页面长什么样,本质上还是要接它当前暴露出来的通信协议。这意味着我们自己必须实现或理解这些能力:
-
WebSocket 建连
-
connect 握手
-
req / res / event 帧模型
-
认证信息的组织方式
-
请求与响应的对应关系
-
服务端事件的分发
-
断线重连和异常处理
也就是说,虽然我们“不复用代码”,但不能“不理解协议”。
2. 我们自己的前端,至少要自己实现哪些模块
如果不复用 OpenClaw 现有前端代码,建议把自己的前端拆成下面几块。
2.1 自己的 WS client
这是最核心的一层。当前 OpenClaw 前端的 GatewayBrowserClient 给了一个很清楚的参考:一个浏览器端的 WS client 至少要支持:
-
start()
-
stop()
-
request(method, params)
-
收到服务端 event 后触发回调
-
收到 response 后 resolve/reject 对应 promise
-
close 后做错误处理和重连
即使我们完全自己写,也建议保留类似抽象。自己写这个 client 时,至少要实现:
-
连接状态管理
-
请求 ID 生成
-
pending request 映射表
-
req 帧发送
-
res 帧解析
-
event 帧解析
-
connect 握手
-
close / error / reconnect 策略
2.2 自己的应用状态层
即使页面是我们自己写,也仍然需要一个“状态中枢”。不一定要像 OpenClaw 这样用一个根组件实例承载所有状态,但你至少要有一层统一状态来管理:
-
当前是否已连接
-
当前 sessionKey
-
当前消息列表
-
当前 stream 内容
-
当前 runId
-
当前错误信息
-
当前等待中的请求状态
如果没有这一层,view 很快就会和 WS 处理逻辑缠在一起。
2.3 自己的事件协调层
当前 OpenClaw 的 app-gateway.ts 本质上是一个“连接和事件协调层”。我们自己写 UI 时,也最好单独做这一层,而不要让 view 直接吃 WS event。
这一层建议负责:
-
初始化 client
-
注册 onHello / onEvent / onClose / onGap 之类的回调
-
把 event 转换成页面状态变化
-
做 run 生命周期处理
-
做重连恢复
简单说:
-
client 负责“收到消息”
-
事件协调层负责“这条消息对页面意味着什么”
2.4 自己的 action / controller 层
如果你的页面上有“发送消息”“停止生成”“加载配置”“加载 agent 列表”这些动作,建议仍然保留一个类似 controller 的层。原因是:
-
view 不应该知道具体方法名
-
view 不应该自己拼请求参数
-
view 不应该直接知道 WS 帧结构
这一层建议做的事:
-
对 OpenClaw Server 方法做封装
-
例如封装 chat.send、chat.abort、sessions.reset
-
把业务参数转成 OpenClaw 要的协议参数
-
处理错误映射
3. 如果只是做一个最小可用页面,需要先打通哪些能力
如果目标是“先做一个最小可用 UI”,建议不要一开始就做完整控制台,而是先打通一个最小闭环。
这个最小闭环通常是聊天链路:
-
WS 建连成功
-
完成 OpenClaw 的 connect 握手
-
可以发 chat.send
-
可以接收聊天事件
-
可以在终态时收尾
-
可以发 chat.abort
因为一旦这条链路通了,说明下面几层都基本通了:
-
连接
-
协议
-
请求
-
响应
-
事件
-
页面状态回流
4. 我们自己写 client 时,需要重点参考现有前端的哪些设计
虽然不复用代码,但下面这些设计思想非常值得保留。
4.1 请求-响应映射
当前前端通过请求 ID 和 pending map 来把 res 帧回填到对应 promise。自己写 client 时,这个机制几乎一定要有。否则你无法优雅地写:
-
await request(“chat.send”, …)
4.2 event 和 response 分流
当前协议是明显双轨的:
-
res 负责某个 request 的结果
-
event 负责服务端主动推送
自己写的时候,不要把这两类消息混在一起处理。
4.3 运行状态与页面状态解耦
当前实现里,真正决定“队列是否继续”“run 是否结束”的,不是 view,而是 app 层和事件协调层。这个思路建议保留。否则页面会很快出现:
-
一个按钮管太多事情
-
一个组件维护太多隐式状态
4.4 transport 和 UI 分离
即使你是自己写页面,也不要让 UI 组件直接 new WebSocket。更好的做法仍然是:
-
transport 层单独封装
-
状态层单独封装
-
页面只通过回调和状态工作
5. 针对 OpenClaw Server,自研前端需要完成的实际工作清单
下面这份清单更接近真正要做的事。
5.1 协议理解
需要先确认并验证:
-
建连 URL
-
握手流程
-
connect challenge 是否存在
-
请求帧格式
-
响应帧格式
-
事件帧格式
-
认证参数需要哪些字段
5.2 client 实现
需要自己完成:
-
WS 封装
-
请求 ID 管理
-
pending promise 管理
-
消息解析
-
重连策略
-
错误处理策略
5.3 页面状态模型设计
需要定义自己的状态结构,例如:
-
connectionState
-
currentSessionKey
-
messages
-
streamText
-
activeRunId
-
lastError
-
queuedMessages
5.4 事件到状态的映射
需要自己规定:
-
收到 hello 后怎么更新状态
-
收到 chat 相关 event 后怎么更新消息列表
-
收到 final/error/aborted 后怎么清理 run 状态
-
断线时页面显示什么
-
重连后是否恢复某些状态
5.5 动作封装
至少要实现你页面需要的动作封装,例如:
-
sendMessage()
-
abortMessage()
-
loadSessions()
-
resetSession()
这些方法内部再去调 OpenClaw Server 对应的方法。
5.6 页面实现
最后才是页面本身:
-
聊天输入框
-
消息列表
-
发送按钮
-
停止按钮
-
连接状态 UI
-
错误提示 UI
也就是说,UI 最后做,但不是最先做。
6. 推荐实施顺序
在“自己写 client、自己写 UI、后端仍然是 OpenClaw Server”的前提下,建议按这个顺序推进:
-
先读懂并验证 OpenClaw Server 的 WS 协议
-
先写最小 client,能连、能发 request、能收 response/event
-
再写最小状态层
-
再打通 chat.send / chat.abort / chat event 的闭环
-
再补会话、配置、agents 等功能
-
最后再做完整页面整理和 UI 体验优化
原因很简单:
-
协议没打通,页面做得再好也只是壳
-
状态没设计好,事件一多 UI 就会乱
-
chat 链路打通后,再扩展别的功能会稳很多
用路线图表示会更直观:

09
当前架构的强耦合点
如果要接自己的后端,这几个地方耦合最深,要重点注意。
1. Gateway 协议帧格式
GatewayBrowserClient 假定了固定的协议结构:
-
type: “req”
-
type: “res”
-
type: “event”
如果你的服务不是这个格式,gateway.ts 一定要改。
2. connect 握手逻辑
当前客户端不是普通一连就发消息,它有 challenge、device identity、token、role、scopes 等流程。这部分如果你的后端没有,就需要简化。
3. 聊天 run 生命周期
聊天队列依赖:
-
chatRunId
-
run 终态事件
-
最终事件后 flush 队列
如果你的后端没有这一套,不能直接照搬当前 chat queue 逻辑。
4. controller 的方法名约定
目前 controller 强依赖方法名,例如:
-
chat.send
-
chat.abort
-
sessions.reset
如果你的后端方法名和参数结构不同,controller 必须改。
10
总结
当前 OpenClaw 前端的核心流转逻辑为:
-
view 负责交互触发
-
app.ts 暴露方法
-
app-* 负责功能逻辑
-
controllers/* 负责后端调用
-
gateway.ts 负责 WS 封装
-
app-gateway.ts 负责把 WS 事件回流到 UI 状态
在DingOS的自研产品体系中,如果我们的目标是“自己做 UI,但接 OpenClaw Server,而且前端代码自己写”,最重要的结论是:
-
现有前端代码最重要的价值是参考架构和协议实现思路
-
我们真正要自己补的是 client、状态层、事件协调层、动作封装层
-
不应该从 view 开始做,而应该先从协议和 client 开始打通
-
chat 是最适合做第一阶段验证的最小闭环
从实施角度看,最小可行路径通常是:
-
先写自己的 WS client
-
先接通 OpenClaw Server 协议
-
再写自己的状态层和事件协调层
-
再做聊天最小页面
-
最后再扩展更多管理能力
如果你已经理解下面这些文件,基本就理解了这个前端的主干:
-
ui/src/ui/app.ts
-
ui/src/ui/app-render.ts
-
ui/src/ui/app-chat.ts
-
ui/src/ui/app-gateway.ts
-
ui/src/ui/controllers/chat.ts
-
ui/src/ui/gateway.ts
后面无论是:
-
复用当前 UI 改成自己的 WS
-
还是自己重做一个页面参考这套结构
这几个文件都是最值得先读懂的部分。
综上,OpenClaw 前端架构的核心价值在于分层解耦的设计思路,这也是鼎道智联 DingVerse 服务自研 UI 对接 OpenClaw Server 的关键参考。针对仍在优化、暂未上线的 DingVerse 产品,自研 UI 无需拘泥于复用现有代码,优先打通 WS 协议与 chat 链路的最小闭环,再逐步扩展功能更高效。
若你在对接过程中遇到协议理解、client 开发、状态层设计等问题,或是有适配 DingVerse 场景的优化思路想要交流,欢迎在评论区留言,我们一起探讨解决方案,助力产品迭代优化与上线。
精彩回顾
夜雨聆风