diff --git a/AGENTS.md b/AGENTS.md index 5200274..1a4c8e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,6 +83,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del - **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact - **WebUI slash completion** must consume the existing `get_slash_commands` WebSocket response; do not duplicate the backend command list in frontend source - **WebUI chat rendering** sanitizes Markdown before inserting HTML; session history must preserve structured tool-call metadata so calls and results remain independently collapsible +- **WebUI/TUI file transfer** streams bytes over authenticated HTTP and sends only short-lived upload IDs/attachment metadata over WebSocket; messages persist local media paths without guaranteeing later availability, and client responses must never expose those paths - **WebUI authentication** protects every management API and `/ws`; only static pairing assets, public health/status, and pairing submission may bypass device auth. Pair-code issuance requires both a real loopback peer and the filesystem-held admin token; never put bearer tokens in URLs or logs - **Providers** are pure HTTP clients; no bus/session/channel awareness - **Tools** are executed by `AgentLoop`; they receive raw arguments and return string results diff --git a/Cargo.toml b/Cargo.toml index 657fa99..dd10b00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,10 +11,10 @@ serde_json = "1.0" async-trait = "0.1" thiserror = "2.0.18" tokio = { version = "1.52", features = ["full"] } -tokio-util = { version = "0.7", features = ["rt"] } +tokio-util = { version = "0.7", features = ["rt", "io"] } dashmap = "6.1" uuid = { version = "1.23", features = ["v4"] } -axum = { version = "0.8", features = ["ws"] } +axum = { version = "0.8", features = ["ws", "multipart"] } tokio-tungstenite = { version = "0.29.0", features = ["rustls-tls-webpki-roots", "rustls"] } futures-util = "0.3" clap = { version = "4", features = ["derive"] } diff --git a/README.md b/README.md index 71b1351..83e6d25 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,7 @@ picobot pair WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件,提供: - 在线聊天、会话创建/切换、历史回放、Markdown 消息、可折叠工具调用卡片,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。 +- 文件选择、拖放和剪贴板图片上传;消息中的附件可预览或下载。附件按服务端路径引用,原文件移动或删除后历史附件可能不可用。 - 可持久化的浅色/深色主题,首次访问时跟随系统偏好。 - Cron 定时任务、最近运行记录和后台子任务状态。 - 当前聊天 session 的可展开 Todo 侧栏;计划变化时自动展开,其他 session 的变化显示未读提示。 @@ -156,7 +157,7 @@ picobot service uninstall unit 位于 `~/.config/systemd/user/picobot.service`,以执行 `service install` 时的当前目录作为初始工作目录。服务异常退出时由 systemd 自动重启;`stop` 和 `restart` 会通过 SIGTERM 触发 Gateway 的有界优雅关停。 -TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因此关闭并重新打开客户端后会恢复同一组 dialog 和最近使用的会话。界面支持历史回放、会话列表与归档筛选、命令补全、Unicode/中文编辑、括号粘贴和多行输入。 +TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因此关闭并重新打开客户端后会恢复同一组 dialog 和最近使用的会话。界面支持历史回放、会话列表与归档筛选、命令补全、Unicode/中文编辑、括号粘贴、多行输入和文件传输。 常用快捷键: @@ -168,6 +169,9 @@ TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因 | `Ctrl+R` / `Ctrl+A` / `Ctrl+D` | 重命名 / 归档 / 删除所选会话 | | `Ctrl+L` / `Ctrl+O` | 清空历史 / 显示归档会话 | | `Enter` / `Shift+Enter` | 发送 / 换行 | +| `Ctrl+F` / `F2` | 添加附件 / 下载历史中最近的附件 | + +WebUI/TUI 上传文件默认保存到 `~/.picobot/media/cli_chat`,单文件上限 25 MiB;可通过 `gateway.file_transfer` 调整目录、上限和待发送上传的有效期。消息只保存文件路径,不保证文件被移动或删除后仍能下载。 | `PageUp` / `PageDown` | 滚动对话历史 | | 连按两次 `Ctrl+C` | 退出客户端 | @@ -402,6 +406,7 @@ docs/ 面向维护者和 Agent 的架构与开发文档 ## 进一步阅读 - [维护者架构文档](docs/ARCHITECTURE.md) +- [WebUI 与 TUI 文件收发设计](docs/FILE_TRANSFER_DESIGN.md) - [内置 Skill:架构机制](resources/skills/about-picobot/references/architecture.md) - [配置说明](resources/skills/about-picobot/references/config.md) - [命令说明](resources/skills/about-picobot/references/commands.md) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4560363..45b9018 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -199,6 +199,8 @@ WebSocket 每个客户端的 writer task 是连接局部任务,由连接 handl Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript,不依赖外部 CDN 或前端运行服务。前端源码位于 `webui/`,由 Svelte 5 + Vite 构建,Bits UI 提供无样式的可访问组件原语。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`,Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws` 和 `cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane;会话历史帧保留工具调用 ID、名称、参数和工具结果角色,WebUI 在本轮完成后刷新历史并将其渲染为默认折叠的工具卡片;聊天页的 Todo 侧栏默认隐藏,按 session 保存快照和未读状态,并通过结构化 `session_plan`/`plan_updated` 帧刷新,计划变化不会写入聊天历史;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单,不在前端重复定义;WebUI 不直接调用 Provider 或 SessionManager。 +WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只携带短期 `upload_id` 和结构化附件描述。`UploadRegistry` 在内存中按 `cli_chat` chat scope 校验并消费待发送上传;消息继续以 `media_refs` 保存 Gateway 本地路径,不建立永久附件资产。下载接口必须通过 client、session、message 和附件序号反查路径,不能接受客户端路径。历史附件路径失效属于正常状态,不得影响历史文本读取。待发送但未进入消息的上传由 `TaskSupervisor` 所有的限时清理任务回收。Agent 上下文会为所有媒体注入内部路径清单,客户端响应不得暴露该路径。 + `AuthManager` 默认保护所有管理 API 和 `/ws`。静态资源、`/health`、`/api/auth/status` 与 `/api/auth/pair` 保持公开,使未配对浏览器只能加载配对界面。`picobot pair` 使用权限为 `0600` 的本机管理密钥调用仅接受真实回环连接的 `/api/auth/code`;反向代理即使从回环连接也无法在没有该密钥时签发代码。配对码为 8 位、5 分钟有效、单次消费,并按来源实施失败锁定。浏览器收到 HttpOnly、SameSite=Strict Cookie,CLI 使用 Bearer token;服务端仅持久化 SHA-256 哈希。`--revoke-all` 的持久化成功后才清空内存令牌,活动 WebSocket 每 5 秒复核身份并回收已撤销连接。 同源 `/api/*` 管理接口只提供显式白名单能力: @@ -207,6 +209,7 @@ Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript,不依赖外 - `USER.md`、`AGENTS.md` 只允许固定文件名,不接受任意路径。 - 日志、记忆、任务和运行记录均限制单次返回数量;日志目录固定为 `~/.picobot/logs`。 - 任务与记忆读取复用 Storage API,不允许 WebUI 直接持有 SQLite 连接或拼接任意查询。 +- 文件上传/下载复用设备鉴权并校验 chat/session/message scope;客户端不能提交或获得服务端路径,inline 预览仅允许安全 MIME 白名单。 - 前端依赖只存在于源码构建阶段;生产页面不加载 CDN。`build.rs` 以 `package-lock.json` 的依赖 stamp 判断是否需要 `npm ci`,并依靠 Cargo `rerun-if-changed` 避免后端代码变化触发前端重建。前端开发仍须运行 `npm run check`,并以 `cargo build` 验证最终嵌入路径。 配对鉴权只证明设备持有凭据,不提供机密性。非回环部署仍必须由反向代理或其他外层提供 TLS;显式设置 `gateway.require_pairing=false` 会恢复无鉴权模式,仅适合隔离环境。 diff --git a/docs/FILE_TRANSFER_DESIGN.md b/docs/FILE_TRANSFER_DESIGN.md new file mode 100644 index 0000000..8cd5102 --- /dev/null +++ b/docs/FILE_TRANSFER_DESIGN.md @@ -0,0 +1,374 @@ +# 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。 diff --git a/resources/templates/config.example.json b/resources/templates/config.example.json index c4dbbca..fb18857 100644 --- a/resources/templates/config.example.json +++ b/resources/templates/config.example.json @@ -49,7 +49,15 @@ "gateway": { "host": "127.0.0.1", "port": 19876, - "require_pairing": true + "require_pairing": true, + "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 + } }, "client": { "gateway_url": "ws://127.0.0.1:19876/ws" diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 88468b5..a242ee4 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -28,13 +28,53 @@ fn build_content_blocks( ) -> Vec { let mut blocks = Vec::new(); - if !text.is_empty() { - blocks.push(ContentBlock::text(text)); - } - if !media_refs.is_empty() { + let attachments = media_refs + .iter() + .map(|media_ref| { + let path = std::path::Path::new(&media_ref.path); + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| media_ref.path.clone()); + let extension = path + .extension() + .map(|extension| extension.to_string_lossy().into_owned()); + let mime_type = mime_guess::from_path(path) + .first_or_octet_stream() + .to_string(); + let size_bytes = std::fs::metadata(path).ok().map(|metadata| metadata.len()); + let native_input = input_types.contains(&media_ref.media_type) + && registry.supports(&media_ref.media_type); + serde_json::json!({ + "name": name, + "extension": extension, + "media_type": media_ref.media_type, + "mime_type": mime_type, + "size_bytes": size_bytes, + "path": media_ref.path, + "content_delivery": if native_input { + "also included as a model-native content block" + } else { + "content is not embedded; use a file tool with path to inspect it" + }, + }) + }) + .collect::>(); + let manifest = serde_json::Value::Array(attachments).to_string(); + let message_text = if text.is_empty() { + format!( + "[用户发送了以下附件。path 是 Gateway 内部存储路径,可供文件工具读取;附件内容未必已嵌入模型输入。]\n{manifest}" + ) + } else { + format!( + "{text}\n\n[随本条用户消息同时提交的附件。path 是 Gateway 内部存储路径,可供文件工具读取;content_delivery 说明附件内容是否另以模型原生内容块提供。]\n{manifest}" + ) + }; + blocks.push(ContentBlock::text(message_text)); + for mr in media_refs { - if input_types.contains(&mr.media_type) { + if input_types.contains(&mr.media_type) && registry.supports(&mr.media_type) { match registry.handle(&mr.media_type, &mr.path) { Ok(content_blocks) => blocks.extend(content_blocks), Err(e) => { @@ -55,14 +95,12 @@ fn build_content_blocks( path = %mr.path, media_type = %mr.media_type, model_input_types = ?input_types, - "Media type not supported by model, using text placeholder" + "Media type not supported by model; attachment manifest remains available" ); - blocks.push(ContentBlock::text(format!( - "[用户发来了一个文件: {}]", - mr.path - ))); } } + } else if !text.is_empty() { + blocks.push(ContentBlock::text(text)); } if blocks.is_empty() { @@ -868,12 +906,63 @@ mod tests { ®istry, ); - assert!( - matches!(blocks.first(), Some(ContentBlock::Text { text }) if text == "先看这段文字") + assert_eq!(blocks.len(), 1); + assert!(matches!(blocks.first(), Some(ContentBlock::Text { text }) + if text.starts_with("先看这段文字\n\n") + && text.contains("随本条用户消息同时提交的附件") + && text.contains("missing.png") + && text.contains("\"media_type\":\"image\"") + && text.contains("content is not embedded"))); + } + + #[test] + fn test_build_content_blocks_describes_attachment_only_message() { + let registry = MediaHandlerRegistry::new(); + let blocks = build_content_blocks( + "", + &[MediaRef { + path: "/tmp/report.docx".to_string(), + media_type: "file".to_string(), + }], + &[], + ®istry, ); - assert!( - matches!(blocks.get(1), Some(ContentBlock::Text { text }) if text.contains("用户发来了一个文件")) + + assert_eq!(blocks.len(), 1); + assert!(matches!(blocks.first(), Some(ContentBlock::Text { text }) + if text.starts_with("[用户发送了以下附件") + && text.contains("report.docx") + && text.contains("application/vnd.openxmlformats-officedocument.wordprocessingml.document") + && text.contains("\"extension\":\"docx\"") + && text.contains("\"size_bytes\":null") + && text.contains("content is not embedded"))); + } + + #[test] + fn test_build_content_blocks_includes_path_for_supported_images() { + use std::io::Write; + + let mut image = tempfile::Builder::new().suffix(".png").tempfile().unwrap(); + image.write_all(b"image bytes").unwrap(); + let path = image.path().to_string_lossy().into_owned(); + let registry = MediaHandlerRegistry::with_defaults(); + + let blocks = build_content_blocks( + "分析图片", + &[MediaRef { + path: path.clone(), + media_type: "image".to_string(), + }], + &["image".to_string()], + ®istry, ); + + assert!(matches!(blocks.first(), Some(ContentBlock::Text { text }) + if text.starts_with("分析图片\n\n") + && text.contains("随本条用户消息同时提交的附件") + && text.contains(&path) + && text.contains("model-native content block"))); + assert!(matches!(blocks.get(1), Some(ContentBlock::ImageUrl { .. }))); } } diff --git a/src/agent/media_handler.rs b/src/agent/media_handler.rs index 96b412c..5765bb0 100644 --- a/src/agent/media_handler.rs +++ b/src/agent/media_handler.rs @@ -99,6 +99,10 @@ impl MediaHandlerRegistry { } } + pub fn supports(&self, media_type: &str) -> bool { + self.handlers.contains_key(media_type) + } + pub fn with_defaults() -> Self { let mut reg = Self::new(); reg.register(Box::new(ImageHandler)); diff --git a/src/channels/cli_chat.rs b/src/channels/cli_chat.rs index c3e2ad9..031cf4e 100644 --- a/src/channels/cli_chat.rs +++ b/src/channels/cli_chat.rs @@ -4,7 +4,10 @@ use std::sync::Arc; use tokio::sync::{Mutex, mpsc}; use crate::bus::{ControlMessage, InboundMessage, MessageBus, OutboundMessage}; -use crate::protocol::{HistoryMessage, SlashCommandInfo, WsInbound, WsOutbound, parse_inbound}; +use crate::gateway::uploads::UploadRegistry; +use crate::protocol::{ + HistoryMessage, MessageAttachment, SlashCommandInfo, WsInbound, WsOutbound, parse_inbound, +}; use crate::session::{SessionCommand, SessionEvent, UnifiedSessionId}; use super::base::{Channel, ChannelError}; @@ -32,6 +35,7 @@ impl Client { pub struct CliChatChannel { bus: std::sync::Mutex>>, clients: Mutex>>, + uploads: UploadRegistry, } impl Default for CliChatChannel { @@ -42,9 +46,14 @@ impl Default for CliChatChannel { impl CliChatChannel { pub fn new() -> Self { + Self::with_upload_registry(UploadRegistry::disabled()) + } + + pub fn with_upload_registry(uploads: UploadRegistry) -> Self { Self { bus: std::sync::Mutex::new(None), clients: Mutex::new(HashMap::new()), + uploads, } } @@ -166,21 +175,49 @@ impl CliChatChannel { match inbound { WsInbound::UserInput { - content, chat_id, .. + content, + upload_ids, + chat_id, + .. } => { // All messages (including slash commands) go through the normal inbound flow // SessionManager handles session creation/reuse internally + if content.trim().is_empty() && upload_ids.is_empty() { + return Err(ChannelError::Other("Message is empty".to_string())); + } + if !upload_ids.is_empty() + && crate::channels::parse_slash_command(&content).is_some() + { + return Err(ChannelError::Other( + "Attachments cannot be sent with slash commands".to_string(), + )); + } + let target_chat_id = chat_id.unwrap_or_else(|| client.chat_id.clone()); + if target_chat_id != client.chat_id { + return Err(ChannelError::Other( + "Chat does not belong to this client".to_string(), + )); + } + let uploads = self + .uploads + .take_many(&client.chat_id, &upload_ids) + .await + .map_err(|error| ChannelError::Other(error.to_string()))?; + let media = uploads.iter().map(UploadRegistry::as_media).collect(); let msg = InboundMessage { channel: self.name().to_string(), sender_id: "cli".to_string(), - chat_id: chat_id.unwrap_or_else(|| client.chat_id.clone()), + chat_id: target_chat_id, content, timestamp: crate::bus::message::current_timestamp(), - media: Vec::new(), + media, metadata: Default::default(), forwarded_metadata: Default::default(), }; - bus.publish_inbound(msg).await?; + if let Err(error) = bus.publish_inbound(msg).await { + self.uploads.restore(uploads).await; + return Err(error.into()); + } } WsInbound::ClearHistory { chat_id, @@ -391,20 +428,37 @@ impl CliChatChannel { .into_iter() .filter(|message| { !message.content.is_empty() + || message.media_refs.is_some() || message.tool_calls.is_some() || message.role == "tool" }) - .map(|message| HistoryMessage { - id: message.id, - seq: message.seq, - role: message.role, - content: message.content, - created_at: message.created_at, - tool_call_id: message.tool_call_id, - tool_name: message.tool_name, - tool_calls: message - .tool_calls - .and_then(|calls| serde_json::from_str(&calls).ok()), + .map(|message| { + let attachments = message + .media_refs + .as_deref() + .and_then(|refs| { + serde_json::from_str::>(refs).ok() + }) + .unwrap_or_default() + .iter() + .enumerate() + .map(|(index, media_ref)| { + MessageAttachment::from_media_ref(index, media_ref) + }) + .collect(); + HistoryMessage { + id: message.id, + seq: message.seq, + role: message.role, + content: message.content, + created_at: message.created_at, + tool_call_id: message.tool_call_id, + tool_name: message.tool_name, + tool_calls: message + .tool_calls + .and_then(|calls| serde_json::from_str(&calls).ok()), + attachments, + } }) .collect(); let _ = client @@ -778,6 +832,17 @@ impl Channel for CliChatChannel { }; let message_type = msg.metadata.get("_type").map(String::as_str); let session_id = msg.metadata.get("_session_id").cloned(); + let message_id = msg + .metadata + .get("_message_id") + .cloned() + .unwrap_or_else(crate::util::short_id); + let attachments = msg + .media + .iter() + .enumerate() + .map(|(index, media)| MessageAttachment::from_media_ref(index, &media.to_media_ref())) + .collect(); let outbound = if message_type == Some("notification") { WsOutbound::SystemNotification { content: msg.content, @@ -789,9 +854,10 @@ impl Channel for CliChatChannel { } } else { WsOutbound::AssistantResponse { - id: crate::util::short_id(), + id: message_id, content: msg.content, role: "assistant".to_string(), + attachments, session_id, } }; @@ -874,4 +940,90 @@ mod tests { .is_some_and(|client| Arc::ptr_eq(client, &replacement)) ); } + + #[tokio::test] + async fn upload_ids_become_inbound_media_paths() { + let uploads = UploadRegistry::new(crate::config::FileTransferConfig::default()); + uploads + .register( + "upload-1".into(), + "client".into(), + std::path::PathBuf::from("/tmp/report.pdf"), + crate::protocol::UploadDescriptor { + upload_id: String::new(), + name: "report.pdf".into(), + media_type: "file".into(), + mime_type: "application/pdf".into(), + size: 42, + expires_at: 0, + }, + ) + .await + .unwrap(); + let channel = CliChatChannel::with_upload_registry(uploads); + let bus = MessageBus::new(4); + channel.start(bus.clone()).await.unwrap(); + let (sender, _receiver) = mpsc::channel(1); + let client = Arc::new(Client { + sender, + chat_id: "client".into(), + current_session_id: Mutex::new(None), + }); + + channel + .handle_ws_inbound( + client, + WsInbound::UserInput { + content: "处理附件".into(), + upload_ids: vec!["upload-1".into()], + channel: None, + chat_id: None, + sender_id: None, + }, + ) + .await + .unwrap(); + + let inbound = bus.consume_inbound().await.unwrap(); + assert_eq!(inbound.media.len(), 1); + assert_eq!(inbound.media[0].path, "/tmp/report.pdf"); + assert_eq!(inbound.media[0].media_type, "file"); + } + + #[tokio::test] + async fn outbound_media_is_exposed_as_safe_attachment_metadata() { + let channel = CliChatChannel::new(); + let (sender, mut receiver) = mpsc::channel(1); + let client = Arc::new(Client { + sender, + chat_id: "client".into(), + current_session_id: Mutex::new(None), + }); + channel.clients.lock().await.insert("client".into(), client); + + channel + .send(OutboundMessage { + channel: "cli_chat".into(), + chat_id: "client".into(), + content: "报告".into(), + reply_to: None, + media: vec![crate::bus::MediaItem::new("/secret/report.pdf", "file")], + metadata: HashMap::from([("_message_id".into(), "message-1".into())]), + delivery: None, + }) + .await + .unwrap(); + + match receiver.recv().await.unwrap() { + WsOutbound::AssistantResponse { + id, attachments, .. + } => { + assert_eq!(id, "message-1"); + assert_eq!(attachments[0].name, "report.pdf"); + let json = serde_json::to_string(&attachments).unwrap(); + assert!(!json.contains("/secret")); + } + other => panic!("unexpected outbound: {other:?}"), + } + } } diff --git a/src/client/mod.rs b/src/client/mod.rs index 722062e..c976c2f 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -36,7 +36,7 @@ pub async fn run( load_auth_token() }; let mut request = connect_url.into_client_request()?; - if let Some(token) = token { + if let Some(token) = &token { request .headers_mut() .insert(header::AUTHORIZATION, format!("Bearer {token}").parse()?); @@ -51,6 +51,9 @@ pub async fn run( let (ws_sender, ws_receiver) = ws_stream.split(); let mut app = App::new(); + app.http_base_url = gateway_http_base_url(gateway_url)?; + app.auth_token = token; + app.client_id = client_id; app.ws_sender = Some(ws_sender); app.ws_receiver = Some(ws_receiver); @@ -75,6 +78,23 @@ pub async fn run( result } +fn gateway_http_base_url(gateway_url: &str) -> Result> { + let mut url = reqwest::Url::parse(gateway_url)?; + let scheme = match url.scheme() { + "ws" => "http", + "wss" => "https", + "http" => "http", + "https" => "https", + other => return Err(format!("unsupported gateway URL scheme: {other}").into()), + }; + url.set_scheme(scheme) + .map_err(|_| "failed to set gateway URL scheme")?; + url.set_path(""); + url.set_query(None); + url.set_fragment(None); + Ok(url.to_string().trim_end_matches('/').to_string()) +} + async fn exchange_pairing_code( gateway_url: &str, code: &str, @@ -234,7 +254,9 @@ async fn run_app( async fn handle_ws_message(app: &mut App, outbound: WsOutbound) { match outbound { WsOutbound::AssistantResponse { + id, content, + attachments, session_id, .. } => { @@ -244,7 +266,10 @@ async fn handle_ws_message(app: &mut App, outbound: WsOutbound) { .as_ref() .is_none_or(|session_id| app.current_session_id.as_ref() == Some(session_id)) { - app.add_message(MessageRole::Assistant, content); + app.add_message_with_attachments(id, MessageRole::Assistant, content, attachments); + if let Some(current) = app.current_session_id.clone() { + request_history(app, current).await; + } } else { app.status_message = Some("另一个会话已完成响应".to_string()); } @@ -255,8 +280,13 @@ async fn handle_ws_message(app: &mut App, outbound: WsOutbound) { app.status_message = Some(message.clone()); app.add_message(MessageRole::System, format!("Error: {}", message)); } - WsOutbound::SessionEstablished { session_id } => { + WsOutbound::SessionEstablished { + session_id, + capabilities, + } => { app.connected = true; + app.file_transfer_supported = + capabilities.iter().any(|value| value == "file_transfer_v1"); app.set_current_session(Some(session_id.clone())); request_history(app, session_id).await; request_session_list(app).await; diff --git a/src/client/tui/app.rs b/src/client/tui/app.rs index 051d343..613791e 100644 --- a/src/client/tui/app.rs +++ b/src/client/tui/app.rs @@ -1,4 +1,6 @@ -use crate::protocol::{HistoryMessage, SessionSummary, SlashCommandInfo}; +use crate::protocol::{ + HistoryMessage, MessageAttachment, SessionSummary, SlashCommandInfo, UploadDescriptor, +}; use std::collections::VecDeque; use tokio_tungstenite::tungstenite::Message; @@ -14,8 +16,10 @@ pub enum MessageRole { #[derive(Debug, Clone)] pub struct ChatMessage { + pub id: String, pub role: MessageRole, pub content: String, + pub attachments: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -34,6 +38,7 @@ pub enum ConfirmAction { #[derive(Debug, Clone, PartialEq, Eq)] pub enum Modal { Rename { input: String, cursor: usize }, + AttachPath { input: String, cursor: usize }, Confirm(ConfirmAction), } @@ -73,6 +78,11 @@ pub struct App { pub commands: Vec, pub show_command_menu: bool, pub selected_command_idx: usize, + pub http_base_url: String, + pub auth_token: Option, + pub client_id: String, + pub file_transfer_supported: bool, + pub pending_uploads: Vec, } impl App { @@ -99,11 +109,31 @@ impl App { commands: Vec::new(), show_command_menu: false, selected_command_idx: 0, + http_base_url: String::new(), + auth_token: None, + client_id: String::new(), + file_transfer_supported: false, + pending_uploads: Vec::new(), } } pub fn add_message(&mut self, role: MessageRole, content: String) { - self.messages.push_back(ChatMessage { role, content }); + self.add_message_with_attachments(crate::util::short_id(), role, content, Vec::new()); + } + + pub fn add_message_with_attachments( + &mut self, + id: String, + role: MessageRole, + content: String, + attachments: Vec, + ) { + self.messages.push_back(ChatMessage { + id, + role, + content, + attachments, + }); while self.messages.len() > MAX_MESSAGES { self.messages.pop_front(); } @@ -124,8 +154,10 @@ impl App { _ => return None, }; Some(ChatMessage { + id: message.id, role, content: message.content, + attachments: message.attachments, }) }) .collect(); @@ -153,6 +185,7 @@ impl App { if self.current_session_id != session_id { self.current_session_id = session_id; self.messages.clear(); + self.pending_uploads.clear(); self.chat_scroll_from_bottom = 0; } if let Some(current) = &self.current_session_id diff --git a/src/client/tui/components/chat_history.rs b/src/client/tui/components/chat_history.rs index 60be6d0..38dbadf 100644 --- a/src/client/tui/components/chat_history.rs +++ b/src/client/tui/components/chat_history.rs @@ -35,6 +35,12 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) { ); } } + for attachment in &message.attachments { + lines.push(Line::from(Span::styled( + format!(" [附件 {}] {}", attachment.index + 1, attachment.name), + Style::default().fg(Color::Cyan), + ))); + } lines.push(Line::from("")); } if app.pending_responses > 0 { diff --git a/src/client/tui/components/help_popup.rs b/src/client/tui/components/help_popup.rs index a14c156..69e249b 100644 --- a/src/client/tui/components/help_popup.rs +++ b/src/client/tui/components/help_popup.rs @@ -18,6 +18,8 @@ pub fn render(frame: &mut Frame, area: Rect) { Line::from(" Ctrl+R 重命名 Ctrl+O 显示/隐藏归档"), Line::from(" Ctrl+A 归档 Ctrl+D 删除"), Line::from(" Ctrl+L 清空历史 Ctrl+C 两次 退出"), + Line::from(" Ctrl+F 添加附件 F2 下载最近附件"), + Line::from(" Ctrl+X 清除待发送附件"), Line::from(""), Line::from(Span::styled( "输入", diff --git a/src/client/tui/components/input_area.rs b/src/client/tui/components/input_area.rs index c29dbcc..21f1af1 100644 --- a/src/client/tui/components/input_area.rs +++ b/src/client/tui/components/input_area.rs @@ -15,7 +15,11 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) { Style::default() }; let title = if app.connected { - " 输入 · Enter 发送 / Shift+Enter 换行 " + if app.pending_uploads.is_empty() { + " 输入 · Enter 发送 / Ctrl+F 附件 " + } else { + " 输入 · Enter 发送(已有附件)" + } } else { " 输入 · Gateway 已断开 " }; diff --git a/src/client/tui/event.rs b/src/client/tui/event.rs index ddd8cbc..a80b639 100644 --- a/src/client/tui/event.rs +++ b/src/client/tui/event.rs @@ -1,8 +1,10 @@ use crate::client::tui::app::{App, ConfirmAction, Focus, MessageRole, Modal}; -use crate::protocol::{WsInbound, serialize_inbound}; +use crate::protocol::{MessageAttachment, UploadDescriptor, WsInbound, serialize_inbound}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use futures_util::SinkExt; +use tokio::io::AsyncWriteExt; use tokio_tungstenite::tungstenite::Message; +use tokio_util::io::ReaderStream; pub async fn handle_key_event(app: &mut App, key: KeyEvent) { if app.show_help { @@ -64,10 +66,24 @@ pub async fn handle_key_event(app: &mut App, key: KeyEvent) { app.show_archived = !app.show_archived; request_session_list(app).await; } + KeyCode::Char('f') => { + if app.file_transfer_supported { + app.modal = Some(Modal::AttachPath { + input: String::new(), + cursor: 0, + }); + } else { + app.status_message = Some("当前 Gateway 不支持文件传输".to_string()); + } + } KeyCode::Char('u') if app.focus == Focus::Input => { app.input.clear(); app.input_cursor_pos = 0; } + KeyCode::Char('x') if app.focus == Focus::Input => { + app.pending_uploads.clear(); + app.status_message = Some("已移除待发送附件".to_string()); + } KeyCode::Up => app.scroll_chat_up(3), KeyCode::Down => app.scroll_chat_down(3), _ => {} @@ -85,6 +101,7 @@ pub async fn handle_key_event(app: &mut App, key: KeyEvent) { KeyCode::Esc => app.focus = Focus::Input, KeyCode::PageUp => app.scroll_chat_up(10), KeyCode::PageDown => app.scroll_chat_down(10), + KeyCode::F(2) => download_latest_attachment(app).await, KeyCode::Home if app.focus == Focus::Sessions => app.selected_session = 0, KeyCode::End if app.focus == Focus::Sessions => { app.selected_session = app.sessions.len().saturating_sub(1); @@ -95,7 +112,9 @@ pub async fn handle_key_event(app: &mut App, key: KeyEvent) { } pub fn handle_paste(app: &mut App, text: &str) { - if let Some(Modal::Rename { input, cursor }) = &mut app.modal { + if let Some(Modal::Rename { input, cursor } | Modal::AttachPath { input, cursor }) = + &mut app.modal + { let remaining = 256_usize.saturating_sub(input.len()); let mut end = text.len().min(remaining); while !text.is_char_boundary(end) { @@ -157,14 +176,36 @@ async fn handle_input_key(app: &mut App, key: KeyEvent) { KeyCode::Enter => { let input = app.take_input(); close_command_menu(app); - if !input.trim().is_empty() { - app.add_message(MessageRole::User, input.clone()); + if !input.trim().is_empty() || !app.pending_uploads.is_empty() { + let upload_ids = app + .pending_uploads + .iter() + .map(|upload| upload.upload_id.clone()) + .collect::>(); + let attachments = app + .pending_uploads + .iter() + .enumerate() + .map(|(index, upload)| MessageAttachment { + index: u32::try_from(index).unwrap_or(u32::MAX), + name: upload.name.clone(), + media_type: upload.media_type.clone(), + mime_type: upload.mime_type.clone(), + }) + .collect(); + app.add_message_with_attachments( + crate::util::short_id(), + MessageRole::User, + input.clone(), + attachments, + ); app.pending_responses = app.pending_responses.saturating_add(1); app.status_message = Some("PicoBot 正在处理…".to_string()); let sent = send( app, WsInbound::UserInput { content: input, + upload_ids, channel: None, // Session routing is owned by the server. A full session // id is not a chat id and must never be sent here. @@ -175,6 +216,8 @@ async fn handle_input_key(app: &mut App, key: KeyEvent) { .await; if !sent { app.pending_responses = app.pending_responses.saturating_sub(1); + } else { + app.pending_uploads.clear(); } } } @@ -259,10 +302,202 @@ async fn handle_modal_key(app: &mut App, key: KeyEvent) { } _ => app.modal = Some(Modal::Rename { input, cursor }), }, + Some(Modal::AttachPath { + mut input, + mut cursor, + }) => match key.code { + KeyCode::Esc => {} + KeyCode::Enter => { + let path = input.trim().to_string(); + if !path.is_empty() { + upload_tui_file(app, &path).await; + } + } + KeyCode::Char(character) => { + input.insert(cursor, character); + cursor += character.len_utf8(); + app.modal = Some(Modal::AttachPath { input, cursor }); + } + KeyCode::Backspace => { + if let Some((index, _)) = input[..cursor].char_indices().next_back() { + input.drain(index..cursor); + cursor = index; + } + app.modal = Some(Modal::AttachPath { input, cursor }); + } + KeyCode::Delete => { + if let Some(character) = input[cursor..].chars().next() { + input.drain(cursor..cursor + character.len_utf8()); + } + app.modal = Some(Modal::AttachPath { input, cursor }); + } + KeyCode::Left => { + if let Some((index, _)) = input[..cursor].char_indices().next_back() { + cursor = index; + } + app.modal = Some(Modal::AttachPath { input, cursor }); + } + KeyCode::Right => { + if let Some(character) = input[cursor..].chars().next() { + cursor += character.len_utf8(); + } + app.modal = Some(Modal::AttachPath { input, cursor }); + } + _ => app.modal = Some(Modal::AttachPath { input, cursor }), + }, None => {} } } +async fn upload_tui_file(app: &mut App, raw_path: &str) { + let path = std::path::PathBuf::from(raw_path); + let metadata = match tokio::fs::metadata(&path).await { + Ok(metadata) if metadata.is_file() => metadata, + _ => { + app.status_message = Some("附件路径不是可读普通文件".to_string()); + return; + } + }; + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("attachment") + .to_string(); + let file = match tokio::fs::File::open(&path).await { + Ok(file) => file, + Err(error) => { + app.status_message = Some(format!("无法打开附件:{error}")); + return; + } + }; + app.status_message = Some(format!("正在上传 {file_name}…")); + let body = reqwest::Body::wrap_stream(ReaderStream::new(file)); + let part = reqwest::multipart::Part::stream_with_length(body, metadata.len()) + .file_name(file_name.clone()); + let form = reqwest::multipart::Form::new().part("file", part); + let url = format!("{}/api/chat/{}/uploads", app.http_base_url, app.client_id); + let client = reqwest::Client::new(); + let mut request = client.post(url).multipart(form); + if let Some(token) = &app.auth_token { + request = request.bearer_auth(token); + } + match request.send().await { + Ok(response) if response.status().is_success() => { + match response.json::().await { + Ok(upload) => { + app.status_message = Some(format!("已添加附件:{}", upload.name)); + app.pending_uploads.push(upload); + } + Err(error) => app.status_message = Some(format!("上传响应无效:{error}")), + } + } + Ok(response) => { + let status = response.status(); + let detail = response.text().await.unwrap_or_default(); + app.status_message = Some(format!("上传失败 ({status}):{detail}")); + } + Err(error) => app.status_message = Some(format!("上传失败:{error}")), + } +} + +async fn download_latest_attachment(app: &mut App) { + let Some(session_id) = app.current_session_id.clone() else { + return; + }; + let Some((message_id, attachment)) = app.messages.iter().rev().find_map(|message| { + message + .attachments + .first() + .map(|attachment| (message.id.clone(), attachment.clone())) + }) else { + app.status_message = Some("当前历史中没有附件".to_string()); + return; + }; + let mut url = match reqwest::Url::parse(&app.http_base_url) { + Ok(url) => url, + Err(error) => { + app.status_message = Some(format!("下载地址无效:{error}")); + return; + } + }; + if let Ok(mut segments) = url.path_segments_mut() { + segments.extend([ + "api", + "chat", + &app.client_id, + "sessions", + &session_id, + "messages", + &message_id, + "attachments", + &attachment.index.to_string(), + ]); + } + let client = reqwest::Client::new(); + let mut request = client.get(url); + if let Some(token) = &app.auth_token { + request = request.bearer_auth(token); + } + let response = match request.send().await { + Ok(response) if response.status().is_success() => response, + Ok(response) => { + app.status_message = Some(format!("附件不可用 ({})", response.status())); + return; + } + Err(error) => { + app.status_message = Some(format!("下载失败:{error}")); + return; + } + }; + let target = unique_download_path(&attachment.name); + let temporary = target.with_extension("picobot.part"); + let mut output = match tokio::fs::File::create(&temporary).await { + Ok(output) => output, + Err(error) => { + app.status_message = Some(format!("无法创建下载文件:{error}")); + return; + } + }; + let mut stream = response.bytes_stream(); + while let Some(chunk) = futures_util::StreamExt::next(&mut stream).await { + match chunk { + Ok(chunk) => { + if let Err(error) = output.write_all(&chunk).await { + let _ = tokio::fs::remove_file(&temporary).await; + app.status_message = Some(format!("写入下载文件失败:{error}")); + return; + } + } + Err(error) => { + let _ = tokio::fs::remove_file(&temporary).await; + app.status_message = Some(format!("下载中断:{error}")); + return; + } + } + } + drop(output); + if let Err(error) = tokio::fs::rename(&temporary, &target).await { + let _ = tokio::fs::remove_file(&temporary).await; + app.status_message = Some(format!("保存附件失败:{error}")); + return; + } + app.status_message = Some(format!("附件已保存到 {}", target.display())); +} + +fn unique_download_path(name: &str) -> std::path::PathBuf { + let safe = std::path::Path::new(name) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("attachment"); + let mut candidate = std::path::PathBuf::from(safe); + let mut counter = 1_u32; + while candidate.exists() { + candidate = std::path::PathBuf::from(format!("{safe}.{counter}")); + counter += 1; + } + candidate +} + fn open_rename(app: &mut App) { if target_session_id(app).is_some() { let input = if app.focus == Focus::Sessions { diff --git a/src/client/tui/ui.rs b/src/client/tui/ui.rs index cef9b9d..64e337b 100644 --- a/src/client/tui/ui.rs +++ b/src/client/tui/ui.rs @@ -110,6 +110,33 @@ fn render_modal(frame: &mut Frame, area: Rect, modal: &Modal) { frame.set_cursor_position((cursor_x, cursor_y)); } } + Modal::AttachPath { input, cursor } => { + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled( + "添加附件路径", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(input.as_str()), + Line::from(Span::styled( + "Enter 上传 · Esc 取消", + Style::default().fg(Color::DarkGray), + )), + ]) + .block(block) + .wrap(Wrap { trim: false }), + area, + ); + let cursor_x = area.x + + 1 + + UnicodeWidthStr::width(&input[..*cursor]) + .min(area.width.saturating_sub(3) as usize) as u16; + let cursor_y = area.y.saturating_add(3); + if cursor_x < area.right() && cursor_y < area.bottom() { + frame.set_cursor_position((cursor_x, cursor_y)); + } + } Modal::Confirm(action) => { let prompt = match action { ConfirmAction::Archive => "归档所选会话?", diff --git a/src/config/mod.rs b/src/config/mod.rs index 08c7404..f05b366 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -158,6 +158,8 @@ pub struct GatewayConfig { pub max_concurrent_background_tasks: usize, #[serde(default)] pub scheduler: Option, + #[serde(default)] + pub file_transfer: FileTransferConfig, } impl Default for GatewayConfig { @@ -171,10 +173,67 @@ impl Default for GatewayConfig { session_db_path: None, max_concurrent_background_tasks: 10, scheduler: None, + file_transfer: FileTransferConfig::default(), } } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileTransferConfig { + #[serde(default = "default_file_transfer_enabled")] + pub enabled: bool, + #[serde(default = "default_upload_dir")] + pub upload_dir: String, + #[serde(default = "default_max_file_bytes")] + pub max_file_bytes: u64, + #[serde(default = "default_max_files_per_message")] + pub max_files_per_message: usize, + #[serde(default = "default_max_message_bytes")] + pub max_message_bytes: u64, + #[serde(default = "default_pending_ttl_seconds")] + pub pending_ttl_seconds: u64, +} + +impl Default for FileTransferConfig { + fn default() -> Self { + Self { + enabled: default_file_transfer_enabled(), + upload_dir: default_upload_dir(), + max_file_bytes: default_max_file_bytes(), + max_files_per_message: default_max_files_per_message(), + max_message_bytes: default_max_message_bytes(), + pending_ttl_seconds: default_pending_ttl_seconds(), + } + } +} + +fn default_file_transfer_enabled() -> bool { + true +} + +fn default_upload_dir() -> String { + get_user_config_dir() + .join("media/cli_chat") + .to_string_lossy() + .to_string() +} + +fn default_max_file_bytes() -> u64 { + 25 * 1024 * 1024 +} + +fn default_max_files_per_message() -> usize { + 8 +} + +fn default_max_message_bytes() -> u64 { + 64 * 1024 * 1024 +} + +fn default_pending_ttl_seconds() -> u64 { + 60 * 60 +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SchedulerConfig { /// Whether the scheduler is enabled @@ -639,6 +698,11 @@ mod tests { assert_eq!(config.gateway.host, "0.0.0.0"); assert_eq!(config.gateway.port, 19876); assert!(config.gateway.require_pairing); + assert!(config.gateway.file_transfer.enabled); + assert_eq!( + config.gateway.file_transfer.max_file_bytes, + 25 * 1024 * 1024 + ); } #[test] diff --git a/src/gateway/http.rs b/src/gateway/http.rs index 3c72263..2ee16bf 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -3,7 +3,7 @@ use crate::config::Config; use crate::memory::MemoryCategory; use axum::Json; use axum::body::Body; -use axum::extract::{Path, Query, State}; +use axum::extract::{Multipart, Path, Query, State}; use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; @@ -11,12 +11,26 @@ use serde_json::{Value, json}; use std::collections::VecDeque; use std::path::{Path as FsPath, PathBuf}; use std::sync::Arc; -use tokio::io::{AsyncReadExt, AsyncSeekExt}; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use tokio_util::io::ReaderStream; const REDACTED: &str = "********"; const MAX_CONFIG_BYTES: usize = 1024 * 1024; const MAX_PROFILE_BYTES: usize = 256 * 1024; +struct TemporaryUpload { + path: PathBuf, + committed: bool, +} + +impl Drop for TemporaryUpload { + fn drop(&mut self) { + if !self.committed { + let _ = std::fs::remove_file(&self.path); + } + } +} + #[derive(Serialize)] pub struct HealthResponse { status: String, @@ -85,6 +99,13 @@ impl ApiError { } } + fn payload_too_large(message: impl Into) -> Self { + Self { + status: StatusCode::PAYLOAD_TOO_LARGE, + message: message.into(), + } + } + fn internal(error: impl std::fmt::Display) -> Self { tracing::error!(error = %error, "WebUI API request failed"); Self { @@ -94,6 +115,219 @@ impl ApiError { } } +pub async fn upload_file( + State(state): State>, + Path(client_id): Path, + mut multipart: Multipart, +) -> Result<(StatusCode, Json), ApiError> { + if !valid_client_id(&client_id) { + return Err(ApiError::bad_request("invalid client id")); + } + if !state.uploads.enabled() { + return Err(ApiError::bad_request("file transfer is disabled")); + } + + let mut field = multipart + .next_field() + .await + .map_err(|error| ApiError::bad_request(format!("invalid multipart body: {error}")))? + .ok_or_else(|| ApiError::bad_request("file field is required"))?; + if field.name() != Some("file") { + return Err(ApiError::bad_request("expected multipart field named file")); + } + let original_name = field.file_name().unwrap_or("attachment").to_string(); + let safe_name = crate::gateway::uploads::UploadRegistry::safe_file_name(&original_name); + let (temporary_path, final_path, upload_id) = state + .uploads + .allocate_path(&client_id, &safe_name) + .await + .map_err(ApiError::internal)?; + let mut temporary = TemporaryUpload { + path: temporary_path.clone(), + committed: false, + }; + let mut output = tokio::fs::File::create(&temporary_path) + .await + .map_err(ApiError::internal)?; + let mut size = 0_u64; + while let Some(chunk) = field + .chunk() + .await + .map_err(|error| ApiError::bad_request(format!("failed to read upload: {error}")))? + { + size = size.saturating_add(chunk.len() as u64); + if size > state.uploads.max_file_bytes() { + drop(output); + let _ = tokio::fs::remove_file(&temporary_path).await; + return Err(ApiError::payload_too_large(format!( + "file exceeds {} bytes", + state.uploads.max_file_bytes() + ))); + } + if let Err(error) = output.write_all(&chunk).await { + drop(output); + let _ = tokio::fs::remove_file(&temporary_path).await; + return Err(ApiError::internal(error)); + } + } + if size == 0 { + drop(output); + let _ = tokio::fs::remove_file(&temporary_path).await; + return Err(ApiError::bad_request("empty files are not supported")); + } + output.sync_all().await.map_err(ApiError::internal)?; + drop(output); + tokio::fs::rename(&temporary_path, &final_path) + .await + .map_err(ApiError::internal)?; + temporary.committed = true; + + let mime_type = mime_guess::from_path(&safe_name) + .first_or_octet_stream() + .to_string(); + let media_type = media_type_for_mime(&mime_type).to_string(); + let descriptor = match state + .uploads + .register( + upload_id, + client_id, + final_path.clone(), + crate::protocol::UploadDescriptor { + upload_id: String::new(), + name: safe_name, + media_type, + mime_type, + size, + expires_at: 0, + }, + ) + .await + { + Ok(descriptor) => descriptor, + Err(error) => { + let _ = tokio::fs::remove_file(final_path).await; + return Err(ApiError::bad_request(error.to_string())); + } + }; + Ok((StatusCode::CREATED, Json(descriptor))) +} + +#[derive(Debug, Default, Deserialize)] +pub struct AttachmentQuery { + disposition: Option, +} + +pub async fn download_attachment( + State(state): State>, + Path((client_id, session_id, message_id, index)): Path<(String, String, String, usize)>, + Query(query): Query, +) -> Result { + if !valid_client_id(&client_id) { + return Err(ApiError::not_found("attachment not found")); + } + let unified = crate::session::UnifiedSessionId::parse(&session_id) + .filter(|id| id.channel == "cli_chat" && id.chat_id == client_id) + .ok_or_else(|| ApiError::not_found("attachment not found"))?; + let message = state + .storage + .get_message(&unified.to_string(), &message_id) + .await + .map_err(ApiError::internal)? + .ok_or_else(|| ApiError::not_found("attachment not found"))?; + let media_refs = message + .media_refs + .as_deref() + .and_then(|value| serde_json::from_str::>(value).ok()) + .unwrap_or_default(); + let media_ref = media_refs + .get(index) + .ok_or_else(|| ApiError::not_found("attachment not found"))?; + let path = FsPath::new(&media_ref.path); + let metadata = tokio::fs::metadata(path) + .await + .map_err(|_| ApiError::not_found("the original file was moved or deleted"))?; + if !metadata.is_file() { + return Err(ApiError::not_found( + "the original file was moved or deleted", + )); + } + let file = tokio::fs::File::open(path) + .await + .map_err(|_| ApiError::not_found("the original file was moved or deleted"))?; + let name = path + .file_name() + .and_then(|value| value.to_str()) + .map(crate::gateway::uploads::UploadRegistry::safe_file_name) + .unwrap_or_else(|| "attachment".to_string()); + let mime_type = mime_guess::from_path(&name) + .first_or_octet_stream() + .to_string(); + let inline = query.disposition.as_deref() == Some("inline") && inline_mime_allowed(&mime_type); + let disposition = if inline { "inline" } else { "attachment" }; + let encoded_name = encode_header_filename(&name); + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime_type) + .header(header::CONTENT_LENGTH, metadata.len()) + .header( + header::CONTENT_DISPOSITION, + format!("{disposition}; filename*=UTF-8''{encoded_name}"), + ) + .header("X-Content-Type-Options", "nosniff") + .header(header::CACHE_CONTROL, "private, no-store") + .body(Body::from_stream(ReaderStream::new(file))) + .map_err(ApiError::internal) +} + +fn valid_client_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') +} + +fn media_type_for_mime(mime: &str) -> &'static str { + if mime.starts_with("image/") { + "image" + } else if mime.starts_with("audio/") { + "audio" + } else if mime.starts_with("video/") { + "video" + } else { + "file" + } +} + +fn inline_mime_allowed(mime: &str) -> bool { + matches!( + mime, + "image/png" + | "image/jpeg" + | "image/gif" + | "image/webp" + | "audio/mpeg" + | "audio/ogg" + | "audio/wav" + | "video/mp4" + | "video/webm" + ) +} + +fn encode_header_filename(value: &str) -> String { + value + .as_bytes() + .iter() + .map(|byte| match *byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-' => { + (*byte as char).to_string() + } + other => format!("%{other:02X}"), + }) + .collect() +} + impl IntoResponse for ApiError { fn into_response(self) -> Response { (self.status, Json(json!({ "error": self.message }))).into_response() @@ -482,4 +716,15 @@ mod tests { ); assert!(response.headers().contains_key("Content-Security-Policy")); } + + #[test] + fn attachment_headers_are_safely_encoded_and_inline_is_allowlisted() { + assert_eq!( + encode_header_filename("报告 1.pdf"), + "%E6%8A%A5%E5%91%8A%201.pdf" + ); + assert!(inline_mime_allowed("image/png")); + assert!(!inline_mime_allowed("image/svg+xml")); + assert!(!inline_mime_allowed("text/html")); + } } diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 772c785..9d40aa1 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod http; +pub mod uploads; pub mod ws; use axum::{Router, middleware, routing}; @@ -28,6 +29,7 @@ pub struct GatewayState { pub task_supervisor: TaskSupervisor, pub connection_shutdown: tokio_util::sync::CancellationToken, pub auth: auth::AuthManager, + pub uploads: uploads::UploadRegistry, } impl GatewayState { @@ -41,6 +43,7 @@ impl GatewayState { crate::config::get_user_config_dir().join("web_auth.json"), ) .await?; + let uploads = uploads::UploadRegistry::new(config.gateway.file_transfer.clone()); // Initialize workspace directory: expand path and ensure it exists let workspace_path = expand_path(&config.workspace_dir); @@ -118,7 +121,7 @@ impl GatewayState { let session_manager = Arc::new(session_manager); // Create ChannelManager and init channels - let cli_chat_channel = Arc::new(CliChatChannel::new()); + let cli_chat_channel = Arc::new(CliChatChannel::with_upload_registry(uploads.clone())); let channel_manager = ChannelManager::with_bus(cli_chat_channel, bus); channel_manager .init(&config, workspace_path.clone()) @@ -201,6 +204,7 @@ impl GatewayState { task_supervisor, connection_shutdown, auth, + uploads, }) } @@ -220,6 +224,22 @@ impl GatewayState { let bus_for_outbound = bus.clone(); let session_manager = self.session_manager.clone(); + if self.uploads.enabled() { + let uploads = self.uploads.clone(); + self.task_supervisor + .spawn("pending-upload-cleanup", async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(300)); + interval.tick().await; + loop { + interval.tick().await; + let removed = uploads.cleanup_expired().await; + if removed > 0 { + tracing::debug!(removed, "Expired pending uploads removed"); + } + } + }); + } + // Relay structured plan changes to WebSocket clients. This remains // separate from chat messages, so task UI updates never pollute history. let mut plan_events = self.session_manager.work_manager().subscribe(); @@ -480,6 +500,14 @@ pub async fn run( .route("/api/jobs", routing::get(http::get_jobs)) .route("/api/jobs/{id}/runs", routing::get(http::get_job_runs)) .route("/api/memories", routing::get(http::get_memories)) + .route( + "/api/chat/{client_id}/uploads", + routing::post(http::upload_file).layer(axum::extract::DefaultBodyLimit::disable()), + ) + .route( + "/api/chat/{client_id}/sessions/{session_id}/messages/{message_id}/attachments/{index}", + routing::get(http::download_attachment), + ) .route("/ws", routing::get(ws::ws_handler)) .route_layer(middleware::from_fn_with_state( state.auth.clone(), diff --git a/src/gateway/uploads.rs b/src/gateway/uploads.rs new file mode 100644 index 0000000..f6aac07 --- /dev/null +++ b/src/gateway/uploads.rs @@ -0,0 +1,310 @@ +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use sha2::{Digest, Sha256}; +use tokio::sync::Mutex; + +use crate::bus::MediaItem; +use crate::config::{FileTransferConfig, expand_path}; +use crate::protocol::UploadDescriptor; + +#[derive(Debug, Clone)] +pub struct PendingUpload { + pub descriptor: UploadDescriptor, + pub owner_chat_id: String, + pub path: PathBuf, + expires_at: Instant, +} + +#[derive(Debug, thiserror::Error)] +pub enum UploadError { + #[error("file transfer is disabled")] + Disabled, + #[error("too many files; maximum is {0}")] + TooManyFiles(usize), + #[error("duplicate upload id")] + Duplicate, + #[error("upload not found or expired")] + NotFound, + #[error("upload does not belong to this client")] + WrongOwner, + #[error("attachments exceed the per-message limit of {0} bytes")] + MessageTooLarge(u64), +} + +#[derive(Clone)] +pub struct UploadRegistry { + config: FileTransferConfig, + upload_dir: PathBuf, + pending: Arc>>, +} + +impl UploadRegistry { + pub fn new(config: FileTransferConfig) -> Self { + let upload_dir = expand_path(&config.upload_dir); + Self { + config, + upload_dir, + pending: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub fn disabled() -> Self { + let config = FileTransferConfig { + enabled: false, + ..FileTransferConfig::default() + }; + Self::new(config) + } + + pub fn enabled(&self) -> bool { + self.config.enabled + } + + pub fn max_file_bytes(&self) -> u64 { + self.config.max_file_bytes + } + + pub fn safe_file_name(name: &str) -> String { + let base = Path::new(name) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("attachment"); + let cleaned = base + .chars() + .filter(|character| !character.is_control() && *character != '/' && *character != '\\') + .collect::(); + let cleaned = cleaned.trim().trim_matches('.'); + let mut result = if cleaned.is_empty() { + "attachment".to_string() + } else { + cleaned.to_string() + }; + while result.len() > 180 { + result.pop(); + } + result + } + + pub async fn allocate_path( + &self, + chat_id: &str, + file_name: &str, + ) -> Result<(PathBuf, PathBuf, String), std::io::Error> { + let scope_hash = hex_digest(chat_id.as_bytes()); + let upload_id = uuid::Uuid::new_v4().to_string(); + let directory = self.upload_dir.join(&scope_hash[..16]).join(&upload_id); + tokio::fs::create_dir_all(&directory).await?; + let safe_name = Self::safe_file_name(file_name); + let final_path = directory.join(&safe_name); + let temporary_path = directory.join(".upload.part"); + Ok((temporary_path, final_path, upload_id)) + } + + pub async fn register( + &self, + upload_id: String, + chat_id: String, + path: PathBuf, + mut descriptor: UploadDescriptor, + ) -> Result { + if !self.enabled() { + return Err(UploadError::Disabled); + } + let ttl = Duration::from_secs(self.config.pending_ttl_seconds.max(1)); + let expires_at = Instant::now() + ttl; + let expires_at_unix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .saturating_add(ttl.as_secs()) as i64; + descriptor.upload_id = upload_id.clone(); + descriptor.expires_at = expires_at_unix; + let mut pending = self.pending.lock().await; + pending.insert( + upload_id, + PendingUpload { + descriptor: descriptor.clone(), + owner_chat_id: chat_id, + path, + expires_at, + }, + ); + Ok(descriptor) + } + + pub async fn take_many( + &self, + chat_id: &str, + upload_ids: &[String], + ) -> Result, UploadError> { + if upload_ids.is_empty() { + return Ok(Vec::new()); + } + if !self.enabled() { + return Err(UploadError::Disabled); + } + if upload_ids.len() > self.config.max_files_per_message { + return Err(UploadError::TooManyFiles(self.config.max_files_per_message)); + } + let unique = upload_ids.iter().collect::>(); + if unique.len() != upload_ids.len() { + return Err(UploadError::Duplicate); + } + + let now = Instant::now(); + let mut pending = self.pending.lock().await; + let mut total = 0_u64; + for id in upload_ids { + let upload = pending.get(id).ok_or(UploadError::NotFound)?; + if upload.expires_at <= now { + return Err(UploadError::NotFound); + } + if upload.owner_chat_id != chat_id { + return Err(UploadError::WrongOwner); + } + total = total.saturating_add(upload.descriptor.size); + } + if total > self.config.max_message_bytes { + return Err(UploadError::MessageTooLarge(self.config.max_message_bytes)); + } + + Ok(upload_ids + .iter() + .filter_map(|id| pending.remove(id)) + .collect()) + } + + pub async fn restore(&self, uploads: Vec) { + let mut pending = self.pending.lock().await; + for upload in uploads { + pending.insert(upload.descriptor.upload_id.clone(), upload); + } + } + + pub async fn cleanup_expired(&self) -> usize { + let now = Instant::now(); + let expired = { + let mut pending = self.pending.lock().await; + let ids = pending + .iter() + .filter(|(_, upload)| upload.expires_at <= now) + .map(|(id, _)| id.clone()) + .collect::>(); + ids.into_iter() + .filter_map(|id| pending.remove(&id)) + .map(|upload| upload.path) + .collect::>() + }; + let count = expired.len(); + for path in expired { + let _ = tokio::fs::remove_file(&path).await; + if let Some(parent) = path.parent() { + let _ = tokio::fs::remove_dir(parent).await; + } + } + count + } + + pub fn as_media(upload: &PendingUpload) -> MediaItem { + MediaItem { + path: upload.path.to_string_lossy().into_owned(), + media_type: upload.descriptor.media_type.clone(), + mime_type: Some(upload.descriptor.mime_type.clone()), + original_key: None, + } + } +} + +fn hex_digest(value: &[u8]) -> String { + let digest = Sha256::digest(value); + digest.iter().map(|byte| format!("{byte:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitizes_file_names() { + assert_eq!( + UploadRegistry::safe_file_name("../../report.pdf"), + "report.pdf" + ); + assert_eq!(UploadRegistry::safe_file_name(".."), "attachment"); + assert_eq!(UploadRegistry::safe_file_name("a\n.txt"), "a.txt"); + } + + #[tokio::test] + async fn upload_ids_are_scoped_and_consumed() { + let registry = UploadRegistry::new(FileTransferConfig::default()); + registry + .register( + "upload-1".into(), + "client-a".into(), + PathBuf::from("/tmp/a"), + UploadDescriptor { + upload_id: String::new(), + name: "a.txt".into(), + media_type: "file".into(), + mime_type: "text/plain".into(), + size: 10, + expires_at: 0, + }, + ) + .await + .unwrap(); + + assert!(matches!( + registry.take_many("client-b", &["upload-1".into()]).await, + Err(UploadError::WrongOwner) + )); + assert_eq!( + registry + .take_many("client-a", &["upload-1".into()]) + .await + .unwrap() + .len(), + 1 + ); + assert!(matches!( + registry.take_many("client-a", &["upload-1".into()]).await, + Err(UploadError::NotFound) + )); + } + + #[tokio::test] + async fn expired_pending_uploads_are_removed() { + let directory = tempfile::tempdir().unwrap(); + let config = FileTransferConfig { + upload_dir: directory.path().to_string_lossy().into_owned(), + pending_ttl_seconds: 1, + ..FileTransferConfig::default() + }; + let registry = UploadRegistry::new(config); + let path = directory.path().join("expired.txt"); + tokio::fs::write(&path, b"expired").await.unwrap(); + registry + .register( + "expired".into(), + "client".into(), + path.clone(), + UploadDescriptor { + upload_id: String::new(), + name: "expired.txt".into(), + media_type: "file".into(), + mime_type: "text/plain".into(), + size: 7, + expires_at: 0, + }, + ) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(1_050)).await; + + assert_eq!(registry.cleanup_expired().await, 1); + assert!(!path.exists()); + } +} diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index e9c4c60..5831e72 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -56,6 +56,11 @@ async fn handle_socket( let _ = sender .send(WsOutbound::SessionEstablished { session_id: session_id.clone(), + capabilities: if state.uploads.enabled() { + vec!["file_transfer_v1".to_string()] + } else { + Vec::new() + }, }) .await; diff --git a/src/protocol.rs b/src/protocol.rs index 637c68f..9a43174 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -19,6 +19,43 @@ pub struct SlashCommandInfo { pub aliases: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct UploadDescriptor { + pub upload_id: String, + pub name: String, + pub media_type: String, + pub mime_type: String, + pub size: u64, + pub expires_at: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MessageAttachment { + pub index: u32, + pub name: String, + pub media_type: String, + pub mime_type: String, +} + +impl MessageAttachment { + pub fn from_media_ref(index: usize, media_ref: &crate::bus::MediaRef) -> Self { + let name = std::path::Path::new(&media_ref.path) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "attachment".to_string()); + let mime_type = mime_guess::from_path(&name) + .first_or_octet_stream() + .to_string(); + Self { + index: u32::try_from(index).unwrap_or(u32::MAX), + name, + media_type: media_ref.media_type.clone(), + mime_type, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HistoryMessage { pub id: String, @@ -32,6 +69,8 @@ pub struct HistoryMessage { pub tool_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_calls: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub attachments: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -40,6 +79,8 @@ pub enum WsInbound { #[serde(rename = "user_input")] UserInput { content: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + upload_ids: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] channel: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -104,13 +145,19 @@ pub enum WsOutbound { id: String, content: String, role: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + attachments: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] session_id: Option, }, #[serde(rename = "error")] Error { code: String, message: String }, #[serde(rename = "session_established")] - SessionEstablished { session_id: String }, + SessionEstablished { + session_id: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + capabilities: Vec, + }, #[serde(rename = "session_created")] SessionCreated { session_id: String, title: String }, #[serde(rename = "session_list")] diff --git a/src/session/messenger.rs b/src/session/messenger.rs index d527f1d..26fad85 100644 --- a/src/session/messenger.rs +++ b/src/session/messenger.rs @@ -54,6 +54,7 @@ impl OutboundMessenger for SessionManager { }; let message = outbound_history_message(marked_content.clone(), source, &media); + let message_id = message.id.clone(); append_persisted_messages(&session, vec![message]) .await .map_err(|error| error.to_string())?; @@ -62,6 +63,10 @@ impl OutboundMessenger for SessionManager { self.restore_origin_dialog(&origin_id, &target_sid).await; } + let metadata = HashMap::from([ + ("_session_id".to_string(), target_sid.to_string()), + ("_message_id".to_string(), message_id), + ]); self.bus .deliver_outbound(OutboundMessage { channel: channel.to_string(), @@ -69,7 +74,7 @@ impl OutboundMessenger for SessionManager { content: marked_content, reply_to: None, media, - metadata: HashMap::new(), + metadata, delivery: None, }) .await diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 3628f55..1d3612d 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -859,6 +859,40 @@ impl Storage { .collect()) } + pub async fn get_message( + &self, + session_id: &str, + message_id: &str, + ) -> Result, StorageError> { + let row = sqlx::query( + r#" + SELECT id, session_id, seq, role, content, reasoning_content, media_refs, + tool_call_id, tool_name, tool_calls, source, created_at + FROM messages + WHERE session_id = ? AND id = ? + "#, + ) + .bind(session_id) + .bind(message_id) + .fetch_optional(self.pool()) + .await?; + + Ok(row.map(|row| crate::storage::message::MessageMeta { + id: row.get("id"), + session_id: row.get("session_id"), + seq: row.get("seq"), + role: row.get("role"), + content: row.get("content"), + reasoning_content: row.get("reasoning_content"), + media_refs: row.get("media_refs"), + tool_call_id: row.get("tool_call_id"), + tool_name: row.get("tool_name"), + tool_calls: row.get("tool_calls"), + source: row.get("source"), + created_at: row.get("created_at"), + })) + } + pub async fn get_max_message_seq(&self, session_id: &str) -> Result { let row = sqlx::query( "SELECT COALESCE(MAX(seq), 0) as max_seq FROM messages WHERE session_id = ?", diff --git a/tests/test_request_format.rs b/tests/test_request_format.rs index bf2d10b..a6a5be7 100644 --- a/tests/test_request_format.rs +++ b/tests/test_request_format.rs @@ -139,6 +139,7 @@ fn test_bounded_session_history_protocol() { tool_call_id: None, tool_name: None, tool_calls: None, + attachments: Vec::new(), }], }; let decoded: WsOutbound = @@ -152,6 +153,26 @@ fn test_bounded_session_history_protocol() { } } +#[test] +fn test_user_input_accepts_upload_ids_and_old_payloads() { + let old: WsInbound = + serde_json::from_str(r#"{"type":"user_input","content":"hello"}"#).unwrap(); + match old { + WsInbound::UserInput { upload_ids, .. } => assert!(upload_ids.is_empty()), + other => panic!("unexpected decoded variant: {other:?}"), + } + + let message = WsInbound::UserInput { + content: "处理文件".to_string(), + upload_ids: vec!["upload-1".to_string()], + channel: None, + chat_id: None, + sender_id: None, + }; + let json = serde_json::to_string(&message).unwrap(); + assert!(json.contains(r#""upload_ids":["upload-1"]"#)); +} + #[test] fn test_session_history_preserves_tool_call_metadata() { let outbound = WsOutbound::SessionHistory { @@ -169,6 +190,7 @@ fn test_session_history_preserves_tool_call_metadata() { name: "read_file".to_string(), arguments: serde_json::json!({ "path": "README.md" }), }]), + attachments: Vec::new(), }], }; diff --git a/tmp.jpg b/tmp.jpg new file mode 100644 index 0000000..029b253 Binary files /dev/null and b/tmp.jpg differ diff --git a/webui/src/pages/ChatPage.svelte b/webui/src/pages/ChatPage.svelte index 33668ab..442eefd 100644 --- a/webui/src/pages/ChatPage.svelte +++ b/webui/src/pages/ChatPage.svelte @@ -17,6 +17,8 @@ let selectedCommand = $state(0); let commandMenuDismissed = $state(false); let thinking = $state(false); + let pendingUploads = $state([]); + let fileInput; let plansBySession = $state({}); let unseenPlanSessions = $state({}); let todoOpen = $state(false); @@ -126,7 +128,7 @@ case "assistant_response": thinking = false; if (!frame.session_id || frame.session_id === currentId) { - appendMessage(frame.role || "assistant", frame.content); + appendMessage(frame.role || "assistant", frame.content, frame.attachments || [], frame.id); if (currentId) send({ type: "get_session_history", session_id: currentId, limit: 1000 }); } send({ type: "list_sessions", include_archived: false }); @@ -144,14 +146,15 @@ if (messageBox) messageBox.scrollTop = messageBox.scrollHeight; } - function appendMessage(role, content) { - messages = [...messages, { id: crypto.randomUUID(), role, content }]; + function appendMessage(role, content, attachments = [], id = crypto.randomUUID()) { + messages = [...messages, { id, role, content, attachments }]; scrollToBottom(); } function loadSession(id) { if (!id) return; currentId = id; + clearPendingUploads(); messages = []; todoOpen = Boolean(unseenPlanSessions[id]); unseenPlanSessions[id] = false; @@ -171,16 +174,108 @@ function submit() { const content = draft.trim(); - if (!content || !connected) return; - appendMessage("user", content); + const ready = pendingUploads.filter((upload) => upload.status === "ready"); + if ((!content && !ready.length) || !connected || pendingUploads.some((upload) => upload.status === "uploading")) return; + appendMessage("user", content, ready.map((upload, index) => ({ + index, + name: upload.name, + media_type: upload.media_type, + mime_type: upload.mime_type, + local_url: upload.localUrl + }))); thinking = true; - send({ type: "user_input", content }); + send({ type: "user_input", content, upload_ids: ready.map((upload) => upload.upload_id) }); draft = ""; + pendingUploads = []; commandMenuDismissed = false; selectedCommand = 0; if (input) input.style.height = "auto"; } + function attachmentUrl(message, attachment, inline = false) { + if (attachment.local_url) return attachment.local_url; + if (!currentId || !message.id) return ""; + const base = `/api/chat/${encodeURIComponent(clientId())}/sessions/${encodeURIComponent(currentId)}/messages/${encodeURIComponent(message.id)}/attachments/${attachment.index}`; + return inline ? `${base}?disposition=inline` : base; + } + + function formatBytes(value) { + if (!Number.isFinite(value)) return ""; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`; + return `${(value / 1024 / 1024).toFixed(1)} MiB`; + } + + function canPreview(attachment) { + return ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(attachment.mime_type); + } + + function addFiles(files) { + for (const file of Array.from(files || [])) uploadFile(file); + } + + function uploadFile(file) { + if (!connected) return notify("聊天连接尚未就绪", true); + const localId = crypto.randomUUID(); + const localUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null; + pendingUploads = [...pendingUploads, { + localId, name: file.name, size: file.size, progress: 0, status: "uploading", localUrl + }]; + const body = new FormData(); + body.append("file", file, file.name); + const request = new XMLHttpRequest(); + request.open("POST", `/api/chat/${encodeURIComponent(clientId())}/uploads`); + request.upload.onprogress = (event) => { + if (!event.lengthComputable) return; + pendingUploads = pendingUploads.map((item) => item.localId === localId + ? { ...item, progress: Math.round(event.loaded / event.total * 100) } + : item); + }; + request.onload = () => { + let response = {}; + try { response = JSON.parse(request.responseText || "{}"); } catch {} + if (request.status >= 200 && request.status < 300) { + pendingUploads = pendingUploads.map((item) => item.localId === localId + ? { ...item, ...response, status: "ready", progress: 100 } + : item); + } else { + pendingUploads = pendingUploads.map((item) => item.localId === localId + ? { ...item, status: "error", error: response.error || `上传失败 (${request.status})` } + : item); + } + }; + request.onerror = () => { + pendingUploads = pendingUploads.map((item) => item.localId === localId + ? { ...item, status: "error", error: "网络错误" } + : item); + }; + request.send(body); + } + + function removeUpload(localId) { + const upload = pendingUploads.find((item) => item.localId === localId); + if (upload?.localUrl) URL.revokeObjectURL(upload.localUrl); + pendingUploads = pendingUploads.filter((item) => item.localId !== localId); + } + + function clearPendingUploads() { + for (const upload of pendingUploads) if (upload.localUrl) URL.revokeObjectURL(upload.localUrl); + pendingUploads = []; + } + + function dropFiles(event) { + event.preventDefault(); + addFiles(event.dataTransfer?.files); + } + + function pasteFiles(event) { + const files = Array.from(event.clipboardData?.items || []) + .filter((item) => item.kind === "file") + .map((item) => item.getAsFile()) + .filter(Boolean); + if (files.length) addFiles(files); + } + async function moveCommandSelection(offset) { const length = commandSuggestions.length; if (!length) return; @@ -247,6 +342,7 @@ stopped = true; clearTimeout(reconnectTimer); socket?.close(); + clearPendingUploads(); }; }); @@ -283,11 +379,24 @@

今天想做些什么?

消息与 CLI 客户端使用同一套会话、记忆和工具能力。

{/if} {#each messages as message (message.id)} - {#if message.role !== "tool" && (message.content || message.tool_calls?.length)} + {#if message.role !== "tool" && (message.content || message.tool_calls?.length || message.attachments?.length)}
{message.role === "user" ? "你" : "P"}
{#if message.content}
{/if} + {#if message.attachments?.length} +
+ {#each message.attachments as attachment (`${message.id}:${attachment.index}`)} +
+ {#if canPreview(attachment)} + {attachment.name} + {:else}{/if} +
{attachment.name}{attachment.mime_type || attachment.media_type}
+ +
+ {/each} +
+ {/if} {#if message.tool_calls?.length}
{#each message.tool_calls as call (call.id)} @@ -301,7 +410,7 @@ {/each} {#if thinking}
P
正在思考…
{/if}
-
{ event.preventDefault(); submit(); }}> + { event.preventDefault(); submit(); }} ondragover={(event) => event.preventDefault()} ondrop={dropFiles}> {#if commandSuggestions.length}
斜杠命令↑↓ 选择 · Tab/Enter 补全 · Esc 关闭
@@ -320,11 +429,24 @@ {/each}
{/if} + {#if pendingUploads.length} +
+ {#each pendingUploads as upload (upload.localId)} +
+
{upload.name}{upload.status === "uploading" ? `上传中 ${upload.progress}%` : upload.status === "error" ? upload.error : formatBytes(upload.size)}
+ +
+ {/each} +
+ {/if} + { addFiles(event.currentTarget.files); event.currentTarget.value = ""; }} /> + - + {connected ? "已连接" : "已断开,正在重连"}/ 打开命令 · Shift+Enter 换行
diff --git a/webui/src/styles.css b/webui/src/styles.css index 5efe6ea..63265cd 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -143,6 +143,12 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; } .message.user .message-content { justify-items: end; } .bubble { max-width: 100%; padding: 11px 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); font-size: 14px; line-height: 1.62; overflow-wrap: anywhere; } .message.user .bubble { background: var(--user-bubble); border-color: var(--accent-border); } +.message-attachments { display: grid; gap: 7px; width: min(100%, 520px); } +.attachment-card { display: grid; grid-template-columns: 42px minmax(0, 1fr) 30px; gap: 9px; align-items: center; padding: 8px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); } +.attachment-card img, .attachment-icon { width: 42px; height: 42px; border-radius: 7px; object-fit: cover; background: var(--panel-2); display: grid; place-items: center; color: var(--accent); } +.attachment-card strong, .attachment-card small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.attachment-card strong { font-size: 12px; }.attachment-card small { margin-top: 3px; color: var(--muted); font-size: 9px; } +.attachment-card a { width: 28px; height: 28px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 7px; color: var(--accent); text-decoration: none; } .typing .bubble { color: var(--muted); } .pulse { display: inline-block; width: 6px; height: 6px; margin-right: 8px; border-radius: 50%; background: var(--accent); animation: pulse 1.1s infinite; } @keyframes pulse { 50% { opacity: .25; transform: scale(.8); } } @@ -182,8 +188,13 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; } .tool-call-details > div > span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 9px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } .tool-call-details pre, .tool-result { max-height: 300px; margin: 0; padding: 10px 11px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; color: var(--text-soft); background: var(--code-bg); font: 11px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; } .tool-result .markdown-body { font: inherit; } -.composer { position: relative; margin: 0 max(18px, calc((100% - 850px) / 2)) 18px; border: 1px solid var(--line); background: var(--panel); border-radius: 14px; padding: 10px 11px 6px; display: grid; grid-template-columns: 1fr 38px; box-shadow: var(--shadow); } +.composer { position: relative; margin: 0 max(18px, calc((100% - 850px) / 2)) 18px; border: 1px solid var(--line); background: var(--panel); border-radius: 14px; padding: 10px 11px 6px; display: grid; grid-template-columns: 38px 1fr 38px; box-shadow: var(--shadow); } .composer textarea { resize: none; max-height: 180px; background: transparent; border: 0; outline: 0; color: var(--text); padding: 7px; line-height: 1.5; } +.file-input { display: none; } +.attach { width: 34px; height: 34px; align-self: end; border: 1px solid var(--line); border-radius: 9px; color: var(--muted); background: var(--panel-2); cursor: pointer; font-size: 18px; } +.pending-uploads { grid-column: 1 / -1; display: flex; gap: 7px; padding: 2px 4px 8px; overflow-x: auto; } +.pending-upload { min-width: 170px; max-width: 260px; display: grid; grid-template-columns: 24px minmax(0, 1fr) 22px; gap: 6px; align-items: center; padding: 7px; border: 1px solid var(--line); border-radius: 9px; background: var(--panel-2); } +.pending-upload.error { border-color: var(--danger); }.pending-upload strong, .pending-upload small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.pending-upload strong { font-size: 10px; }.pending-upload small { color: var(--muted); font-size: 9px; }.pending-upload button { border: 0; color: var(--muted); background: none; cursor: pointer; } .send { width: 36px; height: 36px; border-radius: 10px; color: var(--accent-contrast); background: var(--accent); border: 0; font-size: 19px; cursor: pointer; } .composer small { grid-column: 1 / -1; display: flex; justify-content: space-between; padding: 3px 7px; color: var(--muted); font-size: 10px; } .composer small .online { color: var(--accent); }