日志是事实,对话是投影
Conversation UI 投影层:浏览器拿到 append-only 事件日志之后,怎么把它折成眼睛看到的对话。Host 是唯一计算点,客户端只存整值、按 seq 合并——没有客户端域折叠,一个新域零客户端代码。
本章以中文撰写
第 0 秒,你在浏览器里看到一句助手回复”我跑了测试,3 个通过”。这句话不是 Host 直接推给你的——它是一堆 session.event 帧在客户端被折起来之后的样子。append-only 日志是事实,但事实本身不会自己长成对话:要有一层投影,把”事件序列”翻译成”一条条带角色、带工具调用、带推理过程的消息”。这层投影有个铁律——Host 是唯一计算点,客户端只存算好的整值、按 seq 合并,绝不在浏览器里做域逻辑折叠。这是第七部五个产品面里离你眼睛最近的一层。
日志是事实,Surface 是第一道折
packages/core/session/src/surface.ts 是架在事件日志上的表面层。它自己注释得很清楚:“The append-only log remains the source of truth.” 它做的事,是从事件日志里筛出那些”产出 LLM 消息”的事件,按顺序折成一个有序的视图。关键约束是浏览器安全——这个子路径导出会被 web 客户端消费,所以它不能有任何 node: import(会把 vite bundle 弄炸)。
折叠通过 SessionMessageProjection 注册。一个域声明它关心哪种事件类型(@messageProjection),实现一个 project(event, context):拿”这条事件之前的完整历史”,返回”这条事件落地后,当前消息该变成什么样”。注意它的契约——校验在返回更新之前做,保持消息身份、发不可变副本、不 mutate 输入。这意味着投影是纯函数:同一个日志永远折出同一个对话,重放不产生新状态。SurfaceManager/foldSurface 就是跑这些注册投影的引擎。
对话块:把内容分类成眼睛认识的形状
折完之后,一条助手消息由若干内容块组成。packages/client/ui-chat/src/client/conversation-nodes/event-projection.ts 负责把它们分类成 UI 认识的块型:
| 内容块类型 | 投影成 | 用途 |
|---|---|---|
| text | {kind:'text'} | 正文 |
| reasoning | {kind:'reasoning'} | 推理过程(可折叠) |
| image | {kind:'image', attachment} | 图片附件 |
| tool-call | {kind:'tool-call', callId/name/argsRaw} | 工具调用气泡 |
| 未知类型 | {kind:'other'} | 降级不丢 |
流式过程中,emptyAssistantBlock(blockType) 先开一个空块,之后 delta 往里填——所以一个字一个字往外蹦的回复,背后是”先建空块、再灌 delta”。计时用 isTokenDelta 判断这块 chunk 是不是可见 token(非空 text/reasoning/tool-call delta),用来算”首字时间”。
安全上最讲究的是 displayFailure。一个失败如果 code 是 AUTH——也就是凭据相关错误——它不保留 message 原文,只留 code。注释解释:“Provider AUTH messages may echo a masked or partially preserved credential. Keep the raw diagnostic in the Session log, but never retain it in UI state.” 凭据原文留在 append-only 日志里(有访问控制),UI 状态里只放一个干净的 AUTH code。这是投影层替眼睛做的过滤。
本节源码依据(5 处)
ProjectionValueStore:两类行,高 seq 胜
对话块是”这个会话内部怎么显示”,而侧栏标题、运行状态这些”跨会话也要的值”走另一条路——ProjectionValueStore。它的头注释把设计哲学讲透:“the host is the only computation site; the client holds finished whole values per key”。客户端按 key 存 host 算好的整值,行分两种:
| 行种 | 来源 | 合并规则 |
|---|---|---|
| sequenced | 连上的 Host(基线/控制流 projection 帧) | 高 seq 胜,重放/过期帧丢弃 |
| cached | 会话列表对持久化检查点的零 I/O 视图 | 无 seq,让位任何 sequenced 写 |
这套规则解决一个真实问题:侧栏在你还没点开这个会话时就要显示标题。它从列表的持久化快照里拿一个 cached 标题。但你点开之后,连上的 Host 会推一个 sequenced 标题——连上的会话才是事实,所以 cached 永远让位 sequenced。seed(baseline) 更绝:开会话基线进来,先把所有 cached 行全删,再按 seq 落值——注释说”the connected Session is the truth and a cached value never outranks it”。重放也不会让值回退:apply 时 seq <= row.seq 直接丢弃。
为什么要”客户端不做域折叠”?因为一旦客户端开始按事件累加算标题,就需要在浏览器里复制一份域逻辑——加一个新投影域就要写一遍客户端代码。现在的设计是:域在 host 注册投影、推整值,客户端那个 store 是通用的、不认识任何具体 key。新域上线,客户端零改动。
Notifier:批,但受控输入不等
值变了怎么通知 React?Notifier 是个微任务/动画帧批器。结构更新(比如标题变了)排进微任务,一 tick 内多次写合并成一次通知;流式更新(正文一个 delta)排进 requestAnimationFrame,一帧最多发一次——不然每来一个 token 就重渲染,浏览器扛不住。
但有一个例外:notifyNow()。注释写得直白:受控输入(比如输入框)的写必须和 onChange 同一个 tick 通知,否则 React 会把 DOM 回滚到旧值、光标跳回末尾。这是个微妙的 UI 细节——批处理是为了性能,但为了不让光标乱飞,受控输入走同步直路。
本节源码依据(5 处)
侧栏:lineage 缩进,不重排
最后是侧栏那个会话树。flattenLineage(summaries) 把带 parentSessionId 的会话列表展平成一棵缩进树:根 depth=0,子 depth+1,UI 只是把 depth 乘个缩进宽度。它的容错很温和——父不在列表里的孤儿会话降级成根,不丢;检测到环就 console.warn 然后当根发,不死循环。
有一个克制:它绝不按变动时间戳重排。注释说输入顺序是权威的,lineage 只让每个子贴到父旁边—— hydrate 出来的列表顺序由 host 给定,客户端不乱动。否则你盯着的列表会在后台任务更新时一直跳。
本节源码依据(2 处)
谁在说话:contextProducer 与注入形态
对话里不只有”用户”和”助手”。一条用户消息可能是系统注入的上下文——跨会话召回、加载的 agent 指令、skill 调用。contextProducer(source) 把持久化消息源投影成 UI 的角色与标签:
| source.kind | 投影角色 | 标签来源 |
|---|---|---|
| session-reference | recall | references 里的 label 列表 |
| agent-instructions | inject | changes 里的 path 列表 |
| skill-invocation | inject | 加载的 skill name |
| 未知 kind | inject | kind 本身(保持可见) |
关键在最后一行:MessageSourceMap 是 merge-extensible 的——一个新域可能注入一种 UI 还不认识的 source。投影层不把它吞掉,而是降级成”inject + kind 原文”,让这条消息在界面上仍然可见、可辨认。contextForm 同理:认得的形态(instructions/catalog/snapshot/notice/relay/recall)走结构化渲染,不认得的保持 opaque。投影是”先分类,未知就降级可见”,不是”不认识就扔”。
翻历史:分页窗口与列表相位
会话可能很长。客户端翻历史用两个分页档:普通窗口 PAGE_MESSAGES = 50(一次至少 50 条、至少 2 个 turn),跳页时 JUMP_PAGE_MESSAGES = 200——往后翻一大段时一次多拉点,避免来回。maxMessages 封顶 500。这些窗口参数传给 events.open()/events.prepend(),由 host 那边切窗口。
侧栏列表自己有个到达相位 SessionListPhase:pending(还没一次成功 pull,空数组意思是”还没到”而非”没有会话”)→ ready(至少一次 pull 落地)。注释强调 ready 是单调的——后续 pull 失败、重连都不会让它退回 pending,因为失败建模在另一条 state 轴上(idle/loading/ready/error)。两条轴正交:到达是”来过没有”,活动是”现在干嘛”。标题投影则无论会话实例化与否都落到列表行——注释说”push frames land here whether or not the Session is instantiated”,侧栏在你点开之前就有标题。
本节源码依据(5 处)
useProjection:第五个框架钩子
客户端消费这些值的入口是 useProjection(key),投影存储头注释把它定位成”第五个框架钩子座”。它是个重载函数:不传 selector 返回整个 key 的值,传 selector 就像 useSelector 一样挑一片、还能传自定义相等函数。undefined 统一表示”能力缺席”——host 单元没挂载、或这个 key 还没随基线/帧到达。
身份稳定性是它对 React 的承诺:一个 key 的 face 是 create-on-demand、cached 的,值引用只在真有帧落地时才变——所以组件可以安全地在 effect 依赖里放这个值,不会每帧都触发。store 级还有个 subscribeAny 粗粒度通道,给 manager 那种”任何 key 变了都要重建列表”的消费者用。
本节源码依据(3 处)
开会话的第一帧:asOfSeq 基线与 seed
UI 点开一个会话,store 不是从空开始的。Host 这边会跟着日志的某个水位 asOfSeq,把一批基线投影值一起发过来;客户端的 seed(baseline) 干两件事:先把所有 cached 行全删,再按基线里的 seq 逐条落值。为什么连 cached 都要清?因为一旦连上了这个会话,它就是事实本身——之前侧栏从持久化快照里拿的那个 cached 标题,不配继续活在”正在打开的会话”里。注释把这条纪律写死:“the connected Session is the truth and a cached value never outranks it”。
之后每来一个 projection 帧,apply(key, value, seq) 走的是同一条合并规则:新帧 seq 比现存行高才写,seq <= row.seq 直接丢弃。这叫重放免疫——断线重连时,通知可能被重复推送;没有”高 seq 胜”,一条旧标题帧就会把刚刷新的新标题盖回去,界面闪一下还倒退一格。有了它,迟到、重复、乱序的帧都是无害的:store 永远停在见过的最高水位上。
| 帧 / 动作 | 落到 store 的什么 | 为什么必须这样 |
|---|---|---|
| seed(baseline) | 清空 cached,按基线 seq 落 sequenced 值 | 连上的会话是事实,快照视图不得残留 |
| apply(frame) | 仅当 seq 更高才写,旧 seq 丢弃 | 重放/乱序帧不回退 UI |
| applyCached(snapshot) | 只填空、让位任何 sequenced 写 | 未点开会话的侧栏标题能先显示 |
这套水位纪律和第 43 章 headless 记 firstSeq、第 44 章服务器按 seq 推通知,是同一个思路的三处复用:事实流永远单调前进,任何一帧都无权把时钟拨回去。客户端那个 store 因此不需要知道”这一帧是不是重发”——它只比 seq。
本节源码依据(2 处)
小结:投影是单向的,从日志到眼睛
五个产品面,这是最后一个。从第 43 章那个”问一句话就退出”的薄脸,到 stdio 上的 JSON-RPC、两端 SDK、浏览器网关,最后落到这层——把日志折成对话。所有这些脸底下是同一个运行时、同一份 append-only 日志;投影层的纪律(host 唯一计算、客户端不折叠、高 seq 胜、AUTH 码脱敏)保证了:不管多少张脸,看到的都是同一份事实,只是折法不同。