Warp 块列表图片渲染的 CommonMark Title 解析、悬停工具提示与 Alt 回退:从 markdown_parser 到 Block-List 渲染器的完整实现解析

原创2026-10-03 09:23:331,719 阅读
文章标签:人工智能AI Agent代码智能体桌面应用

Warp 块列表图片渲染的 CommonMark Title 解析、悬停工具提示与 Alt 回退:从 markdown_parser 到 Block-List 渲染器的完整实现解析

本篇指南围绕 Warp 开源仓库中 GH849 技术规范展开:AI 块列表(block-list)在 BlocklistMarkdownImages 特性开关下渲染 Markdown 图片时,原先只支持 alt 这一最简形式;本功能为共享解析模型与渲染链路补齐了 CommonMark 图片 Title("…"、'…'、(…) 三种分隔符),让 Title 成为成功渲染图片的悬停工具提示(tooltip),并让作者编写的 Alt 文本在图片加载失败时成为可见回退文本。读完本文,你将掌握这条链路从 crates/markdown_parser 解析层、crates/editor 缓冲往返/HTML 序列化层,到 app/src/ai 块列表渲染层的完整改造逻辑、行为不变量、测试策略与已知风险。

背景:块列表图片渲染的两个缺口

AI 块列表的 Markdown 图片渲染早已通过 FeatureFlag::BlocklistMarkdownImages 上线,整体架构沿用 specs/zachlloyd/ 目录下 inline-markdown-images-in-blocklist 系列的既有设计:图片先经 markdown_parser 解析,承载于 AgentOutputImage,再由 app/src/ai/blocklist/block/view_impl/common.rs 中的一组 section 渲染器绘制。但原有管线存在两个信息缺口(tech.md):

  1. Title 形式无法解析:任何使用 CommonMark 标准 Title 形式的图片——alt、alt、alt)——都会解析失败并退化为原始 Markdown 文本,一个完全符合标准的图片却无法渲染。
  2. Alt 没有用户可见落点:Alt 文本虽然被解析模型捕获,却从未在任何用户可见的位置呈现。图片加载失败(文件缺失、格式不支持、资源加载出错)时用户看到的是原始 Markdown 而不是作者提供的 Alt 回退;成功渲染的图片也没有任何悬停提示。

产品规范(product.md)将此功能定位为既有块列表图片特性的纯增量扩展,不改变无 Title、无 Alt 时的默认路径行为。

核心设计决策:共享模型加字段,链接解析不动

技术规范中最关键的设计约束是:保持链接(link)解析行为完全不变,只为图片开辟独立的 Title 解析路径。因为 parse_link_target 同时被 parse_link 调用,如果直接扩展这个共享辅助函数,会顺带把「链接 Title」解析引入链接路径——而链接 Title(text)被明确列为产品非目标(product invariant 13)。

因此最终的实现方案可以概括为一条主线:

  • 在共享图片模型 FormattedImage 上新增 pub title: Option<String>;
  • 让 markdown_parser 拥有整套图片专用的 parse_image_target / parse_image_destination / parse_image_title 辅助函数;
  • 将 title 字段贯通 FormattedImage、编辑器缓冲 BufferBlockItem::Image、AI 层的 AgentOutputImage 三个数据模型及各自的所有构造/解构点;
  • 在块列表渲染器中,把 Title 接进 Warp 既有 tooltip 原语,把 Alt 接进加载失败回退分支。

第一层:共享解析模型 FormattedImage 增加 title 字段

模型的落点位于 crates/markdown_parser/src/lib.rs:

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct FormattedImage {
    pub alt_text: String,
    pub source: String,
    /// Optional CommonMark image title, e.g. the `title` in `alt`.
    /// Empty titles are normalized to `None` by the parser.
    pub title: Option<String>,
}

字段语义约定为:None 表示「没有 Title」;真正为空的 Title(""、''、())在解析阶段统一归一化为 None,这样所有下游消费者只需一次 Option::is_some 判断即可决定是否渲染工具提示或输出 HTML title="…"。

伴随模型变更,lib.rs 中 FormattedTextLine::Image 的行为同步调整但保持向后兼容:

  • raw_text() 仍只输出 alt_text\n——Title 不进入纯文本投影;
  • num_lines() 仍恒为 1;
  • compute_formatted_text_delta 无需特判,因为 FormattedImage 已派生 Eq。

第二层:图片专用的 Title 解析路径

解析层实现位于 crates/markdown_parser/src/markdown_parser.rs,包含一组新函数:

  • parse_image:整行图片解析入口,允许前置空白,图片必须独占一行(或以空白分隔的多个图片),行尾后结束;
  • parse_image_run_line(公开):解析「整行只含一个或多个 Markdown 图片」的纯图片行,混合内容行(如 text img)返回 None;
  • parse_image_prefix / parse_image_prefix_internal:消费 ![、Alt 文本、],然后委托 parse_image_target,最后执行 title.filter(|t| !t.is_empty()) 归一化;
  • parse_image_target:图片专用的 (source [title]) 目标解析;
  • parse_image_destination:图片目标地址解析,支持尖括号定界(<…>)与带括号配平的非定界形式;
  • parse_image_title_leading_whitespace:destination 与 Title 之间的分隔空白,必须至少一个空格或 Tab;
  • parse_image_title:三种 CommonMark Title 分隔符的解析。

parse_image_target 的组合逻辑

fn parse_image_target<'a, E>(input: &'a str) -> IResult<&'a str, (String, Option<String>), E> {
    let (input, _) = tag("(")(input)?;
    let (input, _) = space0(input)?;
    let (input, source) = parse_image_destination(input)?;
    let (input, saw_whitespace) = parse_image_title_leading_whitespace(input)?;
    let (input, title) = if saw_whitespace {
        match parse_image_title::<E>(input) {
            Ok((rest, title)) => {
                let (rest, _) = space0(rest)?;
                (rest, Some(title))
            }
            Err(_) => (input, None),
        }
    } else {
        (input, None)
    };
    let (input, _) = tag(")")(input)?;
    Ok((input, (source, title)))
}

几个值得注意的实现细节:

  • destination 与 Title 之间必须存在至少一个空格/Tab(parse_image_title_leading_whitespace 用 space1)。这是本特性的有意收窄:destination 与 Title 之间出现行尾换行时不支持,整个构造回退为纯文本(product invariant 2)。
  • 只有看到空白后才尝试解析 Title;Title 解析失败(如未闭合)时按 None 继续,最终由外层关闭 )——若 ) 也缺失,则整行回退为纯文本,与今天畸形图片 alt,之后由调用方确认行尾或 EOF。

parse_image_destination 的边界处理

非尖括号定界的 destination 在首个空白处结束;遇到未配平的 ) 即终止(此时由 target 层接管关闭括号);(/) 支持括号深度配平;控制字符与行尾在 destination 内视为硬错误;转义序列(parse_escape)始终合法并解码为字面字符。

parse_image_title 的三种分隔符与转义规则

let closing = match opening {
    '"' => '"',
    '\'' => '\'',
    '(' => ')',
    _ => return Err(...),
};
  • 接受 "…"、'…'、(…) 三种定界形式,要求匹配的关闭定界符;
  • 遵循 CommonMark 转义规则:\"、\'、\) 解码为对应字面字符;
  • Title 跨行未闭合直接整体回退:parse_line_ending 在关闭定界符出现前命中即报错,从而保证产品 invariant 5(畸形图片整体回退纯文本)不被打破;
  • 只有「真正为空」的 Title 才归一化为 None;非空 Title 内部的首尾空白按字面保留(" " 视为非空,不归一化,invariant 4)。

parse_link_target 保持不变

链接专用解析 parse_link_target 未被触碰,继续只拥有链接 destination 语法。这是本功能与「链接 Title」解耦的关键:图片路径完全走独立的 parse_image_* 家族。

第三层:编辑器缓冲往返与 HTML 序列化

FormattedImage 的 title 字段需要贯通编辑器 crate。关键落点:

  • crates/editor/src/content/text.rs 中的 BufferBlockItem::Image { alt_text, source, title }:as_markdown 与 as_rich_format_text 在 Title 存在时以双引号形式重序列化,to_formatted_text_line 原样转发字段,BufferText::BlockItem 的 Debug 输出在 Title 存在时包含它(img_{alt}_{source}_{title}),以便缓冲调试输出区分带 Title 与不带 Title 的图片;
  • text.rs 中的共享序列化辅助 format_image_markdown:
pub fn format_image_markdown(alt_text: &str, source: &str, title: Option<&str>) -> String {
    match title.filter(|t| !t.is_empty()) {
        Some(title) => {
            let escaped = title.replace('\\', "\\\\").replace('"', "\\\"");
            format!("{alt_text}")
        }
        None => format!("{alt_text}"),
    }
}

这是全仓库 Title 重序列化的规范化出口:以双引号为统一定界符(与 CommonMark 规范输出一致),并转义字面 \ 与 ",保证 Markdown 往返(markdown → buffer → markdown)不丢失 Title 内容(invariant 8/9)。

  • crates/editor/src/content/markdown.rs 的 HTML 导出:<img> 标签的属性数组默认只含 src 与 alt,仅当解析出的 Title 非空时追加 title 属性,且 Title 值继续走既有 HTML 序列化/属性路径,使 "、<、> 等字符被转义而非原样拼接(invariant 9)。

编辑器对行内图片自身的渲染(core.rs、edit.rs、render/element/image.rs、render/model/ 下各解构点)不在本特性范围内,只需接受新字段即可(.. 模式或显式未使用绑定),依赖 Rust 对具名结构体字段的穷尽解构检查在编译期兜底。

第四层:AI 块列表层的字段贯通与解析收敛

在 app/src/ai/agent/mod.rs 中,AgentOutputImage 同样获得 title 字段:

pub struct AgentOutputImage {
    pub alt_text: String,
    pub source: String,
    /// Optional CommonMark image title preserved from `alt`.
    /// Empty titles are normalized to `None` by the shared markdown parser.
    pub title: Option<String>,
    pub markdown_source: String,
    pub layout: AgentOutputImageLayout,
}

app/src/ai/agent/util.rs 中的三个辅助函数形成闭环:

  • image_section(image, layout) 构造 AIAgentTextSection::Image,把 image.title 直接转发进 AgentOutputImage;
  • markdown_source_for_image 委托 format_image_markdown(&image.alt_text, &image.source, image.title.as_deref()),使 markdown_source 在带 Title 时序列化为 alt 的双引号规范形式;
  • flush_plain_text_sections 不再自行维护第二套图片解析器,而是统一委托共享的 parse_image_run_line,再根据返回的图片数量决定 Block(独占行)还是 Inline(行内多图)布局——所有图片行解析逻辑收敛到 markdown_parser 一处。

此外 app/src/ai/agent_sdk/driver/output.rs 的图片 section 管线需与新字段保持对齐(字段等价性)。

第五层:渲染——Title 工具提示与 Alt 失败回退

渲染层是用户可感知的部分,集中在 app/src/ai/blocklist/block/view_impl/common.rs。

工具提示的封装方式

render_visual_markdown_block(inline 图片与 Mermaid 的共享渲染辅助)在 VisualMarkdownBlockOptions 中新增两个可选字段:tooltip: Option<String> 与 tooltip_mouse_state: Option<MouseStateHandle>。当 tooltip 为 Some(non_empty) 时,用 appearance.ui_builder().tool_tip_on_element(tooltip, mouse_state, content, ParentAnchor::TopMiddle, ChildAnchor::BottomMiddle, vec2f(0., -2.)) 包裹渲染内容——-2px 的负 Y 偏移让工具提示底边与图片顶边保持一丝缝隙。Mermaid 无 CommonMark Title,调用点传 tooltip: None,行为完全不变。

MouseStateHandle 跨帧持久化——工具提示能显示的物理前提

tool_tip_on_element 内部把元素包进 Hoverable::new(mouse_state_handle, …),而 Hoverable 只在 state.is_hovered() 返回 true 时渲染工具提示。如果每一帧都传入全新的 MouseStateHandle::default(),悬停状态每帧被重置,is_hovered() 永远返回 false,工具提示永远不出现——这正是此前图片工具提示失效的根因。

因此实现要求:为每个 AIAgentTextSection::Image section 在 AIBlockStateHandles 上预分配一个稳定的 MouseStateHandle(新字段 image_section_tooltip_handles: Vec<MouseStateHandle>,位于 app/src/ai/blocklist/block.rs,与 normal_response_code_snippet_buttons、table_section_handles 并列),并在 block.rs 的 per-section fold 中填充。渲染时该 slice 通过 TextSectionsProps 随既有 code/table 句柄 slice 一并下发;渲染器以新的 starting_image_section_index 计数器按源顺序推进——分组渲染按 image_group.images.len() 递增,单图回退路径每次 +1。只有确实没有稳定句柄的调用点(如可折叠 reasoning 路径)才回退到 MouseStateHandle::default()。

加载失败回退:Alt 优先,Markdown 兜底

fn image_fallback_text(image: &AgentOutputImage) -> String {
    if image.alt_text.is_empty() {
        image.markdown_source.clone()
    } else {
        image.alt_text.clone()
    }
}

render_visual_markdown_fallback 保持「哑」辅助定位,只负责把传入文本渲染成 Text 元素;Alt 与 Markdown 源的选择决策由各 section 渲染器恰好计算一次后传入(inline 图片行、分组块图片、单图回退三条路径各自独立计算)。这对应 product invariant 7:Alt 非空时回退 Alt,Alt 为空时才回退原始 Markdown(保持既有行为),且失败项独立回退、邻居图片继续渲染。

复制与导出:Title 不丢失、统一双引号规范

  • 右键复制:成功渲染图片的右键复制继续把 AgentOutputImage::markdown_source 原样写入剪贴板;由于 section 载荷已携带 Title 感知的 Markdown 序列化,复制内容自动保留 alt/source/title,且统一为双引号规范形式 alt(invariant 8)。
  • HTML 导出:<img> 在 Title 存在时输出 alt="…" title="…",否则只输出 alt="…",特殊字符经属性转义路径处理(invariant 9)。
  • 纯文本:Title 不作为可见字幕、徽标或兄弟文本出现在任何布局中(inline 行、块级分组或未来布局),悬停工具提示是本特性中 Title 唯一的用户可见面(invariant 10)。

特性门控与行为矩阵

Title 后缀由共享 markdown_parser 无条件接受;块列表的工具提示与 Alt 回退行为继续由既有 FeatureFlag::BlocklistMarkdownImages 门控,不新增特性开关(tech.md 第 7 节)。当开关关闭时,图片 section 仍回退到 markdown_source,此时文本包含 Title 后缀(invariant 12)。因为 Title 解析本身不设门控,未来任何消费 FormattedImage 的非块列表调用方都能免费获得 Title 字段。

产品规范的 13 条不变量与实现/测试的对应关系可归纳如下:

不变量 行为要点 实现/测试落点
1 无 Title 形式零回归 解析器回归测试 alt
2 解析 alt;destination 与 Title 间至少一个空格/Tab;跨行分隔不支持 parse_image_target + 解析器测试
3 支持 "…"、'…'、(…) 三种分隔符 markdown_parser_tests.rs 三组用例
4 空 Title 等价无 Title;转义匹配定界符;未闭合/跨行整体回退 parse_image_title + 测试
5 畸形图片(无法解析/缺 ))整体回退纯文本 parse_image_target/parse_image_destination 错误分支
6 成功渲染 + 非空 Title → 悬停工具提示;空/缺 Title → 无工具提示 common_tests.rs 渲染器测试
7 加载失败:Alt 非空回退 Alt,否则回退 Markdown image_fallback_text + common_tests.rs
8 右键复制保留 alt/source/title,统一双引号 format_image_markdown 规范化
9 HTML 导出条件性输出 title="…",特殊字符转义 markdown.rs
10 Title 不渲染为可见字幕/徽标 渲染器仅 tooltip 一处暴露
11 流式与恢复的块行为一致 集成测试 test_restored_ai_block_renders_mermaid_and_local_images 扩展
12 BlocklistMarkdownImages 门控不变;关闭时含 Title 后缀回退 common.rs 渲染入口门控检查
13 相邻构造不受影响(普通括号文本、带 Title 链接、代码块内图片语法) 链接回归测试 + 手动验证

测试与验证策略

单元测试(tech.md 第 5 节):

  • markdown_parser 测试覆盖三种定界符、空 Title 归一化、转义关闭定界符、未闭合 Title 回退、Title 内嵌行尾回退,以及 alt 无 Title 回归守卫;
  • 编辑器 text_tests.rs / edit_tests.rs 验证 alt 在 markdown → BufferBlockItem::Image → markdown 往返中存活(invariant 8/9);
  • HTML 序列化测试确认带 Title 时输出 title="…"、None/空时省略;
  • app/src/ai/agent/util_tests.rs 覆盖带 Title 的独立块级图片、带 Title 的行内多图 run、空 Title 归一化、未闭合 Title 回退;
  • common_tests.rs 覆盖:成功渲染携带给定工具提示字符串、None/空 Title 不挂工具提示、失败渲染 Alt 非空回退 Alt、Alt 为空回退 Markdown、恢复块与新鲜解析块采用同一决策。

集成测试:扩展 crates/integration/src/test/agent_mode.rs 的恢复 AI 块测试(或同级兄弟用例),注入含「带 Title 行内图 + 带 Title 块级图 + 无 Title 图」的合成响应,通过既有截图/观察路径断言:带 Title 图正常渲染、无 Title 图渲染不变、文件被删除的块级图在 Alt 非空时回退到 Alt 字符串而非原始 Markdown。

手动验证清单(可直接照做):

  1. 构造 dogfood AI 响应包含 A dog,确认图片渲染且悬停显示 "Rex, my dog" 工具提示(invariant 6);
  2. 依次触发 "…"、'…'、(…) 三种定界形式,确认工具提示文本与作者书写的 Title 一致(invariant 3);
  3. 触发空 Title(""),确认不出现工具提示(invariant 4/6);
  4. 将图片指向缺失文件,确认行内回退在 Alt 非空时显示 Alt、Alt 为空时显示原始 Markdown(invariant 7);
  5. 右键复制带 Title 图片并粘贴到纯文本编辑器,确认剪贴板保留 alt/source/title 且定界符归一为双引号(invariant 8);
  6. 经既有导出入口将块导出为 HTML,确认 <img> 在带 Title 时同时含 alt 与 title 属性、无 Title 时仅含 alt、Title 属性值中的特殊字符被转义(invariant 9);
  7. 从历史记录恢复同一 AI 会话,确认工具提示与回退行为与初次渲染一致(invariant 11);
  8. 本地关闭 BlocklistMarkdownImages,确认带 Title 图片以原始 Markdown(含 Title 后缀)渲染(invariant 12);
  9. 编写 something ("not a title") 普通文本、带 Title 的链接 text、以及包含 alt 的围栏代码块,确认三者均不触发图片渲染或工具提示(invariant 13)。

风险与缓解

风险 说明 缓解
图片解析改动意外波及链接解析 parse_link_target 同时被 parse_link 调用,直接扩展共享辅助会静默给链接路径加上 Title 解析(非目标,invariant 13) parse_link_target 保持不动,仅图片路径走新的 parse_image_* 家族;增加链接回归测试断言带 Title 的链接渲染行为不变
字段等价性改动波及编辑器与 Agent SDK FormattedImage 与 BufferBlockItem::Image 在多个文件中被解构,漏掉调用点虽只产生编译错误而非行为 bug,但爆炸半径大 使用具名结构体字段(而非元组变体),依赖 Rust 穷尽解构检查在编译期兜底;PR 描述中附上「Relevant code」全文件清单便于评审逐项核对
无 Title 默认路径的工具提示回归 若新包装在 tooltip 为 None 时仍挂载,悬停无 Title 图片可能显示空工具提示或抢占焦点 调用点分支判断——仅当 tooltip: Some(non_empty_string) 时才用工具提示辅助包裹;由「成功渲染 + None/空 Title 不挂工具提示」单元测试覆盖

后续工作(明确超出本次范围)

  • 链接 Title(text):保持现状,图片专用 target 解析器为后续按需实现留下局部空间;
  • 引用式图片(![alt][ref]):继续不支持;
  • Lightbox 内展示 Title 与 Alt:共享全屏 lightbox 遮罩中的呈现可作为后续增强。

本功能从解析模型、解析器、缓冲往返、HTML 序列化、AI 块列表 section 模型到渲染层的完整实现均可在当前仓库源码中直接验证,参考入口依次为 crates/markdown_parser/src/lib.rs、crates/markdown_parser/src/markdown_parser.rs、crates/editor/src/content/text.rs、crates/editor/src/content/markdown.rs、app/src/ai/agent/mod.rs、app/src/ai/agent/util.rs、app/src/ai/blocklist/block/view_impl/common.rs 与 app/src/ai/blocklist/block.rs。

登录后查看全文
warp