附件处理设计

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

一句话原则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.5model.input=["text","image"],无 document)。某行只有当模型声明对应模态时才翻转。

来源文件类型落盘DELIVER(现在, codex/gpt-5.5)READ 路径
uploadimageImageContent block(像素)模型 vision 原生
uploadtext/code[attachment:..@/abs] + ≤4KB 首部预览read 2000行/200KB 分页
uploadpdf[attachment:..(P页)@/abs] + 第1页+大纲pdf 80KB/页窗口
upload其它二进制[attachment:..@/abs] 仅提及bash file/strings/xxd
@-mentionimageImageContent block模型 vision 原生
@-mentiontext/pdf[attachment:..@/abs] + 首部(file-resolve)read/pdf 分页
打路径任意=@file-resolve 把裸路径按对应类型同等处理
远程渠道imageImageContent(从落盘字节重读)模型 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 显示层

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=4KBMAX_ATTACH_BYTES=32MB。唯一长期产品问题:大二进制永久累积在 per-session git 历史(“workdir = 自包含已提交状态”不变式的代价)是否可接受——现在有意保留这个不变式,不是 P0 待决项。

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