乐于分享
好东西不私藏

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

鼎享会 | 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()

  • 在生命周期里做初始化、连接、同步

  • 把自身状态交给渲染层

重要特点:

  • 它不是只负责“渲染”

  • 它更像“全局应用实例 + 状态容器 + 方法门面”

在这个项目里,很多地方的:

  • this
  • host
  • state

本质上都经常指向这个根组件实例,只是不同文件里的命名不同。

渲染装配层

  • ui/src/ui/app-render.ts

这个文件的职责是把根状态翻译成 UI。它主要做:

    它不是某一个页面,而是整个页面结构的装配中心。

    比如聊天页的onSendonAbortdraftqueuemessages等,都是在这里传给 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.ts
    • ui/src/ui/app-gateway.ts
    • ui/src/ui/app-settings.ts
    • ui/src/ui/app-scroll.ts
    • ui/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 场景的优化思路想要交流,欢迎在评论区留言,我们一起探讨解决方案,助力产品迭代优化与上线。

    精彩回顾

    OMX实践Harness:长任务开发变可执行流程

    OpenClaw在K8s Pod中稳定运行的Docker制作指南