Day 16 / 共 20 天 · 第 4 周 前端/安全/生态

前端实时展示

第 1 周的 Action/Observation 事件模型,如何变成你看到的聊天界面?今天读前端真实代码:WebSocket 连接、事件分发、类型守卫,和那条"观察替换动作"的核心渲染规则。

📍 第 4 周 前端与生态 · 你在这里
Day16 前端展示Day17 安全权限Day18 微代理SkillsDay19 企业版Day20 构建收官
L01

前端的任务

前端(React)要做的事:接收事件流 → 判断每个事件是什么 → 渲染成对应的 UI 卡片,并让你能下任务、暂停、确认。核心目录 frontend/src/

前端本质是"事件流的可视化器" 后端(Agent)产出的是一串 Action/Observation 事件(Day 03)。前端做的就是把这串抽象数据"翻译成人看得懂的画面":一个 bash Action → 一个终端命令卡片;一个文件编辑 Observation → 一个 diff 视图;一个截图 Observation → 一张网页图片。你在界面上看到的一切,本质都是某个事件的可视化。理解了事件模型(第1周),前端逻辑就一通百通。
前端就像生活中的"外卖 App 的骑手轨迹地图":真正送餐的是骑手(后端 Agent),App 只是把骑手每一步的位置上报(事件)画成地图上移动的小电驴。你盯着屏幕看的不是骑手本人,而是他一连串位置汇报的可视化。
L02

WebSocket 连接

前端直连沙箱里 agent-server 的 WebSocket(Day 05 讲过为什么直连)。地址由 websocket-url.ts:78buildWebSocketUrl() 拼成:

// ws(s)://{host}:{port}[/前缀]/sockets/events/{conversationId}
// 连接参数带 resend_all: true(重连时重发所有事件)+ session_api_key(鉴权)
读法:每个会话一个 WebSocket 连接,URL 里带会话 id。连接管理中枢是 conversation-websocket-context.tsx(约 1000 行)的 ConversationWebSocketProvider——它用 useWebSocket hook 建连,还支持"主对话 + planning 子 agent"两条并行连接(Day 11 的规划型 Agent)。
为什么用 WebSocket 而不是 SSE? 项目里 grep 不到 EventSource(SSE),实时通道就是原生 WebSocket。因为 WebSocket 是双向的——前端既要"收事件"(看 Agent 干活),也要"发消息"(给 Agent 下指令、暂停)。SSE 只能服务端单向推,不够用。resend_all:true 让你刷新/重连时能重新拿到全部事件、重建界面。
L03

收到消息怎么分发

收到 WebSocket 消息时,handleMainMessageconversation-websocket-context.tsx:365)做几件事:

const event = JSON.parse(messageEvent.data);   // WS 传来的是 JSON 字符串
if (!isV1Event(event)) return;                  // 校验是合法事件
addEvent(event);                                // 存进 event store
// 再按类型分发副作用:
//   bash 输出 → 终端 appendOutput
//   浏览器截图 → browser store
//   状态更新 → setExecutionStatus
//   错误 → error store
读法:JSON.parse 解析、校验、存进统一的事件仓库,再根据事件类型触发对应的"副作用"(更新终端/浏览器/状态面板)。发消息则通过 sendMessage:878currentSocket.send(JSON.stringify(...))
为什么要先"存"再"分发"? 统一存进 event store,好处是:① 事件有单一可信来源(所有 UI 都从 store 读,不会各存一份导致不一致);② 能回放、能调试(store 里就是完整历史)。分发副作用则是"顺便通知各个专门视图更新自己"。"单一数据源 + 派生视图"是现代前端状态管理的黄金模式。
👶💬 对话体:为什么不来一条画一条就完了? 👶 小白:收到事件直接画到屏幕上不就行了,干嘛先存进 store 再分发?
👨‍🏫 老师:因为终端、浏览器截图、状态面板是好几个独立视图。若各存各的,刷新页面就全丢、还容易对不上。
👶 小白:那存一份统一的有啥好处?
👨‍🏫 老师:所有视图都从这一份读,天然一致;而且这份就是完整历史,能回放、能调试——出 bug 时你能重现 Agent 当时看到的一切。
L04

类型守卫 type-guards

WebSocket 传来的是 JSON.parse 出的 unknown——得先判断它到底是哪种事件才能安全使用。type-guards.ts 提供一整套守卫函数:

// type-guards.ts
isActionEvent(e)       // :103 source==="agent" && "action" in e && "kind" in e.action
isObservationEvent(e)  // :58  source==="environment" && "action_id" in e && "observation" in e
isMessageEvent(e), isUserMessageEvent(e)
isExecuteBashActionEvent(e), isBrowserObservationEvent(e)  // 更细粒度
读法:每个守卫函数检查事件的关键字段,确认它是某种类型,让后续代码能安全地访问对应字段。注意 isObservationEventsource==="environment"(Day 03 讲过观察永远来自环境)+ 有 action_idobservation 字段来判断——正是我们前面学的事件结构。
类型守卫为什么必要? TypeScript 在编译期检查类型,但 WebSocket 传来的数据是运行时才到的 unknown,编译器不知道它是啥。类型守卫是"运行时的类型检查"——检查完,TypeScript 就收窄了类型,你能安全地 event.action.command 而不会因为字段不存在报错。不信任外部数据、先校验再使用,是健壮前端的基本纪律。
L05

核心规则:观察替换动作

最能体现"事件流→UI"的是 handleEventForUIutils/handle-event-for-ui.ts:293)。它有条核心规则(:404):

// ObservationEvent 到达时,用 action_id 找到之前那条 ActionEvent,原地替换
const actionIndex = newUiEvents.findIndex((ui) => ui.id === event.action_id);
if (actionIndex !== -1) newUiEvents[actionIndex] = event;
⏳ ActionEvent
「要运行 pytest」
观察到达
action_id 配对 →
✅ ObservationEvent
「pytest 输出:5 passed」
读法:这就是 Day 03 埋的伏笔的兑现——观察靠 action_id 找到对应动作卡片,原地替换。所以界面上是"一张卡片先显示意图、再变成结果",不是两张卡片。例外:390):ThinkObservationFinishObservation 不加入 UI——因为思考/结束语的内容已经在对应 Action 里显示过了,避免重复。
🤔 痛点:一个动作有"意图"和"结果"两条事件,界面上该显示一张卡还是两张卡?Agent 先说"我要跑 pytest"(ActionEvent),过一会才回来"5 passed"(ObservationEvent)。如果各画一张卡,界面会越滚越长、意图和结果还对不上。
💡 本质:用 action_id 把结果"贴回"原动作卡,原地替换。handle-event-for-ui.tsevent.action_id === ui.id 找到那张动作卡,newUiEvents[actionIndex] = event 就地覆盖——所以你看到的是"同一张卡先显示意图、再变成结果"。这就像生活中外卖 App 里同一个订单卡片:先显示"商家已接单",骑手送达后不是新弹一张卡,而是原卡直接刷新成"已送达"。订单号(action_id)就是把两个状态串到同一张卡的钥匙。
L06

流式增量合并

handleEventForUI 的其余逻辑(:299-374)主要在处理 Day 03 的 StreamingDeltaEvent——把一小段段流式 token 合并成"正在生成的消息气泡",等最终的完整 MessageEvent 到达时,用它取代那个预览气泡。

"打字机效果"的实现原理 大模型流式吐字(Day 14),每个 delta 事件带一小段文本。前端把它们累加到一个"临时预览气泡"里——你就看到文字一个个冒出来(打字机)。等生成完,后端发一个完整的 MessageEvent(这个才持久化,Day 03),前端用它替换预览气泡(对账,确保最终显示的是权威版本)。预览求快、最终求准——两者对账,既流畅又正确。
流式输出就像生活中"餐厅厨师边炒边上菜":菜(完整回答)还没全做好,但香味和第一口已经先端上来(delta 一段段冒字),你不用干等到全部做完。等整道菜正式装盘(完整 MessageEvent),服务员会用正式那盘替换掉试吃的小碟(对账)。
📝 举个例子模型要回复"测试全部通过啦"。它不是一次吐完,而是连发多条 StreamingDeltaEvent"测试""全部""通过啦",前端累加进预览气泡,你看到字一个个冒出来;最后一条完整 MessageEvent="测试全部通过啦" 到达,替换掉预览气泡(这条才会被持久化)。
第一人称:我是一个 ActionEvent,我的旅程 ① 后端 Agent 生成了我 ② WebSocket 我被推给前端 ③ 类型守卫 isActionEvent 认出我是谁 ④ 渲染成 UI 卡片 观察到达后,会用我的 id 找到这张卡,把结果贴回来(观察替换动作)
图:扮演一个 ActionEvent,走完"后端→WebSocket→守卫→渲染"的第一人称之旅
L07

历史加载与降级

两个健壮性设计:

  • 历史加载:连接打开时先调 REST(EventService.getEventCountuseConversationHistory)从 app_server 拉历史事件(Day 08 的只读接口),重建界面——这样刷新页面/重进会话不会丢历史。
  • 发送降级:WebSocket 没连上时,sendMessage 降级为 REST 队列 PendingMessageService.queueMessage:894)——消息先排队,连上了再发,不丢消息。
两条数据通道各司其职(呼应 Day 05/08)实时走 WebSocket 直连沙箱(低延迟);历史走 REST 从 app_server 拉(可靠存档)。前端把两者拼起来——历史 + 实时 = 完整体验。降级到 REST 队列则保证了网络抖动时消息不丢。好前端不仅要"正常时好用",还要"异常时不崩、不丢数据"。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 前端的本质任务是什么?(事件流可视化)
  • 为什么用 WebSocket 而非 SSE?resend_all 干嘛?
  • 收到消息为什么"先存再分发"?
  • 类型守卫为什么必要?isObservationEvent 靠什么判断?
  • "观察替换动作"规则是什么?为什么 Think/Finish 观察不入 UI?
一句话复述前端不做智能、只做翻译:把后端一串 Action/Observation 事件,一条条渲染/对账成你看到的卡片和打字机气泡——它就是那张"外卖骑手实时轨迹地图"。

✋ 动手

sed -n '78,95p' frontend/src/utils/websocket-url.ts
grep -n 'handleMainMessage\|addEvent\|sendMessage\|queueMessage' frontend/src/contexts/conversation-websocket-context.tsx | head
sed -n '58,112p' frontend/src/types/v1/type-guards.ts
grep -n 'action_id\|findIndex\|ThinkObservation\|FinishObservation' frontend/src/utils/handle-event-for-ui.ts | head
明天预告 · Day 17安全与权限——把散落各处的安全机制汇总:沙箱隔离、confirmation mode 人工确认、security_risk 风险评估、密钥按需下发、路径前缀隔离。看 OpenHands 如何"敢把执行权交给 AI"。
← Day 15 CodeAct Day 17 · 安全与权限 →