# 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<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` 持有，只管理尚未提交到消息的上传：

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

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

file=<binary>
```

成功返回 `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 `<img>` 使用同源 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 上下文中的路径

构造发给模型的用户消息时，无论附件类型是否被模型原生支持，都把结构化附件清单和用户正文放在同一个文本内容块中。清单包含安全文件名、扩展名、媒体类型、MIME、当前可取得的大小、Gateway 内部路径和内容交付状态，例如：

```text
[随本条用户消息同时提交的附件。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：协议与服务端

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。
