Skip to content

第 14 章 · 前端集成 ​

🎯 本章目标 ​

  • 想清楚前端到底要什么:实时性、稳定的契约、人机协作、会话与历史——四件事
  • 用 node:http 起一个真正的 SSE 流式后端,把图的 token 流写进 HTTP 响应,并用 fetch + ReadableStream 亲手把它解析回来
  • 把每种 streamMode 事件的真实形状抓下来,生成一张「前后端事件协议清单表」和可直接复制的 TypeScript 类型定义
  • 用两个 HTTP 接口(POST /chat 暂停 + POST /resume 恢复)把人工介入(第 5 章)接到前端
  • 拿到可直接复制的 React 片段(官方 useStream 版 + 裸 EventSource 版,明确标注:本机未安装 React,未实跑)
  • 用 getStateHistory 给前端提供会话与历史回放的数据(时间轴 / 时间旅行 UI)
  • 认识官方现成的 UI 生态(Agent Chat UI、gen-ui 示例、各框架 SDK),知道该选哪条路

本章代码

📌 代码约定:正文的代码块都保持可直接运行(含 import 与模型初始化)。若某段为聚焦概念的节选,会明确标注「节选」并指向同名的 code/ 完整文件。所有示例的输出都是真实运行结果,不是编造的示例输出。

⚠️ 本章特别说明:本机不能安装 React、也不能在浏览器里跑前端。因此本章的做法是:用 Node 端把「前端需要消费的东西」真实产出来(这部分全部实跑通过),而 React 代码作为「可直接复制的片段」打印出来,并在输出与正文里明确标注未实跑。不伪造任何浏览器里的运行输出。

文件内容实跑?
code/01-sse-backend.tsSSE 流式后端:node:http + streamMode:"messages",自起服务、自解析✅ 实跑
code/02-event-protocol.ts前后端事件协议:抓真实事件形状,生成协议清单 + TS 类型✅ 实跑
code/03-hitl-over-http.ts通过 HTTP 做人工介入:POST /chat 暂停 + POST /resume 恢复✅ 实跑
code/04-react-snippets.ts可复制的 React 片段(打印为字符串,非实跑)⚠️ 仅打印
code/05-session-history-api.ts会话与历史回放:getStateHistory → GET /history✅ 实跑

一、前端到底要什么 ​

走到全书最后一章,后端的能力其实已经齐了:状态、控制流、持久化、人工介入、流式、工程化。唯一还没回答的问题是:坐在浏览器前的那个用户,怎么实时看到这一切?

拆开来看,前端真正需要后端提供的是四样东西:

前端要的后端要给的对应章节
实时性:字要一个字一个字蹦出来,别转圈等到天荒地老流式推送(SSE)第 6 章 + 本章示例 1
稳定的契约:后端会推哪些事件、每个长什么样,得写死事件协议(类型定义)本章示例 2
人机协作:关键步骤停下来等用户点「批准 / 拒绝」中断的 HTTP 收发第 5 章 + 本章示例 3
会话与回放:刷新页面还能看到历史,甚至回看每一步getStateHistory第 4 章 + 本章示例 5

这四件事里,实时性是最容易被低估、又最影响体感的一件。

为什么流式对前端是刚需:一个数字 ​

第 6 章我们用一个指标把「体感」量化了——TTFT(Time To First Token,首字延迟)。那一次本机实测的数字是:

首帧延迟(第一个事件到达):162 ms
整轮总耗时              :1899 ms

差了一个数量级。 如果前端用非流式的 invoke(),用户要干等 1899ms 才能看到任何东西——这 1.9 秒里界面一片空白,用户会以为「卡死了」,甚至反复点按钮。而流式让用户在 162ms 就看到了「系统在动」:先是一行进度、再是一句「正在思考」,然后文字才开始一个个长出来。

本章示例 1 会再实测一次:它从发出请求到收到第一个正文 token 只花了 1356ms(本次运行),而整段回答到 1392ms 才结束——注意这里 TTFT 已经是「第一个正文 token」,等待的大头是推理型模型先输出的一大段思考过程。

💡 一句话:流式不改变总耗时,它改变的是**「用户什么时候开始觉得系统活着」**。对聊天界面,这不是锦上添花,是生死线。

所以前端集成的第一课,就是先把 SSE 流式后端搭起来——这正是示例 1 要做的。


二、SSE 流式后端 ​

示例 1:SSE 流式后端 ​

什么是 SSE ​

SSE(Server-Sent Events) 是一条 HTTP 长连接:客户端发起一次请求,服务器保持连接不关闭,持续往里写文本;客户端持续读。它的每一帧格式固定:

event: token
data: {"text":"你"}
<空行>
  • event: 是事件类型(前端 es.addEventListener("token", ...) 监听它)
  • data: 是 UTF-8 文本(通常是 JSON)
  • 一个空行代表一帧结束

第 6 章我们把图的事件翻译成了这样的字符串,但只是打印在终端。本章示例 1 更进一步:真的起一个 HTTP 服务,把这些帧写进响应体。

关键点 ​

  1. 响应头必须声明 Content-Type: text/event-stream,浏览器/客户端才会把它当流来读。
  2. 每产生一个事件就 res.write(frame),中途不要 res.end();全部结束后再 res.end()。
  3. streamMode:"messages" 负责把模型 token 抠出来(第 6 章),我们只负责「事件 → 帧」的包装。
  4. 推理型模型要分流:reasoning_content(思考)单发一个 reasoning 事件,别混进正文。

完整代码(code/01-sse-backend.ts) ​

typescript
/**
 * 示例 1:SSE 流式后端 —— 把图的 token 流变成一条 HTTP 长连接
 *
 * 第 6 章我们把图的事件翻译成 SSE 帧,但只是"打印在终端里"。本示例真的用
 * node:http 起服务,把帧写进 HTTP 响应体,让另一个进程 / 另一台机器上的前端
 * 实时收到。关键:响应头声明 Content-Type: text/event-stream,然后每产生一个
 * 事件,就往响应流 write 一帧(event: X \n data: {...} \n 空行)。
 *
 * ⚠️ 本示例被设计成"一次 npx tsx 就能跑完并自动退出":自己 listen → 自己 fetch
 *    请求、手工解析 SSE → 打印每帧 → server.close() 退出,不是常驻服务。
 */
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { StateGraph, START, END, MessagesAnnotation } from "@langchain/langgraph";
import { HumanMessage, AIMessageChunk } from "@langchain/core/messages";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";

const PORT = 8790; // 挑一个不常冲突的端口

// 把一个事件编码成一帧 SSE 文本(帧与帧之间用一个空行分隔)
function sseFrame(event: string, data: unknown, id?: number): string {
  const lines: string[] = [];
  if (id !== undefined) lines.push(`id: ${id}`);
  lines.push(`event: ${event}`);
  lines.push(`data: ${JSON.stringify(data)}`);
  return lines.join("\n") + "\n\n";
}

async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk as Buffer);
  const raw = Buffer.concat(chunks).toString("utf8");
  if (!raw) return {};
  return JSON.parse(raw) as Record<string, unknown>;
}

async function main() {
  banner("示例 1:SSE 流式后端(node:http + graph.stream streamMode:\"messages\")");
  assertChatKey();

  const model = createModel({ temperature: 0 });
  // 图:一个 chat 节点。streamMode:"messages" 会把它调的 token 抠出来
  async function chatNode(state: typeof MessagesAnnotation.State) {
    const res = await model.invoke(state.messages);
    return { messages: [res] };
  }
  const graph = new StateGraph(MessagesAnnotation)
    .addNode("chat", chatNode)
    .addEdge(START, "chat")
    .addEdge("chat", END)
    .compile();

  // ★ 核心:把一个"聊天请求"变成一串 SSE 帧,写进响应流
  async function streamChatToClient(res: ServerResponse, message: string, threadId: string) {
    // 前端收到的是"流",必须声明 text/event-stream;另两个头是防中间代理缓冲
    res.writeHead(200, {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no",
    });
    let seq = 0;
    let reasoningHinted = false;
    try {
      const stream = await graph.stream(
        { messages: [new HumanMessage(message)] },
        { streamMode: "messages", configurable: { thread_id: threadId } }
      );
      for await (const chunk of stream) {
        const [msg, meta] = chunk as unknown as [AIMessageChunk, Record<string, unknown>];
        const node = String(meta?.langgraph_node ?? "?");
        // 推理型模型的"思考":单独发一个 reasoning 事件(只发一次),不当正文
        const reasoning = (msg.additional_kwargs as Record<string, unknown>)?.reasoning_content;
        if (typeof reasoning === "string" && reasoning.length > 0) {
          if (!reasoningHinted) {
            res.write(sseFrame("reasoning", { node, stage: "thinking" }, seq++));
            reasoningHinted = true;
          }
          continue;
        }
        const text = typeof msg.content === "string" ? msg.content : "";
        if (text.length > 0) res.write(sseFrame("token", { node, text }, seq++));
      }
      res.write(sseFrame("done", { ok: true, thread_id: threadId }, seq++));
    } catch (e) {
      // 出错也要让前端知道,否则它只会看到连接莫名断开
      res.write(sseFrame("error", { message: (e as Error).message }, seq++));
    }
    res.end();
  }

  const server = createServer(async (req, res) => {
    const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
    const method = req.method ?? "GET";
    console.log(`   [server] ${method} ${url.pathname}`);
    try {
      if (method === "GET" && url.pathname === "/health") {
        res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
        return void res.end(JSON.stringify({ status: "ok" }));
      }
      // POST /chat:从 JSON body 拿参数(推荐,前端 fetch 用)
      if (method === "POST" && url.pathname === "/chat") {
        const body = await readJson(req);
        if (typeof body.message !== "string" || typeof body.thread_id !== "string") {
          res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
          return void res.end(JSON.stringify({ error: "需要 { message: string, thread_id: string }" }));
        }
        return streamChatToClient(res, body.message, body.thread_id);
      }
      // GET /chat:给浏览器原生 EventSource 用(它只能发 GET)
      if (method === "GET" && url.pathname === "/chat") {
        const message = url.searchParams.get("message");
        const threadId = url.searchParams.get("thread_id");
        if (!message || !threadId) {
          res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
          return void res.end(JSON.stringify({ error: "需要 query 参数 message 和 thread_id" }));
        }
        return streamChatToClient(res, message, threadId);
      }
      res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
      res.end(JSON.stringify({ error: `未知路径:${method} ${url.pathname}` }));
    } catch (e) {
      console.error("   [server] 处理出错:", (e as Error).message);
      if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
      res.end(JSON.stringify({ error: (e as Error).message }));
    }
  });

  await new Promise<void>((resolve) => server.listen(PORT, resolve));
  const addr = server.address();
  const actualPort = typeof addr === "object" && addr ? addr.port : PORT;
  const base = `http://127.0.0.1:${actualPort}`;
  console.log(`✅ 服务已启动:${base}(跑完会自己关掉)\n`);

  // ═══════ 自测:像前端那样去消费这条 SSE 流 ═══════
  console.log("🔎 环境探测:");
  console.log(`   typeof EventSource = ${typeof (globalThis as Record<string, unknown>).EventSource}`);
  const hasEventSource = typeof (globalThis as Record<string, unknown>).EventSource === "function";
  console.log(
    hasEventSource
      ? "   → 本机有内置 EventSource"
      : "   → 本机没有内置 EventSource,改用 fetch + ReadableStream 手工解析(下面的自测)"
  );
  console.log(
    "   ℹ️  浏览器里的 EventSource 只能发 GET。所以本示例额外保留了 GET /chat:\n" +
      "      EventSource 打 GET;fetch 打 POST(带 JSON body)。\n"
  );

  // 手工解析版:不依赖任何库,最能看清协议
  async function readSseStream(resp: Response): Promise<void> {
    const reader = resp.body!.getReader();
    const decoder = new TextDecoder();
    let buffer = "";
    const t0 = Date.now();
    let ttft: number | null = null;
    let frameCount = 0;
    console.log("📨 前端收到并解析出的帧(每帧一段):");
    console.log("   " + "-".repeat(56));
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      let sep: number;
      // SSE 以空行分隔帧;按 "\n\n" 切
      while ((sep = buffer.indexOf("\n\n")) !== -1) {
        const rawFrame = buffer.slice(0, sep);
        buffer = buffer.slice(sep + 2);
        let event = "message";
        let data = "";
        for (const line of rawFrame.split("\n")) {
          if (line.startsWith("event:")) event = line.slice(6).trim();
          else if (line.startsWith("data:")) data = line.slice(5).trim();
        }
        frameCount++;
        const payload = JSON.parse(data) as Record<string, unknown>;
        if (event === "token") {
          if (ttft === null) ttft = Date.now() - t0;
          console.log(`   [event: token ] ${JSON.stringify(payload)}`);
        } else {
          console.log(`   [event: ${event.padEnd(5)}] ${JSON.stringify(payload)}`);
        }
        if (event === "done" || event === "error") {
          console.log("   " + "-".repeat(56));
          console.log(`   📊 共收到 ${frameCount} 帧;TTFT(第一个 token)≈ ${ttft} ms;总耗时 ≈ ${Date.now() - t0} ms`);
          return;
        }
      }
    }
  }

  console.log("🔹 自测 A:POST /chat(fetch + ReadableStream,请求体里带 JSON)");
  const respA = await fetch(`${base}/chat`, {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
    body: JSON.stringify({ message: "用一句话说明什么是 SSE,不要超过 30 个字。", thread_id: "sse-demo-1" }),
  });
  console.log(`   响应状态:HTTP ${respA.status},Content-Type = ${respA.headers.get("content-type")}\n`);
  await readSseStream(respA);

  // 若本机真有全局 EventSource,再演示 GET 路径
  if (hasEventSource) {
    console.log("\n🔹 自测 B:内置 EventSource 走 GET /chat(浏览器里的原生写法)");
    const ES = (globalThis as unknown as { EventSource: new (u: string) => EventSource }).EventSource;
    await new Promise<void>((resolve) => {
      const url = `${base}/chat?message=${encodeURIComponent("再打个招呼,一句话。")}&thread_id=sse-demo-2`;
      const es = new ES(url);
      let acc = "";
      for (const ev of ["reasoning", "token", "done", "error"]) {
        es.addEventListener(ev, (e: MessageEvent) => {
          const payload = JSON.parse(e.data) as Record<string, unknown>;
          if (ev === "token") acc += String(payload.text);
          else console.log(`   [EventSource:${ev}] ${JSON.stringify(payload)}`);
          if (ev === "done" || ev === "error") {
            console.log(`   [EventSource] 拼出的正文:${acc}`);
            es.close(); // ★ 收到 done 必须 close,否则 EventSource 会自动重连、进程不退出
            resolve();
          }
        });
      }
      setTimeout(() => { es.close(); resolve(); }, 20000); // 兜底
    });
  } else {
    console.log(
      "\n🔹 自测 B:(跳过)本机没有全局 EventSource。\n" +
        "   浏览器里等价写法:\n" +
        '     const es = new EventSource("/chat?message=...&thread_id=...");\n' +
        '     es.addEventListener("token", (e) => { out.textContent += JSON.parse(e.data).text; });\n' +
        '     es.addEventListener("done", () => es.close());'
    );
  }

  await new Promise<void>((resolve, reject) =>
    server.close((err) => (err ? reject(err) : resolve()))
  );
  console.log("\n🛑 服务已关闭,示例结束。");
}

main().catch((e) => {
  console.error("❌ 运行失败:", e);
  process.exit(1);
});

运行:

bash
npx tsx 14-frontend-integration/code/01-sse-backend.ts

真实输出:

========================================================================
  示例 1:SSE 流式后端(node:http + graph.stream streamMode:"messages")
========================================================================

✅ 服务已启动:http://127.0.0.1:8790(跑完会自己关掉)

🔎 环境探测:
   typeof EventSource = undefined
   → 本机没有内置 EventSource,改用 fetch + ReadableStream 手工解析(下面的自测)
   ℹ️  浏览器里的 EventSource 只能发 GET。所以本示例额外保留了 GET /chat:
      EventSource 打 GET;fetch 打 POST(带 JSON body)。

🔹 自测 A:POST /chat(fetch + ReadableStream,请求体里带 JSON)
   [server] POST /chat
   响应状态:HTTP 200,Content-Type = text/event-stream; charset=utf-8

📨 前端收到并解析出的帧(每帧一段):
   --------------------------------------------------------
   [event: reasoning] {"node":"chat","stage":"thinking"}
   [event: token ] {"node":"chat","text":"SS"}
   [event: token ] {"node":"chat","text":"E"}
   [event: token ] {"node":"chat","text":"是"}
   [event: token ] {"node":"chat","text":"服务器"}
   [event: token ] {"node":"chat","text":"通过"}
   [event: token ] {"node":"chat","text":"HTTP"}
   [event: token ] {"node":"chat","text":"向"}
   [event: token ] {"node":"chat","text":"浏览器"}
   [event: token ] {"node":"chat","text":"单向"}
   [event: token ] {"node":"chat","text":"持续"}
   [event: token ] {"node":"chat","text":"推送"}
   [event: token ] {"node":"chat","text":"事件"}
   [event: token ] {"node":"chat","text":"的技术"}
   [event: token ] {"node":"chat","text":"。"}
   [event: done ] {"ok":true,"thread_id":"sse-demo-1"}
   --------------------------------------------------------
   📊 共收到 16 帧;TTFT(第一个 token)≈ 1356 ms;总耗时 ≈ 1392 ms

🔹 自测 B:(跳过)本机没有全局 EventSource。
   浏览器里等价写法:
     const es = new EventSource("/chat?message=...&thread_id=...");
     es.addEventListener("token", (e) => { out.textContent += JSON.parse(e.data).text; });
     es.addEventListener("done", () => es.close());

🛑 服务已关闭,示例结束。

四个必须记住的细节 ​

① 响应头 text/event-stream 是"通关文牒"

没有它,客户端根本不会把响应当流。Cache-Control: no-cache 和 X-Accel-Buffering: no 是给中间的反向代理(nginx 等)看的——否则它们可能把你的流攒成一大坨再吐给前端,流式就白做了。

② 一帧 = event: 行 +(可选的 data: 行)+ 一个空行

res.write() 是流式写:写进去的内容会立刻发给客户端,不需要等 res.end()。这就是"边生成边到货"在 HTTP 层的实现。

③ EventSource 只能发 GET,所以真实项目常常两个端点都留

浏览器原生的 EventSource 不支持自定义请求体,只能 GET,参数只能塞在 URL 里。而 fetch 可以发 POST + JSON body。所以工程上常见的做法是:POST /chat(fetch 用,带 JSON)+ GET /chat(EventSource 用,参数在 query),两者共用同一段"事件 → SSE 帧"的逻辑。示例 1 两个都留了,你可以看到它们都调用同一个 streamChatToClient()。

💡 本机环境:typeof EventSource = undefined——本机这套 Node 里没有内置的全局 EventSource。所以示例用 fetch + ReadableStream 手工解析(这也最能看清协议)。浏览器里则直接用 EventSource;若哪天本机 Node 提供了全局 EventSource,示例的"自测 B"分支会自动改走原生路径。

④ 别忘了 done 和 error 两个收尾事件

流式响应如果没有明确的"结束"信号,前端只能猜什么时候停。所以约定两帧:event: done(正常结束)和 event: error(出错)。前端收到 done 就 es.close()——这一步是必须的:EventSource 默认会在连接断开后自动重连,不手动 close() 的话,聊天界面会在回答结束后不停地重新请求。


三、前后端事件协议 ​

示例 2:前后端事件协议 ​

示例 1 能跑了,但它只推了 token 一种事件。真实前端面对的是好几种事件(token、进度、节点完成、结束、出错、审批……)。如果前后端没有一份写死的契约,前端就得靠猜和试错。

示例 2 的做法:真跑一次多模式 stream,把每种模式的真实形状抓下来,自动推导出一张「协议清单表」,并打印一份可直接复制的 TypeScript 类型定义。前端同学拿着这份类型,就能写出类型安全的消费代码。

完整代码(code/02-event-protocol.ts) ​

typescript
/**
 * 示例 2:前后端事件协议 —— 把每种事件的真实形状抓下来,生成"协议清单"
 *
 * 前端同学最想要的一件事是:后端到底会推哪些事件、每个长什么样?
 * 本示例真跑一次多模式 stream(streamMode 数组),把每种模式的真实 JSON 形状
 * 抓下来,生成一张"协议清单表",并打印一份可直接复制的 TypeScript 类型定义。
 * 这样前后端就有一份共同契约,不用靠猜。
 *
 * ⚠️ 数组模式下每个 chunk 恒为二元组 [模式名, 数据],看第 0 个元素就能分流。
 */
import { StateGraph, START, END, MessagesAnnotation, getWriter } from "@langchain/langgraph";
import { HumanMessage, AIMessage, AIMessageChunk } from "@langchain/core/messages";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

// 一个"形状描述器":从真实数据里推导结构,而不是我手写猜的
function shape(v: unknown): string {
  if (v === null) return "null";
  if (v === undefined) return "undefined";
  if (Array.isArray(v)) return v.length === 0 ? "[]" : `[${shape(v[0])}]`;
  if (typeof v !== "object") return typeof v;
  // 消息对象(BaseMessage 子类)单独归纳,别把一堆内部字段摊出来
  const maybeMsg = v as { _getType?: () => string };
  if (typeof maybeMsg._getType === "function") return `Message<${maybeMsg._getType()}>`;
  const keys = Object.keys(v as Record<string, unknown>);
  if (keys.length === 0) return "{}";
  return `{ ${keys.map((k) => `${k}: ${shape((v as Record<string, unknown>)[k])}`).join(", ")} }`;
}

async function main() {
  banner("示例 2:把每种事件的真实形状抓下来(协议清单)");
  assertChatKey();

  const model = createModel({ temperature: 0 });

  // 节点 1:plan —— 用 getWriter() 上报自定义进度(对应 custom 模式)
  async function plan(state: typeof MessagesAnnotation.State) {
    const write = getWriter();
    for (const step of ["分析意图", "选择策略", "生成计划"]) {
      await sleep(80);
      write?.({ type: "step", label: `规划中:${step}` });
    }
    return { messages: [new AIMessage("(内部)计划:直接作答。")] };
  }

  // 节点 2:answer —— 调模型,触发 messages 模式
  async function answer(state: typeof MessagesAnnotation.State) {
    const res = await model.invoke(state.messages);
    return { messages: [res] };
  }

  const graph = new StateGraph(MessagesAnnotation)
    .addNode("plan", plan)
    .addNode("answer", answer)
    .addEdge(START, "plan")
    .addEdge("plan", "answer")
    .addEdge("answer", END)
    .compile();

  const question = "用一句话介绍事件协议。";
  console.log(`👤 提问:${question}\n`);

  // 每种模式各留一条"样本",用于推导形状
  const sample: Record<string, unknown> = {};
  const counts: Record<string, number> = {};
  console.log("🔀 正在跑多模式流(values + updates + messages + custom)…\n");
  for await (const chunk of await graph.stream(
    { messages: [new HumanMessage(question)] },
    { streamMode: ["values", "updates", "messages", "custom"] }
  )) {
    const [mode, data] = chunk as unknown as [string, unknown];
    counts[mode] = (counts[mode] ?? 0) + 1;
    if (!(mode in sample)) sample[mode] = data; // 只留第一条,避免 token 刷爆内存
  }

  console.log("📊 本次运行各模式事件条数:");
  for (const mode of ["values", "updates", "messages", "custom"]) {
    console.log(`   • ${mode.padEnd(9)} × ${counts[mode] ?? 0}`);
  }

  // 把 messages 样本整理成"人看得懂"的形状
  function messagesSample(data: unknown): string {
    const [msg, meta] = data as [AIMessageChunk, Record<string, unknown>];
    const reasoning = (msg.additional_kwargs as Record<string, unknown>)?.reasoning_content;
    return JSON.stringify({
      content: typeof msg.content === "string" ? "string" : "array",
      additional_kwargs: {
        reasoning_content: typeof reasoning === "string" ? "string(仅推理型模型,可能没有)" : "undefined",
      },
      metadata: { langgraph_node: meta.langgraph_node, langgraph_step: meta.langgraph_step },
    });
  }

  console.log("\n📋 协议清单表(从本次运行的真实事件里推导):\n");
  console.log("   ┌─ 模式 ─────┬─ chunk 形状(真实推导)─────────────────────────────────────────┐");
  console.log(`   │ values    │ ${shape(sample.values).slice(0, 70)}`);
  console.log(`   │ updates   │ ${shape(sample.updates).slice(0, 70)}`);
  console.log(`   │ messages  │ 二元组 -> ${messagesSample(sample.messages).slice(0, 58)}`);
  console.log(`   │ custom    │ ${shape(sample.custom).slice(0, 70)}`);
  console.log("   └───────────┴──────────────────────────────────────────────────────────────┘");
  console.log("\n   ↑ 以上是【数据】部分的形状(二元组的第 1 个元素)。");

  const types = `// ── 前后端事件协议类型定义(贴进前端项目即可)──────────────────────
/** SSE 帧的通用外壳:event 名 + JSON 数据 */
export interface SseFrame<T = unknown> {
  event: "token" | "reasoning" | "node" | "progress" | "done" | "error" | "interrupt";
  data: T;
}
/** streamMode:"messages" —— token 级增量 */
export interface TokenEvent {
  node: string;   // 来自哪个节点(metadata.langgraph_node)
  text: string;   // 正文片段;累积所有 text 就是完整回答
}
/** streamMode:"custom" —— 节点里 getWriter() 主动上报的业务进度 */
export interface ProgressEvent { [key: string]: unknown }
/** streamMode:"updates" —— 某节点执行完毕,key 是节点名 */
export type UpdatesEvent = Record<string, Record<string, unknown> | undefined>;
/** streamMode:"values" —— 完整状态快照(这里演示 messages 状态) */
export interface ValuesEvent { messages: Array<{ type: string; content: string }> }
/** 结束 / 出错 */
export interface DoneEvent { ok: true; thread_id: string }
export interface ErrorEvent { message: string }
/** 人工介入(见示例 3):图在 interrupt 处停下,后端把审批卡片推给前端 */
export interface InterruptEvent { value: unknown; next: string[] }`;
  console.log("\n🧩 可直接复制的 TypeScript 类型定义:\n");
  console.log("─".repeat(72));
  console.log(types);
  console.log("─".repeat(72));
}

main().catch((e) => {
  console.error("❌ 运行失败:", e);
  process.exit(1);
});

运行:

bash
npx tsx 14-frontend-integration/code/02-event-protocol.ts

真实输出:

========================================================================
  示例 2:把每种事件的真实形状抓下来(协议清单)
========================================================================

👤 提问:用一句话介绍事件协议。

🔀 正在跑多模式流(values + updates + messages + custom)…

📊 本次运行各模式事件条数:
   • values    × 3
   • updates   × 2
   • messages  × 536
   • custom    × 3

📋 协议清单表(从本次运行的真实事件里推导):

   ┌─ 模式 ─────┬─ chunk 形状(真实推导)─────────────────────────────────────────┐
   │ values    │ { messages: [Message<human>] }
   │ updates   │ { plan: { messages: [Message<ai>] } }
   │ messages  │ 二元组 -> {"content":"string","additional_kwargs":{"reasoning_conten
   │ custom    │ { type: string, label: string }
   └───────────┴──────────────────────────────────────────────────────────────┘

   ↑ 以上是【数据】部分的形状(二元组的第 1 个元素)。

🧩 可直接复制的 TypeScript 类型定义:

────────────────────────────────────────────────────────────────────────
// ── 前后端事件协议类型定义(贴进前端项目即可)──────────────────────
/** SSE 帧的通用外壳:event 名 + JSON 数据 */
export interface SseFrame<T = unknown> {
  event: "token" | "reasoning" | "node" | "progress" | "done" | "error" | "interrupt";
  data: T;
}
/** streamMode:"messages" —— token 级增量 */
export interface TokenEvent {
  node: string;   // 来自哪个节点(metadata.langgraph_node)
  text: string;   // 正文片段;累积所有 text 就是完整回答
}
/** streamMode:"custom" —— 节点里 getWriter() 主动上报的业务进度 */
export interface ProgressEvent { [key: string]: unknown }
/** streamMode:"updates" —— 某节点执行完毕,key 是节点名 */
export type UpdatesEvent = Record<string, Record<string, unknown> | undefined>;
/** streamMode:"values" —— 完整状态快照(这里演示 messages 状态) */
export interface ValuesEvent { messages: Array<{ type: string; content: string }> }
/** 结束 / 出错 */
export interface DoneEvent { ok: true; thread_id: string }
export interface ErrorEvent { message: string }
/** 人工介入(见示例 3):图在 interrupt 处停下,后端把审批卡片推给前端 */
export interface InterruptEvent { value: unknown; next: string[] }
────────────────────────────────────────────────────────────────────────

这张表怎么用 ​

示例 2 的价值不在"跑一次",而在方法:它用同一个 shape() 函数从真实的运行时数据推导结构,而不是拍脑袋写。你可以把这个脚本放进自己的项目,让 CI 定期跑一次,一旦框架升级导致事件形状变化,就能第一时间发现。

把上面四种模式的数据部分,加上示例 1 里的 token / reasoning / done / error,合起来就是一份完整的事件协议:

event什么时候发data 形状前端怎么用
token模型产出正文 token{ node, text }追加到气泡(打字机)
reasoning推理模型开始思考{ node, stage }显示"正在思考…"
node某节点执行完毕(updates){ 节点名: {...} }更新进度/步骤条
progress节点主动上报(custom)业务自定义进度条、阶段提示
done流正常结束{ ok, thread_id }关闭 EventSource
error出错{ message }弹错误提示
interrupt图暂停等审批(示例 3){ value, next }弹审批卡片

💡 为什么建议后端把每种模式"统一包装"成 { event, data } 再发?因为 streamMode 数组吐出来的 messages 是 [AIMessageChunk, metadata] 二元组、values 是整个状态对象——形状五花八门。后端在写 SSE 时顺手归一化成一个统一的帧外壳(event 名 + 干净的 data),前端就只需要认这一种形状。示例 1 里的 sseFrame() 干的就是这件事。


四、中断与审批怎么传到前端 ​

示例 3:中断与审批怎么传到前端 ​

第 5 章我们学过 interrupt / Command({ resume }),但那是在同一段脚本里完成的。真实产品里,人类坐在浏览器前——图在服务器上跑到 interrupt 停下,服务器必须把"审批卡片"发回前端;用户点完按钮,前端再发一个请求让图继续。

示例 3 用两个 HTTP 接口把这条前后端契约讲清楚:

POST /chat    { topic, thread_id }   → 跑到 interrupt 停下,响应带审批卡片
POST /resume  { thread_id, resume }  → 人类答复作为 resume 值,续跑到结束

完整代码(code/03-hitl-over-http.ts) ​

typescript
/**
 * 示例 3:通过 HTTP 做人工介入(HITL 的前后端契约)
 *
 * 第 5 章讲了 interrupt / Command 的机制,但那是"同一台机器、同一段脚本里"。
 * 真实产品里人类坐在浏览器前:图在服务器上跑到 interrupt 停下,服务器必须把
 * "审批卡片"推回前端;用户点了"批准/拒绝",前端再发请求让图继续。
 *
 * 本示例用两个 HTTP 接口表达这套契约:
 *   POST /chat   { topic, thread_id }  → 图跑到 interrupt 停下,响应带审批卡片
 *   POST /resume { thread_id, resume } → 人类答复作为 resume 值,续跑到结束
 * 自己起服务、自己发两次真实请求、打印完整收发,最后关服务退出。
 */
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import {
  StateGraph, START, END, Annotation, MemorySaver,
  Command, interrupt, isInterrupted, type Interrupt,
} from "@langchain/langgraph";
import { banner } from "../../lib/model.js";

const PORT = 8791;

// 状态:话题、草稿、人类决定、定稿
const ReviewState = Annotation.Root({
  topic: Annotation<string>,
  draft: Annotation<string>,
  decision: Annotation<string>,
  finalText: Annotation<string>,
});

function writeDraft(state: typeof ReviewState.State) {
  console.log("   [节点 writeDraft] 正在起草…");
  return { draft: `《${state.topic}》初稿:一段自动生成的内容,等待人工确认。` };
}

// 人工审核 —— interrupt 就发生在这里
function review(state: typeof ReviewState.State) {
  console.log("   [节点 review] 进入 review(interrupt 首次暂停 / 恢复后返回人类答复)");
  // 第一次跑到这里:抛出信号、暂停,把 payload 作为"审批卡片"交给前端
  // 恢复后重跑这里:不再暂停,直接把人类的 resume 值返回
  const humanAnswer = interrupt({
    kind: "approval_request",
    question: "这份草稿可以发布吗?请回复 approve 或 reject。",
    draft: state.draft,
  });
  console.log(`   [节点 review] 收到人类答复:${JSON.stringify(humanAnswer)}`);
  return { decision: String(humanAnswer) };
}

function finalize(state: typeof ReviewState.State) {
  const approved = state.decision.toLowerCase().includes("approve");
  console.log(`   [节点 finalize] 决定=${state.decision} → ${approved ? "发布" : "退回"}`);
  return {
    finalText: approved
      ? `✅ 已发布:${state.draft}`
      : `❌ 已退回,未发布:${state.draft}`,
  };
}

async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk as Buffer);
  const raw = Buffer.concat(chunks).toString("utf8");
  if (!raw) return {};
  return JSON.parse(raw) as Record<string, unknown>;
}

function sendJson(res: ServerResponse, status: number, body: unknown) {
  const payload = JSON.stringify(body);
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(payload) });
  res.end(payload);
}

async function main() {
  banner("示例 3:通过 HTTP 做人工介入(POST /chat → 暂停 → POST /resume)");

  const graph = new StateGraph(ReviewState)
    .addNode("writeDraft", writeDraft)
    .addNode("review", review)
    .addNode("finalize", finalize)
    .addEdge(START, "writeDraft")
    .addEdge("writeDraft", "review")
    .addEdge("review", "finalize")
    .addEdge("finalize", END)
    .compile({ checkpointer: new MemorySaver() });

  const server = createServer(async (req, res) => {
    const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
    const method = req.method ?? "GET";
    console.log(`   [server] ${method} ${url.pathname}`);
    try {
      if (method === "GET" && url.pathname === "/health") return sendJson(res, 200, { status: "ok" });

      // POST /chat:跑到 interrupt 停下,把审批卡片作为响应返回
      if (method === "POST" && url.pathname === "/chat") {
        const body = await readJson(req);
        const topic = body.topic;
        const threadId = body.thread_id;
        if (typeof topic !== "string" || typeof threadId !== "string") {
          return sendJson(res, 400, { error: "需要 { topic: string, thread_id: string }" });
        }
        const config = { configurable: { thread_id: threadId } };
        const result = await graph.invoke({ topic }, config);
        if (isInterrupted(result)) {
          const itr = result.__interrupt__[0] as Interrupt;
          const state = await graph.getState(config);
          return sendJson(res, 200, {
            status: "interrupted",
            thread_id: threadId,
            interrupt: itr.value, // ★ 前端拿这个去渲染审批卡片
            next: state.next,     // 暂停在哪些节点
          });
        }
        return sendJson(res, 200, { status: "done", thread_id: threadId, finalText: result.finalText });
      }

      // POST /resume:人类答复作为 Command({ resume }) 继续
      if (method === "POST" && url.pathname === "/resume") {
        const body = await readJson(req);
        const threadId = body.thread_id;
        const resumeValue = body.resume;
        if (typeof threadId !== "string" || resumeValue === undefined) {
          return sendJson(res, 400, { error: "需要 { thread_id: string, resume: any }" });
        }
        const config = { configurable: { thread_id: threadId } };
        const result = await graph.invoke(new Command({ resume: resumeValue }), config);
        return sendJson(res, 200, {
          status: "done", thread_id: threadId,
          finalText: result.finalText, decision: result.decision,
        });
      }
      return sendJson(res, 404, { error: `未知路径:${method} ${url.pathname}` });
    } catch (e) {
      console.error("   [server] 出错:", (e as Error).message);
      return sendJson(res, 500, { error: (e as Error).message });
    }
  });

  await new Promise<void>((resolve) => server.listen(PORT, resolve));
  const addr = server.address();
  const actualPort = typeof addr === "object" && addr ? addr.port : PORT;
  const base = `http://127.0.0.1:${actualPort}`;
  console.log(`✅ 服务已启动:${base}\n`);

  // 发一个请求,把【请求 + 响应】完整打印出来
  async function call(label: string, path: string, body: unknown): Promise<Record<string, unknown>> {
    console.log(`\n🔹 ${label}`);
    console.log(`   请求  → POST ${path}`);
    console.log(`           ${JSON.stringify(body)}`);
    const resp = await fetch(`${base}${path}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    const json = (await resp.json()) as Record<string, unknown>;
    console.log(`   响应  ← [HTTP ${resp.status}]`);
    console.log(`           ${JSON.stringify(json, null, 2).split("\n").join("\n           ")}`);
    return json;
  }

  // 场景 A:人类批准
  console.log("═══════════ 场景 A:人类批准 ═══════════");
  await call("① 前端发起对话(后端跑到 interrupt 停下,返回审批卡片)", "/chat", {
    topic: "LangGraph 前端集成", thread_id: "http-hitl-A",
  });
  await call("② 用户点了「批准」→ 前端带 resume=approve 继续", "/resume", {
    thread_id: "http-hitl-A", resume: "approve",
  });

  // 场景 B:人类拒绝(换一个会话)
  console.log("\n═══════════ 场景 B:人类拒绝 ═══════════");
  await call("① 前端发起对话(另一个 thread_id)", "/chat", {
    topic: "LangGraph 前端集成", thread_id: "http-hitl-B",
  });
  await call("② 用户点了「拒绝」→ 前端带 resume=reject 继续", "/resume", {
    thread_id: "http-hitl-B", resume: "reject",
  });

  await new Promise<void>((resolve, reject) =>
    server.close((err) => (err ? reject(err) : resolve()))
  );
  console.log("\n🛑 服务已关闭,示例结束。");
}

main().catch((e) => {
  console.error("❌ 运行失败:", e);
  process.exit(1);
});

运行:

bash
npx tsx 14-frontend-integration/code/03-hitl-over-http.ts

真实输出:

========================================================================
  示例 3:通过 HTTP 做人工介入(POST /chat → 暂停 → POST /resume)
========================================================================

✅ 服务已启动:http://127.0.0.1:8791

═══════════ 场景 A:人类批准 ═══════════

🔹 ① 前端发起对话(后端跑到 interrupt 停下,返回审批卡片)
   请求  → POST /chat
           {"topic":"LangGraph 前端集成","thread_id":"http-hitl-A"}
   [server] POST /chat
   [节点 writeDraft] 正在起草…
   [节点 review] 进入 review(interrupt 首次暂停 / 恢复后返回人类答复)
   响应  ← [HTTP 200]
           {
             "status": "interrupted",
             "thread_id": "http-hitl-A",
             "interrupt": {
               "kind": "approval_request",
               "question": "这份草稿可以发布吗?请回复 approve 或 reject。",
               "draft": "《LangGraph 前端集成》初稿:一段自动生成的内容,等待人工确认。"
             },
             "next": [
               "review"
             ]
           }

🔹 ② 用户点了「批准」→ 前端带 resume=approve 继续
   请求  → POST /resume
           {"thread_id":"http-hitl-A","resume":"approve"}
   [server] POST /resume
   [节点 review] 进入 review(interrupt 首次暂停 / 恢复后返回人类答复)
   [节点 review] 收到人类答复:"approve"
   [节点 finalize] 决定=approve → 发布
   响应  ← [HTTP 200]
           {
             "status": "done",
             "thread_id": "http-hitl-A",
             "finalText": "✅ 已发布:《LangGraph 前端集成》初稿:一段自动生成的内容,等待人工确认。",
             "decision": "approve"
           }

═══════════ 场景 B:人类拒绝 ═══════════

🔹 ① 前端发起对话(另一个 thread_id)
   请求  → POST /chat
           {"topic":"LangGraph 前端集成","thread_id":"http-hitl-B"}
   [server] POST /chat
   [节点 writeDraft] 正在起草…
   [节点 review] 进入 review(interrupt 首次暂停 / 恢复后返回人类答复)
   响应  ← [HTTP 200]
           {
             "status": "interrupted",
             "thread_id": "http-hitl-B",
             "interrupt": {
               "kind": "approval_request",
               "question": "这份草稿可以发布吗?请回复 approve 或 reject。",
               "draft": "《LangGraph 前端集成》初稿:一段自动生成的内容,等待人工确认。"
             },
             "next": [
               "review"
             ]
           }

🔹 ② 用户点了「拒绝」→ 前端带 resume=reject 继续
   请求  → POST /resume
           {"thread_id":"http-hitl-B","resume":"reject"}
   [server] POST /resume
   [节点 review] 进入 review(interrupt 首次暂停 / 恢复后返回人类答复)
   [节点 review] 收到人类答复:"reject"
   [节点 finalize] 决定=reject → 退回
   响应  ← [HTTP 200]
           {
             "status": "done",
             "thread_id": "http-hitl-B",
             "finalText": "❌ 已退回,未发布:《LangGraph 前端集成》初稿:一段自动生成的内容,等待人工确认。",
             "decision": "reject"
           }

🛑 服务已关闭,示例结束。

三个要点 ​

① POST /chat 的响应就是"审批卡片"

图跑到 interrupt 时会正常返回(不是报错),返回的对象带 __interrupt__,我们用 isInterrupted() 判断后,把 interrupt.value(也就是节点里传给 interrupt() 的那个 payload)原样放进 HTTP 响应的 interrupt 字段。前端拿到的这个 value,就是它要渲染的审批卡片数据——kind、question、draft 全在里面。同时还返回 next(暂停在哪些节点),前端可以据此显示"流程卡在哪一步"。

② POST /resume 就是把人类的按钮点击变成 Command({ resume })

用户在卡片上点了「批准」,前端就发 { thread_id, resume: "approve" }。后端 graph.invoke(new Command({ resume: resumeValue }), config)——resume 的值会原样成为 review 节点里 interrupt() 的返回值,图就从断点续跑。这正是第 5 章的机制,只不过"人类"从同一台机器换成了浏览器。

③ 会话靠 thread_id 粘起来,MemorySaver 负责记住"暂停在哪"

POST /chat 暂停时,状态被存进 MemorySaver(checkpointer)。POST /resume 只要带上同一个 thread_id,就能找回那个暂停点继续跑。所以前端必须在两次请求之间保存 thread_id(通常放在 state 或 URL 里)。

💡 一个真实观察:看输出的日志,POST /resume 时 [节点 review] 那一行又打印了一次——这不是 bug,而是第 5 章讲过的规则:恢复时中断的那个节点会从头整体重跑一遍(这次 interrupt() 直接返回人类的答复,不再暂停)。所以 interrupt() 之前的代码必须无副作用、可重复执行。


五、React 接入(可复制片段,未实跑) ​

示例 4:React 接入(可复制片段,未实跑) ​

⚠️ 先说清楚:这一段没有在本项目实跑 ​

┌──────────────────────────────────────────────────────────────────┐
│ 本机未安装 React,此段未在本项目实跑,仅作可复制参考。            │
└──────────────────────────────────────────────────────────────────┘

本机不能安装 React,也没法在浏览器里跑前端。所以示例 4 的脚本本身是纯打印——它把"可以直接复制到 React 项目里"的三段代码作为字符串打印出来。脚本能跑通(你下面能看到它的真实输出),但打印出来的 React 代码本身没有运行过。请把这当作"可复制的参考片段",而不是"已验证的成品"。

完整代码(code/04-react-snippets.ts) ​

typescript
/**
 * 示例 4:可直接复制到 React 项目的前端代码(两版)
 *
 * ⚠️⚠️⚠️ 重要声明 ⚠️⚠️⚠️
 * 本机没有安装 React,也不能在浏览器里运行前端。所以本文件**不实跑任何 React 代码**,
 * 它只是把"可以复制到 React 项目里"的代码**作为字符串打印出来**。这样脚本本身能安全
 * 跑通(纯打印),你把打印出来的代码复制走就能用。
 *
 *   ┌──────────────────────────────────────────────────────────────────┐
 *   │ 本机未安装 React,此段未在本项目实跑,仅作可复制参考。            │
 *   └──────────────────────────────────────────────────────────────────┘
 *
 * 三版:① 官方 SDK 的 useStream() ② 裸 EventSource 消费示例 1 的 SSE
 *       ③ 人工审批卡片(消费示例 3 的 POST /chat → /resume 契约)
 */
import { banner } from "../../lib/model.js";

const SNIPPET_SDK = `// ─────────────────────────────────────────────────────────────
// React 片段 ①:用官方 @langchain/langgraph-sdk 的 useStream()
// 安装:npm i @langchain/langgraph-sdk react
// 前提:后端是 LangGraph Platform / 兼容的 SSE 端点(事件协议见示例 2)
// ─────────────────────────────────────────────────────────────
import { useStream } from "@langchain/langgraph-sdk/react";

export function Chat() {
  // useStream 内部就是走 SSE,替你把 messages / values / isLoading 都管好了
  const { messages, values, isLoading, submit, stop } = useStream({
    apiUrl: "http://localhost:2024", // 你的后端地址
    assistantId: "agent",
    // threadId 不传时 SDK 自动新建会话;持久化会话就传固定 threadId
  });

  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>
          <b>{m.type}:</b> {typeof m.content === "string" ? m.content : ""}
        </div>
      ))}
      {/* isLoading 为 true 时按钮变成"停止" */}
      <button onClick={() => (isLoading ? stop() : submit({ messages: [] }))}>
        {isLoading ? "停止" : "发送"}
      </button>
      {/* values 里也能直接读到最终状态,比如自定义的 finalText 字段 */}
      <pre>{JSON.stringify(values, null, 2)}</pre>
    </div>
  );
}`;

const SNIPPET_EVENTSOURCE = `// ─────────────────────────────────────────────────────────────
// React 片段 ②:不依赖任何 SDK,裸用浏览器原生 EventSource
// 消费示例 1 后端推的 SSE 协议:event: token / done / error ...
// 注意:EventSource 只能发 GET,所以后端要保留 GET /chat(示例 1 已保留)
// ─────────────────────────────────────────────────────────────
import { useEffect, useRef, useState } from "react";

export function Chat({ threadId }: { threadId: string }) {
  const [text, setText] = useState("");        // 累积中的回答
  const [thinking, setThinking] = useState(false);
  const esRef = useRef<EventSource | null>(null);

  function ask(question: string) {
    setText("");
    setThinking(false);
    // 带上 query 参数(EventSource 不支持自定义 body)
    const url = \`/chat?message=\${encodeURIComponent(question)}&thread_id=\${threadId}\`;
    const es = new EventSource(url);
    esRef.current = es;

    es.addEventListener("reasoning", () => setThinking(true)); // 模型开始思考

    es.addEventListener("token", (e: MessageEvent) => {        // 主体:往界面追加
      const { text: piece } = JSON.parse(e.data);
      setText((prev) => prev + piece);
    });

    es.addEventListener("done", () => {        // 必须 close,否则会自动重连
      setThinking(false);
      es.close();
    });

    es.addEventListener("error", () => es.close());
  }

  useEffect(() => () => esRef.current?.close(), []); // 卸载时断开

  return (
    <div>
      {thinking && <small>🧠 模型正在思考…</small>}
      <p>{text}</p>
      <button onClick={() => ask("你好,介绍一下你自己。")}>发送</button>
    </div>
  );
}`;

const SNIPPET_APPROVAL = `// ─────────────────────────────────────────────────────────────
// React 片段 ③:人工审批卡片(消费示例 3 的 POST /chat → /resume 契约)
// 后端 POST /chat 返回 { status:"interrupted", interrupt:{...}, next:[...] },
// 前端据此弹出审批卡片;用户点按钮后再 POST /resume。
// ─────────────────────────────────────────────────────────────
import { useState } from "react";

export function ApprovalCard({ threadId }: { threadId: string }) {
  const [pending, setPending] = useState<null | { question: string; draft: string }>(null);
  const [finalText, setFinalText] = useState("");

  async function start() {
    const r = await fetch("/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ topic: "LangGraph 前端集成", thread_id: threadId }),
    });
    const j = await r.json();
    if (j.status === "interrupted") setPending(j.interrupt);  // 弹出审批卡片
    else setFinalText(j.finalText);
  }

  async function decide(resume: "approve" | "reject") {
    setPending(null);
    const r = await fetch("/resume", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ thread_id: threadId, resume }),
    });
    const j = await r.json();
    setFinalText(j.finalText);
  }

  return (
    <div>
      <button onClick={start}>发起需要审批的任务</button>
      {pending && (
        <div className="card">
          <p>{pending.question}</p>
          <pre>{pending.draft}</pre>
          <button onClick={() => decide("approve")}>✅ 批准</button>
          <button onClick={() => decide("reject")}>❌ 拒绝</button>
        </div>
      )}
      {finalText && <p>{finalText}</p>}
    </div>
  );
}`;

function main() {
  banner("示例 4:可复制的 React 片段(本机未安装 React,未实跑,仅参考)");

  console.log("⚠️  再次强调:下面的代码没有在本项目里运行过——本机既没装 React,也没法跑");
  console.log("    浏览器。它们是「可以复制到你自己的 React 项目里」的参考代码。本脚本本身");
  console.log("    能跑,是因为它只是把这些代码当字符串打印出来。\n");

  const blocks: Array<[string, string]> = [
    ["① 官方 SDK 版:useStream()", SNIPPET_SDK],
    ["② 裸 EventSource 版:零依赖消费 SSE", SNIPPET_EVENTSOURCE],
    ["③ 人工审批卡片:消费 HITL 的 HTTP 契约", SNIPPET_APPROVAL],
  ];
  for (const [title, code] of blocks) {
    console.log("╔" + "═".repeat(70) + "╗");
    console.log(`║ ${title}`);
    console.log("╚" + "═".repeat(70) + "╝");
    console.log(code);
    console.log("\n");
  }

  console.log("─".repeat(72));
  console.log("📦 官方现成的 UI 生态(不用从零写):");
  console.log("   • Agent Chat UI              —— 开箱即用的聊天前端,接上后端就能聊");
  console.log("   • langgraphjs-gen-ui-examples —— 生成式 UI(generative UI)官方示例集合");
  console.log("   • @langchain/langgraph-sdk   —— 提供 React / Vue / Svelte / Angular 各框架绑定");
  console.log("   • useStream()                —— 最省事的钩子,内部走 SSE,事件协议同本书");
  console.log("─".repeat(72));
}

main();

运行:

bash
npx tsx 14-frontend-integration/code/04-react-snippets.ts

真实输出(节选:中间三段 ╔…╗ 之间的内容,就是上面源码里 SNIPPET_SDK / SNIPPET_EVENTSOURCE / SNIPPET_APPROVAL 三个字符串常量原样打印的结果,与源码逐字一致,此处不再重复粘贴):

========================================================================
  示例 4:可复制的 React 片段(本机未安装 React,未实跑,仅参考)
========================================================================

⚠️  再次强调:下面的代码没有在本项目里运行过——本机既没装 React,也没法跑
    浏览器。它们是「可以复制到你自己的 React 项目里」的参考代码。本脚本本身
    能跑,是因为它只是把这些代码当字符串打印出来。

╔══════════════════════════════════════════════════════════════════════╗
║ ① 官方 SDK 版:useStream()
╚══════════════════════════════════════════════════════════════════════╝

…(此处为输出节选:该标题下,是 SNIPPET_SDK 常量原样打印的 React 源码)…

╔══════════════════════════════════════════════════════════════════════╗
║ ② 裸 EventSource 版:零依赖消费 SSE
╚══════════════════════════════════════════════════════════════════════╝

…(此处为输出节选:该标题下,是 SNIPPET_EVENTSOURCE 常量原样打印的 React 源码)…

╔══════════════════════════════════════════════════════════════════════╗
║ ③ 人工审批卡片:消费 HITL 的 HTTP 契约
╚══════════════════════════════════════════════════════════════════════╝

…(此处为输出节选:该标题下,是 SNIPPET_APPROVAL 常量原样打印的 React 源码)…

────────────────────────────────────────────────────────────────────────
📦 官方现成的 UI 生态(不用从零写):
   • Agent Chat UI              —— 开箱即用的聊天前端,接上后端就能聊
   • langgraphjs-gen-ui-examples —— 生成式 UI(generative UI)官方示例集合
   • @langchain/langgraph-sdk   —— 提供 React / Vue / Svelte / Angular 各框架绑定
   • useStream()                —— 最省事的钩子,内部走 SSE,事件协议同本书
────────────────────────────────────────────────────────────────────────

三段片段分别讲了什么 ​

片段依赖用在哪亮点
① useStream()官方 @langchain/langgraph-sdk生产首选messages / values / isLoading / submit / stop 全给你了
② 裸 EventSource零依赖想自己掌控 / 非 React 环境直接消费示例 1 的 SSE 协议,最能看清原理
③ 审批卡片零依赖HITL 场景消费示例 3 的 POST /chat → POST /resume 契约

片段 ①(useStream):这是生产环境最省事的写法。它内部就是一条 SSE 连接,把"累积消息、维护 loading 状态、处理中断重连"这些脏活全包了,你只需要渲染 messages。它要求后端是一个 LangGraph Platform / 兼容的端点——如果你的后端是自己用 node:http 手写的(像本章示例 1),那就用片段 ②。

片段 ②(裸 EventSource):不依赖任何 SDK,直接对着本章示例 1 的协议写。两条铁律:参数只能塞 URL(EventSource 不支持 body),以及 收到 done 必须 es.close()(否则浏览器会不停自动重连)。注意 useEffect(() => () => esRef.current?.close(), []) 这行——组件卸载时也要断开,否则会泄漏连接。

片段 ③(审批卡片):把示例 3 的 HTTP 契约变成 UI——start() 拿到 interrupted 就 setPending(j.interrupt) 弹出卡片,用户点按钮调 decide() 发 resume。interrupt.value 里的字段(question、draft)直接绑定到卡片上。

💡 想更快?直接用官方的 Agent Chat UI。 如果你不需要深度定制界面,官方有一个现成的聊天前端仓库 Agent Chat UI,接上你的 LangGraph 后端就能聊(打字机、工具调用展示、HITL 审批都有)——这比自己从零搭快一个数量级。完整清单见下一节。


六、会话与历史回放 ​

示例 5:会话与历史回放 ​

前端最后两个诉求:会话(刷新页面还能拿到这段对话)和回放(像时间轴 / 时间旅行那样看每一步状态怎么变)。

  • 会话:其实早就有了——第 4 章的 MemorySaver + thread_id 就是。GET /history?thread_id=xxx 一拉,整段消息就回来了。
  • 回放:靠第 5 章的 getStateHistory(config)。它返回这条 thread 上的所有存档快照(最新在前),每个快照都是一个 StateSnapshot,含 values(当时的状态)、next(下一步去哪)、config(含 checkpoint_id)、metadata(source / step)、createdAt 等。

示例 5 把 getStateHistory 的结果整理成前端好用的时间轴结构,通过 GET /history 暴露出去。

完整代码(code/05-session-history-api.ts) ​

typescript
/**
 * 示例 5:为前端提供「会话与历史回放」的接口
 *
 * 聊天前端常见两个诉求:
 *   ① 会话:刷新页面后还能拿回这段对话(按 thread_id 拉历史消息)。
 *   ② 回放:像"时间轴 / 时间旅行"那样看每一步状态怎么变(第 5 章 getStateHistory)。
 *
 * 本示例用 getStateHistory() 把"每一步的快照"整理成前端好用的结构,通过
 * GET /history 暴露出去,并真跑一遍(先聊两轮,再拉历史)。
 *
 *   GET /history?thread_id=xxx
 *     → { thread_id, total_checkpoints, checkpoints:[{ checkpoint_id, step,
 *         source, next, message_count, last_message } ...] }  (倒序,最新在前)
 */
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { StateGraph, START, END, MessagesAnnotation, MemorySaver } from "@langchain/langgraph";
import { HumanMessage, type BaseMessage } from "@langchain/core/messages";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";

const PORT = 8792;

async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk as Buffer);
  const raw = Buffer.concat(chunks).toString("utf8");
  if (!raw) return {};
  return JSON.parse(raw) as Record<string, unknown>;
}

function sendJson(res: ServerResponse, status: number, body: unknown) {
  const payload = JSON.stringify(body);
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(payload) });
  res.end(payload);
}

// 一条消息在前端展示时需要的最小信息
function msgBrief(m: BaseMessage) {
  return { type: m.getType(), content: String(m.content).slice(0, 60) };
}

async function main() {
  banner("示例 5:会话与历史回放(getStateHistory → GET /history)");
  assertChatKey();

  const model = createModel({ temperature: 0 });
  async function chatNode(state: typeof MessagesAnnotation.State) {
    const res = await model.invoke(state.messages);
    return { messages: [res] };
  }
  const graph = new StateGraph(MessagesAnnotation)
    .addNode("chat", chatNode)
    .addEdge(START, "chat")
    .addEdge("chat", END)
    .compile({ checkpointer: new MemorySaver() });

  const server = createServer(async (req, res) => {
    const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
    const method = req.method ?? "GET";
    console.log(`   [server] ${method} ${url.pathname}`);
    try {
      if (method === "GET" && url.pathname === "/health") return sendJson(res, 200, { status: "ok" });

      // POST /chat:发一句话,返回回复(会话按 thread_id 隔离)
      if (method === "POST" && url.pathname === "/chat") {
        const body = await readJson(req);
        const message = body.message;
        const threadId = body.thread_id;
        if (typeof message !== "string" || typeof threadId !== "string") {
          return sendJson(res, 400, { error: "需要 { message: string, thread_id: string }" });
        }
        const result = await graph.invoke(
          { messages: [new HumanMessage(message)] },
          { configurable: { thread_id: threadId } }
        );
        const last = result.messages[result.messages.length - 1] as BaseMessage;
        return sendJson(res, 200, {
          reply: String(last.content), thread_id: threadId, message_count: result.messages.length,
        });
      }

      // GET /history:回放这段会话的每一步
      if (method === "GET" && url.pathname === "/history") {
        const threadId = url.searchParams.get("thread_id");
        if (!threadId) return sendJson(res, 400, { error: "需要 query 参数 thread_id" });
        const config = { configurable: { thread_id: threadId } };
        // ★ getStateHistory 返回异步可迭代对象:从最新快照往最早回滚
        const checkpoints: Array<Record<string, unknown>> = [];
        for await (const snap of graph.getStateHistory(config)) {
          const msgs = (snap.values as { messages: BaseMessage[] }).messages ?? [];
          const cfg = snap.config as { configurable?: Record<string, unknown> };
          checkpoints.push({
            checkpoint_id: cfg.configurable?.checkpoint_id,
            created_at: snap.createdAt,
            source: snap.metadata?.source,     // "input"(新输入) / "loop"(图内部推进)
            step: snap.metadata?.step,         // 框架内部的步数编号
            next: snap.next,                   // 跑完后图准备去哪([] 表示已结束)
            message_count: msgs.length,
            last_message: msgs.length ? msgBrief(msgs[msgs.length - 1]) : null,
          });
        }
        return sendJson(res, 200, { thread_id: threadId, total_checkpoints: checkpoints.length, checkpoints });
      }
      return sendJson(res, 404, { error: `未知路径:${method} ${url.pathname}` });
    } catch (e) {
      console.error("   [server] 出错:", (e as Error).message);
      return sendJson(res, 500, { error: (e as Error).message });
    }
  });

  await new Promise<void>((resolve) => server.listen(PORT, resolve));
  const addr = server.address();
  const actualPort = typeof addr === "object" && addr ? addr.port : PORT;
  const base = `http://127.0.0.1:${actualPort}`;
  console.log(`✅ 服务已启动:${base}\n`);

  const THREAD = "history-demo";
  async function post(path: string, body: unknown): Promise<Record<string, unknown>> {
    const r = await fetch(`${base}${path}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    });
    return (await r.json()) as Record<string, unknown>;
  }

  // 先聊两轮,制造出多个 checkpoint
  console.log("🔹 步骤 1:同一会话聊两轮(每次 invoke 都会写入 checkpoint)");
  const r1 = await post("/chat", { message: "我叫蜗牛。只回复一句确认。", thread_id: THREAD });
  console.log(`   第 1 轮回复:${r1.reply}`);
  const r2 = await post("/chat", { message: "我叫什么?一句话回答。", thread_id: THREAD });
  console.log(`   第 2 轮回复:${r2.reply}`);

  // 再拉历史,模拟前端"时间轴 / 回放"
  console.log("\n🔹 步骤 2:GET /history?thread_id=" + THREAD + "(前端拉时间轴数据)");
  const resp = await fetch(`${base}/history?thread_id=${THREAD}`);
  const hist = (await resp.json()) as {
    thread_id: string;
    total_checkpoints: number;
    checkpoints: Array<Record<string, unknown>>;
  };
  console.log(`   响应状态:HTTP ${resp.status}`);
  console.log(`   thread_id = ${hist.thread_id}`);
  console.log(`   total_checkpoints = ${hist.total_checkpoints}(含开始/中间每一个快照)\n`);

  console.log("📽️  时间轴(最新在前,这正是前端时间旅行 UI 要的顺序):");
  hist.checkpoints.forEach((cp, i) => {
    const last = cp.last_message as { type: string; content: string } | null;
    const preview = last ? `[${last.type}] ${last.content.slice(0, 32)}` : "(空)";
    console.log(
      `   #${String(i).padStart(2)} step=${String(cp.step).padStart(3)} ` +
        `source=${String(cp.source).padEnd(5)} msgs=${String(cp.message_count).padStart(2)} ` +
        `next=${JSON.stringify(cp.next).padEnd(10)} → ${preview}`
    );
  });

  console.log("\n📦 其中最早的一条快照(前端点「回到开头」时会用到)完整结构:");
  console.log(JSON.stringify(hist.checkpoints[hist.checkpoints.length - 1], null, 2));

  await new Promise<void>((resolve, reject) =>
    server.close((err) => (err ? reject(err) : resolve()))
  );
  console.log("\n🛑 服务已关闭,示例结束。");
}

main().catch((e) => {
  console.error("❌ 运行失败:", e);
  process.exit(1);
});

运行:

bash
npx tsx 14-frontend-integration/code/05-session-history-api.ts

真实输出:

========================================================================
  示例 5:会话与历史回放(getStateHistory → GET /history)
========================================================================

✅ 服务已启动:http://127.0.0.1:8792

🔹 步骤 1:同一会话聊两轮(每次 invoke 都会写入 checkpoint)
   [server] POST /chat
   第 1 轮回复:收到,蜗牛。
   [server] POST /chat
   第 2 轮回复:你叫蜗牛。

🔹 步骤 2:GET /history?thread_id=history-demo(前端拉时间轴数据)
   [server] GET /history
   响应状态:HTTP 200
   thread_id = history-demo
   total_checkpoints = 6(含开始/中间每一个快照)

📽️  时间轴(最新在前,这正是前端时间旅行 UI 要的顺序):
   # 0 step=  4 source=loop  msgs= 4 next=[]         → [ai] 你叫蜗牛。
   # 1 step=  3 source=loop  msgs= 3 next=["chat"]   → [human] 我叫什么?一句话回答。
   # 2 step=  2 source=input msgs= 2 next=["__start__"] → [ai] 收到,蜗牛。
   # 3 step=  1 source=loop  msgs= 2 next=[]         → [ai] 收到,蜗牛。
   # 4 step=  0 source=loop  msgs= 1 next=["chat"]   → [human] 我叫蜗牛。只回复一句确认。
   # 5 step= -1 source=input msgs= 0 next=["__start__"] → (空)

📦 其中最早的一条快照(前端点「回到开头」时会用到)完整结构:
{
  "checkpoint_id": "1f1c4601-95eb-6e20-ffff-110e5be6f0f2",
  "created_at": "2026-10-10T04:07:24.674Z",
  "source": "input",
  "step": -1,
  "next": [
    "__start__"
  ],
  "message_count": 0,
  "last_message": null
}

🛑 服务已关闭,示例结束。

这张时间轴怎么变成 UI ​

前端 UI 元素用响应里的哪个字段
聊天记录(刷新后恢复)最新快照的 message_count 对应的 values.messages(示例里简化成了 last_message 预览)
时间轴上的每个点checkpoints[],每个 checkpoint_id 就是一个可点击的节点
每个点的时间created_at
"这一步做完、下一步去哪"next([] 表示结束;["chat"] 表示还停在 chat 节点)
"回到某一步"拿该 checkpoint_id 去 graph.updateState(config, values, checkpointId) 或直接 getState 取该快照的 values 渲染

💡 注意顺序:getStateHistory 返回的是倒序(#0 是最新,#5 是最早)。前端做时间轴通常再反转一次,从最早往后排。另外注意输出里 source 有两种值:input(用户新输入触发的存档)和 loop(图内部推进的存档)——一个"用户发了一句"往往对应多个 loop 存档,因为图内部每走一步都存一次。所以 total_checkpoints = 6 而消息只有 4 条。


七、官方 UI 生态与选型 ​

前面五节你把「前端要的东西」全部亲手产出来了。但不必什么车轮都自己造——官方给了一整套现成的 UI 生态:

名称是什么什么时候用
Agent Chat UI官方开箱即用的聊天前端(打字机、工具调用展示、HITL 审批都有),配好后端地址就能聊不想自己写界面,要最快出成品
langgraphjs-gen-ui-examples生成式 UI(generative UI)官方示例集合:让模型直接"渲染"出界面组件想做大模型驱动的富交互(表单、图表、卡片)
@langchain/langgraph-sdk官方 SDK,提供 React / Vue / Svelte / Angular 各框架的绑定各主流前端框架都有官方支持
useStream()(在 SDK 里)各框架里最省事的钩子:内部走 SSE,直接给你 messages / values / isLoading / submit / stop生产环境的默认选择

选型一句话:

要最快出成品        → Agent Chat UI(现成的)
要自定义但要省事    → useStream()(官方 SDK)
要完全掌控 / 非主流环境 → 自己写 SSE 后端 + EventSource(本章示例 1、2、4②)
要有 HITL 审批      → 后端用示例 3 的契约 + 前端审批卡片(示例 4③)
要有会话与回放      → 示例 5 的 getStateHistory 接口

💡 无论选哪条路,底层协议都是本书讲的那套:graph.stream(streamMode) → SSE 帧 → 前端事件监听。useStream / Agent Chat UI 只是把这一层包得更好用而已。懂协议,就不怕任何一个框架换代。


八、本章小结(全书收束) ​

本章速查表

你要做的事代码
起一个 SSE 服务res.writeHead(200, { "Content-Type": "text/event-stream" }) → 逐帧 res.write(...)
编一帧 SSEevent: X\n数据\n\n(一个空行结束一帧)
把 token 流接到 SSEgraph.stream(input, { streamMode: "messages" }) → 每个 token 一帧
前端 fetch 解析 SSEresp.body.getReader(),按 \n\n 切帧,每帧解析 event: / data:
前端原生接流EventSource(只能 GET)+ addEventListener("token"/"done", ...) + 收到 done 要 close()
HITL 的 HTTP 契约POST /chat 返回 { status:"interrupted", interrupt };POST /resume 带 { thread_id, resume }
拿到中断内容isInterrupted(result) → result.__interrupt__[0].value
恢复执行graph.invoke(new Command({ resume }), config)
会话历史 / 回放for await (const snap of graph.getStateHistory(config))(倒序,最新在前)
快照字段snap.values / snap.next / snap.config / snap.metadata / snap.createdAt
省事前端官方 SDK 的 useStream() / Agent Chat UI

三句话总结

  1. 前端要的四件事——实时性、契约、HITL、会话回放——本章各用一个示例真跑了一遍:SSE 后端(1)、事件协议(2)、HTTP 中断(3)、历史回放(5);React 代码作为可复制片段(4,未实跑)。
  2. 前后端靠"一份协议"耦合,别靠"默契":streamMode 的多模式事件、统一包装成 { event, data } 的 SSE 帧、写死的 TypeScript 类型——契约越清楚,前端越好写、系统越不怕改。
  3. 流式对前端是刚需:invoke() 让用户干等整段生成(本次约 1.4s 起,推理模型更久),SSE 让首字延迟降到几百毫秒-1 秒级之间——差的是"系统是否活着"的体感。

动手练习

  1. 给示例 1 的 POST /chat 加一个 streamMode: ["messages", "custom"] 的双模式流,让节点在回答前先用 getWriter() 上报一条 event: progress("正在准备…"),前端据此在打字机开始前先显示一行进度提示。
  2. 改造示例 3:给 interrupt 的 payload 加一个 expiresAt(几秒后过期),并在 POST /resume 里检查——过期了就返回 409,模拟"审批超时"。想一想前端拿到 409 应该显示什么。
  3. 改造示例 5:把 GET /history 的返回再反转一次(从最早到最新),并给每个 checkpoint 加上 values.messages 的完整列表(不只是 last_message 预览)。这就是一个时间旅行 UI 的数据源。

下一章预告

—— 到这里,《LangGraph.js 中文入门》正文结束。祝你写出越来越聪明的智能体。


结语 ​

从第 1 章"第一个图",到本章"前端集成",我们走完了 LangGraph.js 的完整闭环:

状态 & 节点 & 边  →  控制流  →  持久化  →  人工介入  →  流式  →  多智能体  →  工程化 & 前端
   (怎么搭图)      (怎么分支)  (怎么记住)  (怎么插人)  (怎么实时) (怎么协作) (怎么上线)

前端集成是这条链的最后一环,也是很多项目最容易翻车的一环——因为后端"能跑"和用户体验"好"之间,隔着一条 SSE 连接、一份事件协议、一次 close()。

希望这一章帮你把"最后一公里"走稳。懂协议,就不怕框架换代;懂原理,就不怕需求变化。

更多官方资料(JS 版):


← 上一章:函数式 API | 返回总目录 | 附录:术语表 →

内容基于 LangGraph.js 官方文档与示例(langchain-ai/langgraphjs,MIT License)整理并重排为中文学习路径