AI 内容分块渲染与 HTML Fence 组件化实现
在 AI 对话页面中,回答内容不再只是普通文本。一次回答可能同时包含 Markdown、表格、代码块、外链、结构化详情链接,以及一整段可以继续预览和下载的 HTML 文档。
如果所有内容都直接交给一个 v-html 或一个 Markdown 渲染器处理,渲染能力会快速变得难以维护:普通文本需要 Markdown 解析,HTML 文档需要独立操作区,预览还涉及安全隔离,下载又可能依赖服务端转换接口。
本文以 Vue 3 + TypeScript 的 AI 消息组件为例,总结一套“先分块、再分类渲染”的实现方式。
1. 整体渲染目标
当前 AI 消息的渲染目标可以抽象为下面几类内容:
- 普通文本、标题、列表、引用、代码块:使用 Markdown 渲染。
- 表格:使用 Markdown 生成表格,再根据业务元数据增加展开能力。
- BI 详情链接:转换为内部路由链接,由页面统一接管跳转。
<link>URL</link>:转换为外链<a>,保留当前单元格原有文本作为链接文本。```htmlfence:转换为独立的 HTML 内容卡片,支持收起代码、预览和下载 DOCX。
因此,最终渲染不是“把一段字符串直接转成 HTML”,而是一个内容路由过程:
AI 原始回答
│
├─ HTML fence → HtmlFenceCard.vue
│ ├─ 源码展示
│ ├─ HTML 预览抽屉
│ └─ DOCX 下载
│
└─ 普通 Markdown → MarkdownIt → DOMPurify → v-html
├─ 表格增强
├─ 外链处理
└─ 详情路由处理2. 为什么不直接给 MarkdownIt 注册自定义 fence
markdown-it 本身支持通过 renderer.rules.fence 自定义代码块输出,例如可以让 ```html 输出一个带特殊 class 的 HTML 片段。
但是当前页面中的 HTML 内容卡片包含 Vue 交互:
- 收起和展开代码;
- 打开 Element Plus 抽屉;
- 下载按钮 loading 状态;
- 调用服务端接口下载 DOCX;
- 使用响应式状态管理预览内容。
如果 fence 只输出 HTML 字符串,最终内容会被 v-html 插入页面,但其中的 Vue 组件、事件和响应式逻辑不会被 Vue 编译。因此,单纯通过 MarkdownIt fence 规则很难自然地接入这些交互。
本次采用的方式是:
- 先识别并拆出 HTML fence;
- 普通文本继续交给 MarkdownIt;
- HTML fence 使用真实的 Vue 组件渲染。
这样既保留 MarkdownIt 的成熟解析能力,也能让 HTML 内容拥有正常的 Vue 组件生命周期。
3. 在 MessageItem 中进行内容分块
分块入口位于:
src/views/intelligentQA/components/MessageItem.vue核心识别规则如下:
const fencePattern = /```\s*html\s*\r?\n?([\s\S]*?)```/gi分块函数不会立即生成 HTML,而是先返回内容类型:
type ContentBlock =
| { type: 'markdown'; content: string }
| { type: 'html'; content: string }例如下面的回答:
这是文件分析结果:
```html
<!doctype html>
<html>
<body>投标文件</body>
</html>
```
以上内容可以继续说明。会被拆成三个逻辑片段:
| 顺序 | 类型 | 内容处理方式 |
|---|---|---|
| 1 | markdown | MarkdownIt 渲染 |
| 2 | html | HtmlFenceCard 组件渲染 |
| 3 | markdown | MarkdownIt 渲染 |
模板层根据 block.type 选择渲染方式:
<HtmlFenceCard v-if="block.type === 'html'" :code="block.content" />
<div v-else v-html="block.html"></div>这里使用 blockIndex 和消息 ID 组合生成 key,避免流式消息更新时不同内容块发生错误复用。
4. 普通 Markdown 内容的处理链路
HTML fence 拆出后,剩余内容仍然遵循原有 Markdown 渲染流程:
原始文本
↓
敏感信息脱敏
↓
外链标记转换
↓
BI 内部详情链接转换
↓
JSON 文本格式化
↓
MarkdownIt 渲染
↓
DOMPurify 清洗
↓
v-html 展示4.1 敏感信息处理
在 Markdown 解析前先进行手机号和身份证号脱敏,避免敏感信息直接进入页面 HTML。
这一层应该放在 Markdown 解析之前,因为 Markdown 解析可能生成多个 HTML 节点,解析之后再做文本替换需要遍历 DOM,复杂度更高,也容易遗漏表格、代码块等场景。
4.2 外链处理
后端返回的外链格式为:
<link>https://www.example.com</link>转换时会先验证协议是否为 http 或 https,然后生成安全的 <a> 标签。
在表格场景中,当前单元格可能是:
政策文件标题<link>https://www.example.com/policy</link>渲染时不会额外增加“点击跳转”文字,而是把单元格原本的内容作为链接文本,最终效果类似:
<a href="https://www.example.com/policy">政策文件标题</a>这样可以避免表格内容被冗长 URL 撑开,也能保持原始数据的语义。
4.3 内部详情链接
BI 内部链接与外链使用不同协议。内部链接会先转换成带有目标类型和 ID 的 hash 地址,然后在 MessageItem 的统一点击代理中解析为 Vue Router 路由。
外链和内部链接必须使用不同的处理规则,尤其要注意处理顺序:
let normalized = transformExternalLinkTags(content)
normalized = transformBiDetailLinkMarkers(normalized)如果先处理内部链接,https://... 可能被内部的“类型:ID”正则误识别,导致外链标记被提前消费,后续外链规则就无法匹配。
4.4 DOMPurify 清洗
MarkdownIt 配置了 html: true,因此必须在输出到 v-html 前进行清洗。
当前允许的标签主要包括:
- 文本结构:
p、br、strong、em; - Markdown 结构:
h1到h6、ul、ol、li; - 数据展示:
table、thead、tbody、tr、th、td; - 代码和媒体:
pre、code、img; - 链接:
a。
属性也采用白名单方式,只开放 href、target、rel、class、src 等展示需要的属性。
5. HtmlFenceCard 组件设计
组件位置:
src/views/intelligentQA/components/HtmlFenceCard.vue组件对外只接收两个参数:
interface Props {
code: string
sessionId?: string
}code 是 HTML fence 中的原始源码,sessionId 用于调用 BI 服务端的 DOCX 转换接口。
5.1 代码展示
源码使用 pre > code 原样展示,并通过以下方式保证长文档可阅读:
white-space: pre保留缩进和换行;min-width: max-content保留长行结构;- 外层限制最大高度并开启滚动;
- 使用深色代码背景和浅色等宽字体。
代码区默认展开,点击“收起代码”后只保留操作栏。这个状态由组件内部的 codeCollapsed 管理,不会污染消息数据。
5.2 预览抽屉
点击“预览 HTML”后,组件打开 Element Plus 右侧抽屉:
<el-drawer direction="rtl" size="58%">
<iframe :srcdoc="previewHtml" sandbox=""></iframe>
</el-drawer>抽屉标题包含:
HTML预览;静态渲染:xxx行,行数通过源码换行符统计。
预览内容不会直接使用原始字符串,而是先经过 DOMPurify:
const previewHtml = computed(() =>
DOMPurify.sanitize(code, {
WHOLE_DOCUMENT: true,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
})
)这里有两层隔离:
- DOMPurify 过滤脚本、嵌入对象和事件属性;
- iframe 使用空
sandbox,不授予脚本、表单提交和同源访问权限。
因此当前预览定位为“静态渲染”,并不执行 AI 生成 HTML 中的交互脚本。
5.3 DOCX 下载
下载流程不是浏览器直接下载 HTML,而是将 HTML 提交给服务端转换:
点击下载 DOCX
↓
校验 sessionId
↓
POST /sessions/{sessionId}/html-docx
↓
读取二进制 Blob
↓
解析 Content-Disposition 文件名
↓
创建临时 a 标签触发下载接口适配位于:
src/views/intelligentQA/biAssistantService.ts服务方法接收会话 ID 和 HTML 源码,返回:
interface BiHtmlDocxDownload {
blob: Blob
fileName: string
}组件在请求期间设置 downloading,禁用下载按钮,避免用户重复触发转换任务。
6. 流式回答下的兼容处理
AI 内容是流式到达的,不能假设一次就能拿到完整的 fence。
当前分块逻辑运行在响应式 computed 中,每当 message.content 更新时重新计算:
- 尚未闭合的 HTML fence 不会被识别为完整 HTML 卡片;
- 闭合 fence 到达后才生成
HtmlFenceCard; - fence 前后的 Markdown 内容继续实时刷新。
这避免了在 HTML 尚未完整时提前渲染半截文档。
同时,最终回答面板在 AI 内容出现第一段正文时就会显示,不需要等待调试步骤全部完成。这样“模型生成”阶段的正文可以与调试步骤并行展示。
7. 与表格和操作栏的关系
HTML fence 使用 Vue 组件渲染,但它仍然处于 AI 消息的回答区域中:
AI 消息
├─ 任务进度卡片
├─ 思考/调试区域
└─ 最终回答区域
├─ Markdown 内容块
├─ HTML 内容卡片
└─ 操作栏普通 Markdown 内容仍然支持原有表格逻辑:
- 解析表格元数据;
- 根据服务端返回的列和记录构造展开内容;
- 展示“点击展开更多”按钮;
- 通过回答容器的事件代理处理表格按钮和链接。
HTML 卡片内部的按钮则由组件自身处理,避免和 v-html 内容的事件代理混在一起。
8. 这种实现方式的优点
8.1 交互边界清晰
普通 Markdown 负责内容展示,HTML 卡片负责文档操作,两者职责分离,后续扩展不会让 MessageItem 的 v-html 逻辑继续膨胀。
8.2 安全边界明确
普通回答和 HTML 预览分别经过独立的 DOMPurify 配置,预览再放入 sandbox iframe,避免把 AI 生成的原始 HTML 直接插入宿主页面。
8.3 保留现有渲染能力
不需要替换 MarkdownIt,也不需要引入新的 Markdown 解析库。原有的 Markdown、表格、数学公式、代码高亮、链接和流式渲染逻辑可以继续复用。
8.4 便于未来增加新的内容类型
后续如果需要支持 ```csv、```json、流程图或图表,可以继续扩展内容块类型:
type ContentBlockType = 'markdown' | 'html' | 'json' | 'csv'然后为不同类型提供对应的 Vue 组件,而不是在一个 Markdown 渲染函数中不断增加分支。
9. 维护注意事项
- HTML fence 的结束标记必须完整到达,流式过程中不要对未闭合内容执行组件化渲染。
- HTML 预览默认是静态的,不应为了运行页面脚本而移除 iframe sandbox 和 DOMPurify 过滤。
- DOCX 下载依赖有效的 BI
sessionId,历史消息如果没有会话 ID,需要在调用链上补齐。 - 普通 Markdown 的外链处理要先于 BI 内部链接处理,避免正则规则互相误识别。
- 新增内容块类型时,应同时考虑流式状态、导出逻辑、移动端布局和安全策略。
10. 总结
AI 内容渲染的关键不是选择某一个 Markdown 库,而是建立清晰的内容分层:
- 文本内容交给 Markdown 渲染器;
- 复杂文档内容拆成独立块;
- 需要交互的内容使用 Vue 组件;
- 原始 HTML 预览经过清洗和隔离;
- 文件导出通过服务端接口完成;
- 流式消息在内容边界稳定后再进行组件化。
通过这种分块归类的方式,AI 消息既能保留 Markdown 的通用表达能力,也能承载 HTML 文档预览和下载等复杂能力,同时让安全、交互和后续扩展保持在可控范围内。