WebGPU DeepSeek项目实战(五):封装聊天消息组件、安全渲染Markdown并完善加载重试
WebGPU DeepSeek项目实战(五):封装聊天消息组件、安全渲染Markdown并完善加载重试
前言
系列第四篇已经接通了模型生成链路:用户消息进入 messages,Worker 调用模型生成内容,再通过 start、update 和 complete 把结果持续传回 React。到这一步,数据虽然已经回到了页面线程,但它仍然只是消息数组中的字符串。
模型回答并不总是普通文本。它可能包含标题、列表、代码块、表格、引用和数学公式;DeepSeek-R1 的输出还分为思考过程与最终答案。如果直接把整个 content 放进普通段落,不仅格式全部丢失,思考内容和正式回答也会混在一起。
因此,这一篇继续完成数据展示之前的处理层:创建独立的 Chat.tsx,按照消息角色选择不同的处理方式;使用 answerIndex 拆分思考和答案;使用 Marked 解析 Markdown、DOMPurify 清理 HTML、MathJax 渲染数学公式;再补充模型等待动画、思考过程展开控制、输入框高度计算,以及模型下载失败后的重试机制。
本篇只讲这次新增的功能与组件内部逻辑,不展开
App.tsx的return。聊天区域、输入区域、按钮、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> 的属性,因此 className、width、aria-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 提供最新值,在更新合并或高频触发时更稳妥,也与项目前面处理 messages、progressItems 的原则保持一致。
展开条件写成:
{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>
)}
这段代码包含四层职责:
showThinking &&决定思考正文是否存在于组件树中。MathJax dynamic负责动态公式。.markdown让Chat.css的局部规则生效。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 消息入口通过 await 与 try...catch 接住异步加载错误,TextGenerationPipeline.reset() 清除失败的单例 Promise,使网络恢复后的重新加载真正有效。至此,项目已经具备从流式消息数据到安全富文本内容的完整处理能力。下一篇将回到 App.tsx return,按照页面结构逐层说明这些状态、组件和交互最终如何组合成完整的聊天界面。
更多推荐


所有评论(0)