perf(chat): batch preparing tool updates

This commit is contained in:
imccyu committed 2026-09-23 16:11:44 +08:00
1 parent a60d4df027
commit b2817d77fb
5 files changed
+36 -11

No files matched your search

+2 -2
View File
@@ -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 packages/client/ui-chat/README.md
README.md: c9765db137f1b4fc5740cdf167fcf313d0f47b7a
README.zh.md: 9a11c301e742d309048e0990037780f7c576ea34
README.md: b3d649c319c58ff807f11cc6b6d0e634eb931711
README.zh.md: 6feb9d459299755968a3d99d5789d0cfae8b6e8e
+2
View File
@@ -87,6 +87,8 @@ Chat registers its process Group Definition through `uiConversation.groups`. Rea
`groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. A Tool node owns its preparing, dispatched, and result stages under one callId. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector.
Live tool deltas share reasoning's frame-batched publication; durable calls and results publish immediately. Repeated named deltas retain the Tool node and its data when the projected call, anchor, location, and visibility are unchanged.
The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. CSS variables do not belong in the Group Definition.
Scroll-edge fades initialize when `ResizeObserver` reports the open group's layout; opening the group performs no immediate scroll-dimension read in a layout effect.
+2
View File
@@ -87,6 +87,8 @@ Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过
`groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。同一个 callId 的准备、派发与结果阶段由 Tool 节点自己拥有。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。
实时工具 delta 与推理共用按帧合并的发布节奏;持久调用和结果立即发布。重复具名 delta 在投影调用、锚点、位置及可见性均未变化时保留 Tool 节点及其数据引用。
过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。CSS 变量不属于 Group Definition。
滚动边缘渐隐在 `ResizeObserver` 报告已展开组的布局后初始化;展开组时不会在 layout effect 中立即读取滚动尺寸。
@@ -6,7 +6,7 @@ import type {
import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type {} from '@deepseek-ai/dsh-tools/types'
import type { ChatNode, ToolChatData } from '../contract/chat-nodes.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode, contextLocation } from './common.ts'
declare module '../contract/chat-nodes.ts' {
interface ChatNodeDataMap {
@@ -276,20 +276,24 @@ export const toolDefinition: ConversationNodeDefinition<ToolState> = {
}
return updateDispatch(context.state, match)
},
publication: match => match.event.type === 'assistant/live-chunk' ? 'animation-frame' : 'immediate',
buildViewNode: (context) => {
const current = context.current.get('chat') as ChatNode<'tool-call'> | null | undefined
const state = context.state ?? fallbackState(context)
if (state === undefined) {
const previous = context.current.get('chat') as ChatNode<'tool-call'> | null | undefined
return previous === undefined || previous === null ? null : { ...previous, visibility: 'hidden' }
return current == null ? null : current.visibility === 'hidden' ? current : { ...current, visibility: 'hidden' }
}
const interruptedAt = interruption(context)
const projected = projectBlock(state.root, state, interruptedAt)
const anchor = context.start?.event.seq
?? ('kind' in state.root ? state.root.seq : context.matches[0]?.event.seq ?? 0)
const preparing = !('kind' in projected) && projected.phase === 'preparing'
return chatNode(context, 'tool-call', anchor, { root: projected } satisfies ToolChatData, {
visibility: preparing && interruptedAt !== undefined ? 'hidden' : 'visible',
})
const visibility = preparing && interruptedAt !== undefined ? 'hidden' : 'visible'
const location = contextLocation(context)
const data = current?.data.root === projected ? current.data : { root: projected } satisfies ToolChatData
if (current?.data === data && current.anchorSeq === anchor
&& current.visibility === visibility && current.location === location) return current
return chatNode(context, 'tool-call', anchor, data, { visibility, location })
},
}
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { createAssistantMessage, createToolResultMessage, LlmAttemptId, ToolCallId } from '@deepseek-ai/dsh-llm'
import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
@@ -65,9 +65,9 @@ function settlement(): SessionAssistantSettlementEntry {
} }
}
function harness(entries: readonly SessionEventLikeEntry[] = opening, hasMore = false) {
function harness(entries: readonly SessionEventLikeEntry[] = opening, hasMore = false, tool = toolDefinition) {
const assembler = new ConversationNodeAssembler(
{ entries: () => [assistantDefinition, toolDefinition], fallbackEntry: () => undefined },
{ entries: () => [assistantDefinition, tool], fallbackEntry: () => undefined },
{ entries: () => [chatViewDefinition] },
{ entries: () => [processGroupDefinition], forTarget: target => target === 'chat' ? processGroupDefinition : undefined },
)
@@ -85,6 +85,23 @@ function harness(entries: readonly SessionEventLikeEntry[] = opening, hasMore =
}
describe('Tool preparation and durable replay', () => {
it('batches repeated named deltas and retains the unchanged Tool node', () => {
const start = vi.fn(toolDefinition.start)
const h = harness(opening, false, { ...toolDefinition, start })
expect(h.assembler.append(delta(2.1, first, '{"file_path":"file.txt","content":"'))).toBe('animation-frame')
const original = h.tools()[0]!
for (let index = 0; index < 100; index++) {
expect(h.assembler.append(delta(2.2 + index / 1000, first, 'x'.repeat(128)))).toBe('animation-frame')
expect(h.tools()[0]).toBe(original)
}
expect(start).toHaveBeenCalledOnce()
expect(h.assembler.append(call(6))).toBe('immediate')
expect(h.phases()).toEqual([[first, 'start']])
expect(h.tools()[0]).not.toBe(original)
expect(h.assembler.append(result(7))).toBe('immediate')
expect(h.phases()).toEqual([[first, 'result']])
})
it('creates two independent live preparations and updates each call under the same key', () => {
const h = harness()
h.assembler.append(delta(2.1, first, '', null))