perf: ChatNodeSeat use seperated source

This commit is contained in:
imccyu
2026-09-01 21:09:47 +08:00
parent 6401a64e20
commit a731536ecc
11 changed files with 190 additions and 21 deletions
@@ -1,8 +1,8 @@
import { memo, useCallback, useMemo } from 'react'
import { memo, useCallback, useMemo, useSyncExternalStore } from 'react'
import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
import type { ChatNode } from '../contract/chat-nodes.ts'
import type { ChatNodeStore } from '../contract/snapshot.ts'
import type { ChatNodeSource, ChatNodeStore } from '../contract/snapshot.ts'
import {
decodeTurnProcess, TURN_PROCESS_INDEPENDENT_KINDS, turnProcessGeneration,
type TurnProcessSpec,
@@ -13,6 +13,7 @@ import css from './ChatView.module.css'
interface ChatNodeSeatProps extends ChatNodeOwnerProps {
readonly nodeKey: string
readonly nodeSource: ChatNodeSource
readonly historyIncomplete: boolean
readonly compactTranscript: boolean
readonly useChat: ChatViewSlotProps['useChat']
@@ -78,11 +79,11 @@ function turnProcessLayout(
/** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */
export const ChatNodeSeat = memo(function ChatNodeSeat({
nodeKey, historyIncomplete, compactTranscript,
nodeKey, nodeSource, historyIncomplete, compactTranscript,
selectedCallId, cwd, openFile, inspectCall, forkAt,
renderMessageImages, fileMentions, useChat, useStore, actions, renderSlot, t,
}: ChatNodeSeatProps) {
const node = useChat(snapshot => snapshot.nodes.get(nodeKey))
const node = useSyncExternalStore(nodeSource.subscribe, nodeSource.getSnapshot)
const processSignature = useChat((snapshot) => {
const current = snapshot.nodes.get(nodeKey)
const location = current?.location
@@ -746,6 +746,7 @@ export function ChatView({
<ChatNodeSeat
key={nodeKey}
nodeKey={nodeKey}
nodeSource={nodeStore.source(nodeKey)}
historyIncomplete={hasMore}
compactTranscript={compactTranscript}
useChat={useChat}
@@ -10,10 +10,21 @@ export type {
ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode,
UserMessageNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
/** Per-key observable used by one mounted Chat Node Seat. */
export interface ChatNodeSource {
/** @returns the current Node for this source's stable key. */
getSnapshot(): ChatConversationViewNode | undefined
/** @param listener - callback for changes to this key. @returns the unsubscribe function. */
subscribe(listener: () => void): () => void
}
/** Stable live per-key reader for Chat nodes. */
export interface ChatNodeStore {
/** @param key - stable Conversation Context key. @returns current Node, when visible or hidden. */
get(key: string): ChatConversationViewNode | undefined
/** @param key - stable Conversation Context key. @returns its identity-stable observable source. */
source(key: string): ChatNodeSource
/** @returns all currently materialized Nodes without imposing render order. */
values(): readonly ChatConversationViewNode[]
}
@@ -75,12 +86,17 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
const EMPTY_LIST: readonly never[] = []
const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() }
const EMPTY_NODE_SOURCE: ChatNodeSource = {
getSnapshot: () => undefined,
subscribe: () => () => {},
}
/** Empty Chat target used before a view builder is registered. */
export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = {
order: EMPTY_LIST,
nodes: {
get: () => undefined,
source: () => EMPTY_NODE_SOURCE,
values: () => EMPTY_LIST,
},
locations: {
@@ -1,4 +1,5 @@
import type { Context } from '@deepseek-ai/cordis'
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import type {
ConversationLocation, ConversationTimelineSnapshot, ConversationViewBuilder,
ConversationViewDefinition,
@@ -6,7 +7,7 @@ import type {
import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts'
import { isRunningTool } from '../contract/chat-nodes.ts'
import type {
ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ConversationNode,
ChatLocationNodeIndex, ChatNodeSource, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ConversationNode,
LegacyConversationSlice, PartialAssistant, RunningToolCall, TurnNavigationItem,
} from '../contract/snapshot.ts'
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
@@ -22,8 +23,30 @@ function sameReferences<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index])
}
class MutableChatNodeSource implements ChatNodeSource {
private readonly listeners = new Set<() => void>()
constructor(
private readonly store: MutableChatNodeStore,
private readonly key: string,
) {}
readonly getSnapshot = (): ChatConversationViewNode | undefined => this.store.get(this.key)
readonly subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
publish(): void {
notifySubscribers(this.listeners, `[ui-chat] node source ${this.key}`)
}
}
class MutableChatNodeStore implements ChatNodeStore {
private readonly byKey = new Map<string, ChatConversationViewNode>()
private readonly sources = new Map<string, MutableChatNodeSource>()
private readonly dirtyKeys = new Set<string>()
private valuesCache: readonly ChatConversationViewNode[] = EMPTY_LIST
private valuesDirty = false
@@ -31,6 +54,15 @@ class MutableChatNodeStore implements ChatNodeStore {
return this.byKey.get(key)
}
source(key: string): ChatNodeSource {
let source = this.sources.get(key)
if (source === undefined) {
source = new MutableChatNodeSource(this, key)
this.sources.set(key, source)
}
return source
}
values(): readonly ChatConversationViewNode[] {
if (this.valuesDirty) {
this.valuesCache = [...this.byKey.values()]
@@ -40,8 +72,14 @@ class MutableChatNodeStore implements ChatNodeStore {
}
replace(nodes: readonly ChatConversationViewNode[]): void {
const previous = new Map(this.byKey)
this.byKey.clear()
for (const node of nodes) this.byKey.set(node.key, node)
for (const node of nodes) {
this.byKey.set(node.key, node)
if (previous.get(node.key) !== node) this.dirtyKeys.add(node.key)
previous.delete(node.key)
}
for (const key of previous.keys()) this.dirtyKeys.add(key)
this.valuesCache = [...this.byKey.values()]
this.valuesDirty = false
}
@@ -51,10 +89,17 @@ class MutableChatNodeStore implements ChatNodeStore {
for (const node of nodes) {
if (this.byKey.get(node.key) === node) continue
this.byKey.set(node.key, node)
this.dirtyKeys.add(node.key)
changed = true
}
if (changed) this.valuesDirty = true
}
publish(): void {
const dirty = [...this.dirtyKeys]
this.dirtyKeys.clear()
for (const key of dirty) this.sources.get(key)?.publish()
}
}
class MutableChatLocationIndex implements ChatLocationNodeIndex {
@@ -652,7 +697,9 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
this.locations.rebuild(this.order, this.store)
this.navigation.rebuild(input.timeline, this.locations, this.store)
this.timeline = input.timeline
return this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline))
const snapshot = this.snapshot(input.timeline, this.legacy.replace(nodes, input.timeline))
this.store.publish()
return snapshot
}
apply(input: {
@@ -685,7 +732,9 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
this.navigation.touch(turnsOf(contentOnly), this.locations, this.store)
}
this.timeline = input.timeline
return this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline))
const snapshot = this.snapshot(input.timeline, this.legacy.apply(upserts, input.timeline))
this.store.publish()
return snapshot
}
private snapshot(
+1 -1
View File
@@ -15,7 +15,7 @@ export type {} from './conversation-nodes/turn-tail.ts'
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex,
AssistantTiming, ChatLocationNodeIndex, ChatNodeSource, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex,
CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode,
LegacyConversationSlice, ModelRetryNode, PartialAssistant, RunningToolCall,
SteeringMessageNode, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode,
@@ -162,6 +162,7 @@ describe('Chat apply wiring', () => {
)
const data = { get: (key: string) => key === 'metric' ? 42 : undefined }
const turn = { data }
const emptySource = { getSnapshot: () => undefined, subscribe: () => () => {} }
snapshot = chatSnapshot({
nodes: { get: () => ({ location: { kind: 'turn', turn } }), values: () => [] } as never,
@@ -176,7 +177,11 @@ describe('Chat apply wiring', () => {
})
expect(useTurnData('metric')).toBeUndefined()
snapshot = chatSnapshot({
nodes: { get: () => undefined, values: () => [] },
nodes: {
get: () => undefined,
source: () => emptySource,
values: () => [],
},
})
expect(useTurnData('metric')).toBeUndefined()
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from 'vitest'
import type {
ChatConversationViewNode, ChatNodeSource,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
const timeline: ConversationTimelineSnapshot = { turnOrder: [], turns: new Map() }
function userNode(index: number, text = `message ${String(index)}`): ChatConversationViewNode {
return {
key: `user:${String(index)}`,
id: String(index),
target: 'chat',
kind: 'user',
anchorSeq: index,
location: { kind: 'session' },
visibility: 'visible',
data: {
kind: 'user',
messageId: `message-${String(index)}`,
seq: index,
time: index,
content: [{ type: 'text', text }],
source: null,
},
} as ChatConversationViewNode
}
describe('Chat Node keyed sources', () => {
it('notifies only the updated key among 4,000 mounted sources', () => {
const builder = new ChatSnapshotBuilder()
const nodes = Array.from({ length: 4_000 }, (_, index) => userNode(index + 1))
const initial = builder.replace({ nodes, timeline })
const listeners = nodes.map(() => vi.fn())
const sources: ChatNodeSource[] = nodes.map((node, index) => {
const source = initial.nodes.source(node.key)
source.subscribe(listeners[index]!)
return source
})
const target = 2_347
const changed = userNode(target + 1, 'streamed update')
const next = builder.apply({ upserts: [changed], timeline })
expect(listeners[target]).toHaveBeenCalledOnce()
expect(listeners.reduce((count, listener) => count + listener.mock.calls.length, 0)).toBe(1)
expect(next.nodes.source(changed.key)).toBe(sources[target])
expect(next.nodes.get(changed.key)).toBe(changed)
})
})
@@ -1,6 +1,6 @@
import type {
AssistantChatData, AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode,
ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, FinalAssistantChatData,
ChatLocationNodeIndex, ChatNodeSource, ChatNodeStore, CompactionSummaryNode, FinalAssistantChatData,
LegacyConversationSlice, PartialAssistant, RunningToolCall, ToolCallBlock, TurnNavigationItem,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import type {
@@ -63,35 +63,77 @@ function toolCallName(call: ToolCallBlock): string | null {
return 'name' in call ? call.name : call.call?.name ?? null
}
class FixtureNodeSource implements ChatNodeSource {
private readonly listeners = new Set<() => void>()
constructor(
private readonly store: FixtureNodeStore,
private readonly key: string,
) {}
readonly getSnapshot = (): ChatConversationViewNode | undefined => this.store.get(this.key)
readonly subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
publish(): void {
for (const listener of [...this.listeners]) listener()
}
}
class FixtureNodeStore implements ChatNodeStore {
private byKey = new Map<string, ChatConversationViewNode>()
private readonly sources = new Map<string, FixtureNodeSource>()
private readonly dirtyKeys = new Set<string>()
private list: readonly ChatConversationViewNode[] = EMPTY
get(key: string): ChatConversationViewNode | undefined {
return this.byKey.get(key)
}
source(key: string): ChatNodeSource {
let source = this.sources.get(key)
if (source === undefined) {
source = new FixtureNodeSource(this, key)
this.sources.set(key, source)
}
return source
}
values(): readonly ChatConversationViewNode[] {
return this.list
}
replace(candidates: readonly ChatConversationViewNode[]): void {
const previous = this.byKey
const next = new Map<string, ChatConversationViewNode>()
const list = candidates.map((candidate) => {
const previous = this.byKey.get(candidate.key)
const node = previous !== undefined
&& previous.kind === candidate.kind
&& previous.anchorSeq === candidate.anchorSeq
&& sameFixtureLocation(previous.location, candidate.location)
&& previous.visibility === candidate.visibility
&& nodeSource(previous) === nodeSource(candidate)
? previous
const existing = previous.get(candidate.key)
const node = existing !== undefined
&& existing.kind === candidate.kind
&& existing.anchorSeq === candidate.anchorSeq
&& sameFixtureLocation(existing.location, candidate.location)
&& existing.visibility === candidate.visibility
&& nodeSource(existing) === nodeSource(candidate)
? existing
: candidate
next.set(node.key, node)
return node
})
this.byKey = next
this.list = sameValues(this.list, list) ? this.list : list
const keys = new Set([...previous.keys(), ...next.keys()])
for (const key of keys) {
if (previous.get(key) !== next.get(key)) this.dirtyKeys.add(key)
}
}
publish(): void {
const dirty = [...this.dirtyKeys]
this.dirtyKeys.clear()
for (const key of dirty) this.sources.get(key)?.publish()
}
}
@@ -420,6 +462,7 @@ export function chatSnapshotFixture(input: {
&& derived.every((item, index) => sameTurnNavigationItem(kept[index], item))
? kept
: derived
store.publish()
return {
order,
nodes: store,
@@ -50,6 +50,7 @@ export function toolChatSnapshot(
order: nodes.map(node => node.key),
nodes: {
get: key => byKey.get(key),
source: key => ({ getSnapshot: () => byKey.get(key), subscribe: () => () => {} }),
values: () => nodes,
},
locations: {
@@ -64,9 +64,10 @@ const conversationState: ConversationState = {
activeTargets: new Set(),
}
const emptyKeys: readonly string[] = []
const emptyNodeSource = { getSnapshot: () => undefined, subscribe: () => () => {} }
const chatState: ChatState = {
order: emptyKeys,
nodes: { get: () => undefined, values: () => [] },
nodes: { get: () => undefined, source: () => emptyNodeSource, values: () => [] },
locations: { getTurn: () => emptyKeys, getStep: () => emptyKeys },
navigation: { items: () => [] },
timeline: { turnOrder: [], turns: new Map() },
@@ -63,9 +63,10 @@ const conversationState: ConversationState = {
activeTargets: new Set(),
}
const emptyKeys: readonly string[] = []
const emptyNodeSource = { getSnapshot: () => undefined, subscribe: () => () => {} }
const chatState: ChatState = {
order: emptyKeys,
nodes: { get: () => undefined, values: () => [] },
nodes: { get: () => undefined, source: () => emptyNodeSource, values: () => [] },
locations: { getTurn: () => emptyKeys, getStep: () => emptyKeys },
navigation: { items: () => [] },
timeline: { turnOrder: [], turns: new Map() },