在浏览器里运行 DeepSeek-R1:React 对话界面与安全渲染(三)

在浏览器里运行 DeepSeek-R1:React 对话界面与安全渲染(三)

前两篇已经完成模型加载与流式推理。最后一篇处理用户真正看到的部分:输入框、消息列表、思考过程、Markdown、数学公式、滚动行为和生成速度。

看起来只是"把字符串放到页面上",实际却有几个不能省略的问题:

  • 流式输出会高频更新,状态必须始终基于最新值;
  • 模型输出是 Markdown,不能不经处理直接注入 DOM;
  • 思考过程和最终回答共用一段文本,需要可靠的边界;
  • 新消息应自动滚到底部,但用户主动向上阅读时不能强行拉回;
  • 输入框应随内容增高,同时设置最大高度;
  • 生成中、已完成、未输入三种状态需要显示不同按钮。

一、先整理页面状态

聊天页面需要维护三组状态。

第一组是模型生命周期:

tsx 复制代码
const [status, setStatus] = useState(null);
const [error, setError] = useState(null);
const [loadingMessage, setLoadingMessage] = useState("");
const [progressItems, setProgressItems] = useState([]);
const [isRunning, setIsRunning] = useState(false);

第二组是输入与消息:

tsx 复制代码
const [input, setInput] = useState("");
const [messages, setMessages] = useState([]);

第三组是性能数据:

tsx 复制代码
const [tps, setTps] = useState(null);
const [numTokens, setNumTokens] = useState(null);

另外还要保存三个不会直接参与渲染的对象:

tsx 复制代码
const worker = useRef<Worker | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const chatContainerRef = useRef<HTMLDivElement | null>(null);

useState 保存"变化后需要重新渲染"的数据;useRef 保存 Worker 和 DOM 节点。按照这个标准划分以后,状态职责会很清楚。

二、一个统一的发送入口

页面既支持点击发送按钮,也支持按 Enter,还提供三条示例问题。它们都应该调用同一个函数:

tsx 复制代码
function onEnter(message: string) {
  setMessages((prev) => [
    ...prev,
    { role: "user", content: message },
  ]);

  setTps(null);
  setIsRunning(true);
  setInput("");
}

这个函数不关心消息来自哪里,只负责把用户消息加入历史、清空上一轮速度、进入生成状态并清空输入框。

示例问题直接复用它:

tsx 复制代码
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.",
];

{EXAMPLES.map((message) => (
  <div
    key={message}
    className="m-1 border rounded-md p-2 cursor-pointer"
    onClick={() => onEnter(message)}
  >
    {message}
  </div>
))}

三、输入框自动增高

单行输入框不适合长问题,固定高度的多行输入框又会浪费空间。这里让 textarea 随内容增长,但把高度限制在 24 到 200 像素之间。

tsx 复制代码
function resizeInput() {
  const target = textareaRef.current;
  if (!target) return;

  target.style.height = "auto";

  const newHeight = Math.min(
    Math.max(target.scrollHeight, 24),
    200,
  );

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

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

为什么要先设成 auto?如果只读取当前高度并不断增大,删除文字以后输入框可能缩不回去。先恢复自动高度,浏览器会重新计算 scrollHeight,再把它夹在最小值和最大值之间。

输入框本身是受控组件:

tsx 复制代码
<textarea
  ref={textareaRef}
  rows={1}
  value={input}
  disabled={status !== "ready"}
  title={status === "ready" ? "Model is ready" : "Model not loaded yet"}
  placeholder="Type your message..."
  onChange={(event) => setInput(event.currentTarget.value)}
  onKeyDown={(event) => {
    if (
      input.length > 0 &&
      !isRunning &&
      event.key === "Enter" &&
      !event.shiftKey
    ) {
      event.preventDefault();
      onEnter(input);
    }
  }}
/>

按键逻辑表达了四个条件:有内容、当前没有生成、按下 Enter、没有同时按 Shift。普通 Enter 发送,Shift + Enter 保留换行能力。

这里使用 event.currentTarget.value,因为 currentTarget 明确指向绑定监听器的 textarea,TypeScript 能正确识别 valuetextarea 也不需要 type="text"------typeinput 的属性,不属于 textarea

四、发送、停止与禁用按钮的三态切换

输入区域右侧只占用一个按钮位置,但根据状态显示不同内容:

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

对应关系很直接:

text 复制代码
正在生成           -> 停止按钮
未生成且输入非空   -> 可点击发送按钮
未生成且输入为空   -> 灰色发送图标

图标组件都是很轻量的内联 SVG。它们展开接收到的 props,所以外部可以统一传 className,使用 currentColor 的描边也会跟随文字颜色:

tsx 复制代码
export default function ArrowRightIcon(props) {
  return (
    <svg
      {...props}
      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>
  );
}

用户图标、机器人图标、脑图标与停止图标也采用同一种组件方式,不需要额外的图标运行时。

五、消息组件如何区分用户与助手

聊天列表只负责遍历:

tsx 复制代码
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((message, index) => (
            <Message
              key={`message-${index}`}
              {...message}
            />
          ))
        )}
      </MathJaxContext>
    </div>
  );
}

Message 再根据 role 决定样式:用户消息使用蓝色气泡并按纯文本展示;助手消息使用灰色气泡,还要继续处理思考、Markdown 和公式。

tsx 复制代码
{role === "assistant" ? (
  <>
    <BotIcon className="h-6 w-6" />
    <div className="bg-gray-200 dark:bg-gray-700 rounded-lg p-4">
      {/* 思考过程与最终回答 */}
    </div>
  </>
) : (
  <>
    <UserIcon className="h-6 w-6" />
    <div className="bg-blue-500 text-white rounded-lg p-4">
      <p className="overflow-wrap-anywhere">{content}</p>
    </div>
  </>
)}

用户输入不需要经过 Markdown 解析。React 默认会转义 JSX 中的字符串,直接写 {content} 就能按文本显示。

六、折叠思考过程,只突出最终回答

上一篇记录了 answerIndex,展示层现在可以切分文本:

tsx 复制代码
const thinking =
  answerIndex !== undefined
    ? content.slice(0, answerIndex)
    : content;

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

在回答边界尚未出现时,所有内容都属于 thinking;边界出现后,前一段保持为思考内容,后一段作为最终回答。

每条助手消息拥有自己的折叠状态:

tsx 复制代码
const [showThinking, setShowThinking] = useState(false);
const doneThinking = answer.length > 0;

折叠按钮根据状态变化:

tsx 复制代码
<button
  onClick={() => setShowThinking((prev) => !prev)}
  className="flex items-center gap-2 p-4"
>
  <BrainIcon className={doneThinking ? "" : "animate-pulse"} />
  <span>{doneThinking ? "View reasoning." : "Thinking..."}</span>
  <span className="ml-auto">
    {showThinking ? "▲" : "▼"}
  </span>
</button>

生成过程中脑图标保持脉冲动画,完成后文案变成"查看推理"。用户可以展开或收起思考过程,最终回答则始终显示在外部。

当助手消息刚创建、还没有收到首段输出时,可以显示三个延迟不同的圆点:

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

对应的自定义延迟类只有两条:

css 复制代码
.animation-delay-200 {
  animation-delay: 200ms;
}

.animation-delay-400 {
  animation-delay: 400ms;
}

七、为什么 Markdown 不能直接塞进页面

模型输出使用 Markdown 很合适。标题、代码块、列表、表格、引用等结构都比 HTML 简洁,也能节省输出 token。但浏览器最终需要的是 HTML,因此要经过 Markdown 解析。

tsx 复制代码
import { marked } from "marked";

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

breaks: true 会把普通换行按换行效果处理,更适合聊天文本。async: false 让解析结果保持同步字符串。

随后页面通过 dangerouslySetInnerHTML 插入 HTML:

tsx 复制代码
<span
  className="markdown"
  dangerouslySetInnerHTML={{ __html: html }}
/>

问题也正出在这里:HTML 不再经过 React 的默认文本转义。模型输出不能被当成可信 HTML,所以解析以后必须先净化。

tsx 复制代码
import DOMPurify from "dompurify";

const safeHtml = DOMPurify.sanitize(html);

完整的渲染函数是:

tsx 复制代码
function render(text: string) {
  // 保护数学公式定界符,避免 Markdown 解析阶段吞掉反斜杠
  text = text.replace(/\\([\[\]\(\)])/g, "\\\\$1");

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

顺序不能反:先把 Markdown 转为 HTML,再对最终 HTML 做净化,最后才注入页面。

text 复制代码
模型 Markdown
  -> 处理公式定界符
  -> marked.parse
  -> DOMPurify.sanitize
  -> dangerouslySetInnerHTML

dangerouslySetInnerHTML 的名字就是在提醒开发者:只要内容来自外部,就必须在进入 DOM 前建立明确的安全边界。

八、用 MathJax 渲染数学公式

Markdown 负责文本结构,MathJax 负责数学公式。聊天列表外层放置 MathJaxContext

tsx 复制代码
<MathJaxContext>
  {messages.map((message, index) => (
    <Message key={index} {...message} />
  ))}
</MathJaxContext>

思考过程与最终答案分别放进 MathJax

tsx 复制代码
{showThinking && (
  <MathJax dynamic>
    <span
      className="markdown"
      dangerouslySetInnerHTML={{
        __html: render(thinking),
      }}
    />
  </MathJax>
)}

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

dynamic 很重要,因为助手文本不是一次性出现,而是在持续追加。内容变化后,公式也需要重新处理。

思考和回答分别渲染还有一个好处:折叠思考区域不会影响最终答案的 DOM 结构。

九、给 Markdown 设置局部样式

模型输出会产生 precodeh1h6uloltable 等标签。如果只依赖页面全局样式,聊天内容很容易和应用其他区域互相影响。

所有生成内容都有 .markdown 类,因此可以使用 CSS 作用域:

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;
  }

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

  pre > code {
    padding: 0;
  }
}

行内代码需要自己的内边距,而代码块已经由 pre 提供整体内边距,所以 pre > code 再把子元素内边距清零。

标题、列表和表格也都只在 .markdown 内生效:

css 复制代码
@scope (.markdown) {
  h1,
  h2,
  h3,
  h4,
  h5,
  h6 {
    font-weight: 600;
    line-height: 1.2;
  }

  ul {
    list-style-type: disc;
    margin-left: 1.5rem;
  }

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

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

暗色模式则使用 prefers-color-scheme 调整代码与表格:

css 复制代码
@media (prefers-color-scheme: dark) {
  pre,
  code {
    background-color: #333;
  }

  table,
  th,
  td {
    border-color: #f2f2f2;
  }
}

十、自动滚动,但尊重用户阅读位置

最简单的流式聊天页面会在每个新片段到达时强制滚到底部。这在用户正在看最新回答时很好,但如果用户主动向上翻阅旧内容,页面仍不断把他拉回底部,体验会非常差。

解决方法是设置一个"贴底阈值":

tsx 复制代码
const STICKY_SCROLL_THRESHOLD = 120;

每次消息变化时,计算用户距离底部还有多少像素:

tsx 复制代码
useEffect(() => {
  if (!chatContainerRef.current || !isRunning) return;

  const element = chatContainerRef.current;
  const distanceToBottom =
    element.scrollHeight -
    element.scrollTop -
    element.clientHeight;

  if (distanceToBottom < STICKY_SCROLL_THRESHOLD) {
    element.scrollTop = element.scrollHeight;
  }
}, [messages, isRunning]);

三个尺寸的含义是:

  • scrollHeight:全部滚动内容的高度;
  • scrollTop:已经向上滚过的距离;
  • clientHeight:容器当前可视高度。

只有距离底部不到 120 像素时,才继续自动贴底。用户已经向上滚动较远时,保持当前位置不动。

十一、生成速度与 Reset

Worker 的每次 update 都带回 tpsnumTokens。生成中可以只显示实时速度:

tsx 复制代码
<span>{tps.toFixed(2)} tokens/second</span>

生成结束后再展示完整信息和 Reset:

tsx 复制代码
{!isRunning && (
  <>
    <span>
      Generated {numTokens} tokens in
      {(numTokens / tps).toFixed(2)} seconds.
    </span>
    <button
      className="underline"
      onClick={() => {
        worker.current?.postMessage({ type: "reset" });
        setMessages([]);
      }}
    >
      Reset
    </button>
  </>
)}

Reset 同时通知 Worker 清理推理侧状态,并清空 React 的可见对话。它只在生成结束后出现,避免清空界面时后台仍在输出。

页面底部还保留一条必要提示:模型生成内容可能不准确。这不是技术逻辑,却是生成式应用界面不可缺少的状态说明。

十二、入口、构建和静态检查

HTML 只提供挂载节点和模块入口:

html 复制代码
<body>
  <div id="root"></div>
  <script type="module" src="/src/main.tsx"></script>
</body>

React 入口也保持最小:

tsx 复制代码
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";

createRoot(document.getElementById("root")!).render(<App />);

Vite 同时启用 React 与 Tailwind CSS 插件:

ts 复制代码
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [react(), tailwindcss()],
});

TypeScript 使用项目引用把浏览器应用与 Vite 配置分开:

json 复制代码
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}

应用侧需要 DOMvite/client@webgpu/types;Vite 配置运行在 Node 环境,使用 ES2023node 类型。两边都采用 bundler 模块解析并设置 noEmit,实际产物仍交给 Vite 生成。

ESLint 也要区分 React TypeScript 与 Worker JavaScript。Worker 中的 selfnavigator 等全局变量来自不同运行环境,因此为 src/**/*.js 同时声明浏览器和 Worker globals:

js 复制代码
export default defineConfig([
  globalIgnores(["dist"]),
  {
    files: ["**/*.{ts,tsx}"],
    extends: [
      js.configs.recommended,
      tseslint.configs.recommended,
      reactHooks.configs.flat.recommended,
      reactRefresh.configs.vite,
    ],
    languageOptions: {
      globals: globals.browser,
    },
  },
  {
    files: ["src/**/*.js"],
    extends: [js.configs.recommended],
    languageOptions: {
      globals: {
        ...globals.browser,
        ...globals.worker,
      },
    },
  },
]);

全局样式首先引入 Tailwind:

css 复制代码
@import "tailwindcss";

聊天容器使用了自定义细滚动条。轨道、滑块和悬停颜色都集中在 .scrollbar-thin 下:

css 复制代码
.scrollbar-thin::-webkit-scrollbar {
  width: 0.5rem;
}

.scrollbar-thin::-webkit-scrollbar-track {
  border-radius: 9999px;
  background-color: #f3f4f6;
}

.scrollbar-thin::-webkit-scrollbar-thumb {
  border-radius: 9999px;
  background-color: #d1d5db;
}

.scrollbar-thin::-webkit-scrollbar-thumb:hover {
  background-color: #6b7280;
}

这一实现没有启用 React Compiler,当前聊天、流式更新和 Worker 通信也不依赖它。ESLint 目前使用 typescript-eslint 的 recommended 配置;如果以后切换到需要类型信息的规则,还需要为解析器补充应用与 Node 两套 TypeScript 项目路径。当前文章只按实际启用的规则分析,不把尚未启用的配置写成现有能力。

构建脚本先执行 TypeScript 项目构建,再交给 Vite 打包:

json 复制代码
{
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  }
}

最终检查时尤其注意这些与界面代码直接相关的细节:

  1. textarea 不要添加不存在的 type 属性;
  2. 读取输入值时用 event.currentTarget.value,保持 DOM 类型明确;
  3. resizeInput 可放在引用它的 Effect 之前,满足更严格的 Hooks 静态规则;
  4. 已经在 onEnter 清空 tps 后,不必在发送消息的 Effect 中再次同步调用 setTps
  5. 如果启用未使用变量检查,只解构真正参与判断的 END_THINKING_TOKEN_ID
  6. KV Cache 传参仍被注释时,不要把它描述成已经启用的优化。

这些调整不会改变页面设计,却能让类型检查、静态检查与实际运行行为保持一致。

十三、系列总结

至此,一个浏览器本地大模型应用的完整链路就闭环了:

text 复制代码
WebGPU 能力检测
  -> Worker 中单例加载模型
  -> 多文件进度与模型预热
  -> 聊天模板和 WebGPU 推理
  -> TextStreamer 增量输出
  -> 思考/回答边界记录
  -> React 不可变状态更新
  -> Markdown 净化与公式渲染
  -> 停止、速度、滚动和重置交互

这套实现最值得借鉴的并不是某一个组件,而是边界设计:主线程与 Worker 用消息协议隔离,模型与分词器用单例隔离昂贵初始化,思考与答案用索引隔离,Markdown 与真实 DOM 用 DOMPurify 隔离。

当这些边界都清楚以后,"在浏览器里跑一个大模型"就不再是一团混杂的异步代码,而是一条可以逐段验证、逐段维护的工程流程。

相关推荐
深圳市益普科技有限公司1 小时前
先进封装爆发,封测厂的数字化准备好了吗?
人工智能
Synmbrf1 小时前
micro-app 404 问题排查与修复
前端
晴天161 小时前
LLM 与推理模型:从“快思考“到“慢思考“的范式跃迁-Day27
人工智能·深度学习
西安栈上月明软件科技有限公司1 小时前
GEO 友好度检测器技术实现(已开源)
前端
charles_he1 小时前
Agent风险等于权限乘以自动化倍率
人工智能
汉堡大王95271 小时前
用 Trae Work 自动化任务,6 分钟生成一份前端生态周报
前端·javascript·人工智能
深圳佛手1 小时前
安装DeepSeek Harness npx @deepseek-ai/dsh web 长时间没反应,安装失败,如何解决?
前端
南京云森杉木桩1 小时前
常见打桩木品牌推荐,选对材质让工程更稳固
人工智能·python·材质