Repository Wiki
deepseek-ai/deepseek-harness

对话视图、消息流与工具结果

本页说明 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

Loading diagram...

图中的依赖来自实际装配关系: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 的内部状态表明它同时维护四类关系:

  1. contexts 按 context key 保存唯一的 InternalContext;
  2. contextsByKind 和 contextsBySeq 支持按业务种类与事件序号定位上下文;
  3. contextsByTarget 将上下文反向关联到视图 target;
  4. dependents、dirty、dirtyByTarget 与 revised 记录增量传播范围。

每个内部 context 保存 definition、起始序号、匹配事件、派生 state、revision、当前 view node、location data 和 dependencies。这样的状态布局说明实现目标不是简单的数组映射,而是让窗口修复、后续事件和依赖变化可以只使受影响的 target 重新物化。

事件匹配结果通过 mergeMatches 按 event.seq 合并 additions 与 existing。相同 key 遇到相同序号会立即抛出异常,而不是静默去重;这保护了“一个上下文不能收到重复 match”的不变量。上下文插入使用二分查找 insertionIndex,按照 startSeq 保持有序,避免在历史窗口增长时线性扫描寻找插入位置。

Loading diagram...

该顺序体现了实现的边界:事件先进入 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,所以设置/输入策略不会在插件卸载后继续持有资源。

typescript
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 将失败结果与普通内容区分开。

typescript
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。

typescript
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 解析

typescript
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

选项类型默认值说明
maxConcurrentFileUploadsnumber2浏览器 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。

typescript
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:

  1. 通过 ConversationEventDefinitions 增加 event definition,必要时提供 unmatched-event fallback;
  2. 通过 ConversationViewDefinitions 增加 view builder;view target 可进一步关联 ConversationGroupDefinitions;
  3. 通过 slots 注册 conversation.view 条目,让 viewTabs 自动发现 id 和 label;
  4. 使用 AssistantBlock、ToolResultNode 等稳定契约消费内容,未知 assistant block 走 other;
  5. 对 session-scoped action 使用 sessions.scope(id) 获得的 conversation,而不是缓存一个跨 session service。

Sources

(3 files)
packages/client/ui-conversation/src/client
packages/client/ui-conversation/src/client/contract
packages/client/ui-conversation/src/client/conversation