返回博客列表
技术教程2026-08-3025 分钟阅读

双通道实时通信:WinClaw 桌面端与 PWA 多实例绑定路由实战

WebSocket 双通道实时通信:桌面端与 PWA 多实例绑定路由与多设备消息隔离实战

双通道实时通信: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 核心组件

组件位置职责
ConnectionManagerremote_server/websocket/manager.py管理所有 PWA WebSocket 连接
BridgeConnectionManagerremote_server/websocket/bridge_handler.py管理桌面端连接和请求路由
pendingMessageIdspwa/src/stores/chat.tsPWA 端维护待响应请求 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 个桌面端 ↔ 1 个用户 ↔ N 个 PWA 实例
  2. 路由机制:基于 request_id 的请求 - 响应追踪
  3. 消息隔离:pendingMessageIds 确保每个 PWA 只接收自己的响应
  4. 连接管理:WebSocket 状态检测和自动重连

10.2 最佳实践

  • ✅ 始终由客户端生成 request_id
  • ✅ 服务端原样转发不修改
  • ✅ 响应必须携带相同的 request_id
  • ✅ 客户端验证 request_id 后再处理
  • ✅ 定时清理过期数据防止内存泄漏

10.3 下一步优化方向

  • 支持消息优先级队列
  • 实现消息持久化存储
  • 添加消息送达确认机制
  • 支持断点续传和大文件分片

附录:核心代码文件清单

文件路径作用
remote_server/websocket/manager.pyPWA连接管理
remote_server/websocket/bridge_handler.py请求路由和桌面端管理
remote_server/websocket/handlers.py消息处理和转发
pwa/src/stores/chat.tsPWA 聊天状态和 WebSocket 通信
pwa/src/api/websocket.tsWebSocket 客户端封装

参考资料:

上一篇文章:双通道密钥管理:兼顾开发者便利与用户安全的实战方案

下一篇文章:[当自进化遇上极致工程化:WeClaw 与 DeepSeek Harness 架构深度对比](./WeClaw_98_当自进化遇上极致工程化:WeClaw与DeepSeek Harness架构深度对比.md)