Rust WebSocket 代理:Ara 的三层解耦架构
v0.2 是 Ara Desktop 的第一个架构里程碑。在需求明确(通用 Agent 桌面客户端、使用 Tauri v2)之后,第一个要解决的问题不是 UI 布局或功能细节,而是决定"后端通信的线程"。
这篇文章记录从"前端直连 WebSocket"到"Rust IPC 代理"的架构转变,以及在这个过程中发现的 Tauri 工程实践。
初始架构的问题
Ara Desktop 的日常使用场景是:桌面应用(Tauri)→ WebSocket → Python Hermes Agent。v0.1 的原型采用了最直接的路径——前端 React 直接 new WebSocket() 连 Python 后端:
这个模式在技术验证阶段完全合理——最小路径,快速验证通信链路。但如果一直这么用下去,Tauri 就只是一个 WebView 容器,失去了桌面端框架的核心价值。
具体痛点:
- 窗口管理脱节。 前端无法通过 Tauri IPC 获取窗口状态、触发原生菜单、管理系统托盘。这些能力在 Electron 中可直接使用,在 Tauri 中需要 Rust 命令暴露。
- 无法做协议适配。 前端硬编码了 Hermes 的 JSON 消息格式(
stream_start/stream_chunk/stream_end)。未来切换后端(比如用 stdio 启动的另一个 Agent)需要重写前端 hook。 - 消息中转能力缺失。 工具调用、错误通知等需要在 Rust 层做结构化转换的场景,在前端做要么不安全(前端代码用户可修改),要么效率低。
三层解耦架构
v0.2 重构的核心思路:所有后端通信经过 Rust IPC 层。前端只通过 invoke() 收发消息,不感知后端的具体协议。
关键变更:
- 新增
backend.rs— 包含BackendManager结构体和BackendEvent事件枚举。后者通过 Tauri 的AppHandle::emit()发送给前端。 - WebSocket 客户端用 Rust 重写 — 使用
tokio-tungstenitecrate,在 tokio 异步上下文中管理 WS 连接生命周期。 - 前端新 hook
useHermesBackend— 通过@tauri-apps/api/event的listen()监听backend-event,通过invoke()发消息。 - 流式消息完整透传 —
stream_start/stream_chunk/stream_end三种事件类型全部在 Rust 层解析后重新分发,前端拿到的是标准化的事件流。
BackendManager 设计
Rust 层的核心是 BackendManager,它负责:
- 连接管理 —
connect(host, port)新建 WS 连接,断开自动清理 - 消息代理 — 通过 mpsc channel 接收前端消息并转发给 WS,同时接收 WS 消息并 emit 成 Tauri 事件
- 状态管理 — 维护
BackendStatus(Disconnected / Connecting / Connected / Error),通过 IPC 事件推送给前端
前端通过 4 个 Tauri 命令与 BackendManager 交互:
// Rust 侧 (commands.rs)
#[tauri::command]
async fn connect_backend(app, state, host, port) → Result
#[tauri::command]
async fn disconnect_backend(state) → Result
#[tauri::command]
async fn send_message(state, content) → Result
#[tauri::command]
async fn get_backend_status(state) → Result<BackendStatus>
// 前端调用
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
await invoke("connect_backend", { host: "127.0.0.1", port: 9119 });
const unlisten = await listen("backend-event", (event) => {
// event.payload.type ∈ ["status_change","message","stream_start","stream_chunk","stream_end","error"]
});
流式消息透传的实现
流式消息是 Agent 交互的核心体验。在 Rust 层做代理意味着需要把 ws_loop 中的接收循环与 Tauri 的事件系统桥接起来。
关键实现细节:
// ws_loop 的核心循环 (tokio::select! 多路复用)
loop {
tokio::select! {
// 从前端的 mpsc channel 接收消息,转发给 WS
Some(msg) = rx.recv() => {
ws_write.send(Message::Text(msg)).await?
}
// 从 WS 接收消息,解析后 emit 给前端
Some(ws_msg) = ws_read.next() => {
match ws_msg {
Ok(Message::Text(text)) => {
handle_ws_message(&app, &text).await;
}
Ok(Message::Close(_)) => {
// 更新状态 + 通知前端
break;
}
Err(e) => {
// 错误处理
break;
}
_ => {}
}
}
}
}
每条 WS 消息在 handle_ws_message 中解析为 JSON,根据 type 字段分发为不同的 BackendEvent 变体,通过 app.emit() 发送到前端。前端在 React 组件中通过 listen("backend-event", callback) 接收并更新 UI 状态。
协议抽象的前瞻
虽然 v0.2 只实现了 Hermes WebSocket 协议,但架构为后续的协议抽象预留了空间。v0.4 的目标是实现:
// 未来协议抽象 trait
trait AgentBackend {
async fn connect(&self, config: BackendConfig) -> Result<()>;
async fn send_message(&self, content: &str) -> Result<()>;
fn stream_events(&self) -> BoxStream<AgentEvent>;
async fn disconnect(&self) -> Result<()>;
}
届时 v0.2 的 BackendManager 将成为 HermesWSAdapter 的实现,新增后端只需要实现这个 trait。前端无需感知切换。
前端配套改造
架构变更不只影响 Rust 层,前端也做了对应的重构:
- 主题系统 — 建立 CSS 变量体系(
src/styles/themes.css),定义颜色、间距、字体、圆角的全部 token,消除 v0.1 的内联样式债务。使用 CSS 自定义属性而非 CSS-in-JS,零运行时开销。 - ChatView 组件化 — 聊天面板从 App.tsx 中提取为独立组件,接收
messages和onSendprops。 - StatusBar 组件 — 底部状态栏显示连接状态(Disconnected / Connecting / Connected / Error)。
- 使用 Tauri IPC — 新的
useHermesBackendhook 完全走 IPC 通道,useHermesWS被废弃。
构建验证
重构完成后,完整构建通过:
pnpm tauri build --bundles dmg
产物:
Ara Desktop.app → 11 MB
Ara Desktop_0.1.0_aarch64.dmg → 3.6 MB
验证:
pnpm tsc --noEmit → 零错误
cargo check → 零 warnings
vite build → 23 modules, 199KB JS + 1.5KB CSS
Tauri 的 .app 保持了 11MB 的典型大小,新增的 tokio-tungstenite 依赖没有让包体膨胀。
这次重构的核心理念是:在做功能之前先做对架构。三层解耦让 Ara 的每个层次只关注自己的职责——前端只做 UI,Rust 层管通信和后端生命周期,Python 层管 Agent 逻辑。
下一阶段(v0.3)将在这个架构上构建双面板布局和嵌入式终端。