用户发进来的文件和 agent 交出去的文件,在聊天流里各自是什么样子、能不能点开。四层:现状、八家对照、下一步、理想形态。附件怎么进模型上下文是另一个主题,写在 attachment-handling(图解版),本页只在需要时引用它的结论。两份文档共用 openprogram/attachments.py 里那一份标记词法。
附件的字节、正文里的标记、模型看到的内容、用户看到的东西,是四件独立的事。四条入站路现在走同一套规则:字节落一次盘,正文里留一条同词法的标记,模型该看图看图、该拿路径拿路径,聊天流按同一个解析器画 chip。
attachments.format_marker 唯一生成,chip 由 parseAttachments 唯一解析;再加第五条入站路只要调这个 formatter,不能自己写一套。网页图片以前只在内存里 base64,模型看得见人看不见,现在跟文档共用同一条落盘路(sha256 去重、32MB/64MB 上限、路径回写),同时照旧作为 ImageContent 送进模型。
openprogram/attachments.py · openprogram/channels/_attachments.py:181 · openprogram/webui/ws_actions/chat.py:152-300 · web/components/chat/messages/user-attachments.tsx
parseAttachments 从正文里摘出标记,AttachmentChips 画 chip,AttachmentPreview 用 FileViewer 的绝对路径模式打开。用户气泡、助手气泡、消息导轨走的是同一份。
web/components/chat/messages/user-attachments.tsx · attachment-preview.tsx · web/components/files/file-viewer.tsx · web/lib/net/chat-stream.ts
MEDIA: 指令行。正文约定的失败模式是"抽不干净就当正文漏给用户",这个亏在渠道入站那侧已经吃过一次。工具调用有存在性校验,也有地方放"文件不在了"这种诚实反馈,而且模型只是在正文里提一句路径不会被当成投递意图。
openprogram/functions/tools/send_file/send_file.py · openprogram/agent/dispatcher/__init__.py · openprogram/channels/_conversation.py:_deliver_outbound_files
/files/raw。它"绝对路径一律拒绝"是值得保留的不变量,两条路由各守各的契约。查看器一份不动:FileViewer 多一个 abs 入参,把两个出口切到绝对路径路由,图片、pdf、markdown、带行号代码、下载卡五种渲染判断仍然只有一处实现。
openprogram/attachments.py:readable_roots · openprogram/webui/routes/file_search.py · openprogram/webui/server.py:1498-1531 · web/components/files/file-viewer.tsx
| 需求 | 现在做得到吗 | 怎么做到的 | 还差什么 |
|---|---|---|---|
| 用户发的图 能点开看 |
可以 缩略图加浮层 |
图片接进文档那条落盘路(sha256 去重、32MB/64MB 上限、路径回写),同时保留 ImageContent 块;无文件名的粘贴图按 media type 补出扩展名;chip 正则捕获路径 | 只有浮层没有中心标签页;大图没有分级缩略图 |
| agent 发图 发文件给用户 |
可以 网页渠道两条路 |
send_file 登记,dispatcher 折进 final_text,网页画 chip、渠道调已有的 post_file;unsafe_in 按渠道声明能力 |
没有 caption 参数;插件与 MCP 工具还没有各自的出站白名单 |
| 可读文件 点开就能看 |
可以 图片 pdf md 代码 下载卡 |
新增 /api/file-raw 与绝对路径版 /api/file-read,FileViewer 加 abs 模式,浮层里复用它 |
代码没有语法高亮(pre 加行号够用);没有中心标签页入口 |
[attachment: /abs (image/png, 20481 bytes)] 会当成正文渲染出来。根因是渠道那侧自己写了一套词法(bytes 而不是 KB,也没有 @ 路径),chip 的两条正则都落空。现在两侧共用 format_marker,网页上它是一张可点的缩略图 chip。
references 下八家全部读过。没有这个能力的如实标“没有”。八家里有五家能让 agent 主动把文件交给用户,其中三家用的是同一个招数:在回复正文里写一条指令行,投递层把它抽走。
| 仓库 | 入站怎么进上下文 | 界面展示与点开 | agent 主动发文件 | 可读文件预览 · 清理 |
|---|---|---|---|---|
| claude-code TUI |
图片 base64 内联块。字节存 ~/.claude/image-cache/<会话>/,权限 0600。5MB 加 2000px 上限,sharp 缩放,JPEG 质量梯 80/60/40/20。桥接进来的网页与手机附件走路径注入,交给读工具
utils/imageStore.ts:9-67 · utils/imageResizer.ts:256-334 · bridge/inboundAttachments.ts:2-11 |
[Image #N] 文本 chip,OSC-8 超链接可点,点开走系统默认看图器。非图片是一行摘要 Read <路径> (N lines)。终端内联图协议:没有
components/ClickableImageRef.tsx:16-34 · messages/AttachmentMessage.tsx:131-158 |
有,专门的工具参数。SendUserMessage(旧名 Brief)带 attachments: string[],描述写的是“照片、截图、diff、日志,任何用户应该看到的文件”。上传到服务端换 file_uuid 给手机与网页客户端渲染,30MB 上限。兄弟工具 SendUserFile 只在引用里出现,这份 dump 里目录缺失
tools/BriefTool/BriefTool.ts:25-30 · tools/BriefTool/upload.ts:32-166 · tools.ts:42-43 |
TUI 内置 markdown 渲染加语法高亮。PDF 转 document 块,或用 poppler 按页转 JPEG。图片没有内置查看器,靠 OSC-8 甩给系统。清理:每次会话启动清掉其他会话的图片缓存,内存路径表 200 条 LRU utils/imageStore.ts:129-150 |
| codex-cli TUI |
base64 data URL 作 input_image,外面包一层 <image name path> 标签。字节留在原地不复制。2048px 上限加 patch 预算。sha1 内容寻址内存缓存,64MB LRU。远程 URL 直接拒绝
protocol/src/local_media.rs:21-35 · utils/image/src/lib.rs:26-203 |
[Image #N] 纯标签,不可点。kitty 与 sixel 协议在仓库里,只给吉祥物用,附件一次都没走过
tui/src/pets/image_protocol.rs:27-31 · tui/src/history_cell/messages.rs:95-96 |
没有通用工具。图片生成写盘并打印 file://,提示语写“图片已经展示给用户了”
ext/image-generation/src/artifact.rs:31-43 |
TUI markdown 加 syntect 高亮。没有图片与 PDF 查看器。file_opener 配置把文件引用改写成 vscode:// URI 交给编辑器
core/src/config/mod.rs:939-941 |
| openclaw 网页 + TUI |
两种模式按能力切。模型能看就 base64 块,看不了就下沉到 media store 换一张认领单 media://inbound/<id> 写进正文。store 权限 0700,inbound 与 outbound 两个子目录。图 6MB 音视频 16MB 文档 100MB。EXIF 归正后重编码 JPEG
gateway/chat-attachments.ts:391-393 · media/store.ts:146-152 · media/constants.ts:1-4 |
网页真缩略图,点击开新标签页。非图片是卡片,音频带原生播放器,还有“检查中 / 不可用”状态徽标。TUI 只有标签 ui/src/ui/chat/grouped-render.ts:822-830,1203-1290 | 有,双通道。正文里单独一行 MEDIA:<路径或URL>,系统提示里教;只有白名单核心工具能发本地路径,插件与 MCP 工具被排除,防止诱导读到不该读的文件。另有 message 工具带 attachments/media/caption/asDocument 等参数。出站文件先落 store 的 outbound 子目录再交给渠道
agents/system-prompt.ts:424-426 · agents/pi-embedded-subscribe.tools.ts:280-300 · media/outbound-attachment.ts:5-30 |
网页 markdown-it 加 DOMPurify 消毒,音视频原生播放器。PDF 与文档是抽成文本给模型,不是给人看。清理:媒体 TTL 两分钟 media/document-extractors.runtime.ts:14-45 · media/store.ts:24,188 |
| opencode 网页 + TUI |
base64 data URL 存进会话 SQLite 的 part.data 列,不落盘。@ 提及的文件在提交时真的跑一遍读工具,把结果作为两条合成纯文本部分注入。5MB 加 2000px,Lanczos3 缩放,JPEG 质量梯。按 URL 去重
session/prompt.ts:888-922,982-1002 · image/image.ts:10-136 |
网页图片卡片,点击开灯箱对话框。非图片是文件图标卡,不可点。@ 提及不是卡片,是正文里按字符偏移高亮的片段。TUI 有 MIME 徽标加文件名
ui/src/components/message-part.tsx:1103-1164 · tui/src/routes/session/index.tsx:1350-1358 |
没有。工具可以返回 attachments,但那是流回模型上下文的,不是给用户的。没有任何渠道适配器
tool/tool.ts:52 |
网页内置。GET /file/content 返回文本或 base64 二进制,前端渲染图片音频与 SVG,另有文件标签页与 diff 视图。还有一个“用外部程序打开”菜单,开的是项目不是附件。清理:没有,data URL 随会话行级联删除
server/routes/…/file.ts:62-100 · ui/src/components/file-media.tsx:31-70 |
| hermes-agent 聊天渠道为主 |
两种路由模式按轮切。native 模式内联 base64 并额外附一条路径提示,让模型两样都有;text 模式先跑 vision_analyze 把图片描述前置,模型永远看不到像素。文档只给沙箱内路径,文本文件另外内联内容。字节按类型分缓存目录。不预先限制大小,被服务端拒了再压到 4MB agent/image_routing.py:298-379 · gateway/run.py:7794-7900 | 主界面是 Telegram 与 Discord 自己的原生渲染。自家 CLI 与 TUI 只有一行标签,网页那侧是 xterm.js 终端流。三者都不可点 cli.py:5649 · web/src/pages/ChatPage.tsx:19-23 | 有,八家里最完整。正文 MEDIA:<path> 指令加 send_message 工具两条路。安全闸 validate_media_delivery_path:符号链接先解析再判断包含关系,必须落在安全根或运维白名单里。能力按平台在系统提示里分别声明,CLI 明确写“这里没有附件通道,不要发 MEDIA 标签”。 也会被自动抽出来发成照片
gateway/platforms/base.py:867-905,2262-2301 · agent/prompt_builder.py:420-597 |
没有通用查看器,CLI 用 rich 渲染 markdown。入站文本文档直接内联进提示而不是预览。清理:图片与文档缓存 24 小时 TTL gateway/platforms/base.py:672-690,989-1005 |
| pi-mono TUI |
@file 参数内联,同时给模型一条 <file name> 文本标记。剪贴板粘贴反而只给路径:写进 tmp 文件,把路径当文本插进编辑器,模型自己去读。2000px 加 4.5MB,先 PNG 再 JPEG 质量梯,跑在 worker 里,用户可在设置里关掉
cli/file-processor.ts:48-89 · modes/interactive/interactive-mode.ts:2442-2462 · utils/image-resize-core.ts:22-127 |
八家里唯一真正做终端内联图的。kitty 与 iTerm2 协议,tmux 与 screen 下强制关闭,不支持时退回 [Image: 名字 [mime] 800x600]。但只渲染工具结果里的图,用户自己发的图不回显
tui/src/terminal-image.ts:1-109,447-453 · modes/interactive/components/user-message.ts:11-34 |
没有。工具只有 bash edit find grep ls read write,没有渠道适配器 | TUI markdown 加语法高亮加 diff 加终端内联图。没有 PDF。清理:tmp 剪贴板图片没有任何清理 |
| pi-ai 协议层 |
只是 pi-mono 里那个包的只读副本,十二个文件,没有界面没有工具。图片 base64,模型不声明 image 输入能力时整块剥掉。工具结果里带图会被拆成文本加一条合成用户消息 providers/openai-completions.ts:561-570,657-714 | 没有 | 没有 | 没有 |
| weclaw 微信 |
agent 根本看不到图。入站图片在到达 agent 之前就被短路:下载、嗅探魔数、写成 <时间戳>.<ext> 加一份 sidecar,回一句“Saved: 文件名”。语音是例外,用微信自己的转写文本当提示词。没有大小上限
messaging/handler.go:288-294,726-783 |
没有界面,微信就是客户端 | 有,而且是隐式的。不需要任何约定:回复里裸的绝对路径行或  都会被抽出来上传,然后把那行原地改写成“已发送附件:文件名”,失败就追加“附件发送失败”。附件必须在允许根下,默认 ~/.weclaw/workspace,另有扩展名白名单
messaging/attachment.go:17-107 · messaging/handler.go:491-524 |
没有。markdown 是被剥掉而不是渲染,转成纯文本发给微信 messaging/markdown.go:7-40 |
openclaw 把文档物化成 /__openclaw__/canvas/documents/<id>/index.html,聊天里用 [embed ref="cv_123"] 内联渲染,系统提示明确禁止在 embed 里用本地路径。codex 让助手输出 ::codex-inline-vis{ 指令行,TUI 把 HTML 片段物化成沙箱查看器并把那行改写成一个浏览器链接,片段 2MB 上限加严格 CSP。pi-mono 的 /share 把整个会话导出成自包含 HTML,发成私密 gist,给一个托管查看器地址。
openclaw 的 /__openclaw__/assistant-media?source=…&mediaTicket=…,票据格式 v1.<载荷>.<签名>,TTL 五分钟。这是“网页要看字节但不能给它任意路径”这个问题的一个成熟答案,比单纯校验允许根更严。
claude-code 把 [Image #N] 当成光标与 vim 操作里不可分割的一个原子单位,而且只有占位符还留在正文里,图片才会被发出去。用户删掉那个 chip 就等于取消这次附件,不需要另一个删除按钮。
view_image 工具让模型自己把本地图片拉进上下文;pi-ai 在模型不声明 image 输入能力时把图片块整个剥掉,正是 attachment-handling 里记过的那个降级缺口;媒体清理三家三个尺度,openclaw 两分钟、hermes 二十四小时、claude-code 每次会话启动清掉别的会话。
role: "user" 的文本,不是 tool_use 与 tool_result 内容块。拒绝这条路线的三个理由不受影响,只是描述错了;两份文档的正文都已更正。
三件需求都已落地,第一层描述的就是落地后的行为。这里只剩当时明确压后、或者做的过程中冒出来的东西,都不阻塞用户已经能用的路径。
| 剩下的 | 它买到什么 | 为什么当时没做 | 什么时候做 |
|---|---|---|---|
| 中心文件标签页 作为第二种打开方式 |
需要长期停留的大文件不必一直占着浮层,可以跟聊天分栏并排读 | 中心标签页按 (projectId, 相对路径) 建键,加一种绝对路径标签要动 center-tabs-store 和它的一致性检查脚本;浮层零改动就覆盖了"扫一眼"这个主要场景 |
有人真的想把一个附件读很久的时候。turn-files-chips 已经在用 fileTabId 加 useCenterTabs,照抄即可 |
send_file 的 caption |
Telegram sendPhoto 能把说明文字焊在图片上,而不是分成两条消息 | 标记词法要跟入站完全一致,塞不下 caption;而回信正文本来就跟文件一起送达,重复一遍没有收益 | 某个平台的呈现确实需要文字贴在附件上的时候 |
| 出站工具白名单 | openclaw 那一层:只有核心工具能发本地路径,插件与 MCP 工具一律排除,防止被诱导读走不该读的文件 | 现在只有 send_file 一个入口能触发出站投递,而它自己就带允许根检查,再加一层没有新的东西可挡 |
插件或 MCP 工具能自己触发出站投递的那一天 |
| 签名短时票据 | openclaw 给媒体端点加了五分钟 TTL 的签名票据,比单纯校验允许根更严 | web UI 只监听本地端口,前面已经有同源守卫,路由本身又校验允许根,票据在这个前提下买不到额外的东西 | web UI 暴露到本地之外的第一天,这是第一件要补的事 |
| 代码高亮 | 点开一个 .py 附件时不是一片单色 | FileViewer 的带行号 pre 够用;docs_site 的 pygments 是构建期的,接不进运行期单文件渲染 |
有人真的抱怨的时候 |
sendable_roots 就会把整个 home 变成出站可读——任何一个临时会话里模型都能把 home 下任意文件读出去发给用户。所以允许根只认非默认项目的绑定。这是跑真实状态目录时发现的,静态读代码看不出来,因为 project_for_session 这个名字听上去就该只返回显式绑定。
一句话:任何一个文件,不管从哪个渠道进来、谁产生的、什么格式,在任何一个界面里都能用同一套查看器打开;agent 交出来的文件和用户传进去的文件,除了一个来源字段之外没有区别。下面这张图不受当前实现约束,虚线部分是已知做不到或代价太高的,理由跟在图后。
| 目标形态 | 它解决什么 | 现在的差距 | 为什么现在不做 |
|---|---|---|---|
| 一条附件记录 正文只留引用 |
重命名、去重、跨会话引用、“这个文件在哪几轮被提到过”都从字符串处理变成查询 | 全部信息编码在一句正文标记里,靠两条正则解析 | 要改 session_db schema 并迁移历史消息。正文标记今天承载得住全部必要信息,等“跨会话引用同一个文件”成为真实需求再动 |
| 一个解析器 四个界面 |
网页、中心标签页、浮层、终端拿到同一份“这个资源该怎么渲染”的判断 | 网页侧已经成立(parseAttachments 加 FileViewer 一份实现);TUI 完全没有附件概念,组合器里连贴图都不能 |
TUI 是一条独立工作线,不阻塞网页。八家里只有 pi-mono 真做了终端内联图,而且它连用户自己发的图都不回显;claude-code 与 codex 都只画一个 [Image #N] 标签。这条的实际收益低于看上去 |
| 双向完全对称 | 用户给的和 agent 给的在数据模型和界面上没有区别,只差一个来源字段 | 词法、解析器、chip、查看器四样已经完全共用,剩下的不对称只在降级路径 | 已经做了一半:WeChat 收不到文件时那行标记被改写成一句带路径的中文说明,而不是漏原始标记也不是静默吞掉。改成一条可点链接要等 web UI 有对外地址 |
| 产出可以是一张页面 不只是一个文件 |
agent 边写边渲染,用户在旁边看着文档成型,不用等文件落盘再点开 | canvas 工具已经在写带 id 的 markdown 块,canvas-panel.tsx 也在,两者没有接线 |
线格式已经定好,canvas.py 的注释里写着“等 WebUI 有更丰富的 canvas 界面时保持同样的线格式加渲染即可”。八家里三家各做了一遍这件事,需求是真的。这是第三层之后最值得做的一件 |
本页第一层描述的是仓库当前行为,第三层是还没做的收尾项,第四层是尚未落地的目标形态。按项目文档惯例,未实现的部分用现在时正面陈述写在正文里,在这里统一标注。
| 条目 | 状态 | 落点 |
|---|---|---|
| 一份标记词法:format_marker 加 find_markers,四个生产者共用 | 已实现 | openprogram/attachments.py |
| 允许根策略:readable_roots / sendable_roots / resolve_within(先解符号链接) | 已实现 | openprogram/attachments.py |
| 网页图片落盘并写标记,同时保留 ImageContent | 已实现 | webui/ws_actions/chat.py:_persist_attachments |
| 无文件名的粘贴图按 media type 补扩展名 | 已实现 | webui/ws_actions/chat.py:_attachment_name |
| 渠道附件改用网页词法(原来 chip 正则不认,标记当正文露出) | 已实现 | channels/_attachments.py:attachment_notes |
| 字节出口 /api/file-raw,以及绝对路径版 /api/file-read | 已实现 | webui/routes/file_search.py |
| chip 捕获路径、图片缩略图、点开浮层 | 已实现 | web/components/chat/messages/user-attachments.tsx · attachment-preview.tsx |
| chat_ack 回显存储后正文,刚发出去的附件当轮可点 | 已实现 | webui/ws_actions/chat.py · web/lib/net/chat-stream.ts |
| send_file 工具与 dispatcher 折入 final_text | 已实现 | functions/tools/send_file/ · agent/dispatcher/__init__.py |
| 渠道回发接线:抽标记、调 post_file、失败改写成人话 | 已实现 | channels/_conversation.py:_deliver_outbound_files |
| 按渠道声明能力(cli / tui / wechat / plan 下工具不出现) | 已实现 | functions/tools/send_file/send_file.py unsafe_in |
| 四个平台的 post_file 传输实现 | 已实现,现在有调用方了 | channels/_transport.py:186-211,777-780 |
| FileViewer 接受绝对路径 | 已实现(abs 模式) | web/components/files/file-viewer.tsx |
| 中心标签页作为第二种打开方式、caption、出站工具白名单、签名票据、代码高亮 | 未实现(第三层) | — |
| 附件记录表、TUI 内联图片、canvas 实时渲染面板 | 未实现(第四层) | — |
| document 模态与 choose_delivery 能力开关 | 未实现,attachment-handling 的决策矩阵里那一列仍是设计 | providers/types.py:329 |
openprogram/attachments.py 一处实现——改它就是同时改两份文档描述的行为。