Skip to content

AI 内容分块渲染与 HTML Fence 组件化实现

在 AI 对话页面中,回答内容不再只是普通文本。一次回答可能同时包含 Markdown、表格、代码块、外链、结构化详情链接,以及一整段可以继续预览和下载的 HTML 文档。

如果所有内容都直接交给一个 v-html 或一个 Markdown 渲染器处理,渲染能力会快速变得难以维护:普通文本需要 Markdown 解析,HTML 文档需要独立操作区,预览还涉及安全隔离,下载又可能依赖服务端转换接口。

本文以 Vue 3 + TypeScript 的 AI 消息组件为例,总结一套“先分块、再分类渲染”的实现方式。

1. 整体渲染目标

当前 AI 消息的渲染目标可以抽象为下面几类内容:

  • 普通文本、标题、列表、引用、代码块:使用 Markdown 渲染。
  • 表格:使用 Markdown 生成表格,再根据业务元数据增加展开能力。
  • BI 详情链接:转换为内部路由链接,由页面统一接管跳转。
  • <link>URL</link>:转换为外链 <a>,保留当前单元格原有文本作为链接文本。
  • ```html fence:转换为独立的 HTML 内容卡片,支持收起代码、预览和下载 DOCX。

因此,最终渲染不是“把一段字符串直接转成 HTML”,而是一个内容路由过程:

text
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 规则很难自然地接入这些交互。

本次采用的方式是:

  1. 先识别并拆出 HTML fence;
  2. 普通文本继续交给 MarkdownIt;
  3. HTML fence 使用真实的 Vue 组件渲染。

这样既保留 MarkdownIt 的成熟解析能力,也能让 HTML 内容拥有正常的 Vue 组件生命周期。

3. 在 MessageItem 中进行内容分块

分块入口位于:

text
src/views/intelligentQA/components/MessageItem.vue

核心识别规则如下:

ts
const fencePattern = /```\s*html\s*\r?\n?([\s\S]*?)```/gi

分块函数不会立即生成 HTML,而是先返回内容类型:

ts
type ContentBlock =
  | { type: 'markdown'; content: string }
  | { type: 'html'; content: string }

例如下面的回答:

markdown
这是文件分析结果:

```html
<!doctype html>
<html>
  <body>投标文件</body>
</html>
```

以上内容可以继续说明。

会被拆成三个逻辑片段:

顺序类型内容处理方式
1markdownMarkdownIt 渲染
2htmlHtmlFenceCard 组件渲染
3markdownMarkdownIt 渲染

模板层根据 block.type 选择渲染方式:

vue
<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 渲染流程:

text
原始文本

敏感信息脱敏

外链标记转换

BI 内部详情链接转换

JSON 文本格式化

MarkdownIt 渲染

DOMPurify 清洗

v-html 展示

4.1 敏感信息处理

在 Markdown 解析前先进行手机号和身份证号脱敏,避免敏感信息直接进入页面 HTML。

这一层应该放在 Markdown 解析之前,因为 Markdown 解析可能生成多个 HTML 节点,解析之后再做文本替换需要遍历 DOM,复杂度更高,也容易遗漏表格、代码块等场景。

4.2 外链处理

后端返回的外链格式为:

text
<link>https://www.example.com</link>

转换时会先验证协议是否为 httphttps,然后生成安全的 <a> 标签。

在表格场景中,当前单元格可能是:

text
政策文件标题<link>https://www.example.com/policy</link>

渲染时不会额外增加“点击跳转”文字,而是把单元格原本的内容作为链接文本,最终效果类似:

html
<a href="https://www.example.com/policy">政策文件标题</a>

这样可以避免表格内容被冗长 URL 撑开,也能保持原始数据的语义。

4.3 内部详情链接

BI 内部链接与外链使用不同协议。内部链接会先转换成带有目标类型和 ID 的 hash 地址,然后在 MessageItem 的统一点击代理中解析为 Vue Router 路由。

外链和内部链接必须使用不同的处理规则,尤其要注意处理顺序:

ts
let normalized = transformExternalLinkTags(content)
normalized = transformBiDetailLinkMarkers(normalized)

如果先处理内部链接,https://... 可能被内部的“类型:ID”正则误识别,导致外链标记被提前消费,后续外链规则就无法匹配。

4.4 DOMPurify 清洗

MarkdownIt 配置了 html: true,因此必须在输出到 v-html 前进行清洗。

当前允许的标签主要包括:

  • 文本结构:pbrstrongem
  • Markdown 结构:h1h6ulolli
  • 数据展示:tabletheadtbodytrthtd
  • 代码和媒体:precodeimg
  • 链接:a

属性也采用白名单方式,只开放 hreftargetrelclasssrc 等展示需要的属性。

5. HtmlFenceCard 组件设计

组件位置:

text
src/views/intelligentQA/components/HtmlFenceCard.vue

组件对外只接收两个参数:

ts
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 右侧抽屉:

vue
<el-drawer direction="rtl" size="58%">
  <iframe :srcdoc="previewHtml" sandbox=""></iframe>
</el-drawer>

抽屉标题包含:

  • HTML预览
  • 静态渲染:xxx行,行数通过源码换行符统计。

预览内容不会直接使用原始字符串,而是先经过 DOMPurify:

ts
const previewHtml = computed(() =>
  DOMPurify.sanitize(code, {
    WHOLE_DOCUMENT: true,
    FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form'],
    FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
  })
)

这里有两层隔离:

  1. DOMPurify 过滤脚本、嵌入对象和事件属性;
  2. iframe 使用空 sandbox,不授予脚本、表单提交和同源访问权限。

因此当前预览定位为“静态渲染”,并不执行 AI 生成 HTML 中的交互脚本。

5.3 DOCX 下载

下载流程不是浏览器直接下载 HTML,而是将 HTML 提交给服务端转换:

text
点击下载 DOCX

校验 sessionId

POST /sessions/{sessionId}/html-docx

读取二进制 Blob

解析 Content-Disposition 文件名

创建临时 a 标签触发下载

接口适配位于:

text
src/views/intelligentQA/biAssistantService.ts

服务方法接收会话 ID 和 HTML 源码,返回:

ts
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 消息的回答区域中:

text
AI 消息
  ├─ 任务进度卡片
  ├─ 思考/调试区域
  └─ 最终回答区域
       ├─ Markdown 内容块
       ├─ HTML 内容卡片
       └─ 操作栏

普通 Markdown 内容仍然支持原有表格逻辑:

  • 解析表格元数据;
  • 根据服务端返回的列和记录构造展开内容;
  • 展示“点击展开更多”按钮;
  • 通过回答容器的事件代理处理表格按钮和链接。

HTML 卡片内部的按钮则由组件自身处理,避免和 v-html 内容的事件代理混在一起。

8. 这种实现方式的优点

8.1 交互边界清晰

普通 Markdown 负责内容展示,HTML 卡片负责文档操作,两者职责分离,后续扩展不会让 MessageItemv-html 逻辑继续膨胀。

8.2 安全边界明确

普通回答和 HTML 预览分别经过独立的 DOMPurify 配置,预览再放入 sandbox iframe,避免把 AI 生成的原始 HTML 直接插入宿主页面。

8.3 保留现有渲染能力

不需要替换 MarkdownIt,也不需要引入新的 Markdown 解析库。原有的 Markdown、表格、数学公式、代码高亮、链接和流式渲染逻辑可以继续复用。

8.4 便于未来增加新的内容类型

后续如果需要支持 ```csv```json、流程图或图表,可以继续扩展内容块类型:

ts
type ContentBlockType = 'markdown' | 'html' | 'json' | 'csv'

然后为不同类型提供对应的 Vue 组件,而不是在一个 Markdown 渲染函数中不断增加分支。

9. 维护注意事项

  1. HTML fence 的结束标记必须完整到达,流式过程中不要对未闭合内容执行组件化渲染。
  2. HTML 预览默认是静态的,不应为了运行页面脚本而移除 iframe sandbox 和 DOMPurify 过滤。
  3. DOCX 下载依赖有效的 BI sessionId,历史消息如果没有会话 ID,需要在调用链上补齐。
  4. 普通 Markdown 的外链处理要先于 BI 内部链接处理,避免正则规则互相误识别。
  5. 新增内容块类型时,应同时考虑流式状态、导出逻辑、移动端布局和安全策略。

10. 总结

AI 内容渲染的关键不是选择某一个 Markdown 库,而是建立清晰的内容分层:

  • 文本内容交给 Markdown 渲染器;
  • 复杂文档内容拆成独立块;
  • 需要交互的内容使用 Vue 组件;
  • 原始 HTML 预览经过清洗和隔离;
  • 文件导出通过服务端接口完成;
  • 流式消息在内容边界稳定后再进行组件化。

通过这种分块归类的方式,AI 消息既能保留 Markdown 的通用表达能力,也能承载 HTML 文档预览和下载等复杂能力,同时让安全、交互和后续扩展保持在可控范围内。