perf(chat): batch preparing tool updates
This commit is contained in:
1 parent
a60d4df027
commit
b2817d77fb
5 files changed
+36
-11
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user