Markdown(AI 场景)
ui/markdown 把 Markdown 渲染成 ui/el 元素,针对的是 AI 聊天的特点:回答几个字几个字地到达,而且越写越长。
doc := markdown.New("").OnLink(openInBrowser)
doc.SetStreaming(true)
go func() { // 读模型的流式输出
for tok := range tokens {
core.Update(func() { doc.Append(tok) })
}
core.Update(func() { doc.SetStreaming(false) })
}()
// 在视图的 Render 里
el.Div().Child(doc.Render(cx))
Doc 的方法都要在 UI 锁下调用:回调里直接调,其他 goroutine 通过 core.Update。
完整示例:examples/chat,模拟流式回答,消息区跟随到底,可以中途停止。空白页提供跨段落选择、数学公式、长代码、双击与三击、脚注与引用、图片六项验证样例;go run ./examples/chat -sample=all 直接展示完整内容。样例说明列出了各项交互的检查方法。
支持的语法
段落、标题、加粗、斜体、删除线、行内代码(灰色底)、链接(悬停显示下划线)、自动链接、带语法高亮和复制按钮的代码块、引用、有序/无序/任务列表、GFM 表格(支持对齐)、分隔线。支持图片、脚注及跨块引用式链接。
HTML:渲染成富文本,不显示源码。
- 行内标签:
<b><strong><i><em><u><ins><s><del><mark>(高亮底色)、<code><kbd>、<sup><sub>、<a href>、<br>、<img src alt>。 - 块级 HTML 转成相应的 Markdown 块:
<h1>–<h6>、<p>、<div>、<ul><ol><li>、<blockquote>、<pre><code class="language-go">、<table>(<th>或<thead>作表头,align对齐)、<hr>。 <details>的<summary>加粗显示,内容照常展开;空行隔开的 Markdown 内容照常渲染。<script>、<style>、表单、<iframe>等连内容一起丢弃,什么都不执行。- 注释隐藏;不是 HTML 元素名的“标签”按原文显示,所以
Vec<String>、List<T>不会消失。
选中复制:从段落、标题、引用、列表、代码块或表格单元格开始拖动,可以跨块选择文字。双击按 Unicode 词边界选词(中文按字),三击选中整段;代码块三击选中当前源码行。双击或三击后按住拖动,按整词或整段扩展选区。Cmd/Ctrl+C 复制选区,Cmd/Ctrl+A 选中这篇文档,点别处取消。拖选到上下边缘时,最近的 ScrollY() 容器自动滚动;按住鼠标或松开后均可使用滚轮。复制的是显示的纯文本:文字块之间保留空行,表格单元格用制表符分隔、行之间换行;不包含列表标记、代码块的语言标签、复制按钮或流式光标。代码块的复制按钮仍只复制该块代码。
代码块:圆角卡片左侧显示代码图标和语言名称,未指定语言或标记为 text、txt、plaintext 时显示“纯文本”;右侧提供换行和复制图标,悬停显示操作提示,复制成功后短暂显示勾选图标。默认保留源文的代码行,长行可通过水平滚轮、触控板或底部滚动条查看;底部整条轨道支持点击和拖动,鼠标停在滚动条上时,普通滚轮也可左右滚动;点击“自动换行”按代码区宽度折行,再次点击恢复原始行。语言名称和按钮固定,每个代码块独立保存换行与滚动状态,流式追加不会重置这些状态。
数学公式:$...$ 随正文基线排版,$$...$$ 独立居中。原生排版支持:
- 上下标、分式、根号、
\binom; - 希腊字母及变体,约 200 个关系符、箭头、运算符和杂项符号,
\not否定; - 大型运算符:求和、积分、
\bigoplus等,显示模式下\sum、\lim、\max的上下限在上下方; - 字体:
\mathbb、\mathcal、\mathfrak、\mathsf、\mathtt、\mathbf、\boldsymbol、\mathrm、\text; - 重音:
\hat\bar\vec\dot\ddot\tilde\check\breve\acute\grave,以及\widehat、\overline、\underline、\overrightarrow; - 结构:
\overbrace、\underbrace(带标签)、\overset、\underset、\stackrel、\boxed; - 颜色:
\color、\textcolor、\colorbox,可用常见颜色名或#rrggbb; - 间距:
\phantom、\quad、\hspace等; - 环境:
matrix、pmatrix、bmatrix、Bmatrix、vmatrix、Vmatrix、cases、aligned、align、split、gather、equation、array、smallmatrix,以及\tag。
矩阵可以嵌套,\left / \right 分隔符随内容伸缩(. 隐藏一侧)。支持 \newcommand、\renewcommand、\providecommand 及无分隔参数的 \def,最多 9 个参数;\newcommand 可设置首个参数的默认值。宏按文档顺序生效,替换文档后清除。分式和矩阵撑开行高,公式按整体换行和选择,复制保留带分隔符的 TeX 源码。代码中的美元符号不参与解析;未闭合、语法错误或含未知命令的公式显示源码,流式补全后重新排版。无需外部 TeX 服务。
脚注与引用:引用定义可以放在后面的段落,完整、折叠和缩略引用共享文档上下文。脚注按首次出现排序;点击上标跳到文末脚注,点击返回箭头回到对应引用。重复引用各有返回位置,多段脚注保留格式。
图片:异步加载本地路径、file://、HTTP(S) 和 data URL,支持 PNG、JPEG、GIF 首帧和 WebP。按原始宽高比缩小到容器宽度,加载期间和失败时显示带替代文字的占位;加载完成会重新布局,同篇文档的相同地址共享资源。默认路径相对工作目录;需鉴权或相对文档路径时,在首次渲染前配置 doc.ImageLoader(func(context.Context, string) (image.Image, error))。选区复制图片的替代文字,链接图片仍可点击。默认加载器限制 16 MiB 文件、3200 万像素和 15 秒请求时间。
流式渲染怎么做到又快又稳
1. 按块切分,只解析变化的块。 源文本在"代码块和独立公式外的空行"处切成顶层块,每块单独解析并缓存。追加文字时,只有最后一块的内容变了,只重新解析它。空行后面跟着缩进行时不切(列表项续行、缩进代码)。
存在脚注、引用定义或数学宏时,需要按整篇文档解析,才能使后续定义影响前面的引用、使宏跨段生效;未变化的块仍复用视图和布局缓存。普通文档保持尾块增量解析。
测试 TestStreamingReparsesOnlyTheTail 逐字追加 2KB 的回答,解析次数不超过"追加次数 + 2 × 块数",而不是"追加次数 × 块数"。
2. 未闭合的语法临时补全。 流式输出时,最后一段里没闭合的 **、*、`、~~、[文字](链接 会在解析前临时补上闭合符,所以 **加粗 一出现就显示为粗体,不会先显示星号、等右边的 ** 到了再突然变粗。代码块没闭合时不需要补,解析器会把它延伸到末尾。回答结束(SetStreaming(false))后按原文重新解析最后一块。
3. 写完的块跳过重建和重排。 写完的块通过 cx.Cache 缓存:元素树和布局结果都复用,每帧几乎不花时间;只有正在写的那一块每帧重建。富文本块还缓存测量结果,避免同一帧里被重复排版。
4. 看不见的不画。 滚动容器外的元素跳过绘制。
结果(Apple M 系列,一次追加加一帧的渲染、布局和绘制;go test -bench StreamingFrame ./ui/markdown):
| 文档大小 | 每帧耗时 | 优化前 |
|---|---|---|
| 2 KB | 0.19 ms | 1.3 ms |
| 11 KB | 0.52 ms | 5.0 ms |
60Hz 的一帧预算是 16ms。优化前的时间主要花在富文本每帧被完整排版三次;详见设计决策。
聊天界面的配套能力(在 ui/el 里)
| 能力 | 用法 |
|---|---|
| 跟随到底 | 滚动容器加 StickToBottom():在底部时随内容增长保持在底部;用户往上翻就停下,翻回底部恢复 |
| 发消息跳到底部 | ScrollToEndOn(len(msgs)):参数变化时跳到底部并恢复跟随 |
| 复制 | 代码块自带复制按钮;自己的按钮用 el.WriteClipboard(text) |
| 光标 | 流式输出时,正在写的位置显示一个光标 |
定制
包级变量,渲染前修改:
| 变量 | 默认 | 说明 |
|---|---|---|
CodeBg |
#f0f1f3 |
代码块背景 |
CodeBorder |
#e3e5e8 |
代码块边框 |
CodeHover |
#e2e5e9 |
代码块按钮悬停与选中背景 |
InlineCode |
#1f2328 |
行内代码颜色 |
CodeStyle |
github |
chroma 高亮风格名 |
MonoFace |
theme.MonoFace |
代码字体,中文回退到 theme.Face 里的中文字体 |
标题、正文颜色和字号跟随 ui/theme。
Agent 能看到什么
段落、标题是 text;段落里的每个链接是单独的 link 元素,值是网址,可以直接 click;代码块是 code,名字是语言或“纯文本”,里面的代码文字、“自动换行”或“取消自动换行”按钮,以及“复制”或“已复制”按钮单独列出;表格是 table 和 row。图片是 image,名字来自替代文字,值表示 loading、loaded 或 error;脚注是 footnotes 容器,引用和返回箭头可直接点击。
排版器
段落和代码用 text.go 里自己的富文本排版器,折行算法来自 gioui.org/x/styledtext,另外记录每段文字在每一行的位置,所以能画 Gio 富文本画不了的东西:行内代码的底色、删除线、链接下划线和选区,并给每个链接单独的点击区域。普通文字的行高固定(正文 1.6 倍、代码 1.45 倍字号;公式和图片可撑开行高),字形在行内垂直居中,不同字号共用基线,所以中英混排、代码里的中文注释不会让某一行变高或偏上。
selection.go 为整篇文档维护一个按 rune 计数的选区。绘制前读取各文字块的布局位置,把段落缩进、代码内边距、表格列位置统一到文档坐标,再将选区映射回每个块绘制高亮。滚动后仍按文档坐标定位;流式追加或结束时保留选区,SetSource 替换内容时清除选区。从链接开始拖选不会打开链接,单击仍触发 OnLink。
已知限制
数学排版覆盖日常和教科书里常见的 TeX,但不是完整的 TeX 引擎:不执行 TeX 包、文件命令或任意代码;不支持的命令保留源码。宏展开限制递归深度和输出长度,不提供完整 TeX 分组作用域或带分隔符的 \def 参数。图片暂不解码 SVG,也不播放 GIF 动画。