对话视图、消息流与工具结果
本页说明 ui-conversation 中从会话事件窗口组装对话上下文、生成视图快照,并将用户消息、助手流式内容、工具调用与工具结果呈现为目标无关节点的实现。
Purpose and Scope
本文聚焦 packages/client/ui-conversation 的对话运行时:
ConversationNodeAssembler如何从连续的 session event window 维护上下文、索引、依赖和脏状态;ui-conversation插件如何通过apply装配会话服务、视图、输入组件、设置和 dock;- 消息节点契约如何表达 user、assistant、steering、context、retry、错误、max-token 和 tool-result 等结果;
- 快照替换、视图激活、工具结果配对和窗口边界等对渲染行为有直接影响的机制。
本页不展开会话控制器本身的请求发送实现、LLM provider 协议、通用 UI renderer 或独立的工具实现;这些属于相邻能力。若需要了解发送策略和设置表单,应参阅 ui-conversation 的 submission/input 相关页面;若需要了解 session event 的产生过程,应参阅 session-controller 相关页面。
Overview
对话视图采用“事件窗口 → 上下文节点 → 目标视图快照”的分层方式。ConversationNodeAssembler 是 session-owned 的增量组装引擎:它接收连续的 SessionEventLikeEntry,依据注册的 event definitions 建立业务 context,再由注册的 view definitions 生成某个 target 的 snapshot。组装器同时维护按 key、kind、sequence 和 target 的索引,以及 location index、依赖关系、dirty/revised 集合,因此事件补入、窗口替换和依赖变化不需要每次从零重建所有视图。
消息契约将最终呈现的内容区分为稳定的业务节点。例如,assistant 节点可包含 text、reasoning、image、tool-call 和 other block;tool-result 节点保存 callId、配对调用信息、错误状态和子调用;被中断的助手前缀用 interrupted 标志保留在时间流中。这种契约把“事件如何存储”与“视图如何展示”隔离开来,同时保留 timing、provider metadata、request config 等诊断和运营信息。
在宿主侧,apply 创建 UiConversation、conversation store 和 ComposerSubmissionPolicy,注册 locale、设置项、view tabs,并通过 session scope 解析会话级 conversation service。配置 schema 将通用文件上传的并发上限默认设为 2;无当前 session 时则使用稳定的空 snapshot source,避免 observable hook 的顺序和缓存因 session 切换而变化。
Architecture
图中的依赖来自实际装配关系:apply 导入并创建 UiConversation 与 ConversationController,而 ConversationNodeAssembler 的构造函数接收 event、view 和可选 group definition registry。组装器内部拥有 location index、group store 以及各类脏集合;records 契约定义其输出节点的形状。ISessions/SessionBinding 为宿主提供会话作用域,避免把一个 session 的 conversation action 错误地用于另一个 session。
Source: apply.ts
Source: assembler.ts
Source: records.ts
核心组装流程
事件窗口与上下文生命周期
ConversationNodeAssembler 的内部状态表明它同时维护四类关系:
contexts按 context key 保存唯一的InternalContext;contextsByKind和contextsBySeq支持按业务种类与事件序号定位上下文;contextsByTarget将上下文反向关联到视图 target;dependents、dirty、dirtyByTarget与revised记录增量传播范围。
每个内部 context 保存 definition、起始序号、匹配事件、派生 state、revision、当前 view node、location data 和 dependencies。这样的状态布局说明实现目标不是简单的数组映射,而是让窗口修复、后续事件和依赖变化可以只使受影响的 target 重新物化。
事件匹配结果通过 mergeMatches 按 event.seq 合并 additions 与 existing。相同 key 遇到相同序号会立即抛出异常,而不是静默去重;这保护了“一个上下文不能收到重复 match”的不变量。上下文插入使用二分查找 insertionIndex,按照 startSeq 保持有序,避免在历史窗口增长时线性扫描寻找插入位置。
该顺序体现了实现的边界:事件先进入 session-owned assembler,再由 definitions 解释;location index 和依赖状态更新后才 materialize view。分组不是 presentation mode 的替代品,而是由独立的 ConversationGroupDefinitions 提供,可按 target 找到对应 group definition。
视图和发布策略
组装器使用 ViewState 保存 target、view definition、可选 group definition、active predicate、builder 和 snapshot。发布优先级由 PUBLICATION_RANK 明确规定:none < animation-frame < immediate。因此同一轮变化中如果多个来源提出不同发布要求,使用 maximumPublication 选择更高优先级,而不是由调用顺序决定最终行为。
构造 ConversationNodeAssembler 时会调用 resetViewBuilders(),这意味着 view registry 是 live registry 的输入;视图 builder 的生命周期由组装器管理,而不是由调用者自行拼装快照。ConversationViewSnapshotStore 作为输出接口使 UI 消费者只依赖稳定的 snapshot contract。
宿主装配与会话隔离
apply 是浏览器端插件的装配入口。它从 Context 取得 sessions、slots 和配置表单,创建 UiConversation,绑定 locale,并初始化 conversation store 与提交策略。提交策略在 context effect 清理时调用 dispose,所以设置/输入策略不会在插件卸载后继续持有资源。
1export const inject = [
2 'slots', 'sessions', 'fileUpload', 'uiSession', 'uiWorkspace', 'locale', 'configForms',
3]
4
5export interface Config {
6 /** Maximum generic-file uploads allowed to run concurrently in browser Workers. */
7 maxConcurrentFileUploads?: number
8}
9
10export const Config: z<Config> = z.object({
11 maxConcurrentFileUploads: z.natural().min(1).default(2),
12})Source: apply.ts
视图 tab 不被硬编码。viewTabs 遍历 slots.entries('conversation.view'),跳过没有 id 的条目;当 developer tools 未开启时,TRAJECTORY_VIEW_ID 也会被隐藏。刷新时先比较 tab 的 id 和 label,只有发生变化才替换 snapshot,然后为所有已追踪 binding 恢复 session 的首选 view。这种比较避免了无意义的顶层 snapshot 更新,同时保证注册新 view 后已有 session 能重新选择有效 target。
会话服务解析分成两层:scopedConversation 通过 sessions.scope(id) 获取 session 作用域,并在 scope 或 conversation service 缺失时抛出带上下文的错误;concreteConversation 则从根 Context 取得具体的 ConversationController,同样在服务未安装时 fail loud。前者适合 session action,后者用于 package-internal attachment operations,二者共同防止 undefined service 被延迟到渲染阶段才暴露。
消息与工具结果数据模型
Assistant 与用户消息
UserMessageNode 保存 source event 的 seq、时间、原始 content 与 source。AssistantMessageNode 额外记录 turn、step、可选 message identity、usage、provider metadata、request config 和 timing。AssistantTiming 把 step start、首个非空 delta 与完成时间分开记录,因而 UI 或分析层可以计算首 token 延迟以及完整请求耗时。
助手 block 的 union 保留展示所需的最小稳定形状:text/reasoning 直接保存文本,image 保存 attachment reference,tool-call 保存 callId、name 和 raw args,未知类型落入 other。这使新增 provider block 不必立即改变所有 target 的解析逻辑。
工具调用与结果配对
ToolResultNode 通过 callId 配对调用;若对应调用仍在窗口中,call 保存 name 与 argsRaw,并由 callTime 支持调用行时长计算。窗口截断时,call 可以为 null,但 callId 仍保留,以便卡片展示不丢失身份。parentCallId 与 subCalls 记录 PTC dispatch 的父子关系,isError 和结构化 error 将失败结果与普通内容区分开。
1export interface ToolResultNode {
2 kind: 'tool-result'
3 seq: number
4 time: number
5 callId: string
6 /** Parent Tool call for a PTC dispatch result; absent on a root Session result. */
7 parentCallId?: string
8 name: string
9 args: ToolArgs
10 call: { name: string; argsRaw: string } | null
11 callTime: number | null
12 content: readonly ContentBlock[]
13 isError: boolean
14 error?: { name: string; code: string; reason?: string }
15 meta?: unknown
16 subCalls: readonly ToolCallBlock[]
17}Source: records.ts
实现要点与代码示例
重复事件检测与有序合并
下面的实现是窗口增量合并的关键安全边界:新匹配和已有匹配都按事件序号升序排列;同序号被视为重复输入并抛错。它没有使用 set 覆盖重复项,因为覆盖会掩盖 event registry 或窗口修复层的 bug。
1function mergeMatches(
2 key: string,
3 additions: readonly ConversationMatch[],
4 existing: readonly ConversationMatch[],
5): ConversationMatch[] {
6 const merged: ConversationMatch[] = []
7 let added = 0
8 let current = 0
9 while (added < additions.length || current < existing.length) {
10 const left = additions[added]
11 const right = existing[current]
12 if (left !== undefined && right !== undefined && left.event.seq === right.event.seq) {
13 throw new Error(`conversation Context ${key} received duplicate Match ${left.event.seq}`)
14 }
15 if (right === undefined || (left !== undefined && left.event.seq < right.event.seq)) {
16 merged.push(left as ConversationMatch)
17 added++
18 } else {
19 merged.push(right)
20 current++
21 }
22 }
23 return merged
24}Source: assembler.ts
会话级 service 解析
1function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
2 const scoped = sessions.scope(id)
3 if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`)
4 const conversation = scoped.get('conversation')
5 if (conversation === undefined) {
6 throw new Error('ui-conversation: conversation service unavailable through the session scope')
7 }
8 return conversation
9}
10
11function concreteConversation(ctx: Context): ConversationController {
12 const conversation = ctx.get('conversation') as ConversationController | undefined
13 if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
14 return conversation
15}Source: apply.ts
这两个入口都选择 fail-loud,而不是返回空对象。原因是 conversation service 是对话输入和附件操作的必要依赖;继续渲染会把配置错误转化为更晚、更难定位的用户交互故障。
Configuration Options
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
maxConcurrentFileUploads | number | 2 | 浏览器 Worker 中允许并发运行的通用文件上传数;schema 要求为自然数且最小值为 1。 |
配置通过 Config schema 在传入 apply 前物化默认值。apply 随后将其读取为 number,供文件上传相关装配使用。当前已读源码片段没有显示该值被消费的具体 upload worker 调度循环,因此其调度细节不在本页推断。
API Reference
new ConversationNodeAssembler(eventDefinitions, viewDefinitions, groupDefinitions?)
创建 session-owned 的增量组装器,并立即重置 view builders。
参数:
eventDefinitions(ConversationEventDefinitions):提供entries()、可选 fallback,以及forEvent的 event definition registry。viewDefinitions(ConversationViewDefinitions):按注册顺序提供ConversationViewDefinition。groupDefinitions(ConversationGroupDefinitions,可选):按 target 提供 grouping definition;省略时使用不包含 group 的NO_GROUPS实现。
返回: ConversationNodeAssembler,同时实现 ConversationViewSnapshotStore。
关键行为: 初始化时调用 resetViewBuilders();组装器随后持有 context、event input、location、dirty/revised、dependency、view 和 group 状态。
openTurn(): number | undefined
读取当前未激活 view 的开放 turn。实现从 ConversationLocationIndex.snapshot() 取得 turn order,检查最后一个 turn;只有该 turn 的 status 为 open 且存在 start 时才返回 turn number,否则返回 undefined。
1openTurn(): number | undefined {
2 const snapshot = this.locationIndex.snapshot()
3 const latest = snapshot.turnOrder.at(-1)
4 const turn = latest === undefined ? undefined : snapshot.turns.get(latest)
5 return turn?.status === 'open' && turn.start !== undefined ? turn.turn : undefined
6}Source: assembler.ts
apply(ctx: Context, config: Config = Config({})): void
挂载 Conversation core 和 target-neutral presentation。该入口注册 locale、创建 store 和 submission policy、注入设置行、收集 view tabs,并追踪 active Provider binding。config 缺省时使用 schema 生成的默认配置。
异常行为: 当 session scope、conversation service 或具体 controller 不可用时,相关解析函数会抛出 Error;源码没有将这些情况转换为静默降级。
Failure Modes、边界与并发
已确认的失败模式
- 重复 match:
mergeMatches发现相同 context key 与 event seq 时抛出错误,阻止损坏的上下文继续发布。 - 缺少 session scope:
scopedConversation抛出包含 session id 的错误,明确指出解析失败的 session。 - 缺少 conversation service: session scope 和 root context 两条路径都显式报错。
- 工具调用在窗口外:
ToolResultNode.call可以为 null,callId仍保留;这是窗口截断的受支持结果,不应被当作配对失败异常。 - 中断流: assistant 节点允许
interrupted: true,因此部分流可以冻结为可排序的前缀,而不必伪装成完整 durable message。
一致性与并发
源码直接展示的是浏览器状态并发,而非多线程锁:组装器通过 dirty、dirtyByTarget、revised 和 dependency map 管理同一 session 内的增量传播;上传配置明确限制 browser Workers 的并发数量。消息契约注释还规定每次 publication 替换顶层 snapshot,同时保留未变化的子结构引用,说明消费者可依赖结构共享来降低无关重渲染。
apply 对无 session 的 notices、composer block、lexicon、menu launcher 和 file uploads 使用稳定的空 source,并以 subscribe: () => () => {} 提供无操作订阅。这是一个边界保护:current-session 切换时 source identity 与 observable hook 顺序保持稳定,避免 React/observable 层把 session 切换误判为 hook 结构变化。
Performance / Operational Notes
insertionIndex使用二分查找维护按 start sequence 排序的 context 列表;mergeMatches是线性双指针合并,避免对每个新增 match 重复搜索已有序列;- 视图 tab 刷新先比较长度、id 和 label,未变化时不写入 snapshot;
- 顶层 snapshot 发布保留 unchanged substructure references,降低目标视图的无关更新;
ConversationLocationIndex将 step/turn location 数据从通用 context 状态中分离,openTurn只读取所需索引快照;ComposerSubmissionPolicy在插件 effect 清理时 dispose,减少热重载或插件卸载造成的残留订阅。
Extension Points
扩展对话视图时,应优先使用 registry 和 contract,而不是修改 assembler 的内部 map:
- 通过
ConversationEventDefinitions增加 event definition,必要时提供 unmatched-event fallback; - 通过
ConversationViewDefinitions增加 view builder;view target 可进一步关联ConversationGroupDefinitions; - 通过 slots 注册
conversation.view条目,让viewTabs自动发现 id 和 label; - 使用
AssistantBlock、ToolResultNode等稳定契约消费内容,未知 assistant block 走other; - 对 session-scoped action 使用
sessions.scope(id)获得的 conversation,而不是缓存一个跨 session service。