feat(web,server,core): attach files to a message from the composer (#121)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-29 23:44:10 +08:00
committed by GitHub
parent 37fc715ce9
commit 1d23a7acaf
25 changed files with 1692 additions and 131 deletions
+14 -4
View File
@@ -10,7 +10,7 @@ The PenguinHarness server exposes a same-origin HTTP API used by the bundled Web
- Stack: Hono + @hono/node-server, requires Node >= 24;
- Storage: SQLite (built-in `node:sqlite`, WAL mode) holds only indexes and aggregates — users, auth sessions, Project authorization, Agent / Session indexes, usage, UI preferences, error records, and Schedule state; all Agent, Trace, and Workspace data stays as files under `~/.penguin/data`, shared with the CLI / SDK — see the [Configuration Reference](/configuration);
- Binding: defaults to `127.0.0.1:7364`, adjustable via the `PORT` / `HOST` environment variables;
- Request bodies: writes accept JSON only (Content-Type check, one of the CSRF defenses), capped at 20MB;
- Request bodies: writes accept JSON only (Content-Type check, one of the CSRF defenses), capped at 20MB — counted as the body is read, so a request that declares no length (chunked) is capped just the same;
- Errors share a single shape:
```text
@@ -161,7 +161,7 @@ The paths below omit the `/api/sessions/:sessionId` prefix. For the storage mode
| DELETE | / | Delete the Session (along with its Traces and scratch files) |
| GET | /messages | Full OmniMessage history; while a Task runs the response also carries `live` (the in-progress stream tail, see below) |
| GET | /stream | SSE event stream (next section) |
| POST | /tasks | Start a Task: `{input: TaskInputPart[], thinkingLevel?, queueIfBusy?}` → 202. With `queueIfBusy`, a busy session holds the input as a follow-up (`queued: true`) and auto-starts it as an ordinary next task once idle; `task_state` events report the queued count |
| POST | /tasks | Start a Task: `{input: TaskInputPart[], thinkingLevel?, queueIfBusy?}` → 202. With `queueIfBusy`, a busy session holds the input as a follow-up (`queued: true`) and auto-starts it as an ordinary next task once idle; `task_state` events report the queued count. `file` input parts are written to the Session scratchpad and handed to the model as `[attached file: <path>]` lines (see the request body below) |
| POST | /steer | Mid-run steering: `{text}` queues a message for the running Task (delivered between turns as a standalone `[user_steering]` user message) → 202; 409 `not_running` when no Task is in progress |
| POST | /approvals/:toolCallId | Approval decision: `{decision}` is `allow` or `deny` → 204 |
| POST | /abort | Interrupt the current Task: 202 when triggered, 204 when idle |
@@ -175,7 +175,7 @@ The paths below omit the `/api/sessions/:sessionId` prefix. For the storage mode
| GET | /traces | List this Session's Trace files |
| GET | /traces/:index | Read Trace events (paginated) |
| GET | /traces/:index/analysis | Trace performance analysis |
| GET | /scratchpad/:fileName | Read a session scratch file (e.g. input images) |
| GET | /scratchpad/:fileName | Read a session scratch file (e.g. input images, file attachments) |
General conventions: Sessions the user cannot access always return 404 — their existence is never leaked; only one Task or compaction runs per Session at a time, and conflicts return 409 (`task_in_progress` / `compacting`).
@@ -209,6 +209,8 @@ Workspace files may be Agent-generated, so `GET /files/content` treats them as u
| `preview=1` | the real type (`text/html`, `image/svg+xml`, …) | `inline` | `sandbox allow-scripts allow-popups allow-modals allow-forms`, sent only for `.html` / `.htm` / `.svg` |
| `download=1` | the real type | `attachment` | — |
`GET /scratchpad/:fileName` serves the same kind of untrusted bytes (uploads and Agent-written temp files) and is locked down the same way, without the flags: `nosniff` always, a fixed allowlist of five inert image types (`.png` / `.jpg` / `.jpeg` / `.gif` / `.webp`) served inline for the conversation's `<img>` tags, and everything else `application/octet-stream` with `Content-Disposition: attachment` — so nothing that isn't one of those images can render as a document on the App's origin.
The filename always rides along as `filename*=UTF-8''` with percent-encoding. `preview=1` is where the preview redirect falls back when no separate preview origin is available: the document keeps its real type and does render and run, but the sandbox deliberately omits `allow-same-origin`, so it lands in an opaque origin and can reach neither this origin's cookies nor the API. That isolation is also why `localStorage`, `document.cookie` and third-party embeds do not work there.
### Preview on a separate origin
@@ -241,7 +243,15 @@ interface TaskCreateRequest {
}
type TaskInputPart =
| { type: "text"; text: string }
| { type: "image_url"; imageUrl: string }; // pasted images arrive as data URLs
| { type: "image_url"; imageUrl: string } // pasted images arrive as data URLs
// File attachment: base64 data: URL, ≤10MB each (413 file_too_large beyond that), at most 20
// per request and 12MB of decoded bytes in total (413 too_many_files / payload_too_large;
// all three are checked before anything is written). The server writes it into the Session
// scratchpad and appends an `[attached file: <path>]` line to the message text — the model
// opens the file by path. `fileName` carries no path separators; on disk it keeps its own
// words (`报告 2026.pdf` → `报告-2026.pdf`: non-ASCII survives, shell-hostile ASCII becomes
// `-`), so a name is readable in the message and safe to paste into a command.
| { type: "file"; fileName: string; dataUrl: string };
// POST /api/sessions/:sessionId/approvals/:toolCallId
interface ApprovalDecisionRequest {
+13 -4
View File
@@ -10,7 +10,7 @@ PenguinHarness Server 提供一套同源 HTTP API,自带的 Web App 与其他
- 技术栈:Hono + @hono/node-server,要求 Node >= 24;
- 存储:SQLite(内置 `node:sqlite`,WAL 模式)仅存放索引与聚合数据——用户、登录会话、Project 授权、Agent / Session 索引、用量、UI 偏好、错误记录与 Schedule 状态;Agent、Trace 与 Workspace 数据全部以文件形式存放在 `~/.penguin/data` 下,与 CLI / SDK 共享,见[配置参考](/configuration);
- 监听:默认 `127.0.0.1:7364`,可用环境变量 `PORT` / `HOST` 调整;
- 请求体:写请求仅接受 JSON(Content-Type 校验,CSRF 防线之一),上限 20MB;
- 请求体:写请求仅接受 JSON(Content-Type 校验,CSRF 防线之一),上限 20MB —— 按读取到的字节数统计,未声明长度(分块传输)的请求同样受限;
- 错误响应统一为:
```text
@@ -161,7 +161,7 @@ Trace 下载对任意成员开放;导入仅限 owner(同 Agent 快照导入
| DELETE | / | 删除 Session(连同 Trace 与暂存文件) |
| GET | /messages | 完整 OmniMessage 历史;Task 运行期间响应额外携带 `live`(进行中的流式尾部,见下) |
| GET | /stream | SSE 事件流(见下节) |
| POST | /tasks | 发起 Task:`{input: TaskInputPart[], thinkingLevel?, queueIfBusy?}` → 202。带 `queueIfBusy` 时,运行中的 Session 会把输入暂存为跟进消息(`queued: true`),空闲后按序自动作为普通 Task 发出;`task_state` 事件携带排队数 |
| POST | /tasks | 发起 Task:`{input: TaskInputPart[], thinkingLevel?, queueIfBusy?}` → 202。带 `queueIfBusy` 时,运行中的 Session 会把输入暂存为跟进消息(`queued: true`),空闲后按序自动作为普通 Task 发出;`task_state` 事件携带排队数。`file` 输入部分会写入该 Session 的 scratchpad,并以 `[attached file: <path>]` 行交给模型(见下方请求体) |
| POST | /steer | 运行中插话:`{text}` 为运行中的 Task 排队一条消息(作为独立的 `[user_steering]` 用户消息随下一轮送达)→ 202;无 Task 运行返回 409 `not_running` |
| POST | /approvals/:toolCallId | 审批决定:`{decision}` 取 `allow` 或 `deny` → 204 |
| POST | /abort | 中断当前 Task:已触发返回 202,无任务返回 204 |
@@ -175,7 +175,7 @@ Trace 下载对任意成员开放;导入仅限 owner(同 Agent 快照导入
| GET | /traces | 本 Session 的 Trace 文件列表 |
| GET | /traces/:index | 读取 Trace 事件(分页) |
| GET | /traces/:index/analysis | Trace 性能分析结果 |
| GET | /scratchpad/:fileName | 读取会话暂存文件(如输入图片) |
| GET | /scratchpad/:fileName | 读取会话暂存文件(如输入图片、文件附件) |
通用约定:无权访问的 Session 一律返回 404,不泄露其存在性;每个 Session 同时只允许一个 Task 或压缩在运行,冲突时返回 409(`task_in_progress` / `compacting`)。
@@ -208,6 +208,8 @@ Workspace 文件可能由 Agent 生成,`GET /files/content` 一律按不可信
| `preview=1` | 真实类型(`text/html`、`image/svg+xml` 等) | `inline` | `sandbox allow-scripts allow-popups allow-modals allow-forms`,仅对 `.html` / `.htm` / `.svg` 下发 |
| `download=1` | 真实类型 | `attachment` | 无 |
`GET /scratchpad/:fileName` 提供的同样是不可信字节(用户上传与 Agent 写下的临时文件),防护口径一致,只是没有那两个开关:始终带 `nosniff`;仅五种可安全内联的图片类型(`.png` / `.jpg` / `.jpeg` / `.gif` / `.webp`)按真实类型内联,供对话里的 `<img>` 使用;其余一律 `application/octet-stream` 并带 `Content-Disposition: attachment` —— 非图片内容无法在 App 所在源上作为文档渲染。
文件名始终以 `filename*=UTF-8''` 形式携带(百分号编码)。`preview=1` 是预览跳转在没有独立预览源时的回退目标:文档保留真实类型,可以正常渲染并执行脚本,但沙箱刻意不含 `allow-same-origin`,因此它落在一个不透明源里,既拿不到本源的 Cookie,也调不动 API。这份隔离也正是那里 `localStorage`、`document.cookie` 与第三方 embed 全都不可用的原因。
### 独立源预览
@@ -239,7 +241,14 @@ interface TaskCreateRequest {
}
type TaskInputPart =
| { type: "text"; text: string }
| { type: "image_url"; imageUrl: string }; // 粘贴图片以 data URL 上送
| { type: "image_url"; imageUrl: string } // 粘贴图片以 data URL 上送
// 文件附件:base64 data: URL,单个 ≤10MB(超出返回 413 file_too_large),单次请求最多 20 个、
// 解码后合计 ≤12MB(超出返回 413 too_many_files / payload_too_large;三项校验都在落盘前完成)。
// 服务端将其写入该 Session 的 scratchpad,并在消息文本末尾追加一行
// `[attached file: <path>]`——模型按路径读取该文件。`fileName` 不得含路径分隔符;落盘时保留
// 原有词形(`报告 2026.pdf` → `报告-2026.pdf`:非 ASCII 字符原样保留,对 shell 不友好的
// ASCII 字符替换为 `-`),既便于在消息中辨认,也可安全地拼进命令。
| { type: "file"; fileName: string; dataUrl: string };
// POST /api/sessions/:sessionId/approvals/:toolCallId
interface ApprovalDecisionRequest {
+1
View File
@@ -47,6 +47,7 @@ There are four approval modes: `allow-all`, `deny-all`, `read-only` (only read-o
### Input and Shortcuts
- Enter sends, Shift+Enter inserts a newline, and images can be pasted;
- The "+" menu holds the input add-ons: **image upload**, **file attachment** and goal mode. An attachment can be any type (up to 20 at a time, ≤ 10MB each and 12MB in total; an oversize pick is refused before it is read, so nothing is uploaded to earn the rejection); selected files show as removable chips above the text body in the order they were picked, and a message with attachments and no text is sendable. On send the files are written into the Session's scratchpad — deleted with the Session — and the message gains an `[attached file: <path>]` line per file, which the conversation renders as an "Attached files" notice: the bytes never enter the conversation, the model opens each file by path with its ordinary file tools;
- Typing `/` opens the slash menu: trigger context compaction (`/compact`), hand the conversation over to another Agent (`/agent`), switch the model (`/model`) — both switch commands appear in an active session only, since a draft has nothing to switch and picks its Agent and model up front — or toggle installed Skills — chosen Skills are sent along with the message in a `[use_skills]` block;
- While a Task is running the input stays live and the toolbar keeps a single action button: an empty composer shows **Stop**, and typing turns it into **Send**, whose behavior follows the **mid-run send mode** from the toolbar's More-settings popover (a compact extensible settings panel, also available in draft state; the choice is remembered): **Steer** (default) delivers the text mid-run as a `[user_steering]` user message with the next turn, **Queue** holds the whole message server-side as a follow-up and auto-sends it as an ordinary new message when the run finishes (an "N queued" hint shows near the input until then; the queue survives page reloads);
- `/agent` and `/model` stage their pick instead of acting on it: the chosen Agent or model becomes a chip above the text body and nothing is sent yet, so you keep typing — Enter/Send is what hands the conversation over (a new chat for that Agent) or forks it onto the chosen model, carrying the text along; with an empty composer a default message is filled in, and the chip's × cancels. Both chips are cached with the draft, so they survive a reload or a trip to another conversation together with the text. A model fork additionally waits for the Session to be idle — it continues from the Session's Trace, which a running turn or a compaction is still writing — and a line above the composer says so while it waits;
+1
View File
@@ -47,6 +47,7 @@ penguin web
### 输入与快捷操作
- Enter 发送,Shift+Enter 换行,支持粘贴图片;
- 「+」菜单收纳输入附加项:**上传图片**、**上传文件**与目标模式。附件不限类型(一次最多 20 个,单个 ≤ 10MB、合计 ≤ 12MB;超限的文件在读取前即被拒绝,不会先上传再报错),已选文件按选择顺序以可移除的小卡片显示在文本框上方;只带附件、没有正文也可发送。发送时文件写入该 Session 的 scratchpad(随 Session 一并删除),消息里每个文件追加一行 `[attached file: <path>]`,对话中渲染为一条「附加文件」提示:文件内容不进入对话,模型用普通文件工具按路径读取;
- 输入 `/` 打开快捷菜单:触发上下文压缩(`/compact`)、把会话交接给其他 Agent(`/agent`)、切换模型(`/model`)——两个切换命令都只在进行中的会话里提供,草稿没有可切换的对话,Agent 与模型本就在草稿页选定——或勾选已安装的 Skill——所选 Skill 会以 `[use_skills]` 块随消息发送;
- Task 运行期间输入框保持可用,工具条只保留一个操作按钮:输入框为空时是**停止**,一旦输入内容即变为**发送**,其行为遵循工具条「更多设置」弹出分组中的**运行中发送方式**(一个可扩展的设置面板,草稿态同样可设,选择会被记忆):**插话**(默认)把文字以 `[user_steering]` 用户消息随下一轮送达运行中的 Agent;**排队** 把整条消息暂存在服务端,本轮结束后自动作为普通新消息发出(期间在输入框附近显示「N 条已排队」提示;队列存放在服务端,刷新页面不丢失);
- `/agent` 与 `/model` 都是暂存而非立即生效:选中的 Agent 或模型只在文本区上方留下一枚 chip,此时不发送任何内容,可以继续输入——按 Enter / 点发送才真正交接(为该 Agent 新开一个对话)或换用所选模型继续本对话,输入的文字随之带走;正文为空时自动填入默认消息,点 chip 上的 × 即可取消。两枚 chip 都随草稿缓存,刷新页面或切到别的会话再回来时与文字一同恢复;其中切换模型还需等待会话空闲——新会话要从本会话的 Trace 接续,而运行中的一轮或压缩仍在写入——等待期间输入框上方会给出说明;