← 返回文章列表
017 — 前端 · #React #状态机 #前端架构 #产品化 · 2026-08-18 · 7 MIN READ

给 AI 聊天窗写状态机:让它像个产品,而不是一个 demo

网上「给网站加个 AI 聊天窗」的教程很多,模式基本一样:一个输入框、一个 fetch、把回答塞进 div。调通就算完成。

我照着这个思路写完第一版,然后自己用了十分钟就受不了了:回答到一半关掉窗口再打开,还在流式的那条没了;狂点发送键,请求雨一样发出去;刷新页面,对话历史清零。这些不是 bug,是「没写」。把聊天窗从 demo 变成产品,差的就是这一层状态管理。这篇讲我是怎么补的,源码在 blog-frontend/src/components/chat/

五状态机

聊天窗的全部行为收敛到一个状态字段上:

ts
type ChatStatus = "collapsed" | "expanded" | "waiting" | "streaming" | "idle";
  • collapsed:收起,只留一个悬浮球入口
  • expanded:展开但还没提问,显示欢迎语和推荐问题
  • waiting:已发请求,还没收到第一个 token(转圈)
  • streaming:正在逐字流出
  • idle:展开,有一轮或多轮完成的对话

状态机最值钱的地方不是枚举状态,而是由状态推导 UI

ts
const busy = status === "waiting" || status === "streaming";

busy 一个布尔值同时控制:输入框禁用、发送按钮禁用、doSend 入口直接 return。「流式途中不能再发一条」这种规则,不靠用户自觉,靠 UI 物理上不允许。写 demo 时这类约束散落在各个 onClick 里,写着写着就漏一处;收成状态机之后,约束只有一处定义。

前端限流:10 次/分钟

后端有限流(单 IP 10 次/分钟,反代坑单独写过),但让后端拒绝用户是下策——网络往返白跑一趟,错误处理还多一条路径。所以前端自己也拦一道:

ts
const now = Date.now();
sendTimes.current = sendTimes.current.filter((t) => now - t < 60000);
if (sendTimes.current.length >= RATE_LIMIT) {  // 10
  push({ role: "user", text: q });
  push({ role: "ai", text: "休息一下再问 ☕(每分钟最多 10 次提问)" });
  return;
}

注意一个细节:触发限流时,用户的提问照常进消息列表,AI 回一句拟人的提示。而不是弹个 toast 把问题吞掉——用户打的一段字不该因为限流而消失。这是个很小的选择,但 demo 和产品就差在这种地方。

连续失败 3 次,冷却 10 分钟

后端挂了的场景:DeepSeek Key 欠费、服务器 OOM、网络不通。此时前端如果每次提问都老实发请求,用户每次都要等满 15 秒首字超时才能看到降级文案,体验是「这个站坏得很慢」。

解法是给降级加状态,而不只是加文案:

ts
onFail: () => {
  failCount.current += 1;
  if (failCount.current >= 3) {            // 连续 3 次失败
    degradedUntil.current = Date.now() + 10 * 60 * 1000;  // 冷却 10 分钟
  }
  push({ role: "ai", text: DEGRADED_TEXT, degraded: true });
  setStatus("idle");
},

进入冷却期后,doSend 里直接短路——根本不调后端,350 毫秒后就给出降级文案:

ts
if (now < degradedUntil.current) {
  window.setTimeout(() => {
    push({ role: "ai", text: DEGRADED_TEXT, degraded: true });
    setStatus("idle");
  }, 350);
  return;
}

这个「350 毫秒后假装回答」看起来滑稽,但它是对的:已知后端在冷却期,立刻告诉用户,别演一场注定失败的请求。冷却期结束自动恢复,一次成功回答会把 failCount 清零。

localStorage:刷新之后对话还在

会话历史和 conversation_id 存在 localStorage 的 kalpa-chat 键下,只留最近 20 条:

ts
const KEY = "kalpa-chat";
const MAX_HISTORY = 20;
// 每次 push 时:messages.slice(-MAX_HISTORY) 再写回

封顶 20 条是权衡:存太多,一是 localStorage 有 5MB 上限,二是每次请求把历史发给后端,token 成本线性涨。发给后端的历史还要再加工——buildHistory 把消息流水配成 {question, answer} 对,只取最近 5 对,不成对的(比如降级提示)直接丢掉。后端拿到的永远是一份干净的问答史,不用处理「这条 AI 消息其实是错误提示」这种脏数据。

另外两个小防御:读取时 JSON 解析失败静默丢弃(用户手动改坏了也不至于白屏);写入包 try/catch(隐私模式下 localStorage 可能写不进去)。

入口:一颗沿轨道绕行的小球

聊天窗收起来时,入口不是钉死在右下角的圆形按钮,而是一颗沿椭圆轨道绕页面中心缓慢运行的小球——和品牌 Logo 上的轨道环同一套参数(rx:ry ≈ 2.4:1,倾斜 -15°,约 26 秒一圈)。鼠标悬停会暂停,点了展开聊天窗。

技术上没什么:一个 requestAnimationFrame 循环,按角度算椭圆坐标再旋转,写进 transform。但有两个产品细节:

  • 远端弧时小球缩小加变透明,模拟「绕到页面后面」的深度感,不然看起来像贴在玻璃上的贴纸。
  • prefers-reduced-motion 命中时退回固定右下角。动效是糖,不是每个人都想吃。

这个入口的取舍是「牺牲一点发现成本,换一点不打扰」。聊天窗不是这个博客的主角,文章才是;入口有点性格,但不抢戏。

复盘

回看这些代码,没有一行是难的。状态机是五个字符串,限流是一个时间戳数组,持久化是一个 JSON.stringify。难的是承认「调通接口」只完成了 30%——剩下的 70% 全在想「用户捣乱的十种方式」和「后端挂了的三种姿势」。

这大概就是 demo 和产品的分界线:demo 演示「它能做什么」,产品处理「它会遇到什么」。前者一个下午,后者没有尽头——但每补一条,你就少一件要在深夜里被用户反馈叫醒的事。

Comments