Skip to content

功能规格说明:消息中的 markdown 图片 ​

创建日期:2026-11-04

最近更新:2026-11-04(首版:本地路径不再产出必裂的 <img>,改为可点击路径链接;同日第二版:加载失败的图片就地降级,不留破图标)

本规格只覆盖助手消息 markdown 正文里的图片语法(![alt](src) 与 ![alt](src "title"),含流式渲染)。粘贴图 / 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% 裂图。用户拿到的信息量为零(图看不到、路径也看不到),且这是模型侧无法自救的:换任何写法都得靠渲染层认账。

独立测试:渲染内容为 ![before](screenshots/before-05.png) 的助手消息,验证 DOM 里没有 <img>、有一个 class 含 file-path-link 的可点击元素、文案是路径原文;点击它,验证按宿主通道打开(桌面端进入文件面板并按该 pane 所在主机内联显示图片)。

验收场景:

  1. 假设 图片 src 是相对路径(screenshots/a.png、./a.png、../a.png、裸文件名 a.png),当 渲染时,则 不产出 <img>,产出一个可点击的路径链接,显示文本是 src 原文;点击时按会话 workdir 拼成绝对路径,再走该宿主既有的打开通道。
  2. 假设 图片 src 是绝对路径(Windows 盘符 C:\… / C:/…、POSIX /home/u/a.png、file:///C:/…),当 渲染时,则 同样降级为路径链接;点击时 file:// 前缀先剥离,. /.. 段按既有规则词法归一,再发往宿主。
  3. 假设 图片 src 是 data:、blob:、http: 或 https:,当 渲染时,则 保持现状——产出 <img> 内联显示(页面自己加载得了,不需要宿主参与)。
  4. 假设 图片语法带 alt(![改前](…)),当 渲染成路径链接时,则 alt 落到该链接的 title 上(悬停可见),显示文本仍是路径原文。
  5. 假设 图片语法嵌在链接里([![alt](a.png)](https://example.com)),当 渲染时,则 外层链接正常工作,内层不产生嵌套 <a>(沿用 renderer.link 剥掉标签内路径链接的既有规则)。
  6. 假设 会话没有 workdir 且 src 是相对路径,当 点击该链接,则 与其它相对路径点击一致地按原样交给宿主(宿主按自己的 cwd 解析),不抛错、不静默无反应。
  7. 假设 src 的扩展名不是图片(![](notes.txt)、![](Makefile)),当 渲染时,则 仍按「路径」处理(语法是图片语法就降级为路径链接)——是不是图由宿主在打开/读取时判,页面不替文件下结论。
  8. 假设 宿主是远控 Web 页,当 用户点这条降级后的路径链接,则 走该页既有的按需读取通道(图片扩展名 → getImagePreview 弹层,其余 → getTextPreview),读取失败的原因原样显示,不留「看着能点、点了没反应」。
  9. 假设 宿主把路径链接档位设成 off(FilePathLinkMode = "off"),当 渲染时,则 只显示路径纯文本、不生成链接,与其它路径通道同档。
  10. 假设 消息正在流式输出(图片语法尚未闭合或刚闭合),当 增量重渲染时,则 与其它 markdown 元素一致:闭合前按普通文本显示,闭合后立刻变成路径链接,不抛错、不闪烁成破图。
  11. 假设 用户消息的正文里写了 ![x](a.png),当 渲染时,则 不受本规格影响——用户消息不走 markdown 渲染(走 mention 解析),保持原文。

用户故事:加载失败的图片不留破图标(优先级:P1) ​

作为用户,我希望消息里那些最终没能加载出来的图片(远端 404、桌面端被 CSP 拦下的 http:、坏掉的 data URL)不要以破图标留在页面上,而是就地换成一句能读的说明(alt,没有就用文件名),以便我至少知道「这里本来有张图、它叫什么」,而不是面对一个叉号猜它是什么。

为什么是这个优先级:这类失败渲染期无法预判——要等请求结果回来才知道,所以上一节只能处理「看 scheme 就能断定必裂」的本地路径;剩下的三类照样破图,其中 http: 在桌面端是 100% 失败(CSP 只有 img-src 'self' data: https:)。同一个坑每出现一次,用户看到的就是一条不可信的消息。

独立测试:渲染 ![改前](https://example.com/missing.png),在该 <img> 上派发 error 事件,验证 <img> 从 DOM 里消失、替换成文案为「改前」的文本节点、title 是原地址。

验收场景:

  1. 假设 https 图片请求失败(404 / 网络错误),当 浏览器在该 <img> 上触发 error,则 该元素就地换成文本:文案取 alt,alt 为空则取地址里的文件名(…/shots/a.png → a.png),title 保留原地址(悬停可知它本该指向哪)。
  2. 假设 桌面端渲染 http: 图片(被 CSP 拦下),当 拦截触发 error,则 与场景 12 同一处理——不因为「是安全策略拦的」就留破图标,也不为此按宿主分档判断 http:。
  3. 假设 data: 图片解不出图(坏 base64 / 不支持的格式),当 触发 error,则 文案取 alt,alt 为空时用「图片」;title 不得写入 base64 原文(可能是几 MB 的串)。
  4. 假设 图片加载成功,当 渲染完成,则 什么都不发生——兜底只挂在 error 事件上,不预置占位、不改既有样式、不额外发请求。
  5. 假设 消息正在流式输出且含一张会失败的图,当 增量重渲染重建了这段 HTML,则 新的 <img> 重新尝试一次、失败后再降级;替换是「img → 文本」的一对一替换,不累积占位、不重复插入文本。
  6. 假设 图片出现在 markdown 表格单元格 / 列表项 / 引用块里,当 它加载失败,则 同规则——判据只看 <img> 元素自身,与所在块无关。
  7. 假设 粘贴图、工具块图片、文件面板预览里的图片加载失败,当 发生时,则 不受本故事影响——兜底只挂在 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(![a](x.png "图 1"))? 不保留——降级后 title 由 alt 占用(路径链接的悬停提示只放一句说明)。这类写法在消息里可以忽略。
  • alt 里有 HTML / 引号? 进 title 前与路径文本一视同仁地转义,产物仍过 DOMPurify。
  • 图片语法出现在表格单元格、列表项、引用块里? 同规则,与所在块无关(renderer 与结构无关)。
  • 文件面板 / 计划面板的 markdown 预览怎么办? 不走本规格:那里的相对路径基准是被预览文件所在目录(不是会话 workdir),是另一个问题,本轮不做(renderFileMarkdown 现状不变)。
  • textarea/输入区里的 ![]() 呢? 那是用户正在写的纯文本,不渲染,无关。
  • 会不会误伤本来就正常的消息? 只影响「src 不是 data/blob/http(s)」的图片语法,而这类此前 100% 裂图;data:/http(s): 的既有表现逐字节不变。