瀏覽代碼

perf(conversation): publish streaming updates every two frames

imccyu 1 月之前
父節點
當前提交
c809098b06

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
-2026-08-09-client-conversation-node-assembly.md: 4831c2261791749804b6d0bd555423b7d4894520
-2026-08-09-client-conversation-node-assembly.zh.md: 37463d0543bbabc5d236f662827b932b55bbb11d
+2026-08-09-client-conversation-node-assembly.md: 5efb808af3d551ab65304d5a8d69947f63f25a05
+2026-08-09-client-conversation-node-assembly.zh.md: 870598b46d156d3399d874d6fd1cfc18d5dc3ada

+ 6 - 4
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md

@@ -125,16 +125,16 @@ The Assembler does not use State reference equality to decide publication or pro
 | Return value | Behavior |
 |---|---|
 | `immediate` | Request a notification and flush in the current microtask |
-| `animation-frame` | Coalesce high-frequency updates into materialization on the next frame |
+| `animation-frame` | Coalesce high-frequency updates into materialization after two browser animation frames |
 | `none` | Do not schedule a flush for this Match; retain its State and dirty marker |
 
 Omitting `publication()` means `immediate`. Assistant token deltas and packed runs use `animation-frame`, invisible Inbox Contexts use `none`, and finals, dependency replays, and Location boundaries publish the latest result through an immediate path.
 
-Every live delta within a frame still executes `update()`, while one historical packed run executes one batch `update()`. Only `buildViewNode()`, View Builder work, and React snapshot notification are coalesced; no fragments are lost.
+Every live delta during the two-frame interval still executes `update()`, while one historical packed run executes one batch `update()`. Location-data publication, `buildViewNode()`, View Builder work, and React snapshot notification are coalesced; no fragments are lost. An immediate publication cancels a pending frame interval and flushes the latest State without delay.
 
 #### `buildLocationData(context, scope)`
 
-`buildLocationData()` lets a Definition publish a read-only value derived from its State onto an engine-owned Step or Turn without exposing another business's mutable State. The Assembler always materializes `step` before `turn`, so Turn-level aggregation can read Step data updated in the same flush; it calls `buildViewNode()` only after all Location data is ready.
+`buildLocationData()` lets a Definition publish a read-only value derived from its State onto an engine-owned Step or Turn without exposing another business's mutable State. The Assembler passes the preceding publication back to its owner, which returns that exact value when its business data is unchanged. The Assembler always materializes `step` before `turn`, so Turn-level aggregation can read Step data updated in the same flush; it calls `buildViewNode()` only after all Location data is ready.
 
 A Definition receives the `step` and `turn` scopes separately and may return one value or `null` in either phase. A value must identify the exact turn/step coordinates and use the Definition's `kind` as its key. The Assembler owns replacement and removal and rejects another Context that claims the same Location key.
 
@@ -390,6 +390,8 @@ History-path tests cover complete replace, non-overlapping prepend, complete-ran
 
 **Let a Location-data consumer read the provider's Context State directly.** Rejected: the consumer would depend on another business's mutable internal shape and could not express which Turn/Step owns the value. Declaration-merged data maps expose only the provider-selected read-only value and engine-owned coordinates.
 
+**Cache every Definition's Location data by State identity.** Rejected because a Definition may mutate and return the same State object, and its Location data may also depend on Match Locations or values published by another Definition. Each Definition instead decides whether its business value changed and returns the preceding publication unchanged when it did not.
+
 **Add generic `end()`, prepared, or window-reset lifecycles.** Rejected: businesses have different completion conditions, and a pagination gap is not a business lifecycle. Business Events update State, Location close triggers replay/build, and Reader dependencies own pagination invalidation.
 
 **Reuse one Event Definition across Chat and Trajectory by branching in `buildViewNode(target)`.** Rejected: the views require different business State and intermediate records, so a shared Definition would make each package carry the other's conditions and payloads. Separate target-owned Definitions keep those choices local while sharing the Assembler's ingestion and lifecycle contracts.
@@ -412,7 +414,7 @@ Initial tail, older prepend, and live append share one set of Context invariants
 
 Append does not scan historical Contexts; prepend replays only Contexts whose Matches, Locations, or Reader answers actually changed. A structural Chat change may still recompute visible order and indexes, but does not rerun unrelated business folds or replace unchanged Node identity.
 
-Separating State updates from publication cadence folds every live Assistant delta and each historical packed run while materializing at most once per animation frame. Step or Turn close and final Events can immediately publish the latest State.
+Separating State updates from publication cadence folds every live Assistant delta and each historical packed run while materializing at most once per two animation frames. The Assistant view reads the same projection that the preceding Step Location phase installed. Turn Process returns its existing open data and Node for continuing Assistant chunks without deriving or encoding them again, and Turn Tail defers its complete-Match scan until `turn/end`. Step or Turn close and final Events immediately publish the latest State.
 
 An inactive target retains Definition State and a target Context index but no builder, materialized Nodes, or snapshot. The mounted built-in or third-party View activates its own target through normal subscription; previously opened targets continue receiving incremental updates.
 

+ 6 - 4
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md

@@ -125,16 +125,16 @@ Assembler 不以 State 引用相等判断是否需要发布或传播。每次成
 | 返回值 | 行为 |
 |---|---|
 | `immediate` | 请求当前 microtask 通知与 flush |
-| `animation-frame` | 把多条高频更新合并到下一帧 materialize |
+| `animation-frame` | 跨过两个浏览器 animation frame 后,把多条高频更新合并为一次 materialization |
 | `none` | 本 Match 不主动安排 flush,State 和 dirty 标记仍被保留 |
 
 省略 `publication()` 等于 `immediate`。Assistant token delta 与 packed run 使用 `animation-frame`,不可见 Inbox Context 使用 `none`,final、依赖 replay 和 Location 边界会以 immediate 路径发布最新结果。
 
-一帧内的每条 live delta 仍执行 `update()`,一个历史 packed run 则执行一次 batch `update()`;合并的只是 `buildViewNode()`、View Builder 和 React snapshot 通知,不会丢失 fragment。
+两帧间隔内的每条 live delta 仍执行 `update()`,一个历史 packed run 则执行一次 batch `update()`;Location-data publication、`buildViewNode()`、View Builder 与 React snapshot 通知会合并执行,不会丢失 fragment。immediate publication 会取消等待中的帧间隔,并立即发布最新 State。
 
 #### `buildLocationData(context, scope)`
 
-`buildLocationData()` 让 Definition 把 State 的只读派生值发布到 Engine-owned Step 或 Turn,而不把另一个业务的可变 State 暴露出去。Assembler 在每次 materialize 中固定先处理 `step`、再处理 `turn`,因此 Turn 级聚合可以读取同一轮已经更新的 Step data;全部 Location data 就绪后才调用 `buildViewNode()`。
+`buildLocationData()` 让 Definition 把 State 的只读派生值发布到 Engine-owned Step 或 Turn,而不把另一个业务的可变 State 暴露出去。Assembler 会把前一次 publication 传回它的 owner;业务数据未变时,owner 原样返回该值。Assembler 在每次 materialize 中固定先处理 `step`、再处理 `turn`,因此 Turn 级聚合可以读取同一轮已经更新的 Step data;全部 Location data 就绪后才调用 `buildViewNode()`。
 
 Definition 分别收到 `step` 和 `turn` scope,可以在任一阶段返回一个值或 `null`。返回值必须声明准确的 turn/step 坐标,并使用与 Definition `kind` 相同的 key;Assembler 拥有替换和移除,并拒绝另一个 Context 占用同一 Location key。
 
@@ -390,6 +390,8 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏
 
 **让 Location data 消费者直接读取提供方 Context State。** 拒绝:消费者会依赖另一个业务的可变内部形状,也无法表达值属于哪个 Turn/Step。declaration-merged data map 只公开提供方选择发布的只读值和 Engine-owned 坐标。
 
+**按 State identity 缓存每个 Definition 的 Location data。** 拒绝:Definition 可以原地修改并返回同一个 State 对象,其 Location data 也可能依赖 Match Location 或其他 Definition 发布的 value。各 Definition 改为自行判断业务值是否变化;未变化时原样返回前一次 publication。
+
 **增加通用 `end()`、prepared 或 window reset 生命周期。** 拒绝:不同业务完成条件不同,分页缺口也不是业务生命周期。业务 Event 更新 State,Location close 触发 replay/build,Reader dependency 负责补页失效。
 
 **在同一个 Event Definition 内通过 `buildViewNode(target)` 为 Chat 与 Trajectory 分支。** 拒绝:两种视图需要不同的业务 State 与中间记录,共用 Definition 会迫使每个 package 携带另一边的条件与 payload。target 自有的 Definition 把这些选择留在本地,同时复用 Assembler 的摄入与生命周期约定。
@@ -412,7 +414,7 @@ Host 业务 package 把自己的持久 Event 成员 declaration-merge 到 `@deep
 
 Append 不扫描历史 Context;prepend 只 replay Match、Location 或 Reader 答案真正受影响的 Context。Chat 结构变化仍可能重算 visible order 和索引,但不会重跑无关业务 fold 或替换未变化 Node identity。
 
-State 更新与发布频率分离后,Assistant 的每条 live delta 与每个历史 packed run 都会被 fold,同时每 animation frame 最多 materialize 一次。step/turn close 和 final 可立即发布最新 State。
+State update 与 publication cadence 分离后,Assistant 的每条 live delta 与每个历史 packed run 都会被 fold,同时每两个 animation frame 最多 materialize 一次。Assistant view 读取前置 Step Location 阶段刚写入的同一 projection。Turn Process 对持续 Assistant chunk 直接返回已有 open data 和 Node,不再重复派生或编码;Turn Tail 到 `turn/end` 才执行完整 Match 扫描。Step/Turn close 与 final Event 会立即发布最新 State。
 
 inactive target 会保留 Definition State 和 target Context 索引,但不保留 builder、已物化 Node 或 snapshot。已挂载的内建或第三方 View 通过正常订阅激活自己的 target;已经打开的 target 则继续接收增量更新。
 

+ 1 - 1
packages/client/ui-conversation/src/client/contract/conversation.ts

@@ -158,7 +158,7 @@ export interface ConversationContextReader {
   previous<State>(kind: string): ConversationPreviousContext<State> | undefined
 }
 
-/** Requested cadence for materializing updated business State into view Nodes. */
+/** Requested cadence; `animation-frame` materializes after two browser animation frames. */
 export type ConversationPublication = 'none' | 'animation-frame' | 'immediate'
 
 /** Engine-owned Location data publication phase. */

+ 14 - 6
packages/client/ui-conversation/src/client/conversation/assembly.ts

@@ -86,10 +86,7 @@ class BoundConversation implements ConversationBinding {
   rebuild(): void { this.publish(this.assembler.rebuildRegistry()) }
 
   dispose(): void {
-    if (this.frame !== undefined && typeof cancelAnimationFrame === 'function') {
-      cancelAnimationFrame(this.frame)
-    }
-    this.frame = undefined
+    this.cancelFrame()
     this.disposeFeed()
   }
 
@@ -124,15 +121,26 @@ class BoundConversation implements ConversationBinding {
     if (publication === 'none') return
     if (publication === 'animation-frame' && typeof requestAnimationFrame === 'function') {
       if (this.frame !== undefined) return
+      // Cross two paint opportunities before publishing high-frequency stream updates.
       this.frame = requestAnimationFrame(() => {
-        this.frame = undefined
-        this.flush()
+        this.frame = requestAnimationFrame(() => {
+          this.frame = undefined
+          this.flush()
+        })
       })
       return
     }
+    this.cancelFrame()
     this.flush()
   }
 
+  private cancelFrame(): void {
+    if (this.frame !== undefined && typeof cancelAnimationFrame === 'function') {
+      cancelAnimationFrame(this.frame)
+    }
+    this.frame = undefined
+  }
+
   private flush(): void {
     if (this.assembler.flush()) this.snapshot.set(this.currentSnapshot())
   }

+ 97 - 2
packages/client/ui-conversation/tests/conversation-registry.client.spec.ts

@@ -1,6 +1,6 @@
 import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, vi } from 'vitest'
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import {
   createScope, MutableSessionEventSource,
@@ -17,6 +17,8 @@ import type {
 
 const SESSION_ID = 'resident' as SessionId
 
+afterEach(() => { vi.unstubAllGlobals() })
+
 function sessionSnapshot(): SessionSnapshot {
   return {
     sessionId: SESSION_ID,
@@ -136,6 +138,99 @@ async function bootRegistries(): Promise<{
 }
 
 describe('Conversation registries', () => {
+  it('publishes frame-paced updates after two animation frames and lets immediate updates preempt them', async () => {
+    let nextFrame = 0
+    const frames = new Map<number, FrameRequestCallback>()
+    const requestFrame = vi.fn((callback: FrameRequestCallback) => {
+      nextFrame++
+      frames.set(nextFrame, callback)
+      return nextFrame
+    })
+    const cancelFrame = vi.fn((frame: number) => { frames.delete(frame) })
+    vi.stubGlobal('requestAnimationFrame', requestFrame)
+    vi.stubGlobal('cancelAnimationFrame', cancelFrame)
+    const { uiConversation, binding, events, views } = await bootRegistries()
+    const definition: ConversationNodeDefinition<number> = {
+      kind: 'frame-probe',
+      target: 'chat',
+      match: event => event.type === 'turn/start'
+        ? { id: String(event.data.turn), role: 'start' }
+        : event.type === 'assistant/chunk' || event.type === 'assistant/message'
+          ? { id: String(event.data.turn), role: 'update' }
+          : null,
+      start: () => 0,
+      update: context => context.state + 1,
+      publication: match => match.event.type === 'assistant/chunk' ? 'animation-frame' : 'immediate',
+      buildViewNode: context => ({
+        key: context.key,
+        kind: 'frame-probe',
+        id: context.id,
+        target: 'chat',
+        data: context.state,
+      }),
+    }
+    events.register(definition)
+    views.register(viewDefinition('chat'))
+    await Promise.resolve()
+    const conversation = uiConversation.binding(binding)
+    conversation.activate('chat')
+    const listener = vi.fn()
+    const unsubscribe = conversation.snapshot.subscribe(listener)
+    const source = binding.eventSource as MutableSessionEventSource
+    const append = (event: SessionEvent): void => {
+      source.append({ type: 'event', event })
+    }
+
+    append({ seq: 1, time: 1, type: 'turn/start', data: { turn: 1 } })
+    listener.mockClear()
+    append({
+      seq: 2,
+      time: 2,
+      type: 'assistant/chunk',
+      data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'a' } },
+    })
+    append({
+      seq: 3,
+      time: 3,
+      type: 'assistant/chunk',
+      data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' } },
+    })
+    expect(requestFrame).toHaveBeenCalledOnce()
+    expect(listener).not.toHaveBeenCalled()
+
+    const first = frames.get(1)
+    if (first === undefined) throw new Error('first animation frame was not scheduled')
+    frames.delete(1)
+    first(0)
+    expect(requestFrame).toHaveBeenCalledTimes(2)
+    expect(listener).not.toHaveBeenCalled()
+
+    const second = frames.get(2)
+    if (second === undefined) throw new Error('second animation frame was not scheduled')
+    frames.delete(2)
+    second(16)
+    expect(listener).toHaveBeenCalledOnce()
+
+    append({
+      seq: 4,
+      time: 4,
+      type: 'assistant/chunk',
+      data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'c' } },
+    })
+    append({
+      seq: 5,
+      time: 5,
+      type: 'assistant/message',
+      data: { turn: 1, step: 1, message: { id: 'a1', role: 'assistant', content: [] } },
+    } as SessionEvent)
+    expect(cancelFrame).toHaveBeenCalledWith(3)
+    expect(frames).toHaveLength(0)
+    expect(listener).toHaveBeenCalledTimes(2)
+
+    unsubscribe()
+    await binding.ctx.fiber.dispose()
+  })
+
   it('rejects duplicate Event Definitions and disposes an ordinary registration once', async () => {
     const { events } = await bootRegistries()
     const definition = eventDefinition('message')