WebGPU DeepSeek项目实战(三):加载LLM、展示下载进度与搭建聊天页面
WebGPU DeepSeek项目实战(三):加载LLM、展示下载进度与搭建聊天页面
前言
前两篇先后搭建了 React 与 Web Worker 的通信框架,并使用单例思路加载了 Tokenizer。程序已经能够接收 load 命令,也能把 Transformers.js 的文件加载事件转发给主线程,但这还不等于大模型已经可以回答问题。
这一篇继续向前推进三步:第一步使用 AutoModelForCausalLM 加载真正负责文本生成的 DeepSeek 模型;第二步把并发到达的下载事件维护成 React 状态,并提取 Progress 组件显示文件进度;第三步梳理 App.tsx 的 return,从最外层页面分支讲到欢迎页、加载页、聊天区、输入框和操作图标。
这一篇的完整链路:同时加载 Tokenizer 和 LLM → 维护每个文件的下载状态 → 预热 WebGPU 模型 → 发送
ready→ 解锁聊天输入页面。
1. 从加载 Tokenizer 推进到加载 LLM
1.1 Tokenizer 和模型分别负责什么
Tokenizer 与模型属于同一条文本生成流水线,但它们承担的任务完全不同:
| 组件 | 作用 | 输入与输出 |
|---|---|---|
AutoTokenizer |
按照模型配套规则进行编码和解码 | 文本 ↔ Token ID |
AutoModelForCausalLM |
根据已有 Token 预测后续 Token | Token ID → 新 Token ID |
上一篇加载 Tokenizer,是为了把用户文字转换成模型可以计算的数字。只有继续加载模型权重,程序才真正具备生成内容的计算能力。
worker.js 因此增加了第二个导入:
import {
AutoTokenizer, // 负责文本与 Token ID 之间的转换
AutoModelForCausalLM, // 负责根据已有 Token 继续生成 Token
} from "@huggingface/transformers";
AutoModelForCausalLM 来自 @huggingface/transformers。其中:
AutoModel表示根据仓库配置自动选择合适的具体模型类。CausalLM是 Causal Language Model(因果语言模型),它根据左侧已经出现的 Token,预测下一个 Token。- 连续重复“预测下一个 Token”,就能形成一段完整回答。
DeepSeek-R1-Distill-Qwen-1.5B 属于文本生成模型,因此这里使用 AutoModelForCausalLM,而不是图像分类或文本分类模型类。
1.2 单例资源管理器增加模型 Promise
TextGenerationPipeline.getInstance() 在原有 Tokenizer 的基础上,再缓存一份模型加载 Promise:
class TextGenerationPipeline {
static model_id = "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";
static async getInstance(progress_callback = null) {
// 第一次调用时加载 Tokenizer,后续调用复用同一个 Promise。
this.tokenizer ??= AutoTokenizer.from_pretrained(this.model_id, {
progress_callback,
});
// 第一次调用时加载模型,后续调用同样复用。
this.model ??= AutoModelForCausalLM.from_pretrained(this.model_id, {
dtype: "q4f16",
device: "webgpu",
progress_callback,
});
// 等待两类资源全部可用。
return Promise.all([this.tokenizer, this.model]);
}
}
this.tokenizer 和 this.model 都保存在类本身上。??= 只有在左侧为 null 或 undefined 时才执行右侧,所以两类昂贵资源都只会启动一次加载任务。
这里缓存的是 Promise,而不是等下载完成后才保存结果。第一次执行 from_pretrained() 时,Promise 会立刻写入静态属性;即使第二个 load 在下载过程中到达,也只能复用正在执行的任务,不会重新发起一套模型下载。
单例思路在这里控制的是“Tokenizer 与模型加载任务各创建一次”,不是说整个项目只能调用一次文本生成。资源加载完成后,可以被多次生成任务共同使用。
1.3 模型 from_pretrained() 的三个关键配置
this.model ??= AutoModelForCausalLM.from_pretrained(this.model_id, {
dtype: "q4f16",
device: "webgpu",
progress_callback,
});
from_pretrained() 会根据 model_id 读取模型配置并加载对应的 ONNX 模型文件。三个配置项决定加载哪种权重、在哪个设备上运行,以及如何把下载过程报告给页面。
| 配置项 | 含义 | 写它的目的 |
|---|---|---|
dtype: "q4f16" |
使用 FP16 模型配合 INT4 分块权重量化的文件 | 减少模型体积和部分内存压力,更适合浏览器端运行 |
device: "webgpu" |
把模型推理后端指定为 WebGPU | 让后续神经网络计算尽量交给本地 GPU |
progress_callback |
接收文件加载状态的回调函数 | 让 Worker 可以把下载进度转发给 React 页面 |
q4f16 不是“模型所有数据都只有 4 位”。在 Transformers.js 的数据类型定义中,它表示 FP16 模型配合 INT4 分块权重量化。可以先把它理解为:模型文件通过更紧凑的权重表示减少资源消耗,而运算过程中仍会结合 16 位浮点格式。
device: "webgpu" 则真正把 WebGPU 接进模型加载配置。上一篇的 navigator.gpu.requestAdapter() 只是检查浏览器能否提供 GPU 适配器;这里指定 webgpu,才是告诉 Transformers.js 后续创建 WebGPU 推理会话。
1.4 为什么使用同一个 progress_callback
Tokenizer 和模型都把同一个函数作为 progress_callback:
this.tokenizer ??= AutoTokenizer.from_pretrained(this.model_id, {
progress_callback,
});
this.model ??= AutoModelForCausalLM.from_pretrained(this.model_id, {
dtype: "q4f16",
device: "webgpu",
progress_callback,
});
这样不管是 Tokenizer 配置、模型配置还是权重文件发生状态变化,都走同一条转发通道。Worker 不需要为每一种文件单独建立监听逻辑,只需要把回调参数继续发送给 App:
const [tokenizer, model] = await TextGenerationPipeline.getInstance((x) => {
self.postMessage(x);
});
两次 from_pretrained() 在执行后都会先返回 Promise,然后 Promise.all() 统一等待。因此,多个文件事件可能交错、连续甚至在很短时间内密集到达,这也是 React 必须使用函数式状态更新的重要原因。
2. Promise.all() 如何等待两类资源
2.1 返回值为什么从一个变成两个
上一篇只返回 Tokenizer:
return Promise.all([this.tokenizer]);
加入模型后,数组包含两个 Promise:
return Promise.all([this.tokenizer, this.model]);
Promise.all() 会并行等待数组中的所有异步任务。只有 Tokenizer 和模型都成功后,它返回的 Promise 才会兑现;结果数组的顺序与传入顺序保持一致:
传入:[this.tokenizer, this.model]
↓ Promise.all 等待
返回:[tokenizer, model]
所以 load() 使用同样顺序进行数组解构:
const [tokenizer, model] = await TextGenerationPipeline.getInstance(...);
tokenizer取得第一项,可以把文本转换成 Token。model取得第二项,可以调用generate()生成 Token。
如果其中任何一个 Promise 失败,Promise.all() 就会进入失败状态,不会继续执行后面的模型预热与 ready 消息。这段 load() 没有单独捕获加载异常,因此意外异常会由 Worker 的 error 事件交给 App 中的 onErrorReceived 处理。
2.2 为什么不是先等待 Tokenizer,再开始加载模型
代码的执行顺序是先创建 Tokenizer Promise,再创建模型 Promise,最后统一 await:
this.tokenizer ??= AutoTokenizer.from_pretrained(...);
this.model ??= AutoModelForCausalLM.from_pretrained(...);
return Promise.all([this.tokenizer, this.model]);
两行 from_pretrained() 之间没有 await,因此第一行启动异步任务后,JavaScript 会继续执行第二行,再由 Promise.all() 一起等待。这种组织方式可以让相互独立的资源加载过程重叠进行,而不是强制一个完全结束后才启动另一个。
相应的代价是进度事件更密集,而且不同文件的状态可能交错。React 不能只保存一个百分比,而要维护一个“正在加载的文件列表”。
3. 模型下载完成后为什么还要预热
3.1 第二条 loading 消息代表阶段切换
Tokenizer 和模型都加载完成后,load() 发送新的提示:
self.postMessage({
status: "loading",
data: "Compiling shaders and warming up model...",
});
它仍然使用 status: "loading",所以 React 不需要切换页面,只更新 loadingMessage。用户看到的文案会从:
Loading model...
变成:
Compiling shaders and warming up model...
模型文件下载到浏览器,只说明权重和配置已经到位。第一次真正执行 WebGPU 计算时,运行环境还可能创建推理会话、准备 GPU 资源并编译需要的计算管线。若把这些工作留到用户第一次提问,第一次回答会出现额外等待;因此先运行一次很小的生成任务进行 Warm-up(预热)。
3.2 tokenizer("a") 为什么使用一个简单字符
const inputs = tokenizer("a");
加载完成后的 Tokenizer 是一个可调用对象,可以像函数一样接收文本。它会把字符串 "a" 转换成模型需要的输入对象,其中通常包含:
input_ids:文本对应的 Token ID。attention_mask:告诉模型哪些位置属于有效输入。
这里并不是要让模型认真回答字母 a,只是需要一份最小输入来触发完整推理链路。输入越简单,预热本身的计算成本越低。
console.log(inputs);
这行日志便于在 Worker 的开发者工具中观察 Tokenizer 生成的数据结构,不会把内容发送到 React 页面。
3.3 generate() 为什么只生成一个 Token
await model.generate({
...inputs,
max_new_tokens: 1,
});
对象展开语法 ...inputs 把 Tokenizer 返回对象中的字段复制到 generate() 参数中。例如:
{
...inputs,
max_new_tokens: 1,
}
可以理解为:
{
input_ids: inputs.input_ids,
attention_mask: inputs.attention_mask,
max_new_tokens: 1,
}
max_new_tokens 限制模型最多生成多少个新 Token。预热只需要让推理真正跑通,不需要生成完整回答,因此设为 1 可以减少无意义的等待。
这次生成结果没有保存,也不会显示在页面上。它的效果是提前走过一次 Tokenizer 输入、模型推理和 WebGPU 执行链路,把第一次运行的准备成本放在加载阶段。
3.4 为什么预热后才发送 ready
self.postMessage({ status: "ready" });
ready 放在 await model.generate(...) 之后,意味着只有预热成功结束,Worker 才通知页面解锁输入框。如果不等待预热就提前发送 ready,用户可能已经可以提交问题,但 GPU 初始化仍在后台进行,第一次交互的状态就会变得难以判断。
完整的加载阶段因此分成:
| 阶段 | Worker 行为 | App 页面状态 |
|---|---|---|
| 开始加载 | 发送 Loading model... |
显示加载页 |
| 下载资源 | 转发 initiate / progress / done |
创建、更新、删除进度条 |
| 预热模型 | 发送 Compiling shaders... |
保持加载页并更新提示 |
| 预热结束 | 发送 ready |
切换到聊天区并解锁输入 |
4. React 为什么要维护 progressItems
4.1 一个百分比为什么不够
Tokenizer、配置文件和模型权重不是同一个文件。Transformers.js 会为每个文件分别发送状态:
{
status: "initiate",
file: "tokenizer.json"
}
{
status: "progress",
file: "model_q4f16.onnx",
progress: 46.25,
loaded: 485000000,
total: 1048000000
}
{
status: "done",
file: "tokenizer.json"
}
多个文件可以同时处于下载过程中,所以 App 新增数组状态:
const [progressItems, setProgressItems] = useState([]);
数组中的每个对象代表一个正在加载的文件。事件到达后,App 按照文件生命周期对数组执行三种操作:
| 事件 | 数组操作 | 页面效果 |
|---|---|---|
initiate |
追加文件对象 | 新增一条进度条 |
progress |
更新同名文件对象 | 进度条宽度和文字变化 |
done |
删除同名文件对象 | 对应进度条消失 |
4.2 React State 是一次渲染的快照
理解函数式更新之前,要先理解 React State 的读取方式。组件每次渲染都会得到当次渲染对应的 State 快照:
const [progressItems, setProgressItems] = useState([]);
progressItems 不会因为调用 setProgressItems() 就在当前函数中原地变化。更新会进入 React 的更新队列,React 使用新状态再次调用组件,下一次渲染才能得到新值。
而 Worker 的监听器创建在:
useEffect(() => {
const onMessageReceived = (e) => {
// 处理消息
};
}, []);
依赖数组是 [],这个 Effect 只在挂载后执行一次。监听函数会形成闭包,保存第一次渲染时能看到的变量环境。当多个 Worker 事件连续到达时,直接读取闭包里的 progressItems 很容易一直得到第一次渲染时的空数组。
函数式更新不是从闭包读取旧变量,而是让 React 在真正处理这次更新时,把最新可用状态作为参数传进来。
4.3 initiate:用函数式更新追加文件
case "initiate":
setProgressItems((prev) => [...prev, e.data]);
break;
这段代码的执行过程是:
- Worker 报告某个文件开始加载。
- React 调用更新函数,并把最新数组传给
prev。 [...prev, e.data]创建一个新数组。- 先复制原有文件,再把新文件追加到末尾。
- React 保存新数组并重新渲染进度列表。
不能写成下面这样:
setProgressItems((prev) => {
prev.push(e.data);
return prev;
});
push() 会直接修改旧数组,并返回同一个数组引用。React State 应按照不可变数据思路更新:保留旧快照,创建新数组。展开语法 [...]、map() 和 filter() 都能返回新数组,让 React 清楚地看到状态引用发生了变化。
4.4 如果不传函数会发生什么
最容易想到的替代写法是:
setProgressItems([...progressItems, e.data]);
在普通点击事件中它有时看起来也能工作,但放到这个 Worker 监听器中会出现两个问题。
第一个问题是 闭包拿到旧状态。Effect 只执行一次,监听函数捕获的 progressItems 很可能一直是初始值 []:
第一个 initiate:[] + 文件 A → [A]
第二个 initiate:[] + 文件 B → [B]
第三个 initiate:[] + 文件 C → [C]
结果不是 [A, B, C],而是后到的文件不断覆盖前面的结果,页面可能只剩最后一条进度。
第二个问题是 批量更新相互覆盖。即使代码位于能读到较新 State 的场景,多个事件在 React 完成下一次渲染前连续到达,也可能都根据同一份旧快照计算,后一个更新覆盖前一个更新。
函数式更新会让 React 依次处理更新队列:
prev = [] → 追加 A → [A]
prev = [A] → 追加 B → [A, B]
prev = [A, B] → 追加 C → [A, B, C]
因此,这里传函数的第一目的不是让一行代码“跑得更快”,而是保证并发事件下的数据正确。它的性能收益体现在另一层:不需要把 progressItems 写入 Effect 依赖,也就不必在每次进度变化后移除旧监听器、创建新函数并重新绑定事件。
| 写法 | 状态来源 | 在本项目中的结果 |
|---|---|---|
setProgressItems([...progressItems, data]) |
当前闭包捕获的快照 | 可能丢失连续到达的文件事件 |
setProgressItems(prev => [...prev, data]) |
React 更新队列中的最新状态 | 能按顺序累积多个文件 |
4.5 progress:更新数组中的指定文件
case "progress":
setProgressItems((prev) =>
prev.map((item) => {
if (item.file === e.data.file) {
return { ...item, ...e.data };
}
return item;
}),
);
break;
这里不能直接追加,因为 progress 表示已有文件的数值发生变化。map() 会遍历旧数组,并生成一个等长的新数组:
item.file === e.data.file:找出本次事件对应的文件。{ ...item, ...e.data }:保留旧字段,再用新事件中的字段覆盖同名字段。return item:其他文件不需要修改,继续使用原对象。
对象展开的顺序非常重要:
{ ...item, ...e.data }
后展开的 e.data 优先级更高。假设原对象中的 progress 是 20,新事件中是 35,合并后就会变成 35。
// 原对象
{ file: "model.onnx", progress: 20, total: 1000 }
// 新事件
{ file: "model.onnx", progress: 35, loaded: 350 }
// 合并结果
{ file: "model.onnx", progress: 35, total: 1000, loaded: 350 }
这段逻辑达到的效果是:每个文件只保留一条进度记录,回调每触发一次,就更新对应记录,而不是创建大量重复进度条。
4.6 done:删除已经完成的文件
case "done":
setProgressItems((prev) =>
prev.filter((item) => item.file !== e.data.file),
);
break;
filter() 会创建一个新数组,只保留满足条件的元素。条件使用“不等于”:
item.file !== e.data.file
因此,与完成事件同名的文件会被排除,其他文件继续保留。State 更新后 React 重新渲染,对应进度条自然从页面消失。
三种操作可以记成:
initiate → 展开运算符追加
progress → map 定位并更新
done → filter 定位并删除
5. 新建 Progress.tsx,把进度数据变成进度条
5.1 为什么要创建一个新文件
第 4 部分只是把下载事件整理到了 progressItems 中。此时 React 已经保存了文件名、百分比和文件大小,但页面还不知道应该怎样展示这些数据。
如果把进度条的格式化函数、样式和 JSX 全部继续写进 App.tsx,主组件会同时负责 Worker 通信、状态管理、页面切换和进度条细节,代码会越来越难读。因此,这一步先创建一个专门负责展示单个文件进度的组件。
在项目的 src 目录下新建 components 文件夹,再创建 Progress.tsx:
deepseek-r1-webgpu
└── src
├── App.tsx
├── worker.js
└── components
└── Progress.tsx ← 这一节新建的文件
文件的完整路径是:
src/components/Progress.tsx
这个文件只负责一件事:接收一个文件的名称、下载百分比和总大小,然后返回一条进度条。 它不监听 Worker,也不自己保存下载列表。Worker 消息仍由 App.tsx 处理,App.tsx 再通过 Props 把数据交给它。
职责关系如下:
| 文件 | 负责内容 |
|---|---|
worker.js |
加载模型并报告文件状态 |
App.tsx |
接收状态,维护 progressItems 数组 |
components/Progress.tsx |
把数组中的一个文件对象显示为进度条 |
5.2 在新文件中写入进度组件
创建 src/components/Progress.tsx 后,写入下面的代码:
// 把原始字节数转换成 B、kB、MB、GB 或 TB。
function formatBytes(size) {
// 0 需要单独处理,否则 Math.log(0) 会得到负无穷。
const i = size == 0
? 0
: Math.floor(Math.log(size) / Math.log(1024));
return (
+(size / Math.pow(1024, i)).toFixed(2) * 1 +
["B", "kB", "MB", "GB", "TB"][i]
);
}
// 一个 Progress 只负责显示一个文件的进度。
export default function Progress({ text, percentage, total }) {
// initiate 事件还没有百分比时,先从 0 开始显示。
percentage ??= 0;
return (
// 外层 div 是完整的浅色进度轨道。
<div className="w-full bg-gray-100 dark:bg-gray-700 text-left rounded-lg overflow-hidden mb-0.5">
<div
// 内层 div 是蓝色进度区域,宽度跟随 percentage 变化。
className="bg-blue-400 whitespace-nowrap px-1 text-sm"
style={{ width: `${percentage}%` }}
>
{text} ({percentage.toFixed(2)}%
{/* total 有效时才显示格式化后的文件总大小。 */}
{isNaN(total) ? "" : ` of ${formatBytes(total)}`})
</div>
</div>
);
}
这里使用 export default 导出组件,是为了让 App.tsx 可以导入并写成 <Progress />。这一步完成后,项目里只是多了一个可复用组件,页面还不会自动显示它;下一步还要把它接入 App.tsx。
5.3 Progress 接收的三个 Props 从哪里来
组件通过参数解构接收三个 Props:
export default function Progress({ text, percentage, total }) {
// ...
}
它们并不是 Progress.tsx 自己产生的数据,而是稍后由 App.tsx 传入:
| Props | 对应进度对象字段 | 用途 |
|---|---|---|
text |
file |
显示正在处理的文件名 |
percentage |
progress |
控制进度条宽度并显示百分比 |
total |
total |
显示文件总大小 |
之所以把 file 改名为 text,是因为组件只关心要显示的文字,不需要知道这段文字一定来自文件名。这样组件的展示职责更清楚。
initiate 事件通常只有模型名称和文件名,不一定包含 progress。组件刚创建时,percentage 可能是 undefined。如果立即执行:
percentage.toFixed(2)
就会因为 undefined 没有 toFixed() 方法而报错。因此先写:
percentage ??= 0;
??= 只在左侧为 null 或 undefined 时赋值。合法的 0 不会被替换,所以新文件可以先显示 0.00%,等待后续 progress 事件更新。
5.4 formatBytes() 如何把字节变成人能读懂的单位
Transformers.js 返回的 total 是字节数。直接显示 1048576000 不方便阅读,所以新组件内部加入 formatBytes():
function formatBytes(size) {
const i = size == 0
? 0
: Math.floor(Math.log(size) / Math.log(1024));
return (
+(size / Math.pow(1024, i)).toFixed(2) * 1 +
["B", "kB", "MB", "GB", "TB"][i]
);
}
1024 是二进制容量单位之间常用的换算基数。Math.log(size) / Math.log(1024) 用来判断 size 大致位于第几个单位区间,再通过 Math.floor() 取整数索引:
i |
单位 | 除数 |
|---|---|---|
0 |
B |
1024⁰ |
1 |
kB |
1024¹ |
2 |
MB |
1024² |
3 |
GB |
1024³ |
size == 0 ? 0 : ... 单独处理零。如果直接计算 Math.log(0),结果会是负无穷,无法作为单位数组索引。
(size / Math.pow(1024, i)).toFixed(2)
这部分先换算单位,再保留两位小数。toFixed() 返回字符串,前面的 + 会把它转换回数字;后面的 * 1 同样具有数值转换效果,因此这里属于重复转换,但不影响最终显示。
5.5 两层 div 如何画出进度效果
Progress 的 JSX 由两层 div 组成:
return (
<div className="w-full bg-gray-100 dark:bg-gray-700 text-left rounded-lg overflow-hidden mb-0.5">
<div
className="bg-blue-400 whitespace-nowrap px-1 text-sm"
style={{ width: `${percentage}%` }}
>
{text} ({percentage.toFixed(2)}%
{isNaN(total) ? "" : ` of ${formatBytes(total)}`})
</div>
</div>
);
外层 div 是固定宽度的浅色轨道,内层 div 是蓝色进度区域。关键是:
style={{ width: `${percentage}%` }}
React 的 style 接收一个对象,width 根据运行时数据动态生成:
percentage = 0 → width: 0%
percentage = 35 → width: 35%
percentage = 80 → width: 80%
Tailwind 类适合设置固定样式,下载百分比却会持续变化,因此使用行内 style 控制动态宽度。overflow-hidden 裁掉超出圆角轨道的内容,whitespace-nowrap 防止文件名和百分比随意换行。
组件最后通过条件表达式决定是否显示总大小:
{isNaN(total) ? "" : ` of ${formatBytes(total)}`}
total 还不存在时返回空字符串;拿到有效数字后,页面会显示类似:
model_q4f16.onnx (46.25% of 1.03GB)
5.6 在 App.tsx 中导入新组件
回到 src/App.tsx,在顶部添加导入:
import { useEffect, useState, useRef } from "react";
// 从刚创建的文件中导入默认导出的 Progress 组件。
import Progress from "./components/Progress.tsx";
./ 表示从 App.tsx 所在的 src 目录开始查找,完整关系是:
src/App.tsx
↓ ./components/Progress.tsx
src/components/Progress.tsx
如果只创建文件但没有导入,App.tsx 中的 <Progress /> 就没有来源,TypeScript 和编辑器都会提示组件未定义。
5.7 在加载页中渲染组件列表
完成导入后,在 status === "loading" 对应的加载区域中,把 progressItems 转换成多个进度组件:
{status === "loading" && (
<div className="w-full max-w-[500px] text-left mx-auto p-4 bottom-0 mt-auto">
<p className="text-center mb-1">{loadingMessage}</p>
{/* 一个文件对象对应一个 Progress 组件。 */}
{progressItems.map(({ file, progress, total }, i) => (
<Progress
key={i}
text={file}
percentage={progress}
total={total}
/>
))}
</div>
)}
map() 在这里不是修改下载数据,而是把每个文件对象转换成一个 React 组件。回调参数使用对象解构取出:
file:文件名。progress:0 到 100 的下载百分比。total:文件总字节数。i:元素在数组中的索引。
数据通过 Props 完成以下对应:
file → text
progress → percentage
total → total
key 帮助 React 识别列表元素。项目代码使用索引 i;由于 done 会删除数组元素,更稳定的标识通常是文件名。不过 Progress 没有内部 State,这一阶段先保持项目写法,重点理解“数组数据如何变成组件列表”。
经过“创建文件 → 导出组件 → App 导入 → map() 传入 Props”四步,完整的数据链才真正建立:
Worker 进度事件
↓
App 的 progressItems
↓ map
多个 Progress Props
↓
页面上的多条文件进度条

6. App 新增的状态、引用与 Worker 生命周期
6.1 三个 Ref 分别保存什么
const worker = useRef(null);
const textareaRef = useRef(null);
const chatContainerRef = useRef(null);
useRef() 返回一个在多次渲染之间保持稳定的对象,真正的数据放在 .current 中。修改 .current 不会触发重新渲染,适合保存不直接决定页面内容的对象。
| Ref | 绑定对象 | 设计目的 |
|---|---|---|
worker |
Web Worker 实例 | 保证重新渲染时仍使用同一个 Worker |
textareaRef |
<textarea> DOM 节点 |
后续可控制输入框高度、聚焦等行为 |
chatContainerRef |
聊天容器 DOM 节点 | 后续可读取滚动位置或自动滚动 |
STICKY_SCROLL_THRESHOLD 与 chatContainerRef 可以共同服务于后面的“接近底部时自动跟随新消息”功能;这一节只搭好引用和容器,不提前实现滚动逻辑。
6.2 新增 State 怎样驱动页面
const [progressItems, setProgressItems] = useState([]);
const [isRunning, setIsRunning] = useState(false);
const [input, setInput] = useState("");
progressItems保存正在加载的文件,决定渲染多少个Progress。isRunning表示模型是否正在生成,用于在停止图标和发送图标之间切换。input保存输入框内容,用于控制文本、键盘提交条件和发送按钮状态。
onEnter() 与 onInterrupt() 先提供页面交互入口:
function onEnter(message) {
// 后续把 message 发送给 Worker 的 generate 命令。
}
function onInterrupt() {
// 后续向 Worker 发送 interrupt 命令。
}
这两个函数体还没有生成逻辑,所以点击发送或停止暂时不会改变模型状态。先把界面与事件入口搭好,下一步再接 Worker 的 generate 和 interrupt。
6.3 两类 Worker 错误为什么分开处理
Worker 正常发回的业务错误仍然走 message:
case "error":
setError(e.data.data);
break;
Worker 脚本自身抛出的未处理异常则触发原生 error 事件:
const onErrorReceived = (e) => {
console.error("Worker error:", e);
};
两者区别如下:
| 错误来源 | 到达方式 | 处理效果 |
|---|---|---|
Worker 主动 postMessage({status: "error"}) |
message 事件 |
保存到 React State,在欢迎页显示 |
| Worker 未捕获异常 | error 事件 |
输出到开发者工具,方便排查 |
6.4 Effect 为什么要清理事件监听器
代码:
worker.current.addEventListener("message", onMessageReceived);
worker.current.addEventListener("error", onErrorReceived);
return () => {
worker.current.removeEventListener("message", onMessageReceived);
worker.current.removeEventListener("error", onErrorReceived);
};
useEffect() 返回的函数是清理函数。组件卸载时,React 会调用它,把这一轮绑定的监听器移除。
必须传入原来的函数引用,才能正确移除监听器。因为 onMessageReceived 和 onErrorReceived 都定义在同一次 Effect 执行中,所以添加和删除使用的是同一对象。
这段清理代码解决的是监听器残留和重复响应问题。它没有调用 worker.terminate(),因此它只解除 App 与 Worker 的事件连接,不代表主动立即终止 Worker 线程。
7. 新建 icons 目录,准备发送与停止图标
7.1 为什么还要创建两个图标组件
聊天输入区需要根据状态显示不同操作:没有内容时显示灰色发送箭头,有内容时显示可点击的发送箭头,模型生成时则显示停止按钮。
如果直接把两段完整 SVG 写进 App.tsx 的条件渲染中,页面判断会被大量 <svg>、<path> 和绘图参数打断,很难一眼看出三个分支的区别。因此,在开始拆解 App.tsx 的 return 之前,先把发送图标和停止图标提取成两个组件。
在刚才创建的 src/components 目录下继续新建 icons 文件夹,再创建两个文件:
deepseek-r1-webgpu
└── src
├── App.tsx
├── worker.js
└── components
├── Progress.tsx
└── icons
├── ArrowRightIcon.tsx ← 新建:发送箭头
└── StopIcon.tsx ← 新建:停止生成
两个文件都只负责绘制图标,不保存输入内容,也不决定什么时候显示。图标长什么样由组件负责,什么时候显示以及点击后做什么仍由 App.tsx 负责。
7.2 创建 ArrowRightIcon.tsx
新建:
src/components/icons/ArrowRightIcon.tsx
写入下面的代码:
export default function ArrowRightIcon(props) {
return (
<svg
// App 传入的 className 等属性会落到真正的 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="M5 12h14" />
{/* 折线,与水平线组合成向右箭头。 */}
<path d="m12 5 7 7-7 7" />
</svg>
);
}
<svg> 是 SVG 图形的根元素,viewBox="0 0 24 24" 建立一块 24 × 24 的绘图坐标区域。两个 <path> 使用路径命令绘制横线和箭头尖端,组合后形成发送箭头。
这里最重要的是:
{...props}
props 是 App 使用组件时传进来的属性对象。例如:
<ArrowRightIcon className="h-8 w-8 text-white" />
此时 props 大致是:
{
className: "h-8 w-8 text-white"
}
{...props} 把这些属性展开到真正的 <svg> 上,所以 App 可以在不修改图标文件的情况下控制尺寸、颜色、背景和位置。
stroke="currentColor"
currentColor 表示 SVG 线条继承元素当前的 CSS 文字颜色。因此,App 传入 text-white 时箭头是白色,传入 text-black 时箭头就是黑色。
7.3 创建 StopIcon.tsx
接着新建:
src/components/icons/StopIcon.tsx
写入:
export default function StopIcon(props) {
return (
<svg
// 复用 App 传入的尺寸、颜色和定位类。
{...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="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
{/* 绘制中间的实心停止方块。 */}
<path
fill="currentColor"
d="M9 9.563C9 9.252 9.252 9 9.563 9h4.874c.311 0 .563.252.563.563v4.874c0 .311-.252.563-.563.563H9.564A.562.562 0 0 1 9 14.437V9.564Z"
/>
</svg>
);
}
这个 SVG 同样使用 24 × 24 坐标系。第一个 <path> 画外层圆环,第二个 <path> 画中间的停止方块。方块使用:
fill="currentColor"
表示填充色也继承 CSS 文字颜色。以后改变 StopIcon 的 text-* 类,就能同时改变圆环线条和中间方块的颜色。
这两个图标组件没有 onClick,因为图标本身不应该决定业务行为。点击后是发送消息还是中断生成,应由使用它们的 App.tsx 决定。
7.4 回到 App.tsx 导入两个新组件
两个文件创建并默认导出后,回到 src/App.tsx,在顶部加入:
import Progress from "./components/Progress.tsx";
// 从刚创建的 icons 目录导入两个默认导出组件。
import ArrowRightIcon from "./components/icons/ArrowRightIcon.tsx";
import StopIcon from "./components/icons/StopIcon.tsx";
导入路径可以按目录层级理解:
App.tsx
└── components
└── icons
├── ArrowRightIcon.tsx
└── StopIcon.tsx
完成这一步后,App.tsx 才认识 <ArrowRightIcon /> 和 <StopIcon />。接下来拆解返回页面时,就可以用简短的组件名称表达“这里显示发送图标”或“这里显示停止图标”,而不用重复整段 SVG。
8. 先看清 App.tsx 的返回页面结构
8.1 React 组件的 return 返回什么
函数组件最终需要返回 JSX:
function App() {
// 状态、Ref、Effect 和事件函数
return (
// JSX 页面结构
);
}
JSX 看起来像 HTML,但它本质上是 JavaScript 的界面描述。构建工具会把 JSX 转换成 React 能处理的元素对象,React 再根据 State 的变化更新真实 DOM。
因此,setStatus()、setProgressItems() 和 setInput() 本身不直接修改某个 div。它们先改变状态,React 重新执行 App(),新的 return 结果决定哪些节点应该出现、更新或消失。
8.2 最外层先分成两个浏览器页面
return 的最外层使用条件运算符:
return (
IS_WEBGPU_AVAILABLE ? (
<div>{/* WebGPU 可用时的项目页面 */}</div>
) : (
<div>{/* WebGPU 不可用时的提示页面 */}</div>
)
);
页面结构可以先整理成下面这棵树:
App
├── WebGPU 可用
│ └── 应用根容器
│ ├── 欢迎页:status === null && messages.length === 0
│ ├── 加载页:status === "loading"
│ ├── 聊天消息区:status === "ready"
│ ├── 输入框区域:始终渲染,ready 后才可输入
│ └── 操作图标:停止 / 可发送 / 不可发送
└── WebGPU 不可用
└── 全屏兼容性提示
这里要先区分“是否渲染”和“是否可用”:聊天区只在 ready 时渲染;输入框外层一直存在,但 disabled={status !== "ready"} 会在模型就绪前禁用输入。
9. 逐块拆解 WebGPU 可用时的页面
9.1 应用根容器负责整体布局
<div className="flex flex-col h-screen mx-auto items justify-end text-gray-800 dark:text-gray-200 bg-white dark:bg-gray-900">
{/* 欢迎页、加载页、聊天区、输入区和操作图标 */}
</div>
这层 div 包住应用的所有主要内容。Tailwind 类名定义了页面骨架:
flex flex-col:使用 Flex 布局,并让子元素从上到下排列。h-screen:容器高度占满浏览器视口。justify-end:在主轴方向把内容靠近底部排列。text-gray-800 dark:text-gray-200:分别设置亮色和暗色模式文字。bg-white dark:bg-gray-900:分别设置亮色和暗色背景。
后面的不同页面区域不是跳转到新网址,而是在同一个根容器中根据 status 条件渲染。
9.2 欢迎页对应哪个 div
{status === null && messages.length === 0 && (
<div className="h-full overflow-auto scrollbar-thin flex justify-center items-center flex-col relative">
{/* Logo 与项目标题 */}
{/* 模型介绍、错误信息与加载按钮 */}
</div>
)}
&& 利用了 JavaScript 的短路运算:只有左侧条件为真,React 才取得右侧 JSX。
status === null:还没有开始加载。messages.length === 0:还没有聊天记录。
两者同时满足时显示欢迎页。外层 div 使用 h-full 占满可用高度,通过 flex justify-center items-center flex-col 把内容垂直排列并居中。
欢迎页内部又分为两块:
<div className="flex flex-col items-center mb-1 max-w-[400px] text-center">
<img src="logo.png" width="80%" height="auto"></img>
<h1>DeepSeek-R1 WebGPU</h1>
<h2>浏览器本地推理说明</h2>
</div>
第一块负责品牌和标题。max-w-[400px] 限制文字区域宽度,避免标题说明在大屏幕上拉得过长。
<div className="flex flex-col items-center px-4">
<p>{/* 模型、Transformers.js 与项目链接 */}</p>
{error && <div>{/* 错误信息 */}</div>}
<button>{/* Load model */}</button>
</div>
第二块负责说明和交互。error && ... 只有错误 State 有值时才渲染错误区。加载按钮点击后:
onClick={() => {
worker.current.postMessage({ type: "load" });
setStatus("loading");
}}
先通知 Worker 加载资源,再立即把页面切换到加载状态。按钮还使用:
disabled={status !== null || error !== null}
只要已经进入某个状态,或者存在错误,按钮就不能再次点击,从 UI 层减少重复发送 load 命令的机会;Worker 中的单例缓存则从资源层再次避免重复加载。
9.3 加载页怎样渲染多个文件
{status === "loading" && (
<>
<div className="w-full max-w-[500px] text-left mx-auto p-4 bottom-0 mt-auto">
<p className="text-center mb-1">{loadingMessage}</p>
{progressItems.map(({ file, progress, total }, i) => (
<Progress
key={i}
text={file}
percentage={progress}
total={total}
/>
))}
</div>
</>
)}
<>...</> 是 React Fragment。它可以包裹一组 JSX,但不会额外生成 DOM 节点。这段代码里 Fragment 只有一个 div,语法上可以省略,不过保留也不会在页面中增加无意义标签。
加载区分为两部分:顶部的 loadingMessage 说明大阶段,下面的 Progress 列表说明每个文件的小阶段。max-w-[500px] 限制进度区域宽度,mx-auto 让它水平居中。
当 initiate 追加对象时,列表增加;当 progress 更新对象时,进度条宽度变化;当 done 删除对象时,列表减少。页面没有直接操作 DOM,完全由 progressItems 推导出来。
9.4 ready 聊天区为什么先是空 div
{status === "ready" && (
<div
ref={chatContainerRef}
className="overflow-y-auto scrollbar-thin w-full flex flex-col items-center h-full"
>
</div>
)}
Worker 完成模型预热并发送 ready 后,App 执行:
setStatus("ready");
React 随即隐藏加载区,渲染聊天消息容器。这层 div 具有三个重要作用:
h-full和w-full提供完整的消息显示空间。overflow-y-auto允许消息变多后垂直滚动。ref={chatContainerRef}保存真实 DOM 节点,为后续自动滚动准备入口。
容器内部还没有把 messages 映射成消息气泡,所以切换到 ready 后先得到一个空的聊天区域。它先确定布局和滚动边界,后续生成逻辑可以直接向这套结构中加入消息展示。
9.5 输入框为什么始终存在却只在 ready 后可用
<div className="mt-2 border border-gray-300 dark:bg-gray-700 rounded-lg w-[600px] max-w-[80%] max-h-[200px] mx-auto relative mb-3 flex">
<textarea
ref={textareaRef}
placeholder="Type your message..."
rows={1}
value={input}
disabled={status !== "ready"}
title={status === "ready" ? "Model is ready" : "Model not loaded yet"}
onInput={(e) => setInput(e.currentTarget.value)}
/>
</div>
这层输入容器没有外层状态条件,所以只要 WebGPU 可用,它就会出现在根页面中。模型没有准备好时,真正限制操作的是:
disabled={status !== "ready"}
只有 status 等于 ready,结果才是 false,输入框才会解锁。title 则根据状态提供不同提示,鼠标悬停时可以告诉用户模型是否可用。
输入框采用受控组件写法:
value={input}
onInput={(e) => setInput(e.currentTarget.value)}
用户输入触发 onInput,事件中的最新文字写入 input State;React 重新渲染后,再通过 value={input} 把 State 显示回输入框。这样其他逻辑可以随时读取 input,判断是否允许发送。
9.6 键盘提交为什么要判断四个条件
onKeyDown={(e) => {
if (
input.length > 0 &&
!isRunning &&
e.key === "Enter" &&
!e.shiftKey
) {
e.preventDefault();
onEnter(input);
}
}}
四个条件共同定义“按 Enter 发送”的规则:
| 条件 | 目的 |
|---|---|
input.length > 0 |
空输入不提交 |
!isRunning |
模型生成过程中不重复发起任务 |
e.key === "Enter" |
只响应 Enter 键 |
!e.shiftKey |
为 Shift + Enter 保留换行能力 |
文本框中按 Enter 的默认行为是插入换行:
e.preventDefault();
这行阻止默认换行,再调用 onEnter(input)。函数入口已经接通,但函数体还没有发送 generate 消息,因此页面先具备键盘交互规则,文本生成逻辑继续留给下一步。
9.7 三段条件渲染怎样切换操作图标
输入框后面使用嵌套条件运算符选择图标:
{isRunning ? (
<div className="cursor-pointer" onClick={onInterrupt}>
<StopIcon />
</div>
) : input.length > 0 ? (
<div className="cursor-pointer" onClick={() => onEnter(input)}>
<ArrowRightIcon />
</div>
) : (
<div>
<ArrowRightIcon />
</div>
)}
判断顺序必须从上往下读:
isRunning为true:显示停止图标,点击执行onInterrupt()。- 没有运行且
input.length > 0:显示可点击的发送图标。 - 没有运行且输入为空:显示灰色发送图标,并且不绑定
onClick。
isRunning |
是否有输入 | 显示内容 | 点击效果 |
|---|---|---|---|
true |
任意 | StopIcon |
调用 onInterrupt() |
false |
是 | 深色 ArrowRightIcon |
调用 onEnter(input) |
false |
否 | 灰色 ArrowRightIcon |
无点击函数 |
这段 JSX 先把三种交互状态表达出来。isRunning 还没有在生成流程中更新,所以停止分支暂时不会出现;当后续 start 和 complete 消息分别修改它时,同一段 JSX 就能自动切换图标。
9.8 新建的图标组件如何接入三个状态分支
第 7 部分已经创建并导入两个 SVG 组件。在 return 中,App 只需要为它们传入不同的样式:
<ArrowRightIcon
className="h-8 w-8 p-1 bg-gray-800 text-white rounded-md"
/>
App 使用组件时传入 className:
<ArrowRightIcon className="h-8 w-8 ..." />
ArrowRightIcon 内部的 {...props} 会把 className 展开到真正的 <svg> 上,stroke="currentColor" 再读取 text-white 所提供的当前颜色。StopIcon 的 Props 传递方式相同。
把 SVG 提取为文件之后,App.tsx 的条件分支只需要处理两件事:选择哪个图标,以及给图标绑定什么交互。图标组件负责绘制,App 负责状态与行为,两层职责不会混在一起。
需要注意页面结构:输入框容器拥有 relative,但操作图标的三个 div 在 JSX 中是输入框容器的兄弟节点,不是它的子节点。absolute right-3 bottom-3 只会相对于最近的“有定位的祖先元素”计算,而不会使用兄弟节点的 relative。这一篇按项目结构解释页面,后续调整输入区布局时要记住这条 CSS 定位规则。
10. WebGPU 不可用时返回什么
最外层条件为假时,React 不会创建上面的项目页面,而是返回兼容性提示:
<div className="fixed w-screen h-screen bg-black z-10 bg-opacity-[92%] text-white text-2xl font-semibold flex justify-center items-center text-center">
WebGPU is not supported
<br />
by this browser :(
</div>
fixed w-screen h-screen:固定并覆盖整个浏览器视口。bg-black bg-opacity-[92%]:显示接近不透明的黑色背景。z-10:让提示层位于普通内容上方。flex justify-center items-center:让提示文字水平、垂直居中。:(:(是左括号的 HTML 实体,最终显示为难过表情:(。
这层页面的目的不是修复 WebGPU,而是在入口处明确阻止用户进入一个无法完成 GPU 推理的交互流程。
11. 从点击加载到聊天页出现的完整流程
把 Worker 与 React 合在一起,执行顺序如下:
1. App 创建 Worker,并发送 check
↓
2. 用户点击 Load model
↓
3. App 发送 load,同时把 status 设为 loading
↓
4. Worker 取得单例 Tokenizer Promise 和模型 Promise
↓
5. Transformers.js 并发报告多个文件的加载事件
↓
6. initiate:函数式更新追加文件
progress:map 更新指定文件
done:filter 删除完成文件
↓
7. progressItems.map() 渲染多个 Progress 组件
↓
8. Tokenizer 与模型全部加载完成
↓
9. tokenizer("a") 创建最小输入
↓
10. model.generate(..., max_new_tokens: 1) 预热 WebGPU
↓
11. Worker 发送 ready
↓
12. App 渲染聊天容器并解锁 textarea
这一流程形成了三层明确职责:
| 层级 | 负责内容 |
|---|---|
| Transformers.js | 下载 Tokenizer、模型文件并执行模型 |
| Web Worker | 管理资源、预热模型、转发状态,避免阻塞页面主线程 |
| React App | 保存状态,并把状态转换成欢迎页、进度条和聊天界面 |
此时页面已经具备输入和发送按钮的交互外观,但 onEnter() 还没有把消息发送给 Worker。因此,点击箭头只能进入已经预留的事件函数,还不会真正生成回答。下一步需要继续实现 generate 命令,才能把输入框、Worker 和模型推理连接起来。

12. 本篇完整代码
12.1 worker.js 完整版
import {
AutoTokenizer, // 文本与 Token ID 之间的转换器
AutoModelForCausalLM, // 用于自回归文本生成的大模型类
} from "@huggingface/transformers";
/**
* 使用单例思路延迟加载并复用 Tokenizer 与模型。
* 类本身充当资源管理器,不需要 new TextGenerationPipeline()。
*/
class TextGenerationPipeline {
// Hugging Face 模型仓库 ID。
static model_id = "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";
static async getInstance(progress_callback = null) {
// 只在 tokenizer 为空时启动加载,并立即缓存 Promise。
this.tokenizer ??= AutoTokenizer.from_pretrained(this.model_id, {
progress_callback,
});
console.log(this.tokenizer, "////////");
// 只在 model 为空时启动模型加载,并指定量化类型和 WebGPU 后端。
this.model ??= AutoModelForCausalLM.from_pretrained(this.model_id, {
dtype: "q4f16",
device: "webgpu",
progress_callback,
});
// 两个 Promise 都成功后,按原顺序返回 Tokenizer 和模型。
return Promise.all([this.tokenizer, this.model]);
}
}
// 检查 Worker 环境能否取得 WebGPU Adapter。
async function check() {
try {
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error("WebGPU is not supported (no adapter found)");
}
// 后续可继续检查 16 位浮点着色器支持。
// fp16_supported = adapter.features.has("shader-f16")
} catch (e) {
// Worker 无法修改 React 页面,只能把错误作为消息发回去。
self.postMessage({
status: "error",
data: e.toString(),
});
}
}
async function load() {
// 第一阶段:开始加载 Tokenizer 与模型文件。
self.postMessage({
status: "loading",
data: "Loading model...",
});
// getInstance 返回 [tokenizer, model]。
const [tokenizer, model] = await TextGenerationPipeline.getInstance((x) => {
// x 是 Transformers.js 传入的文件进度对象。
console.log(x, "//////////////");
// 将 initiate、progress、done 等事件原样转发给 App。
self.postMessage(x);
});
// 第二阶段:资源下载结束,准备第一次 GPU 执行。
self.postMessage({
status: "loading",
data: "Compiling shaders and warming up model...",
});
// 使用最小文本生成模型输入。
const inputs = tokenizer("a");
console.log(inputs);
// 只生成一个新 Token,用较小成本触发 WebGPU 预热。
await model.generate({ ...inputs, max_new_tokens: 1 });
// 预热完成后再通知 App 解锁输入框。
self.postMessage({ status: "ready" });
}
// 接收 App 发来的命令,并按 type 分发。
self.addEventListener("message", async (e) => {
const { type, data } = e.data;
switch (type) {
case "check":
check();
break;
case "load":
load();
break;
case "generate":
// 下一步接入正式文本生成。
break;
case "interrupt":
// 下一步接入生成中断。
break;
case "reset":
// 下一步接入会话重置。
break;
}
});
12.2 Progress.tsx 完整版
// 将原始字节数换算成 B、kB、MB、GB 或 TB。
function formatBytes(size) {
// 单独处理 0,避免 Math.log(0) 得到负无穷。
const i = size == 0
? 0
: Math.floor(Math.log(size) / Math.log(1024));
return (
+(size / Math.pow(1024, i)).toFixed(2) * 1 +
["B", "kB", "MB", "GB", "TB"][i]
);
}
export default function Progress({ text, percentage, total }) {
// initiate 事件还没有百分比时,从 0 开始显示。
percentage ??= 0;
return (
// 外层 div 是完整进度轨道。
<div className="w-full bg-gray-100 dark:bg-gray-700 text-left rounded-lg overflow-hidden mb-0.5">
<div
// 内层 div 是随 percentage 变宽的蓝色进度区域。
className="bg-blue-400 whitespace-nowrap px-1 text-sm"
style={{ width: `${percentage}%` }}
>
{text} ({percentage.toFixed(2)}%
{/* total 有效时,再显示格式化后的文件总大小。 */}
{isNaN(total) ? "" : ` of ${formatBytes(total)}`})
</div>
</div>
);
}
12.3 App.tsx 完整版
import { useEffect, useState, useRef } from "react";
import Progress from "./components/Progress.tsx";
import ArrowRightIcon from "./components/icons/ArrowRightIcon.tsx";
import StopIcon from "./components/icons/StopIcon.tsx";
// 控制 WebGPU 页面与兼容性提示页面的最外层分支。
const IS_WEBGPU_AVAILABLE = !!navigator.gpu;
// 后续可以用于判断聊天区是否接近底部。
const STICKY_SCROLL_THRESHOLD = 120;
// 后续可以作为示例问题。
const EXAMPLES = [
"Solve the equation x^2 - 3x + 2 = 0",
"Lily is three times older than her son. In 15 years, she will be twice as old as him. How old is she now?",
"Write python code to compute the nth fibonacci number.",
];
function App() {
// 保存 Worker 和两个 DOM 节点,修改 Ref 不触发重新渲染。
const worker = useRef(null);
const textareaRef = useRef(null);
const chatContainerRef = useRef(null);
// 模型加载与进度状态。
const [status, setStatus] = useState(null);
const [error, setError] = useState(null);
const [loadingMessage, setLoadingMessage] = useState("");
const [progressItems, setProgressItems] = useState([]);
const [isRunning, setIsRunning] = useState(false);
// 用户输入和聊天消息。
const [input, setInput] = useState("");
const [messages, setMessages] = useState([]);
// 后续把 message 发送给 Worker 的 generate 命令。
function onEnter(message) {
}
// 后续把 interrupt 命令发送给 Worker。
function onInterrupt() {
}
useEffect(() => {
// Ref 中没有 Worker 时才创建,避免因重新渲染重复实例化。
if (!worker.current) {
worker.current = new Worker(new URL("./worker.js", import.meta.url), {
// module Worker 才能在 worker.js 中使用 import。
type: "module",
});
// 创建后先检查 WebGPU 能力。
worker.current.postMessage({ type: "check" });
}
const onMessageReceived = (e) => {
switch (e.data.status) {
case "loading":
// 切换到加载页面,并显示 Worker 发来的阶段文案。
setStatus("loading");
setLoadingMessage(e.data.data);
break;
case "initiate":
// 使用函数式更新取得最新数组,再追加新文件。
setProgressItems((prev) => [...prev, e.data]);
break;
case "progress":
// 使用 map 只更新与本次事件同名的文件。
setProgressItems((prev) =>
prev.map((item) => {
if (item.file === e.data.file) {
// 新事件字段覆盖旧进度字段。
return { ...item, ...e.data };
}
return item;
}),
);
break;
case "done":
// 使用 filter 删除已经完成的文件。
setProgressItems((prev) =>
prev.filter((item) => item.file !== e.data.file),
);
break;
case "ready":
// 预热完成:渲染聊天区并解锁输入框。
setStatus("ready");
break;
case "start":
// 后续表示文本生成开始。
break;
case "update":
// 后续接收流式生成片段。
break;
case "complete":
// 后续表示一次文本生成结束。
break;
case "error":
// 显示 Worker 主动发回的业务错误。
setError(e.data.data);
break;
}
};
// 捕获 Worker 脚本自身未处理的错误。
const onErrorReceived = (e) => {
console.error("Worker error:", e);
};
worker.current.addEventListener("message", onMessageReceived);
worker.current.addEventListener("error", onErrorReceived);
// 组件卸载时移除同一批事件监听器。
return () => {
worker.current.removeEventListener("message", onMessageReceived);
worker.current.removeEventListener("error", onErrorReceived);
};
}, []);
return (
IS_WEBGPU_AVAILABLE ? (
// WebGPU 可用:进入项目主页面。
<div className="flex flex-col h-screen mx-auto items justify-end text-gray-800 dark:text-gray-200 bg-white dark:bg-gray-900">
{/* 还未加载、也没有消息时显示欢迎页。 */}
{status === null && messages.length === 0 && (
<div className="h-full overflow-auto scrollbar-thin flex justify-center items-center flex-col relative">
<div className="flex flex-col items-center mb-1 max-w-[400px] text-center">
<img
src="logo.png"
width="80%"
height="auto"
className="block drop-shadow-lg bg-transparent"
></img>
<h1 className="text-4xl font-bold mb-1">
DeepSeek-R1 WebGPU
</h1>
<h2 className="font-semibold">
A next-generation reasoning model that runs locally in your
browser with WebGPU acceleration.
</h2>
</div>
<div className="flex flex-col items-center px-4">
<p className="max-w-[510px] mb-4">
<br />
You are about to load{" "}
<a
href="https://huggingface.co/onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX"
target="_blank"
rel="noreferrer"
className="font-medium underline"
>
DeepSeek-R1-Distill-Qwen-1.5B
</a>
, a 1.5B parameter reasoning LLM optimized for in-browser
inference. Everything runs entirely in your browser with{" "}
<a
href="https://huggingface.co/docs/transformers.js"
target="_blank"
rel="noreferrer"
className="underline"
>
🤗 Transformers.js
</a>{" "}
and ONNX Runtime Web, meaning no data is sent to a server. Once
loaded, it can even be used offline. The source code for the demo
is available on{" "}
<a
href="https://github.com/huggingface/transformers.js-examples/tree/main/deepseek-r1-webgpu"
target="_blank"
rel="noreferrer"
className="font-medium underline"
>
GitHub
</a>
.
</p>
{/* 有错误时才渲染错误说明。 */}
{error && (
<div className="text-red-500 text-center mb-2">
<p className="mb-1">
Unable to load model due to the following error:
</p>
<p className="text-sm">{error}</p>
</div>
)}
<button
className="border px-4 py-2 rounded-lg bg-blue-400 text-white hover:bg-blue-500 disabled:bg-blue-100 cursor-pointer disabled:cursor-not-allowed select-none"
onClick={() => {
// 通知 Worker 执行 load()。
worker.current.postMessage({ type: "load" });
// 立即切换加载页,让用户马上看到反馈。
setStatus("loading");
}}
disabled={status !== null || error !== null}
>
Load model
</button>
</div>
</div>
)}
{/* 加载页:显示阶段文案与每个文件的进度。 */}
{status === "loading" && (
<>
<div className="w-full max-w-[500px] text-left mx-auto p-4 bottom-0 mt-auto">
<p className="text-center mb-1">{loadingMessage}</p>
{progressItems.map(({ file, progress, total }, i) => (
<Progress
key={i}
text={file}
percentage={progress}
total={total}
/>
))}
</div>
</>
)}
{/* 就绪页:先提供可滚动的聊天消息容器。 */}
{status === "ready" && (
<div
ref={chatContainerRef}
className="overflow-y-auto scrollbar-thin w-full flex flex-col items-center h-full"
>
</div>
)}
{/* 输入区始终渲染,但模型 ready 前 textarea 被禁用。 */}
<div className="mt-2 border border-gray-300 dark:bg-gray-700 rounded-lg w-[600px] max-w-[80%] max-h-[200px] mx-auto relative mb-3 flex">
<textarea
ref={textareaRef}
className="scrollbar-thin w-[550px] dark:bg-gray-700 px-3 py-4 rounded-lg bg-transparent border-none outline-hidden text-gray-800 disabled:text-gray-400 dark:text-gray-200 placeholder-gray-500 dark:placeholder-gray-400 disabled:placeholder-gray-200 resize-none disabled:cursor-not-allowed"
placeholder="Type your message..."
rows={1}
value={input}
disabled={status !== "ready"}
title={status === "ready" ? "Model is ready" : "Model not loaded yet"}
onKeyDown={(e) => {
if (
input.length > 0 &&
!isRunning &&
e.key === "Enter" &&
!e.shiftKey
) {
// 阻止 Enter 在 textarea 中插入换行。
e.preventDefault();
onEnter(input);
}
}}
// 把 DOM 输入同步到 React State。
onInput={(e) => setInput(e.currentTarget.value)}
/>
</div>
{/* 运行时显示停止;有输入时显示可发送;无输入时显示灰色图标。 */}
{isRunning ? (
<div className="cursor-pointer" onClick={onInterrupt}>
<StopIcon className="h-8 w-8 p-1 rounded-md text-gray-800 dark:text-gray-100 absolute right-3 bottom-3" />
</div>
) : input.length > 0 ? (
<div className="cursor-pointer" onClick={() => onEnter(input)}>
<ArrowRightIcon
className="h-8 w-8 p-1 bg-gray-800 dark:bg-gray-100 text-white dark:text-black rounded-md absolute right-3 bottom-3"
/>
</div>
) : (
<div>
<ArrowRightIcon
className="h-8 w-8 p-1 bg-gray-200 dark:bg-gray-600 text-gray-50 dark:text-gray-800 rounded-md absolute right-3 bottom-3"
/>
</div>
)}
</div>
) : (
// WebGPU 不可用:返回全屏兼容性提示。
<div className="fixed w-screen h-screen bg-black z-10 bg-opacity-[92%] text-white text-2xl font-semibold flex justify-center items-center text-center">
WebGPU is not supported
<br />
by this browser :(
</div>
)
);
}
export default App;
12.4 两个 SVG 图标组件完整版
ArrowRightIcon.tsx 负责发送箭头:
export default function ArrowRightIcon(props) {
return (
<svg
// 把 App 传入的 className 等属性交给真正的 svg 元素。
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
// 使用 CSS 的当前文字颜色绘制线条。
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
{/* 水平线与折线共同组成向右箭头。 */}
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
);
}
StopIcon.tsx 负责生成过程中的停止图标:
export default function StopIcon(props) {
return (
<svg
// 复用 App 传入的尺寸、颜色和定位类。
{...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="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
{/* 内层实心方块,fill 同样继承当前文字颜色。 */}
<path
fill="currentColor"
d="M9 9.563C9 9.252 9.252 9 9.563 9h4.874c.311 0 .563.252.563.563v4.874c0 .311-.252.563-.563.563H9.564A.562.562 0 0 1 9 14.437V9.564Z"
/>
</svg>
);
}
完整代码再次说明了 React 的核心工作方式:State 不是页面之外的一份记录,而是页面结构的输入。status 决定显示欢迎、加载还是聊天区域,progressItems 决定进度条列表,input 和 isRunning 决定输入行为与图标。Worker 只报告事实,React 使用这些事实计算新的 JSX。
总结
这一篇把浏览器端模型应用从“只有 Tokenizer”推进到“模型可以加载、预热并进入就绪状态”。AutoModelForCausalLM.from_pretrained() 根据模型 ID 加载因果语言模型,通过 dtype: "q4f16" 选择更适合浏览器的量化权重,通过 device: "webgpu" 接入 GPU 推理后端。Promise.all() 同时等待 Tokenizer 和模型,随后用 tokenizer("a") 构造最小输入,再通过只生成一个 Token 的任务完成 WebGPU 预热。
下载过程中,App 使用 progressItems 管理多个文件。initiate 追加、progress 更新、done 删除,三者都使用函数式 State 更新。prev => ... 的核心不是语法简写,而是让每次更新基于 React 队列中的最新状态,避免 Worker 高频事件、闭包旧值和批量更新造成数据丢失;同时,它让只注册一次的 Worker 监听器保持稳定。
最后,App.tsx 的 return 把状态转换成页面:WebGPU 判断负责最外层分支,status 负责欢迎页、加载页和聊天区,受控 textarea 保存用户输入,isRunning 与输入内容决定停止、发送或灰色图标。页面框架已经能够在模型预热后解锁输入,接下来只需让 onEnter()、onInterrupt() 和 Worker 中的对应命令真正处理生成任务。
更多推荐



所有评论(0)