第 6 篇:AI 回答不只是纯文本:Markdown 渲染与代码高亮

3 阅读7分钟

项目地址

说明:当前在线版本部署在 Vercel,国内网络访问可能不稳定。如果打不开,可以直接查看 GitHub 源码并在本地启动。


前言

上一篇我们实现了 SSE 流式输出。

现在 AI 回答已经可以像 ChatGPT 一样逐步显示了:

用户提问
  ↓
Dify streaming
  ↓
Express 转发 SSE
  ↓
前端逐步追加回答

但是当前页面还有一个体验问题:

AI 回答只是普通纯文本展示。

这在简单问答里还能接受,但只要 AI 返回复杂内容,体验就会变差。

比如 AI 很可能返回:

# 标题

- 列表项
- 列表项

```ts
const message = 'hello'
字段说明
role消息角色

如果我们仍然用:

```tsx
<div>{content}</div>

这些 Markdown 内容就不会正确渲染。

所以这一篇我们来做:

Markdown 渲染 + GitHub 风格表格 + 代码高亮。


本篇目标

完成后,AI 回答支持:

1. Markdown 标题
2. 段落
3. 有序 / 无序列表
4. 表格
5. 行内代码
6. 代码块
7. 代码高亮
8. 流式输出时也能逐步渲染 Markdown

我们会使用:

react-markdown
remark-gfm
rehype-highlight
highlight.js

为什么 AI 应用需要 Markdown 渲染?

AI 应用里,Markdown 几乎是标配。

原因是 AI 经常会返回结构化内容:

  • 技术解释
  • 步骤说明
  • 代码示例
  • 表格对比
  • 配置片段
  • 错误排查
  • 总结列表

如果只是纯文本展示,用户很难阅读。

尤其是代码块,如果没有高亮,会非常影响体验。

一个前端 AI 助手如果不能很好展示 Markdown,就会显得很像测试 Demo,而不是正式产品。


第一步:安装依赖

在项目根目录执行:

npm install react-markdown remark-gfm rehype-highlight highlight.js

几个依赖分别做什么:

react-markdown:把 Markdown 渲染成 React 组件
remark-gfm:支持 GitHub Flavored Markdown,比如表格、任务列表
rehype-highlight:代码高亮插件
highlight.js:代码高亮库和主题样式

第二步:引入代码高亮样式

在:

src/main.tsx

或者:

src/App.tsx

引入 highlight.js 的样式:

import 'highlight.js/styles/github.css'

如果你的页面是暗色主题,也可以换成:

import 'highlight.js/styles/github-dark.css'

当前项目先用浅色主题,所以选择:

import 'highlight.js/styles/github.css'

第三步:抽出 ChatMessage 组件

之前消息可能直接写在 App.tsx 里:

{messages.map((message, index) => (
  <div key={index}>
    <strong>{message.role === 'user' ? '你' : 'AI'}:</strong>
    <div>{message.content}</div>
  </div>
))}

这样后面会越来越难维护。

我们先把消息展示抽成组件。

新建:

src/components/ChatMessage.tsx

写入:

import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'

type ChatMessageProps = {
  role: 'user' | 'assistant'
  content: string
}

export function ChatMessage({ role, content }: ChatMessageProps) {
  const isUser = role === 'user'

  return (
    <div className={`message ${isUser ? 'user' : 'ai'}`}>
      <strong>{isUser ? '你' : 'AI'}:</strong>

      {isUser ? (
        <div className="message-text">{content}</div>
      ) : (
        <div className="markdown-body">
          <ReactMarkdown
            remarkPlugins={[remarkGfm]}
            rehypePlugins={[rehypeHighlight]}
          >
            {content}
          </ReactMarkdown>
        </div>
      )}
    </div>
  )
}

这里我们做了一个区分:

用户消息:按普通文本展示
AI 消息:按 Markdown 渲染

为什么用户消息不渲染 Markdown?

因为用户输入通常就是普通问题,直接保留原始文本更符合预期。


第四步:在 App 中使用 ChatMessage

打开:

src/App.tsx

引入组件:

import { ChatMessage } from './components/ChatMessage'

把原来的消息渲染:

{messages.map((message, index) => (
  <div
    key={index}
    className={`message ${message.role === 'user' ? 'user' : 'ai'}`}
  >
    <strong>{message.role === 'user' ? '你' : 'AI'}:</strong>
    <div>{message.content}</div>
  </div>
))}

改成:

{messages.map((message, index) => (
  <ChatMessage
    key={index}
    role={message.role}
    content={message.content}
  />
))}

这样 App.tsx 会变干净一些。


第五步:补充 Markdown 样式

在你的 CSS 文件中增加:

.message-text {
  white-space: pre-wrap;
}

.markdown-body {
  line-height: 1.7;
}

.markdown-body p {
  margin: 0 0 10px;
}

.markdown-body p:last-child {
  margin-bottom: 0;
}

.markdown-body ul,
.markdown-body ol {
  padding-left: 22px;
  margin: 8px 0;
}

.markdown-body li {
  margin: 4px 0;
}

.markdown-body h1,
.markdown-body h2,
.markdown-body h3 {
  margin: 16px 0 8px;
  line-height: 1.4;
}

.markdown-body h1:first-child,
.markdown-body h2:first-child,
.markdown-body h3:first-child {
  margin-top: 0;
}

.markdown-body code {
  font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
  font-size: 0.92em;
}

.markdown-body :not(pre) > code {
  background: #f3f4f6;
  padding: 2px 5px;
  border-radius: 4px;
}

.markdown-body pre {
  margin: 12px 0;
  padding: 12px;
  overflow-x: auto;
  border-radius: 8px;
  background: #f6f8fa;
}

.markdown-body pre code {
  background: transparent;
  padding: 0;
}

.markdown-body table {
  width: 100%;
  border-collapse: collapse;
  margin: 12px 0;
  font-size: 14px;
}

.markdown-body th,
.markdown-body td {
  border: 1px solid #e5e7eb;
  padding: 8px 10px;
  text-align: left;
}

.markdown-body th {
  background: #f9fafb;
}

这不是最终 UI,只是一版基础 Markdown 样式。

后面做 UI 优化时,还会进一步调整整体聊天布局。


第六步:测试 Markdown 渲染

启动项目:

npm run dev:all

然后问 AI:

请用 Markdown 列出前端架构主要包括哪些内容

如果 Dify 的 Prompt 没有限制得太死,AI 可能会返回列表形式:

前端架构主要包括:

- 项目分层
- 组件设计
- 状态管理
- 权限控制
- 性能优化
- 工程化
- 可观测性

页面应该正确显示为列表,而不是一整段纯文本。

再测试代码块:

请给我一个简单的 TypeScript 类型示例

期望返回类似:

type Message = {
  role: 'user' | 'assistant'
  content: string
}

页面应该能显示代码块,并有基础高亮。


第七步:流式输出和 Markdown 渲染会冲突吗?

不会。

因为我们前端的内容仍然是一个字符串:

content: current.content + chunk

每次 chunk 更新后,React 会重新渲染:

<ReactMarkdown>{content}</ReactMarkdown>

所以 Markdown 会随着内容逐步变完整。

不过流式 Markdown 有一个小现象:

当代码块还没输出完整时,比如只输出到:

```ts
const a =

这时 Markdown 可能暂时渲染得不完整。

等后续内容继续输出,代码块闭合后,展示会恢复正常。

这是流式 Markdown 的正常现象。


第八步:为什么选择 react-markdown?

在 React 项目里渲染 Markdown 有很多方案。

我这里选择 react-markdown,原因是:

1. React 生态常用
2. 插件体系比较成熟
3. 可以配合 remark / rehype
4. 支持自定义组件
5. 后续扩展方便

比如后续你可以自定义:

  • a 标签打开方式
  • code 代码块样式
  • table 容器滚动
  • 图片渲染
  • 引用块样式
  • 复制代码按钮

当前阶段先用基础能力即可。


第九步:为什么需要 remark-gfm?

默认 Markdown 不一定完整支持 GitHub 风格语法。

remark-gfm 可以支持:

1. 表格
2. 删除线
3. 任务列表
4. 自动链接

AI 回答里很容易生成表格,比如:

| 能力 | 说明 |
| --- | --- |
| 流式输出 | 提升等待体验 |
| 引用来源 | 增强可信度 |
| 多会话 | 支持长期使用 |

没有 remark-gfm 的话,表格可能不会按表格渲染。


第十步:为什么需要代码高亮?

技术类 AI 助手经常输出代码。

如果没有代码高亮,代码块只是普通文本,可读性比较差。

我们这里使用:

rehype-highlight
highlight.js

它会自动识别代码语言:

```ts
const message: string = 'hello'

并添加对应高亮样式。

---

## 当前代码结构

这一篇结束后,项目大概变成:

```text
src/
  api/
    dify.ts
    difyStream.ts
  components/
    ChatMessage.tsx
  types/
    chat.ts
  App.tsx
  App.css
  main.tsx

相比之前,多了:

components/ChatMessage.tsx

消息展示逻辑从 App.tsx 抽了出来。

这也是后续组件拆分的第一步。


常见问题

1. 代码块没有高亮

检查是否引入了样式:

import 'highlight.js/styles/github.css'

没有样式的话,即使 rehype-highlight 生效了,也看不出高亮效果。

2. 表格没有渲染

检查是否添加了:

remarkPlugins={[remarkGfm]}

3. TypeScript 报 children 类型问题

确认 ReactMarkdown 的用法是:

<ReactMarkdown>{content}</ReactMarkdown>

不要写成旧版本 API。

4. 流式输出时代码块闪动

这是正常现象。

因为 AI 内容还没输出完整时,Markdown 结构可能暂时不闭合。

等输出结束后会稳定。


当前版本还有什么不足?

这一篇解决了 Markdown 展示问题,但 RAG 产品还有一个更重要的问题:

答案到底来自哪里?

现在用户只能看到 AI 回答,看不到引用来源。

而一个知识库问答系统,最好能告诉用户:

这段回答引用自 frontend-notes.md

Dify 在流式结束事件中会返回 retriever_resources,我们下一篇就来解析它,并在回答下方展示引用来源。


本篇总结

这一篇我们完成了 AI 回答展示体验的升级:

1. 安装 react-markdown
2. 支持 GitHub Flavored Markdown
3. 接入 rehype-highlight
4. 引入 highlight.js 样式
5. 抽出 ChatMessage 组件
6. 用户消息按纯文本展示
7. AI 消息按 Markdown 渲染
8. 支持代码块和表格展示

现在项目已经不再只是“能显示回答”,而是开始具备正式 AI 产品的阅读体验。

下一篇我们继续做 RAG 产品最关键的可信度能力:

展示知识库引用来源。