
这份文档的目标,是把当前 OpenClaw Control UI 的前端结构讲清楚,并结合鼎道智联 DingVerse 服务的实践场景,回答核心问题:
DingOS 部分产品页面已尝试基于该架构思路开发,并应用于 DingVerse 服务中。在此背景下,若为 DingVerse 服务开发自研 UI 页面、后端仍对接 OpenClaw Server,前端应从哪里接入、哪些层可复用、哪些层需适配改造。
这份文档重点解决以下问题:
- 当前 OpenClaw 前端主架构是什么?
- 用户操作如何从页面流转至后端?
- WebSocket 客户端如何与页面状态联动?
- 鼎道智联 DingVerse 服务自研 UI 对接 OpenClaw Server,需完成哪些适配与开发工作?
架构图
当前前端可以先粗略理解成下面这张图:
text
浏览器入口
-> 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
如果想把它看成更清晰的分层关系,可以用下面这张图理解:
ui/src/main.ts
前端入口
ui/src/ui/app.ts
根组件 / 全局状态容器
ui/src/ui/app-render.ts
渲染装配层
ui/src/ui/views/*.ts
页面 / 视图层
ui/src/ui/components/*.ts
可复用组件层
ui/src/ui/app-*.ts
功能逻辑层
ui/src/ui/controllers/*.ts
请求 / 数据动作层
ui/src/ui/gateway.ts
GatewayBrowserClient
OpenClaw Server
再换一种方式,按"谁负责什么"来看:
text
view
负责:展示 UI、绑定点击事件、触发 props 回调
app-render
负责:把根状态装配成视图 props
app-*
负责:功能逻辑、队列、连接、状态编排
controllers
负责:调用后端方法、组织请求参数
gateway client
负责:WebSocket、握手、req/res/event 协议
架构分析
1. 入口层
ui/src/main.ts
这个文件非常薄,只做两件事:
- 引入全局样式
- 引入根组件
openclaw-app
这说明真正的应用不是从 main.ts 开始写逻辑的,而是从根组件开始。
在 DingVerse 服务中的部分页面和交互已沿用该轻量入口设计,降低初始包体积,适配产品迭代期的快速调整需求。
2. 根组件层
ui/src/ui/app.ts
这是整个前端最核心的对象。
它的职责是:
- 持有大量
@state()状态 - 暴露应用级方法,比如
handleSendChat()、connect()、handleAbortChat() - 在生命周期里做初始化、连接、同步
- 把自身状态交给渲染层
重要特点:
- 它不是只负责"渲染"
- 它更像"全局应用实例 + 状态容器 + 方法门面"
在这个项目里,很多地方的:
thishoststate
本质上都经常指向这个根组件实例,只是不同文件里的命名不同。
3. 渲染装配层
ui/src/ui/app-render.ts
这个文件的职责是把根状态翻译成 UI。
它主要做:
- 判断当前 tab 渲染什么页面
- 给各个页面组装 props
- 把 app.ts 暴露的方法作为回调传给 view
- 做页面级懒加载,减少初始包体积
它不是某一个页面,而是整个页面结构的装配中心。
比如聊天页的 onSend、onAbort、draft、queue、messages 等,都是在这里传给 chat view 的。
4. 视图层
ui/src/ui/views/chat.tsui/src/ui/views/overview.tsui/src/ui/views/config.tsui/src/ui/views/agents.ts- 以及
ui/src/ui/views/下的其他文件
这些文件主要负责:
- 视图结构
- 页面布局
- 按钮、输入框、列表等 UI 细节
- 调用 props 回调
它们通常不直接承载复杂业务逻辑。
例如聊天发送按钮只会调用 props.onSend(),它不会自己判断:
- 当前是否 busy
- 该直接发送还是入队
- 是否是 slash command
- 发送失败后是否恢复草稿
这些都不是 view 层该做的。
5. 功能逻辑层
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 commandapp-gateway.ts负责连接 Gateway、处理 hello / event / close 回调,并把事件同步回 app 状态app-settings.ts负责 settings、tab 切换和相关加载
所以 app-* 不是"页面本身",而是"页面的功能逻辑"。
6. Controller 层
ui/src/ui/controllers/chat.tsui/src/ui/controllers/config.tsui/src/ui/controllers/agents.tsui/src/ui/controllers/channels.ts- 等等
这层主要负责:
- 和后端通信
- 组织请求参数
- 调用
client.request(...) - 将返回结果映射到前端状态变化
举例:
- 聊天真正发给后端是在
controllers/chat.ts - 配置的读取和保存是在
controllers/config.ts
所以它更接近"数据动作层"。
7. WS 封装层
ui/src/ui/gateway.ts
这个文件里有当前前端最重要的 WebSocket 客户端封装:
GatewayBrowserClient
它主要负责:
- 建立 WebSocket 连接
- 处理
open/message/close/error - connect 握手
req/res/event帧收发- pending 请求 promise 管理
- 重连和回退
这是当前前端和 Gateway 协议绑定最深的地方之一,同时 WS Client 可参考该封装逻辑,预留协议扩展空间,适配产品后续优化需求。
用 Chat 发送流程举例
聊天发送是理解整个架构最好的例子。
先看一张完整时序图:
OpenClaw Server GatewayBrowserClient controllers/chat.ts app-chat.ts app.ts app-render.ts views/chat.ts 用户 OpenClaw Server GatewayBrowserClient controllers/chat.ts app-chat.ts app.ts app-render.ts views/chat.ts 用户 alt [当前 busy] [直接发送] 点击发送按钮 调用 props.onSend() state.handleSendChat() handleSendChatInternal(...) 判断 busy / slash / stop / queue enqueueChatMessage() sendChatMessage() client.request("chat.send", ...) 发送 WS req 帧 返回 res / event 终态事件最终触发 flushChatQueue
第一步:根组件开始渲染
在 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 中,会把队列里的下一条拿出来继续发送。
这样队列机制才能闭环。
这一段也可以单独看成"回流图":
OpenClaw Server
event / res
gateway.ts
handleMessage()
this.opts.onEvent?.(...)
app-gateway.ts
事件协调层
更新根状态
chatRunId / messages / queue / errors
app-render.ts
views/chat.ts
界面重新渲染
host、state、this 到底是什么
这是这个前端特别容易绕的一个点。
在很多地方:
this指向OpenClawApp根组件实例host是把这个根组件实例作为参数传给app-*模块时的命名state是渲染层或 controller 层里对这个状态对象的命名
所以从工程上讲,它们常常是"同一个根对象"的不同叫法。
这意味着当前前端没有单独再做一个 Redux 风格的 store。
它的"共享状态容器"就是根组件实例本身。
WebSocket 客户端在哪里封装
WebSocket 客户端封装在:
ui/src/ui/gateway.ts
核心类是:
GatewayBrowserClient
它内部维护:
wspending请求映射- connect 握手状态
- seq 状态
- 重连状态
它对外最关键的方法是:
start()stop()request<T>(method, params)
其中:
request(...)会发送type: "req"帧handleMessage(...)会解析event和res帧
handleMessage() 怎么和 app-gateway.ts 关联
这不是直接 import 关联,而是通过"构造时注入回调"关联的。
流程是:
app-gateway.ts创建客户端:new GatewayBrowserClient({ onHello, onEvent, onClose, onGap, ... })
gateway.ts构造函数把这些回调保存到this.optshandleMessage()收到消息后,根据帧类型执行: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在合适的时机调用这些回调
这是一种典型的"底层传输层回调上浮到应用层"的设计。
如果只看这部分关系,可以把它简化成:
app-gateway.ts
new GatewayBrowserClient({ onEvent, onHello, onClose, onGap })
gateway.ts
constructor(private opts)
handleMessage()
this.opts.onEvent?.(evt)
app-gateway.ts
处理 event -> 改状态
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 状态对象上了。
为什么要这样分层
从工程角度,这样拆有几个好处。
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 正常通信"。
换句话说,现有前端代码在这里更多是参考实现,而不是直接复用对象。
如果把"我们自己的实现"画成结构图,大概应该长这样:
我们自己的页面 / 组件
我们自己的状态层
我们自己的事件协调层
我们自己的 OpenClaw WS Client
OpenClaw Server
我们自己的动作封装层
sendMessage / abort / loadSessions / loadConfig
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 链路打通后,再扩展别的功能会稳很多
用路线图表示会更直观:
- 读懂协议
- 写最小 WS client
- 写最小状态层
- 打通 chat.send / chat.abort / chat event
- 扩展 sessions / config / agents
- 完整 UI 和交互优化
当前架构的强耦合点
如果要接自己的后端,这几个地方耦合最深,要重点注意。
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.sendchat.abortsessions.reset
如果你的后端方法名和参数结构不同,controller 必须改。
总结
当前 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.tsui/src/ui/app-render.tsui/src/ui/app-chat.tsui/src/ui/app-gateway.tsui/src/ui/controllers/chat.tsui/src/ui/gateway.ts
后面无论是:
- 复用当前 UI 改成自己的 WS
- 还是自己重做一个页面参考这套结构
这几个文件都是最值得先读懂的部分。
综上,OpenClaw 前端架构的核心价值在于分层解耦的设计思路,这也是鼎道智联 DingVerse 服务自研 UI 对接 OpenClaw Server 的关键参考。针对仍在优化、暂未上线的 DingVerse 产品,自研 UI 无需拘泥于复用现有代码,优先打通 WS 协议与 chat 链路的最小闭环,再逐步扩展功能更高效。
若你在对接过程中遇到协议理解、client 开发、状态层设计等问题,或是有适配 DingVerse 场景的优化思路想要交流,欢迎在评论区留言,我们一起探讨解决方案,助力产品迭代优化与上线。