前端实时聊天界面 ------ React + WebSocket 实战
这不是一个"调 API 显示结果"的前端教程。 我们要做的,是一个真正能 实时流式输出 、人机协作审批 、子 Agent 并行监控 的聊天界面。
目录
- [环境搭建:Vite + React + TypeScript](#环境搭建:Vite + React + TypeScript "#1-%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BAvite--react--typescript")
- [WebSocket vs SSE vs 轮询:为什么选 WebSocket?](#WebSocket vs SSE vs 轮询:为什么选 WebSocket? "#2-websocket-vs-sse-vs-%E8%BD%AE%E8%AF%A2%E4%B8%BA%E4%BB%80%E4%B9%88%E9%80%89-websocket")
- 协议设计:前后端怎么"说话"?
- [后端 WebSocket 服务器:流式输出 + 中断检测](#后端 WebSocket 服务器:流式输出 + 中断检测 "#4-%E5%90%8E%E7%AB%AF-websocket-%E6%9C%8D%E5%8A%A1%E5%99%A8%E6%B5%81%E5%BC%8F%E8%BE%93%E5%87%BA--%E4%B8%AD%E6%96%AD%E6%A3%80%E6%B5%8B")
- [前端 Hook:把 WebSocket 封装成 React 状态](#前端 Hook:把 WebSocket 封装成 React 状态 "#5-%E5%89%8D%E7%AB%AF-hook%E6%8A%8A-websocket-%E5%B0%81%E8%A3%85%E6%88%90-react-%E7%8A%B6%E6%80%81")
- [UI 组件:聊天界面 + 审批弹窗 + 子 Agent 监控](#UI 组件:聊天界面 + 审批弹窗 + 子 Agent 监控 "#6-ui-%E7%BB%84%E4%BB%B6%E8%81%8A%E5%A4%A9%E7%95%8C%E9%9D%A2--%E5%AE%A1%E6%89%B9%E5%BC%B9%E7%AA%97--%E5%AD%90-agent-%E7%9B%91%E6%8E%A7")
- [踩坑实录:14 个真实问题](#踩坑实录:14 个真实问题 "#7-%E8%B8%A9%E5%9D%91%E5%AE%9E%E5%BD%9514-%E4%B8%AA%E7%9C%9F%E5%AE%9E%E9%97%AE%E9%A2%98")
- 生产环境建议:上线前还要做什么?
- 回顾与展望
1. 环境搭建:Vite + React + TypeScript
1.1 项目结构
text
lingshi/
├── src/
│ ├── server.ts # WebSocket 后端服务器
│ └── agents.ts # Agent 定义(前几篇已实现)
├── frontend/
│ ├── src/
│ │ ├── main.tsx # React 入口
│ │ ├── App.tsx # 根组件
│ │ ├── types.ts # 前后端共享类型
│ │ ├── hooks/
│ │ │ └── useWebSocket.ts # WebSocket 连接管理 Hook
│ │ └── components/
│ │ ├── ChatInterface.tsx # 聊天主界面
│ │ ├── ApprovalModal.tsx # 审批弹窗
│ │ ├── ToolCallViewer.tsx # 工具调用可视化
│ │ ├── SubagentMonitor.tsx # 子 Agent 监控面板
│ │ └── icons.tsx # SVG 图标
│ ├── vite.config.ts # Vite 配置(含 WebSocket 代理)
│ └── package.json
├── package.json # monorepo 根配置
└── pnpm-workspace.yaml # pnpm workspace 配置
1.2 关键依赖
| 包 | 作用 |
|---|---|
react + react-dom |
UI 框架(React 19) |
vite |
构建工具,开发服务器自带 HMR |
@vitejs/plugin-react |
Vite 的 React 支持 |
ws |
Node.js WebSocket 库(后端) |
typescript |
类型安全 |
1.3 Vite 配置:WebSocket 代理
开发时前端跑在 localhost:5173,后端跑在 localhost:3001,跨域怎么办?Vite 的 proxy 把 /ws 请求代理到后端:
typescript
// vite.config.ts
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/ws': {
target: 'ws://localhost:3001',
ws: true, // 关键:启用 WebSocket 代理
},
},
},
});
踩坑记录 :
pnpm-workspace.yaml必须包含packages: ['frontend'],否则pnpm install不会为 frontend 安装依赖。
2. WebSocket vs SSE vs 轮询:为什么选 WebSocket?
在写代码之前,先搞清楚一个核心问题:前后端怎么通信?
实时通信有三种方案,先看对比:
| 特性 | HTTP 轮询 | SSE(Server-Sent Events) | WebSocket(本项目 ✅) |
|---|---|---|---|
| 通信方向 | 客户端 → 服务端(单向) | 服务端 → 客户端(单向) | 全双工 |
| 延迟 | 高(取决于轮询间隔) | 低 | 最低 |
| 协议开销 | 大(每次带完整 HTTP 头) | 小 | 小 |
| 自动重连 | 需自己实现 | ✅ 内置 | 需自己实现 |
| 适合场景 | 简单数据查询 | 单向推送(通知、日志) | 双向交互 |
为什么 SSE 不够?
我们的场景需要双向通信:
text
前端 → 后端:发送聊天消息、提交审批决策
后端 → 前端:推送流式文本、工具调用、中断请求
SSE 只能服务端 → 客户端单向推送。审批决策(前端 → 后端)没法通过 SSE 发回去,得额外开一个 HTTP POST 接口------能跑,但割裂。
WebSocket:双向高速公路
text
┌──────────┐ ←── 全双工 ──→ ┌──────────┐
│ 前端 │ │ 后端 │
│ React │ chat / decision │ Agent │
│ │ ──────────────→ │ Server │
│ │ ←────────────── │ │
│ │ text / thinking │ │
│ │ tool_call / │ │
│ │ interrupt / done│ │
└──────────┘ └──────────┘
一条连接,双向通行,完美匹配 Agent 交互场景。
但 WebSocket 是"原始管道"------不像 HTTP 有 REST 规范,消息格式完全由我们自己定义。接下来设计协议:前后端怎么"说话"?
3. 协议设计:前后端怎么"说话"?
WebSocket 是"原始管道"------不像 HTTP 有 REST 规范,消息格式完全由我们自己定义。WebSocket 就像一根电话线------线已经拉好了,但说什么语言、怎么打招呼、怎么结束对话,都得我们自己定。
这套协议就是 ACP(Agent Client Protocol) 的简化实现。代码在 frontend/src/types.ts 中。
3.1 前端 → 后端:两种消息
typescript
/** 用户发送聊天消息 */
interface ChatMessage {
type: 'chat';
message: string; // 用户输入的内容
threadId: string; // 会话 ID(同一个 threadId = 同一轮对话上下文)
}
/** 用户对中断请求做出审批决策 */
interface DecisionMessage {
type: 'decision';
threadId: string;
data: Decision[]; // 审批决策数组
}
// 决策类型:批准 / 拒绝 / 编辑参数后执行
type Decision =
| { type: 'approve' }
| { type: 'reject'; message?: string }
| { type: 'edit'; editedAction: { name: string; args: Record<string, unknown> } };
3.2 后端 → 前端:七种事件
typescript
type ServerEvent =
| { type: 'text'; content: string } // AI 文本输出
| { type: 'thinking'; content: string } // AI 思考过程
// Agent 决定调用某个工具------name 是工具名,args 是参数,id 用于和 tool_result 配对
| { type: 'tool_call'; name: string; args: Record<string, unknown>; id: string }
// 工具执行完毕------id 对应上面的 tool_call.id,result 是执行结果(字符串)
| { type: 'tool_result'; name: string; result: string; id: string }
// Agent 触发了需要人工审批的操作------actionRequests 列出待审批动作,reviewConfigs 定义允许的决策类型
| { type: 'interrupt'; actionRequests: ActionRequest[]; reviewConfigs: ReviewConfig[] }
| { type: 'done' } // 本轮完成
| { type: 'error'; message: string }; // 错误
3.3 时序图:一次完整的对话
text
前端 后端
│ │
│── { type: 'chat', message } ──→ │
│ │ Agent 开始执行
│ ←── { type: 'thinking' } ───── │ "让我想想..."
│ ←── { type: 'text' } ───────── │ "我来帮你计算"
│ ←── { type: 'tool_call' } ──── │ 调用 calculator 工具
│ ←── { type: 'tool_result' } ── │ 工具返回结果
│ ←── { type: 'text' } ───────── │ "结果是 6016"
│ ←── { type: 'done' } ───────── │ 本轮结束
│ │
│── { type: 'chat', message } ──→ │ 新一轮对话
│ │ Agent 调用 execute 工具
│ ←── { type: 'interrupt' } ──── │ ⚠️ 需要审批!
│ │
│ 用户点击"批准" │
│── { type: 'decision' } ───────→ │ 恢复执行
│ ←── { type: 'tool_result' } ── │ 工具执行完毕
│ ←── { type: 'text' } ───────── │ "命令执行成功"
│ ←── { type: 'done' } ───────── │
核心公式 = 2 种上行消息 + 7 种下行事件,这就是全部协议。
协议定好了,接下来实现后端------把 Agent 的流式输出和中断检测通过 WebSocket 推给前端。
4. 后端 WebSocket 服务器:流式输出 + 中断检测
代码在 src/server.ts 中。这是整个前端系统的"引擎"。
4.1 HTTP + WebSocket 服务器骨架
typescript
import { createServer } from 'node:http';
import { WebSocketServer, WebSocket } from 'ws';
const PORT = Number(process.env.PORT) || 3001;
const server = createServer((req, res) => {
// 健康检查接口
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return;
}
res.writeHead(404);
res.end();
});
const wss = new WebSocketServer({ server });
const clients = new Set<WebSocket>(); // 跟踪所有连接
wss.on('connection', (ws) => {
clients.add(ws);
ws.on('close', () => clients.delete(ws));
ws.on('message', async (data) => {
const msg = JSON.parse(data.toString());
await handleMessage(ws, msg);
});
});
server.listen(PORT, () => {
console.log(`🔌 WebSocket: ws://localhost:${PORT}`);
});
4.2 流式输出:agent.stream() 而非 agent.invoke()
这是整个后端最核心的选择。
| 方法 | 行为 | 用户体验 |
|---|---|---|
agent.invoke() |
等 Agent 完全执行完毕才返回 | 用户干等 10 秒,内容一次性出现 |
agent.stream() |
按节点逐步返回中间结果 | 逐段输出,用户看到"正在思考" |
agent.streamEvents() |
token 级别流式(最细粒度) | 逐字输出,但中断检测不可靠 |
我们选了 agent.stream({ streamMode: 'messages' })------粒度适中,中断检测可靠。
typescript
const stream = await agent.stream(
{ messages: [{ role: 'user', content: message }] },
{ ...config, streamMode: 'messages' },
);
for await (const [msg, metadata] of stream) {
// msg 是一条 LangChain 消息(AIMessage / ToolMessage 等)
if (msg._getType?.() === 'ai') {
// 处理 AI 输出:文本、thinking、工具调用
if (typeof msg.content === 'string') {
send(ws, { type: 'text', content: msg.content });
} else if (Array.isArray(msg.content)) {
for (const block of msg.content) {
if (block.type === 'thinking') send(ws, { type: 'thinking', content: block.thinking });
if (block.type === 'text') send(ws, { type: 'text', content: block.text });
if (block.type === 'tool_use') send(ws, { type: 'tool_call', ... });
}
}
}
if (msg._getType?.() === 'tool') {
send(ws, { type: 'tool_result', name: msg.name, result: msg.content, id: msg.tool_call_id });
}
}
4.3 中断检测:从 stream 事件中提取 __interrupt__
踩坑记录 :最初用
agent.getState(config)检查isInterrupted,但deepagents中间件导致中断状态不可靠------isInterrupted始终返回false。
解决方案:不依赖 getState ,直接从 stream 的最后一个事件中提取 __interrupt__ 节点:
typescript
function extractInterruptFromStream(event: Record<string, any>) {
const interruptNode = event['__interrupt__'];
if (!Array.isArray(interruptNode) || interruptNode.length === 0) return null;
const hitlRequest = interruptNode[0]?.value as HITLRequest;
return {
actionRequests: hitlRequest?.actionRequests || [],
reviewConfigs: hitlRequest?.reviewConfigs || [],
};
}
4.4 审批恢复:Command({ resume }) + 消息去重
用户审批后,用 Command({ resume: decisions }) 恢复 Agent。但这里有个坑:
踩坑记录 :LangGraph 从中断恢复时会重放被中断的节点,导致同一条 AI 消息被重复发送。
解决方案:维护 sentMessageIds 集合,通过消息 ID 去重:
typescript
const sentMessageIds = new Set<string>();
// 处理每条消息前检查
if (msgId && sentMessageIds.has(msgId)) continue; // 跳过已发送的
if (msgId) sentMessageIds.add(msgId);
4.5 decision 消息队列:解决监听器竞争
这个坑值得单独展开,因为它是一个经典的 WebSocket 监听器竞争问题。
❌ 反面例子:两个监听器抢同一条消息
最初的设计是这样的------waitForDecision() 内部用 ws.once('message') 等待 decision:
typescript
// ❌ 有 bug 的设计
ws.on('message', async (data) => {
// 外层监听器:处理所有消息(包括 decision)
const msg = JSON.parse(data.toString());
await handleMessage(ws, msg);
});
async function handleMessage(ws, msg) {
if (msg.type === 'chat') {
// ... 流式输出 ...
// 检测到中断,发送 interrupt 给前端
send(ws, { type: 'interrupt', ... });
// 等待前端发来 decision
const decisions = await waitForDecision(ws);
// ... 恢复 Agent ...
}
}
function waitForDecision(ws: WebSocket): Promise<any> {
return new Promise((resolve) => {
// ❌ 问题在这里:内层又注册了一个 message 监听器
ws.once('message', (data) => {
const msg = JSON.parse(data.toString());
resolve(msg.data);
});
});
}
出了什么问题?
当用户点击"批准",前端发来 { type: 'decision', data: [...] } 时,两个监听器同时收到这条消息:
text
前端发来 decision 消息
│
▼
┌─────────────────────────────────┐
│ ws.on('message') ← 外层监听器 │ ← 也收到了!
│ handleMessage() │
│ → msg.type === 'decision' │
│ → 走到 else 分支,打印 │
│ "⚠️ 收到孤立的 decision" │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ ws.once('message') ← 内层监听器 │ ← 也收到了!
│ → resolve(msg.data) │
└─────────────────────────────────┘
看起来两个都收到了,好像能跑?但问题是:
- 时序竞争 :如果外层
handleMessage还没执行到waitForDecision()(比如还在处理上一个 stream 事件),内层once监听器还没注册,decision 消息就已经被外层吃掉了------内层永远等不到。 - 多次中断错乱 :如果 Agent 连续触发两次中断,第二次
waitForDecision()注册的once可能吃到上一条 decision 的残留消息。 - 错误消息类型 :外层
handleMessage收到decision消息后走到else分支,报 "未知消息类型" 错误。
根本原因 :同一个 WebSocket 上注册了两个 message 监听器,它们竞争 同一条消息。Node.js 的 ws 库会把每条消息广播给所有监听器------但业务逻辑上,一条 decision 消息只应该被一个地方处理。
✅ 正确设计:用队列替代第二个监听器
这个队列就像银行叫号系统------客户(decision 消息)进来后取个号排队,柜台(waitForDecision)按顺序叫号处理。不会出现两个柜员抢同一个客户的情况。
思路:不让 waitForDecision 注册自己的监听器 ,而是让外层 handler 把 decision 消息路由到一个队列里,waitForDecision 只从队列取数据。
typescript
// ✅ 正确设计:按 ws 隔离的 decision 队列
const pendingDecisions = new Map<WebSocket, Array<{
resolve: (data: any) => void;
reject: (err: Error) => void;
}>>();
// 外层 handler:收到 decision 直接路由到队列,不再经过 handleMessage
ws.on('message', async (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'decision') {
const waiters = pendingDecisions.get(ws);
if (waiters?.length) {
// 取出最早注册的等待者,resolve 它
const { resolve } = waiters.shift()!;
resolve(msg.data);
} else {
console.log('⚠️ 收到孤立的 decision 消息(无对应的 interrupt)');
}
return; // ← 关键:decision 不经过 handleMessage
}
await handleMessage(ws, msg);
});
// waitForDecision:只往队列放一个 entry,不注册任何监听器
function waitForDecision(ws: WebSocket): Promise<any> {
return new Promise((resolve, reject) => {
const entry = { resolve, reject };
if (!pendingDecisions.has(ws)) {
pendingDecisions.set(ws, []);
}
pendingDecisions.get(ws)!.push(entry);
// 客户端断开时清理,防止 Promise 永远 pending
ws.once('close', () => {
const wsWaiters = pendingDecisions.get(ws);
if (wsWaiters) {
const idx = wsWaiters.indexOf(entry);
if (idx !== -1) wsWaiters.splice(idx, 1);
}
reject(new Error('客户端断开连接'));
});
});
}
为什么这样就对了?
text
前端发来 decision 消息
│
▼
┌─────────────────────────────────┐
│ ws.on('message') ← 唯一监听器 │
│ msg.type === 'decision' │
│ → 从队列取出 waiter │
│ → resolve(waiter, msg.data) │
│ → return(不进 handleMessage) │
└─────────────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ waitForDecision 的 Promise │
│ → 被 resolve,拿到 decisions │
│ → 继续恢复 Agent │
└─────────────────────────────────┘
- 只有一个监听器:不存在竞争
- 队列 FIFO:多次中断按顺序配对,不会错乱
- 先注册等待者,再发送 interrupt:确保 decision 到来时一定有人接
经验总结 :WebSocket 上需要"等待特定类型消息"时,不要用
ws.once('message')注册额外监听器------这会导致和外层监听器竞争。正确做法是:外层统一路由,内层用队列/Promise 等待。
4.6 验证
启动后端服务器:
bash
pnpm server
你应该看到:
text
🔌 WebSocket: ws://localhost:3001
用 wscat 或浏览器控制台测试连接:
bash
# 安装 wscat(如果没有)
npm install -g wscat
# 连接并发送消息
wscat -c ws://localhost:3001/ws
> {"type":"chat","message":"你好","threadId":"test-1"}
你应该看到后端逐条推送 thinking、text、tool_call(如果调用了工具)、done 事件。如果 Agent 配置了审批工具,发送 execute 相关消息后会收到 interrupt 事件。
后端引擎已经就绪。接下来把它和前端连起来------用 React Hook 封装 WebSocket 连接。
5. 前端 Hook:把 WebSocket 封装成 React 状态
代码在 frontend/src/hooks/useWebSocket.ts 中。
5.1 为什么用 Hook?
Hook 就像翻译官------后端说"WebSocket 协议",前端说"React 状态",Hook 在中间做翻译,两边各说各的话,互不干扰。
WebSocket 逻辑直接写在组件里会导致:
- 组件代码膨胀(连接管理 + 消息处理 + UI 渲染混在一起)
- 无法复用(多个组件需要同一个连接)
- 难以测试
封装成 Hook 后,组件只需要:
typescript
const { sendMessage, messages, interrupt, status } = useWebSocket(threadId);
一行代码,拿到所有需要的数据。组件不需要知道 WebSocket 怎么连接、消息怎么解析、断线怎么重连------这些全交给 Hook。
5.2 核心设计:用 ref 追踪流式数据
WebSocket 消息是"事件流"------后端持续推送,前端需要把流式数据累积 到 React state 中。关键技巧是用 useRef 追踪"当前正在构建的 assistant 消息":
typescript
// 当前正在构建的 assistant 消息(流式文本累积)
const currentAssistantRef = useRef<ChatEntry | null>(null);
// 当前正在执行的工具调用(按 id 索引)
const pendingToolsRef = useRef<Map<string, ToolCallInfo>>(new Map());
为什么用 ref 而不是 state?useRef 就像一张便签纸 ------你随时可以拿起来改,改完还是那张纸,不会触发任何"通知"。而 useState 每次更新都会通知 React"该重渲染了"。
我们需要的是:收到一条 text 消息,立即 追加到便签纸上(同步更新 ref),然后统一通知 React 重渲染(调用一次 setMessages)。如果用 state 追踪当前消息,多次快速更新会导致闭包陷阱------每次 setState 拿到的"旧值"可能还是上一次渲染时的快照,新值还没来得及生效。
5.3 消息处理:按事件类型分发
typescript
const handleServerEvent = useCallback((event: ServerEvent) => {
switch (event.type) {
case 'text': {
// 如果没有当前 assistant 消息,创建一个
if (!currentAssistantRef.current) {
currentAssistantRef.current = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
timestamp: new Date(),
};
setMessages((prev) => [...prev, currentAssistantRef.current!]);
}
// 累积文本
currentAssistantRef.current.content += event.content;
// 创建新对象引用,触发 React 重渲染
setMessages((prev) =>
prev.map((msg) =>
msg.id === currentAssistantRef.current!.id
? { ...msg, content: currentAssistantRef.current!.content }
: msg
)
);
break;
}
case 'tool_call': {
// 创建工具调用记录,status 为 pending(等 tool_result 回来时更新)
const toolInfo: ToolCallInfo = {
id: event.id, name: event.name, args: event.args, status: 'pending',
};
pendingToolsRef.current.set(event.id, toolInfo);
// 附加到当前 assistant 消息的 toolCalls 数组
if (!currentAssistantRef.current) {
// 还没有 assistant 消息?创建一个,把工具调用放进去
currentAssistantRef.current = {
id: crypto.randomUUID(), role: 'assistant',
content: '', timestamp: new Date(), toolCalls: [toolInfo],
};
setMessages((prev) => [...prev, currentAssistantRef.current!]);
} else {
// 已有 assistant 消息,追加到 toolCalls 数组
currentAssistantRef.current.toolCalls = [
...(currentAssistantRef.current.toolCalls || []), toolInfo,
];
setMessages((prev) => [...prev]);
}
break;
}
case 'tool_result': {
// 找到对应的工具调用,更新结果和状态
const existing = pendingToolsRef.current.get(event.id);
if (existing) {
existing.result = event.result;
existing.status = 'completed';
// 同步更新 assistant 消息中的工具调用信息
if (currentAssistantRef.current?.toolCalls) {
const idx = currentAssistantRef.current.toolCalls.findIndex(
(t) => t.id === event.id,
);
if (idx !== -1) {
currentAssistantRef.current.toolCalls[idx] = existing;
}
}
setMessages((prev) => [...prev]); // 触发重渲染
}
break;
}
case 'interrupt':
setInterrupt(event);
setIsLoading(false);
break;
case 'done':
currentAssistantRef.current = null; // 重置,下一轮创建新的
pendingToolsRef.current.clear();
setIsLoading(false);
break;
}
}, []);
5.4 自动重连
typescript
ws.onclose = () => {
setStatus('disconnected');
reconnectTimerRef.current = setTimeout(connect, 5000); // 5 秒后重连
};
5.5 验证
Hook 无法脱离 UI 单独"运行",但可以在浏览器控制台验证。启动前后端后,打开 http://localhost:5173,在控制台输入:
javascript
// 检查连接状态------页面加载后应该是 'connected'
// (如果用了 React DevTools,可以直接查看 App 组件的 state)
你应该看到:
- 页面顶部状态指示器显示 "已连接"(绿色)
- 输入消息后,消息列表立即出现用户消息
- 几秒后,AI 回复开始逐段出现(不是一次性全部出现)
- 如果 AI 调用了工具,工具调用卡片出现在消息流中
Hook 把数据准备好了,但用户看到的是 UI。接下来用组件把这些状态渲染成界面。
6. UI 组件:聊天界面 + 审批弹窗 + 子 Agent 监控
6.1 整体布局
text
┌─────────────────────────────────────────────┐
│ App.tsx │
│ ┌──────────────────────────┐ ┌───────────┐ │
│ │ ChatInterface │ │ Subagent │ │
│ │ ┌────────────────────┐ │ │ Monitor │ │
│ │ │ 消息列表 │ │ │ │ │
│ │ │ ┌──────────────┐ │ │ │ 📋 子Agent│ │
│ │ │ │ ToolCallViewer│ │ │ │ 任务 1 ✓ │ │
│ │ │ └──────────────┘ │ │ │ 任务 2 ⏳ │ │
│ │ ├────────────────────┤ │ │ │ │
│ │ │ 输入框 │ │ │ │ │
│ │ └────────────────────┘ │ └───────────┘ │
│ └──────────────────────────┘ │
│ ┌─────────────────────────────────────────┐│
│ │ ApprovalModal(中断时弹出) ││
│ └─────────────────────────────────────────┘│
└─────────────────────────────────────────────┘
6.2 ChatInterface:流式文本 + 自动滚动
关键挑战:新消息到来时自动滚动到底部,但用户主动上滚时不打断。
解决方案:用三个 ref 协作:
typescript
const isAtBottomRef = useRef(true); // 用户是否在底部
const userScrolledUpRef = useRef(false); // 用户是否主动上滚
const programmaticScrollUntilRef = useRef(0); // 程序滚动期间,忽略 scroll 事件
// 监听用户滚动
container.addEventListener('scroll', () => {
if (Date.now() < programmaticScrollUntilRef.current) return; // 程序触发的,跳过
const atBottom = scrollHeight - scrollTop - clientHeight < 50;
isAtBottomRef.current = atBottom;
if (!atBottom) userScrolledUpRef.current = true; // 用户上滚了
});
// 新消息到来时自动滚动
useEffect(() => {
if (isAtBottomRef.current && !userScrolledUpRef.current) {
programmaticScrollUntilRef.current = Date.now() + 500; // 标记 500ms 内是程序滚动
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages]);
6.3 ApprovalModal:人机协作审批
当 Agent 调用需要审批的工具(如 execute)时,弹窗展示操作详情,用户可以:
| 决策 | 含义 | 效果 |
|---|---|---|
| ✅ 批准 | 同意执行 | Agent 用原参数执行工具 |
| ❌ 拒绝 | 阻止执行 | Agent 收到拒绝消息,可附原因 |
| ✏️ 编辑 | 修改参数后执行 | Agent 用修改后的参数执行(仅部分工具支持) |
typescript
// 编辑参数:直接编辑 JSON
const handleEditConfirm = (idx: number) => {
try {
const newArgs = JSON.parse(editArgs[idx]);
decisions[idx] = {
type: 'edit',
editedAction: { name: actionRequests[idx].name, args: newArgs },
};
} catch {
alert('JSON 格式错误,请检查');
}
};
6.4 ToolCallViewer:工具调用可视化
每个工具调用是一个可折叠卡片:
text
┌─────────────────────────────────┐
│ 🔧 execute ⏳ 执行中 ▸│
├─────────────────────────────────┤
│ 参数 │
│ { "command": "ls -la" } │
│ │
│ 结果 │
│ total 48 │
│ drwxr-xr-x 8 user staff 256 │
│ ... │
└─────────────────────────────────┘
6.5 SubagentMonitor:子 Agent 并行监控
当主 Agent 委派任务给子 Agent(researcher / coder / reviewer)时,侧边栏实时展示每个子 Agent 的状态:
typescript
// 从所有工具调用中筛选 task 类型(子 Agent 委派)
const subagentTasks = tasks.filter((t) => t.name === 'task');
// 根据 subagent_type 显示不同图标
const SUBAGENT_ICONS: Record<string, React.FC> = {
researcher: IconSearch,
coder: IconTerminal,
reviewer: IconCheck,
'general-purpose': IconCpu,
};
6.6 验证
完整启动前后端:
bash
# 终端 1:后端
pnpm server
# 终端 2:前端
cd frontend && pnpm dev
打开 http://localhost:5173,逐项验证:
| 验证项 | 操作 | 预期效果 |
|---|---|---|
| 连接状态 | 页面加载 | 顶部显示绿色"已连接" |
| 流式输出 | 发送「帮我计算 128 × 47」 | AI 回复逐段出现,不是一次性显示 |
| 思考过程 | 发送需要推理的问题 | 出现可折叠的"思考过程"区块 |
| 工具调用 | 发送「执行 ls -la」 | 出现工具调用卡片:先显示"执行中",完成后显示"完成" |
| 审批弹窗 | Agent 调用 execute 工具 |
弹出审批弹窗,可批准/拒绝/编辑参数 |
| 自动滚动 | 连续发送多条消息 | 消息列表自动滚动到底部;手动上滚后不打断 |
| 子 Agent | 发送需要委派的任务 | 右侧边栏出现子 Agent 任务状态 |
界面已经能跑了。但开发过程中踩了不少坑------接下来逐个复盘。
7. 踩坑实录:14 个真实问题
开发过程中遇到了 14 个真实问题,这里挑最有代表性的几个:
坑 1:agent.invoke() 没有流式效果
我做了什么 → 用 agent.invoke() 获取 Agent 响应,前端有打字机逻辑。 出了什么问题 → 等 10 秒后内容一次性全部出现,没有逐字效果。 为什么 → invoke() 等 Agent 完全执行完毕才返回,所有文本是一次性收到的。 怎么修的 → 改用 agent.stream({ streamMode: 'messages' })。
坑 2:streamEvents 无法检测中断
我做了什么 → 改用 streamEvents({ version: 'v2' }) 获取 token 级流式输出。 出了什么问题 → 流式输出正常,但 agent.getState() 的 isInterrupted 始终返回 false。 为什么 → deepagents 中间件在流结束后清除了 __interrupt__ 字段。 怎么修的 → 不依赖 getState,直接从 stream 事件中提取 __interrupt__ 节点。
坑 3:中断恢复时消息重复
我做了什么 → 用户审批通过后恢复 Agent 执行。 出了什么问题 → 前端收到重复的 thinking、tool_call、tool_result 事件。 为什么 → LangGraph 从中断恢复时会重放被中断的节点。 怎么修的 → 维护 sentMessageIds 集合,通过消息 ID 去重。
坑 4:done 事件时序错误
我做了什么 → 在 handleMessage 末尾发送 done。 出了什么问题 → 审批弹窗弹出后 UI 状态立即错乱。 为什么 → done 在 if (interrupted) 块外面,发完 interrupt 就立即发了 done。 怎么修的 → 把 done 放在所有流程(包括审批恢复循环)完成之后。
坑 5:React 19 useRef 参数变化
我做了什么 → useRef<ReturnType<typeof setTimeout>>() 无参调用。 出了什么问题 → TypeScript 报错 Expected 1 arguments, but got 0。 为什么 → React 19 不再允许 useRef<T>() 无参调用。 怎么修的 → 改为 useRef<ReturnType<typeof setTimeout>>(undefined)。
完整问题清单
| # | 问题 | 类型 | 根本原因 |
|---|---|---|---|
| 1 | pnpm install 不生效 | 配置 | workspace 配置缺失 |
| 2 | CSS 模块 TS 报错 | 类型 | 缺少 vite/client 声明 |
| 3 | useRef 参数错误 | 类型 | React 19 API 变更 |
| 4 | Nullable ref 赋值 | 类型 | ref 可能为 null |
| 5 | 服务器启动路径错误 | 运行时 | 工作目录不对 |
| 6 | 输入框宽度变化 | UI | 缺少固定宽度 |
| 7 | 打字机 effect 不推进 | 逻辑 | useEffect 依赖循环 |
| 8 | 内容一次性出现 | 架构 | invoke 非流式 |
| 9 | streamEvents 类型错误 | 类型 | 联合类型推断失败 |
| 10 | 审批弹窗不弹出 | 逻辑 | done 事件时序错误 |
| 11 | 发送后无 loading | UI | 缺少即时反馈 |
| 12 | streamEvents 无法检测中断 | 架构 | deepagents 中间件不传播中断状态 |
| 13 | streamEvents vs stream 选择 | 架构 | 中断检测可靠性 vs token 粒度 |
| 14 | 中断恢复时消息重复 | 架构 | LangGraph 重放被中断节点 |
8.1 生产环境建议
上面的 14 个坑是开发过程中遇到的,但如果要把这套系统搬到生产环境,还有几件事值得提前做:
| 建议 | 原因 | 做法 |
|---|---|---|
| 心跳检测 | WebSocket 可能被中间代理静默断开(Nginx、负载均衡器),客户端不知道 | 每 30 秒发一次 ping,超时未收到 pong 则主动重连 |
| 消息确认 | 网络抖动可能导致消息丢失,前端发了 decision 但后端没收到 |
后端收到 decision 后回一个 { type: 'ack', id } 确认 |
| 消息大小限制 | 工具返回结果可能很大(比如 cat 一个大文件),超过 WebSocket 帧限制 |
后端对 tool_result 做截断或分片,前端显示"结果已截断" |
| 断线重连恢复 | 当前重连后聊天记录清空,用户体验差 | 用 localStorage 持久化 messages,重连后恢复 |
| 错误边界 | React 组件渲染错误会导致整个页面白屏 | 用 ErrorBoundary 包裹消息列表,单条消息渲染失败不影响其他 |
这些不是"必须马上做"的事,但知道它们存在,在真正上线时就不会手忙脚乱。
9. 回顾与展望
我们做了什么
| 系列 | 文章 | 链接 |
|---|---|---|
| 第 1 篇 | LangChain + TypeScript 实战 | 链接 |
| 第 2 篇 | LocalShellBackend 与命令执行 | 链接 |
| 第 3 篇 | Memory Skills 与上下文工程 | 链接 |
| 第 4 篇 | 任务委派与子 Agent 并行处理 | 链接 |
| 第 5 篇 | 人机协作与中断恢复 | 链接 |
本篇新增:
- WebSocket 服务器 --- 基于
ws库实现全双工通信,支持流式输出和中断检测 - 协议设计 --- 2 种上行消息 + 7 种下行事件,覆盖 Agent 交互的所有场景
- useWebSocket Hook --- 封装连接管理、消息分发、流式数据累积、自动重连
- 聊天界面 --- 流式文本渲染、智能自动滚动、工具调用可视化
- 审批弹窗 --- 批准 / 拒绝 / 编辑参数三种决策,实现人机协作闭环
- 子 Agent 监控 --- 侧边栏实时展示子 Agent 并行任务状态
完整运行
bash
# 终端 1:启动后端
pnpm server
# 终端 2:启动前端
cd frontend && pnpm dev
# 打开浏览器访问 http://localhost:5173
打开后你会看到:连接状态指示 → 输入消息 → AI 逐段输出 → 工具调用可视化 → 审批弹窗(如果需要)→ 子 Agent 侧边栏。
后续可以做什么
- Markdown 渲染 --- 当前文本是纯文本,后续可以用
react-markdown渲染 Markdown - 消息持久化 --- 刷新页面后聊天记录消失,可以用 localStorage 或后端存储
- 多会话管理 --- 支持多个 threadId 切换,类似 ChatGPT 的侧边栏会话列表
- 文件上传 --- 让用户上传图片、文件给 Agent 处理
从终端里的 console.log 到浏览器里的实时聊天界面------Agent 终于有了一个"看得见"的入口。