6. 从零搭建一个 AI Agent —— 构建 Web 前端,让用户能和 agent 实时交互

前端实时聊天界面 ------ React + WebSocket 实战

这不是一个"调 API 显示结果"的前端教程。 我们要做的,是一个真正能 实时流式输出人机协作审批子 Agent 并行监控 的聊天界面。


目录

  1. [环境搭建:Vite + React + TypeScript](#环境搭建:Vite + React + TypeScript "#1-%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BAvite--react--typescript")
  2. [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")
  3. 协议设计:前后端怎么"说话"?
  4. [后端 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")
  5. [前端 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")
  6. [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")
  7. [踩坑实录: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")
  8. 生产环境建议:上线前还要做什么?
  9. 回顾与展望

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)           │
  └─────────────────────────────────┘

看起来两个都收到了,好像能跑?但问题是:

  1. 时序竞争 :如果外层 handleMessage 还没执行到 waitForDecision()(比如还在处理上一个 stream 事件),内层 once 监听器还没注册,decision 消息就已经被外层吃掉了------内层永远等不到。
  2. 多次中断错乱 :如果 Agent 连续触发两次中断,第二次 waitForDecision() 注册的 once 可能吃到上一条 decision 的残留消息。
  3. 错误消息类型 :外层 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"}

你应该看到后端逐条推送 thinkingtexttool_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 状态立即错乱。 为什么doneif (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 篇 人机协作与中断恢复 链接

本篇新增:

  1. WebSocket 服务器 --- 基于 ws 库实现全双工通信,支持流式输出和中断检测
  2. 协议设计 --- 2 种上行消息 + 7 种下行事件,覆盖 Agent 交互的所有场景
  3. useWebSocket Hook --- 封装连接管理、消息分发、流式数据累积、自动重连
  4. 聊天界面 --- 流式文本渲染、智能自动滚动、工具调用可视化
  5. 审批弹窗 --- 批准 / 拒绝 / 编辑参数三种决策,实现人机协作闭环
  6. 子 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 终于有了一个"看得见"的入口。

相关推荐
m0_466525291 小时前
从“算法内卷”到“可信易用” 东软多模态医学人工智能平台开启无代码科研新时代
人工智能·算法
爱勇宝1 小时前
《道德经》第4章,老子写得很像在描述一个“底层系统”。
前端·后端·程序员
开开心心就好1 小时前
免费格式转换工具视频音频文档都能转
人工智能·智能手机·ocr·电脑·音视频·散列表·启发式算法
东风破_1 小时前
手写一个 Mini Cursor:让 Agent 读写文件、执行命令并创建项目
人工智能
tyqtyq221 小时前
营养餐单规划:AI 科学饮食规划系统的鸿蒙实现
人工智能·学习·华为·职场和发展·生活·harmonyos
艾莉丝努力练剑2 小时前
【AI面试】AI八股文
人工智能·ai·面试·langchain·八股文
RSTJ_16252 小时前
PYTHON+AI LLM DAY ONE HUNDRED AND THREE
开发语言·人工智能·python
城中南小2 小时前
3D-VLA 方法原理详解
人工智能·计算机视觉·3d
snow@li2 小时前
Java:Java 服务器(Web 容器 / Servlet 容器)完整工作清单
java·服务器·前端