WebGPU DeepSeek项目实战(五):封装聊天消息组件、安全渲染Markdown并完善加载重试

前言

系列第四篇已经接通了模型生成链路:用户消息进入 messages,Worker 调用模型生成内容,再通过 startupdatecomplete 把结果持续传回 React。到这一步,数据虽然已经回到了页面线程,但它仍然只是消息数组中的字符串。

模型回答并不总是普通文本。它可能包含标题、列表、代码块、表格、引用和数学公式;DeepSeek-R1 的输出还分为思考过程最终答案。如果直接把整个 content 放进普通段落,不仅格式全部丢失,思考内容和正式回答也会混在一起。

因此,这一篇继续完成数据展示之前的处理层:创建独立的 Chat.tsx,按照消息角色选择不同的处理方式;使用 answerIndex 拆分思考和答案;使用 Marked 解析 Markdown、DOMPurify 清理 HTML、MathJax 渲染数学公式;再补充模型等待动画、思考过程展开控制、输入框高度计算,以及模型下载失败后的重试机制。

本篇只讲这次新增的功能与组件内部逻辑,不展开 App.tsxreturn。聊天区域、输入区域、按钮、TPS 和免责声明在页面中的具体布局,统一留到系列最后一篇。

1. 本篇新增内容与项目边界

1.1 第四篇的数据停在哪里

第四篇处理 update 时,会把 Worker 传回的文本片段不断追加到最后一条助手消息:

setMessages((prev) => {
  const cloned = [...prev];
  const last = cloned.at(-1);

  const data = {
    ...last,
    content: last.content + output,
  };

  if (data.answerIndex === undefined && state === "answering") {
    data.answerIndex = last.content.length;
  }

  cloned[cloned.length - 1] = data;
  return cloned;
});

生成过程中,一条助手消息会逐渐变成下面的结构:

{
  role: "assistant",
  content: "先分析问题……最终答案……",
  answerIndex: 8,
}

三个字段各自承担不同职责:

字段 数据来源 本篇用途
role App 创建消息时写入 判断显示用户消息还是模型消息
content Worker 的流式 output 逐步追加 保存完整思考文本与回答文本
answerIndex Worker 从 thinking 切换到 answering 时记录 标记思考和答案在字符串中的分界位置

第四篇解决的是“数据如何回来”,本篇解决的是“回来以后如何解释和组织这些数据”。

1.2 需要创建和修改哪些文件

这次先在 src/components 下新增聊天组件和样式文件,再在 src/components/icons 下新增三个图标组件:

src/
├── components/
│   ├── Chat.tsx               # 组织消息、拆分思考与答案、渲染富文本
│   ├── Chat.css               # 限定 Markdown 内容的排版样式
│   └── icons/
│       ├── BotIcon.tsx        # 模型消息图标
│       ├── BrainIcon.tsx      # 思考过程图标
│       └── UserIcon.tsx       # 用户消息图标
├── App.tsx                    # 增加输入框高度计算和错误状态恢复
└── worker.js                  # 捕获异步错误并清理失败的单例 Promise

每个文件的职责需要保持清楚:

  • App.tsx 继续管理全局消息、模型状态和 Worker 通信。
  • Chat.tsx 只接收 messages,负责把消息数据转换成可显示内容。
  • Chat.css 只约束模型生成的 Markdown HTML,不污染整个页面。
  • 图标组件只提供 SVG,不保存业务状态。
  • worker.js 负责模型加载、推理和异步错误上报。

这种拆分避免把 Markdown 解析、公式渲染、折叠状态和大量 SVG 全部堆进 App.tsx

2. 渲染模型回答前需要理解的基础知识

2.1 Markdown 为什么不能直接显示成富文本

大模型经常返回下面这样的字符串:

## 解题过程

方程可以分解为:

```python
result = (x - 1) * (x - 2)
```

最终得到 **x = 1 或 x = 2**。

对 JavaScript 来说,这仍然只是一段普通字符串。如果直接写入 JSX:

<p>{content}</p>

React 会把 ##、三反引号和 ** 当作普通字符展示,不会自动生成标题、代码块和加粗内容。

Marked 的作用就是完成第一步转换:

Markdown 字符串
        ↓ marked.parse()
HTML 字符串

例如:

**WebGPU**

会被解析成类似:

<p><strong>WebGPU</strong></p>

但是 HTML 字符串还不能不经检查地放进页面,这就引出了下一个问题。

2.2 为什么必须在 dangerouslySetInnerHTML 前清理 HTML

React 默认会转义字符串中的 HTML:

<p>{"<script>alert('xss')</script>"}</p>

浏览器只会把它当成文字,不会执行标签。这是 React 默认提供的一层安全保护。

当项目需要显示 Marked 生成的 HTML 时,必须使用:

dangerouslySetInnerHTML={{ __html: html }}

这个 API 的名字中故意带有 dangerously,因为它会告诉 React:不要再转义这段字符串,直接作为 HTML 插入 DOM。 如果 HTML 中混入事件属性、危险标签或恶意链接,就可能造成 XSS。

XSS(跨站脚本攻击)是指不可信内容被当成网页代码执行。模型输出、用户输入和外部接口返回值都不能因为“看起来是文本”就默认安全。

DOMPurify 用于在插入页面前清理 HTML:

模型返回 Markdown
        ↓
Marked 转成 HTML
        ↓
DOMPurify 删除危险内容
        ↓
dangerouslySetInnerHTML 插入页面

顺序不能反过来。如果先清理 Markdown 原文,再由 Marked 生成 HTML,转换过程仍然可能产生新的 HTML 结构;因此项目选择先解析,再净化,最后插入

2.3 MathJax 解决什么问题

Markdown 可以表达标题、列表和代码块,但普通 Markdown 解析器并不负责把数学公式排版成专业公式。例如模型可能返回:

\(x^2 - 3x + 2 = 0\)

Marked 只能保留这段文本,不能把它排版成数学公式。MathJax 会扫描组件内部的公式标记,并生成浏览器可显示的数学结构。

本项目使用两个组件:

组件 作用
MathJaxContext 为内部消息提供 MathJax 配置和运行环境
MathJax 包裹一段需要扫描和渲染的动态内容

由于模型是流式输出,公式内容会不断变化,所以项目为 MathJax 设置 dynamic

<MathJax dynamic>
  {/* 持续变化的模型回答 */}
</MathJax>

它表示子内容更新后需要重新处理公式,而不是只在第一次挂载时扫描一次。

2.4 answerIndex 为什么可以拆开一条消息

第四篇已经在模型从思考阶段切换到回答阶段时记录:

data.answerIndex = last.content.length;

假设完整内容是:

先分解方程。根分别是 1 和 2。

如果前 6 个字符属于思考过程,answerIndex 就记录为 6。字符串可以通过 slice() 分成两部分:

const thinking = content.slice(0, answerIndex);
const answer = content.slice(answerIndex);

其核心不是寻找某个中文词语,而是使用生成阶段切换时留下的位置索引。这样无论思考和答案写了什么内容,都不需要再次解析自然语言。

3. 安装富文本渲染依赖

3.1 安装命令与依赖职责

如果按照系列文章逐步搭建项目,在创建 Chat.tsx 之前安装:

npm install marked dompurify better-react-mathjax

三个依赖不能互相替代:

依赖 输入 输出或效果 为什么需要
marked Markdown 字符串 HTML 字符串 保留模型回答中的标题、列表、代码块等格式
dompurify HTML 字符串 清理后的 HTML 降低将动态 HTML 插入页面时的 XSS 风险
better-react-mathjax React 子内容 排版后的数学公式 让模型回答中的 LaTeX 公式可读

安装后,package.json 中包含:

{
  "dependencies": {
    "better-react-mathjax": "^2.0.3",
    "dompurify": "^3.2.3",
    "marked": "^15.0.5"
  }
}

这些属于运行时依赖,因为浏览器真正显示模型回答时仍然需要它们,不应只放进 devDependencies

4. 创建消息所需的三个图标组件

4.1 为什么先创建图标再创建 Chat.tsx

Chat.tsx 会直接导入用户、模型和思考图标。如果先写聊天组件但图标文件不存在,编辑器会立刻报告“找不到模块”。所以项目按依赖顺序,先创建:

src/components/icons/UserIcon.tsx
src/components/icons/BotIcon.tsx
src/components/icons/BrainIcon.tsx

三个文件都返回 SVG。以用户图标为例:

export default function UserIcon(props) {
  return (
    <svg
      {...props}
      xmlns="http://www.w3.org/2000/svg"
      width="24"
      height="24"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
      <circle cx="12" cy="7" r="4" />
    </svg>
  );
}

这里最重要的是:

<svg {...props}>

父组件传入的 className、尺寸或其他 SVG 属性会继续传给真正的 <svg>。因此 Chat.tsx 可以这样控制图标:

<UserIcon className="h-6 w-6 text-gray-500" />

如果没有 {...props},组件外部传入的 className 不会落到 SVG 上,图标就无法复用统一样式。

BotIcon.tsx 还通过 React 提供的 SVGProps 标注了参数类型:

import type { SVGProps } from "react";

export default function BotIcon(props: SVGProps<SVGSVGElement>) {
  // props 只能接收合法的 SVG 属性
}

import type 表示这里只引入 TypeScript 类型,构建后的 JavaScript 不会保留这条导入;SVGProps<SVGSVGElement> 则说明 props 是一组可以传给 <svg> 的属性,因此 classNamewidtharia-label 等参数都能得到类型检查。

三个图标在消息中的语义如下:

组件 出现位置 表达的含义
UserIcon 用户消息旁边 这条内容由用户提交
BotIcon 助手消息旁边 这条内容由模型生成
BrainIcon 思考区域按钮中 这里保存模型推理过程

还需要注意文件名必须是 BotIcon.tsx,导入路径也必须保持一致:

import BotIcon from "./icons/BotIcon.tsx";

BotIcon 中的大写 I 与小写 l 在部分字体中很像,但在区分大小写的文件系统和部署环境中是完全不同的字符。

5. 创建 Chat.css,只约束模型生成内容

5.1 为什么不能只依赖 Tailwind CSS

聊天组件本身的布局可以直接使用 Tailwind CSS,但模型生成的标题、列表和代码块不是开发时写在 JSX 中的,而是运行时由 Marked 生成:

<h2>标题</h2>
<pre><code>const value = 1;</code></pre>
<ul><li>列表项</li></ul>

这些标签没有手写 className,因此需要一份普通 CSS 统一设置样式。新建:

src/components/Chat.css

并使用 @scope 把规则限制在 .markdown 内部:

@scope (.markdown) {
  pre {
    margin: 0.5rem 0;
    white-space: break-spaces;
  }

  code {
    padding: 0.2em 0.4em;
    border-radius: 4px;
    font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
    font-size: 0.9em;
  }

  pre,
  code {
    background-color: #f2f2f2;
  }
}

@scope (.markdown) 可以理解为:这些规则只对 .markdown 这块区域里的元素生效。模型生成一个 <h1> 时,它不会意外改变项目加载页或其他组件中的 <h1>

代码块中的几个属性分别解决:

属性 作用
white-space: break-spaces 保留代码中的换行与连续空格,同时允许必要换行
font-family 使用等宽字体显示代码
padding 让行内代码与普通文字之间有明确边界
border-radius 让代码背景与聊天卡片风格一致

深色模式使用媒体查询切换背景:

@media (prefers-color-scheme: dark) {
  pre,
  code {
    background-color: #333;
  }
}

后面的标题、列表、段落和表格规则同样都写在 .markdown 作用域中。这样 Chat.css 处理的是模型生成 HTML 的内部排版,而不是整个聊天页面的布局。

6. 创建 Chat.tsx,把消息数据转换成可读内容

6.1 导入依赖和刚创建的文件

src/components 下新建:

src/components/Chat.tsx

文件顶部先导入状态、解析工具、图标和样式:

import { useState } from "react";
import { marked } from "marked";
import DOMPurify from "dompurify";

import BotIcon from "./icons/BotIcon.tsx";
import BrainIcon from "./icons/BrainIcon.tsx";
import UserIcon from "./icons/UserIcon.tsx";

import { MathJaxContext, MathJax } from "better-react-mathjax";
import "./Chat.css";

此时依赖关系已经完整:Chat.tsx 依赖的三个图标和 Chat.css 都已经提前创建,不会出现导入路径爆红。

6.2 render() 完成 Markdown 安全转换

新增一个只负责文本转换的函数:

function render(text) {
  // 处理数学公式中括号前的反斜杠,避免 Markdown 解析吞掉公式边界。
  text = text.replace(/\\([[\]()])/g, "\\\\$1");

  // 先把 Markdown 转成 HTML,再清除不安全内容。
  const result = DOMPurify.sanitize(
    marked.parse(text, {
      async: false,
      breaks: true,
    }),
  );

  return result;
}

第一行正则用于处理公式边界字符:

text.replace(/\\([[\]()])/g, "\\\\$1");

可以拆成三部分理解:

部分 含义
\\ 匹配文本中的一个反斜杠
([[\]()]) 捕获 []() 中的一个字符
"\\\\$1" 在捕获字符前保留两个反斜杠

这样做是为了减少 Marked 处理反斜杠时对 MathJax 公式定界符的影响。

marked.parse() 的两个选项分别表示:

参数 当前值 作用
async false 当前函数同步返回 HTML 字符串,不返回 Promise
breaks true 普通换行也转换成 HTML 换行,更符合聊天文本习惯

最终返回值不是原始模型文本,而是经过两层处理的 HTML:

text
  ↓ 修正公式反斜杠
marked.parse(text)
  ↓ Markdown 转 HTML
DOMPurify.sanitize(html)
  ↓ 清理危险内容
安全 HTML 字符串

6.3 Message 为什么是独立组件

Chat 负责遍历整个数组,而每一条消息的角色判断、思考折叠和富文本渲染都交给 Message

function Message({ role, content, answerIndex }) {
  // 当前消息自己的处理逻辑
}

这三个参数不是 Message 自己创建的,而是从消息对象中取得的 Props。后面遍历时会使用:

<Message {...msg} />

假设 msg 是:

{
  role: "assistant",
  content: "思考文本最终回答",
  answerIndex: 4,
}

对象展开等价于:

<Message
  role="assistant"
  content="思考文本最终回答"
  answerIndex={4}
/>

独立组件还有一个重要意义:每一条助手消息都可以拥有自己的 showThinking State。展开第一轮思考时,不会同时展开其他消息。

6.4 拆分思考内容和最终答案

组件先根据 answerIndex 切割字符串:

const thinking = answerIndex
  ? content.slice(0, answerIndex)
  : content;

const answer = answerIndex
  ? content.slice(answerIndex)
  : "";

生成仍处于思考阶段时,answerIndex 尚未写入:

thinking = 当前全部 content
answer = ""

切换到回答阶段后:

thinking = content 的 0 到 answerIndex
answer = content 的 answerIndex 到结尾

再用 answer 是否已经出现内容判断思考是否结束:

const doneThinking = answer.length > 0;

这个值同时控制两件事:

  • 思考图标是否继续执行 animate-pulse
  • 提示文字显示 Thinking... 还是 View reasoning.

整个状态变化可以整理为:

生成阶段 thinking answer doneThinking 页面语义
刚收到 start false 等待第一个文本片段
正在思考 持续增长 false 思考中
开始回答 固定 持续增长 true 可查看思考并阅读答案

6.5 用局部 State 控制思考区域展开

每条 Message 内部新增:

const [showThinking, setShowThinking] = useState(false);

点击思考按钮时使用函数式更新:

onClick={() => setShowThinking((prev) => !prev)}

这里传函数不是为了写法好看,而是因为新状态依赖旧状态:

prev = false → true
prev = true  → false

如果直接读取闭包中的 showThinking

setShowThinking(!showThinking);

普通单击通常也能工作,但函数式更新由 React 提供最新值,在更新合并或高频触发时更稳妥,也与项目前面处理 messagesprogressItems 的原则保持一致。

展开条件写成:

{showThinking && (
  <MathJax
    className="border-t border-gray-200 dark:border-gray-700 px-4 py-2"
    dynamic
  >
    <span
      className="markdown"
      dangerouslySetInnerHTML={{
        __html: render(thinking),
      }}
    />
  </MathJax>
)}

这段代码包含四层职责:

  1. showThinking && 决定思考正文是否存在于组件树中。
  2. MathJax dynamic 负责动态公式。
  3. .markdownChat.css 的局部规则生效。
  4. render(thinking) 完成 Markdown 解析与 HTML 清理。

最终答案使用相同的安全渲染链路:

{doneThinking && (
  <MathJax className="mt-2" dynamic>
    <span
      className="markdown"
      dangerouslySetInnerHTML={{
        __html: render(answer),
      }}
    />
  </MathJax>
)}

区别在于:思考过程可以折叠,最终答案在生成后直接显示。

6.6 为什么用户消息不走 dangerouslySetInnerHTML

用户分支直接使用:

<p className="min-h-6 overflow-wrap-anywhere">
  {content}
</p>

React 会把用户输入当作普通文本转义。用户即使输入 <script>,也只会看到这几个字符,不会被当作标签执行。

模型回答需要 Markdown,所以必须经过 render() 后插入 HTML;用户消息只需要原样显示,没有必要扩大 HTML 注入面。这体现了一个实用原则:

只有确实需要富文本的内容才进入 HTML 解析链路,普通文本继续使用 React 默认转义。

6.7 空助手消息为什么显示三个圆点

Worker 发出 start 时,App 会先创建:

{
  role: "assistant",
  content: "",
}

此时模型已经开始推理,但第一个可显示文本片段还没有到达。如果页面什么都不显示,用户会误以为点击没有生效。因此助手分支为内容为空的阶段准备三个脉冲圆点:

<span className="h-6 flex items-center gap-1">
  <span className="w-2.5 h-2.5 bg-gray-600 rounded-full animate-pulse"></span>
  <span className="w-2.5 h-2.5 bg-gray-600 rounded-full animate-pulse animation-delay-200"></span>
  <span className="w-2.5 h-2.5 bg-gray-600 rounded-full animate-pulse animation-delay-400"></span>
</span>

它不是模型输出,也不会写进 messages,只是当前消息暂时为空时的视觉反馈。第一个 update 到达、content 不再为空后,这个分支会自动被真实内容替换。

6.8 Chat 如何把数组交给每条 Message

外层组件接收完整消息数组:

export default function Chat({ messages }) {
  const empty = messages.length === 0;

  return (
    <div>
      <MathJaxContext>
        {empty ? (
          <div className="text-xl">Ready!</div>
        ) : (
          messages.map((msg, i) => (
            <Message key={`message-${i}`} {...msg} />
          ))
        )}
      </MathJaxContext>
    </div>
  );
}

这里完成两个状态:

messages 状态 结果
空数组 显示 Ready!,表示模型已准备好但还没有对话
有消息 使用 map() 把每个消息对象转换成一个 Message 组件

map() 的意义与前面更新进度数组时相似,都是逐项处理数组;区别是这里返回的不是新数据对象,而是 JSX 组件。

key={`message-${i}`}

key 帮助 React 区分列表中的每一项。当前消息只会按顺序追加,不会在中间排序,所以使用下标能够满足这段列表的现有更新方式。

MathJaxContext 放在 map() 外面,所有消息共享同一套 MathJax 上下文,不必为每一条消息重新创建一层 Provider。

7. 让输入框高度跟随内容变化

7.1 为什么需要 DOM 引用

输入框内容保存在 React 的 input State 中,但它真实需要的高度由浏览器根据字体、宽度、换行和滚动高度计算。单靠字符串长度不能准确判断高度,因此项目使用:

const textareaRef = useRef(null);

textareaRef.current 会指向真实的 <textarea> DOM。输入框具体如何绑定 ref,属于 App.tsx return 的页面结构,本篇先不展开,只分析高度计算函数。

7.2 resizeInput() 为什么先设置为 auto

新增函数:

function resizeInput() {
  // DOM 尚未挂载时直接结束,避免读取 null。
  if (!textareaRef.current) return;

  const target = textareaRef.current;

  // 先取消上一次写入的固定高度,让浏览器重新计算内容高度。
  target.style.height = "auto";

  // 最低 24px,最高 200px。
  const newHeight = Math.min(
    Math.max(target.scrollHeight, 24),
    200,
  );

  target.style.height = `${newHeight}px`;
}

如果不先执行:

target.style.height = "auto";

输入内容减少时,元素仍然保留上一次较大的固定高度,scrollHeight 可能无法按照收缩后的内容重新得到正确结果。先恢复自动高度,再读取 scrollHeight,输入框才能既变高也变矮。

高度限制分两层:

Math.max(target.scrollHeight, 24)

保证输入框不会低于 24px;外层:

Math.min(result, 200)

保证输入框不会无限挤压聊天区域。超过 200px 后,由输入框自身滚动承担更多内容。

7.3 为什么用 Effect 监听 input

useEffect(() => {
  resizeInput();
}, [input]);

用户输入或发送后,input 都会变化。React 完成本轮渲染后执行 Effect,此时 DOM 已经拥有最新文字,再读取 scrollHeight 才可靠。

用户输入
  ↓
setInput() 更新 State
  ↓
React 把新内容写入 textarea
  ↓
依赖 input 的 Effect 执行
  ↓
读取最新 scrollHeight 并调整高度

这段逻辑没有创建新的业务状态,而是让 DOM 高度始终跟随现有 input 状态。

8. 捕获异步加载错误,让失败后可以重新尝试

8.1 Uncaught (in promise) 是怎样出现的

原来的 Worker 消息分支直接调用异步函数:

case "load":
  load();
  break;

load() 内部需要从 Hugging Face 下载约 1.28GB 的模型权重。网络中途断开时,from_pretrained() 返回的 Promise 会进入 rejected 状态。如果调用处既没有 await,也没有 .catch(),错误就会变成:

Uncaught (in promise) TypeError: network error

这不代表错误是由最后一行代码制造的,而是异步调用链没有统一接住 Promise rejection。

8.2 为什么失败的单例 Promise 必须清空

单例加载使用:

this.model ??= AutoModelForCausalLM.from_pretrained(...);

这里缓存的不只是最终模型,也可能是一个尚未完成的 Promise。如果网络失败,this.model 不会自动回到 null,而会继续保存 rejected Promise。

下一次再次调用:

this.model ??= ...;

由于 this.model 已经有值,??= 不会重新执行右侧下载,而是继续返回同一个失败结果。于是即使网络恢复,重新加载也可能立刻失败。

因此为单例类增加:

static reset() {
  // 清除失败的 Promise,让下一次 getInstance() 可以重新创建加载任务。
  this.tokenizer = null;
  this.model = null;
}

Tokenizer 即使之前已经成功下载,清空引用也不会必然重新消耗完整网络流量,因为成功资源仍可能存在浏览器缓存中。这里更重要的是保证 Tokenizer 和模型重新进入一致的加载流程。

8.3 在 Worker 消息入口统一 await 和捕获错误

消息监听器本身改成异步函数,并用 try...catch 包住所有命令:

self.addEventListener("message", async (e) => {
  const { type, data } = e.data;

  try {
    switch (type) {
      case "check":
        await check();
        break;

      case "load":
        await load();
        break;

      case "generate":
        stopping_criteria.reset();
        await generate(data);
        break;

      case "interrupt":
        stopping_criteria.interrupt();
        break;

      case "reset":
        past_key_values_cache = null;
        stopping_criteria.reset();
        break;
    }
  } catch (error) {
    if (type === "load") {
      TextGenerationPipeline.reset();
    }

    self.postMessage({
      status: "error",
      data: error instanceof Error
        ? error.message
        : String(error),
      operation: type,
    });
  }
});

await 的意义是让 try...catch 等到异步操作完成:

收到 load
  ↓
await load()
  ├─ 成功:继续执行,最终发送 ready
  └─ 失败:跳入 catch
              ↓
        清空失败单例
              ↓
        向 App 发送 error

错误消息增加 operation: type

{
  status: "error",
  data: "network error",
  operation: "load",
}

data 告诉 App 发生了什么,operation 告诉 App 错误发生在哪个阶段。加载错误和生成错误对页面状态的影响并不完全相同,所以不能只传一个模糊字符串。

8.4 App 如何恢复加载状态

App 的 Worker 消息监听器补充:

case "error":
  // 保存可显示的错误文字。
  setError(e.data.data);

  // 无论加载还是生成失败,都结束运行状态。
  setIsRunning(false);

  // 只有加载失败才回到未加载状态,并清空旧进度条。
  if (e.data.operation === "load") {
    setStatus(null);
    setProgressItems([]);
  }
  break;

加载失败后的 State 变化如下:

State 失败前 失败后 原因
error null "network error" 保存错误说明
isRunning 可能为 true false 结束本次异步任务状态
status "loading" null 允许重新进入加载流程
progressItems 包含失败下载项 [] 不保留已经失效的旧进度

注意:错误捕获不会让不稳定的网络突然变快。它解决的是另外三个问题:

  • 错误不再以未处理 Promise 的形式散落在控制台。
  • React 可以得到明确错误信息并更新状态。
  • 失败的单例 Promise 被清除,网络恢复后能够真正发起新请求。

错误信息和重新加载按钮如何排列,属于 App.tsx return,继续留到系列最后一篇。

9. 把本篇新增链路串起来

9.1 从流式消息到富文本回答

第四篇与本篇连接后的数据流程如下:

Worker 生成 Token
      ↓ TextStreamer 解码
Worker 发送 update
      ↓
App 追加到 assistant.content
并在阶段切换时记录 answerIndex
      ↓
Chat 接收 messages
      ↓ messages.map()
每个对象变成 Message
      ↓
role === user
  └─ React 普通文本渲染

role === assistant
  ├─ content 为空:三个等待圆点
  ├─ slice(0, answerIndex):思考过程
  └─ slice(answerIndex):最终答案
          ↓
     Marked 转 HTML
          ↓
     DOMPurify 清理
          ↓
     MathJax 处理公式

这里没有重新请求模型,也没有复制第二套对话状态。messages 仍然是唯一消息数据源,Chat 只是根据这些数据计算应该显示什么。

9.2 加载失败后的恢复流程

用户启动模型加载
      ↓
Worker await load()
      ↓
网络中断,Promise rejected
      ↓
Worker catch
  ├─ TextGenerationPipeline.reset()
  └─ postMessage({ status: "error", operation: "load" })
      ↓
App 收到 error
  ├─ 保存错误信息
  ├─ 停止运行状态
  ├─ status 回到 null
  └─ 清空失败进度项
      ↓
下一次加载重新创建 Promise

这条链路与模型生成链路相互独立。Chat.tsx 不处理模型下载,worker.js 也不处理页面中的思考折叠;组件各自只承担自己的职责。

10. 本篇新增代码汇总

10.1 src/components/Chat.tsx 完整代码

import { useState } from "react";
import { marked } from "marked";
import DOMPurify from "dompurify";

import BotIcon from "./icons/BotIcon.tsx";
import BrainIcon from "./icons/BrainIcon.tsx";
import UserIcon from "./icons/UserIcon.tsx";

import { MathJaxContext, MathJax } from "better-react-mathjax";
import "./Chat.css";

// 把模型返回的 Markdown 转成经过安全清理的 HTML。
function render(text) {
  // 保留 MathJax 公式边界中的反斜杠。
  text = text.replace(/\\([[\]()])/g, "\\\\$1");

  const result = DOMPurify.sanitize(
    marked.parse(text, {
      async: false,
      breaks: true,
    }),
  );

  return result;
}

// 一条消息拥有自己的角色、内容、答案边界和折叠状态。
function Message({ role, content, answerIndex }) {
  // answerIndex 出现前,全部内容都属于思考过程。
  const thinking = answerIndex
    ? content.slice(0, answerIndex)
    : content;

  // answerIndex 出现后,从边界位置开始截取最终答案。
  const answer = answerIndex
    ? content.slice(answerIndex)
    : "";

  // 每条消息独立控制自己的思考区域。
  const [showThinking, setShowThinking] = useState(false);

  // answer 开始出现内容,表示模型已进入正式回答阶段。
  const doneThinking = answer.length > 0;

  return (
    <div className="flex items-start space-x-4">
      {role === "assistant" ? (
        <>
          <BotIcon className="h-6 w-6 min-h-6 min-w-6 my-3 text-gray-500 dark:text-gray-300" />

          <div className="bg-gray-200 dark:bg-gray-700 rounded-lg p-4">
            <div className="min-h-6 text-gray-800 dark:text-gray-200 overflow-wrap-anywhere">
              {thinking.length > 0 ? (
                <>
                  <div className="bg-white dark:bg-gray-800 rounded-lg flex flex-col">
                    <button
                      className="flex items-center gap-2 cursor-pointer p-4 hover:bg-gray-50 dark:hover:bg-gray-900 rounded-lg "
                      onClick={() =>
                        setShowThinking((prev) => !prev)
                      }
                      style={{
                        width: showThinking ? "100%" : "auto",
                      }}
                    >
                      <BrainIcon
                        className={
                          doneThinking ? "" : "animate-pulse"
                        }
                      />

                      <span>
                        {doneThinking
                          ? "View reasoning."
                          : "Thinking..."}
                      </span>

                      <span className="ml-auto text-gray-700">
                        {showThinking ? "▲" : "▼"}
                      </span>
                    </button>

                    {showThinking && (
                      <MathJax
                        className="border-t border-gray-200 dark:border-gray-700 px-4 py-2"
                        dynamic
                      >
                        <span
                          className="markdown"
                          dangerouslySetInnerHTML={{
                            __html: render(thinking),
                          }}
                        />
                      </MathJax>
                    )}
                  </div>

                  {doneThinking && (
                    <MathJax className="mt-2" dynamic>
                      <span
                        className="markdown"
                        dangerouslySetInnerHTML={{
                          __html: render(answer),
                        }}
                      />
                    </MathJax>
                  )}
                </>
              ) : (
                // start 已到达但还没有文本时,显示等待动画。
                <span className="h-6 flex items-center gap-1">
                  <span className="w-2.5 h-2.5 bg-gray-600 dark:bg-gray-300 rounded-full animate-pulse"></span>
                  <span className="w-2.5 h-2.5 bg-gray-600 dark:bg-gray-300 rounded-full animate-pulse animation-delay-200"></span>
                  <span className="w-2.5 h-2.5 bg-gray-600 dark:bg-gray-300 rounded-full animate-pulse animation-delay-400"></span>
                </span>
              )}
            </div>
          </div>
        </>
      ) : (
        <>
          <UserIcon className="h-6 w-6 min-h-6 min-w-6 my-3 text-gray-500 dark:text-gray-300" />

          <div className="bg-blue-500 text-white rounded-lg p-4">
            <p className="min-h-6 overflow-wrap-anywhere">
              {content}
            </p>
          </div>
        </>
      )}
    </div>
  );
}

export default function Chat({ messages }) {
  const empty = messages.length === 0;

  return (
    <div
      className={`flex-1 p-6 max-w-[960px] w-full ${
        empty
          ? "flex flex-col items-center justify-end"
          : "space-y-4"
      }`}
    >
      <MathJaxContext>
        {empty ? (
          <div className="text-xl">Ready!</div>
        ) : (
          messages.map((msg, i) => (
            <Message
              key={`message-${i}`}
              {...msg}
            />
          ))
        )}
      </MathJaxContext>
    </div>
  );
}

10.2 src/components/Chat.css 完整代码

@scope (.markdown) {
  /* 代码块 */
  pre {
    margin: 0.5rem 0;
    white-space: break-spaces;
  }

  code {
    padding: 0.2em 0.4em;
    border-radius: 4px;
    font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
    font-size: 0.9em;
  }

  pre,
  code {
    background-color: #f2f2f2;
  }

  @media (prefers-color-scheme: dark) {
    pre,
    code {
      background-color: #333;
    }
  }

  pre:has(code) {
    padding: 1rem 0.5rem;
  }

  pre > code {
    padding: 0;
  }

  /* 标题 */
  h1,
  h2,
  h3,
  h4,
  h5,
  h6 {
    font-weight: 600;
    line-height: 1.2;
  }

  h1 {
    font-size: 2em;
    margin: 1rem 0;
  }

  h2 {
    font-size: 1.5em;
    margin: 0.83rem 0;
  }

  h3 {
    font-size: 1.25em;
    margin: 0.67rem 0;
  }

  h4 {
    font-size: 1em;
    margin: 0.5rem 0;
  }

  h5 {
    font-size: 0.875em;
    margin: 0.33rem 0;
  }

  h6 {
    font-size: 0.75em;
    margin: 0.25rem 0;
  }

  h1,
  h2,
  h3,
  h4,
  h5,
  h6:first-child {
    margin-top: 0;
  }

  /* 列表 */
  ul {
    list-style-type: disc;
    margin-left: 1.5rem;
  }

  ol {
    list-style-type: decimal;
    margin-left: 1.5rem;
  }

  li {
    margin: 0.25rem 0;
  }

  p:not(:first-child) {
    margin-top: 0.75rem;
  }

  p:not(:last-child) {
    margin-bottom: 0.75rem;
  }

  ul > li {
    margin-left: 1rem;
  }

  /* 表格 */
  table,
  th,
  td {
    border: 1px solid lightgray;
    padding: 0.25rem;
  }

  @media (prefers-color-scheme: dark) {
    table,
    th,
    td {
      border: 1px solid #f2f2f2;
    }
  }
}

10.3 三个图标组件完整代码

src/components/icons/BotIcon.tsx

import type { SVGProps } from "react";

export default function BotIcon(props: SVGProps<SVGSVGElement>) {
  return (
    <svg
      {...props}
      xmlns="http://www.w3.org/2000/svg"
      width="24"
      height="24"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M12 8V4H8" />
      <rect width="16" height="12" x="4" y="8" rx="2" />
      <path d="M2 14h2" />
      <path d="M20 14h2" />
      <path d="M15 13v2" />
      <path d="M9 13v2" />
    </svg>
  );
}

src/components/icons/UserIcon.tsx

export default function UserIcon(props) {
  return (
    <svg
      {...props}
      xmlns="http://www.w3.org/2000/svg"
      width="24"
      height="24"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
      <circle cx="12" cy="7" r="4" />
    </svg>
  );
}

src/components/icons/BrainIcon.tsx

export default function BrainIcon(props) {
  return (
    <svg
      {...props}
      xmlns="http://www.w3.org/2000/svg"
      width="24"
      height="24"
      viewBox="0 0 32 32"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path
        className="stroke-gray-600 dark:stroke-gray-400"
        d="M16 6v3.33M16 6c0-2.65 3.25-4.3 5.4-2.62 1.2.95 1.6 2.65.95 4.04a3.63 3.63 0 0 1 4.61.16 3.45 3.45 0 0 1 .46 4.37 5.32 5.32 0 0 1 1.87 4.75c-.22 1.66-1.39 3.6-3.07 4.14M16 6c0-2.65-3.25-4.3-5.4-2.62a3.37 3.37 0 0 0-.95 4.04 3.65 3.65 0 0 0-4.6.16 3.37 3.37 0 0 0-.49 4.27 5.57 5.57 0 0 0 3.07 4.15M16 9.33v17.34m0-17.34c0 2.18 1.82 4 4 4m6.22 7.5c.67 1.3.56 2.91-.27 4.11a4.05 4.05 0 0 1-4.62 1.5c0 1.53-1.05 2.9-2.66 2.9A2.7 2.7 0 0 1 16 26.66m10.22-5.83a4.05 4.05 0 0 0-3.55-2.17m-16.9 2.18a4.05 4.05 0 0 0 .28 4.1c1 1.44 2.92 2.09 4.59 1.5 0 1.52 1.12 2.88 2.7 2.88A2.7 2.7 0 0 0 16 26.67M5.78 20.85a4.04 4.04 0 0 1 3.55-2.18"
      ></path>
    </svg>
  );
}

10.4 App.tsx 本篇新增的非页面逻辑

// 获取 textarea 的真实 DOM。
const textareaRef = useRef(null);

function resizeInput() {
  if (!textareaRef.current) return;

  const target = textareaRef.current;

  // 允许输入框在删除内容后重新收缩。
  target.style.height = "auto";

  // 将自动高度限制在 24px 到 200px 之间。
  const newHeight = Math.min(
    Math.max(target.scrollHeight, 24),
    200,
  );

  target.style.height = `${newHeight}px`;
}

// input 更新并完成渲染后,重新计算真实内容高度。
useEffect(() => {
  resizeInput();
}, [input]);

Worker 错误状态处理:

case "error":
  setError(e.data.data);
  setIsRunning(false);

  if (e.data.operation === "load") {
    setStatus(null);
    setProgressItems([]);
  }
  break;

本篇没有展示 App.tsx return 中的 Chat 放置位置、输入框绑定和按钮状态,这些页面代码会在最后一篇统一拆解。

10.5 worker.js 本篇新增的错误恢复代码

在单例类中增加:

static reset() {
  // 失败后清空 rejected Promise,允许下一次重新下载。
  this.tokenizer = null;
  this.model = null;
}

将消息入口改为:

self.addEventListener("message", async (e) => {
  const { type, data } = e.data;

  try {
    switch (type) {
      case "check":
        await check();
        break;

      case "load":
        await load();
        break;

      case "generate":
        stopping_criteria.reset();
        await generate(data);
        break;

      case "interrupt":
        stopping_criteria.interrupt();
        break;

      case "reset":
        past_key_values_cache = null;
        stopping_criteria.reset();
        break;
    }
  } catch (error) {
    // 加载失败时必须清除单例中保存的 rejected Promise。
    if (type === "load") {
      TextGenerationPipeline.reset();
    }

    self.postMessage({
      status: "error",
      data: error instanceof Error
        ? error.message
        : String(error),
      operation: type,
    });
  }
});

总结

这一篇没有重复模型如何生成 Token,而是继续处理第四篇留下的流式消息。新增的 Chat.tsx 将数组遍历与单条消息展示分开,让每条模型消息都能独立管理思考区域;answerIndex 把同一条助手内容拆成推理过程和最终答案;Marked、DOMPurify 与 MathJax 依次完成 Markdown 转换、HTML 安全清理和数学公式排版;Chat.css 使用局部作用域约束动态生成的标题、代码、列表与表格;三个 SVG 组件则补充了消息角色和思考状态的视觉语义。

同时,resizeInput() 根据真实 scrollHeight 调整输入框高度,Worker 消息入口通过 awaittry...catch 接住异步加载错误,TextGenerationPipeline.reset() 清除失败的单例 Promise,使网络恢复后的重新加载真正有效。至此,项目已经具备从流式消息数据到安全富文本内容的完整处理能力。下一篇将回到 App.tsx return,按照页面结构逐层说明这些状态、组件和交互最终如何组合成完整的聊天界面。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐