# 附件处理设计

OpenProgram Web 聊天 · 综合 Claude Code / opencode / openclaw 三家做法 + 自身约束

P0 已实现（本会话） 待浏览器验证 + 提交 基线 c29ef3dd
路径统一引用方案已提交于 `c29ef3dd`。本文是其演进：P0（首部预览 + 页/行数 + sha256 去重 + 32MB 超限保护） 已写完并通过后端单元测试；P1（provider 原生 document block）/ P2（远程渠道）为接缝/推迟项。
一句话原则：materialize once to a path; deliver the best block the active model accepts plus a small head preview; let the agent page the rest with its bounded tools.
附件字节最多落盘一次，用一个绝对路径标识；内容怎么到模型，每轮按 `(文件类型 × 模型声明的模态)` 重算，逐级降级。每个文件 prompt 成本 O(1)，与大小无关；同一次上传 codex 现在能用、换 Claude/Gemini 自动升级，前端零改动。

## 1 三层判断

#### ① 是不是图片？

是 → vision block（模型直接看像素）。图片永不落盘。

#### ② 有没有现成路径？

上传/远程＝没有 → 落盘到 workdir；`@`提及/打路径＝有 → 原地引用，零复制。

#### ③ 能力叠加层

只有模型声明支持 `document` 时，才把 PDF 升级成原生 document block（P1）。

## 2 决策矩阵（权威）

DELIVER 基于默认 `codex/gpt-5.5`（`model.input=["text","image"]`，无 document）。某行只有当模型声明对应模态时才翻转。

来源 | 文件类型 | 落盘 | DELIVER（现在, codex/gpt-5.5） | READ 路径
--- | --- | --- | --- | ---
upload | image | 否 | ImageContent block（像素） | 模型 vision 原生
upload | text/code | 是 | [attachment:..@/abs] + ≤4KB 首部预览 | read 2000行/200KB 分页
upload | pdf | 是 | [attachment:..(P页)@/abs] + 第1页+大纲 | pdf 80KB/页窗口
upload | 其它二进制 | 是 | [attachment:..@/abs] 仅提及 | bash file/strings/xxd
@-mention | image | 否 | ImageContent block | 模型 vision 原生
@-mention | text/pdf | 否 | [attachment:..@/abs] + 首部（file-resolve） | read/pdf 分页
打路径 | 任意 | ＝@ | file-resolve 把裸路径按对应类型同等处理
远程渠道 | image | 是 | ImageContent（从落盘字节重读） | 模型 vision 原生
远程渠道 | text/pdf | 是 | [attachment:..@/abs] + 首部预览 | read/pdf 分页

轴的纪律：`来源`轴只决定字节落在哪；`(文件类型 × 能力)` 是唯一决定 DELIVER 的东西。

## 3 能力自适应：降级链

一个纯函数 `choose_delivery(类型, 大小, 模型)` 就是全部决策；每一级降级都保持同一个稳定身份——落盘的绝对路径。

native_image / native_document
模型声明支持 → 原生 content block（图片像素 / 整 PDF base64）。document 需 size ≤ 10MB 且 provider 页数上限。
path_preview
不支持 document 或超 10MB → 落盘 + 路径提及 + ≤4KB 首部预览（首轮一次）。codex/gpt-5.5 的 PDF 走这级。
path_only
二进制无安全文本 → 仅路径提及，agent 用 bash 读。

误判永远是安全的：落到工具抽取，不会丢数据。`"document"` 暂当 “pdf-capable” 处理，docx/xlsx 一律 path_preview/path_only。

## 4 大文件保证：O(1) prompt 成本

后端塞进 prompt 的只可能是：(a) 一个 image block，(b) ≤4KB 首部预览（仅首轮），(c) 一条约 90 字节路径提及，或 (d) 受“模型能力 + size≤10MB”双门控的原生 doc block。其它一切只通过 agent 自己的有界分页工具逐页进上下文。

十个 30MB PDF 一起拖：那一轮约 `10×(90B + 4KB) ≈ 41KB`，之后为零——与大小无关。500 页 PDF 在 codex 上：落盘一次，提及带 “500 pages”，预览＝第1页 + 每页首行大纲（截到 ~50 条后 “…(450 more pages)”），8MB 本体永不进上下文；agent 靠大纲 `pdf(offset=N, limit=20)` 直接跳页。

实测上限（源码核验）：`pdf` 80KB 字符/次按页分页；`read` 2000 行/次、结果上限 200KB；`file_search` 的 256KB 只喂预览、永不喂交付。

## 5 这轮挖出的 bug

#### 已修 dedup 双拷

重拖同一文件 → 旧 `-N` 循环无字节比较 → 多存一份。修法：sha256 会话内去重（`.opdedup.json`，复用前校验）。

#### 归 P1 图片静默丢弃

本以为是 bug；实查 `validate_input_modalities` 在 HTTP 前会 raise，不是静默。属优雅降级增强（存盘 + image_analyze 提示），不咬默认模型。

#### 伪问题 symlink 逃逸

两个 proposer 担心根内符号链接指向根外——`.resolve()` 已解析符号链接、`is_relative_to` 已拒。现有代码无此洞。

## 6 存储 / 去重 / 安全

#### 落盘位置

per-session `workdir/attachments/`——就是 agent 的 cwd、每轮 git 提交、可重放。比 openclaw 全局 + TTL 更适合 agentic。只有无路径来源（上传/远程）落盘；`@`/打路径原地引用零复制。

#### 命名 + 去重

`_safe_attach_name`：basename + 非安全字符替 `_` + 120 上限，人类可读。sha256 仅作会话内去重索引，不做文件名。不同字节同名 → `-N`。

#### 超限

硬上限 32MB/文件、64MB/轮，落盘前检查；超限不存、提及改 “too large — not stored”，告诉模型，不给死路径。前端 25→32MB 对齐，超限不再静默丢、显示错误 chip。

#### 逃逸 + GC

上传/远程无源路径（沙箱）+ basename 清洗 → 结构上无法逃逸；`@`/打路径走 file-resolve 的 `resolve()+is_relative_to` → 越界 400。GC 会话级懒回收（删会话 = rm -rf workdir）。

## 7 显示层

- chip + scope 徽章：解析 `[attachment: name (type, KB[, P pages|L lines]) @ /abs]`，徽章显示 “500 pages” / “200K lines”；`@/abs` 显示时剥掉。
- 剥首部预览：`<attachment-preview>` 片段是给模型的，气泡里整块剥掉——用户看到 chip，不是 4KB 首部。在扫提及之前剥，防止预览里的文件内容生成假 chip。
- 乐观气泡时序：前端永远不知道落盘后的绝对路径（后端追加），所以乐观 chip 用客户端数据渲染、对账键 = 客户端 sha8；提及对“无路径（在途）/有路径（改写后）”两形式都渲染同一个 chip。
- 预览弹窗：本地完整解码、永不发送——人滚完整文件，模型只看 4KB 首部。
- 侧边栏标题：`_title_from_text` 在 50 字截断前剥提及 + 预览块，防止截断的长路径泄漏。

## 8 分阶段计划

#### P0 现在（codex 端到端）

`_persist`：32MB 上限 + “too large”；sha256 去重；页/行数注入括号组；一次性 `<attachment-preview>`（≤4KB 首部，二进制无预览）。
前端：scope 徽章 + 剥预览 + 超限诚实 chip + 25→32MB 对齐。
`_title_from_text` 扩剥预览。
价值 中小文档免费首部预览；大文件 O(1)；超限被告知；去重修双拷。

#### P1 推迟（需 doc-capable 模型）

各 provider 原生 document block 构建器（Anthropic document / Gemini inline_data）+ `NATIVE_DOC_INLINE_CAP` 守卫；`chat.py:307` 的“进 dispatcher 前剥文档”按能力条件化；`types.py` + `validate_modalities` 加 `"document"`。接缝在 P0 就位，到时零前端改动。

#### P2 推迟（需远程渠道）

openclaw 式入站 staging + 2 分钟 TTL + claim-check + `media://` + discord/wechat 适配器，把入站字节接进同一 `_persist`。保存+提及+预览的表示已能容纳“只有字节”的来源。

## 9 待定（不阻塞 P0）

两个可调常量，都有默认、都是单一旋钮：`PREVIEW_CAP=4KB`、`MAX_ATTACH_BYTES=32MB`。唯一长期产品问题：大二进制永久累积在 per-session git 历史（“workdir = 自包含已提交状态”不变式的代价）是否可接受——现在有意保留这个不变式，不是 P0 待决项。

完整 markdown 版：`docs/design/attachment-handling.md` · 报错超时设计：`docs/design/error-and-timeout-mechanism.html`