PicoBot/docs/FILE_TRANSFER_DESIGN.md

19 KiB
Raw Blame History

WebUI 与 TUI 文件收发设计

1. 目标与非目标

当前统一消息链路已经有 MediaItemMediaRefmessages.media_refsAgent 能处理图片,飞书及 send_message(files=...) 也能收发媒体。缺口集中在 cli_chatWebSocket 入站把 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 中;消息最终记录的路径可以带安全扩展名,但不能包含未经清理的目录组件。

状态规则:

  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 上传

POST /api/chat/{client_id}/uploads
Content-Type: multipart/form-data

file=<binary>

成功返回 201 CreatedUploadDescriptor。上传 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 下载与预览

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. 重新检查路径当前指向普通文件;不存在、已移动、是目录或不可读时返回 404410
  5. 流式读取当前文件内容,不在打开前把整个文件载入内存。

响应设置 Content-Length、推断的 Content-Type、安全编码的 Content-DispositionX-Content-Type-Options: nosniffCache-Control: private, no-storeinline 仅允许白名单图片/音视频 MIME其余强制下载。

下载路由不能接受 path 查询参数。错误响应和日志都不能包含服务端路径。路径在消息存在不等于文件存在,这属于正常的可预期状态。

client_id 是非秘密 chat scope可以出现在路径Bearer token 仍只能进入 Authorization header不能放入 URL。WebUI <img> 使用同源 HttpOnly CookieTUI 使用 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 上下文中的路径

构造发给模型的用户消息时无论附件类型是否被模型原生支持都把结构化附件清单和用户正文放在同一个文本内容块中。清单包含安全文件名、扩展名、媒体类型、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 数量。
  • 内容伪装:不信任客户端 MIMEinline 采用白名单并设置 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。

阶段 BWebUI

实现选择/拖放/粘贴、上传进度、附件卡、预览和不可用提示;运行 npm run checknpm run buildcargo build

阶段 CTUI

实现路径 modal、HTTP 传输任务、状态渲染和安全下载;补齐断线、退出取消和旧 Gateway capability 降级。

阶段 D收口

完善 send_message(files=...) 校验、可观测性和保留策略说明。实现后同步更新 README.mddocs/ARCHITECTURE.mdAGENTS.md 和配置示例。

13. 测试与验收

重点测试:

  • 上传分片计数、超限中止、文件名清理、MIME 映射和 orphan 清理;
  • upload ID 的 scope、过期、重复消费、批量原子取得与入队失败恢复
  • 文本+附件、纯附件、slash command 带附件拒绝和 session 队列满;
  • 历史 media_refsMessageAttachment 映射绝不泄露路径;
  • 文件存在时可下载,路径删除/移动/变成目录时返回不可用且历史仍正常;
  • 跨 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 buildWebUI 额外执行独立 check/build。