给 AI 聊天窗写状态机:让它像个产品,而不是一个 demo
网上「给网站加个 AI 聊天窗」的教程很多,模式基本一样:一个输入框、一个 fetch、把回答塞进 div。调通就算完成。
我照着这个思路写完第一版,然后自己用了十分钟就受不了了:回答到一半关掉窗口再打开,还在流式的那条没了;狂点发送键,请求雨一样发出去;刷新页面,对话历史清零。这些不是 bug,是「没写」。把聊天窗从 demo 变成产品,差的就是这一层状态管理。这篇讲我是怎么补的,源码在 blog-frontend/src/components/chat/。
五状态机
聊天窗的全部行为收敛到一个状态字段上:
type ChatStatus = "collapsed" | "expanded" | "waiting" | "streaming" | "idle";
- collapsed:收起,只留一个悬浮球入口
- expanded:展开但还没提问,显示欢迎语和推荐问题
- waiting:已发请求,还没收到第一个 token(转圈)
- streaming:正在逐字流出
- idle:展开,有一轮或多轮完成的对话
状态机最值钱的地方不是枚举状态,而是由状态推导 UI:
const busy = status === "waiting" || status === "streaming";
busy 一个布尔值同时控制:输入框禁用、发送按钮禁用、doSend 入口直接 return。「流式途中不能再发一条」这种规则,不靠用户自觉,靠 UI 物理上不允许。写 demo 时这类约束散落在各个 onClick 里,写着写着就漏一处;收成状态机之后,约束只有一处定义。
前端限流:10 次/分钟
后端有限流(单 IP 10 次/分钟,反代坑单独写过),但让后端拒绝用户是下策——网络往返白跑一趟,错误处理还多一条路径。所以前端自己也拦一道:
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 秒首字超时才能看到降级文案,体验是「这个站坏得很慢」。
解法是给降级加状态,而不只是加文案:
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 毫秒后就给出降级文案:
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 条:
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 演示「它能做什么」,产品处理「它会遇到什么」。前者一个下午,后者没有尽头——但每补一条,你就少一件要在深夜里被用户反馈叫醒的事。