新增: 跨端WS隧道脚手架(df-tunnel客户端+df-relay云中继)

df-tunnel:桌面端出站WS客户端(TunnelClient trait+骨架,穿NAT连云后端)
df-relay:axum WS中继服务(转发小程序↔桌面端,广播骨架)
双crate独立消息骨架,不依赖src-tauri/df-types避跨crate强耦合
workspace crates/* glob自动注册,Phase2填充WS握手/鉴权/重连/路由
This commit is contained in:
2026-06-22 01:24:01 +08:00
parent 566bbcb394
commit 5d1749a58f
11 changed files with 818 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
[package]
name = "df-relay"
version = "0.1.0"
edition = "2021"
description = "跨端 AI Chat 云中继服务(axum WS Server,转发小程序 ↔ 桌面端)"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
# HTTP + WS 服务端
axum = { version = "0.7", features = ["ws"] }
tokio-tungstenite = { version = "0.23", features = ["native-tls"] }
futures-util = "0.3"

View File

@@ -0,0 +1,85 @@
//! df-relay 广播消息骨架
//!
//! 设计依据:设计文档「Layer2」—— 云后端纯转发,无业务逻辑。
//! 中继消息骨架定义「谁发给谁」的路由语义:
//! - `DeviceEvent`:桌面端事件 → 广播给该 device 绑定的小程序
//! - `MiniappCommand`:小程序指令 → 转发给该 device 绑定的桌面端
//! - `Control`:控制面消息(配对/心跳/在线状态)
//!
//! 与 df-tunnel::TunnelMessage 区分:
//! - TunnelMessage 是隧道两端(桌面↔云)的传输单元
//! - BroadcastMessage 是云后端内部的路由单元(标识来源/去向/广播范围)
//!
//! 脚手架阶段仅定义 enum,Phase2 填路由表 + 广播实现。
use serde::{Deserialize, Serialize};
/// 客户端类型(用于路由判断)
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum ClientKind {
/// 桌面端(出站长连接,事件真相源)
Device,
/// 小程序(远程操作发起方)
Miniapp,
}
/// 中继路由消息骨架
///
/// 每条消息携带 `device_id`(配对绑定键),云后端按 device_id
/// 找到绑定的对端连接进行转发/广播。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BroadcastMessage {
/// 配对绑定的设备 ID(桌面端首次配置时生成)
pub device_id: String,
/// 消息来源(用于鉴权校验:device 不能发 MiniappCommand)
pub from: ClientKind,
/// 消息载荷(原始 JSON,转发时不解析业务字段,保持轻量)
pub payload: serde_json::Value,
/// 时间戳(毫秒,用于冲突处理 —— 桌面端为真相源,但保留时间戳辅助判断)
pub ts: i64,
}
impl BroadcastMessage {
/// 便捷构造:device 发出的事件
pub fn from_device(device_id: impl Into<String>, payload: serde_json::Value, ts: i64) -> Self {
Self {
device_id: device_id.into(),
from: ClientKind::Device,
payload,
ts,
}
}
/// 便捷构造:小程序发出的指令
pub fn from_miniapp(device_id: impl Into<String>, payload: serde_json::Value, ts: i64) -> Self {
Self {
device_id: device_id.into(),
from: ClientKind::Miniapp,
payload,
ts,
}
}
}
/// 客户端在线状态(用于离线降级提示)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum PresenceState {
/// 在线
Online,
/// 离线(对端可缓存指令待重连)
Offline,
}
/// 控制面消息(配对绑定 / 心跳 / 在线状态广播)
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ControlMessage {
/// 配对绑定请求(小程序持 token 申请绑定 device_id)
Pair { device_id: String, token: String },
/// 心跳(保活 + 在线确认)
Heartbeat { device_id: String },
/// 在线状态变更通知
Presence { device_id: String, state: PresenceState },
}

View File

@@ -0,0 +1,37 @@
//! df-relay 错误类型
//!
//! 设计:单一 RelayError 覆盖服务启动/客户端管理/广播/WS 协议四类失败,
//! thiserror 派生保留错误链。脚手架阶段仅定义类型。
use thiserror::Error;
/// 中继服务运行期错误
#[derive(Debug, Error)]
pub enum RelayError {
/// 服务启动失败(端口占用 / 绑定失败)
#[error("中继服务启动失败: {0}")]
Start(String),
/// 客户端管理错误(配对失败 / 重复连接 / 鉴权拒绝)
#[error("客户端管理错误: {0}")]
Client(String),
/// 广播失败(无可用连接 / 发送失败)
#[error("广播失败: {0}")]
Broadcast(String),
/// WS 协议层错误(底层 tungstenite)
#[error("WS 协议错误: {0}")]
WebSocket(String),
/// 序列化/反序列化失败
#[error("消息序列化失败: {0}")]
Serde(#[from] serde_json::Error),
/// 其他未归类错误(降级通道)
#[error("中继内部错误: {0}")]
Other(#[from] anyhow::Error),
}
/// 模块级 Result 别名
pub type Result<T> = std::result::Result<T, RelayError>;

View File

@@ -0,0 +1,26 @@
//! # df-relay
//!
//! 跨端 AI Chat 云中继服务(设计文档 Layer2)。
//!
//! ## 职责
//! - axum WS Server 接受两类连接:小程序(device_id 鉴权)+ 桌面端(token 配对)
//! - 广播中继:小程序操作 → 桌面端;桌面端事件 → 小程序
//! - 纯转发,无业务逻辑(保持轻量,设计文档明确)
//!
//! ## 边界
//! - 不依赖 df-tunnel / df-types / src-tauri(独立消息骨架,避免跨 crate 强耦合)
//! - 不含业务逻辑(Phase2 填连接生命周期 / 注册表 / 广播分发)
//!
//! ## 模块
//! - [`broadcast`]:广播消息骨架(BroadcastMessage / ClientKind / ControlMessage)
//! - [`relay`]:RelayServer trait + DefaultRelayServer 骨架 + axum WS 路由
//! - [`error`]:RelayError 错误类型
pub mod broadcast;
pub mod error;
pub mod relay;
// 顶层再导出常用项
pub use broadcast::{BroadcastMessage, ClientKind, ControlMessage, PresenceState};
pub use error::{RelayError, Result};
pub use relay::{build_router, DefaultRelayServer, RelayServer, RelayState};

View File

@@ -0,0 +1,158 @@
//! df-relay 中继服务骨架
//!
//! 设计依据:设计文档「Layer2」—— axum WS Server + 广播中继。
//! 接受两类连接:小程序(device_id 鉴权)+ 桌面端(token 配对)。
//! 纯转发,无业务逻辑。
//!
//! 脚手架范围:RelayServer trait + axum WS 路由骨架(占位 handler),
//! Phase2 填:
//! - 客户端注册表(HashMap<device_id, 连接集>)
//! - 广播分发(按 device_id 路由)
//! - 鉴权中间件
use async_trait::async_trait;
use axum::{
extract::{
ws::{WebSocket, WebSocketUpgrade},
State,
},
response::IntoResponse,
routing::get,
Router,
};
use std::sync::Arc;
use crate::broadcast::BroadcastMessage;
use crate::error::Result;
/// 中继服务抽象
///
/// 设计为 trait 便于测试 mock + 未来替换实现(如换 tonic gRPC 网关)。
#[async_trait]
pub trait RelayServer: Send + Sync {
/// 启动 HTTP/WS 服务监听指定地址
async fn start(&self, addr: &str) -> Result<()>;
/// 广播消息给指定 device_id 绑定的对端
async fn broadcast(&self, msg: BroadcastMessage) -> Result<()>;
/// 注册新连接(连接建立后入表)
async fn register_client(&self, device_id: String) -> Result<()>;
/// 注销连接(断开后出表)
async fn unregister_client(&self, device_id: String) -> Result<()>;
/// 查询 device 是否有在线连接(离线降级判断用)
fn is_device_online(&self, device_id: &str) -> bool;
}
/// 共享状态(axum State 传递)
///
/// 脚手架占位:Phase2 填 tokio::sync::RwLock<HashMap<device_id, ConnectionSet>>。
/// 当前用 unit 占位以满足 Router state 约束。
#[derive(Clone, Default)]
pub struct RelayState {
/// Phase2:客户端注册表
/// 例:`clients: Arc<RwLock<HashMap<String, ConnectionSet>>>`
#[allow(dead_code)]
_reserved: (),
}
/// axum WS 路由构造
///
/// 暴露 `/ws/device`(桌面端连入)与 `/ws/miniapp`(小程序连入)两个端点,
/// 共享 RelayState。Phase2 在 handler 内填鉴权 + 连接生命周期管理。
pub fn build_router(state: Arc<RelayState>) -> Router {
Router::new()
.route("/ws/device", get(device_ws_handler))
.route("/ws/miniapp", get(miniapp_ws_handler))
.with_state(state)
}
/// 桌面端 WS 连接 handler(脚手架占位)
///
/// 当前仅接受升级并立即返回(不消费流),Phase2 填:
/// - 握手鉴权(token)
/// - register_client
/// - 循环 recv 转发 BroadcastMessage
/// - 断开 unregister_client
async fn device_ws_handler(
ws: WebSocketUpgrade,
State(_state): State<Arc<RelayState>>,
) -> impl IntoResponse {
tracing::info!("桌面端 WS 连接接入(脚手架占位)");
ws.on_upgrade(handle_connection)
}
/// 小程序 WS 连接 handler(脚手架占位)
async fn miniapp_ws_handler(
ws: WebSocketUpgrade,
State(_state): State<Arc<RelayState>>,
) -> impl IntoResponse {
tracing::info!("小程序 WS 连接接入(脚手架占位)");
ws.on_upgrade(handle_connection)
}
/// 通用 WS 连接处理(脚手架:接收一次即关闭)
///
/// Phase2 替换为完整读写循环 + 广播分发。
async fn handle_connection(mut socket: WebSocket) {
// 接收首条消息(占位),Phase2 改为持续循环
if let Some(Ok(msg)) = futures_util::StreamExt::next(&mut socket).await {
tracing::debug!(?msg, "收到首条消息(脚手架占位,Phase2 填路由)");
}
// 脚手架:静默关闭,不报错(连接生命周期管理留 Phase2)
}
/// 默认中继服务骨架
///
/// 脚手架阶段:trait 方法返回未实现错误,不静默假成功。
/// Phase2 替换为完整实现(启动 axum::serve + 注册表 + 广播)。
pub struct DefaultRelayServer {
#[allow(dead_code)]
bind_addr: Option<String>,
}
impl DefaultRelayServer {
pub fn new() -> Self {
Self { bind_addr: None }
}
}
impl Default for DefaultRelayServer {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl RelayServer for DefaultRelayServer {
async fn start(&self, addr: &str) -> Result<()> {
tracing::warn!(addr = %addr, "DefaultRelayServer.start 尚未实现(Phase2 填充)");
Err(crate::error::RelayError::Other(anyhow::anyhow!(
"DefaultRelayServer.start 尚未实现(Phase2)"
)))
}
async fn broadcast(&self, _msg: BroadcastMessage) -> Result<()> {
Err(crate::error::RelayError::Other(anyhow::anyhow!(
"DefaultRelayServer.broadcast 尚未实现(Phase2)"
)))
}
async fn register_client(&self, _device_id: String) -> Result<()> {
Err(crate::error::RelayError::Other(anyhow::anyhow!(
"DefaultRelayServer.register_client 尚未实现(Phase2)"
)))
}
async fn unregister_client(&self, _device_id: String) -> Result<()> {
Err(crate::error::RelayError::Other(anyhow::anyhow!(
"DefaultRelayServer.unregister_client 尚未实现(Phase2)"
)))
}
fn is_device_online(&self, _device_id: &str) -> bool {
false
}
}

View File

@@ -0,0 +1,18 @@
[package]
name = "df-tunnel"
version = "0.1.0"
edition = "2021"
description = "本地 DevFlow 桌面端出站 WS 隧道客户端(穿 NAT 连云后端 df-relay)"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
# WS 客户端(tungstenite 底层 + tokio 异步封装)
tokio-tungstenite = { version = "0.23", features = ["native-tls"] }
futures-util = "0.3"

View File

@@ -0,0 +1,43 @@
//! df-tunnel 错误类型
//!
//! 设计:单一 TunnelError 覆盖 WS 连接/收发/序列化/鉴权四类失败,
//! thiserror 派生保留错误链(`#[from]` 自动 From)。
//! 脚手架阶段仅定义类型,不涉及具体错误码细分(Phase2 填充)。
use thiserror::Error;
/// 隧道客户端运行期错误
///
/// 覆盖范围:
/// - 连接建立失败(网络不可达 / TLS 握手 / 鉴权拒绝)
/// - 收发失败(底层 WS 帧 / 序列化 / 反序列化)
/// - 协议层错误(未预期的消息类型 / 配对绑定失败)
#[derive(Debug, Error)]
pub enum TunnelError {
/// WS 连接建立或断线重连失败
#[error("隧道连接失败: {0}")]
Connect(String),
/// 底层 tungstenite WS 协议错误
#[error("WS 协议错误: {0}")]
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
/// 消息序列化/反序列化失败
#[error("消息序列化失败: {0}")]
Serde(#[from] serde_json::Error),
/// 云后端鉴权/配对拒绝(token 无效 / device 未绑定)
#[error("鉴权/配对失败: {0}")]
Auth(String),
/// 隧道未连接(对 send/recv 的前置状态校验失败)
#[error("隧道未连接")]
NotConnected,
/// 其他未归类错误(降级通道,Phase2 视需要细分)
#[error("隧道内部错误: {0}")]
Other(#[from] anyhow::Error),
}
/// 模块级 Result 别名,简化调用方签名
pub type Result<T> = std::result::Result<T, TunnelError>;

View File

@@ -0,0 +1,108 @@
//! df-tunnel 隧道事件类型
//!
//! 设计依据:设计文档「三、技术选型」指出 AiChatEvent 17 变体在两端同语言保证类型一致。
//! 但 AiChatEvent 定义在 src-tauri 非 crate 层,跨 crate path 引用会破坏分层边界
//! (lib crate 依赖 src-tauri 二进制不合理)。故此处**独立定义隧道透传子集**:
//! - 仅保留跨端高频透传的 4 个变体(TextDelta/ToolCall/Approval/Completed),
//! Phase2 可按需追加,不依赖 src-tauri。
//! - 携带 conv_id 字段做多会话路由(对齐 F-09 多会话 conv_id 契约)。
//!
//! 向后兼容:新增变体只加不减,旧消费方忽略未知变体(serde 配 `#[serde(other)]` 捕获兜底)。
use serde::{Deserialize, Serialize};
/// 隧道透传的事件类型(AiChatEvent 简化子集)
///
/// 变体语义对齐桌面端 ai-chat-event:
/// - `TextDelta`:流式增量文本块
/// - `ToolCall`:工具调用请求(含工具名/参数)
/// - `Approval`:审批请求(用户授权确认)
/// - `Completed`:单轮对话完成
///
/// Phase2 可扩展 Error/Stopped/Regenerated 等变体。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TunnelEvent {
/// 流式文本增量
TextDelta {
/// 会话 ID(多会话路由用,对齐 F-09)
conv_id: String,
/// 增量文本片段
delta: String,
},
/// 工具调用请求
ToolCall {
/// 会话 ID
conv_id: String,
/// 工具调用唯一标识(用于回填结果)
call_id: String,
/// 工具名
name: String,
/// 工具参数(原始 JSON,由消费方按 name 解析)
arguments: serde_json::Value,
},
/// 审批请求(工具/操作需用户授权)
Approval {
/// 会话 ID
conv_id: String,
/// 审批项唯一标识
approval_id: String,
/// 审批描述(展示给用户)
description: String,
},
/// 对话单轮完成
Completed {
/// 会话 ID
conv_id: String,
/// 本轮产生的消息 ID
message_id: String,
},
}
impl TunnelEvent {
/// 取事件所属会话 ID(路由分发的便利方法)
pub fn conv_id(&self) -> &str {
match self {
TunnelEvent::TextDelta { conv_id, .. }
| TunnelEvent::ToolCall { conv_id, .. }
| TunnelEvent::Approval { conv_id, .. }
| TunnelEvent::Completed { conv_id, .. } => conv_id,
}
}
}
/// 云后端 → 桌面端的操作指令(小程序远程操作映射)
///
/// 对齐设计文档「Layer1 指令路由」:
/// send/stop/approve/regenerate/switch 五类操作。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TunnelCommand {
/// 发送消息
Send { conv_id: String, content: String },
/// 停止当前生成
Stop { conv_id: String },
/// 审批通过/拒绝(approval_id + accepted)
Approve { conv_id: String, approval_id: String, accepted: bool },
/// 重新生成
Regenerate { conv_id: String },
/// 切换对话
Switch { conv_id: String },
}
/// 隧道传输的消息包装(双向)
///
/// 区分「桌面端 → 云后端 → 小程序」的事件流(TunnelEvent)
/// 与「小程序 → 云后端 → 桌面端」的指令流(TunnelCommand)。
/// 同一 WS 连接复用,靠 kind 标签区分。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "dir", rename_all = "snake_case")]
pub enum TunnelMessage {
/// 桌面端发出的事件(下行)
Event(TunnelEvent),
/// 云后端转发的指令(上行,发往桌面端)
Command(TunnelCommand),
}

View File

@@ -0,0 +1,26 @@
//! # df-tunnel
//!
//! 本地 DevFlow 桌面端出站 WS 隧道客户端(设计文档 Layer1)。
//!
//! ## 职责
//! - 主动连接云后端 df-relay(出站穿 NAT,无需端口映射)
//! - 事件桥接:桌面端 ai-chat-event 透传至云后端 → 小程序
//! - 指令路由:接收小程序远程操作 → 调对应 Tauri command
//!
//! ## 边界
//! - 不依赖 src-tauri(TunnelEvent/TunnelCommand 独立定义,避免跨 crate path 引用二进制)
//! - 不含业务逻辑(Phase2 填充 WS 握手/鉴权/重连/路由)
//!
//! ## 模块
//! - [`events`]:隧道透传事件/指令/消息包装类型
//! - [`tunnel`]:TunnelClient trait + WsTunnelClient 骨架
//! - [`error`]:TunnelError 错误类型
pub mod error;
pub mod events;
pub mod tunnel;
// 顶层再导出常用项,简化调用方 use
pub use error::{Result, TunnelError};
pub use events::{TunnelCommand, TunnelEvent, TunnelMessage};
pub use tunnel::{TunnelClient, WsTunnelClient};

View File

@@ -0,0 +1,114 @@
//! df-tunnel 隧道客户端
//!
//! 设计依据:设计文档「Layer1」—— 桌面端主动出站连云后端 `wss://your-server/ws/device`,
//! 穿 NAT 无需端口映射。本模块定义 `TunnelClient` trait + tokio-tungstenite WS 连接骨架。
//!
//! 脚手架范围:trait 定义 + 连接骨架(占位实现),Phase2 填:
//! - 实际 WS 握手 + 鉴权(token / device_id)
//! - 断线指数退避重连
//! - 事件桥接(app.emit 同步 clone 推隧道)
//! - 指令路由(收到 TunnelCommand 调 Tauri command)
use async_trait::async_trait;
use crate::error::Result;
use crate::events::{TunnelCommand, TunnelEvent};
/// 隧道客户端抽象
///
/// 设计为 trait 而非具体结构,便于:
/// 1. 测试用 mock 实现(不依赖真实 WS)
/// 2. 未来替换底层实现(如换 sse / quic)不改调用方
///
/// 所有方法 async + 返回 Result<T>,失败显式上抛不吞错。
#[async_trait]
pub trait TunnelClient: Send + Sync {
/// 主动连接云后端(出站长连接,穿 NAT)
///
/// `url` 形如 `wss://your-server/ws/device?token=xxx`。
/// 成功后进入「已连接」状态,失败返回 TunnelError::Connect。
async fn connect(&self, url: &str) -> Result<()>;
/// 主动断开(优雅关闭 WS,释放底层 TCP)
async fn disconnect(&self) -> Result<()>;
/// 是否处于已连接状态(轻量查询,不阻塞,不发心跳)
fn is_connected(&self) -> bool;
/// 发送事件(桌面端 → 云后端 → 小程序)
///
/// 设计为按事件粒度发送,不聚合;调用方在 app.emit 的同时 clone 推隧道。
async fn send_event(&self, event: TunnelEvent) -> Result<()>;
/// 接收指令(云后端 → 桌面端)
///
/// 阻塞等待下一条指令。返回 None 表示连接已关闭(EOF)。
async fn recv_command(&self) -> Result<Option<TunnelCommand>>;
}
/// 默认 WS 隧道客户端骨架(tokio-tungstenite)
///
/// 脚手架阶段:字段占位,方法返回 `TunnelError::Other("未实现")`。
/// Phase2 替换为完整实现:
/// - `WebSocketStream<MaybeTlsStream<TcpStream>>` 持有连接
/// - `mpsc` 通道解耦 send/recv 任务
/// - 鉴权握手 + 心跳 + 重连
pub struct WsTunnelClient {
/// 目标服务器 URL(连接后保存,重连用)
#[allow(dead_code)]
server_url: Option<String>,
/// 连接状态标志(轻量 is_connected 查询用)
#[allow(dead_code)]
connected: bool,
}
impl WsTunnelClient {
/// 创建未连接的客户端实例
pub fn new() -> Self {
Self {
server_url: None,
connected: false,
}
}
}
impl Default for WsTunnelClient {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl TunnelClient for WsTunnelClient {
async fn connect(&self, url: &str) -> Result<()> {
// 脚手架占位:Phase2 实现 tokio_tungstenite::connect_async + 鉴权握手。
// 此处仅记录意图,返回未实现错误(不静默假成功,避免掩盖调用方逻辑)。
tracing::warn!(url = %url, "WsTunnelClient.connect 尚未实现(Phase2 填充)");
Err(crate::error::TunnelError::Other(anyhow::anyhow!(
"WsTunnelClient.connect 尚未实现(Phase2)"
)))
}
async fn disconnect(&self) -> Result<()> {
tracing::warn!("WsTunnelClient.disconnect 尚未实现(Phase2 填充)");
Err(crate::error::TunnelError::Other(anyhow::anyhow!(
"WsTunnelClient.disconnect 尚未实现(Phase2)"
)))
}
fn is_connected(&self) -> bool {
self.connected
}
async fn send_event(&self, _event: TunnelEvent) -> Result<()> {
Err(crate::error::TunnelError::Other(anyhow::anyhow!(
"WsTunnelClient.send_event 尚未实现(Phase2)"
)))
}
async fn recv_command(&self) -> Result<Option<TunnelCommand>> {
Err(crate::error::TunnelError::Other(anyhow::anyhow!(
"WsTunnelClient.recv_command 尚未实现(Phase2)"
)))
}
}