diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index c9f58c8963..a272be594a 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.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 packages/client/ui-chat/README.md -README.md: c9765db137f1b4fc5740cdf167fcf313d0f47b7a -README.zh.md: 9a11c301e742d309048e0990037780f7c576ea34 +README.md: b3d649c319c58ff807f11cc6b6d0e634eb931711 +README.zh.md: 6feb9d459299755968a3d99d5789d0cfae8b6e8e diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index c9765db137..b3d649c319 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -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. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 9a11c301e7..6feb9d4592 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -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 中立即读取滚动尺寸。 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts index efa28a82e9..ffbc94de5a 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -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 = { } 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 }) }, } diff --git a/packages/client/ui-chat/tests/tool-preparation.client.spec.ts b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts index cbd6e12d62..f09a6bae98 100644 --- a/packages/client/ui-chat/tests/tool-preparation.client.spec.ts +++ b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts @@ -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))