# pi-image-slim

[English](./README.md)

一个 [Pi](https://pi.dev) 扩展：压缩图片；如果你需要，还能让旧图不再反复重传。

## 问题

Pi 每次请求都会把整个对话发一遍。prompt cache 让服务端算起来便宜，但省不了你的上行带宽——一张图一旦进了对话，之后**每一轮**都会被重新上传，永远如此。

读了 30 MB 图片，就是每轮上传 30 MB。在 500 KB/s 的链路上，模型还没开始思考，光上传就要一分钟。

## 做法

两个时刻，各做一件事。第一件默认开着，第二件要你自己开。

**工具返回图片时**，压缩：

```
你的图片  →  小 6.4 倍的 WebP
```

**等它那一轮结束之后**，可以换成文字（`"strip": true`）：

```
[image removed from history to save upload bandwidth. Re-read /proj/title.png if you need to see it again.]
```

压缩会缩小分辨率并使用有损 WebP 编码，能显著减少字节，但可能损失小字和细线等细节。旧图替换会改变模型可见的历史，所以默认关着，由你决定。网络真的是瓶颈时再开：图片在保留窗口内仍会随每次请求上传，过期后留下占位符，以及可用的路径和描述。

## 安装

```bash
pi install npm:pi-image-slim
```

或者不改配置先试试：

```bash
pi -e npm:pi-image-slim
```

## 实测

Pi 官方文档里的四张真实图片，压到 1280 px / WebP / 质量 75：

| 文件 | 压缩前 | 压缩后 | |
| --- | --- | --- | --- |
| `tree-view.png` | 275 KB | 68 KB | 4.0x |
| `interactive-mode.png` | 506 KB | 84 KB | 6.0x |
| `exy.png` | 1475 KB | 179 KB | 8.2x |
| `doom-extension.png` | 167 KB | 44 KB | 3.7x |
| **合计** | **2.4 MB** | **377 KB** | **6.4x** |

这既省了图片被发送那一轮的带宽，**也**省了会话文件的磁盘空间。打开剥离后，还能省掉之后每一轮的带宽。

## 适合谁

- 你会把图片读进 Pi——截图、设计稿、浏览器抓图
- 你在用慢速、计费或需要代理的网络
- 你感觉请求很慢，但模型看起来在发呆

如果你只是偶尔贴一张图就再也不看，那不需要装这个。

## 它怎么工作

两个钩子，没有别的。

**`tool_result`** —— 工具返回图片时触发。

1. 把 PNG/JPEG 重新编码成 WebP，最长边限制在 `maxEdge`（`compress`）。
2. 如果开着剥离，让当前模型对“真正会被存下来的那份字节”写一句描述（`caption`）。
3. 把路径和描述作为普通文本写进对话：

```
Image file: /proj/docs/mockups/title.png
Image description: A dark settings panel with three toggles, the middle one off.
```

一次调用返回多张图时，描述会带序号，而且**不写路径**——Pi 对整次调用只报一个 `path`，把它写给每张图会让占位符指向一个只含其中一张的文件：

```
Image batch: 2 images from one call, no single file to re-read.
Image description: [1] Header 区域：深色导航栏，右侧一个登录按钮。
Image description: [2] Footer 区域：灰色链接列表，版权信息在底部。
```

因为这就是普通的对话记录，它能挺过 `/reload` 和进程重启；上下文压缩后能否保留具体细节，取决于压缩摘要。描述只生成一次，但它的 token 每轮都要重付。没开剥离时这些一个字都不会写：永远不会离开上下文的图，既不需要地址也不需要描述，那次模型往返也一并省掉。

**`before_provider_request`** —— 在请求体组装完、还没发出去的一刻触发。

保留轮次之外的图片变成占位符。这一遍**不解码图片数据**，只复制被修改的对象层级；会话中的图片不受剥离影响。Gemini 3 的工具响应媒体数组不接受文本块，因此占位说明写入同一个工具响应的 JSON 文本中。

### 模型每轮看到什么

| | 发送内容 |
| --- | --- |
| 你上次发言以来读进来的所有图 | 完整图片 |
| 更早轮次里的图 | 占位符 + 路径 |
| 那条描述 note | 始终保留 |
| 默认（`strip: false`）时 | 完整图片，永远都在 |

### 窗口按轮算，不按图片算

你上次发言之后 agent 产出的一切都完整发送。你再次开口时，上一轮的图片变成占位符，新一轮从干净的窗口开始。`keepTurns`（默认 `1`）把这个边界再往前推几轮。

- **同一轮**里的十张图片在整个那一轮里都完整可见，不管它们来自几次工具调用。按条数计窗口在这里是错的：第三张会把第一张挤出去，而任务正需要同时看这两张。
- 模型要再看 → 它对路径 `read` 一次，那一张图会被重新上传。
- 默认不设图片数量上限时，**同一轮内不会因为剥离而修改已有前缀**，有利于复用 prompt cache。旧图过期时会改变前缀；实际命中情况取决于供应商的缓存规则、有效期和其它请求变化。

代价是：agent 自己干活时没有新的用户消息，这一轮就不会结束，也就不会按轮次剥离。按 30 步、每步一张 68 KB 图片估算，累计图片上传约 **31 MB**（每张只传一次则约 2 MB）。`maxKeptImages` 就是为这个准备的，默认 `0`——不限，因为在一轮内部剥离会更频繁地改变缓存前缀。慢链路上把它设成 6–8，同样的循环约为 11–14 MB。

### 为什么是 WebP

在真实界面图上实测，同视觉质量下比 JPEG 小 30–45%，照片类内容则大致持平。AVIF 能再小些，但 Anthropic 直接 400 拒绝，而 Pi 会把不认识的格式重写成 PNG——反而更大。压缩是尽力而为的：不支持的格式、失败、或者结果没变小，都保持原字节不动。

## 配置

用 JSON 文件，不用环境变量。两个位置，越靠后优先级越高：

| 路径 | 作用范围 |
| --- | --- |
| `<agent-dir>/pi-image-slim.json` | 所有项目（默认为 `~/.pi/agent`） |
| `.pi/pi-image-slim.json` | 仅当前项目 |

```json
{
  "maxEdge": 1280,
  "quality": 75,
  "strip": false,
  "keepTurns": 1,
  "maxKeptImages": 0
}
```

| 键 | 默认 | 含义 |
| --- | --- | --- |
| `compress` | `true` | 把 PNG/JPEG 重新编码成 WebP |
| `caption` | `true` | 像素丢掉之前先写一句描述（只在 `strip` 开着时生效） |
| `strip` | `false` | 把旧图换成占位符 |
| `keepTurns` | `1` | 保留最近几轮里的图片（1–20） |
| `maxKeptImages` | `0` | 即使在保留的轮次里，也最多留下几条带图消息；`0` 表示不限 |
| `maxEdge` | `1280` | **较长边**的上限 |
| `quality` | `75` | WebP 质量，0–100 |
| `captionTimeoutMs` | `20000` | 超过这么久就退回纯路径；`0` 表示不限时 |

所有键都可选。未知的键和不合法的值会被忽略，文件格式坏了也只是退回默认值，不会弄坏工具结果。改完记得 `/reload`。

0.1.x 的 `keepImages` 已经不存在了。它数的是消息条数而不是轮次，所以直接忽略，而不是悄悄当成 `maxKeptImages`——那会给用户强加一个他没要求的限制。

### 选一个档位

| `compress` | `strip` | 效果 |
| --- | --- | --- |
| `true` | `false` | **默认。** 图片小 6 倍左右，模型始终能看到全部图片。 |
| `true` | `true` | 图片变小，旧的变成文字。 |
| `false` | `true` | 原图尺寸，旧的变成文字。 |

默认设置保留所有图片的可见性，但图片本身经过有损压缩。在上面四张样本中，图片字节数减少约 84%；实际收益和细节损失取决于图片。需要进一步减少历史图片上传时，再打开 `strip`。

`caption` 只在 `strip` 开着时才有意义：永远不会离开上下文的图，不需要描述，所以那次额外的模型往返会自动跳过。`keepTurns` 小于 1 会被忽略——模型总得能看见它刚要求的那张图。

**如果常看小字，把 `maxEdge` 调大** —— 1568 是常见选择。但别超过 2000：超过之后 Pi 自己的图片归一化会把结果重新编码，我们生成的 WebP 就白做了。

**`maxEdge` 量的是较长边**，所以 1200×4000 的手机竖屏图片按高度缩，和宽屏图片按宽度缩是一个道理。

## 关闭

没有 `enabled` 键，也没有环境变量。想关掉就在 Pi 的配置里禁用这个扩展，或者直接卸载——只有一种方式，而不是三种。

## 兼容性

占位符的形状按模型的 API 生成：

| API | 形状 |
| --- | --- |
| `openai-completions`、`anthropic-messages`、`mistral-conversations`、`pi-messages` | `{ type: "text", text }` |
| `openai-responses`、`azure-openai-responses`、`openai-codex-responses` | `{ type: "input_text", text }` |
| `google-generative-ai`、`google-vertex`、`bedrock-converse-stream` | `{ text }` |

支持这些协议的图片布局，包括嵌套工具结果，以及 `pi-messages` 的 `context.messages`。Google `functionResponse.parts` 的过期图片会被移除，占位说明加入该工具响应的 `response` JSON，不会向媒体数组插入文本块。

路径只从图片所属的工具结果中提取。OpenAI 把多个工具结果的图片合并后，如果无法可靠判断归属，占位符不提供重读路径；原有路径和描述 note 仍保留。

## 注意事项

- **压缩和描述只处理工具返回的图片。** `Ctrl+V` 不算例外：Pi 把剪贴板里的图片写成临时文件，再把路径插进输入框，模型要用 `read` 打开才知道里面是什么，那次 `read` 的结果就是工具返回。真正绕过这两步的是直接挂在消息上的图片——RPC 的 `images` 字段，或第一个 prompt 里的 `@shot.png`；开启 `strip` 后，它们仍可能在过期时被剥离。
- **压缩会损失细节。** 原文件不变，但会话保存的是压缩后的图片；同样配置下重新 `read` 仍会再次压缩。需要看清小字就调大 `maxEdge`，或关闭 `compress`。
- **描述可能是错的。** 一句自信但错误的描述比没有更糟，因为模型会照着它推理。描述由本来就要看这张图的模型当场生成，也没发给任何新的端点，但**没有被验证过**。设 `"caption": false` 可以关掉。
- **批量返回的图无法重新读取。** 一次调用返回多张图时，Pi 只报一个 `path`，所以它们谁也用不上。描述还在，像素没了。
- **旧图在长任务里仍然保留**，因为那一轮没结束。这是缓存友好的代价。如果上传比 miss 更痛，把 `maxKeptImages` 调小。
- **描述只生成一次，不会重试。** 如果那次请求超时，占位符就永远是纯路径。调大 `captionTimeoutMs` 可以降低概率。
- **开启 `strip` 和 `caption` 后，每张工具图片会多一次模型往返**，因为描述要在工具结果落盘前生成。图片是一张一张处理的，读十张就是十次往返。
- **剥离会改变缓存前缀。** 按轮次剥离可以减少同轮内的前缀变化；设置 `maxKeptImages` 后，变化可能更频繁。实际缓存命中范围和成本由供应商决定。
- **模型可能照描述回答，而不是重新看图。** 做像素级的工作时，直接让它再 `read` 一次那个文件。
- **轮次边界依赖 Pi 的序列化细节。** 已识别的工具结果和合成图片消息不算新轮次；用户的文字加图片算新轮次。无法区分来源的纯图片消息也不算新轮次，因此可能多保留一些旧图。未来 Pi 改变结构时需要重新核实兼容性。
- **旧会话不会被追溯处理。** 只有扩展启用期间读进来的图才会被压缩和描述。
- **会话文件里仍然保留压缩后的图片**，不只是文字。剥离只影响线上传输的部分。

## 开发

```bash
git clone https://github.com/wiidede/pi-image-slim
cd pi-image-slim
pnpm install
pnpm check       # lint + typecheck + 测试
pnpm test        # 不联网；读取 Pi 包内自带图片，配置测试会创建并清理临时文件
```

## License

MIT
