AI 对话为什么需要 Markdown 解析器:从纯文本气泡到专业内容渲染

AI 对话为什么需要 Markdown 解析器:从纯文本气泡到专业内容渲染

前言

在做 AI 对话页面时,消息展示一开始很容易被低估。

很多时候我们会先把后端返回的内容直接放进气泡里:

vue 复制代码
{{ message.content }}

如果 AI 只回复一句普通文本,这当然没有问题。

但真实项目里,AI 的输出通常不会这么简单。

它可能返回:

text 复制代码
标题
段落
列表
代码块
表格
链接
引用
步骤说明
错误分析
实现方案

尤其是在 Zero Code Agent 这种 AI Coding 场景里,AI 经常要输出方案、代码、目录结构、配置说明。

如果仍然只按普通字符串展示,用户看到的会是一大坨文本:

text 复制代码
# 页面结构
- Header
- Sidebar
- Content
```ts
const app = createApp(App)
```

内容本身是结构化的,但页面没有把结构表达出来。

这就是为什么 AI 对话不能只停留在"把字符串显示出来"。

这篇文章记录一下我在项目里为什么要引入 Markdown 解析器,以及我是如何基于成熟底层库封装自己的 MarkdownRenderer 业务组件,再接入到 Ant Design X VueBubble 消息气泡里的。

为什么不是直接展示字符串

AI 返回的内容,本质上经常是"带结构的文本"。

Markdown 正好是这类内容最常见的表达格式。

比如模型返回一份实现方案:

md 复制代码
## 实现思路

1. 新增 MarkdownRenderer 组件
2. 使用 markdown-it 解析 Markdown
3. 使用 highlight.js 处理代码高亮
4. 使用 DOMPurify 做安全过滤

```ts
messageRender: (content) => h(MarkdownRenderer, {
  content: String(content ?? ''),
})
```

如果按纯文本展示,用户看到的是符号本身。

如果按 Markdown 渲染,用户看到的是:

text 复制代码
清晰的标题
有层级的列表
带高亮的代码块
可点击的链接
可阅读的表格

这两种体验差很多。

纯文本展示的问题主要有三个。

第一个问题是可读性差。

AI 回复越长,纯文本越难读。尤其是生成方案、技术分析、代码说明时,用户需要快速扫结构,而不是从一堆字符里自己找重点。

第二个问题是代码不可用。

AI Coding 场景里,代码块是高频内容。没有代码块样式和语法高亮,用户复制、阅读、定位问题都会变得困难。

第三个问题是产品质感不够。

AI 产品的输出不是普通日志,消息区本身就是产品主界面。内容渲染质量直接影响用户对系统能力的判断。

所以 Markdown 渲染不是锦上添花。

在 AI 对话产品里,它更像是基础能力。

Markdown 解析器解决什么问题

Markdown 解析器要解决的核心问题是:

把 AI 返回的 Markdown 字符串,转换成浏览器可以渲染的 HTML 结构。

比如输入:

md 复制代码
## 页面结构

- 顶部导航
- 左侧历史
- 中间对话区

```ts
const message = 'hello'
```

经过解析之后,会变成类似:

html 复制代码
<h2>页面结构</h2>
<ul>
  <li>顶部导航</li>
  <li>左侧历史</li>
  <li>中间对话区</li>
</ul>
<pre><code class="language-ts">const message = 'hello'</code></pre>

浏览器真正擅长渲染的是 HTML。

Markdown 解析器就是中间这一层:

text 复制代码
AI 输出 Markdown
  ↓
Markdown 解析器
  ↓
HTML 结构
  ↓
组件样式渲染

但注意,Markdown 解析器本身只负责解析。

它不等于完整的消息展示方案。

真实项目里还需要处理:

text 复制代码
代码高亮
HTML 安全过滤
链接打开方式
表格横向滚动
代码块样式
流式输出中的半截 Markdown
和消息气泡组件的配合

所以我没有直接把某个 Markdown 组件丢进页面,而是选择了更稳的方式:

成熟底层库 + 自己封装业务组件。

为什么选择成熟底层库

当前项目选择的是:

text 复制代码
markdown-it   Markdown 解析
highlight.js  代码高亮
DOMPurify     HTML 安全过滤

这三个库各司其职。

作用
markdown-it 把 Markdown 字符串解析成 HTML
highlight.js 给代码块做语法高亮
DOMPurify 清理不安全 HTML,降低 XSS 风险

这个组合在工程上比较舒服。

它不是一个黑盒大组件,而是一组成熟的基础能力。

这样做的好处是:渲染逻辑掌握在自己手里,后面要加复制代码、代码块标题、图片预览、表格优化、暗色主题,都比较容易扩展。

如果一开始直接使用封装很重的 Vue Markdown 组件,短期会快一点,但后期要改细节时经常会被组件内部结构限制。

AI 对话这种页面,后面一定会长出很多业务细节。

所以我的判断是:

text 复制代码
底层解析用成熟库
业务样式和行为自己封装

这也是更接近真实项目的做法。

安装依赖

项目里新增了这些依赖:

json 复制代码
{
  "dependencies": {
    "dompurify": "^3.2.7",
    "highlight.js": "^11.11.1",
    "markdown-it": "^14.1.0"
  },
  "devDependencies": {
    "@types/markdown-it": "^14.1.2"
  }
}

对应命令可以是:

bash 复制代码
pnpm add markdown-it highlight.js dompurify
pnpm add -D @types/markdown-it

DOMPurifyhighlight.js 当前版本本身已经带类型支持,不需要额外安装类型包。

封装 MarkdownRenderer

在项目里,我把 Markdown 渲染单独封装成了一个组件:

text 复制代码
src/features/workbench/components/chat/MarkdownRenderer.vue

它的职责很明确:

text 复制代码
接收 markdown 字符串
解析成 HTML
做代码高亮
做安全过滤
输出最终可渲染内容

组件对外只暴露一个 content

ts 复制代码
const props = defineProps<{
  content: string;
}>();

这点很重要。

业务层不需要关心 Markdown 怎么解析,也不需要关心代码高亮怎么配置。

业务层只需要传入字符串:

text 复制代码
MarkdownRenderer(content)

组件内部负责剩下的事情。

markdown-it 配置

核心解析器这样创建:

ts 复制代码
const markdown: MarkdownIt = new MarkdownIt({
  html: false,
  linkify: true,
  breaks: true,
  langPrefix: 'language-',
  highlight(code: string, lang: string): string {
    if (lang && hljs.getLanguage(lang)) {
      return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
    }

    return escapeHtml(code);
  },
});

这几个配置分别解决不同问题。

html: false 表示不允许 Markdown 里的原始 HTML 直接生效。

这是我在 AI 对话场景里比较坚持的一点。

因为模型输出不完全可控,用户输入也不完全可信。如果放开原始 HTML,后面就必须面对更复杂的 XSS 风险。

当前项目只需要 Markdown 语义,不需要模型直接插入任意 HTML,所以关掉是更稳的选择。

linkify: true 表示自动识别链接。

比如模型输出:

text 复制代码
https://example.com

可以自动变成链接。

breaks: true 更适合聊天场景。

用户在聊天里通常认为换行就是换行,而不是严格遵守标准 Markdown 的段落规则。打开这个配置后,模型输出中的普通换行会更接近对话阅读习惯。

langPrefix: 'language-' 是给代码块语言 class 加前缀。

比如:

md 复制代码
```ts
const name = 'zero code'
```

会得到:

html 复制代码
<code class="language-ts">

这对代码高亮和后续样式扩展都有帮助。

代码高亮如何处理

AI Coding 场景里,代码块是核心内容。

所以 MarkdownRenderer 里接入了 highlight.js

ts 复制代码
highlight(code: string, lang: string): string {
  if (lang && hljs.getLanguage(lang)) {
    return hljs.highlight(code, { language: lang, ignoreIllegals: true }).value;
  }

  return escapeHtml(code);
}

这里没有盲目高亮所有内容。

先判断:

ts 复制代码
lang && hljs.getLanguage(lang)

只有模型标注了语言,并且 highlight.js 支持这个语言时,才执行高亮。

如果语言不存在,就走普通转义:

ts 复制代码
return escapeHtml(code);

这样可以避免因为模型输出了不存在的语言标识,导致高亮库解析异常。

这也是 AI 场景里常见的工程处理:

模型输出可以不完美,前端渲染必须稳定。

为什么要手动 escapeHtml

代码块即使不做高亮,也不能直接返回原始内容。

比如代码里有:

html 复制代码
<script>alert(1)</script>

如果不转义,后续又通过 v-html 渲染,就会有安全风险。

所以项目里写了一个轻量转义函数:

ts 复制代码
const escapeHtml = (value: string) => value
  .replace(/&/g, '&amp;')
  .replace(/</g, '&lt;')
  .replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;')
  .replace(/'/g, '&#39;');

它的作用是把危险字符变成普通文本。

这样即使代码块里出现 HTML 标签,也只会作为代码展示,而不会被浏览器执行。

DOMPurify:最后一道安全过滤

Markdown 解析最终会得到 HTML 字符串。

Vue 里要渲染 HTML,一般会用:

vue 复制代码
<div v-html="html" />

但只要用了 v-html,就必须认真考虑安全问题。

因为 v-html 会把字符串当 HTML 插入页面。

所以最终渲染前,项目里又加了一层 DOMPurify

ts 复制代码
const html = computed(() => {
  const rawHtml = markdown.render(props.content);

  return DOMPurify.sanitize(rawHtml, {
    USE_PROFILES: { html: true },
  });
});

这里的链路是:

text 复制代码
Markdown 字符串
  ↓
markdown.render
  ↓
rawHtml
  ↓
DOMPurify.sanitize
  ↓
safeHtml
  ↓
v-html 渲染

有人可能会问:

text 复制代码
既然 markdown-it 已经 html: false 了,为什么还要 DOMPurify?

我的理解是,html: false 是第一层防线。

DOMPurify 是最后一道兜底。

真实项目里,后续可能会新增 Markdown 插件,可能会支持更多语法,也可能有人改配置打开 HTML。

安全过滤单独保留,可以让组件更稳。

这不是多余,而是防止未来扩展时把安全边界弄丢。

链接为什么要特殊处理

AI 回复里经常会带链接。

比如文档地址、接口地址、参考资料。

如果链接直接在当前页打开,用户可能会离开工作台。

所以项目里改写了 link_open 渲染规则:

ts 复制代码
const defaultLinkOpenRenderer = markdown.renderer.rules.link_open;

markdown.renderer.rules.link_open = (tokens, index, options, env, self) => {
  const token = tokens[index];
  const targetIndex = token.attrIndex('target');
  const relIndex = token.attrIndex('rel');

  if (targetIndex < 0) {
    token.attrPush(['target', '_blank']);
  } else {
    token.attrs![targetIndex][1] = '_blank';
  }

  if (relIndex < 0) {
    token.attrPush(['rel', 'noopener noreferrer']);
  } else {
    token.attrs![relIndex][1] = 'noopener noreferrer';
  }

  return defaultLinkOpenRenderer
    ? defaultLinkOpenRenderer(tokens, index, options, env, self)
    : self.renderToken(tokens, index, options);
};

这里做了两件事。

第一,给链接加:

html 复制代码
target="_blank"

让链接在新窗口打开。

第二,给链接加:

html 复制代码
rel="noopener noreferrer"

避免新页面通过 window.opener 影响当前页面。

这属于很小但很实用的安全细节。

为什么通过 Bubble 的 messageRender 接入

当前消息列表使用的是 Ant Design X VueBubbleList

之前纯文本时,assistant 消息直接展示 content 就够了。

现在要渲染 Markdown,就不能再让 Bubble 按普通字符串展示。

项目里是在 assistant 的 role 配置里加了 messageRender

ts 复制代码
const roles = {
  assistant: {
    placement: 'start',
    variant: 'borderless',
    avatar: {
      src: aiAvatar,
      size: 32,
      shape: 'square',
    },
    classNames: { content: 'agent-message-ai' },
    messageRender: (content: unknown) => h(MarkdownRenderer, {
      content: String(content ?? ''),
    }),
  },
};

这段代码的意思是:

text 复制代码
assistant 消息仍然由 Bubble 渲染气泡
但消息正文不直接显示字符串
而是交给 MarkdownRenderer 渲染

这里我没有用 BubbleListmessage 插槽直接渲染 item.content

原因是项目里还用到了 Bubble 的打字机能力。

如果在插槽里直接写:

vue 复制代码
<template #message="{ item }">
  <MarkdownRenderer :content="item.content" />
</template>

很容易绕过 Bubble 内部处理后的 typedContent

这样打字机效果可能会退化成整段直接出现。

messageRender 更适合这个场景。

因为它接收的是 Bubble 内部已经处理后的内容。

也就是说:

text 复制代码
SSE 追加完整 content
Bubble 根据 typing 生成 typedContent
messageRender 拿到 typedContent
MarkdownRenderer 渲染当前可见内容

这条链路比较干净。

和 SSE 流式输出的关系

后端通过 SSE 返回 chunk 时,前端持续把 chunk 追加到 assistant 消息:

ts 复制代码
replaceMessage(target, assistantKey, item => ({
  ...item,
  loading: false,
  content: `${item.content}${chunk}`,
}));

这时 message.content 是完整的 Markdown 字符串。

但是页面显示的内容不是直接等于完整 content

因为 assistant 消息创建时配置了 typing:

ts 复制代码
typing: { step: 2, interval: 24, suffix: '|' }

于是显示链路变成:

text 复制代码
后端 SSE chunk
  ↓
追加到 assistant.content
  ↓
Bubble typing 截取当前展示内容
  ↓
messageRender 接收当前 typedContent
  ↓
MarkdownRenderer 渲染 Markdown

这个设计的好处是:

text 复制代码
数据层仍然是完整 Markdown
展示层仍然有打字机效果
最终内容仍然能按 Markdown 排版

三件事没有混在一起。

useWorkbenchChat 不需要知道 Markdown 怎么渲染。

MarkdownRenderer 也不需要知道 SSE 怎么连接。

Bubble 继续负责消息气泡、角色样式和 typing。

每一层只做自己的事情。

流式 Markdown 的一个现实问题

流式输出时,Markdown 内容经常是"半截"的。

比如模型正在输出代码块:

md 复制代码
```ts
const message =

这时结尾的三个反引号可能还没回来。

MarkdownRenderer 必须能接受这种半成品内容。

它不能因为 Markdown 暂时不完整就报错,也不能让页面白屏。

这也是为什么我没有在渲染层做太多复杂假设。

当前策略是:

text 复制代码
每次拿到当前 content
按当前内容尽量解析
解析结果交给 DOMPurify 清洗
页面正常展示当前阶段能展示的内容

等后续 chunk 到达,Markdown 结构逐渐完整,渲染结果也会自然更新。

对于 AI 流式产品来说,这比追求每一帧 Markdown 都完全正确更重要。

用户要的是持续反馈,而不是等所有格式闭合后才展示。

为什么用户消息不走 Markdown

当前项目只给 assistant 配置了 messageRender

用户消息仍然按普通文本展示。

原因很简单:

text 复制代码
用户消息通常是输入需求
AI 消息才是结构化输出

如果用户消息也走 Markdown,反而可能产生一些意外效果。

比如用户输入:

text 复制代码
# 这个按钮为什么不生效

它可能被渲染成一级标题。

这不一定符合聊天输入的预期。

所以当前设计是:

text 复制代码
user:普通文本气泡
assistant:Markdown 富文本气泡

这也是多数 AI 产品更自然的展示方式。

当前实现的完整链路

最后把整个链路串一下。

用户发送消息后:

text 复制代码
用户输入需求
  ↓
前端插入 user 消息
  ↓
前端插入 assistant 空消息,并配置 typing
  ↓
建立 SSE 连接
  ↓
后端持续返回 Markdown chunk
  ↓
前端持续追加 assistant.content
  ↓
Bubble 根据 typing 生成当前 typedContent
  ↓
messageRender 把 typedContent 传给 MarkdownRenderer
  ↓
MarkdownRenderer 解析、高亮、净化并渲染

这条链路里,各层职责是这样的:

责任
后端 SSE 返回流式 Markdown 文本
useWorkbenchChat 维护会话和消息状态
BubbleList 渲染消息列表和滚动
Bubble 处理角色、气泡、typing
messageRender 接管 assistant 正文渲染
MarkdownRenderer Markdown 解析、高亮、安全过滤

我比较喜欢这种分层。

因为它没有把所有逻辑塞进消息列表组件里。

ChatMessageList 仍然只负责消息展示结构。

MarkdownRenderer 只负责内容渲染。

useWorkbenchChat 只负责数据流和消息状态。

后面要扩展任何一个能力,都不会牵一发动全身。

小结

这次接入 Markdown 渲染,本质上不是为了"让文本好看一点"。

它解决的是 AI 对话产品里的核心展示问题:

text 复制代码
AI 输出是结构化内容
页面必须把结构正确表达出来

所以我选择了:

text 复制代码
markdown-it 负责解析
highlight.js 负责代码高亮
DOMPurify 负责安全过滤
MarkdownRenderer 负责业务封装
Bubble messageRender 负责接入消息气泡

这样做之后,AI 消息可以同时具备:

text 复制代码
流式输出
打字机效果
Markdown 排版
代码高亮
安全过滤
统一气泡样式
相关推荐
爱酱丶1 小时前
VS Code 快速生成Vue3 + TypeScript + Setup 基础空模板
前端·javascript·typescript
Maxkim1 小时前
在 GitHub 仓库里配一个 AI Code Reviewer,自动审查 PR
前端·javascript
忆江南1 小时前
Flutter 图片库与 Dio 库优化实战指南
前端
NeverSettle_1 小时前
Agent 如何快速调用公司接口?——CLI + Skill 实践与踩坑
前端·javascript·后端
环境栈笔记1 小时前
指纹浏览器怎么用:从 Profile、代理到环境检测的完整上手流程
前端·人工智能·后端·自动化
sugar__salt1 小时前
三列布局与 TypeScript 工具类型 Pick / Omit / Partial 详解
前端·javascript·typescript
IMPYLH2 小时前
HTML 的 <html> 元素
前端·javascript·html
IT_陈寒2 小时前
SpringBoot自动配置的坑,把我整不会了
前端·人工智能·后端
赵大仁2 小时前
Human-in-the-loop:前端确认流与后端幂等
前端·后端·ai·agent·人机协作