从零在浏览器里跑 DeepSeek-R1:WebGPU + Transformer.js 全链路实战(二)

从零到一:在浏览器里跑 DeepSeek-R1,WebGPU 大模型推理全链路解析(二)

前言

大家好,我是 Daijvnzhao。最近我在做一个项目------把 DeepSeek-R1 大模型完整跑在浏览器里,纯本地推理,数据绝不离开你的电脑。过程中踩了不少坑,也学到了很多底层知识。

本文按照项目的实际流程,从模型下载到推理输出,把每个关键知识点讲透。不会铺开所有边角内容,聚焦核心链路和底层原理。


一、整体架构:一个模型如何从云端跑到浏览器里

整个链路五步走:

markdown 复制代码
HuggingFace 模型仓库
    ↓
Transformers.js 远程下载 ONNX 模型文件
    ↓
浏览器本地缓存(下次无需重新下载)
    ↓
WebGPU 推理加速(GPU Compute Shader 并行矩阵运算)
    ↓
Web Worker 异步执行(主线程不卡,UI 流畅)
    ↓
React 主线程渲染(进度条 → 聊天界面 → Markdown + 数学公式)

核心依赖:

  • @huggingface/transformers:JS 版 Transformers,负责模型下载和推理调度
  • marked + DOMPurify:Markdown → 安全 HTML
  • MathJax:LaTeX 数学公式渲染

二、HuggingFace:AI 界的 GitHub

HuggingFace 是目前 AI 圈最活跃的开源模型社区。Meta 的 LLaMA、Google 的 Gemma、Mistral、国内 DeepSeek 和 Qwen,会把训练好的模型发布到这里。它本质上是模型文件的 GitHub,托管的不止代码,更多的是几百 MB 到几百 GB 的模型权重。

本项目使用 DeepSeek-R1-Distill-Qwen-1.5B-ONNX

  • DeepSeek-R1:深度求索出品的推理模型,擅长数学和逻辑推理
  • Distill-Qwen-1.5B:用 Qwen 做基座、蒸馏成 1.5B 参数的小模型
  • ONNX:开放神经网络交换格式,跨框架跨平台

1.5B 参数意味着不需要数据中心级 GPU,普通电脑的集成显卡就能跑------这是浏览器端推理的前提。

国内有阿里的 ModelScope(魔搭),但 HuggingFace 生态更成熟。Transformers.js 基于 HuggingFace 格式设计,选它最自然。


三、Transformers.js:把 HuggingFace 搬进浏览器

worker.js 中的两个核心 import:

javascript 复制代码
import {
  AutoTokenizer,          // 分词器:文本 → token id 序列
  AutoModelForCausalLM,   // 因果语言模型:token id 序列 → 逐 token 生成
} from "@huggingface/transformers";

"Causal"(因果)是什么意思?

因果语言模型的核心是注意力掩码:每个 token 只能看到自己前面的 token,不能"偷看"未来。

arduino 复制代码
输入序列:[我, 爱, 北京, __]
           ↓   ↓   ↓
预测时:  token "我"   只能看到 "我"
预测时:  token "爱"   只能看到 "我", "爱"
预测时:  token "北京" 只能看到 "我", "爱", "北京"

这就是"因果"------只能根据上文预测下文

对比 BERT 的 AutoModelForMaskedLM(完形填空),BERT 可以看到前后所有 token(双向注意力)。Transformer 原始论文中,Encoder 用双向注意力,Decoder 用因果注意力。GPT、DeepSeek、LLaMA 等生成式模型只用 Decoder,所以归为 Causal LM。

from_pretrained() 底层做了什么

arduino 复制代码
AutoModelForCausalLM.from_pretrained("onnx-community/DeepSeek-R1-...")

这个调用展开后经历了五个步骤:

  1. 构造请求 URL :拼出 HuggingFace CDN 上 config.jsontokenizer.jsononnx/model.q4f16.onnx 等文件的地址
  2. HTTP Range 分段下载.onnx 文件几百 MB,浏览器用 Range: bytes=0-1048575 分段请求,每完成一个 chunk 就调用 progress_callback
  3. ONNX Runtime Web 解析计算图 :ONNX 文件本质是 protobuf 序列化的计算图,每层算子描述为 node { op_type: "MatMul", input: [...], output: [...] },ORT 将其解析为内存中的图结构
  4. 注册 WebGPU 后端算子:ORT 的 WebGPU EP(Execution Provider)将每个算子映射到预先写好的 WGSL Shader 模板
  5. 返回 Pipeline 对象 :提供给上层调用 .generate() 方法

整个过程是异步的,文件大、耗时长,这也是为什么后面要用单例模式------绝不能重复执行。


四、WebGPU 检测与 TypeScript 类型困境

App.tsx 开头的浏览器能力检测:

ini 复制代码
const IS_WEBGPU_AVAILABLE = !!navigator.gpu;

!! 的本质

!! 不是特殊语法,是两个 ! 的叠加。第一个 ! 把值转布尔并取反,第二个再取反:

javascript 复制代码
!!undefined → !true → false
!!{}        → !false → true

如果浏览器不支持 WebGPU,navigator.gpuundefined!!undefined = false,界面直接展示 "WebGPU is not supported by this browser"。

TypeScript 的类型问题

直接写 navigator.gpu,TS 会报 Property 'gpu' does not exist on type 'Navigator'。因为 WebGPU 是 2023 年才在 Chrome 113 默认开启的新 API,TS 内置的 Navigator 类型还没收录。

本项目安装了 @webgpu/types

perl 复制代码
"@webgpu/types": "^0.1.71"

TypeScript 的 interface 是开放式(Declaration Merging) ------同名 interface 自动合并属性。@webgpu/types 内部声明了 interface Navigator { readonly gpu: GPU },安装后无需任何 import,navigator.gpu 就有了完整类型提示。^0.1.71 中版本号 0.x.x 表示 WebGPU 规范尚未到 1.0 正式版。


五、Web Worker:主线程不卡顿的秘诀

大模型推理是计算密集型任务,跑在主线程会让 UI 完全冻结。

Worker 的创建

go 复制代码
worker.current = new Worker(new URL("./worker.js", import.meta.url), {
  type: "module", // 前端打包工具默认不支持 ESM,需显式声明
});
  • new URL("./worker.js", import.meta.url) :Vite/Webpack 需要这种写法才能在打包时正确解析 worker 文件路径
  • type: "module" :Worker 默认只能用 importScripts(),加这个标志后可直接用 import/export

线程通信

主线程和 Worker 通过 postMessage 通信。数据被浏览器结构化克隆(Structured Clone),不是共享内存,两端互不干扰。大型数据(如模型权重)浏览器会走 Transferable 优化,接近零拷贝。

css 复制代码
主线程                     Worker
  │                          │
  │── { type: "check" } ──→  │  检测 WebGPU
  │── { type: "load" }  ──→  │  开始加载模型
  │  ←── { status: "initiate" }  文件开始下载
  │  ←── { status: "progress" }  下载进度更新
  │  ←── { status: "ready" }     就绪

六、单例模式与 ??= 运算符

kotlin 复制代码
class TextGenerationPipeline {
  static model_id = "onnx-community/DeepSeek-R1-Distill-Qwen-1.5B-ONNX";
  // 单例模式:llm 只需要初始化一次,后面可以一直用,实例化开销很大
  static async getInstance(progress_callback = null) {
    // 分词器:只初始化一次
    this.tokenizer ??= AutoTokenizer.from_pretrained(this.model_id, {
      progress_callback, // 下载进度回调函数
    });

    // 大模型本体:只初始化一次
    this.model ??= AutoModelForCausalLM.from_pretrained(this.model_id, {
      dtype: "q4f16",     // 4-bit 存储 + float16 计算
      device: "webgpu",   // 使用 GPU 推理
      progress_callback,
    });

    return Promise.all([this.tokenizer, this.model]);
  }
}

??= 的精确语义

kotlin 复制代码
this.model ??= someAsyncOperation();
// 等价于:
if (this.model === null || this.model === undefined) {
  this.model = someAsyncOperation();
}

| 运算符 | 触发赋值的条件 |
|-----------|-----------------------------|-------|-----------------------------------------------------------------|
| `x | | = y` | x 为任意 falsy 值(false, 0, "", null, undefined, NaN) |
| x ??= y | x 仅为 nullundefined |

from_pretrained() 返回 Promise(永远是 truthy),两个运算符在这里行为一致。但 ??= 语义更精确------"只有没初始化过才去下载"。这是防御性编码习惯。

为什么必须用单例

from_pretrained() 做的事情极其昂贵:HTTP 下载几百 MB、解析 ONNX、注册 GPU 算子。整个过程可能几十秒到几分钟。如果用普通实例化,每次调用都重新执行,用户要反复等待。**单例保证不管调用多少次,下载和初始化只发生一次。**这是 GOF 23 种设计模式中最常用的之一------用于管理全局状态、避免重复初始化。

Promise.all 并行

kotlin 复制代码
return Promise.all([this.tokenizer, this.model]);

分词器和模型文件并行下载 。浏览器对同域名的 HTTP/2 连接是多路复用的,并行能充分利用带宽。如果写成两个 await 串行,总耗时就是两者之和,浪费一半等待时间。


七、dtype: "q4f16":量化的底层原理

这是整个项目中最关键的配置。

为什么需要量化

原始权重是 float32------每个参数 4 字节。1.5B × 4 = 6 GB,浏览器根本扛不住。

量化的思路:用更少的比特存储,计算时反量化回来。

逐字符拆解

css 复制代码
q4f16
││││
│││└── f16 → 计算精度:forward pass 时用 float16 运算
││└─── 4   → 存储精度:每权重 4 个比特(0.5 字节)
│└──── f   → 浮点格式运算
└───── q   → Quantized(量化版)

格式对比

格式 每权重 模型大小 精度损失 适用场景
fp32 4 字节 ~6 GB 基准 服务器 GPU
fp16 2 字节 ~3 GB < 0.1% 高端显卡
q8f16 1 字节 ~1.5 GB < 0.5% 台式机
q4f16 0.5 字节 ~750 MB < 1% 普通浏览器

存储减少 87.5%,精度损失不到 1%------这是浏览器跑大模型的王牌配置。

线性量化怎么做的

ONNX 文件里不仅存了 4-bit 权重,还存了每层的量化参数:

ini 复制代码
量化公式:w_float16 = scale × (w_int4 - zero_point)

  w_int4      ∈ {0, 1, ..., 15}     ← 4-bit 只能表示 16 个离散值
  w_float16   ∈ [-2.3, 1.8]         ← 真实的浮点权重范围
  scale       = (max - min) / 15     ← 步长
  zero_point  = -min / scale         ← 零点偏移

举个例子,某层权重范围是 -1.0, 2.0

ini 复制代码
scale = 3.0 / 15 = 0.2
zero_point = 1.0 / 0.2 = 5

存储(4-bit)  →  反量化后(float16)
    0         →  0.2 × (0 - 5) = -1.0
    5         →  0.2 × (5 - 5) =  0.0
   15         →  0.2 × (15 - 5)=  2.0

16 个离散值均匀覆盖了整个权重范围。对神经网络来说,权重的精确值不如相对大小重要,16 个档位已经足够。

数据流全链路

arduino 复制代码
[HuggingFace CDN] 4-bit 量化权重
     │ HTTP 分段下载
     ▼
[浏览器 ArrayBuffer] 4-bit packed
     │ GPU buffer upload
     ▼
[GPU VRAM] WebGPU Storage Buffer(4-bit packed)
     │ WGSL Compute Shader 中:
     ├─ 读取 packed 值
     ├─ 查 scale/zero_point 表
     ├─ 解包 + 反量化 → float16
     ├─ MatMul / Attention 矩阵运算
     └─ 输出 float16 logits
     ▼
[推理结果] token 概率分布 → 采样 → 下一个 token

为什么用 GPU 而不是 CPU

矩阵乘法本质是"每个输出元素独立计算",天然适合 GPU 并行:

yaml 复制代码
CPU(8核):     约 30 GFLOPS  → 1 token / 3-10 秒
GPU(集显):  约 2000 GFLOPS → 1 token / 50-200 毫秒

差距不在频率,在并行度。GPU 有 1000+ 着色器核心同时工作。


八、模型预热:为什么要用 "a" 跑一遍

模型文件下载完成后,Worker 里有一段关键逻辑:

php 复制代码
self.postMessage({
  status: "loading",
  data: "Compiling shaders and warming up model...",
});

// Run model with dummy input to compile shaders
// a 分词
const inputs = tokenizer("a");
// 调用 generate 方法生成文本
// max_new_tokens 生成的文本长度
await model.generate({ ...inputs, max_new_tokens: 1 });
self.postMessage({ status: "ready" });

为什么需要预热

ONNX Runtime Web 第一次调用 generate() 时,需要把模型每一层算子惰性编译为 WGSL Shader。这个过程包括:

  • MatMul 着色器编译(约 800ms)
  • Attention 着色器编译(约 400ms)
  • 其他算子编译(约 300ms)
  • GPU 缓冲区分配(约 100ms)

总计约 1.6 秒。如果不预热,这 1.6 秒会叠加在用户输入第一个问题的首次响应上------"我发了消息,怎么等了两秒才有反应?"体验很差。

预热后,这 1.6 秒发生在用户点 "Load model" 之后、看到输入框之前,用户此时在看加载进度条,完全无感知。

为什么是 "a" 而不是别的

  • :仅 1 个 token + BOS 特殊标记,GPU 计算量极低
  • 合法:任何模型词表里肯定有字母 "a"
  • 无意义:输出什么都不重要,我们只关心推理管线跑通没

max_new_tokens: 1 是关键------不加它模型会按默认值(通常 256 或 512)连续生成一长串无意义文本,白费时间。1 个 token 刚好走一遍完整的 forward pass,触发所有 Shader 编译。


九、进度回调与 React 函数式 setState

模型由多个文件组成(tokenizer.jsonconfig.jsononnx/model.q4f16.onnx 等)。下载过程中,Worker 对每个文件分别发送 initiate → progress → done 三条消息:

yaml 复制代码
Worker → 主线程:
  { status: "initiate", file: "tokenizer.json", progress: 0, total: 1.2MB }
  { status: "progress",  file: "tokenizer.json", progress: 50%, total: 1.2MB }
  { status: "initiate", file: "model.q4f16.onnx", progress: 0, total: 752MB }
  { status: "progress",  file: "tokenizer.json", progress: 100%, total: 1.2MB }
  { status: "done",     file: "tokenizer.json" }
  { status: "progress",  file: "model.q4f16.onnx", progress: 45%, total: 752MB }
  { status: "done",     file: "model.q4f16.onnx" }

App.tsx 中用 switch 实现三段式状态机:

javascript 复制代码
case "initiate":
  // 多个文件并发下载时进度回调频繁触发
  // 给函数为了获得最新状态
  setProgressItems((prev) => [...prev, e.data]);
  break;

case "progress":
  // Model file progress: update one of the progress items.
  setProgressItems((prev) =>
    prev.map((item) => {
      if (item.file === e.data.file) {
        return { ...item, ...e.data };
      }
      return item;
    }),
  );
  break;

case "done":
  // Model file loaded: remove the progress item from the list.
  setProgressItems((prev) =>
    prev.filter((item) => item.file !== e.data.file),
  );
  break;

case "ready":
  setStatus("ready");
  break;

三条 case 对应三种数组操作:

scss 复制代码
initiate  → 追加新文件   (prev) => [...prev, e.data]
progress  → 原地更新进度  (prev) => prev.map(...)
done      → 删除完成项    (prev) => prev.filter(...)

为什么必须用函数式 (prev) => 而不是直接传值

根源在 useEffect 的依赖数组是 []

javascript 复制代码
useEffect(() => {
  const onMessageReceived = (e) => {
    // 这个函数在 useEffect body 中定义
    // useEffect 只执行一次,所以 onMessageReceived 也只创建一次
    // 它闭包中捕获的 progressItems = [](初始值),永远不会变

    case "initiate":
      // ❌ setProgressItems([...progressItems, e.data])
      //    progressItems 永远是 [] → 每次都 [...[], 新数据] → 只保留最后一个

      // ✅ setProgressItems((prev) => [...prev, e.data])
      //    prev 由 React 内部传入,保证是最新值
      break;
  };
}, []); // ← 空依赖 = 闭包永久锁死在初始值

React 内部的执行流程:

scss 复制代码
// 两条 "initiate" 消息几乎同时到达,还没 re-render

// 第 1 条:加入队列
updateQueue.push((prev) => [...prev, { file: "tokenizer.json" }])

// 第 2 条:加入队列
updateQueue.push((prev) => [...prev, { file: "model.onnx" }])

// React commit 阶段,逐个执行:
prev = []                                          // 初始值
prev = updateQueue[0](prev)  → [tokenizer.json]
prev = updateQueue[1](prev)  → [tokenizer.json, model.onnx]

// 最终渲染:两个文件都在 ✅

如果用值形式,两个闭包读到的 progressItems 都是 [],第二个直接覆盖第一个------数据丢了。

Progress 组件

arduino 复制代码
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];
}

export default function Progress({ text, percentage, total }) {
  percentage ??= 0; // null/undefined 时默认 0%
  return (
    <div className="w-full bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden">
      <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>
  );
}

formatBytes 用换底公式 Math.log(size) / Math.log(1024) 一次性算出最合适的单位索引(B/kB/MB/GB/TB),O(1) 复杂度,比 while 循环高效。

percentage ??= 0 保证 initiateprogressundefined 也不显示 NaN%。进度条动画由 CSS width 过渡驱动------写入 GPU 合成层,不触发 layout,不卡主线程。


十、useEffect cleanup 与 StrictMode

erlang 复制代码
useEffect(() => {
  // ...
  worker.current.addEventListener("message", onMessageReceived);
  worker.current.addEventListener("error", onErrorReceived);

  return () => {
    worker.current.removeEventListener("message", onMessageReceived);
    worker.current.removeEventListener("error", onErrorReceived);
  };
}, []);

为什么必须 cleanup

React 18 的 StrictMode 在开发环境会故意双重挂载组件来暴露副作用问题:

markdown 复制代码
StrictMode 开发模式执行顺序:
1. 挂载组件 → body 执行 → addEventListener(第 1 次绑定)
2. 模拟卸载 → cleanup 执行 → removeEventListener(清理掉)
3. 再挂载 → body 执行 → addEventListener(重新绑定,始终只有 1 次)

如果没有 cleanup(第 2 步什么都不做),第 3 步会加上第二个监听器------同一条 Worker 消息触发两次回调,每一条进度更新都导致重复渲染。

removeEventListener 的匹配要求

arduino 复制代码
worker.current.removeEventListener("message", onMessageReceived);

removeEventListener 需要完全相同的函数引用 。这就是为什么 onMessageReceived 必须在 useEffect 内部定义------同一个闭包引用被 body 和 cleanup 共享,能精确匹配。

StrictMode 是开发环境专用工具,生产构建完全不受影响。它是一个零成本的代码质量守门员------双重调用帮你提前发现不纯的副作用。cleanup 已经补好,建议在 main.tsx 中保留 <StrictMode> 包装。


十一、输入交互:条件判断的三段式按钮

ini 复制代码
{isRunning ? (
  <div className="cursor-pointer" onClick={onInterrupt}>
    <StopIcon ... />
  </div>
) : input.length > 0 ? (
  <div className="cursor-pointer" onClick={() => onEnter(input)}>
    <ArrowRightIcon className="bg-gray-800 dark:bg-gray-100 text-white dark:text-black ..." />
  </div>
) : (
  <div>
    <ArrowRightIcon className="bg-gray-200 dark:bg-gray-600 text-gray-50 dark:text-gray-800 ..." />
  </div>
)}

决策树:

lua 复制代码
isRunning?
  ├── true  → StopIcon(■ 停止按钮)------正在生成中,可中断
  └── false → input.length > 0?
                ├── true  → ArrowRightIcon 亮色(→ 可发送)
                └── false → ArrowRightIcon 灰色(→ 禁用态)

键盘事件与门

javascript 复制代码
onKeyDown={(e) => {
  if (
    input.length > 0 &&       // ① 输入不为空
    !isRunning &&             // ② 没有正在生成
    e.key === "Enter" &&      // ③ 按下的是 Enter
    !e.shiftKey               // ④ 没有同时按 Shift
  ) {
    e.preventDefault();       // 阻止默认换行
    onEnter(input);           // 触发发送
  }
}}

四个条件构成与门:

ini 复制代码
input.length > 0 ──┐
!isRunning ────────┤
e.key === "Enter" ─┼─ AND → onEnter(input)
!e.shiftKey ───────┘

任何一个不满足都不发送。Shift+Enter 放行默认行为(换行),这是聊天应用的标准交互模式。


十二、Chat 组件:Markdown + XSS 防护 + 数学公式 + 思考过程

依赖分工

  • marked:Markdown 文本 → HTML 字符串(AIGC 输出天然是 Markdown,含代码块、加粗、列表等)
  • DOMPurify :剥离危险标签(<script>onerrorjavascript: 等),防御 XSS
  • MathJax :渲染 LaTeX 公式($x^2$$$\frac{a}{b}$$

安全渲染链路

arduino 复制代码
function render(text) {
  // 修复 marked 对括号转义的已知问题
  text = text.replace(/\([[]()])/g, "\\$1");

  const result = DOMPurify.sanitize(
    marked.parse(text, {
      async: false,
      breaks: true, // 单换行也转 <br>
    }),
  );
  return result;
}

数据流:

scss 复制代码
Markdown 文本
  → marked.parse()      // Markdown → HTML
  → DOMPurify.sanitize() // 剥离 XSS 攻击向量
  → dangerouslySetInnerHTML  // 注入 DOM
  → MathJax 二次渲染    // 识别 $...$ 中 LaTeX 公式

dangerouslySetInnerHTML 如其名------直接塞 HTML 进 DOM。模型可能幻觉生成 <script>alert(1)</script>,不加 DOMPurify 就是 XSS 漏洞。

思考过程的折叠展示

DeepSeek-R1 输出包含"思考过程"和"最终回答"两部分。Message 组件通过 answerIndex 切分:

ini 复制代码
const thinking = answerIndex ? content.slice(0, answerIndex) : content;
const answer = answerIndex ? content.slice(answerIndex) : "";

思考过程默认折叠,点击 "View reasoning." 展开------类似 ChatGPT 的 "Thinking" 区域。BrainIcon 在思考中播放脉冲动画(animate-pulse),给用户"模型正在工作"的视觉反馈。思考完成后 doneThinkingtrue,图标停止脉冲。

三点加载动画

当模型还在计算、content 为空时,显示三个依次闪烁的圆点:

css 复制代码
<span className="h-6 flex items-center gap-1">
  <span className="w-2.5 h-2.5 ... animate-pulse"></span>
  <span className="w-2.5 h-2.5 ... animate-pulse animation-delay-200"></span>
  <span className="w-2.5 h-2.5 ... animate-pulse animation-delay-400"></span>
</span>

三个点通过不同的 animation-delay 制造波浪脉冲效果------和 ChatGPT 的加载动画逻辑一致。


十三、整体状态机流转

status 是贯穿整个 App 的核心状态变量:

bash 复制代码
null ──→ "loading" ──→ "ready" ──→ (未来: "generating")
  │          │
  │          └── 显示进度条
  │               inititate → progress → done
  │
  └── 显示欢迎页 + Load model 按钮

当 status 变化时,条件渲染自动切换:

status 界面
null 欢迎页 + 模型介绍 + Load 按钮
"loading" 进度条列表 + 加载文案
"ready" 聊天区域 + 输入框(可交互)
"error" 红色错误提示
(未来)"generating" 流式输出中,stop 按钮亮起

十四、总结

本文覆盖了从 HuggingFace 到浏览器推理的完整链路:

层级 知识点 核心价值
模型获取 HuggingFace + Transformers.js 浏览器直接加载开源模型
硬件加速 WebGPU Compute Shader 1000+ GPU 核心并行,比 CPU 快 50 倍
模型压缩 q4f16 线性量化 6GB → 750MB,精度损失 < 1%
性能优化 单例模式 + 模型预热 只下载一次,提前编译 Shader
并发处理 Web Worker + postMessage 计算离主线程,UI 不冻结
状态管理 函数式 setState (prev) => [...prev, x] 解决闭包陷阱
代码质量 useEffect cleanup + StrictMode 清理副作用,零成本 bug 检测
安全渲染 DOMPurify + MathJax 防 XSS + 数学公式排版
工程规范 @webgpu/types + 声明合并 TypeScript 类型安全

这个项目的流式生成部分(generate / update / complete)还在实现中,后续会继续补充。有问题欢迎在掘金评论区交流!

相关推荐
雪隐4 小时前
个人电脑玩AI-16让5060 Ti给你打工——MiniMax H3 提速实录:从"泡杯茶等视频"到"视频等你",一张 5060 Ti 的自我修养
前端·人工智能·后端
FungLeo4 小时前
Flutter Web 中文字体困境与渲染器选择:CanvasKit vs HTML renderer 实战
前端·flutter·html
BD_Marathon4 小时前
部署Spark
大数据·javascript·spark
敲代码的玉米C4 小时前
我修的那个 bug,制造了另一个 bug
前端·人工智能·架构
gis开发之家4 小时前
《Vue3 从入门到大神50篇》Vue3 源码详解(二十):生命周期钩子源码解析 —— onMounted / onUpdated 如何实现?
前端·javascript·前端框架·vue3·vue3源码
全栈项目管理程序猿4 小时前
ArcGIS JS 基础教程(9):天空盒与大气效果
javascript
Narrastory4 小时前
我用 Claude Code 写代码不到 2 小时,却花了 3 天做完这个软件—Vibe Coding 的正确姿势,是设计不是生成
前端·人工智能·github
Enaium5 小时前
我实现了KMP的SDL绑定并编写了高性能软光追
前端·kotlin
古夕5 小时前
本地正常线上白屏:一次路由切换后刷新恢复问题的排查与修复
前端·ai编程