Appearance
功能规格说明:消息中的 markdown 图片
创建日期:2026-11-04
最近更新:2026-11-04(首版:本地路径不再产出必裂的 <img>,改为可点击路径链接;同日第二版:加载失败的图片就地降级,不留破图标)
本规格只覆盖助手消息 markdown 正文里的图片语法(
与,含流式渲染)。粘贴图 / Read 等工具产出的image块、工具块的images、以及「已发送图片标签」点开看原图各有既定行为,见 图片粘贴;消息里以纯文本出现的路径链接见 文件路径链接,本规格只复用它的点击通道。
背景:本地路径的图片为什么必裂
消息 markdown 由 webview 的 marked renderer 生成,图片语法此前交给 marked 默认实现,产出 <img src="…" alt="…"> 后由 DOMPurify 按 img/src/alt 放行——也就是说 src 被原样交给浏览器去加载。这在三种宿主上都不可能成功:
- 桌面端页面自己是
file://…/webview/index.html:screenshots/a.png会被解析成…/webview/screenshots/a.png→ 404;CSP 是img-src 'self' data: https:,file://与http:另有拦截; - 插件端页面是
vscode-webview:/// JB 的 JCEF origin:相对路径同样落在资源目录上 → 404; - 远控 Web 页是 https 站点:相对路径落在站点根上 → 404。
更糟的是两种常见写法连 src 都留不下:DOMPurify 的 URI 白名单不认 file: 与盘符(C:\…),<img> 的 src 被整条剥掉。最终呈现就是用户看到的「破图标 + alt 文本」——图看不到,文件在哪也不知道。
这与 图片粘贴 场景 2 已经写下的口径一致:「渲染层不得把该路径直接塞进 <img src> 自行加载」。那条规则此前只覆盖「已发送图片标签」,漏掉了 markdown 图片语法,本规格就是把它补齐。
用户场景与测试 (必填)
用户故事:markdown 图片的本地路径不再裂图,而是可点开看(优先级:P1)
作为用户,我希望助手在消息里用  这样的语法引用本机截图或图片时,不要看到一个破图标,而是看到一个可点击的路径,点开就能看到那张图,以便我既能读到图,也能从路径知道文件到底在哪。
为什么是这个优先级:模型写「改前/改后」这类对照说明时的自然写法就是 markdown 图片语法,而它引用的都是本机文件——于是这条路径 100% 裂图。用户拿到的信息量为零(图看不到、路径也看不到),且这是模型侧无法自救的:换任何写法都得靠渲染层认账。
独立测试:渲染内容为  的助手消息,验证 DOM 里没有 <img>、有一个 class 含 file-path-link 的可点击元素、文案是路径原文;点击它,验证按宿主通道打开(桌面端进入文件面板并按该 pane 所在主机内联显示图片)。
验收场景:
- 假设 图片 src 是相对路径(
screenshots/a.png、./a.png、../a.png、裸文件名a.png),当 渲染时,则 不产出<img>,产出一个可点击的路径链接,显示文本是 src 原文;点击时按会话 workdir 拼成绝对路径,再走该宿主既有的打开通道。 - 假设 图片 src 是绝对路径(Windows 盘符
C:\…/C:/…、POSIX/home/u/a.png、file:///C:/…),当 渲染时,则 同样降级为路径链接;点击时file://前缀先剥离,./..段按既有规则词法归一,再发往宿主。 - 假设 图片 src 是
data:、blob:、http:或https:,当 渲染时,则 保持现状——产出<img>内联显示(页面自己加载得了,不需要宿主参与)。 - 假设 图片语法带 alt(
),当 渲染成路径链接时,则 alt 落到该链接的title上(悬停可见),显示文本仍是路径原文。 - 假设 图片语法嵌在链接里(
[](https://example.com)),当 渲染时,则 外层链接正常工作,内层不产生嵌套<a>(沿用renderer.link剥掉标签内路径链接的既有规则)。 - 假设 会话没有 workdir 且 src 是相对路径,当 点击该链接,则 与其它相对路径点击一致地按原样交给宿主(宿主按自己的 cwd 解析),不抛错、不静默无反应。
- 假设 src 的扩展名不是图片(
、),当 渲染时,则 仍按「路径」处理(语法是图片语法就降级为路径链接)——是不是图由宿主在打开/读取时判,页面不替文件下结论。 - 假设 宿主是远控 Web 页,当 用户点这条降级后的路径链接,则 走该页既有的按需读取通道(图片扩展名 →
getImagePreview弹层,其余 →getTextPreview),读取失败的原因原样显示,不留「看着能点、点了没反应」。 - 假设 宿主把路径链接档位设成
off(FilePathLinkMode = "off"),当 渲染时,则 只显示路径纯文本、不生成链接,与其它路径通道同档。 - 假设 消息正在流式输出(图片语法尚未闭合或刚闭合),当 增量重渲染时,则 与其它 markdown 元素一致:闭合前按普通文本显示,闭合后立刻变成路径链接,不抛错、不闪烁成破图。
- 假设 用户消息的正文里写了
,当 渲染时,则 不受本规格影响——用户消息不走 markdown 渲染(走 mention 解析),保持原文。
用户故事:加载失败的图片不留破图标(优先级:P1)
作为用户,我希望消息里那些最终没能加载出来的图片(远端 404、桌面端被 CSP 拦下的 http:、坏掉的 data URL)不要以破图标留在页面上,而是就地换成一句能读的说明(alt,没有就用文件名),以便我至少知道「这里本来有张图、它叫什么」,而不是面对一个叉号猜它是什么。
为什么是这个优先级:这类失败渲染期无法预判——要等请求结果回来才知道,所以上一节只能处理「看 scheme 就能断定必裂」的本地路径;剩下的三类照样破图,其中 http: 在桌面端是 100% 失败(CSP 只有 img-src 'self' data: https:)。同一个坑每出现一次,用户看到的就是一条不可信的消息。
独立测试:渲染 ,在该 <img> 上派发 error 事件,验证 <img> 从 DOM 里消失、替换成文案为「改前」的文本节点、title 是原地址。
验收场景:
- 假设 https 图片请求失败(404 / 网络错误),当 浏览器在该
<img>上触发error,则 该元素就地换成文本:文案取alt,alt为空则取地址里的文件名(…/shots/a.png→a.png),title保留原地址(悬停可知它本该指向哪)。 - 假设 桌面端渲染
http:图片(被 CSP 拦下),当 拦截触发error,则 与场景 12 同一处理——不因为「是安全策略拦的」就留破图标,也不为此按宿主分档判断http:。 - 假设
data:图片解不出图(坏 base64 / 不支持的格式),当 触发error,则 文案取alt,alt为空时用「图片」;title不得写入 base64 原文(可能是几 MB 的串)。 - 假设 图片加载成功,当 渲染完成,则 什么都不发生——兜底只挂在
error事件上,不预置占位、不改既有样式、不额外发请求。 - 假设 消息正在流式输出且含一张会失败的图,当 增量重渲染重建了这段 HTML,则 新的
<img>重新尝试一次、失败后再降级;替换是「img → 文本」的一对一替换,不累积占位、不重复插入文本。 - 假设 图片出现在 markdown 表格单元格 / 列表项 / 引用块里,当 它加载失败,则 同规则——判据只看
<img>元素自身,与所在块无关。 - 假设 粘贴图、工具块图片、文件面板预览里的图片加载失败,当 发生时,则 不受本故事影响——兜底只挂在 markdown 正文容器上,那几处各有既有处理(见 图片粘贴)。
行为规则
| src 形态 | 渲染结果 |
|---|---|
data: / blob: / http: / https:(大小写不敏感) | <img src alt> 内联显示(与改造前一致) |
其余(相对 / 盘符 / POSIX 绝对 / file:) | 可点击路径链接,无 <img> |
- 降级判据:src 不是「页面自己能加载的地址」即降级。这条判据只看 scheme,不看扩展名、不判断文件是否存在(渲染期不碰文件系统)。
- 显示文本:src 原文,不展开 workdir、不改写分隔符——与其它路径链接一致,可点击目标的真实标识要写在脸上。
- 元素结构:复用既有
a.file-path-link(追加md-image-link标记类),href固定#,alt 进title。不新增标签、不新增属性 → DOMPurify 白名单不需要改;路径文本进 HTML 前转义。 - 点击解析:先按既有规则识别绝对路径(
detectFilePathToken→resolveFilePathMatch,覆盖盘符 / POSIX /file://);不匹配时(也就是相对路径)按toAbsoluteFilePath(src, workdir)拼绝对。两条都拿不到绝对路径时原样把 src 交给宿主。 - 点击去向:与其它路径链接完全同一条通道——桌面端
onOpenFile(文件面板按该分屏所在主机读,SSH 会话经 ssh 读回)、插件端openFile、远控 Web 页按需读取后弹层显示。 - 破图兜底:markdown 正文容器以捕获阶段监听
error(error事件不冒泡,只能在容器上拦),命中「目标元素是<img>且解不出宽度(naturalWidth === 0)」时就地替换成span.md-image-broken。文案阶梯是alt→ 地址里的文件名 → 「图片」;title放原地址,data:地址不放(避免把 base64 塞进悬停提示)。替换是纯 DOM 操作、不进 React 状态,所以容器下次重渲染会重新尝试加载——流式期间短暂重试,内容 settle 后稳定。
边界情况
- 为什么不在消息里直接把本地图片内联显示出来? 渲染层没有字节,只能向宿主要(新 RPC),还要处理字节预算/缓存/CSP;而且「渲染层自己把路径塞进
<img>」正是本规格要消除的写法。点击 → 宿主读 → 显示这条既有链路顺手带来一个好处:消息列表不会因为模型写了几十张图就自动读盘(远端会话尤其贵)。 - 大图 / SSH 远端会话? 点击后的读盘与压缩各有既有责任方:桌面端文件面板按 pane 所在主机读(远端经 ssh)、远控页走压缩到单帧预算内的取图通道。渲染层只负责把路径交出去,不关心这些。
http:图片在桌面端仍然显示不出来? 桌面 CSP 是img-src 'self' data: https:,http:会被拦。本规格不改 CSP,也不让渲染层按宿主去判断http::被拦掉的图片会触发error,由上面的破图兜底统一降级(场景 13);VS Code / JetBrains 允许http:,那边照旧能显示。- 为什么不用 React 的
onError兜底? 正文是 marked + DOMPurify 产出的 HTML 字符串,走dangerouslySetInnerHTML,没有可挂onError的 React 元素;而error不冒泡,所以「在容器上加捕获阶段的原生监听」是唯一不用重写整条渲染管线的位置。 - CSP 拦截也算「加载失败」吗? 算——被 CSP 拦下的图片与 404 一样触发
error。这正是本规格不需要按宿主分档、也不需要放宽 CSP 的原因:本质是「这一次请求没成」,不是「地址形态不合法」。 - 图片语法里的 title(
)? 不保留——降级后title由 alt 占用(路径链接的悬停提示只放一句说明)。这类写法在消息里可以忽略。 - alt 里有 HTML / 引号? 进
title前与路径文本一视同仁地转义,产物仍过 DOMPurify。 - 图片语法出现在表格单元格、列表项、引用块里? 同规则,与所在块无关(renderer 与结构无关)。
- 文件面板 / 计划面板的 markdown 预览怎么办? 不走本规格:那里的相对路径基准是被预览文件所在目录(不是会话 workdir),是另一个问题,本轮不做(
renderFileMarkdown现状不变)。 textarea/输入区里的![]()呢? 那是用户正在写的纯文本,不渲染,无关。- 会不会误伤本来就正常的消息? 只影响「src 不是 data/blob/http(s)」的图片语法,而这类此前 100% 裂图;
data:/http(s):的既有表现逐字节不变。