mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +00:00
perf: ChatNodeSeat use seperated source
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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() },
|
||||
|
||||
Reference in New Issue
Block a user