19 KiB
WebUI 与 TUI 文件收发设计
1. 目标与非目标
当前统一消息链路已经有 MediaItem、MediaRef 和 messages.media_refs,Agent 能处理图片,飞书及 send_message(files=...) 也能收发媒体。缺口集中在 cli_chat:WebSocket 入站把 media 固定为空,实时响应和历史协议也没有附件字段,因此 WebUI/TUI 无法使用已有能力。
本设计补齐:
- WebUI 文件选择、拖放、粘贴图片、上传进度、附件展示和下载;
- TUI 通过本地路径上传、展示附件和下载文件;
- 文本加附件及纯附件消息进入现有 session worker;
- Agent 通过现有
send_message(files=...)发出的文件可被 WebUI/TUI 获取; - 文件大小、并发、scope、路径和 MIME 安全边界。
附件采用“路径引用”语义,而不是持久化文件资产:
- 消息仍在
messages.media_refs中记录服务端本地路径和媒体类型; - 不新增附件表、消息附件关联表、内容寻址、引用计数或永久 blob 存储;
- 不保证历史附件可获取。路径变化、文件被覆盖或删除后,预览/下载可以返回不可用;
- 历史消息必须仍能正常显示,文件不可用不能导致整段历史读取失败;
- 客户端永远看不到或提交服务端路径,路径只在 Gateway 内部使用。
首版不包含目录上传、断点续传、对象存储、跨 Gateway 部署、缩略图服务和自动文档解析。
2. 总体方案
采用“HTTP 传字节,WebSocket 传短期上传引用和消息事件”:
sequenceDiagram
participant C as WebUI / TUI
participant H as Attachment HTTP API
participant U as UploadRegistry
participant W as cli_chat WebSocket
participant S as Session worker
C->>H: POST multipart file
H->>H: 流式保存到本地媒体目录
H->>U: 注册 upload_id -> path + chat scope
H-->>C: UploadDescriptor(upload_id, name, size, type)
C->>W: user_input(content, upload_ids)
W->>U: 校验并消费 upload IDs
W->>S: InboundMessage(media paths)
S->>S: 按现有 media_refs 持久化路径
S-->>C: assistant_response / session_history
C->>H: GET message attachment by message ID + index
H->>H: 查询 media_refs,按当前路径流式读取
H-->>C: bytes 或 404/410
不把文件塞入 WebSocket binary frame 或 base64 JSON:现有 WebSocket writer、Bus 和 session 队列面向小型消息,大帧会造成内存峰值与队头阻塞;HTTP 更适合流式 I/O、上传进度、状态码和后续 Range 支持。
3. 模型与协议
3.1 短期上传描述
上传成功后返回只在短期内有效的 UploadDescriptor:
pub struct UploadDescriptor {
pub upload_id: String, // UUID v4,仅用于随后提交 user_input
pub name: String,
pub media_type: String, // image | audio | video | file
pub mime_type: String,
pub size: u64,
pub expires_at: i64,
}
upload_id 不是持久化附件 ID。Gateway 重启、过期或被成功消费后均可失效。
3.2 消息附件描述
WebSocket 对客户端返回的描述不含路径:
pub struct MessageAttachment {
pub index: u32, // 在该消息 media_refs 中的位置
pub name: String, // 从当前 path basename 安全派生
pub media_type: String,
pub mime_type: String, // 按扩展名推断,仅用于展示/响应默认值
}
下载定位使用 session_id + message_id + index。不在描述中放服务端路径,也不使用可映射回路径的编码 ID。
是否增加 available 字段:首版不增加。历史查询不为最多 2000 条消息逐个执行文件 stat;客户端点击预览/下载时,由 HTTP 状态码反映文件是否仍存在。WebUI/TUI 收到 404/410 后把该附件标记为“文件已不存在”。
3.3 WebSocket 扩展
保持旧 JSON 可解析:
WsInbound::UserInput {
content: String,
upload_ids: Vec<String>, // serde(default),最多 8 个
client_message_id: Option<String>,
// 现有字段保留
}
HistoryMessage {
// 现有字段保留
attachments: Vec<MessageAttachment>, // serde(default)
}
WsOutbound::AssistantResponse {
// 现有字段保留
attachments: Vec<MessageAttachment>, // serde(default)
}
上传引用无效、跨 scope、重复/过期、文件数量或总量超限时,Gateway 使用现有 error 帧返回明确原因。成功提交沿用普通消息的处理/响应语义,不新增附件专用确认帧。
SessionEstablished 增加默认空的 capabilities;支持本方案的 Gateway 返回 file_transfer_v1。新 TUI 连接旧 Gateway 时隐藏文件功能并提示升级,旧客户端忽略新增字段。
4. UploadRegistry
新增轻量 UploadRegistry,由 GatewayState 持有,只管理尚未提交到消息的上传:
struct PendingUpload {
upload_id: String,
owner_channel: String,
owner_chat_id: String,
path: PathBuf,
name: String,
media_type: String,
mime_type: String,
size: u64,
expires_at: Instant,
state: Pending | Consuming,
}
Registry 存于内存,不写 SQLite。默认文件目录建议为 ~/.picobot/media/cli_chat,配置归入 gateway.file_transfer:
{
"enabled": true,
"upload_dir": "~/.picobot/media/cli_chat",
"max_file_bytes": 26214400,
"max_files_per_message": 8,
"max_message_bytes": 67108864,
"pending_ttl_seconds": 3600
}
文件布局不使用用户文件名定位:
cli_chat/<client-scope-hash>/<uuid>.upload
cli_chat/.staging/<uuid>.part
原始文件名只保存在 registry 中;消息最终记录的路径可以带安全扩展名,但不能包含未经清理的目录组件。
状态规则:
- 上传完成后为
Pending。 user_input批量校验所有 upload ID 后,从 registry 原子取出。- 成功发布到 inbound Bus 后不删除文件;后续消息持有路径引用。
- 发布到 inbound Bus 失败时恢复 registry 记录,允许客户端重试。
- 到期且仍在 registry 中的孤儿上传由 GC 删除。
- Gateway 重启后 registry 丢失;残留文件由启动时按文件年龄限量清理。它们没有进入消息,因此可以删除。
一旦路径已经写入消息,该文件不再由“pending upload GC”追踪。项目不承诺它的保存期限;用户、工具、外部清理任务或后续保留策略都可以移动或删除它。
GC 任务必须由 TaskSupervisor 持有,观察取消,并限制每轮扫描数和总执行时间。
5. HTTP API
路由加入现有受保护 Router,复用 Cookie/Bearer 鉴权。
5.1 上传
POST /api/chat/{client_id}/uploads
Content-Type: multipart/form-data
file=<binary>
成功返回 201 Created 和 UploadDescriptor。上传 handler:
- 校验与 WebSocket 相同规则的
client_id; - 流式读取 multipart,不聚合整个文件;
- 边写临时文件边计算实际大小,超限立即停止并清理;
- 清理文件名,限制 UTF-8 长度,去除路径、控制字符和保留名称;
- MIME 以有限 magic-byte 检测为主、扩展名为辅,未知为
application/octet-stream; sync_all后原子 rename,再注册 upload ID;注册失败删除文件;- 每个身份/chat scope 使用 semaphore 限制并发上传。
主要错误为 400 非法请求、401 未认证、413 超限、429 并发或暂存量超限、507 空间不足。
5.2 下载与预览
GET /api/chat/{client_id}/sessions/{session_id}/messages/{message_id}/attachments/{index}
GET /api/chat/{client_id}/sessions/{session_id}/messages/{message_id}/attachments/{index}?disposition=inline
handler 必须:
- 验证设备身份和
client_id; - 使用 Session/Storage API 验证该 session 属于
cli_chat:{client_id}; - 查询指定 message,并从其
media_refs[index]取得内部路径; - 重新检查路径当前指向普通文件;不存在、已移动、是目录或不可读时返回
404或410; - 流式读取当前文件内容,不在打开前把整个文件载入内存。
响应设置 Content-Length、推断的 Content-Type、安全编码的 Content-Disposition、X-Content-Type-Options: nosniff 和 Cache-Control: private, no-store。inline 仅允许白名单图片/音视频 MIME,其余强制下载。
下载路由不能接受 path 查询参数。错误响应和日志都不能包含服务端路径。路径在消息存在不等于文件存在,这属于正常的可预期状态。
client_id 是非秘密 chat scope,可以出现在路径;Bearer token 仍只能进入 Authorization header,不能放入 URL。WebUI <img> 使用同源 HttpOnly Cookie,TUI 使用 Bearer header。
6. 入站处理
cli_chat 收到带上传的 user_input 后:
- 允许正文为空,但正文和
upload_ids不能同时为空; - 校验数量、去重并批量验证所有上传属于
client.chat_id; - 检查上传未过期、文件当前仍是普通文件且总大小未超限;
- 原子取得 upload IDs,转换为带本地路径的
MediaItem; - 走现有 Bus → SessionManager → session worker;
- 成功发布到 inbound Bus 后消费 registry 记录;发布失败则恢复记录并返回现有 error 帧;
- worker 按现有流程把路径写入
messages.media_refs。
为此,SessionManager::handle_message 应返回结构化的“已入队/拒绝”,不能再把队列满包装为普通命令输出。Gateway 主循环仍只等待快速入队,不等待模型。
Slash command 不接受上传。正文识别为 slash command 且带 upload_ids 时返回 UPLOADS_NOT_ALLOWED_FOR_COMMAND,避免文件被静默忽略。
7. 出站和历史
7.1 出站文件
Agent 继续使用 send_message(files=...)。文件路径由服务端内部产生,不需要复制到附件资产目录:
SendMessageTool将路径转换为现有MediaItem;- 发送前检查每个路径当前是可读普通文件且未超过发送限制;
OutboundMessenger按现有逻辑把路径写入 assistant 消息的media_refs;OutboundMessage.media传给CliChatChannel;cli_chat只返回文件名、类型和消息内 index,不返回路径;- 用户点击下载时再次读取当前路径,因此发送后路径变化或文件删除会导致下载失败。
多个文件建议在发送前全部校验;校验通过后仍可能发生 TOCTOU 删除,下载端必须把这种情况作为正常不可用处理。
即时 AssistantResponse 需要携带已持久化消息的真实 message_id,不能继续使用与历史无关的临时短 ID,否则客户端无法构造安全下载地址。若当前投递链路暂时拿不到 message ID,客户端收到响应后立即刷新历史,以历史记录为附件展示权威来源。
7.2 历史读取
HistoryMessage.attachments 从已保存的 media_refs 映射而来。映射只做字符串解析和 basename/MIME 推断,不访问文件系统,因此:
- 历史查询性能不依赖附件文件数量和存储速度;
- 文件缺失不会导致历史帧失败;
- 展示附件不代表下载一定成功;
- 旧
media_refs数据无需 schema 迁移即可生效。
客户端在下载返回不可用后只更新本地 UI 状态,不修改历史消息。
7.3 Agent 上下文中的路径
构造发给模型的用户消息时,无论附件类型是否被模型原生支持,都把结构化附件清单和用户正文放在同一个文本内容块中。清单包含安全文件名、扩展名、媒体类型、MIME、当前可取得的大小、Gateway 内部路径和内容交付状态,例如:
[随本条用户消息同时提交的附件。path 是 Gateway 内部存储路径,可供文件工具读取;content_delivery 说明附件内容是否另以模型原生内容块提供。]
[
{
"name": "report.pdf",
"extension": "pdf",
"media_type": "file",
"mime_type": "application/pdf",
"size_bytes": 12345,
"path": "/gateway/media/report.pdf",
"content_delivery": "content is not embedded in this model request; path remains available to file tools"
}
]
随后再追加图片等原生多模态 content block。这样支持视觉输入的模型既能看到图片内容,也知道其文件路径;普通文件同样可以由 LLM 使用 file_read、Bash 等工具读取。附件路径只进入服务端到 Provider 的模型上下文,不进入 WebSocket/HTTP 客户端响应。
file_read 读取 PNG、JPEG、GIF 或 WebP 时不把 Base64 当作工具文本返回,而是通过结构化工具媒体侧通道返回规范化路径。AgentLoop 根据当前模型能力构造原生图片块;只有最新连续工具结果批次携带图片内容,旧结果仅保留文本路径。OpenAI-compatible 请求把工具图片汇总为工具批次后的临时 user 多模态消息,Anthropic 请求把图片放入对应 tool_result。这些 Provider 请求视图不写入历史,消息仍只持久化路径引用。
历史路径已经失效时,清单仍反映消息所记录的原路径;工具读取失败应作为普通、可解释的“文件已移动或删除”结果返回,不能导致 Agent loop panic。
8. WebUI 交互
Composer 增加回形针按钮、隐藏多选 file input,并支持拖放和剪贴板图片。待发送区域展示文件名、大小、进度、失败、重试和移除。
- 选中文件后立即通过 HTTP 上传,并发不超过 3;
- 全部上传成功后才可发送;文本为空但有上传时允许发送;
- 上传或消息提交返回 error 时给出明确提示,过期项要求重新上传;
- 切换 session 时按 session 保存内存草稿,刷新页面后的孤儿上传由 TTL 清理;
- 历史消息在正文下显示附件卡;图片可尝试 inline 预览;
- 下载/预览为 404/410 时显示“原文件已移动或删除”;
- 不把文件字节、服务端路径或 upload ID 长期写入 localStorage;
- object URL 在移除或销毁时 revoke。
Markdown 继续走现有 sanitizer;附件使用结构化字段渲染,文件名不能拼接成 HTML。
9. TUI 交互
TUI 保存 Gateway HTTP base URL、Bearer token 和 client_id:
Ctrl+F打开“添加附件”路径输入框;相对路径按 TUI 启动目录解释;- TUI 读取本机文件并通过 HTTP 上传,不能把客户端路径直接传给 Gateway;
- Composer 上方显示待发送文件和上传状态,支持移除;
- 消息显示
[附件 1] report.pdf;按F2将历史中最近的附件下载到当前目录; - 下载先写目标目录临时文件,再原子 rename,默认不覆盖已有文件;
- 服务端返回不可用时显示“原文件已移动或删除”;
- HTTP 上传/下载通过受管理任务和内部 channel 回报进度,不阻塞键盘或 WebSocket;退出时取消并有界 join。
不新增伪服务端 /attach、/download 命令,避免与 get_slash_commands 的权威列表冲突。
10. 安全与资源边界
- 路径穿越:上传目标路径完全由 Gateway 生成;清理后的名字只用于展示。
- 任意文件读取:客户端只提交 upload ID;下载只通过已归属该 session/message 的
media_refs定位,不接受路径参数。 - 跨 scope:上传消费、session 查询和下载都验证
cli_chat:{client_id}。 - 符号链接与特殊文件:上传落盘为普通文件;出站和下载每次打开前拒绝目录、设备、FIFO 等非普通文件。对于工具路径是否跟随 symlink,应保留现有工具权限语义并在打开后检查 metadata。
- 资源耗尽:限制单文件、单消息总量、文件数、并发上传、multipart body、pending TTL 和每轮 GC 数量。
- 内容伪装:不信任客户端 MIME;inline 采用白名单并设置
nosniff。 - 恶意文件:Gateway 不自动解压或解析任意文档;图片进入模型前另设格式、像素和编码后大小限制。
- 提示注入:附件是不可信用户内容;普通文件只提供路径提示,由 Agent 显式调用工具读取。
- 路径泄漏:协议、HTTP 错误和常规日志不得包含完整服务端路径。
- 传输安全:非回环部署仍要求外层 HTTPS/WSS;设备配对本身不提供机密性。
11. 生命周期与并发
UploadRegistry 的 pending GC 和启动残留清理由 TaskSupervisor 持有,观察 cancellation 并有硬超时。已经进入消息的路径不属于该 GC。
HTTP 连接断开或 Gateway shutdown 时停止读写并删除未提交 .part 文件。任何 session mutex 都不得跨上传、下载、文件 stat/open 或数据库 I/O 持有。慢文件 I/O 完成后提交会话结果时继续验证 worker_generation/state_version。
12. 分阶段实施
阶段 A:协议与服务端
- 新增 file-transfer 配置、UploadRegistry、HTTP 上传/下载路由和 pending GC。
- 扩展 WebSocket capability、upload IDs 和附件描述,并复用现有 error 帧。
cli_chat入站恢复media,出站和历史不再丢弃media_refs。- 给 Storage/SessionManager 增加按 scope 查询单条消息的安全 API。
本阶段不修改数据库 schema。
阶段 B:WebUI
实现选择/拖放/粘贴、上传进度、附件卡、预览和不可用提示;运行 npm run check、npm run build 和 cargo build。
阶段 C:TUI
实现路径 modal、HTTP 传输任务、状态渲染和安全下载;补齐断线、退出取消和旧 Gateway capability 降级。
阶段 D:收口
完善 send_message(files=...) 校验、可观测性和保留策略说明。实现后同步更新 README.md、docs/ARCHITECTURE.md、AGENTS.md 和配置示例。
13. 测试与验收
重点测试:
- 上传分片计数、超限中止、文件名清理、MIME 映射和 orphan 清理;
- upload ID 的 scope、过期、重复消费、批量原子取得与入队失败恢复;
- 文本+附件、纯附件、slash command 带附件拒绝和 session 队列满;
- 历史
media_refs→MessageAttachment映射绝不泄露路径; - 文件存在时可下载,路径删除/移动/变成目录时返回不可用且历史仍正常;
- 跨 client/session/message/index 访问被拒绝且不泄露文件是否存在;
- Cookie 与 Bearer 认证、UTF-8 文件名、inline 白名单和下载中断;
- Agent 图片输入和
send_message(files)的在线/历史展示; - Gateway shutdown 时传输与 GC 有界退出,无裸后台任务;
- 旧文本客户端继续工作,新 TUI 对旧 Gateway 安全降级。
验收标准:
- WebUI/TUI 可发送限制内的本地文件,不使用 WebSocket base64。
- 纯附件消息可靠入队,失败有明确、可重试反馈。
- 在线响应和历史均能显示附件引用;文件仍存在时可下载。
- 文件路径变化或删除后允许下载失败,但历史消息、文本和其他附件不受影响。
- 客户端看不到服务端路径,也不能利用接口读取未归属当前 chat scope 的路径。
- 同一 session 顺序、跨 session 并发和现有锁/取消不变量保持不变。
Rust 实现完成后运行目标测试、cargo test --lib、离线协议/调度集成测试、Clippy warnings denied 和 cargo build;WebUI 额外执行独立 check/build。