背景与痛点
在浏览器里塞进一个“随时待命”的 AI 助手,听起来只是把 ChatGPT 塞进侧边栏,但真动手就会发现:
- 用户一句话可能触发多轮追问,历史记录要随叫随到,还要保证新消息插进来不闪屏
- 网络抖动、浏览器休眠、标签页切换都会让长连接断得悄无声息,用户却要求“秒回”
- 侧边栏面积有限,不能做全页刷新,又得在 DOM 里塞下可能上万 token 的上下文,内存和渲染都得精打细算
一句话:既要“记得住”,又要“回得快”,还得“长得瘦”。
技术选型对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| WebSocket 全双工 | 低延迟、服务器主动推送 | 需自己处理重连、心跳、Backpressure | 高频、低延迟对话 |
| SSE(EventSource) | 基于 HTTP,自动重连,同域 Cookie 自带 | 仅服务端→客户端单向,需额外接口发用户输入 | 流式回答、只读推送 |
| 长轮询(Comet) | 协议简单,兼容远古代理 | 每次都要 重新握手,Header 浪费大 | 低频问答、旧系统兼容 |
| gRPC-Web / HTTP2 | 多路复用、头部压缩 | 需要网关、浏览器兼容细节多 | 对二进制、微服务内部 |
ChatGPT Sidebar 最终采用“WebSocket + 轻量 SSE 降级”双轨策略:
- 主轨 WebSocket 负责双向信令与流式回答帧
- 当企业代理掐掉 Upgrade 头时,100 ms 内自动降级到 SSE,保证“至少能用”
核心实现细节
1. 整体架构
┌-------- Browser ---------┐ ┌-------- Edge ---------┐ │ Sidebar (React) │◆───│ WSS 反向代理 │ │ ├─ ContextStore │ │ ├─ SessionMgr │ │ ├─ StreamRenderer │ │ ├─ LLM Gateway │ │ └─ ReconnectHub │ │ └─ RateLimiter │ └--------------------------┘ └-----------------------┘- ContextStore:Indexed 结构,按
conversationId → message[]存储,只保留最近 4 k token,溢出部分做摘要缓存 - StreamRenderer:虚拟滚动 + diff 补丁,增量插入 token,不整句替换,避免 React 反复 reconcile
- ReconnectHub:指数退避重连,重连成功后把本地未上链消息
clientSeq带上,服务端去重做幂等校验
2. 数据流(一次用户输入的生命周期)
- 用户按下 Enter
- Sidebar 把输入 push 进本地队列,立刻渲染“正在输入”占位,拿到本地 seq
- WebSocket 发送
{"type":"chat","cid":"c123","seq":42,"text":"xxx"} - 服务端返回
{"type":"ack","seq":42,"srv_time":...},客户端移除占位并校准时间戳 - 服务端流式下发
{"type":"delta","delta":"Hel","finish_reason":null},Sidebar 按 SSE 格式 parse,逐 token 插入 - 收到
finish_reason=stop后,把完整消息写回 ContextStore,触发本地压缩摘要
3. 状态管理(以 React 为例)
- 全局只维护一个
useContext(ConversationCtx),避免 Props Drilling - 消息数组用
useRef持有,渲染层用useSyncExternalStore订阅,减少 setState 频率 - 对每条消息生成
contentHash,React key 用messageId而非数组下标,防止并发插入错位
代码示例(React 18 + TypeScript)
以下示例裁剪掉样式,突出“上下文管理 + 实时流式渲染”两条主线,可直接粘进 Vite 项目跑通。
// src/hooks/useChat.ts import { useCallback, useEffect, useRef, useState } from 'react'; interface Message { id: string; role: 'user' | 'assistant'; content: string; timestamp: number; } export function useChat(convId: string) decoded by https://weilai.netlify.app { const [msgs, setMsgs] = useState<Message[]>([]); const wsRef = useRef<WebSocket | null>(null); const ackMap = useRef<Record<number, string>>({}); // seq -> tempId // 1. 建立连接 & 监听 useEffect(() => { const ws = new WebSocket(`${import.meta.env.VITE_WSS}/chat/${convId}`); wsRef.current = ws; ws.onmessage = (e) => { const frame = JSON.parse(e.data); switch (frame.type) { case 'ack': // 服务端已确认,移除占位 setMsgs((prev) => prev.filter((m) => m.id !== ackMap.current[frame.seq])); delete ackMap.current[frame.seq]; break; case 'delta': // 流式插入 setMsgs((prev) => { const last = prev[prev.length - 1]; if (last && last.role === 'assistant' && !last.timestamp) { // 同一条未完成消息 return [...prev.slice(0, -1), { ...last, content: last.content + frame.delta }]; } // 新助手消息 return [...prev, { id: crypto.randomUUID(), role: 'assistant', content: frame.delta, timestamp: Date.now() }]; }); break; case 'done': // 标记时间戳,触发摘要 setMsgs((prev) => { const copy = [...prev]; const last = copy[copy.length - 1]; if (last) last.timestamp = frame.srv_time; return copy; }); break; } }; return () => ws.close(); }, [convId]); // 2. 发送函数 const send = useCallback(async (text: string) => { const seq = Date.now(); const tempId = `temp-${seq}`; // 本地快速渲染 setMsgs((m) => [...m, { id: tempId, role: 'user', content: text, timestamp: seq }]); ackMap.current[seq] = tempId; wsRef.current?.send(JSON.stringify({ type: 'chat', seq, text })); }, []); return { msgs, send }; }// src/components/Sidebar.tsx import { useChat } from '../hooks/useChat'; import { Virtuoso } from 'react-virtuoso'; export default function Sidebar() { const { msgs, send } = useChat('demo'); return ( <div className="w-80 h-screen flex flex-col"> <Virtuoso alignToBottom data={msgs} itemContent={(_, m) => ( <div className={`p-2 ${m.role === 'user' ? 'text-right' : 'text-left'}`}> <span className={m.role === 'user' ? 'bg-blue-500 text-white' : 'bg-gray-200'}> {m.content} </span> </div> )} /> <InputBox onSend={send} /> </div> ); }关键注释已写在代码里,逻辑概括:
- 用
seq做幂等,保证弱网重发不重复落库 - 用
tempId占位,用户侧零等待 - 用
Virtuoso做虚拟滚动,1 万条消息也不卡
性能与安全考量
- 传输压缩:WebSocket 开启
permessage-deflate,文本帧平均压缩率 55 % - 渲染节流:后端 30 ms 批量打包 delta,前端用
requestIdleCallback做空闲渲染,降低主线程阻塞 - 上下文裁剪:ConversationCtx 只维护最近 4 k token,溢出文本走 LLM 摘要接口,返回 100 字梗概,节省 90 % 上行流量
- 安全
- 服务端强制 wss + JWT,Cookie 置
SameSite=Strict - 输入做 128 长度截断 + RegExp 过滤,防止 Prompt Injection
- 返回流式帧同样过一遍敏感词库,命中即下发
{"type":"blocked"},前端自动折叠消息并提示
- 服务端强制 wss + JWT,Cookie 置
避坑指南
- WebSocket 断网后浏览器不会立刻触发
onclose,别依赖它做“在线”图标;用心跳 ping/pong,三秒无响应即判离线 - 流式渲染时,不要把每条 delta 都 setState,会触发 React 18 的并发调度风暴;用 ref 累加,再 16 ms 定时 flush
- 虚拟滚动库(react-window、Virtuoso)要求“定高”或“异步测高”,如果气泡高度随内容变化,一定开
itemSize动态测量,否则滚动条会跳 - 本地开发用 Vite 的 ws proxy,记得配
server.hmr.protocol = 'ws',否则握手时会被 Vite 抢占端口 3000,出现玄学 1006 断链
互动引导
读到这里,不妨把示例代码git clone下来,改两行提示词,就能拥有一个私有侧边栏 AI。
下一步可以试试:
- 把上下文摘要策略换成向量召回,看能否在 10 万条历史里秒级定位相关内容
- 用 WebCodecs 把 TTS 音频流直接喂给
<audio>,实现真正的“语音侧边栏” - 把 WebSocket 二进制帧改成 protobuf,流量再砍一半
如果希望跳过踩坑,直接体验一条龙的“耳朵→大脑→嘴巴”全链路,可以看看这个动手实验:
从0打造个人豆包实时通话AI
实验把 ASR、LLM、TTS 串成完整 Web 通话,代码全开源,我跟着跑了一遍,本地 30 分钟就能聊起来。对想快速落地实时语音交互的开发者,确实能省不少折腾时间。