从 pre 到 AgentMarkdown,补上阅读页真正用得到的排版。
模型回答里写着 ## 重点,页面也原样显示 ## 重点。列表前的横杠、加粗的星号都在,答案生成了,读起来却像一段待处理的文本。查组件,原因只有一行:回答直接放进了 <pre>。
渲染没坏,这个标签本来就是这么显示的。MDN 对 <pre> 的定义就是预格式化文本,按写下的样子原样呈现,通常用等宽字体。模型写的是 Markdown,页面却当成了要原样展示的源码。
只支持常用的那几种块
5 月 15 日,ArticleReader.tsx 的 1425c9d 版本把回答交给一个单独的展示组件:
<AgentMarkdown text={answer} />改动本身就是把 <pre className="agentAnswer"> 那一行换成这一行。同一次提交还新增了 AgentMarkdown.tsx 和它的测试,顺手改了专注阅读页和全局样式,后面两节说的布局和抽屉也在这次提交里。
AgentMarkdown 先辨认块,再处理块里的行内格式:
| 层 | 第一批支持 |
|---|---|
| 块 | 一至三级标题、普通段落、有序与无序列表、引用 |
| 行内 | 代码、加粗、http(s) 链接 |
这样回答里的重点成了真正的标题,代码名也和正文分开了。
这张表在代码里是写死的:块是一个只有五种成员的联合类型,标题等级限定 1–3;行内只有一个正则,覆盖反引号代码、双星号加粗和 http(s) 链接三类。支持什么列得清清楚楚,没有范围模糊的“尽量解析”。
我没有把它写成完整的 Markdown 解析器,这个版本还不支持表格和围栏代码块,复杂回答别指望全部排对。块结构管整块的形状,行内格式管块里的局部;标题和列表各有明确的起止标志,支持了它们,表格的行列关系、围栏代码块的边界并不会跟着处理好。常见的几种输出先看得下去,以后要扩展也有明确的位置可改。同次提交的 AgentMarkdown.tsx 保留了这批支持范围。
项目的 learning-notes 把这个取舍记成了决定:渲染不引入新依赖,不渲染原始 HTML,不认识的语法按文本显示,不把模型输出当可信 HTML。笔记末尾还留了一个没答的问题:轻量渲染器和引入 react-markdown 怎么取舍。
漏掉的两样,分量其实不一样。围栏代码块是 CommonMark 规范的核心内容(§4.5),表格不是,规范只在引言里提到有人扩展了表格。表格来自 GFM,它标成扩展,GFM 自称是 CommonMark 的严格超集。CommonMark 官网说各家实现这些年分歧很大,这才需要一份可测试的标准。同一段 Markdown 本来就没有唯一的渲染结果,挑一个够用的子集是常规做法。
“以后要扩展”后来也应验了。两个月后补的第一件事就是围栏代码块,带语言标记,块里不再解析行内格式;表格到现在还没补。
回答里的 HTML 不执行
测试里那条 javascript: 仍是普通文本,没有渲染成链接。原始 HTML 不直接执行,模型给出的文本由 React 转义,尖括号按普通字符写出,不会变成标签。链接另走一层判断,只认 http(s):先用 new URL() 解析,协议必须是 http: 或 https:,URL 里还不能有 < > " ',不通过就把原 token 当文本输出。javascript:alert(1) 能被解析,但协议不在白名单里,所以退回文本。测试里对应两条断言:尖括号转义成 <script>,[bad](javascript:alert(1)) 原样保留。不想因为一段回答用了 HTML,就让它变成可执行的页面。
为什么不靠框架兜底?React 自己只挡一种写法。它的 sanitizeURL.js 把 javascript: 换成一段会抛错的 URL,其余 URL 原样返回,没有协议白名单。框架挡的是已知的那一种,允许什么得应用自己说清。
如果当时直接引库,代价换个地方出:
| 手写 AgentMarkdown | react-markdown | |
|---|---|---|
| 覆盖范围 | 五种块、三种行内 | CommonMark,可加 GFM |
| 依赖 | 无新增 | remark/rehype 一串 |
| 原始 HTML | 转义成文本 | 默认当文本或忽略,要渲染得加 rehype-raw,官方建议再加 rehype-sanitize |
| 链接协议 | 只认 http(s) | 默认放行 http、https、irc、ircs、mailto、xmpp 和相对路径 |
react-markdown 的 README 说它默认安全,也提醒改写 urlTransform 会打开 XSS 的口子。手写省了依赖,代价是白名单得自己维护。
抽屉保持挂载,动画才播得完
阅读布局也接着收拾:正文给更宽的位置,原来收在“操作”下拉菜单里的动作拿出来,改成一排直接可见的操作栏,Agent 放进底部抽屉,需要时再打开。
抽屉关闭时如果直接卸载,退出动画来不及播放:动画需要一个还挂在页面上的元素。所以这一版的抽屉始终保持挂载,关闭只改状态,靠 CSS 的 max-height、opacity 和 transform 过渡开合,同时加上 aria-hidden 和 inert,收起后点不到。同一天引入 motion 后,AnimatePresence 用在了“更多操作”浮层上,让它先播完退场动画再移除;抽屉仍然保持挂载。
内容还挂在页面上,就得保证关着的时候点不到里面的按钮。HTML 的 inert 属性正好管这个:元素和后代不能聚焦、点不到,也从 tab 顺序和无障碍树里移除。退场动画今天还有纯 CSS 的路:transition-behavior: allow-discrete 能让 display: none 参与过渡,代价是浏览器要到 2024 年的基线。
这些细节不改变答案,但会影响我愿不愿意接着读。
留下几句话 读者来信
读到这里,有什么想法?欢迎留下你的看法。