作者 : 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,保留协议+域名+端口 wss→https,ws→http,保持 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 行