聊天附件:双向流转与预览

用户发进来的文件和 agent 交出去的文件,在聊天流里各自是什么样子、能不能点开。四层:现状、八家对照、下一步、理想形态。附件怎么进模型上下文是另一个主题,写在 attachment-handling图解版),本页只在需要时引用它的结论。两份文档共用 openprogram/attachments.py 里那一份标记词法。

1 现状 入站四条路 气泡里看到什么 出站怎么走 字节出口 三件需求到哪了 2 八家怎么做 出站四种设计 没想到的招数 3 剩下的收尾 4 理想状态 差距与取舍 实现状态

01我们现在怎么做的现状

附件的字节、正文里的标记、模型看到的内容、用户看到的东西,是四件独立的事。四条入站路现在走同一套规则:字节落一次盘,正文里留一条同词法的标记,模型该看图看图、该拿路径拿路径,聊天流按同一个解析器画 chip。

1.1 入站四条路,一套标记词法

来源 字节落在哪 正文里的标记 模型收到 聊天流显示 网页 · 图片 粘贴 / 拖拽 / 选择器 会话 workdir/attachments/ 跟文档同一条落盘路,同去重同上限 [attachment: image-1.png (png, 20 KB) @ /abs/path] ImageContent 加一条路径 缩略图 chip 点开看原图 网页 · 文档 pdf / 代码 / 二进制 会话 workdir/attachments/ sha256 去重,32MB/64MB 上限 [attachment: 名字 (pdf, 1531 KB, 500 pages) @ /abs/path] 路径 加 4KB 头部预览 chip 可点 浮层里渲染 @提及 · 输入路径 file-search / file-resolve 原地不动 零拷贝,只取绝对路径 同上,词法完全一致 路径 chip 可点 同一个解析器 渠道 Telegram / Discord Slack(WeChat 无附件) 状态目录/channels/<渠道>/ accounts/<账号>/attachments/ 另一个目录,20MB 上限,不去重 [attachment: photo.png (png, 20 KB) @ /abs/path] 跟网页同一个 format_marker 图片 ImageContent 其余给路径 缩略图 chip 网页上可点开 发言人前缀 [显示名 (id)] 加在正文最前,附件标记追加在最后,两者互不重叠,方括号已被 _one_line 消掉,不会伪造出第二个前缀。
入站四条路都把内容送进模型,也都在聊天流里留下同一条可点的痕迹。标记由 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

1.2 同一句话,三种来源,一种结局

气泡的实际渲染 网页拖进一个 pdf spec.pdf pdf · 1531 KB · 500 pages 帮我看下这个规格 按钮,点开浮层里的浏览器 pdf 阅读器 网页粘贴一张截图 image-1.png png · 20 KB 这个报错是什么意思 缩略图来自 /api/file-raw,点开看原尺寸 Telegram 发一张图 photo.png png · 20 KB [张三 (10086)] 看这个 正文只剩发言人前缀和一句话,标记不再露出 chip 正则的路径组以前匹配了但不捕获,注释写的是"路径是给模型的,不是给 chip 的"。捕获它就是 chip 可点的全部改动。 乐观气泡的空档也补上了:chat_ack 回显存储后的正文,刚发出去的那一张当轮就能点开,不用等重载。
三种来源,一个组件。parseAttachments 从正文里摘出标记,AttachmentChips 画 chip,AttachmentPreviewFileViewer 的绝对路径模式打开。用户气泡、助手气泡、消息导轨走的是同一份。 web/components/chat/messages/user-attachments.tsx · attachment-preview.tsx · web/components/files/file-viewer.tsx · web/lib/net/chat-stream.ts

1.3 出站:一个工具,两条投递路

agent 手上的文件 write / image_generate canvas / 任何脚本产物 send_file(path) 解符号链接 → 判允许根 读大小 → 登记一条标记 不投递,对渠道一无所知 拒绝时给模型说明,不静默 dispatcher 收尾时折进正文 [attachment: chart.png (png, 42 KB) @ /abs/path] final_text 是所有消费者读的同一个串: 存库、流式结果、TurnResult 一次到位 网页助手气泡 跟用户附件同一个 chip 和浮层 渠道原生上传 抽标记 → post_file → 标记不上线 失败/不支持 → 改写成一句人话 四个平台的 post_file 早就写好了(telegram sendPhoto/sendDocument、discord multipart、slack 三步上传),缺的只是从工具那一侧接出来的一步。 能力按渠道声明走 unsafe_in:cli / tui / wechat / plan 四种场景下工具根本不出现在 tools 数组里,模型不会调了才发现发不出去。 允许根是会话 workdir 加显式绑定的项目目录。默认项目不算——它的路径是用户家目录,认这条绑定等于把整个 home 开放成出站可读。 沙箱(openprogram/sandbox/)管的是 shell 命令,这个工具是进程内 Python,路径检查就是全部边界,所以它必须先解符号链接再判包含。
为什么是工具而不是正文里的 MEDIA: 指令行。正文约定的失败模式是"抽不干净就当正文漏给用户",这个亏在渠道入站那侧已经吃过一次。工具调用有存在性校验,也有地方放"文件不在了"这种诚实反馈,而且模型只是在正文里提一句路径不会被当成投递意图。 openprogram/functions/tools/send_file/send_file.py · openprogram/agent/dispatcher/__init__.py · openprogram/channels/_conversation.py:_deliver_outbound_files

1.4 字节出口:两条路由,各守各的契约

浏览器能取到字节的两条路 项目相对路径 GET /files/raw?project_id&path 绝对路径一律拒绝——这条不变量保留 服务文件面板和中心文件标签页 FileViewer 图片 / pdf / markdown / 带行号代码 二进制给名字加大小加下载卡 绝对路径 GET /api/file-raw?path&session_id 响应头照抄 /files/raw:nosniff 加 CSP sandbox,pdf 那一处豁免也照抄 GET /api/file-read?path=<绝对> 文本走这条,多回一个 binary 标志 FileViewer 的 abs 模式换掉两个出口 readable_roots 项目根 状态目录/sessions channels/*/accounts/*/attachments 显式绑定的项目目录 glob 到 attachments 叶子, 因为 credentials.json 是它的兄弟 两个方向共用一个 resolve_within .resolve() 把符号链接解开,再判包含关系。允许根里种一条指向 ~/.ssh 的链接,按名字判会放行,按真实目标判才会拒。 出站那侧用的是更窄的 sendable_roots:只有会话 workdir 和显式绑定的项目目录,不含项目根,也不含渠道目录。
不去扩 /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

1.5 三件需求,现在各自到哪

需求现在做得到吗怎么做到的还差什么
用户发的图
能点开看
可以
缩略图加浮层
图片接进文档那条落盘路(sha256 去重、32MB/64MB 上限、路径回写),同时保留 ImageContent 块;无文件名的粘贴图按 media type 补出扩展名;chip 正则捕获路径 只有浮层没有中心标签页;大图没有分级缩略图
agent 发图
发文件给用户
可以
网页渠道两条路
send_file 登记,dispatcher 折进 final_text,网页画 chip、渠道调已有的 post_fileunsafe_in 按渠道声明能力 没有 caption 参数;插件与 MCP 工具还没有各自的出站白名单
可读文件
点开就能看
可以
图片 pdf md 代码 下载卡
新增 /api/file-raw 与绝对路径版 /api/file-readFileViewerabs 模式,浮层里复用它 代码没有语法高亮(pre 加行号够用);没有中心标签页入口
顺带修掉的那个缺陷:从 Telegram 或 Discord 进来的附件,在网页上打开同一个会话时,那行 [attachment: /abs (image/png, 20481 bytes)] 会当成正文渲染出来。根因是渠道那侧自己写了一套词法(bytes 而不是 KB,也没有 @ 路径),chip 的两条正则都落空。现在两侧共用 format_marker,网页上它是一张可点的缩略图 chip。

02别人怎么做的八家对照

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 标签”。![alt](url) 也会被自动抽出来发成照片 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 没有界面,微信就是客户端 有,而且是隐式的。不需要任何约定:回复里裸的绝对路径行或 ![](url) 都会被抽出来上传,然后把那行原地改写成“已发送附件:文件名”,失败就追加“附件发送失败”。附件必须在允许根下,默认 ~/.weclaw/workspace,另有扩展名白名单 messaging/attachment.go:17-107 · messaging/handler.go:491-524 没有。markdown 是被剥掉而不是渲染,转成纯文本发给微信 messaging/markdown.go:7-40

出站四种设计,抄哪一种

模型怎么表达“我要把这个文件给你” 工具参数 claude-code SendUserMessage( attachments: [路径]) 类型明确,模型不会写错格式 工具层就能做存在性校验 发文件必须占一次工具调用 说话和给文件被拆成两件事 正文指令行 openclaw · hermes MEDIA:/abs/path 单独一行,投递层抽走 和说话是同一个动作,零额外往返 按渠道在系统提示里开关能力 模型可能写错路径,必须有安全闸 抽不干净就当正文漏给用户 隐式扫描 weclaw 任何裸绝对路径行 或 ![alt](url) 模型什么都不用学,零提示词成本 那行会被改写成“已发送附件:x” 模型只是提一句路径也会被当成投递 意图和描述分不开 没有 codex-cli · opencode · pi-mono · pi-ai 模型想给用户一个文件, 只能在正文里贴一句路径。 codex 的图片生成会打印 file:// 并宣称“已经展示给用户”, 实际 TUI 只画了一行文字。 这一格就是我们今天所在的位置。 三家用正文指令的实现,安全闸都长一个样:路径必须落在允许根内,符号链接先解析再判断包含关系。openclaw 还多一层, 只有白名单里的核心工具能发本地路径,插件与 MCP 工具一律排除,防止被诱导读走不该读的文件。

我们没想到的招数

产出即页面

三家各做了一遍

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,给一个托管查看器地址。

canvas-hostinline-vis/share
字节怎么安全地给到网页

签名短时票据

openclaw 的 /__openclaw__/assistant-media?source=…&mediaTicket=…,票据格式 v1.<载荷>.<签名>,TTL 五分钟。这是“网页要看字节但不能给它任意路径”这个问题的一个成熟答案,比单纯校验允许根更严。

ticket TTL 5minroot guardsymlink resolve
chip 是编辑单元

占位符没了就不发

claude-code 把 [Image #N] 当成光标与 vim 操作里不可分割的一个原子单位,而且只有占位符还留在正文里,图片才会被发出去。用户删掉那个 chip 就等于取消这次附件,不需要另一个删除按钮。

Cursor.ts:322vim/operators.ts:469
另外几条值得记住的:hermes 把宿主缓存路径改写成容器内路径,让沙箱里的 agent 打得开同一个文件;hermes 的 native 模式在内联像素的同时还附一条路径提示,模型两样都有;codex 有个 view_image 工具让模型自己把本地图片拉进上下文;pi-ai 在模型不声明 image 输入能力时把图片块整个剥掉,正是 attachment-handling 里记过的那个降级缺口;媒体清理三家三个尺度,openclaw 两分钟、hermes 二十四小时、claude-code 每次会话启动清掉别的会话。
一处旧记载已经改掉:attachment-handling 原先写 opencode 的做法是“提交时合成一个假的 read 工具调用与结果”。实际代码里注入的是两条标了 synthetic 的纯文本部分,内容是一句叙述“Called the Read tool with the following input …”加上真实读取结果,落到模型那侧是 role: "user" 的文本,不是 tool_use 与 tool_result 内容块。拒绝这条路线的三个理由不受影响,只是描述错了;两份文档的正文都已更正。

03我们计划怎么优化下一步

三件需求都已落地,第一层描述的就是落地后的行为。这里只剩当时明确压后、或者做的过程中冒出来的东西,都不阻塞用户已经能用的路径。

剩下的它买到什么为什么当时没做什么时候做
中心文件标签页
作为第二种打开方式
需要长期停留的大文件不必一直占着浮层,可以跟聊天分栏并排读 中心标签页按 (projectId, 相对路径) 建键,加一种绝对路径标签要动 center-tabs-store 和它的一致性检查脚本;浮层零改动就覆盖了"扫一眼"这个主要场景 有人真的想把一个附件读很久的时候。turn-files-chips 已经在用 fileTabIduseCenterTabs,照抄即可
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 这个名字听上去就该只返回显式绑定。

04理想状态目标形态

一句话:任何一个文件,不管从哪个渠道进来、谁产生的、什么格式,在任何一个界面里都能用同一套查看器打开;agent 交出来的文件和用户传进去的文件,除了一个来源字段之外没有区别。下面这张图不受当前实现约束,虚线部分是已知做不到或代价太高的,理由跟在图后。

来源 唯一的附件记录 读出的界面 网页上传 · 粘贴 · 拖拽 @提及 · 输入路径 渠道入站附件 agent 产出的文件 一条附件记录 id · sha256 · 名字 · mime 字节位置 · 页数或行数 来源(用户/agent/渠道) 会话 · 消息 正文里只留一个引用 [attachment:<id>] 重命名 · 去重 · 跨轮引用都成了查询 网页气泡的 chip 和缩略图 同一个组件,不问来源 中心标签页 · 灯箱 同一个 FileViewer 渠道原生文件回发 网页能点开的,Telegram 就能收到 TUI 终端内联图片 iTerm2 · kitty 协议,其余退回文件卡 同样理想,但明确排在后面 附件表进 session_db,历史消息迁移 正文标记今天已经承载全部必要信息,收益不够动 schema canvas 工具接上实时渲染面板 canvas-panel 组件在,和 canvas 工具没接线 主动排除的一项 全局内容寻址 blob 存储 它会打破“会话 workdir 每轮提交进 git、可重放”这条不变量。 代价是大二进制永久留在 git 历史,这是已知且被接受的成本, 记在 attachment-handling 里。
第三层和第四层的关系:第三层做完了,图里实线的部分现在成立,只是靠一句正文标记而不是一条记录来承载。第四层要的是把那句标记换成一个 id,剩下的界面全都不用改。

四条目标形态,各自差在哪

目标形态它解决什么现在的差距为什么现在不做
一条附件记录
正文只留引用
重命名、去重、跨会话引用、“这个文件在哪几轮被提到过”都从字符串处理变成查询 全部信息编码在一句正文标记里,靠两条正则解析 要改 session_db schema 并迁移历史消息。正文标记今天承载得住全部必要信息,等“跨会话引用同一个文件”成为真实需求再动
一个解析器
四个界面
网页、中心标签页、浮层、终端拿到同一份“这个资源该怎么渲染”的判断 网页侧已经成立(parseAttachmentsFileViewer 一份实现);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
与 attachment-handling 的分工:那份文档回答“附件的内容怎么进模型上下文”,本页回答“附件在聊天界面里长什么样、能不能点开、agent 怎么交回来”。两份文档共用同一条正文标记词法,而这条词法现在只有 openprogram/attachments.py 一处实现——改它就是同时改两份文档描述的行为。