添加 Web Client Conversation Node
1. 设计可回放的事件族
Section titled “1. 设计可回放的事件族”编写 Definition 前先选定稳定的业务 id。构成同一个 Node 的每条事件都必须携带该 id,或只凭自身 payload 独立推导出该 id;Client 绝不能把 update 猜测为属于“最近一个未完成”的 Context。
以一个 review job 为例,事件约定可以是:
| 事件 | 角色 | 必须持久化的事实 |
|---|---|---|
review/start | 唯一 start | reviewId、Turn/Step 坐标、标题 |
review/progress | update | 相同的 reviewId、坐标、可回放进度 |
review/end | update | 相同的 reviewId、坐标、最终摘要 |
跨进程边界使用生产方拥有的 branded id 类型。把 SessionEventMap 合并和 payload 类型放在生产方的纯类型导出中,再由 Client 包通过仅类型副作用导入该导出。每个 (kind, id) 最多只能有一条 start 事件。单事件业务可以把事件自身的稳定身份(例如 event.seq)作为 Definition 内部 id。
系统支持增量事件。如果生产方能以较低成本发出 whole-value checkpoint,应优先采用,因为 start 位于已加载窗口之外时它仍可直接使用。每条 delta 都必须携带稳定 id,并且按照日志 seq 升序回放时能够确定性地产生 State;它不能依赖只存在于实时内存中的状态。如果当前历史窗口只有 update,Assembler 会保留一个 pending Context,并在更早分页补齐 start 前不构造 State。如果产品必须在 start 尚未加载时渲染,terminal 或 checkpoint 事件就必须携带足够的完整 fallback 状态,让 Definition 能直接构造结果;不要通过扫描无关事件恢复它。
2. 实现 Definition 与类型化 Chat payload
Section titled “2. 实现 Definition 与类型化 Chat payload”为了完整展示关联关系,下面把生产方声明和 Client 贡献写在同一个代码块里。实际的包族中,branded id 与 SessionEventMap 声明留在事件生产方,Definition、Chat data 合并与 renderer 留在 Client 插件。
import { createElement } from 'react'import type { Branded } from '@deepseek-ai/dsh-brand'import type { ClientContext, ConversationLocation, ConversationNodeContext, ConversationNodeDefinition,} from '@deepseek-ai/dsh-client-runtime/client'import type { ChatNodeViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
type ReviewId = Branded<'ReviewId'>
interface ReviewStartData { readonly reviewId: ReviewId readonly turn: number readonly step: number readonly title: string}
interface ReviewProgressData { readonly reviewId: ReviewId readonly turn: number readonly step: number readonly completed: number}
interface ReviewEndData { readonly reviewId: ReviewId readonly turn: number readonly step: number readonly summary: string}
declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** * Opens one durable review job. * @mode emit * @param data - stable identity, location, and initial display state. */ 'review/start': ReviewStartData /** * Records replayable progress for one review job. * @mode emit * @param data - stable identity, location, and latest progress. */ 'review/progress': ReviewProgressData /** * Closes one review job with its final summary. * @mode emit * @param data - stable identity, location, and final display state. */ 'review/end': ReviewEndData }}
interface ReviewChatData { readonly title: string readonly completed: number readonly status: 'running' | 'completed' readonly summary?: string}
declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ChatNodeDataMap { 'review-job': ReviewChatData }}
declare module '@deepseek-ai/dsh-client-runtime/client' { interface ConversationStepDataMap { 'review-job': ReviewChatData }}
interface ReviewState extends ReviewChatData { readonly turn: number readonly step: number}
function locationOf(context: ConversationNodeContext): ConversationLocation { return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' }}
function viewData(state: ReviewState): ReviewChatData { return { title: state.title, completed: state.completed, status: state.status, ...state.summary === undefined ? {} : { summary: state.summary }, }}
const reviewDefinition: ConversationNodeDefinition<ReviewState> = { kind: 'review-job', target: 'chat', match: (event) => { if (event.type === 'review/start') { return { id: String(event.data.reviewId), role: 'start' } } if (event.type === 'review/progress' || event.type === 'review/end') { return { id: String(event.data.reviewId), role: 'update' } } return null }, start: (_context, match) => { if (match.event.type !== 'review/start') throw new Error('review-job requires review/start') return { turn: match.event.data.turn, step: match.event.data.step, title: match.event.data.title, completed: 0, status: 'running', } }, update: (context, match) => { if (match.event.type === 'review/progress') { return { ...context.state, completed: match.event.data.completed } } if (match.event.type === 'review/end') { return { ...context.state, completed: 100, status: 'completed', summary: match.event.data.summary } } return context.state }, publication: match => match.event.type === 'review/progress' ? 'animation-frame' : 'immediate', buildLocationData: (context, scope) => { if (scope !== 'step' || context.state === undefined) return null return { kind: 'step', turn: context.state.turn, step: context.state.step, key: 'review-job', value: viewData(context.state), } }, buildViewNode: (context) => { if (context.state === undefined) return null return { key: context.key, kind: 'review-job', id: context.id, target: 'chat', anchorSeq: context.start?.event.seq ?? context.matches[0]?.event.seq ?? 0, location: locationOf(context), visibility: 'visible', data: viewData(context.state), } },}
function ReviewNodeView({ node }: ChatNodeViewProps<'review-job'>) { const text = node.data.summary ?? `${node.data.title}: ${node.data.completed}%` return createElement('p', null, text)}
export const inject = ['conversationEvents', 'slots']
export function apply(ctx: ClientContext): void { ctx.conversationEvents.register(reviewDefinition) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'review-job', }, ReviewNodeView))}match(event) 是身份提取器,不是 fold:它只能收到当前事件,并返回 Definition 内部 id 与生命周期角色。命中后,Assembler 通过 (kind, id) 定位 Context,再调用一次 start,或把当前 State 交给 update。两个函数都必须返回引擎随后采用的 State;推荐返回新的 immutable value,但函数原地修改后返回同一对象时,采用语义也相同。
buildLocationData(context, scope) 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 useTurnData(key))读取该值,无须取得 Session,也无须扫描 snapshot.chat.nodes。
target 与 buildViewNode(context) 必须同时声明一项由 target 拥有的渲染贡献。把 context.key 保留为 React 侧身份,根据持久排序证据选择 anchorSeq,并且只返回 renderer 可以直接使用的数据。某个 target Node 一旦发布,就要继续返回同一个 key;需要暂时离开可见流时使用 visibility: 'hidden',不要改为返回 null 撤回它。
3. 只在 start 时查询更早的业务 Context
Section titled “3. 只在 start 时查询更早的业务 Context”有些 Definition 需要另一个业务 kind 在当前位置之前的最新 State。start 会收到 ConversationContextReader;应在这里调用 reader.previous<State>(kind),不要接收 Context 集合或扫描事件。Reader 返回当前 start seq 之前最近一个已启动 Context 的只读数据。
Assembler 会记录这项依赖。如果后续 older prepend 带来了更近的前序 Context、补齐了原先未知的窗口缺口,或者前序 State 被修订,引擎会从 start 重新运行依赖方 Context,并按 seq 升序回放其 update。被查询的 Definition 仍负责把有用信息写入自身 State;Reader 不提供业务专用查询方法,也不授予修改其他 Context 的权限。
4. 理解三条摄入路径
Section titled “4. 理解三条摄入路径”历史可能从尾部开始一页一页向前请求,但每个已接收分页都会先按 seq 升序归一化,再进入 State 回放。
| 路径 | 引擎工作 | Definition 可观察到的行为 |
|---|---|---|
| open、resync 或 gap repair 时 replace | 重建已加载窗口,每条事件对每个 Definition 匹配一次,再回放每个已有 start 的 Context | 先执行 start,再按 seq 升序执行其 update;只有 update 的 pending Context 仍没有 State |
| prepend 一页更早历史 | 只匹配新增的更早事件,按 (kind, id) 合并进 Context,保留现有 keyed node,并只重放受影响的 Context 与依赖 | 新发现的 start 会激活已收集 update;Location 或前序依赖变化也可能重跑 Context |
| append 一条实时事件 | 每个 Definition 各调用一次 match,按 key 查找命中的 Context,只更新该 Context | 对 start 之后的匹配事件执行一次 update 并请求一次发布;不扫描已有 Context |
注册 D 个 Definition 时,一条新事件会进行 D 次仅当前事件匹配;命中后的 Context key 查询是常数时间。Definition 代码必须维持这个性质:正常 append 热路径不得遍历完整事件窗口、所有 Context、context.matches 或已渲染 Node 集合。累计事实放进 State,同 Turn/Step 共享信息放进 Location data,有索引的前序依赖使用 reader.previous()。
publication 控制发生 State 变更后何时物化。结构或 terminal 变化使用 immediate,高频可见 delta 使用 animation-frame,只为后续发布积累 State 时使用 none。引擎仍会按日志顺序应用每条 update;该选项只合并视图发布频率。
5. 验证回放、分页与渲染
Section titled “5. 验证回放、分页与渲染”添加聚焦测试,证明以下结果:
- 完整窗口通过 replace 后产生预期的最终 State、Location data、Node payload 与
anchorSeq。 - 只有 update 的尾部窗口保持 pending;prepend 唯一 start 后,结果与完整 replace 相同。
- 初始历史后继续实时 append,与回放合并后的完整窗口得到相同结果。
- prepend 更早分页只增加更早的行;数据未变化的既有 keyed Node value 不被替换。
- 重复的可见 delta 保持
context.key,并在请求animation-frame时每帧最多发布一次。 - keyed renderer 只消费
node.data与受限 Location hook,不扫描 Session 事件窗口、Context 或 Chat Node。
流式与中断处理可参考 packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts,前序查询可参考 inbox.ts 与 message.ts,只发布 Turn data 而不创建自有 Node 的例子见 packages/client/ui-deliverables。