381 lines
19 KiB
Markdown
381 lines
19 KiB
Markdown
# 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。
|