
这个项目还没有真正把大模型跑起来但它已经把端侧 AI 应用最重要的 UI 骨架搭好了能力检测、加载状态、错误出口、下载进度和输入交互。本文从现有代码出发梳理它已经完成的部分以及接入真实推理链路还差什么。每次调用云端大模型 API输入内容都要离开浏览器还会受到网络、额度和服务可用性的影响。另一条路线是把模型和推理放在用户设备上模型首次下载到浏览器缓存后续由本机 GPU 或 CPU 执行用户的提示词不必为了推理而发送到业务服务器。deepseek-r1-webgpu正是在探索这条路线。项目的页面描述指向DeepSeek-R1-Distill-Qwen-1.5B-ONNX、Transformers.js、ONNX Runtime Web 和 WebGPU代码则先实现了一个 React TypeScript Vite Tailwind CSS 的交互骨架。先说明本文的边界当前仓库尚未安装或调用huggingface/transformers、onnxruntime-web也没有模型下载与文本生成代码。点击Load Model只会把status从0改为loading。因此它是一个很适合学习前端状态设计的端侧 AI 页面原型而不是已经可推理的完整应用。项目先解决了什么问题入口src/main.tsx使用 React 19 的createRoot挂载App并由 Vite 插件启用 React 与 Tailwind CSS。页面主逻辑在src/App.tsx可先把它看成一台由状态驱动的界面机器const [inputValue, setInputValue] useState(); const [status, setStatus] useState(0); const [error, setError] useState(null); const [loadingMessage, setLoadingMessage] useState(开始加载); const [progressItems, setProgressItems] useState([]); const isWebGpuAvailable !!navigator.gpu;这些变量对应的并不是某几个散落的 DOM 节点而是页面的业务状态状态当前用途应驱动的界面isWebGpuAvailable检测浏览器是否暴露 WebGPU API主界面或“不支持 WebGPU”提示status初始、加载、就绪等模型生命周期按钮可用性、输入框可用性、进度区error失败原因错误文案与重试入口progressItems模型文件下载事件多条下载进度条inputValue用户正在输入的问题受控textarea与 Enter 发送这种写法的价值在于界面不再由“找到某个元素再改样式”的命令式 DOM 操作拼出来。状态变化后React 重新执行组件函数再根据条件渲染对应 UIdisabled{status ! 0 || error ! null} {status loading LoadingPanel /} textarea disabled{status ! ready} /Load Model只有在初始状态且没有错误时可点击模型未就绪时文本框保持禁用。即便真实模型逻辑还没接入这两个约束已经避免了“模型还没加载完用户就发送请求”的无效交互。第一层降级先判断 WebGPU 能力项目使用下面的判断const isWebGpuAvailable !!navigator.gpu;navigator.gpu是浏览器提供 WebGPU 接口时暴露的入口。双重否定把可能为undefined的值转换成清晰的布尔值随后组件在支持与不支持之间选择不同视图return isWebGpuAvailable ? MainPage / : div您的浏览器还不支持 WebGPU/div;这是合理的第一步不过它只回答“浏览器有没有 API”并不保证设备一定能完成模型推理。真实接入时还应调用navigator.gpu.requestAdapter()并处理拿不到 adapter 或设备创建失败的情况。错误状态应该把这些失败呈现给用户而不是只停留在控制台。这里也有一个产品取舍如果应用目标必须依赖 GPU明确告知不支持是最诚实的方案如果希望覆盖更多设备可以把 WebGPU 作为优先后端再提供 WASM/CPU 作为性能较低的回退。是否回退取决于模型体积、内存占用和可接受的响应时间不是一条单纯的 API 判断。进度条为何要拆成组件src/components/Progress.tsx把一条下载项的展示抽成组件Progress key{index} text{text} percentage{percentage} total{total} /组件内部把百分比映射为宽度并将字节数格式化成用户可读的单位percentage ?? 0; div style{{ width: ${percentage}% }} classNamebg-blue-400 h-2 /这对应模型加载的真实形态一个“模型”通常不是一份孤立文件配置、分词器、权重分片和 ONNX 图都可能分别下载。父组件持有下载项数组子组件只按 props 渲染最适合表达这种一对多关系。后续对接运行库时进度回调应只更新父组件的数组。下面是与当前页面结构匹配的示意代码并非仓库已有实现type ProgressItem { text: string; percentage?: number; total?: number; }; function updateProgress(item: ProgressItem) { setProgressItems((previous) { const index previous.findIndex((entry) entry.text item.text); if (index -1) return [...previous, item]; return previous.map((entry, currentIndex) currentIndex index ? { ...entry, ...item } : entry, ); }); }注意这里使用函数式更新。下载事件可能连续到达下一次数组取决于前一次数组时用setProgressItems(previous ...)能避免闭包拿到旧值。渲染列表的key也建议使用稳定的文件名或路径而不是数组下标下载项顺序变化时稳定 key 才能让 React 正确复用节点。受控输入与发送时机底部输入框采用受控组件textarea value{inputValue} disabled{status ! ready} onInput{(event) { const target event.target as HTMLTextAreaElement; setInputValue(target.value); }} onKeyDown{(event) { if (inputValue.length 0 event.key Enter !event.shiftKey) { event.preventDefault(); onEnter(); } }} /它的行为很符合聊天产品普通 Enter 发送Shift Enter 保留换行。代码还在事件中通过类型断言把event.target视为HTMLTextAreaElement这样 TypeScript 才能安全读取value。当前onEnter只将状态设置为loading没有真正把输入交给模型。接上推理后建议把“模型正在下载”和“模型正在生成”拆开而不是共用一个模糊的loading。例如type ModelStatus idle | downloading | ready | generating | error;用联合类型代替字符串0可以让编辑器检查拼写也能让状态分支更易读。生成期间禁用重复发送按钮结束或失败后回到ready聊天体验会更完整。真正的端侧推理链路长什么样要让“Load Model”不再只是切换页面状态需要把页面状态与模型运行时连接起来。结合项目已有的模型链接最小闭环可以拆为六步检查 WebGPU 并请求 adapter/device失败时进入可见的错误或回退状态。首次点击后加载模型配置、分词器和 ONNX 权重利用运行库的进度回调更新progressItems。创建可复用的模型或pipeline实例完成后将状态设为ready。用户提交文本后将 prompt 经过 tokenizer 编码为 token。推理后端在浏览器的本机设备上逐步生成 token生成过程可持续刷新输出区。解码为文本并追加到会话缓存命中时后续访问可跳过或减少下载。“端侧”不等于模型文件永远不接触网络。首次加载仍需从模型托管服务获取资源除非它们已经在浏览器缓存中真正的边界是完成加载后推理提示词和生成计算可留在本机执行。生产应用仍需审视模型来源、资源完整性、缓存策略以及浏览器存储配额。当前工程还需要补的两处基础工作执行npm run build时当前项目会在 TypeScript 检查阶段停止。原因包括App.tsx中setError、setLoadingMessage、setProgressItems还没有被使用Progress.tsx的 props 没有类型并且该文件保留了未使用的zod导入。项目的tsconfig.app.json启用了strict、noUnusedLocals和noUnusedParameters所以这些教学阶段的占位代码会被严格检查出来。这是一个很好的提醒TypeScript 不是等所有功能做完才开启的装饰。把状态模型和组件 props 定义清楚能让“UI 正在等待什么、谁可以修改这个数据”在编译期就被约束。例如进度条可以先定义一个明确的契约type ProgressProps { text: string; percentage?: number; total?: number; }; function Progress({ text, percentage 0, total }: ProgressProps) { // render... }同时未接入的 setter 应暂时移除或在真正的模型加载、报错和回调逻辑接入后再使用。这样npm run build才能成为可信的交付检查。小结这个项目最值得学习的不是“已经在浏览器跑通 DeepSeek-R1”而是它为端侧 AI 建立了正确的前端分层用 WebGPU 能力检测决定入口体验。用 React 状态驱动加载、就绪、错误与交互禁用。用父组件数组和Progress子组件表达多个文件的下载反馈。用受控输入把键盘行为收束为可预测的状态更新。用 TypeScript 严格检查为真实运行时接入留出边界。模型运行时接入以后UI 层不需要推倒重来只需让运行库的下载、加载和生成事件进入现有的状态机。对学习端侧大模型应用而言这正是最可靠的起点先把用户能看见、能恢复、能理解的状态做对再把模型接进去。