双通道实时通信:WinClaw 桌面端与 PWA 多实例绑定路由实战
系列文章第 7 篇 - 深入解析 WebSocket 双向通信、请求响应路由和多设备消息隔离的完整实现方案
前言
在上一篇文章中,我们介绍了 双通道密钥管理 方案。本文将进一步揭示 WinClaw 架构中另一个核心技术挑战:如何实现桌面端与多个 PWA 实例的一对一绑定和精准通信。
想象这样一个场景:
- 用户在办公室电脑上登录 WinClaw 桌面端
- 同时用手机浏览器、家用电脑浏览器打开 PWA
- 三个客户端都使用同一个账号
- 每个 PWA 发送的消息都能正确路由到桌面端,且响应只返回给对应的 PWA
这听起来简单,但背后涉及复杂的技术实现。让我们一探究竟。
一、架构全景图
1.1 系统组成
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ PWA-A │ │ PWA-B │ │ WinClaw │
│ (手机浏览器)│ │(家用电脑) │ │ (办公室) │
│ │ │ │ │ │
│ user=alice │ │ user=alice │ │ device=PC1 │
│ session=S1 │ │ session=S2 │ │ session=D1 │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ WebSocket │ WebSocket │ WebSocket
│ (PWA连接池) │ (PWA连接池) │ (Bridge 连接)
│ │ │
└───────────────────┼───────────────────┘
│
┌────────▼────────┐
│ Remote Server │
│ │
│ ConnectionMgr │
│ - PWA连接池 │
│ - Bridge 连接 │
│ - 请求路由表 │
└─────────────────┘
1.2 核心组件
| 组件 | 位置 | 职责 |
|---|---|---|
ConnectionManager | remote_server/websocket/manager.py | 管理所有 PWA WebSocket 连接 |
BridgeConnectionManager | remote_server/websocket/bridge_handler.py | 管理桌面端连接和请求路由 |
pendingMessageIds | pwa/src/stores/chat.ts | PWA 端维护待响应请求 ID 集合 |
二、绑定关系模型
2.1 1:N 绑定设计
虽然一个桌面端只能绑定一个用户,但一个用户可以有多个 PWA 实例(不同浏览器/设备):
-- 数据库层面:1 个桌面端 ↔ 1 个用户
CREATE TABLE device_bindings (
id INTEGER PRIMARY KEY,
user_id TEXT NOT NULL, -- 用户 ID
device_fingerprint TEXT UNIQUE, -- 设备指纹(桌面端)
binding_token TEXT,
created_at TIMESTAMP,
expires_at TIMESTAMP
);
-- 内存层面:1 个用户 ↔ N 个 PWA连接
-- ConnectionManager._connections: Dict[conn_id, ConnectionInfo]
-- 每个 conn_id 对应一个 browser session
2.2 数据结构
# manager.py
class ConnectionInfo:
def __init__(
self,
user_id: str, # 用户 ID(相同用户)
session_id: str, # PWA 会话 ID(不同浏览器不同)
websocket: WebSocket, # WebSocket 连接
metadata: dict # 包含设备信息、连接时间等
):
self.user_id = user_id
self.session_id = session_id
self.websocket = websocket
self.connected_at = datetime.now()
关键点:
user_id相同:标识同一个用户session_id不同:区分不同的浏览器会话websocket不同:每个连接有独立的 WebSocket 对象
三、请求响应路由机制
3.1 核心挑战
当桌面端收到用户的消息时,需要知道这个请求来自哪个 PWA 实例,以便将响应准确返回:
PWA-A (S1) ──request_id=R1──> Server ──R1──> WinClaw
│
PWA-B (S2) ──request_id=R2──> Server ──R2──> WinClaw│
▼
执行工具调用
│
▼
响应 R1 → ??? (应该去 S1)
响应 R2 → ??? (应该去 S2)
3.2 request_id 的作用
每个请求携带全局唯一的 request_id,用于追踪完整的请求 - 响应链路:
// PWA 前端生成
const requestId = generateUUID() // 例如:"bc622a18-..."
3.3 路由表设计
服务器维护一个临时的请求路由表:
# bridge_handler.py
class BridgeConnectionManager:
def __init__(self):
# 请求 ID → PWA 会话映射
self._request_to_pwa_session: Dict[str, Tuple[str, str]] = {}
# 结构:{ request_id: (user_id, pwa_session_id) }
def register_pwa_request(self, request_id: str, user_id: str, session_id: str):
"""记录 PWA 发起的请求"""
self._request_to_pwa_session[request_id] = (user_id, session_id)
# 5 分钟后清理(防止内存泄漏)
asyncio.create_task(self._cleanup_request_id(request_id))
四、完整通信流程
4.1 消息上行(PWA → 桌面端)
Step 1: PWA 发送消息
// pwa/src/stores/chat.ts
async function sendMessage(content: string, attachments?: Attachment[]) {
const requestId = generateUUID()
pendingMessageIds.value.add(requestId) // 本地记录
ws.send('message', {
request_id: requestId, // ✅ 关键:携带 request_id
content,
attachments
})
}
Step 2: 服务器接收并转发
# remote_server/websocket/handlers.py
async def handle_chat_message(
user_id: str,
session_id: str,
content: str,
request_id: str = None # ✅ 接收客户端传来的 ID
):
# 优先使用客户端的 request_id
if not request_id:
request_id = str(uuid.uuid4())
# 注册请求,建立映射关系
bridge_manager.register_pwa_request(request_id, user_id, session_id)
# 转发到桌面端
await bridge_manager.send_to_winclaw(winclaw_conn.session_id, {
"type": "chat",
"request_id": request_id, // 原样转发
"payload": {
"user_id": user_id,
"content": content,
"pwa_session_id": session_id // 备用路由信息
}
})
Step 3: 桌面端处理
# winclaw/src/core/remote_client.py
async def handle_message(self, message: dict):
request_id = message.get("request_id")
payload = message.get("payload")
# 执行 AI 推理和工具调用
response_stream = await self.process_chat(payload)
# 流式返回响应
for chunk in response_stream:
await self.send_response({
"type": "stream",
"request_id": request_id, // ✅ 必须原样返回
"payload": {"content": chunk}
})
4.2 响应下行(桌面端 → PWA)
Step 4: 服务器路由响应
# remote_server/websocket/bridge_handler.py
async def forward_to_pwa_by_request(
self,
request_id: str,
response: dict
):
# 查找请求来源
mapping = self._request_to_pwa_session.get(request_id)
if mapping:
user_id, pwa_session_id = mapping
# 通过 ConnectionManager 发送到指定 PWA
from .manager import get_connection_manager
conn_mgr = get_connection_manager()
success = await conn_mgr.send_to_session(pwa_session_id, {
"type": response.get("type"),
"payload": response.get("payload"),
"request_id": request_id # ✅ 原样传递,用于客户端验证
})
Step 5: PWA 接收并验证
// pwa/src/stores/chat.ts
function initWebSocket() {
ws = useWebSocket({
onMessage: (data) => {
const requestId = data.request_id
const isMyRequest = !requestId || pendingMessageIds.value.has(requestId)
if (!isMyRequest) {
return // ❌ 过滤掉不属于本实例的响应
}
// ✅ 处理响应
if (data.type === 'stream') {
streamingContent.value += data.payload.content
}
}
})
}
五、关键技术点详解
5.1 request_id 的生命周期
┌─────────────────────────────────────────────────────────────┐
│ 1. PWA 生成 │
│ requestId = generateUUID() │
│ pendingMessageIds.add(requestId) │
└────────────────┬────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 2. 服务器接收并记录 │
│ register_pwa_request(requestId, user_id, session_id) │
│ ↓ │
│ 转发给桌面端(携带 requestId) │
└────────────────┬────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 3. 桌面端处理并返回 │
│ 执行工具调用 │
│ ↓ │
│ 响应(携带相同的 requestId) │
└────────────────┬────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 4. 服务器路由 │
│ 查找 _request_to_pwa_session[requestId] │
│ ↓ │
│ 发送到对应的 pwa_session_id │
└────────────────┬────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 5. PWA 验证并显示 │
│ if pendingMessageIds.has(requestId): │
│ 显示响应 │
│ pendingMessageIds.delete(requestId) │
└─────────────────────────────────────────────────────────────┘
5.2 多设备消息隔离
场景:同一用户的两个 PWA 实例同时发送消息
// PWA-A (手机)
sendMessage("查看天气") // requestId = "R1"
pendingMessageIds = {"R1"}
// PWA-B (电脑)
sendMessage("查看日程") // requestId = "R2"
pendingMessageIds = {"R2"}
// 服务器路由表
_request_to_pwa_session = {
"R1": ("alice", "session_mobile"),
"R2": ("alice", "session_desktop")
}
// 桌面端返回响应
response_R1 = { request_id: "R1", content: "今天晴朗..." }
response_R2 = { request_id: "R2", content: "上午 10 点开会..." }
// 服务器分别路由
send_to_session("session_mobile", response_R1) // ✅ 到 PWA-A
send_to_session("session_desktop", response_R2) // ✅ 到 PWA-B
// PWA 验证
PWA-A: pendingMessageIds.has("R1") → true → 显示
PWA-A: pendingMessageIds.has("R2") → false → 忽略
PWA-B: pendingMessageIds.has("R2") → true → 显示
PWA-B: pendingMessageIds.has("R1") → false → 忽略
5.3 连接状态管理
# manager.py
class ConnectionManager:
async def send_to_session(self, session_id: str, message: dict) -> bool:
"""发送消息到指定 PWA 会话"""
# 1. 查找连接
conn_info = self._connections.get(session_id)
if not conn_info:
logger.debug(f"发送失败:连接不存在")
return False
# 2. 检查 WebSocket 状态
try:
ws_state = conn_info.websocket.client_state
if ws_state.name != 'CONNECTED':
logger.warning(f"连接异常:state={ws_state.name}")
await self._disconnect_user(session_id)
return False
except Exception as e:
logger.warning(f"检查状态失败:{e}")
# 3. 发送消息
try:
await conn_info.websocket.send_json(message)
return True
except Exception as e:
logger.warning(f"发送失败:{e}")
await self._disconnect_user(session_id)
return False
六、心跳与重连机制
6.1 心跳检测
// pwa/src/api/websocket.ts
let lastPongTime = 0
let pingTimer: any = null
let pongCheckTimer: any = null
function startPing() {
lastPongTime = Date.now()
// 每 15 秒发送 ping
pingTimer = setInterval(() => {
if (ws?.readyState === WebSocket.OPEN) {
send('ping')
}
}, 15000)
// 每 15 秒检查 pong 响应
pongCheckTimer = setInterval(() => {
const elapsed = Date.now() - lastPongTime
if (elapsed > 45000) {
console.warn(`心跳超时 (${elapsed}ms),重连...`)
ws.close(4000, 'Heartbeat timeout')
}
}, 15000)
}
// 收到 pong 响应
case 'pong':
lastPongTime = Date.now()
break
6.2 自动重连
ws.onclose = () => {
isConnected.value = false
// 自动重连逻辑
if (reconnect && reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++
setTimeout(() => {
connect() // 重新建立 WebSocket
}, reconnectInterval)
}
}
七、调试与监控
7.1 日志增强
# bridge_handler.py
logger.info(
f"响应路由查找:request={request_id[:8]}, "
f"user={user_id[:8]}, session={pwa_session_id[:20]}"
)
if found_mapping:
logger.info(
f"PWA连接状态:user={user_id[:8]}, "
f"ws_connected={ws_connected}, ws_count={ws_count}"
)
logger.info(
f"响应已定点路由:request={request_id[:8]}, "
f"session={pwa_session_id[:20]}, type={response_type}"
)
else:
logger.warning(
f"未找到请求来源:request={request_id[:8]}, "
f"尝试 fallback 到广播"
)
7.2 典型日志分析
成功场景:
22:37:02 | 响应路由查找:request=bc622a18, user=96f29153, session=remote_96f29153-
22:37:02 | PWA连接状态:user=96f29153, ws_connected=True, ws_count=1
22:37:02 | 响应已定点路由:request=bc622a18, session=remote_96f29153-, type=stream
22:37:09 | 响应完成:request=bc622a18, type=stream_end
失败场景(修复前):
22:21:24 | 响应已定点路由:request=xxx (显示成功)
... (无 stream_end)
22:22:01 | WebSocket 断开:user=96f29153
八、常见问题与解决方案
Q1: 为什么不用 session_id 而要用 request_id?
A:
session_id标识的是连接,一个用户可能有多个连接request_id标识的是请求,能精确匹配请求 - 响应对- 使用
request_id可以支持并发请求和乱序响应
Q2: 如何处理请求超时?
A:
# bridge_handler.py
async def _cleanup_request_id(self, request_id: str):
"""5 分钟后清理请求记录"""
await asyncio.sleep(300)
self._request_to_pwa_session.pop(request_id, None)
# PWA 端也有超时处理
setTimeout(() => {
if (pendingMessageIds.has(requestId)) {
showToast('请求超时,请重试')
pendingMessageIds.delete(requestId)
}
}, 30000)
Q3: 桌面端离线时怎么办?
A:
# handlers.py
if not winclaw_conn:
# 存入离线消息队列
await queue.enqueue(
user_id=user_id,
content=content,
priority=MessagePriority.NORMAL,
ttl_minutes=30
)
# 推送通知
await broadcast_winclaw_status_change(user_id, "offline")
Q4: 如何防止消息重复?
A: 每个请求的 request_id 全局唯一,即使重连也不会重复处理:
// 重连后 pendingMessageIds 清空
function disconnect() {
pendingMessageIds.value.clear()
}
// 每次发送新消息生成新的 ID
const requestId = generateUUID() // UUID 保证唯一性
九、性能优化建议
9.1 连接池管理
# manager.py
class ConnectionManager:
def __init__(self):
self._connections: Dict[str, ConnectionInfo] = {}
self._user_connections: Dict[str, Set[str]] = defaultdict(set)
# user_id → set of session_ids
9.2 内存清理
async def _cleanup_request_id(self, request_id: str):
"""定时清理过期的请求映射"""
await asyncio.sleep(300) # 5 分钟
self._request_to_pwa_session.pop(request_id, None)
async def _disconnect_user(self, conn_id: str, reason: str = ""):
"""断开连接时清理所有相关数据"""
conn_info = self._connections.pop(conn_id, None)
if conn_info:
self._user_connections[conn_info.user_id].discard(conn_info.session_id)
9.3 批量操作
避免频繁创建销毁对象,使用对象池:
class RequestPool:
def __init__(self):
self._pool: List[str] = []
def acquire(self) -> str:
if self._pool:
return self._pool.pop()
return str(uuid.uuid4())
def release(self, request_id: str):
self._pool.append(request_id)
十、总结
10.1 核心要点回顾
- 绑定关系:1 个桌面端 ↔ 1 个用户 ↔ N 个 PWA 实例
- 路由机制:基于
request_id的请求 - 响应追踪 - 消息隔离:
pendingMessageIds确保每个 PWA 只接收自己的响应 - 连接管理:WebSocket 状态检测和自动重连
10.2 最佳实践
- ✅ 始终由客户端生成
request_id - ✅ 服务端原样转发不修改
- ✅ 响应必须携带相同的
request_id - ✅ 客户端验证
request_id后再处理 - ✅ 定时清理过期数据防止内存泄漏
10.3 下一步优化方向
- 支持消息优先级队列
- 实现消息持久化存储
- 添加消息送达确认机制
- 支持断点续传和大文件分片
附录:核心代码文件清单
| 文件路径 | 作用 |
|---|---|
remote_server/websocket/manager.py | PWA连接管理 |
remote_server/websocket/bridge_handler.py | 请求路由和桌面端管理 |
remote_server/websocket/handlers.py | 消息处理和转发 |
pwa/src/stores/chat.ts | PWA 聊天状态和 WebSocket 通信 |
pwa/src/api/websocket.ts | WebSocket 客户端封装 |
参考资料:
上一篇文章:双通道密钥管理:兼顾开发者便利与用户安全的实战方案
下一篇文章:[当自进化遇上极致工程化:WeClaw 与 DeepSeek Harness 架构深度对比](./WeClaw_98_当自进化遇上极致工程化:WeClaw与DeepSeek Harness架构深度对比.md)