# Agent Note: Web 多模态图片输入与持久附件

Status: implemented

[English](2026-07-22-web-multimodal-image-input-and-durable-attachments.md) | 中文

## 问题

在此变更之前，Web 输入区仅接受文本：`InputBar` 接收字符串草稿，`ConversationService.send()` 创建文本内容，宿主再把该内容转发给 agent（智能体）。用户无法粘贴图片、在发送前查看图片、提交仅含图片的提示词，也无法从历史记录中恢复已发送图片。

这不只是输入区功能缺失。核心层需要持久图片内容块，提供方需要明确处理模态，会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../implemented/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。

[Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md)要求组件保持纯粹，并将每个会话的输入区状态放在 `ctx.conversation` 中；[GUI 分层与 RPC 协议](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此，图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。

同类产品普遍在编辑器上方设置附件栏，但存储方案各不相同。诸如 `/var/folders/.../codex-clipboard-*.png` 的 Codex 式路径适合作为接收输入时的暂存位置，却不能作为持久消息身份：操作系统可能删除文件，另一台宿主无法读取文件，恢复后的会话也不能依赖文件仍然存在。

## 决策

粘贴或拖放的光栅图片是 Web 输入区对持久附件能力的首个应用场景。未发送文件仍是由客户端持有的临时草稿状态。宿主在追加相应消息事件前，校验并持久提交每张已接受的用户图片。生成结构化图片输出的提供方适配器在追加相应助手块前，也必须持久提交输出。规范用户内容与助手内容只包含角色无关的 `ImageBlock` 引用。

第一版支持粘贴和拖放 PNG、JPEG、WebP 与 GIF，支持仅图片或混合提示词，支持渲染历史用户图片与助手图片，并支持双击预览原图。文件选择、通用文件、PDF、音频、视频、图片复制和自定义上下文菜单仍分别作为后续工作。

### 产品行为

- 粘贴或拖放一张或多张受支持的图片后，文本框上方会按顺序显示缩略图，但不会插入占位文本。文件拖入输入区时会高亮放置目标。
- 同一个常驻 `InputBar` 会在空白会话 Hero 和活跃会话布局中渲染附件栏。附件栏为空时隐藏，通过横向滚动避免撑宽输入区。
- 每个缩略图约为 72 × 72 像素，带有移除操作；双击时打开草稿原图。
- 提示词可同时包含文本与图片，也可仅包含图片。粘贴纯文本时保持浏览器原生行为；粘贴混合的剪贴板内容时，文本会正常插入，文件则同时添加到附件栏；仅粘贴文件时才阻止浏览器的默认处理。在输入区放置文件时总会阻止浏览器导航，并在本地报告不受支持的文件。
- 发送失败时恢复完整的文本与图片草稿，但不会覆盖请求飞行期间新增的文本或图片。移除、发送成功、会话 scope 释放、已渲染历史记录释放和应用释放都会撤销各自持有的对象 URL。
- 历史用户图片与助手图片共用一个 `MessageImage` 控件。行内图片保持固有宽高比、不放大，并限制在 240 × 240 像素的边界框内。
- 双击消息图片会在不超出视口的模态框中打开存储的原图。按 Escape、激活关闭控件或激活背景区域都会关闭模态框并恢复焦点。
- 第一版不覆盖浏览器上下文菜单，也不提供明确的图片复制操作。

### 存储生命周期与归属

持久化边界是消息被接受，而不是图片被粘贴：

| 状态 | 允许的表示 | 持久性与顺序 |
| --- | --- | --- |
| 未发送的用户草稿 | 浏览器 `File` 加对象 URL；原生客户端可以使用 `/var/...` 等操作系统临时文件 | 临时且由客户端持有。它可能在重载或进程退出后消失，绝不出现在会话事件中。 |
| 已接受的用户图片 | `DSH_HOME` 下的不可变对象加 `ImageAttachmentRef` | 在 `agent.send()` 或 `agent.steer()` 能够追加所属用户事件前，宿主提交每张图片。 |
| 结构化模型图片输出 | `DSH_HOME` 下的不可变对象加 `ImageAttachmentRef` | 提供方适配器在发出已完成的图片块或助手消息事件前提交字节。事件中禁止出现临时 URL、路径和 base64。 |

每个会话的 `InputMachine` 状态在实时草稿旁保存仅限运行时的有序附件标识符。框架持有的 chat store 只接收草稿的纯文本持久化镜像，`ConversationService` 则持有相应的浏览器专用 `File` 与对象 URL 注册表：

```ts
import type { Branded } from '@deepseek-ai/dsh-brand'

type DraftAttachmentId = Branded<'DraftAttachmentId'>

interface ChatStoreState {
  selection: object | null
  draft: string
  view: string | null
}

interface InputState {
  draft: string
  imageIds: readonly DraftAttachmentId[]
}

interface ComposerAttachment {
  kind: 'image'
  id: DraftAttachmentId
  file: File
  previewUrl: string
}
```

这一拆分把会话 provide 通道的输入 hook 与 actions 用作实时输入区状态的唯一订阅路径，同时避免把不可序列化的浏览器对象写进持久 JSON。只有纯文本草稿镜像使用 `localStorage`；附件标识符、浏览器 `File` 对象和对象 URL 都限定在实时会话输入外壳的 scope 内。未发送图片因此无法跨重载或会话 scope 释放保留。切换 Workspace 时，只有目标外壳接受完整图片批次，图文混合草稿才会移动；拒绝时，文本和图片都留在来源外壳。原生客户端可以在操作系统临时目录中暂存输入，但必须像对待浏览器对象 URL 一样对待该路径：不再需要时删除，并在消息被接受前把字节复制进持久存储。

本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects/<prefix>/<sha256>` 下，并为目录和文件设置仅所有者可访问的权限。每个进程首次为某个 home 保存对象时，都会创建该 home，并逐级同步每个祖先目录项直至文件系统根目录；不能把存在视为持久性，因为另一个进程可能仍处于 `mkdir` 与父目录 `fsync` 之间。随后，服务写入并同步临时文件，再以原子方式发布，并对发布路径执行目录同步使其持久（POSIX；Windows 依赖文件系统元数据日志），之后才返回引用。内容摘要编码在不透明的 `sha256:<digest>` 标识符中。写入准入与读取都会完整解码受支持的光栅图片，之后才接受其格式和尺寸；每次读取还会校验摘要、字节长度和已记录的元数据。

第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留，以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计，因为仅按时间清理可能删除仍被持久会话引用的数据。部署的字节和像素限制是写入时的准入策略；读取时会校验摘要和已记录的元数据，但不重新应用当前准入限制，因此收紧策略不会导致旧历史记录失效。

### 持久内容与提示词协议

附件服务边界公开不可变图片写入和经过校验的读取操作。规范元数据刻意比通用文件记录更窄：

```ts
import type { Branded } from '@deepseek-ai/dsh-brand'

type AttachmentId = Branded<'AttachmentId'>

interface ImageAttachmentRef {
  attachmentId: AttachmentId
  mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
  bytes: number
  width: number
  height: number
  name?: string
}

interface ImageBlock {
  type: 'image'
  attachment: ImageAttachmentRef
}
```

`ImageBlock` 加入可合并扩展的核心 `ContentBlockMap`，在用户内容和助手内容中都有效。它绝不携带 base64、对象 URL、文件系统路径或提供方持有的定位符。因此，会话事件与不可变对象存储足以共同重建模型可见的确切图片。LLM 词汇由此仅在类型层面依赖附件服务边界；提供方运行时依赖仍由各适配器持有。

浏览器无法生成持久引用，因此 `session.prompt` 接受范围狭窄的接收联合类型，而不是规范 `ContentBlock[]`：

```ts
export {}

type PromptInputPart =
  | { type: 'text'; text: string }
  | {
      type: 'image'
      mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
      data: string
      name?: string
    }
```

Base64 只跨越一次 JSON-RPC，并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、声明的 MIME 与完整解码后的光栅图片是否一致、固有尺寸和解码像素数。它会在保存任何成员之前，等待服务边界上不触碰存储的 `validateImage` 完成对每个批次成员的校验，因此一张畸形图片不会把批次中的有效成员留成无引用对象。随后按提交顺序执行存储提交，以限制完整光栅解码器的内存占用。如果后续存储 I/O 操作失败，宿主不会追加用户事件，但先前的不可变内容寻址对象可能保持无引用状态；第一版将清理留给未来按引用感知的垃圾回收，而不向去重存储添加破坏性回滚。只有每张图片都成功后，宿主才会用规范化文本和按提交顺序排列的持久图片块调用 agent。失败时不公开任何附件路径或原始字节。

`session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符，宿主才提供字节。会话处于渲染状态时，客户端会按会话和附件标识符对加载操作去重；已渲染会话释放时会撤销已解析的 URL，并在分配对象 URL 前拒绝已失效的延迟加载，以免已卸载的会话或已释放的服务重新写入缓存。

### 模型能力与提供方行为

模型目录项增加可选且可合并扩展的输入模态声明。缺少声明表示未知；声明存在但不含 `image`，则明确表示不支持图片。

宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型，并在缺失时依次回退到 agent 选项和宿主默认值；如果该模型明确排除图片输入，宿主会在写入任何附件或事件前拒绝提示词，客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界，而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态（[顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md)）；steering 载体则从入队起就参与门槛，直到其 `steering/message` 事件发布为止，堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时，模型选择会拒绝纯文本目标。压缩（compaction）可以移除旧图片，使之后选择纯文本目标变得有效；未发布任何事件即转入空闲时，已认领的 queued 载体会被释放，而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容，因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查，使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型，但不会为部署限制或模型能力保留快照：握手快照无法表达 `session.selectModel` 之后会话的当前目标，部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次，再写入任何附件或事件；其拒绝通过 composer 错误条呈现。

Pi-AI 适配器是首条视觉输入路径：它在请求时解析 `ctx.attachments`，递归转换每个持久图片引用，包括嵌套在工具结果中的引用，并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由；选择当前提供方／模型仍由宿主组合或配置承担，而不是图片输入 CLI（命令行界面）的功能。在请求时解析服务，可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误，包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。

核心层支持结构化助手图片块，但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节，通过同一个附件服务校验并持久化字节，之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本，绝不自动下载。

提供方无关的 token 估算不会根据图片尺寸猜测视觉定价；提供方返回的用量仍是权威值。在 ACP（Agent Client Protocol）接口原生支持图片前，ACP 会渲染明确的图片标记，而不是静默省略该块。

压缩会把选定的会话前缀（包含图片引用）回放到已配置的摘要生成路径中。支持视觉的路径会通过适配器解析这些引用；仅文本路径会明确失败，而不是静默丢弃视觉上下文。合成的检查点仍仅包含文本，`compact-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。

### 历史渲染与原图预览

历史记录折叠会在用户消息和助手消息中保留 `ImageBlock`。用户图片在文本上方靠尾端对齐；助手图片则保留在靠前端叙述流中的原内容块位置。`MessageImage` 根据记录的尺寸派生稳定的行内边界框，通过会话授权加载器解析字节，使用 `object-fit: contain`，并将对象缺失或损坏转换为可重试的错误控件。

输入区缩略图和每个 `MessageImage` 各自持有临时原图预览状态，并调用同一个纯 `ImageLightbox`。模态框使用已经解析的原始对象 URL，只限制显示尺寸；它会聚焦关闭控件，并在关闭时把焦点恢复到先前的目标。

### 限制与信任边界

第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数，以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置，并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限（默认 32 MiB）；如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小，加载就会失败。因此，降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝，而不是先读完再拒。

格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段，控制字符会被移除，并且任何本地路径都不会写入日志或返回浏览器。

### 包与接口变更

| 接口 | 职责 |
| --- | --- |
| `packages/attachment/attachment` | 不透明附件标识符、图片引用、限制、错误和 `ctx.attachments` 服务。 |
| `packages/attachment/attachment-local` | 私有内容寻址存储、完整光栅解码、完整性校验和配置。 |
| `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 |
| `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 |
| `packages/llm/llm-deepseek` | 明确拒绝图片内容。 |
| `packages/compact/compact-basic` | 在摘要输入中保留图片，并明确拒绝非文本检查点输出。 |
| `packages/host/apiproxy` 和 `packages/bundle/base` | 范围狭窄的上传协议、先持久化再追加事件的顺序、会话授权读取、限制和模型前置检查，以及默认 profile 组合。 |
| `packages/client/connection` 和 `packages/client/runtime` | 有界请求缓冲、协议类型、fixture（测试前置数据）图片、提示词上传、附件读取和持久引用折叠。 |
| `packages/client/ui-conversation` | 每个会话的草稿图片、附件栏、用户与助手图片控件和原图预览。 |
| `packages/acp/acp` | 图片块的明确兜底渲染。 |

附件包（package）构成一个能力服务边界的接口与实现侧。输入区行为留在会话对象层，提供方转换留在适配器中，无需修改 `agent-loop`。

### 实现

已实现的范围包括附件服务边界、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、宿主持久化顺序、Web 上传与读取协议、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、双击预览、压缩处理，以及组装后无需密钥的 Web 覆盖。

预发布提示词协议不需要兼容包装层；引入相应切片时会同时修改所有调用点和 fixture。

## 曾考虑的替代方案

### 将每张粘贴图片保留在 `/var` 或其他临时目录中

临时存储适合在发送前使用，也适合通过操作系统接收剪贴板文件的原生客户端。但它不适合在消息被接受后继续使用：清理不在 harness 的控制范围内，路径因宿主而异，恢复或 fork 后的会话也可能比文件存在得更久。提案允许临时暂存，但会在追加事件前将已接受的字节复制进 `DSH_HOME`。

### 粘贴或拖放后立即持久化

立即持久化可以让草稿在重载后继续存在，但会在会话或消息持有对象前就创建持久对象，因此必须定义配额、遗留对象生命周期和清理策略。第一版保持未发送草稿为临时状态，并把发送被接受作为持久性边界。

### 在消息与会话日志中内联 base64

这种方式会在 RPC、事件、历史分页、fork、压缩和浏览器存储中复制二进制数据，还会诱使 token 计量把编码文本当成模型文本。单一不可变对象配合小型引用，可以让持久表示保持有界。

### 使用浏览器对象 URL、本地路径或提供方 URL 作为规范内容

对象 URL 会随文档失效，本地路径不可移植，提供方 URL 则可能过期、跟踪查看者或暴露凭据。它们只能作为临时传输或预览细节存在。

### 用一个通用 `AttachmentBlock` 表示图片、文件、音频和视频

输入区展示可以使用通用附件栏，但提供方语义取决于具体模态。图片是原生多模态输入；PDF 可能是提供方文件或提取后的文本；视频可能由模型原生支持、抽帧处理或不受支持。特定的 `ImageBlock` 会迫使每个消费方明确处理或拒绝该模态。

### 依赖 UI 能力检查或静默过滤图片

UI 状态可能陈旧，也无法保护直接 SDK、ACP、回放或未收录模型的路径。静默过滤会改变用户意图。提供方强制检查仍是必需项，UI 检查则是可选的提前反馈。

## 测试

- 存储测试覆盖内容寻址去重、私有权限、准入失败、对象损坏或缺失时的失败，以及收紧部署限制后读取历史数据。
- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的竞态（排队与 steering 两种放置）、待发布状态、未发布即空闲时的门槛释放、仅文本的队列编辑，以及压缩后依据当前派生历史进行的选择。
- 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序、草稿、会话作用域和应用层级的对象 URL 清理，以及一项在释放后才完成的延迟历史读取；keyless 的组装后构建产物通道（`apps/web/tests/image-display.snapshot.ts`，`DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`）覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox，以及 composer 粘贴缩略图条。
- 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、递归嵌套在工具结果中的图片、保留摘要输入，以及明确拒绝图片输出。
- 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG，并要求模型识别其中的二维码。
- 当前生产适配器集合没有经过认证的图片输出路由；输出提供方认证仍不在第一版范围内。

## 后果

- 持久存储会在没有垃圾回收时持续增长。第一版选择回放安全，而不是过早删除。
- 对象缺失或损坏会让模型请求无法精确重建。明确失败可以保持完整性，但在修复前可能阻止该会话继续运行。
- JSON-RPC base64 会增加上传内存，并带来约三分之一的编码开销。第一版的限制可以约束开销；更大的媒体需要流式传输或二进制传输协议。
- 未发送图片无法跨重载保留。持久草稿需要配额和遗留对象清理，而不是隐式复用消息存储。
- 原图预览解码的像素多于行内控件显示的像素。像素限制、一次只打开一个预览和对象 URL 释放可以约束但无法消除浏览器瞬时内存占用。
- 能力元数据可能缺失或陈旧。宿主前置检查可以改善反馈，适配器强制检查仍是权威结果。
- 未来输出提供方可能需要经过身份认证的下载，助手图片才能完成，这会增加延迟与新的故障点。先持久化再追加事件的顺序优先保障回放完整性。
- 文件选择、通用文件与 PDF、音频与视频、持久草稿暂存、图片复制、自定义上下文菜单、输出提供方认证和按引用感知的垃圾回收仍是相互独立的设计。
