WeClaw_41_桌面端与PWA文件双向传输:WebSocket与HTTP混合协议设计

作者 : WeClaw 开发团队
日期 : 2026-03-29
版本 : v1.0
标签: WebSocket、HTTP 上传、消息协议、文件传输、PWA


📖 摘要

本文详细剖析 WeClaw 桌面端与 PWA 之间文件双向传输的完整协议设计与实现。面对"桌面端 AI 生成文件后如何推送到手机"的核心需求,我们设计了 WebSocket 消息信令 + HTTP 文件上传的混合传输架构。文章涵盖三种消息协议(file_share / file_request / file_response)、HTTP multipart 上传、URL 协议转换、文件智能分类等关键技术。

核心收获

  • 🔗 理解 WebSocket 信令 + HTTP 数据的混合传输架构
  • 📨 掌握三种文件传输消息协议的设计原则
  • 📤 学会 HTTP multipart/form-data 文件上传实现
  • 🏷️ 了解 60+ 种文件类型的智能分类策略
  • 🔄 掌握 ws → http URL 协议自动转换

🎯 需求背景:为什么需要文件双向传输?

现有能力与瓶颈

在 v3.0.0 之前,WeClaw 远程桥接系统仅支持:

复制代码
PWA → 桌面端:文本消息 ✅ + 图片/音频附件 ✅
桌面端 → PWA:文本消息 ✅ + 文件推送 ❌

问题场景

  • ❌ 用户在手机上说"帮我做个 PPT",桌面端生成了 PPT 但无法发回
  • ❌ 用户在手机上说"截个屏发给我",桌面端截图后无传输通道
  • ❌ 用户在手机上说"把那份报告发过来",桌面端无法响应文件请求

设计目标

复制代码
┌──────────┐     WebSocket 信令     ┌──────────┐     WebSocket 信令     ┌──────────┐
│  桌面端   │ ◄──────────────────► │   服务器  │ ◄──────────────────► │   PWA    │
│          │                       │          │                       │          │
│          │ ─── HTTP POST ──────► │          │                       │          │
│          │     文件上传           │   文件    │ ─── HTTP GET ──────► │   文件   │
│          │                       │   存储    │     文件下载           │   下载   │
└──────────┘                       └──────────┘                       └──────────┘

核心思路:WebSocket 只传递信令(元数据),文件实体通过 HTTP 传输。


📝 核心模块一:URL 协议转换

问题:WebSocket 地址无法用于 HTTP

桌面端通过 WebSocket 连接到服务器:

复制代码
wss://weclaw.cc:8188/ws/bridge    ← WebSocket 地址

但文件上传需要 HTTP 接口:

复制代码
https://weclaw.cc:8188/api/files/upload    ← HTTP 地址

解决方案:统一转换函数

python 复制代码
def _get_server_base_url(self) -> str:
    """从 WebSocket server_url 推导 HTTP 基础地址。
    
    转换规则:
    - wss://weclaw.cc:8188/ws/bridge → https://weclaw.cc:8188
    - ws://localhost:8000/ws/bridge  → http://localhost:8000
    """
    server_url = getattr(self.config, "server_url", "")
    if not server_url:
        return "https://weclaw.cc:8188"
    
    base = server_url.replace("/ws/bridge", "")
    if base.startswith("wss://"):
        base = "https://" + base[len("wss://"):]
    elif base.startswith("ws://"):
        base = "http://" + base[len("ws://"):]
    return base

设计要点

  • 剥离路径 /ws/bridge,保留协议+域名+端口
  • wsshttpswshttp,保持 TLS 一致性
  • 无配置时提供默认回退地址

📨 核心模块二:三种消息协议

2.1 file_share --- 桌面端主动推送

场景:桌面端 AI 生成了文件(如 PPT),需要主动发给 PWA 用户。

json 复制代码
{
    "type": "file_share",
    "payload": {
        "user_id": "target_user_id",
        "session_id": "session_id",
        "files": [
            {
                "file_id": "attachment_id_from_upload",
                "filename": "report.pdf",
                "file_type": "document",
                "mime_type": "application/pdf",
                "size_bytes": 102400,
                "url": "https://weclaw.cc:8188/api/files/xxx",
                "description": "月度报告"
            }
        ],
        "message": "可选的附带文字消息"
    }
}

实现代码

python 复制代码
async def send_file_to_pwa(
    self,
    user_id: str,
    file_path: str,
    description: str = "",
    session_id: str = "",
) -> bool:
    """将本地文件主动分享给指定 PWA 用户。"""
    if not self.is_connected:
        return False
    
    # 1. 上传文件到服务器
    upload_result = self._upload_file_to_remote(
        file_path=file_path,
        user_id=user_id,
        session_id=session_id,
        description=description,
    )
    if not upload_result:
        return False
    
    # 2. 构建 file_share 消息
    base_url = self._get_server_base_url()
    mime_type, _ = mimetypes.guess_type(str(file_path))
    file_type = self._categorize_file(
        upload_result['filename'], mime_type or "", "file"
    )
    
    message = {
        "type": "file_share",
        "payload": {
            "user_id": user_id,
            "session_id": session_id,
            "files": [{
                "file_id": upload_result["attachment_id"],
                "filename": upload_result["filename"],
                "file_type": file_type,
                "mime_type": mime_type or "application/octet-stream",
                "size_bytes": upload_result["size_bytes"],
                "url": f"{base_url}{upload_result['url']}",
                "description": description,
            }],
        },
    }
    
    await self._send_raw(message)
    return True

关键设计决策

  • files 始终为数组格式,单文件和多文件使用统一协议
  • URL 拼接为完整绝对路径,PWA 端可直接用于下载
  • 文件类型由 _categorize_file() 智能推断

2.2 file_request --- PWA 请求文件

场景:PWA 用户主动向桌面端请求特定文件。

复制代码
PWA 用户: "把昨天的会议记录发给我"
    ↓
服务器 → 桌面端: file_request 消息
    ↓
桌面端 GUI: 弹出文件选择对话框
    ↓
用户选择文件 → respond_file_request()
json 复制代码
{
    "type": "file_request",
    "request_id": "unique_request_id",
    "payload": {
        "user_id": "requesting_user_id",
        "session_id": "pwa_session_id",
        "reason": "请发送昨天的会议记录",
        "filters": {
            "types": ["document"],
            "extensions": [".docx", ".pdf"]
        }
    }
}

桌面端处理实现

python 复制代码
async def _handle_file_request(self, request_id: str, payload: dict):
    """处理来自 PWA 的文件请求。"""
    user_id = payload.get("user_id", "")
    reason = payload.get("reason", "")
    filters = payload.get("filters", {})
    
    # 若没有 event_bus,无法交互式选择文件
    if not self.event_bus:
        await self._send_error(
            "FILE_REQUEST_UNSUPPORTED",
            "当前桌面端不支持远程文件请求处理",
            request_id=request_id,
        )
        return
    
    # 通知 GUI 弹出文件选择对话框
    await self.event_bus.emit("remote_file_request", {
        "request_id": request_id,
        "user_id": user_id,
        "session_id": payload.get("session_id", ""),
        "reason": reason,
        "filters": filters,
    })

2.3 file_response --- 响应文件请求

关键request_id 是路由的纽带,串联请求和响应。

json 复制代码
{
    "type": "file_response",
    "request_id": "original_request_id",
    "payload": {
        "user_id": "requesting_user_id",
        "files": [{ ... }]
    }
}
python 复制代码
async def respond_file_request(
    self,
    request_id: str,
    user_id: str,
    file_path: str,
    description: str = "",
) -> bool:
    """响应 PWA 的文件请求 --- 关键是沿用原始 request_id。"""
    upload_result = self._upload_file_to_remote(
        file_path=file_path, user_id=user_id,
    )
    if not upload_result:
        await self._send_error(
            "FILE_UPLOAD_FAILED", "上传失败",
            request_id=request_id,
        )
        return False
    
    response_message = {
        "type": "file_response",
        "request_id": request_id,  # 关键:沿用原始 request_id
        "payload": {
            "user_id": user_id,
            "files": [{
                "file_id": upload_result["attachment_id"],
                "filename": upload_result["filename"],
                "url": f"{base_url}{upload_result['url']}",
                # ...
            }],
        },
    }
    
    await self._send_raw(response_message)
    return True

三种协议的消息路由总览

复制代码
           file_share                    file_share
桌面端 ──────────────► 服务器 ──────────────► PWA
                       (转发)

           file_request                  file_request
桌面端 ◄──────────────  服务器 ◄──────────────  PWA
                       (转发+注册映射)

           file_response                 file_response
桌面端 ──────────────► 服务器 ──────────────► PWA
                       (路由+清理映射)

📤 核心模块三:HTTP 文件上传

multipart/form-data 上传

python 复制代码
def _upload_file_to_remote(
    self,
    file_path: str,
    user_id: str,
    session_id: str = "",
    description: str = "",
) -> dict | None:
    """HTTP POST 上传本地文件到服务器。"""
    path = Path(file_path)
    
    # 安全检查:50MB 限制
    file_size = path.stat().st_size
    if file_size > 50 * 1024 * 1024:
        return None
    
    # MIME 类型推断
    mime_type, _ = mimetypes.guess_type(str(path))
    if not mime_type:
        mime_type = "application/octet-stream"
    
    # 构建认证头(详见第 43 篇博客)
    headers = {}
    if self._device_fingerprint:
        headers["X-Device-Fingerprint"] = self._device_fingerprint
    access_token = self._get_valid_access_token()
    if access_token:
        headers["Authorization"] = f"Bearer {access_token}"
    
    # 执行上传
    upload_url = f"{self._get_server_base_url()}/api/files/upload"
    with open(path, "rb") as f:
        files = {"file": (path.name, f, mime_type)}
        data = {"session_id": session_id} if session_id else {}
        resp = requests.post(
            upload_url, headers=headers,
            files=files, data=data, timeout=120,
        )
    
    resp.raise_for_status()
    result = resp.json()
    
    # 返回 {attachment_id, filename, size_bytes, url}
    return result

上传后持久化

上传成功后,元数据写入 AttachmentStorage(SQLite),便于历史检索:

python 复制代码
from .attachment_storage import get_attachment_storage, StoredAttachment

attachment = StoredAttachment(
    id=attachment_id,
    session_id=session_id,
    user_id=user_id,
    filename=filename,
    file_type=self._categorize_file(filename, mime_type, "file"),
    local_path=str(path),
    remote_url=f"{base_url}{url}",
    file_size=size_bytes,
    description=description,
)
storage.save_attachment(attachment)

🏷️ 核心模块四:文件智能分类

三级分类策略

python 复制代码
def _categorize_file(self, filename: str, mime_type: str, att_type: str) -> str:
    """基于 MIME → 扩展名 → 传入类型 的三级策略。"""
    
    # 第一级:MIME 类型(最准确)
    if mime_type:
        if mime_type.startswith('image/'): return 'image'
        if mime_type.startswith('audio/'): return 'audio'
        if mime_type.startswith('video/'): return 'video'
    
    # 第二级:扩展名(覆盖 60+ 种类型)
    ext = filename.lower().rsplit('.', 1)[-1]
    
    if ext in ('jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'ico'):
        return 'image'
    if ext in ('mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a', 'wma'):
        return 'audio'
    if ext in ('mp4', 'webm', 'avi', 'mov', 'mkv', 'flv'):
        return 'video'
    if ext in ('py', 'js', 'ts', 'java', 'c', 'cpp', 'go', 'rs', ...):
        return 'code'
    if ext in ('pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', ...):
        return 'document'
    
    # 第三级:传入类型兜底
    if att_type in ('image', 'audio', 'video', 'document', 'code'):
        return att_type
    
    return 'file'  # 未知类型

分类映射全表

分类 扩展名 数量
image jpg, jpeg, png, gif, webp, bmp, svg, ico 8
audio mp3, wav, ogg, aac, flac, m4a, wma 7
video mp4, webm, avi, mov, mkv, flv 6
code py, js, ts, java, c, cpp, go, rs, html, css, json, xml, sql 等 30+
document pdf, doc, docx, xls, xlsx, ppt, pptx, txt, md, csv 等 14
file 以上均不匹配时的默认分类 -

📊 架构总结

完整数据流

复制代码
PWA 用户: "帮我做个 PPT"
    ↓ WebSocket
服务器 → 桌面端: pwa_request 消息
    ↓
桌面端 AI: 调用 ppt_generator 生成 PPT
    ↓
桌面端: _upload_file_to_remote() → HTTP POST → 服务器存储
    ↓
桌面端: send_file_to_pwa() → WebSocket file_share → 服务器
    ↓ WebSocket
服务器 → PWA: file_share 消息(含下载 URL)
    ↓
PWA: 显示文件卡片,用户点击下载

关键技术决策

决策 选择 原因
信令通道 WebSocket 复用已有连接,低延迟
数据通道 HTTP POST 可靠传输,支持大文件
消息格式 JSON 可扩展,便于调试
文件数组 统一 files[] 单文件/多文件协议统一
URL 格式 绝对路径 PWA 可直接下载

字数统计 : 约 4,500 字
阅读时间 : 约 12 分钟
代码行数: 约 300 行

相关推荐
i建模2 小时前
python, conda SSL证书错误修复及conda更新
网络协议·conda·ssl
FPGA小迷弟5 小时前
FPGA工程师面试题汇总(九)
网络协议·tcp/ip·fpga开发·面试·verilog·fpga
白慕慕5 小时前
tcp传输
linux·网络协议·tcp/ip
左手厨刀右手茼蒿18 小时前
Flutter 组件 http_requests 适配鸿蒙 HarmonyOS 实战:极简网络请求,构建边缘端轻量级 RESTful 通讯架构
网络·flutter·http
晏宁科技YaningAI20 小时前
全球短信路由系统设计逻辑打破 80%送达率瓶颈:工程实践拆解
网络·网络协议·架构·gateway·信息与通信·paas
WIN-U621 小时前
新版华三H3C交换机配置NTP时钟步骤 示例(命令及WEB配置)
网络协议·tcp/ip·http
F1FJJ21 小时前
什么是 Shield CLI?视频讲解:一条命令,可浏览器远程访问一切内部服务(RDP/VNC/SSH/数据库等)
运维·网络·数据库·网络协议·ssh
F1FJJ1 天前
Shield CLI 命令全解析:15 个命令覆盖所有远程访问场景
网络·数据库·网络协议·容器·开源软件
nbsaas-boot1 天前
基于 HTTP 构建 MCP Tools 的完整工程解析
网络·网络协议·http·ai