今天要解决的问题
到目前为止 SupportPilot 只能在终端里跑。面试官不会 clone 你的仓库、配 API key、起数据库来看效果,他们要的是一个打开就能用的页面,或者一段两分钟的演示视频。今天把界面做出来。
重点不是”做个界面”,而是让人看得见 Agent 在干什么。一张工单处理下来要十几秒,如果页面上只有一个转圈的图标,用户会以为卡死了;如果能看到”查客户档案 ✓ 分诊 ✓ 检索帮助中心 ✓,调用了 list_invoices,正在写回复……“,等待就变得可以忍受,而且用户能判断 Agent 有没有走偏。客服和财务更需要看到过程:Agent 查了哪些数据、依据哪条规则,才敢点”通过”。
这一篇默认你会前端。招聘方看 Agent 项目时,界面往往是最先被看到、也最容易拉开差距的部分:后端出身的人大多用 Streamlit 糊一个页面就交差了,你如果能交出一个流式渲染干净、工具调用可读、审批流顺手、能中断能出错的工作台,作品集会好看很多。所以主线用 Next.js + React + TypeScript,Streamlit 版压缩后放在文末备选。
今天结束时你应该做到:
- 一组 HTTP 接口:建工单、跑工单、追问、查状态、列待审批、审批。跑工单、追问和审批是 SSE 流式接口,推送节点完成、步骤细节、工具调用、逐字输出、审批暂停、完成六类事件。
- 一个 Next.js 前端:客户视角(提交工单、看过程、追问)和客服工作台(待审批列表、一键通过或驳回、看恢复后的流程跑完)。
- 前端把事件流收进一个 reducer,组件只负责渲染;支持停止接收,错误和空状态都有像样的提示。
- 修掉一个并发下成本统计出错的 bug。
- 后端用 FastAPI 测试客户端加假 LLM 测通”跑到审批 → 审批 → 完成”;前端用一个假 SSE 服务端在浏览器里把整套交互走一遍。
预计用时 5–6 小时。前置:Day 4(流式输出)、Day 36(FastAPI + SSE)、Day 47–48。
先修一个 bug:并发时成本算错
Day 47 的节点这样算成本:开始前读一下 LLMClient 账本的累计值,结束后再读一次,差值就是这个节点花的钱。在终端里一张一张跑没问题。
放到 Web 服务里,所有请求共用一个 Deps,也就共用同一个 LLMClient 和它的账本。两张工单同时在跑,工单 A 的 draft 节点开始时账本是 0.010,结束时是 0.016,但这中间工单 B 的 classify 节点也往账本里记了 0.002。A 的 draft 就被算成花了 0.006,实际只花了 0.004。
这种 bug 不会报错,不会崩,只是数字悄悄不对。等你在 Day 52 的成本看板上看到”draft 节点平均成本比上周涨了 30%“,会花一下午去查提示词是不是变长了。
修法是每个请求用一份新的 LLM 客户端,各记各的账。检索器(内存里有 BM25 索引)和数据库连接这些重的东西照样共用:
def fresh(self) -> "Deps":
"""每个请求用一份新的 LLM 客户端(各自的成本账本),检索器和数据库这些重的东西共用。
不这么做的话,两个工单并发时 spent() 的差值会把别人的花费算进来。"""
llm = LLMClient(self.settings, model=self.llm.model)
small = llm if self.small_llm is self.llm else LLMClient(self.settings, model=self.small_llm.model)
return replace(self, llm=llm, small_llm=small)
def fresh(self) -> "Deps":返回类型写成字符串"Deps",因为在类体里引用自己时类还没定义完,这叫前向引用。每个 HTTP 请求进来,_graph()都会调一次它。llm = LLMClient(self.settings, model=self.llm.model)新建一个客户端,模型名沿用原来的(我的 .env 里是deepseek-flash),账本从 0 开始记。small = llm if self.small_llm is self.llm else没配小模型时,from_conn()里small_llm和llm是同一个对象。新的这份也要保持”是同一个对象”,否则spent()里的is not判断失效,同一笔花费会被加两次。return replace(self, llm=llm, small_llm=small)dataclasses.replace是浅拷贝:retriever、call_tool 这些字段还是原来那个对象,只有两个客户端换成新的。实测审批那次请求只跑 approval、finalize 两个不调模型的节点,done 里的cost_cny和审批前一样是 0.03198,没有混进别的请求的钱。
dataclasses.replace 复制一个 dataclass 并替换指定字段,其余字段共享同一个对象。创建 LLMClient 只是创建一个 openai SDK 客户端对象,很便宜。
我写 Day 47 时没想到这个问题,是写今天的接口时想到”多个请求同时跑会怎样”才发现的。做服务化时,把每个”全局共享”的对象都过一遍”并发时会不会串”,这个习惯很值钱。
接口设计
# supportpilot/src/supportpilot/api/app.py
"""HTTP 接口。启动:uv run uvicorn supportpilot.api.app:app --reload --port 8000
POST /tickets 建工单,返回 ticket_id
POST /tickets/{id}/run 跑工作流,SSE 推送过程(第一轮)
POST /tickets/{id}/messages 客户追问,SSE 推送过程(后续轮)
GET /tickets/{id} 当前状态
GET /approvals 待审批的退款
POST /approvals/{ticket_id} 审批,SSE 推送恢复后的过程
"""
import json
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from langgraph.types import Command
from pydantic import BaseModel, Field
from sse_starlette import EventSourceResponse
from starlette.concurrency import iterate_in_threadpool
from ..agent.deps import Deps
from ..agent.graph import build_graph
from ..db import connect
from ..memory.checkpoint import make_checkpointer
from ..tools import business as biz
R: dict = {} # 进程级资源:数据库连接、checkpointer、基础 deps
@asynccontextmanager
async def lifespan(app: FastAPI):
R["conn"] = connect()
R["saver"] = make_checkpointer()
R["deps"] = Deps.from_conn(R["conn"])
yield
R["conn"].close()
app = FastAPI(title="SupportPilot", lifespan=lifespan)
# Next.js 前端跑在 3000 端口,和 API 不同源,浏览器会先发 OPTIONS 预检。只放行自己的前端地址,别写 "*"
app.add_middleware(
CORSMiddleware,
allow_origins=os.getenv("SUPPORTPILOT_WEB_ORIGINS", "http://localhost:3000").split(","),
allow_methods=["GET", "POST"],
allow_headers=["content-type", "accept"],
)
class NewTicket(BaseModel):
customer_email: str
subject: str = Field(max_length=200)
body: str = Field(max_length=4000)
class Message(BaseModel):
content: str = Field(max_length=4000)
class Decision(BaseModel):
approved: bool
approver: str
note: str = ""
def _graph():
return build_graph(R["deps"].fresh(), checkpointer=R["saver"])
def _cfg(ticket_id: str) -> dict:
return {"configurable": {"thread_id": ticket_id}}
def _events(graph, payload, ticket_id: str):
"""把 LangGraph 的流翻译成前端好消费的 SSE 事件。这是个同步生成器,外面用线程池包一层。"""
for mode, chunk in graph.stream(payload, _cfg(ticket_id), stream_mode=["updates", "custom"]):
if mode == "custom":
yield {"event": chunk["type"], "data": json.dumps(chunk, ensure_ascii=False)}
continue
for node, update in chunk.items():
if node == "__interrupt__":
yield {"event": "interrupt", "data": json.dumps(update[0].value, ensure_ascii=False, default=str)}
else:
yield {"event": "node", "data": json.dumps({"node": node}, ensure_ascii=False)}
yield {"event": "done", "data": json.dumps(_summary(graph, ticket_id), ensure_ascii=False, default=str)}
def _summary(graph, ticket_id: str) -> dict:
snap = graph.get_state(_cfg(ticket_id))
v = snap.values
return {"ticket_id": ticket_id, "outcome": v.get("outcome") or ("waiting_approval" if snap.next else None),
"category": v.get("triage", {}).get("category"), "reply": v.get("reply"),
"tool_log": v.get("tool_log", []), "cost_cny": round(v.get("cost_cny", 0.0), 5),
"waiting_on": list(snap.next)}
def _sse(gen) -> EventSourceResponse:
return EventSourceResponse(iterate_in_threadpool(gen))
@app.post("/tickets")
def create_ticket(t: NewTicket) -> dict:
return {"ticket_id": biz.create_ticket(R["conn"], t.customer_email, t.subject, t.body)}
@app.post("/tickets/{ticket_id}/run")
def run_ticket(ticket_id: str):
row = R["conn"].execute("SELECT id, customer_email, subject, body FROM biz.tickets WHERE id = %s",
(ticket_id,)).fetchone()
if not row:
raise HTTPException(404, "ticket not found")
return _sse(_events(_graph(), {"ticket": dict(row)}, ticket_id))
@app.post("/tickets/{ticket_id}/messages")
def follow_up(ticket_id: str, m: Message):
graph = _graph()
snap = graph.get_state(_cfg(ticket_id))
if not snap.values:
raise HTTPException(404, "ticket has not been run yet")
if snap.next:
raise HTTPException(409, "工单还在等待审批,审批完成后才能继续对话")
return _sse(_events(graph, {"turns": [{"role": "customer", "content": m.content}]}, ticket_id))
@app.get("/tickets/{ticket_id}")
def get_ticket(ticket_id: str) -> dict:
return _summary(_graph(), ticket_id)
@app.get("/approvals")
def list_approvals() -> list[dict]:
rows = R["conn"].execute(
"SELECT r.*, t.subject, c.name AS customer_name FROM biz.refund_requests r "
"JOIN biz.customers c ON c.id = r.customer_id LEFT JOIN biz.tickets t ON t.id = r.ticket_id "
"WHERE r.status = 'pending' ORDER BY r.created_at").fetchall()
return [biz._jsonable(r) | {"created_at": str(r["created_at"])} for r in rows]
@app.post("/approvals/{ticket_id}")
def decide(ticket_id: str, d: Decision):
graph = _graph()
snap = graph.get_state(_cfg(ticket_id))
if not snap.values: # checkpointer 里没有这个 thread:工单不存在或者从没跑过
raise HTTPException(404, "工单不存在或还没有处理过")
if "approval" not in snap.next:
raise HTTPException(409, "这个工单不在等待审批")
return _sse(_events(graph, Command(resume=d.model_dump()), ticket_id))
"""HTTP 接口。启动:文件开头把六个接口列一遍,打开文件就知道这个服务能干什么。我实跑时本机 8000 被占了,用的是--port 8049,前端的NEXT_PUBLIC_SUPPORTPILOT_API也跟着改成 8049。from langgraph.types import Command审批恢复要用Command(resume=...),把审批结果作为 approval 节点里interrupt()的返回值送回去。from sse_starlette import EventSourceResponse负责 SSE 的格式、心跳和断线检测。实测它返回的响应头是content-type: text/event-stream; charset=utf-8、cache-control: no-store、x-accel-buffering: no,最后一个是让 nginx 别缓冲。from starlette.concurrency import iterate_in_threadpool把同步生成器搬到线程池里一步一步取,下面_sse()用。R: dict = {}进程级的资源袋。放一个模块级字典,lifespan 里往里放、接口里取,不用写global。@asynccontextmanagerlifespan 的写法:yield之前的代码在服务启动时跑,之后的在服务关闭时跑。R["conn"] = connect()一条 psycopg 连接全进程共用,正文说过 Day 53 会换成连接池。R["saver"] = make_checkpointer()Postgres 版的 checkpointer。第一次会建表,实测临时库里多了checkpoints、checkpoint_writes、checkpoint_blobs、checkpoint_migrations四张。工单停在审批时,状态就存在这几张表里,服务重启也不丢。R["deps"] = Deps.from_conn(R["conn"])基础 deps 只建一次,检索器的 BM25 索引在内存里,建一次就够。R["conn"].close()yield之后:服务关闭时把连接关掉。allow_origins=os.getenv("SUPPORTPILOT_WEB_ORIGINS"允许哪些前端来源,逗号分隔。实测用 TestClient 发预检:Origin 是默认的http://localhost:3000返回 200,带access-control-allow-origin: http://localhost:3000;Origin 换成http://evil.example返回 400Disallowed CORS origin。我的前端开在 3049,启动时设了SUPPORTPILOT_WEB_ORIGINS=http://localhost:3049。allow_headers=["content-type", "accept"],前端 fetch 只带这两个请求头(见sse.ts),多放行没用。subject: str = Field(max_length=200)超长的请求在进函数之前就被 pydantic 拦下,返回 422,不会写进数据库。class Decision(BaseModel):审批的请求体。实测漏传 approver,只发{"approved": true},返回 422,detail 里是"loc": ["body", "approver"], "msg": "Field required"。def _graph():每个请求现编译一张图:deps 用fresh()拿新账本,checkpointer 共用。return {"configurable": {"thread_id": ticket_id}}thread_id 直接用工单号,checkpointer 靠它区分工单。实测踩到一个坑:seed_db.py重新造数后工单号又从 T000153 开始,但 checkpoint 表没清,新工单会接上旧工单的状态。重新造数时要把 checkpoint 那几张表一起 truncate。def _events(graph, payload, ticket_id: str):整个接口层的核心:把 LangGraph 的流翻译成前端认识的六种事件。for mode, chunk in graph.stream(payload, _cfg(ticket_id), stream_mode=["updates", "custom"]):两种模式一起要,每个元素是(模式, 数据)二元组。实测一张真实的退款工单,这个循环一共吐了 9 个节点更新、1 个__interrupt__,外加 259 条 custom(5 个 step、3 个 tool、251 个 token),前后 16.7 秒。if mode == "custom":custom 是节点里_emit()推的字典,自带type字段。yield {"event": chunk["type"], "data": json.dumps(chunk, ensure_ascii=False)}直接拿type当 SSE 的事件名。实测第一条是event: step/data: {"type": "step", "node": "load_customer", "found": true}。ensure_ascii=False让中文原样输出,不加的话每个 token 都是一串\u转义。for node, update in chunk.items():updates 模式的数据是{节点名: 这个节点返回的更新}。if node == "__interrupt__":图停在interrupt()处时,LangGraph 用这个特殊键报告。yield {"event": "interrupt", "data": json.dumps(update[0].valueupdate是 Interrupt 对象组成的元组,.value就是 approval 节点传给interrupt()的字典。实测里面有 type、ticket_id、request、draft、needs_finance_review 五个键;request 是RF-T000153-INV-202609-0002、950.0 元、规则annual_first_14_days,needs_finance_review 是 false(950 没过 1000 的复核线)。default=str不能省:request 里的created_at是 datetime,json 不认识,要转成字符串。yield {"event": "node", "data": json.dumps({"node": node}其他节点只推名字,不推整份更新,因为更新里有整段草稿和工具结果,太大了。实测这一轮的顺序是 load_customer、prepare_context、classify、retrieve、act、draft、check、draft、check:质检退回过一次,draft 和 check 各跑了两遍。yield {"event": "done", "data": json.dumps(_summary(graph, ticket_id)循环结束(跑完了或者停在 interrupt)再推一条 done。注意节点里抛异常时,生成器直接中断,done 不会推。我重建项目时漏配了 Day 46 加的console_url,act 节点调create_refund_request时报 AttributeError,curl 收到 3 个 tool 事件后连接就断了:没有 done,也没有任何错误事件。这就是练习 7 要补 error 事件的原因。snap = graph.get_state(_cfg(ticket_id))从 checkpointer 读这张工单的最新状态。return {"ticket_id": ticket_id, "outcome": v.get("outcome") or ("waiting_approval" if snap.next else None),outcome 只有 finalize 和 handoff 才写。停在审批时还没写,就用snap.next不为空推断成 waiting_approval。实测停在审批时snap.next是('approval',)。"tool_log": v.get("tool_log", []), "cost_cny": round(v.get("cost_cny", 0.0), 5),实测停在审批时 tool_log 有 3 条,cost_cny 是 0.04563,reply 是 None(还没定稿)。"waiting_on": list(snap.next)}tuple 转成 list,JSON 才认。实测是["approval"]。return EventSourceResponse(iterate_in_threadpool(gen))同步生成器包进线程池,事件循环不会被一张工单的十几秒卡住。def create_ticket(t: NewTicket) -> dict:实测 curl 建工单返回{"ticket_id":"T000153"},种子数据里已经有 152 张工单。raise HTTPException(404, "ticket not found")工单号在 biz.tickets 里查不到。return _sse(_events(_graph(), {"ticket": dict(row)}, ticket_id))第一轮的输入只有 ticket 这一个字段。dict(row)是因为 psycopg 的行对象不能直接塞进状态。if not snap.values:没跑过的工单在 checkpointer 里没有状态,values是空字典。raise HTTPException(409, "工单还在等待审批实测停在审批时追问「多久能到账?」,返回 409{"detail":"工单还在等待审批,审批完成后才能继续对话"}。return _sse(_events(graph, {"turns": [{"role": "customer", "content": m.content}]}, ticket_id))追问只追加一条 turns。turns 在 Day 48 的 state 里是用operator.add累加的,所以这里给一条就是加一条,不是覆盖。"SELECT r.*, t.subject, c.name AS customer_name FROM biz.refund_requests r "实测返回一条:idRF-T000153-INV-202609-0002、customer_name 李娜、amount_cny 950.0、ruleannual_first_14_days、subject 想退款、status pending、created_at2026-10-22 07:54:05.306872+08:00,另外 decided_by、decided_at、note 三个是 null。"WHERE r.status = 'pending' ORDER BY r.created_at").fetchall()只看退款申请的状态,不管工单还在不在等审批。实测碰到过一次:那张工单质检两次没过、转了人工(outcome 是 escalated),但 act 节点已经把退款申请建好了,它照样出现在待审批列表里,客服点”通过”拿到 409。return [biz._jsonable(r) | {"created_at": str(r["created_at"])} for r in rows]_jsonable把 Decimal 这类类型转成 JSON 认识的,|合并字典(3.9+),再把 created_at 单独转成字符串。snap = graph.get_state(_cfg(ticket_id))先取一次状态,下面两个判断都用它。if not snap.values:不存在的 thread 的 values 是空字典,next 也是空的。只判断 next 的话,“工单不存在”和”工单存在但不在等审批”都会返回 409,前端分不清。这里先单独拦下来。raise HTTPException(404, "工单不存在或还没有处理过")实测审批NOPE返回 404{"detail":"工单不存在或还没有处理过"}。if "approval" not in snap.next:实测审批完再批一次,返回 409这个工单不在等待审批。return _sse(_events(graph, Command(resume=d.model_dump()), ticket_id))model_dump()把 Decision 转成字典,成为 approval 节点里interrupt()的返回值。实测审批这次的流只有三条:node approval、node finalize、done,done 里 outcome 是 resolved。
六个接口,对应工单的生命周期:
| 方法 | 路径 | 作用 | 返回 |
|---|---|---|---|
| POST | /tickets | 建工单(写进 biz.tickets) | {"ticket_id"} |
| POST | /tickets/{id}/run | 第一次处理这张工单 | SSE 流 |
| POST | /tickets/{id}/messages | 客户追问 | SSE 流 |
| GET | /tickets/{id} | 当前状态(结果、工具记录、成本、是否在等审批) | JSON |
| GET | /approvals | 所有待审批的退款 | JSON 列表 |
| POST | /approvals/{ticket_id} | 审批,恢复工作流 | SSE 流 |
几个设计决定:
建工单和跑工单分开。 一个接口干两件事也可以,但分开后,建工单可以来自任何渠道(网页表单、邮件网关、企业微信),跑工单可以是同步触发也可以是后台队列触发(Day 37 讲过)。另外前端拿到 ticket_id 之后再连 SSE,断线重连时知道自己在跑哪张工单。
追问前检查状态。 如果工单还停在审批节点(snap.next 不为空),这时追问会和审批冲突,返回 409。返回 409 而不是默默排队,是让前端明确告诉用户”等审批完再说”。
审批接口也是流式的。 审批后工作流要从 approval 节点接着跑 finalize,前端需要看到最终回复和补充说明。
每个请求现编译一张图。 _graph() 每次都 build_graph(deps.fresh(), ...)。编译一张九个节点的图是毫秒级的事,换来每个请求有自己的 LLM 账本。checkpointer 是共用的,因为它就是靠 thread_id 区分工单的。
同步的图,异步的服务
FastAPI 是异步框架,我们的图是同步的(graph.stream 是普通生成器,里面的 openai 调用是阻塞的)。如果直接在 async 函数里跑同步生成器,一张工单处理的十几秒里,整个服务的事件循环都被卡住,别的请求全部排队。
iterate_in_threadpool 把同步生成器放到线程池里跑,每取一个元素就在线程池里执行一步,事件循环不会被阻塞。接口函数本身写成普通的 def(不是 async def),FastAPI 也会把它放进线程池。
另一种做法是把整条链路改成异步:LLMClient 用 AsyncOpenAI,节点写成 async def,用 graph.astream。性能更好,但要改的地方多,psycopg 也要换成异步连接。对一个演示项目,线程池足够了。面试时被问到,能说清楚这个取舍就行。
有一处要说明:所有请求共用一条 psycopg 连接。psycopg 3 的连接可以跨线程使用,但同一时刻只能执行一条语句,并发高了会排队。演示项目单进程、并发很低,没问题。Day 53 部署时会换成连接池。
SSE 事件协议
前端和后端之间约定了六种事件:
| event | data 里有什么 | 前端怎么用 |
|---|---|---|
node | {"node": "classify"} | 某个节点跑完了,进度条打一个勾 |
step | 节点自己推的信息,比如分类结果、检索到的文章、质检结论 | 显示细节;质检不通过时提示正在重写 |
tool | 工具名、参数、是否出错 | 显示一张工具调用卡片 |
token | 一小段文字 | 打字机效果 |
interrupt | 退款申请详情 | 提示”已提交审批” |
done | 最终状态摘要 | 显示最终回复和成本 |
node 来自 LangGraph 的 updates 流模式(每个节点执行完的状态更新),step、tool、token 来自 custom 流模式(节点里用 get_stream_writer 主动推送的)。一次 graph.stream(..., stream_mode=["updates", "custom"]) 两种都拿到,每个元素是 (模式, 数据) 的二元组。
updates 里如果出现 __interrupt__ 这个键,说明图停在了 interrupt 处,我们把它翻译成 interrupt 事件。流结束后再查一次完整状态,推一个 done 事件。
SSE 格式本身很简单,每条消息是 event: 名字 和 data: 内容 两行,后面跟一个空行。sse-starlette 帮我们处理格式、心跳和断线检测。
前端选型:Vercel AI SDK,还是自己读流
做 AI 聊天界面,React 圈子里最顺手的工具是 Vercel 的 AI SDK。2026 年 10 月我查的时候,ai 包是 7.0.x,React 绑定在 @ai-sdk/react 4.0.x,核心是 useChat 这个 hook:消息列表、流式更新、工具调用、甚至工具审批(协议里有 tool-approval-request 这种事件)都帮你管好了。它也支持 Python 后端,官方仓库里有一个 examples/next-fastapi 示例。
听起来该直接用。我最后没用,原因有三条,你可以对照自己的情况判断。
它要求后端说它的协议。 useChat 消费的是 AI SDK 自己的 UI Message Stream 协议:后端响应头要带 x-vercel-ai-ui-message-stream: v1,事件是 text-delta、tool-input-available、data-* 这些固定类型。我们 Day 47 起后端推的是自己定义的 node/step/tool/token/interrupt/done。要用 useChat,就得在 Python 这边再写一层翻译,把 LangGraph 的流改写成它的格式。这层翻译并不难,但它让前端的方便建立在后端迁就前端之上。
我们的交互不完全是”聊天”。 useChat 的模型是同一个用户和 Agent 你来我往,消息列表由前端持有。SupportPilot 的审批是另一个人(客服)在另一个页面做的,恢复后的流程推给的是客服的页面,客户那边要刷新状态才知道结果。硬塞进 useChat 的消息模型,要绕不少弯。
课程的目的。 自己读流只要四十行,读完你会知道一个 SSE 流在浏览器里到底经过了哪些步骤:fetch 拿到字节流、解码成文本、按空行切事件、解析 JSON、进 reducer。面试被问”流式输出前端怎么做”,你能讲到字节层面,而不是”用了个 hook”。
所以今天的选择是:原生 fetch 读响应体,用 eventsource-parser(4.1.x)切事件,自己写一个 useReducer 管状态。如果你以后起一个全新的 TypeScript 项目,后端也是 Node,那 AI SDK 是更好的选择,能省很多代码。课后有一道练习让你把后端改成说 AI SDK 的协议,两种做法都摸一遍。
为什么不能用 EventSource
浏览器自带 EventSource,专门读 SSE,还会自动重连。问题是它只能发 GET,不能带请求体,也不能加自定义请求头。我们的三个流式接口都是 POST:追问要带客户说的话,审批要带决定和备注。
有人会把接口改成 GET,把内容塞进 URL 参数。别这么做:审批是有副作用的操作,GET 会被预取、被缓存、被日志完整记下来,还可能被重放。保持 POST,前端换成 fetch 读流就行。代价是自动重连没了,要自己处理,下面”停止和断线”一节会说。
项目结构
前端放在 SupportPilot 仓库的 web/ 目录,和 Python 包平级。用 create-next-app 生成:
cd supportpilot
npx create-next-app@16.4.0 web --ts --app --no-eslint --no-tailwind --src-dir --import-alias "@/*" --use-npm --yes
cd web && npm i eventsource-parser@4.1.1
rm src/app/page.module.css
我本机生成出来的是 Next.js 16.4.0、React 19.3.0、TypeScript 5.x,构建器默认是 Turbopack。版本号写死是故意的:前端生态半年一个大版本,教程不锁版本,三个月后读者照着敲就对不上了。脚手架自带的 layout.tsx 用了 next/font/google,构建时要从 Google 拉字体,国内网络经常卡死,我们的 layout 里直接去掉了。
web/
├── .env.local # NEXT_PUBLIC_SUPPORTPILOT_API=http://localhost:8000
├── scripts/fake_api.py # 假后端,不花钱调界面用
└── src/
├── app/
│ ├── layout.tsx # 顶部导航
│ ├── page.tsx # 客户视角
│ ├── desk/page.tsx # 客服工作台
│ └── globals.css
├── components/
│ ├── RunView.tsx # 一次运行:状态、步骤、工具卡片、草稿/回复
│ ├── ToolCard.tsx
│ └── Reply.tsx # 回复正文 + 引用
└── lib/
├── events.ts # 事件类型,和后端 _events() 一一对应
├── sse.ts # fetch + eventsource-parser
├── api.ts # 六个接口的类型化封装
├── useRun.ts # reducer + hook
└── reply.ts # 解析 render_sources() 的输出
分层的思路是:lib/ 不碰 DOM,只管协议和状态,可以单独测;components/ 只拿状态渲染,不知道数据从哪来;app/ 的页面把两者接起来。事件协议变了,只改 events.ts,TypeScript 会把所有需要跟着改的地方标红。
事件类型和读流
先把协议写成类型。下面每个分支都对应后端 _events() 里的一种 event:,data 的形状对应 Day 47 节点里 _emit() 推出来的字典:
// supportpilot/web/src/lib/events.ts
// 和后端 api/app.py 的 _events() 一一对应。改后端事件就先改这里,让 TypeScript 帮你找漏掉的地方。
// args 是模型原样给的 JSON 字符串(Day 47 act 节点直接转发 call["arguments"]),error 只是"有没有出错"
export type ToolEvent = { type: "tool"; name: string; args: string; error: boolean };
export type StepEvent =
| { type: "step"; node: "load_customer"; found: boolean }
| { type: "step"; node: "classify"; category: string }
| { type: "step"; node: "retrieve"; docs: string[] }
| { type: "step"; node: "check"; passed: boolean; problems: string[] }
| { type: "step"; node: string; [k: string]: unknown };
export type RefundRequest = { id: string; amount_cny: number; rule: string; reason?: string };
export type Summary = {
ticket_id: string;
outcome: "resolved" | "escalated" | "waiting_approval" | null;
category: string | null;
reply: string | null;
tool_log: unknown[];
cost_cny: number;
waiting_on: string[];
};
export type AgentEvent =
| { event: "node"; data: { node: string } }
| { event: "step"; data: StepEvent }
| { event: "tool"; data: ToolEvent }
| { event: "token"; data: { type: "token"; text: string } }
| {
event: "interrupt";
data: { type: "refund_approval"; ticket_id: string; request: RefundRequest; needs_finance_review: boolean };
}
| { event: "done"; data: Summary }
// Day 49 练习 7 加的:后端出错时推一个 error 事件,而不是让连接断掉
| { event: "error"; data: { message: string } };
export const STEP_NAMES: Record<string, string> = {
load_customer: "查客户档案",
prepare_context: "整理上下文",
classify: "分诊",
retrieve: "检索帮助中心",
act: "查业务数据",
draft: "起草回复",
check: "质检",
approval: "等待审批",
finalize: "定稿",
handoff: "转人工",
};
export type ToolEventargs是字符串、error是布尔值。实测真后端推的是{"type": "tool", "name": "list_invoices", "args": "{\"customer_id\": \"C0002\", \"limit\": 10}", "error": false}。我最早写成Record<string, unknown>和string | null,接上真后端才发现不对,经过见工具卡片那一节。export type StepEvent =联合类型,每个|开头的一行是一种形状。| { type: "step"; node: "classify"; category: string }node写成字面量类型"classify",TypeScript 靠它判断这一支有 category 字段。实测数据是{"type": "step", "node": "classify", "category": "refund"}。| { type: "step"; node: "retrieve"; docs: string[] }实测 docs 是检索到的 5 个切块各自所属的文章 id,有重复:["KB-refund-001", "KB-refund-002", "KB-refund-001", "KB-refund-001", "KB-plans-002"]。| { type: "step"; node: "check"; passed: boolean; problems: string[] }实测一次退回的 problems 是:「草稿最后一句”审核通过后会原路退回”中的退款到账方式”原路退回”,在帮助中心资料和调查结论中均无依据,属于未证实的细节承诺」。| { type: "step"; node: string; [k: string]: unknown };兜底分支:任意节点名、任意字段。代价是 TypeScript 没法只看 node 就确定是哪一支,所以 reducer 里要再用"passed" in e.data确认一次。export type RefundRequest = { id: string; amount_cny: number; rule: string; reason?: string };reason?表示可选。实测后端的 request 还带 invoice_id、customer_id、status、created_at 等字段,类型里没写的字段 TypeScript 不管,运行时照样在对象上。export type Summary = {和后端_summary()的返回一一对应。export type AgentEvent =按event字段区分的联合类型,也叫可辨识联合。switch (e.event)之后,每个 case 里e.data的类型是确定的。| { event: "token"; data: { type: "token"; text: string } }实测 token 是模型一次吐出的一小段,一张工单前十个是 “您好”、”,”、“已”、“查到”、“您的”、“账户”、“及”、“该”、“笔”、“账单”,一共 251 个。data: { type: "refund_approval"; ticket_id: string; request: RefundRequest; needs_finance_review: boolean };实测后端还多推了一个draft(当时的草稿全文),类型里没声明,前端也用不上。| { event: "error"; data: { message: string } };后端现在还不推,先写上,练习 7 补后端。export const STEP_NAMES: Record<string, string> = {节点名翻成中文。Record<string, string>允许查不到,所以 RunView 里写的是STEP_NAMES[node] ?? node。
AgentEvent 是一个按 event 字段区分的联合类型。reducer 里 switch (e.event) 之后,TypeScript 知道每个分支里 e.data 长什么样,写错字段名会直接编译失败。step 那一组最后留了一个宽松的兜底分支,因为节点以后可能会推新的细节,前端不认识就忽略,别因为后端多推了一个字段就崩。
error 事件后端现在还没有,是动手练习第 7 步要加的。类型先写上,前端先处理,后端补上之后就能直接用。
然后是读流:
// supportpilot/web/src/lib/sse.ts
// 我们的流式接口都是 POST,浏览器自带的 EventSource 只能发 GET,所以用 fetch 读响应体,
// 交给 eventsource-parser 切成一条条事件。
import { EventSourceParserStream } from "eventsource-parser/stream";
import type { AgentEvent } from "./events";
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
export async function* streamEvents(
url: string,
init: { method?: string; body?: unknown; signal?: AbortSignal } = {},
): AsyncGenerator<AgentEvent> {
const res = await fetch(url, {
method: init.method ?? "POST",
headers: { "content-type": "application/json", accept: "text/event-stream" },
body: init.body === undefined ? undefined : JSON.stringify(init.body),
signal: init.signal,
});
if (!res.ok || !res.body) {
// FastAPI 的 HTTPException 返回 {"detail": "..."},409 的中文提示直接给用户看
const detail = await res.json().then((j) => j.detail, () => res.statusText);
throw new ApiError(res.status, typeof detail === "string" ? detail : JSON.stringify(detail));
}
// TS 默认的 DOM 类型里 ReadableStream 还不能 for await,用 reader 手动读,哪个浏览器都没问题
const reader = res.body.pipeThrough(new TextDecoderStream()).pipeThrough(new EventSourceParserStream()).getReader();
try {
for (;;) {
const { done, value: msg } = await reader.read();
if (done) return;
if (!msg.event) continue; // sse-starlette 的心跳是注释行,解析器已经丢掉了,这里防一手空事件
yield { event: msg.event, data: JSON.parse(msg.data) } as AgentEvent;
}
} finally {
reader.releaseLock();
}
}
import { EventSourceParserStream } from "eventsource-parser/stream";4.1.1 的流式接口。它是一个 TransformStream:进去的是文本,出来的是一条条{event, data, id}事件对象。export class ApiError extends Error {自定义错误,多带一个 HTTP 状态码。public status: number,构造函数参数前加public,TypeScript 会自动生成同名属性,相当于this.status = status。这叫参数属性。export async function* streamEvents(async function*是异步生成器:里面能 await,也能 yield,外面用for await一个个取。method: init.method ?? "POST",??只在 undefined 或 null 时用默认值,空字符串不会被替换。headers: { "content-type": "application/json", accept: "text/event-stream" },正好是后端 CORS 的allow_headers里放行的两个。body: init.body === undefined ? undefined : JSON.stringify(init.body),run 接口不带请求体;追问带{content};审批带{approved, approver, note}。signal: init.signal,点停止时 abort,fetch 和正在等的read()都会抛 AbortError。const detail = await res.json().then((j) => j.detail, () => res.statusText);then的第二个参数是失败回调:响应体不是 JSON 时退回 statusText。实测停在审批时追问,拿到的 detail 是「工单还在等待审批,审批完成后才能继续对话」。throw new ApiError(res.status, typeof detail === "string" ? detail : JSON.stringify(detail));422 的 detail 是数组(每个字段的校验错误),要转成字符串。const reader = res.body.pipeThrough(new TextDecoderStream()).pipeThrough(new EventSourceParserStream()).getReader();字节 → 文本 → 事件,两次pipeThrough。实测真后端每行用\r\n结尾(event: step\r\ndata: {...}\r\n\r\n),假后端用\n,解析器两种都认。const { done, value: msg } = await reader.read();解构的同时把value改名成msg。if (done) return;流结束,生成器结束,外面的for await自然退出。yield { event: msg.event, data: JSON.parse(msg.data) } as AgentEvent;as AgentEvent是断言,不是校验:后端推什么就当成什么。tool 事件的形状和最初写的类型对不上,TypeScript 一声没吭,就是因为这里的as。reader.releaseLock();写在 finally 里,正常结束、出错、被 abort 都会释放读锁。
这几十行里有几个细节值得说。
TextDecoderStream 不能省。 网络分片是按字节切的,一个汉字三个字节,完全可能被切在两个包里。自己 new TextDecoder().decode(chunk) 逐块解码的话,切断的汉字会变成乱码。TextDecoderStream 内部会把半个字符留到下一块再拼,这个坑我们不用自己踩。
事件的切分交给 eventsource-parser。 SSE 的规则不复杂:按空行分隔事件,event: 行是名字,data: 行是内容,可以有多行 data:,以冒号开头的是注释。但自己写的话,“一个事件被切在两个网络包里”这种情况很容易漏。sse-starlette 默认每 15 秒发一条注释当心跳,防止代理把空闲连接掐掉,解析器会把注释吞掉,我们拿到的只有真事件。
出错时把后端的 detail 带出来。 FastAPI 的 HTTPException(409, "工单还在等待审批……") 返回 {"detail": "..."}。这句话是写给用户看的,前端原样显示,比”请求失败”有用得多。
为什么用 reader 而不是 for await。 ReadableStream 在新浏览器里可以直接 for await 遍历,但 TypeScript 默认的 DOM 类型声明还没跟上,next build 的类型检查会报 TS2504。我第一次就是这么写的,构建失败,改成 getReader() 手动读,任何环境都没问题。
接口封装就是把六个接口写成六个函数:
// supportpilot/web/src/lib/api.ts
import { ApiError, streamEvents } from "./sse";
import type { RefundRequest, Summary } from "./events";
export const API = process.env.NEXT_PUBLIC_SUPPORTPILOT_API ?? "http://localhost:8000";
async function json<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...init?.headers } });
if (!res.ok) {
const detail = await res.json().then((j) => j.detail, () => res.statusText);
throw new ApiError(res.status, String(detail));
}
return res.json() as Promise<T>;
}
export type PendingRefund = RefundRequest & {
ticket_id: string;
customer_name: string;
subject: string | null;
created_at: string;
};
export const createTicket = (t: { customer_email: string; subject: string; body: string }) =>
json<{ ticket_id: string }>("/tickets", { method: "POST", body: JSON.stringify(t) });
export const getTicket = (id: string) => json<Summary>(`/tickets/${id}`);
export const listApprovals = () => json<PendingRefund[]>("/approvals");
export const runTicket = (id: string, signal: AbortSignal) => streamEvents(`${API}/tickets/${id}/run`, { signal });
export const followUp = (id: string, content: string, signal: AbortSignal) =>
streamEvents(`${API}/tickets/${id}/messages`, { body: { content }, signal });
export const decide = (
ticketId: string,
d: { approved: boolean; approver: string; note: string },
signal: AbortSignal,
) => streamEvents(`${API}/approvals/${ticketId}`, { body: d, signal });
export const API = process.env.NEXT_PUBLIC_SUPPORTPILOT_API ?? "http://localhost:8000";构建时被替换成字面量。实测我用NEXT_PUBLIC_SUPPORTPILOT_API=http://localhost:8049 npx next dev -p 3049起,浏览器发出的四个请求都打到http://localhost:8049/...:POST /tickets、POST /tickets/T000153/run、GET /tickets/T000153、POST /tickets/T000153/messages。async function json<T>(path: string, init?: RequestInit): Promise<T> {泛型T由调用方指定返回的形状,比如json<Summary>(...)。const res = await fetch(API + path, { ...init, headers: { "content-type": "application/json", ...init?.headers } });...init?.headers是可选链加展开:init 没传时展开的是 undefined,什么都不加。return res.json() as Promise<T>;同样是断言,不校验。export type PendingRefund = RefundRequest & {&是交叉类型,两边的字段合在一起。json<{ ticket_id: string }>("/tickets", { method: "POST", body: JSON.stringify(t) });实测返回{"ticket_id":"T000153"}。export const runTicket = (id: string, signal: AbortSignal) => streamEvents(返回的是生成器。调用的那一刻还没发请求,第一次取值时才真正 fetch。export const decide = (审批也是 POST + SSE,所以同样走streamEvents,不能用 EventSource。
API 地址从 NEXT_PUBLIC_SUPPORTPILOT_API 读。Next.js 只会把 NEXT_PUBLIC_ 开头的环境变量打进浏览器端的代码,而且是在构建时替换成字面量,不是运行时读。所以部署时换 API 地址要重新 build,这个和后端读环境变量的习惯不一样,第一次部署经常栽在这。
前后端不同源(3000 和 8000),浏览器会先发 OPTIONS 预检请求。接口设计那一节给 FastAPI 加的 CORSMiddleware 就是为这个。另一种做法是用 Next.js 的 rewrites 把 /api/* 代理到后端,同源就没有跨域问题,但开发服务器转发 SSE 时可能缓冲,我没用这条路。
状态:一次运行就是一个 reducer
一次运行(第一次跑、一次追问、一次审批恢复)会收到几十到几百个事件。用一堆 useState 各管一块很快就乱,这里用 useReducer:事件进 reducer,算出新状态,组件只读状态。
// supportpilot/web/src/lib/useRun.ts
// 一次运行(run / 追问 / 审批恢复)的全部界面状态。事件流进 reducer,组件只管渲染。
"use client";
import { useCallback, useReducer, useRef } from "react";
import type { AgentEvent, RefundRequest, Summary, ToolEvent } from "./events";
import { ApiError } from "./sse";
export type RunStatus = "idle" | "running" | "waiting_approval" | "done" | "error" | "stopped";
export type RunState = {
status: RunStatus;
steps: string[]; // 跑完的节点,按顺序
details: Record<string, string>; // 节点内部推的细节,按节点名挂上去
tools: ToolEvent[];
draft: string; // 打字机里的草稿
rewrites: number; // 质检退回了几次
approval: { request: RefundRequest; needsFinanceReview: boolean } | null;
summary: Summary | null;
error: string | null;
};
const initial: RunState = {
status: "idle",
steps: [],
details: {},
tools: [],
draft: "",
rewrites: 0,
approval: null,
summary: null,
error: null,
};
type Action =
| { type: "start" }
| { type: "event"; e: AgentEvent }
| { type: "fail"; message: string }
| { type: "stopped" };
function stepDetail(e: Extract<AgentEvent, { event: "step" }>["data"]): string | undefined {
if (e.node === "classify" && "category" in e) return `分类:${String(e.category)}`;
if (e.node === "retrieve" && "docs" in e && Array.isArray(e.docs)) {
const docs = [...new Set(e.docs)]; // 后端按切块推,同一篇文章的几个块会重复出现
return `命中 ${docs.length} 篇:${docs.join("、")}`;
}
if (e.node === "load_customer" && "found" in e) return e.found ? "找到客户" : "没找到这个邮箱对应的客户";
return undefined;
}
export function reducer(s: RunState, a: Action): RunState {
switch (a.type) {
case "start":
return { ...initial, status: "running" };
case "fail":
return { ...s, status: "error", error: a.message };
case "stopped":
return s.status === "running" ? { ...s, status: "stopped" } : s;
case "event": {
const e = a.e;
switch (e.event) {
case "node":
return { ...s, steps: [...s.steps, e.data.node] };
case "step": {
// 质检没过:draft 节点会从头重写,旧草稿必须清掉,否则两版文字会接在一起
if (e.data.node === "check" && "passed" in e.data && e.data.passed === false) {
return { ...s, draft: "", rewrites: s.rewrites + 1 };
}
// step 在节点内部推,比 node(节点跑完)早到。细节单独存,渲染时按节点名对上,不要再插一行
const detail = stepDetail(e.data);
return detail ? { ...s, details: { ...s.details, [e.data.node]: detail } } : s;
}
case "tool":
return { ...s, tools: [...s.tools, e.data] };
case "token":
return { ...s, draft: s.draft + e.data.text };
case "interrupt":
return { ...s, approval: { request: e.data.request, needsFinanceReview: e.data.needs_finance_review } };
case "error":
return { ...s, status: "error", error: e.data.message };
case "done":
return {
...s,
summary: e.data,
status: e.data.outcome === "waiting_approval" ? "waiting_approval" : "done",
};
}
}
}
}
export function useRun() {
const [state, dispatch] = useReducer(reducer, initial);
const ctrl = useRef<AbortController | null>(null);
const start = useCallback(async (open: (signal: AbortSignal) => AsyncGenerator<AgentEvent>) => {
ctrl.current?.abort();
const c = new AbortController();
ctrl.current = c;
dispatch({ type: "start" });
try {
for await (const e of open(c.signal)) dispatch({ type: "event", e });
} catch (err) {
if (c.signal.aborted) dispatch({ type: "stopped" });
else dispatch({ type: "fail", message: err instanceof ApiError ? err.message : "连接中断,请稍后重试" });
}
}, []);
const stop = useCallback(() => ctrl.current?.abort(), []);
// 不走流,直接塞一个事件进来。用于审批完成后刷新最终状态(GET /tickets/{id} 返回的就是 done 的格式)
const apply = useCallback((e: AgentEvent) => dispatch({ type: "event", e }), []);
return { state, start, stop, apply };
}
"use client";Next.js App Router 里的模块默认在服务端渲染,用到useReducer这类 hook 的要声明成客户端模块。export type RunStatus =六个状态,对应正文那张状态图。function stepDetail(e: Extract<AgentEvent, { event: "step" }>["data"]): string | undefined {Extract从联合类型里挑出 event 是 “step” 的那个成员,再用["data"]取它的 data 类型,也就是 StepEvent。if (e.node === "classify" && "category" in e) return实测得到「分类:refund」。const docs = [...new Set(e.docs)];Set 去重,再展开回数组,顺序保持第一次出现的顺序。实测["KB-refund-003", "KB-refund-001", "KB-plans-002", "KB-refund-001", "KB-refund-001"]去重后剩 3 个。我一开始没去重,页面上写着「命中 5 篇」,其中 3 个其实是同一篇。命中 ${docs.length} 篇实测显示「命中 3 篇:KB-refund-003、KB-refund-001、KB-plans-002」。if (e.node === "load_customer" && "found" in e)实测 lina@example.com 能找到,显示「找到客户」。export function reducer(s: RunState, a: Action): RunState {纯函数,同样的输入一定是同样的输出。下面的实测值就是我用 Node 直接调这个函数、喂真实事件拿到的。return { ...initial, status: "running" };实测 start 之后:status 是 running,其余字段全是初始值。return s.status === "running" ? { ...s, status: "stopped" } : s;已经结束的运行再 abort 不改状态。实测对一个 waiting_approval 的状态发 stopped,返回的就是原对象(===为 true),React 也就不会重新渲染。return { ...s, steps: [...s.steps, e.data.node] };实测事件顺序是先 step(load_customer, found: true)再 node(load_customer):step 到了之后 steps 还是[],details 已经有{load_customer: "找到客户"};node 到了,steps 才变成["load_customer"]。if (e.data.node === "check" && "passed" in e.data && e.data.passed === false) {质检没过才清草稿,通过的那次不动。return { ...s, draft: "", rewrites: s.rewrites + 1 };实测草稿打到 “您好,” 时收到 passed: false,draft 变成空串,rewrites 从 0 变 1。真模型那次,第一版草稿结尾是「审核通过后会原路退回」,被质检以”原路退回没有依据”退回;第二版改成了「审批结果出来后,我们会再通知您」。return detail ? { ...s, details: { ...s.details, [e.data.node]: detail } } : s;[e.data.node]是计算属性名,用变量的值当键。stepDetail 返回 undefined 的(比如 check passed: true)直接返回原状态。return { ...s, draft: s.draft + e.data.text };打字机就是不停地拼字符串。return { ...s, approval: { request: e.data.request, needsFinanceReview: e.data.needs_finance_review } };顺手把后端的下划线命名转成驼峰。status: e.data.outcome === "waiting_approval" ? "waiting_approval" : "done",实测停在审批的那次,done 之后 status 变成 waiting_approval。const ctrl = useRef<AbortController | null>(null);用 useRef 存控制器:改它不触发渲染,又能在多次渲染之间保留。const start = useCallback(async (open: (signal: AbortSignal) => AsyncGenerator<AgentEvent>) => {传进来的是”怎么打开流”的函数,不是流本身。这样 start 能先建好 AbortController,再把 signal 交给它。for await (const e of open(c.signal)) dispatch({ type: "event", e });每来一个事件就 dispatch 一次。{ type: "event", e }是{ type: "event", e: e }的简写。if (c.signal.aborted) dispatch({ type: "stopped" });实测追问时点停止,状态行变成「已停止接收」,假后端那边打印client disconnected。else dispatch({ type: "fail", message: err instanceof ApiError ? err.message : "连接中断,请稍后重试" });不是主动停的才算出错。后端 409 的中文提示原样显示,网络断了才用兜底文案。const apply = useCallback((e: AgentEvent) => dispatch({ type: "event", e }), []);实测客户页点「刷新进展」:GET /tickets/T000153 拿到 outcome 是 resolved 的摘要,当成 done 塞进来,状态行变成「已完成 · 花费 ¥0.03198」,追问框恢复可用。
reducer 里有两处是踩过坑才写成这样的。
step 和 node 的顺序。 后端的 step 事件是节点执行中推的(比如分诊结果、检索到哪几篇),node 事件是节点跑完后才推的。我最早的写法是两种事件各往步骤列表里插一行,结果页面上出现”✓ 分诊 · 分类:refund”紧跟着又一个”✓ 分诊”。现在 steps 只由 node 事件追加,step 带来的细节存进 details,按节点名挂上去。这种”同一件事从两个通道到达”的情况,在事件驱动的界面里很常见,先想清楚哪个事件是这件事的”权威来源”。
质检退回时清空草稿。 draft 节点会重新生成,新的 token 从头推。不清空的话,第二版接在第一版后面,用户看到两段互相矛盾的文字。顺手记一个 rewrites 计数,界面上提示”质检退回 1 次,正在重写”,用户就知道刚才那段话为什么消失了。
start 先 abort 上一次。 客户连点两次发送,或者上一次还没结束就发起新的追问,旧的流要先断掉,否则两个流的事件会交替进同一个 reducer。
apply 用来塞一个不是从流里来的事件。 GET /tickets/{id} 返回的摘要和 done 事件的 data 是同一个形状(后端都是 _summary() 生成的),所以客户页面刷新进展时,直接当成一个 done 事件喂进去,不用再写一套更新逻辑。前后端共用一种数据形状,能省掉很多这样的胶水代码。
React 会把短时间内连续的状态更新合并成一次渲染,一个网络包里来十几个 token,通常只渲染一次,所以这里没有自己做节流。如果你的模型吐字特别快、回复特别长,页面开始掉帧,再考虑把 token 攒到 requestAnimationFrame 里批量提交。
组件:过程、工具卡片、回复和引用
// supportpilot/web/src/components/RunView.tsx
"use client";
import { STEP_NAMES } from "@/lib/events";
import type { RunState } from "@/lib/useRun";
import { Reply } from "./Reply";
import { ToolCard } from "./ToolCard";
const STATUS_TEXT: Record<RunState["status"], string> = {
idle: "",
running: "处理中",
waiting_approval: "已提交审批,等客服处理",
done: "已完成",
error: "出错了",
stopped: "已停止接收",
};
export function RunView({ state, id, onStop }: { state: RunState; id: string; onStop?: () => void }) {
if (state.status === "idle") return null;
const running = state.status === "running";
const final = state.summary?.reply;
return (
<section className="run" aria-busy={running} aria-labelledby={`${id}-status`}>
<header className="run-head">
{/* 只把状态变化读给屏幕阅读器听;逐字的 token 不播报,否则读屏会疯掉 */}
<h3 id={`${id}-status`} aria-live="polite" className={`badge badge-${state.status}`}>
{STATUS_TEXT[state.status]}
{state.summary && ` · 花费 ¥${state.summary.cost_cny}`}
</h3>
{running && onStop && (
<button type="button" className="btn-ghost" onClick={onStop}>
停止
</button>
)}
</header>
<ol className="steps">
{state.steps.map((node, i) => (
<li key={i}>
<span className="tick" aria-hidden="true">
✓
</span>
{STEP_NAMES[node] ?? node}
{state.details[node] && <span className="step-detail"> · {state.details[node]}</span>}
</li>
))}
{running && <li className="pending">…</li>}
</ol>
{state.rewrites > 0 && (
<p className="note">
质检退回 {state.rewrites} 次{running && ",正在重写回复"}
</p>
)}
{state.tools.length > 0 && <ul className="tools">{state.tools.map((t, i) => <ToolCard key={i} tool={t} />)}</ul>}
{state.approval && (
<p className="note note-approval">
退款申请 {state.approval.request.id}(¥{state.approval.request.amount_cny})已提交
{state.approval.needsFinanceReview && ",金额超过复核线,需要财务复核"}
</p>
)}
{final ? (
<Reply text={final} id={id} />
) : (
state.draft && (
<p className={`reply-body ${running ? "typing" : "draft"}`} aria-hidden={running}>
{!running && <span className="muted">草稿,审批后定稿:</span>}
{state.draft}
</p>
)
)}
{state.error && (
<p className="error" role="alert">
{state.error}
</p>
)}
</section>
);
}
const STATUS_TEXT: Record<RunState["status"], string> = {RunState["status"]取出那个六选一的类型。用它当 Record 的键,六个状态一个都不能少,漏写一个就编译失败。export function RunView({ state, id, onStop }参数直接解构 props。onStop?可选:历史轮次不传,也就不显示停止按钮。if (state.status === "idle") return null;还没开始的运行什么都不画。const final = state.summary?.reply;?.可选链:summary 是 null 时整个表达式是 undefined,不会报错。<section className="run" aria-busy={running}实测停在审批之后,aria-busy 是 “false”。aria-live="polite"只有这一行会被读屏软件播报。实测它先后显示「处理中」(第 0.1 秒)和「已提交审批,等客服处理 · 花费 ¥0.03198」(第 10.2 秒),中间的 251 个 token 一个都不播。{state.summary &&summary 到了才显示花费,跑的过程中不显示。{state.steps.map((node, i) => (实测 10.2 秒时列表是:✓查客户档案 · 找到客户、✓整理上下文、✓分诊 · 分类:refund、✓检索帮助中心 · 命中 3 篇、✓查业务数据、✓起草回复、✓质检。{STEP_NAMES[node] ?? node}不认识的节点名原样显示,不至于空着。{state.details[node] &&step 事件带来的细节按节点名挂在对应的那一行后面。{running && <li className="pending">…</li>}跑的时候末尾挂一个省略号,表示后面还有。{state.rewrites > 0 && (实测跑的过程中显示「质检退回 1 次,正在重写回复」,结束后变成「质检退回 1 次」。我最早没判断 running,结束了还挂着”正在重写回复”,是看截图发现的:那张工单质检退回 2 次后转了人工,页面上写着「已完成」,下面却是「质检退回 2 次,正在重写回复」。质检退回 {state.rewrites} 次{running && ",正在重写回复"}running 是 false 时,{false}在 React 里什么都不渲染。{state.tools.length > 0 &&实测 4 张卡片:list_invoices、list_subscriptions、check_refund_eligibility 是查询,create_refund_request 是写操作。退款申请 {state.approval.request.id}实测显示「退款申请 RF-T000153-INV-202609-0002(¥950)已提交」。{state.approval.needsFinanceReview &&950 元没超过 1000 元的复核线,这半句不显示。{final ? (有定稿就显示定稿(带引用),没有就显示打字机里的草稿。aria-hidden={running}>实测停在审批时这个段落的 class 是reply-body draft,aria-hidden 是 “false”,读屏软件可以读草稿了。{!running && <span className="muted">草稿,审批后定稿:</span>}实测这段文字是「草稿,审批后定稿:您好,您这笔 Pro 年付是首次购买,付款日 2026-09-24,目前是第 7 天……」。<p className="error" role="alert">role="alert"的内容一出现就会被播报。
RunView 分四块:顶部状态和花费、步骤列表、工具卡片、正文。正文有两种形态:跑的过程中显示打字机里的草稿;收到 done 之后,如果摘要里有 reply,换成后端定稿的回复(带引用)。停在审批时还没有定稿,继续显示草稿,并标明”草稿,审批后定稿”,免得客户以为这就是最终答复。
// supportpilot/web/src/components/ToolCard.tsx
import type { ToolEvent } from "@/lib/events";
// 写操作给客服看要醒目,和只读查询区分开
const WRITES = new Set(["create_refund_request", "escalate_to_human"]);
// 参数是模型给的 JSON 字符串:能解析就排版,解析不了(模型偶尔会给坏 JSON)就原样显示
function pretty(args: string): string {
try {
return JSON.stringify(JSON.parse(args), null, 2);
} catch {
return args;
}
}
export function ToolCard({ tool }: { tool: ToolEvent }) {
const write = WRITES.has(tool.name);
return (
<li className={`tool ${write ? "tool-write" : ""} ${tool.error ? "tool-error" : ""}`}>
<div className="tool-head">
<code>{tool.name}</code>
<span className="tool-tag">{tool.error ? "出错" : write ? "写操作" : "查询"}</span>
</div>
<details>
<summary>参数</summary>
<pre>{pretty(tool.args)}</pre>
</details>
{tool.error && <p className="tool-err">工具返回了错误,Agent 会看到错误信息再决定下一步</p>}
</li>
);
}
const WRITES = new Set(["create_refund_request", "escalate_to_human"]);只决定显示样式。权限在后端 Day 46 的工具分级里管,前端改了这一行也调不了不该调的工具。function pretty(args: string): string {参数现在是字符串,单独写个函数,先排版再显示。return JSON.stringify(JSON.parse(args), null, 2);先解析再排版。实测"{\"customer_id\": \"C0002\", \"limit\": 10}"变成两格缩进的三行。} catch {catch 后面不写变量是 ES2019 起的写法,用不到错误对象就不用声明。return args;模型偶尔会给不合法的 JSON,解析失败就原样显示,不让整个卡片崩掉。write ? "tool-write" : ""实测 create_refund_request 那张卡片的 class 是tool tool-write,查询卡片是tool。多出来的空格是空字符串拼的,不影响样式。<span className="tool-tag">实测四张卡片的标签依次是 查询、查询、查询、写操作。<pre>{pretty(tool.args)}</pre>修之前这里写的是JSON.stringify(tool.args, null, 2),对一个字符串再编码一次。实测参数框里显示的是"{\"customer_id\": \"C0002\"}",外面一圈引号,里面全是反斜杠。{tool.error && <p className="tool-err">error 是布尔值。原来写的是{tool.error},想显示错误文字,但 React 遇到 true 什么都不渲染,出错的卡片下面会是一个空段落。具体的错误文字在后端的 tool_log 里,客服要看可以走 GET /tickets/{id}。
注意 args 的类型。Day 47 的 act 节点推 tool 事件时,args 直接用的是模型返回的 call["arguments"],那是一段 JSON 字符串,error 是 call_tool 返回的布尔值。我最早按”参数对象 + 错误文字”写的类型,假后端也照这个形状造数据,界面看着一切正常;接上真后端才发现参数框里显示的是 "{\"customer_id\": \"C0002\"}",被编码了两遍。所以假后端的数据形状一定要从真后端抓一份来对,别凭记忆写。
工具卡片默认只显示工具名,参数折叠在 <details> 里。客服排查时点开看,客户不会被一屏 JSON 吓到。写操作(创建退款申请、转人工)用另一种底色,和只读查询一眼区分开。这个”写操作清单”在前端只是用来显示,真正的权限控制在后端 Day 46 的工具分级里,前端不能当安全边界。
回复里的引用要专门处理一下。Day 45 的 render_sources() 输出的是纯文本:正文里是 [1] [2],末尾一段”参考文章:“,每行一个标题加链接;审批通过后 finalize 节点还会在最后追加一句”补充:……”。前端把它拆成结构化数据:
// supportpilot/web/src/lib/reply.ts
// 后端 render_sources() 输出:正文里是 [1] [2],末尾一段「参考文章:」,每行 "[n] 标题 链接"。
// 这里拆成结构化数据,前端自己渲染,不把模型输出当 HTML/Markdown 塞进页面。
export type Source = { n: number; title: string; url: string };
export type ParsedReply = { body: string; sources: Source[] };
const FOOTER = "\n\n参考文章:\n";
const LINE = /^\[(\d+)\]\s+(.+?)\s+(https?:\/\/\S+)$/;
export function parseReply(reply: string): ParsedReply {
const at = reply.indexOf(FOOTER);
if (at < 0) return { body: reply, sources: [] };
const sources: Source[] = [];
const tail: string[] = [];
for (const line of reply.slice(at + FOOTER.length).split("\n")) {
const m = LINE.exec(line.trim());
if (m) sources.push({ n: Number(m[1]), title: m[2], url: m[3] });
else if (line.trim()) tail.push(line); // finalize 追加的「补充:…审核通过」在参考文章后面
}
const body = reply.slice(0, at) + (tail.length ? "\n\n" + tail.join("\n") : "");
return { body, sources };
}
// 把正文切成文字段和引用标记,方便渲染成上标链接
export function splitCitations(body: string): (string | number)[] {
return body.split(/\[(\d+)\]/).map((part, i) => (i % 2 === 1 ? Number(part) : part));
}
const FOOTER = "\n\n参考文章:\n";和后端render_sources()拼的格式一字不差。实测审批通过后的定稿一共 327 个字符,这个分隔符在第 152 个字符处。const LINE = /^\[(\d+)\]\s+(.+?)\s+(https?:\/\/\S+)$/;三个捕获组:编号、标题、链接。(.+?)是非贪婪匹配,配合后面的\s+(https?...)$,标题里有空格(「年付超过 14 天怎么退」)也能正确切开。实测[1] 退款政策总览 https://help.cloudledger.example/help/refund/001匹配出["1", "退款政策总览", "https://help.cloudledger.example/help/refund/001"]。if (at < 0) return { body: reply, sources: [] };实测传一句没有参考文章的回复,body 原样返回,sources 是空数组。转人工那句固定回复「您好,您的问题已经转给人工客服同事……」就走这里。for (const line of reply.slice(at + FOOTER.length).split("\n")) {实测切出四行:两行参考文章、一个空行、一行”补充:……”。if (m) sources.push({ n: Number(m[1]), title: m[2], url: m[3] });实测 sources 是[{n: 1, title: "退款政策总览", url: ".../refund/001"}, {n: 2, title: "年付超过 14 天怎么退", url: ".../refund/003"}]。else if (line.trim()) tail.push(line);实测 tail 只有一行:「补充:您的退款申请已审核通过,950.0 元将原路退回,到账时间以支付渠道为准。」950.0 带个小数点,是因为后端 approval 节点直接拼了一个 float。const body = reply.slice(0, at) + (tail.length ? "\n\n" + tail.join("\n") : "");把”补充”那句挪回正文末尾,参考文章单独放。return body.split(/\[(\d+)\]/).mapsplit 用的正则里有捕获组时,捕获到的内容也会留在结果数组里。实测"a [1] b [2] c"切成["a ", "1", " b ", "2", " c"],奇数下标正好是编号;真实的定稿切出 5 段:文字、1、文字、2、文字。
// supportpilot/web/src/components/Reply.tsx
import { parseReply, splitCitations } from "@/lib/reply";
export function Reply({ text, id }: { text: string; id: string }) {
const { body, sources } = parseReply(text);
return (
<div className="reply">
<p className="reply-body">
{splitCitations(body).map((part, i) =>
typeof part === "number" ? (
<sup key={i}>
<a href={`#${id}-ref-${part}`} aria-label={`参考文章 ${part}`}>
[{part}]
</a>
</sup>
) : (
<span key={i}>{part}</span>
),
)}
</p>
{sources.length > 0 && (
<ol className="sources" aria-label="参考文章">
{sources.map((s) => (
<li key={s.n} id={`${id}-ref-${s.n}`} value={s.n}>
<a href={s.url} target="_blank" rel="noopener noreferrer">
{s.title}
</a>
</li>
))}
</ol>
)}
</div>
);
}
const { body, sources } = parseReply(text);先拆成正文和参考文章两部分。typeof part === "number" ? (splitCitations 返回的数组里,数字是引用编号,字符串是正文。<sup key={i}>实测审批后的卡片里渲染出 2 个上标,链接是#run-RF-T000153-INV-202609-0002-ref-1和-ref-2,前缀来自工作台传进来的 id。<span key={i}>{part}</span>正文走文本节点,React 自动转义,模型吐出<img ...>也只会显示成字符。<li key={s.n}value={s.n}让有序列表的序号和正文里的 [n] 对上,哪怕编号不是从 1 连续开始。target="_blank" rel="noopener noreferrer"实测两个链接:「退款政策总览」指向 …/refund/001,「年付超过 14 天怎么退」指向 …/refund/003,rel 都是 noopener noreferrer,新页面拿不到 window.opener。
正文里的 [1] 渲染成上标链接,点了跳到下面的参考文章列表;参考文章渲染成真正的 <a>,target="_blank" 加 rel="noopener noreferrer"。
这里有一条原则:模型的输出永远当纯文本处理。 不用 dangerouslySetInnerHTML,也不引一个 Markdown 渲染库把它直接转成 HTML。模型可能被提示注入,吐出一个 <img src="https://evil.invalid/?q=客户邮箱">,浏览器一渲染就把数据发出去了。Day 53 会专门讲这种外泄方式,今天在界面层先把门关上:React 的文本节点天然会转义,我们只把自己解析出来、格式可控的链接渲染成 <a>。引用链接的格式是后端 render_sources() 生成的,不是模型直接写的,这也是为什么要后端来拼,不让模型自己写 URL。
两个页面
布局和样式:
// supportpilot/web/src/app/layout.tsx
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";
export const metadata: Metadata = { title: "SupportPilot", description: "CloudLedger 智能客服演示" };
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="zh-CN">
<body>
<nav className="topnav" aria-label="主导航">
<strong>SupportPilot</strong>
<Link href="/">客户视角</Link>
<Link href="/desk">客服工作台</Link>
</nav>
<main>{children}</main>
</body>
</html>
);
}
export const metadata: Metadata = {只有服务端组件能导出 metadata,所以 layout 没写 “use client”。export default function RootLayout({ children }: LayoutProps<"/">) {LayoutProps是 Next.js 16 根据路由自动生成的全局类型,不用 import。实测next build的类型检查通过(TypeScript 5.9.3)。<html lang="zh-CN">读屏软件按中文读。<nav className="topnav" aria-label="主导航">页面上不止一个 nav 时,读屏靠 aria-label 区分。
/* supportpilot/web/src/app/globals.css 够用就行,不引 UI 库 */
:root { --ink:#1e2329; --soft:#474d57; --faint:#707a8a; --rule:#e6e8ea; --bg:#fafafa; --card:#fff;
--accent:#9a6c00; --accent-bg:#fdf3d1; --tool:#03875a; --tool-bg:#dcf5ea; --danger:#c62828; color-scheme: light dark; }
@media (prefers-color-scheme: dark) { :root { --ink:#eaecef; --soft:#b7bdc6; --faint:#848e9c; --rule:#2b3139;
--bg:#0b0e11; --card:#181a20; --accent:#f0b90b; --accent-bg:#2b2410; --tool:#0ecb81; --tool-bg:#0f2a20; --danger:#f6465d; } }
* { box-sizing: border-box; }
body { margin: 0; font: 16px/1.7 system-ui, "PingFang SC", sans-serif; color: var(--ink); background: var(--bg); }
.topnav { display: flex; gap: 1.2rem; align-items: center; padding: .8rem 1.5rem; border-bottom: 1px solid var(--rule); }
.topnav a { color: var(--soft); }
.page { max-width: 52rem; margin: 0 auto; padding: 1.5rem; }
label { display: grid; gap: .3rem; font-size: .9rem; color: var(--soft); }
input, select, textarea, button { font: inherit; }
input, select, textarea { padding: .5rem .6rem; border: 1px solid var(--rule); border-radius: 6px; background: var(--card); color: var(--ink); }
button { padding: .5rem 1rem; border-radius: 6px; border: 1px solid var(--accent); background: var(--accent); color: var(--bg); cursor: pointer; }
button:disabled { opacity: .45; cursor: not-allowed; }
button:focus-visible, a:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.btn-ghost, .link { background: transparent; color: var(--accent); }
.link { border: 0; padding: 0; text-decoration: underline; }
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
.ticket-form, .decide { display: grid; gap: .8rem; }
.decide { grid-template-columns: 1fr auto auto; align-items: end; }
.ask { display: flex; gap: .5rem; margin-top: 1rem; } .ask input { flex: 1; }
.muted { color: var(--faint); font-size: .9rem; } .empty { color: var(--soft); padding: 2rem 0; }
.error { color: var(--danger); } .warn { color: var(--danger); font-weight: 600; }
.card, .run { background: var(--card); border: 1px solid var(--rule); border-radius: 10px; padding: 1rem 1.2rem; margin: 1rem 0; }
.run-head { display: flex; justify-content: space-between; align-items: center; }
.badge { margin: 0; font-size: .95rem; } .badge-waiting_approval { color: var(--accent); } .badge-error { color: var(--danger); }
.steps { list-style: none; padding: 0; margin: .6rem 0; display: flex; flex-wrap: wrap; gap: .3rem .9rem; font-size: .9rem; }
.tick { color: var(--tool); margin-right: .2rem; } .step-detail { color: var(--faint); } .pending { color: var(--faint); }
.tools { list-style: none; padding: 0; display: grid; gap: .5rem; }
.tool { border: 1px solid var(--rule); border-left: 3px solid var(--tool); border-radius: 6px; padding: .4rem .7rem; font-size: .88rem; }
.tool-write { border-left-color: var(--accent); background: var(--accent-bg); } .tool-error { border-left-color: var(--danger); }
.tool-head { display: flex; justify-content: space-between; } .tool-tag { color: var(--faint); font-size: .8rem; }
.tool pre { margin: .3rem 0 0; font-size: .8rem; overflow-x: auto; } .tool-err { color: var(--danger); margin: .3rem 0 0; }
.note { font-size: .9rem; color: var(--soft); } .note-approval { color: var(--accent); }
.reply-body { white-space: pre-wrap; } .sources { font-size: .88rem; color: var(--soft); }
.typing::after { content: "▌"; animation: blink 1s steps(2) infinite; }
@keyframes blink { 50% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .typing::after { animation: none; } }
.bubble { margin-left: auto; max-width: 80%; background: var(--accent-bg); padding: .5rem .8rem; border-radius: 10px; width: fit-content; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.amount { color: var(--accent); }
@media (max-width: 560px) { .decide { grid-template-columns: 1fr; } }
样式没引 UI 库,四十来行 CSS,跟随系统切换明暗。演示项目的重点是交互和数据,花两天调组件库主题不值得。如果你平时用 shadcn/ui 或者 Tailwind,换上去也就半小时。
客户视角:
// supportpilot/web/src/app/page.tsx 客户视角:提交工单、看过程、追问
"use client";
import { useState } from "react";
import { RunView } from "@/components/RunView";
import { createTicket, followUp, getTicket, runTicket } from "@/lib/api";
import { ApiError } from "@/lib/sse";
import { useRun } from "@/lib/useRun";
const DEMO_EMAILS = ["lina@example.com", "wangfang@example.com", "liuyang@example.com", "chenjing@example.com",
"yanglei@example.com", "wugang@example.com", "nobody@example.com"];
type Turn = { role: "customer"; text: string } | { role: "agent"; key: string };
export default function CustomerPage() {
const [ticketId, setTicketId] = useState<string | null>(null);
const [turns, setTurns] = useState<Turn[]>([]);
const [submitError, setSubmitError] = useState<string | null>(null);
const [msg, setMsg] = useState("");
const { state, start, stop, apply } = useRun();
// 历史轮次只保留最终结果,当前这一轮用 useRun 的实时状态渲染
const [history, setHistory] = useState<Record<string, typeof state>>({});
const busy = state.status === "running";
function archiveCurrent() {
if (state.status === "idle") return;
const key = `t${turns.length}`;
setHistory((h) => ({ ...h, [key]: state }));
setTurns((t) => [...t, { role: "agent", key }]);
}
async function submit(form: FormData) {
setSubmitError(null);
try {
const { ticket_id } = await createTicket({
customer_email: String(form.get("email")),
subject: String(form.get("subject")),
body: String(form.get("body")),
});
setTicketId(ticket_id);
setTurns([]);
setHistory({});
start((signal) => runTicket(ticket_id, signal));
} catch (e) {
setSubmitError(e instanceof ApiError ? e.message : "提交失败,请检查后端是否启动");
}
}
function ask(e: React.FormEvent) {
e.preventDefault();
if (!ticketId || !msg.trim()) return;
archiveCurrent();
setTurns((t) => [...t, { role: "customer", text: msg }]);
const content = msg;
setMsg("");
start((signal) => followUp(ticketId, content, signal));
}
return (
<div className="page">
<h1>提交工单</h1>
{!ticketId && (
<form action={submit} className="ticket-form">
<label>
发件邮箱(演示数据)
<select name="email" defaultValue={DEMO_EMAILS[0]}>
{DEMO_EMAILS.map((m) => (
<option key={m}>{m}</option>
))}
</select>
</label>
<label>
主题
<input name="subject" defaultValue="想退款" maxLength={200} required />
</label>
<label>
问题描述
<textarea name="body" rows={4} maxLength={4000} required
defaultValue="上周买的专业版年付,发现用不上,能退吗?请帮我退款。" />
</label>
<button type="submit">提交工单</button>
{submitError && <p className="error" role="alert">{submitError}</p>}
</form>
)}
{ticketId && (
<>
<p className="muted">
工单号 {ticketId} ·{" "}
<button type="button" className="link" onClick={() => { setTicketId(null); setTurns([]); }} disabled={busy}>
新建工单
</button>
</p>
{turns.map((t, i) =>
t.role === "customer" ? (
<p key={i} className="bubble">{t.text}</p>
) : (
<RunView key={i} state={history[t.key]} id={t.key} />
),
)}
<RunView state={state} id="current" onStop={stop} />
<form onSubmit={ask} className="ask">
<label htmlFor="ask" className="sr-only">继续追问</label>
<input id="ask" value={msg} onChange={(e) => setMsg(e.target.value)} placeholder={`在工单 ${ticketId} 里继续追问`}
disabled={busy || state.status === "waiting_approval"} />
<button type="submit" disabled={busy || !msg.trim() || state.status === "waiting_approval"}>发送</button>
</form>
{state.status === "waiting_approval" && (
<p className="muted">
退款在等客服审批,审批完成后可以继续追问。{" "}
<button type="button" className="link"
onClick={async () => apply({ event: "done", data: await getTicket(ticketId) })}>
刷新进展
</button>
</p>
)}
</>
)}
</div>
);
}
"use client";这一页用了 useState 和事件处理,整页都是客户端组件。const DEMO_EMAILS = [种子数据里的客户邮箱,nobody@example.com是故意不存在的,用来看「没找到这个邮箱对应的客户」那条路。实测 lina@example.com 对应 C0002 李娜。type Turn = { role: "customer"; text: string } | { role: "agent"; key: string };客户说的话直接存文字;Agent 那一轮只存一个 key,真正的状态在 history 里。const { state, start, stop, apply } = useRun();当前这一轮的实时状态。const [history, setHistory] = useState<Record<string, typeof state>>({});typeof state写在类型位置时,取的是变量的类型,也就是 RunState。const busy = state.status === "running";正在跑的时候禁用「新建工单」和「发送」。const key =实测第一次追问时 turns 还是空的,key 是 “t0”。setHistory((h) => ({ ...h, [key]: state }));函数式更新:参数 h 一定是最新的 history,连续调用也不会丢。customer_email: String(form.get("email")),form.get返回FormDataEntryValue | null,用String()统一转成字符串。setTicketId(ticket_id);实测拿到 T000153。start((signal) => runTicket(ticket_id, signal));没有 await:流在后台跑,submit 立刻返回。实测提交后 0.1 秒状态行就是「处理中」,1.5 秒出现第一个勾,7.3 秒开始出字,10.2 秒停在审批。setSubmitError(e instanceof ApiError ? e.message : "提交失败,请检查后端是否启动");后端没起时 fetch 抛的是 TypeError,不是 ApiError,走后半句。archiveCurrent();先把当前这一轮存进历史,再开新的一轮。setTurns((t) => [...t, { role: "customer", text: msg }]);实测追问「多久能到账?」之后,页面上多了一个气泡,上一轮的 RunView 进了历史,一共两个section.run。const content = msg;先存一份再清空输入框。这个函数里的 msg 其实不会因为 setMsg 而变,复制一份是让意图更清楚:发出去的就是清空之前的那一句。<form action={submit} className="ticket-form">React 19 的 form action:直接传一个函数,React 把 FormData 交给它,不用自己 preventDefault。<select name="email" defaultValue={DEMO_EMAILS[0]}>非受控组件:defaultValue 只给初始值,提交时从 FormData 里读。<RunView key={i} state={history[t.key]} id={t.key} />历史轮次不传 onStop,不显示停止按钮。<RunView state={state} id="current" onStop={stop} />当前这一轮,id 是 “current”,所以状态行的 id 是current-status。disabled={busy || state.status === "waiting_approval"} />实测停在审批时追问框是禁用的;点「刷新进展」拿到 resolved 之后恢复可用。onClick={async () => apply({ event: "done", data: await getTicket(ticketId) })}>GET 返回的摘要和 done 事件形状一样,直接当 done 喂进 reducer。
几个交互上的决定:
- 提交表单用的是 React 19 的 form action(
<form action={submit}>),不用自己preventDefault。 - 历史轮次只保存最终状态,当前这一轮用
useRun的实时状态渲染。追问时先把当前这一轮存进history,再开始新的一轮。 - 停在审批时,输入框禁用,下面一行说明原因,并给一个”刷新进展”。不禁用的话客户发了追问,后端返回 409,虽然我们也会显示 409 里的中文提示,但能在前面挡住的就别让用户撞上去。
- 刷新进展是手动的。做成每 5 秒轮询一次也可以,演示项目手动更直观;生产环境更好的做法是客户页面也订阅一个事件通道,审批完成时后端主动推。
客服工作台:
// supportpilot/web/src/app/desk/page.tsx 客服工作台:待审批列表,一键通过/驳回
"use client";
import { useCallback, useEffect, useState } from "react";
import { RunView } from "@/components/RunView";
import { decide, listApprovals, type PendingRefund } from "@/lib/api";
import { useRun } from "@/lib/useRun";
const FINANCE_REVIEW_LINE = 1000; // 和后端 settings.refund_review_threshold 保持一致,只用于提示,真正的判断在后端
function ApprovalCard({ r }: { r: PendingRefund }) {
const [note, setNote] = useState("");
const { state, start } = useRun();
const decided = state.status !== "idle";
function go(approved: boolean) {
start((signal) => decide(r.ticket_id, { approved, approver: "demo-finance", note }, signal));
}
return (
<article className="card" aria-labelledby={`h-${r.id}`}>
<h2 id={`h-${r.id}`}>
{r.id} · {r.customer_name} · <span className="amount">¥{r.amount_cny}</span>
</h2>
<p className="muted">
工单 {r.ticket_id}:{r.subject ?? ""} · 规则 <code>{r.rule}</code> · 原因:{r.reason}
</p>
{r.amount_cny > FINANCE_REVIEW_LINE && <p className="warn">金额超过 {FINANCE_REVIEW_LINE} 元,按政策需要财务复核</p>}
{!decided && (
<div className="decide">
<label>
审批备注
<input value={note} onChange={(e) => setNote(e.target.value)} placeholder="驳回时建议写原因,会发给客户" />
</label>
<button type="button" onClick={() => go(true)}>通过</button>
<button type="button" className="btn-danger" onClick={() => go(false)}>驳回</button>
</div>
)}
<RunView state={state} id={`run-${r.id}`} />
</article>
);
}
export default function DeskPage() {
const [items, setItems] = useState<PendingRefund[] | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
listApprovals().then(setItems, () => setError("拉取待审批列表失败,请检查后端是否启动"));
}, []);
useEffect(load, [load]);
return (
<div className="page">
<h1>
待审批退款{" "}
<button type="button" className="link" onClick={load}>刷新</button>
</h1>
{error && <p className="error" role="alert">{error}</p>}
{items === null && !error && <p className="muted">加载中…</p>}
{items?.length === 0 && <p className="empty">没有待审批的退款。去客户视角提交一张退款工单试试。</p>}
{/* 审批完不立刻从列表移除:客服要看到恢复后的流程跑完,下次刷新再消失 */}
{items?.map((r) => <ApprovalCard key={r.id} r={r} />)}
</div>
);
}
const FINANCE_REVIEW_LINE = 1000;只用来显示提示。真正判断要不要财务复核的是后端,interrupt 事件里的 needs_finance_review 就是后端算的。const { state, start } = useRun();每张卡片一个独立的 useRun,批这一笔不会影响别的卡片。const decided = state.status !== "idle";点过通过或驳回,按钮就收起来,防止重复提交。start((signal) => decide(r.ticket_id, { approved, approver: "demo-finance", note }, signal));{ approved, note }是属性简写,等于{ approved: approved, note: note }。{r.id} · {r.customer_name} ·实测卡片标题是「RF-T000153-INV-202609-0002 · 李娜 · ¥950」。工单 {r.ticket_id}:实测这一行是「工单 T000153:想退款 · 规则 annual_first_14_days · 原因:年付首购第7天,客户表示专业版用不上,明确要求退款」。原因是模型调 create_refund_request 时自己写的,每次跑措辞都不一样。<button type="button" onClick={() => go(true)}>通过</button>实测备注填「首购 7 天内,符合规则」再点通过:卡片里跑出 ✓等待审批、✓定稿,状态变成「已完成 · 花费 ¥0.03198」(这两个节点不调模型,花费和审批前一样),正文末尾多了「补充:您的退款申请已审核通过,950.0 元将原路退回」和两条参考文章。const [items, setItems] = useState<PendingRefund[] | null>(null);null 表示还没加载完,[]表示加载完了但是空的,两种情况界面不一样。listApprovals().then(setItems, () => setError(成功直接把数组交给 setItems,失败显示提示。useEffect(load, [load]);load 用 useCallback 包过、依赖是空数组,引用永远不变,所以这个 effect 只在页面挂载时跑一次。{items?.length === 0 &&实测审批完点刷新,显示「没有待审批的退款。去客户视角提交一张退款工单试试。」
每张审批卡片自己持有一个 useRun,点”通过”或”驳回”后,恢复的流程就在这张卡片里跑完,客服能看到定稿的回复和最终花费。审批后卡片不立刻从列表里消失,等客服点刷新才消失;一点就消失的话,客服没法确认自己刚才批的是哪一笔。
复核线 1,000 元在前端写死了一份,只用来显示提示。后端 interrupt 事件里其实带了 needs_finance_review,但 /approvals 列表接口没返回这个字段。更干净的做法是让列表接口也返回它,前端就不用和后端各存一份阈值。这个留作练习。
停止和断线
“停止”按钮调的是 AbortController.abort()。浏览器这边很直接:fetch 的读取抛出 AbortError,reducer 进入 stopped,界面显示”已停止接收”。
后端那边会发生什么,要想清楚。sse-starlette 会检测到客户端断开,不再从我们的生成器里取下一个事件。我们的生成器是放在线程池里一步一步取的,每取一步可能要执行完一整个节点(比如 act 节点里的几次工具调用),所以正在执行的那一步会跑完,之后的步骤不会再执行,LangGraph 的状态停在最后一个完成的节点。这是我根据 sse-starlette 和 iterate_in_threadpool 的工作方式推出来的,你可以自己验证:用 curl -N 跑一张工单,看到第一个 tool 事件就按 Ctrl-C,再 GET /tickets/{id},看 waiting_on 停在哪个节点。
这带来一个产品问题:停止之后,工单是一个跑了一半的状态。如果停在 act 之后,退款申请可能已经建好了。所以”停止”对客户来说更准确的含义是”我不想看了”,而不是”撤销”。真正的撤销要在后端设计(比如退款申请在审批前可以作废),不能靠前端断开连接实现。
断线是另一回事:网络抖了一下,不是用户主动停的。EventSource 会自动重连,我们用 fetch 就得自己来。演示版只显示”连接中断,请稍后重试”;生产环境的做法是断线后先 GET /tickets/{id} 看工单走到哪了,已经结束就直接显示结果,没结束就提示用户刷新。真正的断点续传(带着 Last-Event-ID 重连,从断开的那条事件接着推)需要后端把事件存下来,思路和 Day 37 的断点续跑类似,这里不展开。
可访问性
流式界面对读屏软件很不友好,最常见的错误是把逐字输出的区域设成 aria-live:读屏软件会试图播报每一次变化,一句话被拆成几十次播报,用户根本听不下去。
我们的处理:
- 只有顶部的状态行是
aria-live="polite",播报”处理中""已提交审批,等客服处理""已完成 · 花费 ¥0.0123”这几次变化。 - 打字机区域在流式过程中
aria-hidden,跑完之后定稿回复正常可读。 - 运行区域在流式过程中标
aria-busy,告诉辅助技术内容还在变。 - 错误用
role="alert",会被立即播报。 - 所有按钮和输入框都有可见的焦点样式;追问输入框有一个视觉上隐藏、读屏能读到的
<label>。 - 打字机光标的闪烁动画在系统开了”减少动画”时关掉。
这些加起来不到二十行,但面试时被问到”你的界面考虑过无障碍吗”,能说出”逐字输出不该放进 live region”这种具体的点,比泛泛说”考虑过”有分量得多。
不花钱调界面:假后端
调界面要反复跑同一个流程几十次。每次都真调模型,既慢又花钱,而且模型每次输出不一样,不好对比。我写了一个假后端:路径、事件名、数据形状都和真后端一样,内容写死,还专门放了一次”质检退回”和一笔待审批的退款,把界面上该出现的状态都覆盖到。只用 Python 标准库,不用装任何东西:
# supportpilot/web/scripts/fake_api.py
"""假后端:和 Day 49 api/app.py 同样的路径和 SSE 事件,用来在没有模型和数据库时测前端。"""
import json, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
STATE = {"T1": "new"}
REPLY_DRAFT = "您好,查到您 9 月 12 日的年付订单被扣了两次 [1],多扣的 39 元已提交退款申请 [2]。"
FINAL = (REPLY_DRAFT + "\n\n参考文章:\n[1] 重复扣款怎么办 https://help.cloudledger.example/kb/refund-003\n"
"[2] 退款政策 https://help.cloudledger.example/kb/refund-002\n\n补充:您的退款申请已审核通过,39 元将原路退回。")
class H(BaseHTTPRequestHandler):
def cors(self):
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Headers", "content-type, accept")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
def do_OPTIONS(self):
self.send_response(204); self.cors(); self.end_headers()
def j(self, code, obj):
b = json.dumps(obj, ensure_ascii=False).encode()
self.send_response(code); self.cors(); self.send_header("content-type", "application/json")
self.send_header("content-length", str(len(b))); self.end_headers(); self.wfile.write(b)
def sse(self, events):
self.send_response(200); self.cors(); self.send_header("content-type", "text/event-stream")
self.send_header("cache-control", "no-cache"); self.end_headers()
try:
self.wfile.write(b": ping\n\n"); self.wfile.flush()
for ev, data, delay in events:
time.sleep(delay)
self.wfile.write(f"event: {ev}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n".encode()); self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
print("client disconnected", flush=True)
def summary(self, outcome, reply):
return {"ticket_id": "T1", "outcome": outcome, "category": "refund", "reply": reply, "tool_log": [],
"cost_cny": 0.0123, "waiting_on": ["approval"] if outcome == "waiting_approval" else []}
def do_GET(self):
if self.path == "/approvals":
items = [] if STATE["T1"] != "waiting" else [{"id": "RR-0007", "ticket_id": "T1", "customer_name": "刘洋",
"amount_cny": 39.0, "rule": "duplicate_charge", "reason": "同日重复扣款", "subject": "扣了两次",
"created_at": "2026-10-12 10:00"}]
return self.j(200, items)
if self.path == "/tickets/T1":
return self.j(200, self.summary("resolved", FINAL) if STATE["T1"] == "resolved" else self.summary("waiting_approval", None))
self.j(404, {"detail": "not found"})
def do_POST(self):
n = int(self.headers.get("content-length") or 0); body = json.loads(self.rfile.read(n) or b"{}")
if self.path == "/tickets":
return self.j(200, {"ticket_id": "T1"})
if self.path == "/tickets/T1/run":
STATE["T1"] = "waiting"
ev = [("node", {"node": "load_customer"}, .3),
("step", {"type": "step", "node": "classify", "category": "refund"}, .3), ("node", {"node": "classify"}, .1),
("step", {"type": "step", "node": "retrieve", "docs": ["KB-refund-002", "KB-refund-003"]}, .3), ("node", {"node": "retrieve"}, .1),
("tool", {"type": "tool", "name": "list_invoices", "args": json.dumps({"customer_id": "C0003"}), "error": False}, .3),
("tool", {"type": "tool", "name": "create_refund_request", "args": json.dumps({"invoice_id": "INV-2291", "reason": "同日重复扣款"}, ensure_ascii=False), "error": False}, .3),
("node", {"node": "act"}, .1)]
ev += [("token", {"type": "token", "text": "这是第一版草稿。"}, .05)]
ev += [("step", {"type": "step", "node": "check", "passed": False, "problems": ["引用编号不存在"]}, .3)]
ev += [("token", {"type": "token", "text": ch}, .03) for ch in REPLY_DRAFT]
ev += [("step", {"type": "step", "node": "check", "passed": True, "problems": []}, .2), ("node", {"node": "check"}, .05),
("interrupt", {"type": "refund_approval", "ticket_id": "T1",
"request": {"id": "RR-0007", "amount_cny": 39.0, "rule": "duplicate_charge"},
"needs_finance_review": False}, .1),
("done", self.summary("waiting_approval", None), .05)]
return self.sse(ev)
if self.path == "/approvals/T1":
if STATE["T1"] != "waiting":
return self.j(409, {"detail": "这个工单不在等待审批"})
STATE["T1"] = "resolved"
return self.sse([("node", {"node": "approval"}, .3), ("node", {"node": "finalize"}, .3),
("done", self.summary("resolved", FINAL), .1)])
if self.path == "/tickets/T1/messages":
if STATE["T1"] == "waiting":
return self.j(409, {"detail": "工单还在等待审批,审批完成后才能继续对话"})
return self.sse([("node", {"node": "draft"}, .2)] + [("token", {"type": "token", "text": c}, .05) for c in "一般 3 到 5 个工作日到账。" * 6]
+ [("done", self.summary("resolved", "一般 3 到 5 个工作日到账。"), .1)])
self.j(404, {"detail": "not found"})
def log_message(self, *a):
pass
ThreadingHTTPServer(("127.0.0.1", 8077), H).serve_forever()
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer全是标准库。用 Threading 版本,每个请求一个线程,一个页面在收流的时候,另一个页面照样能请求。STATE = {"T1": "new"}整个假后端只有一张工单 T1,状态从 new 到 waiting 再到 resolved。class H(BaseHTTPRequestHandler):每来一个请求就新建一个 H 实例,按 HTTP 方法分发到 do_GET、do_POST、do_OPTIONS。self.send_header("Access-Control-Allow-Origin", "*")假后端只在本机调界面,放开所有来源;真后端只放行自己的前端。def do_OPTIONS(self):浏览器跨域发 POST 之前的预检请求,回 204 加上 CORS 头就行。self.send_header("content-length", str(len(b)));普通 JSON 响应要写长度。下面的 SSE 响应不写长度,因为事先不知道会推多少。self.wfile.write(b": ping\n\n"); self.wfile.flush()冒号开头是 SSE 的注释行,解析器会丢掉。先推一条,让浏览器尽早拿到响应头。实测 curl 看到的第一行就是: ping。for ev, data, delay in events:每个元素是 (事件名, 数据, 推之前等几秒)。self.wfile.write(f"event: {ev}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n".encode()); self.wfile.flush()每条都手动 flush,否则会攒在缓冲区里一起到,看不出打字机效果。except (BrokenPipeError, ConnectionResetError):前端断开后再往里写就会抛这两种错误之一。实测前端点停止后,终端打印了client disconnected。items = [] if STATE["T1"] != "waiting" else实测审批前返回一条「RR-0007 · 刘洋 · ¥39」,审批后返回[]。n = int(self.headers.get("content-length") or 0);没有请求体时 header 不存在,or 0兜底。("tool", {"type": "tool", "name": "list_invoices", "args": json.dumps(args 是 JSON 字符串、error 是 False,这是我从真后端的 SSE 里照抄的形状。最初这里写的是字典和 None,界面在假后端上一切正常,接上真后端才露馅。假后端的数据要从真后端抓,别凭记忆写。ensure_ascii=False), "error": False}, .3),内层也加ensure_ascii=False,参数里的中文才是原文,和真模型给的一样。实测点开参数显示的是"reason": "同日重复扣款"。ev += [("token", {"type": "token", "text": "这是第一版草稿。"}, .05)]先打一版草稿。ev += [("step", {"type": "step", "node": "check", "passed": False故意造一次质检退回。实测前端在第 2.0 秒显示「这是第一版草稿。」,第 2.3 秒草稿清空、出现「质检退回 1 次,正在重写回复」,开始逐字打第二版。ev += [("token", {"type": "token", "text": ch}, .03) for ch in REPLY_DRAFT]一个字一个 token,每个间隔 30 毫秒。("done", self.summary("waiting_approval", None), .05)]实测第 4.3 秒停在审批,状态行是「已提交审批,等客服处理 · 花费 ¥0.0123」。if STATE["T1"] != "waiting":实测审批完再 POST 一次,返回 409 Conflict。"一般 3 到 5 个工作日到账。" * 6字符串乘 6,拉长到 96 个字,留够时间去点停止。实测 0.8 秒时打到「一般 3 到 5 个工」,点停止。def log_message(self, *a):覆盖掉默认的”每个请求打一行访问日志”,终端里只留 client disconnected 这种有用的。
python3 web/scripts/fake_api.py & # 8077 端口
cd web
NEXT_PUBLIC_SUPPORTPILOT_API=http://127.0.0.1:8077 npm run dev
我用它在浏览器里把整套交互跑了一遍(Playwright 自动点的),结果是这样:
| 检查 | 结果 |
|---|---|
| 跑到一半时状态行 | 处理中,“质检退回 1 次”提示出现,打字机里是第二版草稿 |
| 停在审批时 | 状态”已提交审批,等客服处理”;2 张工具卡片,其中 1 张标为写操作;追问框禁用 |
| 工作台点”通过” | 卡片里跑完 approval → finalize,显示定稿回复、2 个上标引用、2 条参考文章 |
| 客户页点”刷新进展” | 状态变”已完成”,追问框可用 |
| 追问中点”停止” | 状态”已停止接收”,假后端日志打印 client disconnected |
| 审批后刷新工作台 | 显示”没有待审批的退款”的空状态 |
步骤重复显示的那个 bug,就是在这一步截图时发现的。界面上的问题,看截图比读代码快得多。
给谁看什么:三种用户,三种信息密度
同一张工单,客户、客服、财务想看到的东西完全不一样,界面设计的核心就是这个。
客户只关心结果和进度。工具调用的参数、检索到的文章编号、质检意见,对客户来说都是噪音,甚至会引起不必要的担心(“它为什么在查我的账单?”)。生产环境给客户的界面,过程信息应该收敛成几个友好的阶段提示,比如”正在查询您的账户""正在为您整理答复”,最终回复里的引用渲染成可点击的帮助文章链接。我们的演示界面把全部过程都摊开了,是为了让面试官看到 Agent 在干什么,真实产品里要收起来。
客服需要看全过程。转人工的工单到了客服手里,他第一件事是搞清楚 Agent 已经做了什么、查到了什么、为什么没处理好。GET /tickets/{id} 返回的 tool_log 就是给这个场景准备的。一个好的客服工作台会把 Agent 的调查结论、检索到的资料、草稿和质检意见并排展示,客服在草稿基础上改两句就能发,而不是从头再查一遍。
财务只需要做决定所需的最少信息:多少钱、哪个客户、依据哪条规则、有没有异常(比如金额超过复核线)。信息太多反而会让审批变慢。我们的审批卡片只放了这几样。
还有一个容易被忽略的指标:首字延迟。一张工单完整处理要十几秒,但如果第一个字在三四秒内出现,用户的感受会好很多。我们的设计里,分诊、检索、调工具这几步都在起草之前,所以首字要等这些全部完成。能优化的地方有两个:一是把阶段提示尽早推出去(我们已经做了),二是 Day 52 用小模型做分诊、压缩各步的耗时。PRD 里定的”首个 token ≤ 8 秒”,到时候用真实数据验收。
最后是失败的样子。模型接口超时、数据库断了、工具报错,界面上应该显示什么?演示版里任何异常都会让 SSE 流中断,前端只能看到一个报错。生产环境至少要做到:后端捕获异常后推一个 error 事件,前端显示”处理遇到问题,已转人工”,同时把工单标为转人工,而不是让客户对着一个卡住的页面。这个留作练习。
测试整条链路
# supportpilot/tests/test_api.py
from fakes import FakeLLM, FakeTools, make_deps
from fastapi.testclient import TestClient
from langgraph.checkpoint.memory import InMemorySaver
from test_graph_smoke import ACT, DRAFT, REFUND_TRIAGE, TICKET
import supportpilot.api.app as api
from supportpilot.agent.graph import build_graph
def parse(text: str) -> list[tuple[str, str]]:
out, event = [], None
for line in text.splitlines():
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
out.append((event, line[5:].strip()))
return out
def test_sse_run_then_approve(monkeypatch):
saver = InMemorySaver()
tools = FakeTools(customer={"id": "C0002"})
deps = make_deps(FakeLLM(REFUND_TRIAGE, ACT, DRAFT), tools)
monkeypatch.setattr(api, "_graph", lambda: build_graph(deps, checkpointer=saver))
events = list(api._events(api._graph(), {"ticket": TICKET}, TICKET["id"]))
kinds = [e["event"] for e in events]
assert "tool" in kinds and "token" in kinds and "interrupt" in kinds and kinds[-1] == "done"
client = TestClient(api.app) # 不进 with 块,不触发 lifespan(不连数据库)
r = client.post(f"/approvals/{TICKET['id']}", json={"approved": True, "approver": "amy"})
got = parse(r.text)
assert got[-1][0] == "done" and '"outcome": "resolved"' in got[-1][1]
assert client.post(f"/approvals/{TICKET['id']}", json={"approved": True, "approver": "amy"}).status_code == 409
from fakes import FakeLLM, FakeTools, make_deps能直接 import fakes,是因为 Day 44 的 conftest.py 把 tests/ 加进了 sys.path。from langgraph.checkpoint.memory import InMemorySaver内存版 checkpointer,不连数据库。from test_graph_smoke import ACT, DRAFT, REFUND_TRIAGE, TICKET复用 Day 47 冒烟测试的剧本。实测 TICKET 是{"id": "T000900", "customer_email": "lina@example.com", "subject": "想退款", "body": "上周买的专业版年付,用不上,请帮我退款。"}。def parse(text: str) -> list[tuple[str, str]]:一个最简单的 SSE 解析:只认 event: 和 data: 两种行。event = line[6:].strip()"event:"正好 6 个字符,切掉后去空格。out.append((event, line[5:].strip()))"data:"是 5 个字符。monkeypatch.setattr(api, "_graph", lambda: build_graph(deps, checkpointer=saver))把接口里编译图的函数换掉,测试结束 pytest 自动还原。events = list(api._events(api._graph(), {"ticket": TICKET}, TICKET["id"]))实测一共 18 个事件:3 个 tool(list_invoices、check_refund_eligibility、create_refund_request)、2 个 token(假 LLM 把草稿拆成了两段)、1 个 interrupt,最后是 done,outcome 是 waiting_approval,cost_cny 是 0.00752。client = TestClient(api.app)不进 with 块,lifespan 不会执行,也就不会去连数据库。got = parse(r.text)实测原始文本以'event: node\r\ndata: {"node": "approval"}\r\n\r\n'开头,解析出 3 条:node approval、node finalize、done。assert got[-1][0] == "done" and '"outcome": "resolved"' in got[-1][1]检查最后一条是 done,并且工单已经 resolved。.status_code == 409实测第二次审批的 detail 是「这个工单不在等待审批」。这个文件 1 passed,和 Day 47、48 的测试一起跑是 14 passed。
这个测试做了三件事:用假 LLM 跑 _events,检查事件流里有工具调用、逐字输出、审批暂停和完成;用 FastAPI 的 TestClient 调审批接口,解析返回的 SSE 文本,确认最后的 done 事件里工单已经 resolved;再调一次审批接口,应该返回 409,因为工单已经不在等审批了。
monkeypatch.setattr(api, "_graph", ...) 把接口里编译图的函数换成用假依赖编译的版本。TestClient(api.app) 不放在 with 块里,FastAPI 就不会触发 lifespan,也就不会去连数据库。这两个技巧让接口层的测试也能做到不连库、不花钱。我本机跑过,通过。
备选:不会前端就用 Streamlit
如果你没写过 React,不想为了一个界面去学 Next.js,Streamlit 是更快的路:纯 Python,一个文件,一百行。它的模型是”每次交互,整个脚本从头跑一遍”,点一下按钮脚本重跑,按钮那一行返回 True。读 SSE 用 httpx 的流式请求自己逐行解析;st.status 折叠框里逐行写步骤和工具调用;st.empty 占位符收到 token 就重写一遍,就是打字机效果。上面讲的几条原则在这里一样适用:质检退回要清空草稿,停在审批时不能追问,模型输出别当 HTML 渲染(st.markdown 默认会渲染图片,Day 53 会让你换掉)。
uv add streamlit httpx
uv run streamlit run src/supportpilot/ui/streamlit_app.py # 先起后端
# supportpilot/src/supportpilot/ui/streamlit_app.py
"""演示界面。先起后端(uvicorn),再:uv run streamlit run src/supportpilot/ui/streamlit_app.py"""
import json
import os
import httpx
import streamlit as st
API = os.getenv("SUPPORTPILOT_API", "http://localhost:8000")
DEMO_EMAILS = ["lina@example.com", "wangfang@example.com", "liuyang@example.com", "chenjing@example.com",
"yanglei@example.com", "wugang@example.com", "nobody@example.com"]
STEP_NAMES = {"load_customer": "查客户档案", "prepare_context": "整理上下文", "classify": "分诊",
"retrieve": "检索帮助中心", "act": "查业务数据", "draft": "起草回复", "check": "质检",
"approval": "等待审批", "finalize": "定稿", "handoff": "转人工"}
def sse(method: str, path: str, payload: dict | None = None):
"""读 SSE 流,逐个产出 (event, data)。"""
with httpx.Client(timeout=None) as client, client.stream(method, API + path, json=payload) as r:
r.raise_for_status()
event = "message"
for line in r.iter_lines():
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
yield event, json.loads(line[5:].strip())
def render_run(events) -> dict:
"""把一次运行的事件流画出来:步骤进度、工具卡片、打字机效果的回复。"""
status = st.status("处理中…", expanded=True)
text_box = st.empty()
typed = ""
done: dict = {}
for event, data in events:
if event == "node":
status.write(f"✓ {STEP_NAMES.get(data['node'], data['node'])}")
elif event == "tool":
status.code(f"{data['name']}({data['args']})" + (" ← 出错" if data["error"] else ""), language="text")
elif event == "step" and data.get("node") == "check" and not data["passed"]:
status.warning("质检退回:" + ";".join(data["problems"]))
typed = "" # 重写草稿,清空打字机
elif event == "token":
typed += data["text"]
text_box.markdown(typed + "▌")
elif event == "interrupt":
status.info(f"退款申请 {data['request']['id']}({data['request']['amount_cny']} 元)已提交,等待人工审批")
elif event == "done":
done = data
status.update(label=f"完成 · {done.get('outcome')} · 花费 ¥{done.get('cost_cny')}", state="complete",
expanded=False)
text_box.markdown(done.get("reply") or typed)
return done
st.set_page_config(page_title="SupportPilot", layout="wide")
st.title("SupportPilot · CloudLedger 智能客服")
customer_tab, desk_tab = st.tabs(["客户视角", "客服工作台"])
with customer_tab:
with st.form("new_ticket"):
email = st.selectbox("发件邮箱(演示数据)", DEMO_EMAILS)
subject = st.text_input("主题", "想退款")
body = st.text_area("问题描述", "上周买的专业版年付,发现用不上,能退吗?请帮我退款。")
submitted = st.form_submit_button("提交工单")
if submitted:
tid = httpx.post(f"{API}/tickets", json={"customer_email": email, "subject": subject, "body": body}).json()[
"ticket_id"]
st.session_state["ticket_id"] = tid
st.caption(f"工单号 {tid}")
render_run(sse("POST", f"/tickets/{tid}/run"))
if tid := st.session_state.get("ticket_id"):
if msg := st.chat_input(f"在工单 {tid} 里继续追问"):
st.chat_message("user").write(msg)
with st.chat_message("assistant"):
render_run(sse("POST", f"/tickets/{tid}/messages", {"content": msg}))
with desk_tab:
st.subheader("待审批退款")
pending = httpx.get(f"{API}/approvals").json()
if not pending:
st.write("没有待审批的退款。")
for r in pending:
with st.container(border=True):
st.markdown(f"**{r['id']}** · {r['customer_name']} · ¥{r['amount_cny']} · 规则 `{r['rule']}`")
st.caption(f"工单 {r['ticket_id']}:{r.get('subject') or ''} · 原因:{r['reason']}")
if r["amount_cny"] > 1000:
st.error("金额超过 1,000 元,按政策需要财务复核")
note = st.text_input("审批备注", key=f"note-{r['id']}")
c1, c2 = st.columns(2)
for col, ok, label in ((c1, True, "通过"), (c2, False, "驳回")):
if col.button(label, key=f"{label}-{r['id']}"):
render_run(sse("POST", f"/approvals/{r['ticket_id']}",
{"approved": ok, "approver": "demo-finance", "note": note}))
API = os.getenv("SUPPORTPILOT_API", "http://localhost:8000")运行时读环境变量,和 Next.js 构建时替换不一样,换地址不用重新构建。实测我是用SUPPORTPILOT_API=http://localhost:8049起的。with httpx.Client(timeout=None) as client, client.stream(method, API + path, json=payload) as r:一个 with 管两个上下文。timeout=None是因为一张工单要跑十几秒,默认 5 秒超时会掐断。r.raise_for_status()409 时抛 httpx.HTTPStatusError,Streamlit 会把异常直接显示在页面上。event = "message"SSE 规范里,没写 event: 的消息默认叫 message。yield event, json.loads(line[5:].strip())每读到一行 data 就产出一对 (事件名, 数据)。status = st.status("处理中…", expanded=True)一个可折叠的状态框,步骤和工具调用都写在里面。text_box = st.empty()占位符,后面每次.markdown()都覆盖同一块地方,不会越写越多。status.code(f"{data['name']}({data['args']})"args 是字符串,直接拼出来就是list_invoices({"customer_id": "C0002"})的样子。typed = ""质检退回时清空打字机,和 React 版的 reducer 一个道理。text_box.markdown(typed + "▌")每来一个 token 就重画一次,末尾加个光标。status.update(label=f"完成 · {done.get('outcome')}实测停在审批时,折叠框的标题是「完成 · waiting_approval · 花费 ¥0.04666」。text_box.markdown(done.get("reply") or typed)停在审批时 reply 是 None,显示草稿。实测草稿里的引用还是原始的[KB-refund-002],因为只有 finalize 节点才会把它换成 [1] 加参考文章。with st.form("new_ticket"):表单里改输入框不会触发脚本重跑,点提交才会。st.session_state["ticket_id"] = tid脚本每次交互都从头跑,工单号要存进 session_state 才记得住。实测是 T000153。if tid := st.session_state.get("ticket_id")::=是海象运算符(3.8+),赋值的同时判断真假。pending = httpx.get(f"{API}/approvals").json()切到工作台这个 tab 时,脚本重跑,重新拉一次待审批列表。note = st.text_input("审批备注", key=f"note-{r['id']}")key 带上申请 id,多张卡片的输入框不会串。for col, ok, label in ((c1, True, "通过"), (c2, False, "驳回")):两个按钮用一个循环生成,点了哪个,那一轮脚本里col.button(...)就返回 True。
Streamlit 版的局限要在 README 里写清楚:每次只显示最近一轮,不保留对话历史(要保留得存进 st.session_state);没有登录,任何人都能审批;流式过程中不能停止。面试官看到”任何人都能审批”会问的,你提前写了就是”知道并且有计划”,没写就是”没想到”。
动手练习
步骤 1:后端。 agent/deps.py 加上 fresh() 方法(顶部 import 改成 from dataclasses import dataclass, replace),新增 api/app.py(含 CORS 中间件)和 tests/test_api.py,跑 make test。
步骤 2:先用 curl 看原始事件。 在看界面之前,先确认后端的流是对的,出问题时能分清是后端还是前端:
make api # uvicorn,http://localhost:8000/docs 能看到自动生成的接口文档
curl -s -X POST localhost:8000/tickets -H 'content-type: application/json' \
-d '{"customer_email":"liuyang@example.com","subject":"扣了两次","body":"9月12号被扣了两次39元,请退一笔"}'
# 返回 {"ticket_id":"T000xxx"}
curl -N -X POST localhost:8000/tickets/T000xxx/run
-N 关掉 curl 的缓冲,你会看到 event: node、event: tool、一串 event: token 一条条冒出来。顺手按一次 Ctrl-C,再 GET 这张工单,看看停在哪个节点。
步骤 3:前端先接假后端。 按”项目结构”一节生成 web/,放入全部文件,npm run build 必须通过(类型检查也在里面)。起假后端和 npm run dev,把”不花钱调界面”那张表里的六项逐一在浏览器里点一遍。
步骤 4:接真后端。 web/.env.local 写 NEXT_PUBLIC_SUPPORTPILOT_API=http://localhost:8000,重启 dev server。客户视角选 lina@example.com 提交退款工单,看着它停在审批;打开 /desk,点通过;回到客户视角点”刷新进展”,再追问”大概几天到账?”。再用 chenjing@example.com 提交”这个月的钱退给我”,确认不会出现在待审批里。Makefile 里加一个 web 目标(cd web && npm run dev),和 api 并排。
步骤 5:录一段 2 分钟的屏。 录下步骤 4,存起来。Day 54 做演示视频时这是素材。
步骤 6:并发检查成本。 开两个浏览器窗口同时提交两张工单,两张的花费都应该在合理范围(各几分钱以内),不会一张特别高一张特别低。这是在验证今天修的那个 bug。
步骤 7:失败也要好看。 在 _events 外面包一层 try/except:出异常时推一个 event: error(data 是 {"message": "处理遇到问题,已转给人工客服"}),并调用 escalate_to_human 把工单转人工。前端已经认识 error 事件了。验证方法:把 .env 里的 LLM_API_KEY 故意改错,提交一张工单,页面上应该看到红色提示,数据库里这张工单是 escalated。
步骤 8(选做):让列表接口返回 needs_finance_review。 去掉前端写死的 1,000 元阈值。
步骤 9(选做):说一次 AI SDK 的协议。 给后端加一个 POST /chat 接口,把 LangGraph 的流翻译成 AI SDK 的 UI Message Stream(响应头 x-vercel-ai-ui-message-stream: v1,token 映射成 text-delta,工具调用映射成 tool-input-available,步骤映射成自定义的 data-step),前端新开一个页面用 useChat 接。做完你会对”前端方便”和”后端迁就”之间的取舍有自己的判断。
验收标准: 后端测试全绿;next build 通过;假后端下六项交互全部符合预期;真后端下能完成”提交 → 看过程 → 审批 → 刷新看到定稿 → 追问”全流程;停止、错误、空状态都有提示;读屏软件(macOS 用 VoiceOver,Cmd+F5)不会逐字播报回复。
名词解释
| 名词 | 意思 |
|---|---|
| SSE | Server-Sent Events,服务器单向推送事件的 HTTP 协议,浏览器原生支持 |
| EventSource | 浏览器自带的 SSE 客户端,只能发 GET 请求,会自动重连 |
| ReadableStream | 浏览器里表示”一点点到达的数据”的对象,fetch 的响应体就是一个 |
| TextDecoderStream | 把字节流解码成文本流,能正确处理被切断的多字节字符 |
| eventsource-parser | 把 SSE 文本切成一条条事件的小库 |
| 判别联合类型 | TypeScript 里用一个公共字段(这里是 event)区分不同形状的联合类型 |
| useReducer | React 的状态管理 hook,用一个纯函数根据动作算出新状态 |
| AbortController | 用来取消 fetch 等异步操作的浏览器 API |
| CORS | 跨域资源共享,浏览器限制网页向不同源的服务器发请求,服务端用响应头放行 |
| aria-live | 告诉读屏软件”这块内容变了要播报”的属性 |
| UI Message Stream | Vercel AI SDK 定义的流式协议,useChat 消费的就是它 |
| 409 Conflict | HTTP 状态码,表示请求和资源当前状态冲突 |
| iterate_in_threadpool | 把同步生成器放进线程池迭代,避免阻塞事件循环 |
常见误区
只显示一个转圈。 Agent 处理一张工单要十几秒,看不到过程的用户会焦虑、会重复提交。把过程推出来,既是体验也是可信度。
为了用 EventSource 把接口改成 GET。 有副作用的操作走 GET,会被预取、缓存、记进访问日志。保持 POST,用 fetch 读流。
把模型输出当 HTML 或 Markdown 直接渲染。 一个被注入的 <img> 就能把数据发到外面。模型输出当纯文本,链接只渲染自己解析出来、格式可控的那部分。
逐字输出的区域设成 aria-live。 读屏软件会把一句话拆成几十次播报。只播报状态变化,回复跑完再读。
以为”停止”就是撤销。 断开连接只是不再往下跑,已经执行的工具调用不会回滚。撤销要在后端设计。
在 async 接口里直接跑同步阻塞代码。 一个请求卡住整个服务。要么放线程池,要么整条链路异步。
共享对象不考虑并发。 成本账本只是一个例子。缓存、计数器、连接,所有跨请求共享的可变状态都要过一遍”两个请求同时用会怎样”。
以为 NEXT_PUBLIC_ 变量是运行时读的。 它在构建时被替换成字面量,换地址要重新 build。
学习资料
文档与文章
- MDN · 使用可读流:
getReader()、pipeThrough()这些今天用到的 API,中文版,30 分钟。 - MDN · Server-sent events:SSE 协议本身和 EventSource 的限制,20 分钟。
- MDN · AbortController:取消请求,10 分钟。
- MDN · aria-live:live region 的几个取值和播报时机,英文,15 分钟。
- React · useReducer:什么时候用 reducer 而不是一堆 useState,20 分钟。
- Next.js · Environment Variables:
NEXT_PUBLIC_的构建期替换,10 分钟。 - AI SDK · Stream Protocols:UI Message Stream 的全部事件类型,做练习 9 时对着看,30 分钟。
- FastAPI · CORS:CORSMiddleware 的参数,10 分钟。
- LangGraph Streaming:
updates和custom两种模式的输出格式,复习 15 分钟。
B 站视频
- 上面嵌的今晚吃泡面a《chatgpt stream 实时输出功能实现原理 SSE》和码里奥Ziho《Vercel AI SDK 零基础教程》。
- 黑马程序员《FastAPI 从入门到实战》(BV1zV2QBtE39,合集):如果 Day 36 的 FastAPI 还不熟,挑路由、依赖注入、异步编程那几集补。
- 技术蛋老师《入门即可搭建 AI 应用的框架 Streamlit》(BV1T5CJY7ECQ,9 分钟):走 Streamlit 备选路线的看这个。
其他经典视频
- Alejandro AO《Stream LLMs with LangChain + Streamlit》(YouTube,zKGeRWjJlTU):Streamlit 路线里做流式输出和会话历史的另一种写法。
书籍
- Chip Huyen《AI Engineering》第 10 章 AI Engineering Architecture and User Feedback:后半部分讲怎么在界面上收集用户反馈(显式的点赞点踩、隐式的重新生成和修改),这是我们界面下一步该加的东西,约 1 小时。
开源项目与代码
- rexxars/eventsource-parser:今天用的解析器,源码很短,值得读一遍 parse.ts,看它怎么处理被切断的事件。
- vercel/ai 的 next-fastapi 示例:Python 后端说 AI SDK 协议的官方示例,练习 9 的参考。
- sysid/sse-starlette:README 里的心跳(ping)和断开检测配置。
自测
1. 所有请求共用一个 LLMClient 时,节点的成本为什么会算错?fresh() 怎么解决?
答案
节点用”结束时账本读数减开始时读数”算成本。共享客户端时,两个请求的调用都记在同一个账本上,A 节点执行期间 B 的花费也会被算进 A。fresh() 给每个请求新建 LLMClient(各自独立的账本),检索器、数据库连接等重对象仍然共享。
2. 为什么前端不用浏览器自带的 EventSource?
答案
EventSource 只能发 GET,不能带请求体和自定义请求头。我们的流式接口(跑工单、追问、审批)都是 POST,追问和审批要带内容;把有副作用的操作改成 GET 会被预取、缓存和记录。所以用 fetch 读响应体,自己(借助 eventsource-parser)解析 SSE,代价是自动重连要自己处理。
3. 页面上出现了”✓ 分诊 · 分类:refund”紧跟着又一个”✓ 分诊”,原因是什么,怎么改?
答案
同一个节点通过两个事件到达:step 在节点执行中推送细节,node 在节点跑完后推送完成。两个事件各往步骤列表插了一行。改法是确定 node 是”步骤完成”的唯一来源,只由它追加步骤;step 带来的细节按节点名存到另一个字典里,渲染时挂到对应步骤上。
4. 用户点了”停止”,后端的工作流会怎样?退款申请会被撤销吗?
答案
前端 abort 后连接断开,sse-starlette 检测到断开就不再从生成器取下一个事件;线程池里正在执行的那一步会跑完,之后的节点不再执行,状态停在最后完成的节点。已经执行的工具调用不会回滚,如果 act 节点已经建了退款申请,它还在。所以”停止”只表示不再往下跑,撤销要在后端单独设计。
5. 「面试题」你的 Agent 服务怎么做流式输出?前端怎么接?为什么用 SSE 不用 WebSocket?
答案
后端:LangGraph 用 stream_mode=["updates","custom"] 同时拿到节点完成事件和节点内部主动推送的事件(工具调用、逐字 token、质检结果),FastAPI 里翻译成约定好的 SSE 事件(node/step/tool/token/interrupt/done),同步的图通过线程池迭代避免阻塞。前端:fetch 读响应体,TextDecoderStream 解码,eventsource-parser 切事件,事件进 reducer,组件只渲染状态;质检退回清空草稿,停在审批时禁用追问,AbortController 支持停止。用 SSE 是因为通信本质是单向的:服务器推过程,客户端的追问和审批是独立的普通 HTTP 请求;SSE 基于普通 HTTP,穿透代理和负载均衡更容易。需要双向实时通信(比如语音)时才考虑 WebSocket。
6. 「面试题」做 AI 聊天界面,你会用 Vercel AI SDK 的 useChat 还是自己写?怎么判断?
答案
看后端和交互模型。后端是 Node 或者愿意按 AI SDK 的 UI Message Stream 协议输出(响应头 x-vercel-ai-ui-message-stream: v1、text-delta/tool-input-available/data-* 等事件),交互又是标准的一问一答聊天,用 useChat 能省掉流解析、消息状态、工具调用渲染这一大块代码。如果后端已经有自己的事件协议(比如 LangGraph 的节点、步骤、审批暂停),或者交互不是单人聊天(审批在另一个角色的页面上完成),硬套 useChat 要么后端写一层翻译迁就前端,要么前端绕弯,这时自己用 fetch + 解析器 + reducer 更直接。两种都能说清楚取舍,比只会其中一种更有说服力。
明天预告
Day 50 · 评测体系:100 条测试集、LLM-judge、轨迹评测,跑出第一份分数。按 Day 43 的验收指标设计评测集:36 个场景、100 种客户说法,按场景拆 dev/test;三层评测:规则检查(分类、工具、退款闸门、引用、事实、禁语)、LLM 评审(有没有依据、有没有解决问题)、轨迹和成本;跑出第一份基线报告。
评论