# 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 传短期上传引用和消息事件”: ```mermaid 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`: ```rust 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 对客户端返回的描述不含路径: ```rust 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 可解析: ```rust WsInbound::UserInput { content: String, upload_ids: Vec, // serde(default),最多 8 个 client_message_id: Option, // 现有字段保留 } HistoryMessage { // 现有字段保留 attachments: Vec, // serde(default) } WsOutbound::AssistantResponse { // 现有字段保留 attachments: Vec, // serde(default) } ``` 上传引用无效、跨 scope、重复/过期、文件数量或总量超限时,Gateway 使用现有 `error` 帧返回明确原因。成功提交沿用普通消息的处理/响应语义,不新增附件专用确认帧。 `SessionEstablished` 增加默认空的 `capabilities`;支持本方案的 Gateway 返回 `file_transfer_v1`。新 TUI 连接旧 Gateway 时隐藏文件功能并提示升级,旧客户端忽略新增字段。 ## 4. UploadRegistry 新增轻量 `UploadRegistry`,由 `GatewayState` 持有,只管理尚未提交到消息的上传: ```rust 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`: ```json { "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 } ``` 文件布局不使用用户文件名定位: ```text cli_chat//.upload cli_chat/.staging/.part ``` 原始文件名只保存在 registry 中;消息最终记录的路径可以带安全扩展名,但不能包含未经清理的目录组件。 状态规则: 1. 上传完成后为 `Pending`。 2. `user_input` 批量校验所有 upload ID 后,从 registry 原子取出。 3. 成功发布到 inbound Bus 后不删除文件;后续消息持有路径引用。 4. 发布到 inbound Bus 失败时恢复 registry 记录,允许客户端重试。 5. 到期且仍在 registry 中的孤儿上传由 GC 删除。 6. Gateway 重启后 registry 丢失;残留文件由启动时按文件年龄限量清理。它们没有进入消息,因此可以删除。 一旦路径已经写入消息,该文件不再由“pending upload GC”追踪。项目不承诺它的保存期限;用户、工具、外部清理任务或后续保留策略都可以移动或删除它。 GC 任务必须由 `TaskSupervisor` 持有,观察取消,并限制每轮扫描数和总执行时间。 ## 5. HTTP API 路由加入现有受保护 Router,复用 Cookie/Bearer 鉴权。 ### 5.1 上传 ```http POST /api/chat/{client_id}/uploads Content-Type: multipart/form-data file= ``` 成功返回 `201 Created` 和 `UploadDescriptor`。上传 handler: 1. 校验与 WebSocket 相同规则的 `client_id`; 2. 流式读取 multipart,不聚合整个文件; 3. 边写临时文件边计算实际大小,超限立即停止并清理; 4. 清理文件名,限制 UTF-8 长度,去除路径、控制字符和保留名称; 5. MIME 以有限 magic-byte 检测为主、扩展名为辅,未知为 `application/octet-stream`; 6. `sync_all` 后原子 rename,再注册 upload ID;注册失败删除文件; 7. 每个身份/chat scope 使用 semaphore 限制并发上传。 主要错误为 `400` 非法请求、`401` 未认证、`413` 超限、`429` 并发或暂存量超限、`507` 空间不足。 ### 5.2 下载与预览 ```http 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 必须: 1. 验证设备身份和 `client_id`; 2. 使用 Session/Storage API 验证该 session 属于 `cli_chat:{client_id}`; 3. 查询指定 message,并从其 `media_refs[index]` 取得内部路径; 4. 重新检查路径当前指向普通文件;不存在、已移动、是目录或不可读时返回 `404` 或 `410`; 5. 流式读取当前文件内容,不在打开前把整个文件载入内存。 响应设置 `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 `` 使用同源 HttpOnly Cookie,TUI 使用 Bearer header。 ## 6. 入站处理 `cli_chat` 收到带上传的 `user_input` 后: 1. 允许正文为空,但正文和 `upload_ids` 不能同时为空; 2. 校验数量、去重并批量验证所有上传属于 `client.chat_id`; 3. 检查上传未过期、文件当前仍是普通文件且总大小未超限; 4. 原子取得 upload IDs,转换为带本地路径的 `MediaItem`; 5. 走现有 Bus → SessionManager → session worker; 6. 成功发布到 inbound Bus 后消费 registry 记录;发布失败则恢复记录并返回现有 error 帧; 7. 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=...)`。文件路径由服务端内部产生,不需要复制到附件资产目录: 1. `SendMessageTool` 将路径转换为现有 `MediaItem`; 2. 发送前检查每个路径当前是可读普通文件且未超过发送限制; 3. `OutboundMessenger` 按现有逻辑把路径写入 assistant 消息的 `media_refs`; 4. `OutboundMessage.media` 传给 `CliChatChannel`; 5. `cli_chat` 只返回文件名、类型和消息内 index,不返回路径; 6. 用户点击下载时再次读取当前路径,因此发送后路径变化或文件删除会导致下载失败。 多个文件建议在发送前全部校验;校验通过后仍可能发生 TOCTOU 删除,下载端必须把这种情况作为正常不可用处理。 即时 `AssistantResponse` 需要携带已持久化消息的真实 `message_id`,不能继续使用与历史无关的临时短 ID,否则客户端无法构造安全下载地址。若当前投递链路暂时拿不到 message ID,客户端收到响应后立即刷新历史,以历史记录为附件展示权威来源。 ### 7.2 历史读取 `HistoryMessage.attachments` 从已保存的 `media_refs` 映射而来。映射只做字符串解析和 basename/MIME 推断,不访问文件系统,因此: - 历史查询性能不依赖附件文件数量和存储速度; - 文件缺失不会导致历史帧失败; - 展示附件不代表下载一定成功; - 旧 `media_refs` 数据无需 schema 迁移即可生效。 客户端在下载返回不可用后只更新本地 UI 状态,不修改历史消息。 ### 7.3 Agent 上下文中的路径 构造发给模型的用户消息时,无论附件类型是否被模型原生支持,都先增加一个结构化附件清单。清单包含安全文件名、媒体类型和 Gateway 内部路径,例如: ```text [附件清单:path 是 Gateway 内部存储路径,可供文件工具读取] [ { "name": "report.pdf", "media_type": "file", "path": "/gateway/media/report.pdf" } ] ``` 随后再追加图片等原生多模态 content block。这样支持视觉输入的模型既能看到图片内容,也知道其文件路径;普通文件同样可以由 LLM 使用 `file_read`、Bash 等工具读取。附件路径只进入服务端到 Provider 的模型上下文,不进入 WebSocket/HTTP 客户端响应。 历史路径已经失效时,清单仍反映消息所记录的原路径;工具读取失败应作为普通、可解释的“文件已移动或删除”结果返回,不能导致 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:协议与服务端 1. 新增 file-transfer 配置、UploadRegistry、HTTP 上传/下载路由和 pending GC。 2. 扩展 WebSocket capability、upload IDs 和附件描述,并复用现有 error 帧。 3. `cli_chat` 入站恢复 `media`,出站和历史不再丢弃 `media_refs`。 4. 给 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 安全降级。 验收标准: 1. WebUI/TUI 可发送限制内的本地文件,不使用 WebSocket base64。 2. 纯附件消息可靠入队,失败有明确、可重试反馈。 3. 在线响应和历史均能显示附件引用;文件仍存在时可下载。 4. 文件路径变化或删除后允许下载失败,但历史消息、文本和其他附件不受影响。 5. 客户端看不到服务端路径,也不能利用接口读取未归属当前 chat scope 的路径。 6. 同一 session 顺序、跨 session 并发和现有锁/取消不变量保持不变。 Rust 实现完成后运行目标测试、`cargo test --lib`、离线协议/调度集成测试、Clippy warnings denied 和 `cargo build`;WebUI 额外执行独立 check/build。