refactor(client): isolate trajectory history reads
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
/** Trajectory view: compact summary over a turn-aware event ledger. */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {
|
||||
AssistantMessageNode, CompactionRequestView, ConversationContext,
|
||||
ConversationPromptChange, ModelRequestView,
|
||||
AssistantMessageNode, ConversationContext, SessionHistory,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
deriveTrajectoryContextBranches, trajectoryBranchContainsSeq,
|
||||
} from './context-branches.ts'
|
||||
inspectRequests, projectConversationHistory,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { deriveTrajectoryContextBranches } from './context-branches.ts'
|
||||
import {
|
||||
TrajectoryTable,
|
||||
type TrajectoryRequestNumber,
|
||||
@@ -19,9 +19,11 @@ import { deriveTrajectoryLayout } from './layout.ts'
|
||||
import css from './views.module.css'
|
||||
|
||||
const EMPTY_IDS: ReadonlySet<number> = new Set()
|
||||
const EMPTY_COMPACTION_REQUESTS: readonly CompactionRequestView[] = []
|
||||
const EMPTY_MODEL_REQUESTS: readonly ModelRequestView[] = []
|
||||
const EMPTY_PROMPT_CHANGES: readonly ConversationPromptChange[] = []
|
||||
|
||||
/** Raw session-history source needed by the event-complete trajectory view. */
|
||||
export interface TrajectoryViewInjected {
|
||||
history: SessionHistory
|
||||
}
|
||||
|
||||
interface UsageLike {
|
||||
inputTokens?: number
|
||||
@@ -67,30 +69,47 @@ function addUsage(
|
||||
}
|
||||
}
|
||||
|
||||
export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
export function TrajectoryView({ useSession, history }: ConvViewProps & TrajectoryViewInjected) {
|
||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const [collapsedAssistants, setCollapsedAssistants] =
|
||||
useState<ReadonlySet<number>>(EMPTY_IDS)
|
||||
const nodes = useSession(s => s.nodes)
|
||||
const projectedContexts = useSession(s => s.contexts)
|
||||
const compactionRequests = useSession(
|
||||
s => s.compactionRequests ?? EMPTY_COMPACTION_REQUESTS,
|
||||
)
|
||||
const requestAttempts = useSession(
|
||||
s => s.requestAttempts ?? EMPTY_MODEL_REQUESTS,
|
||||
)
|
||||
const promptChanges = useSession(
|
||||
s => s.promptChanges ?? EMPTY_PROMPT_CHANGES,
|
||||
)
|
||||
const partial = useSession(s => s.partial)
|
||||
const runningCalls = useSession(s => s.runningCalls)
|
||||
const callSchemas = useSession(s => s.callSchemas)
|
||||
const codeDispatches = useSession(s => s.codeDispatches)
|
||||
const subscribeHistory = useMemo(
|
||||
() => (listener: () => void) => history.subscribe(listener),
|
||||
[history],
|
||||
)
|
||||
const getHistorySnapshot = useMemo(
|
||||
() => () => history.getSnapshot(),
|
||||
[history],
|
||||
)
|
||||
const historySnapshot = useSyncExternalStore(
|
||||
subscribeHistory,
|
||||
getHistorySnapshot,
|
||||
getHistorySnapshot,
|
||||
)
|
||||
useEffect(() => {
|
||||
if (historySnapshot.openState === 'open' && historySnapshot.hasMore) {
|
||||
void history.loadAll()
|
||||
}
|
||||
}, [history, historySnapshot.hasMore, historySnapshot.openState])
|
||||
const projectedHistory = useMemo(
|
||||
() => projectConversationHistory(historySnapshot.entries),
|
||||
[historySnapshot.entries],
|
||||
)
|
||||
const requestInspection = useMemo(
|
||||
() => inspectRequests(historySnapshot.entries),
|
||||
[historySnapshot.entries],
|
||||
)
|
||||
const requests = requestInspection.requests
|
||||
const callSchemas = requestInspection.callSchemas
|
||||
const contexts = useMemo<readonly ConversationContext[]>(
|
||||
() => projectedContexts === undefined || projectedContexts.length === 0
|
||||
() => projectedHistory.contexts.length === 0
|
||||
? [{ id: 0, nodes }]
|
||||
: projectedContexts,
|
||||
[nodes, projectedContexts],
|
||||
: projectedHistory.contexts,
|
||||
[nodes, projectedHistory.contexts],
|
||||
)
|
||||
const branches = useMemo(
|
||||
() => deriveTrajectoryContextBranches(contexts),
|
||||
@@ -98,11 +117,9 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
)
|
||||
const currentBranch = branches.at(-1)
|
||||
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
|
||||
const selectedNodes = useMemo(() => {
|
||||
const bySeq = new Map(currentBranch.nodes.map(node => [node.seq, node]))
|
||||
for (const node of nodes) bySeq.set(node.seq, node)
|
||||
return [...bySeq.values()].sort((left, right) => left.seq - right.seq)
|
||||
}, [currentBranch, nodes])
|
||||
const selectedNodes = projectedHistory.eventNodes.length === 0
|
||||
? nodes
|
||||
: projectedHistory.eventNodes
|
||||
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
|
||||
const assistantsByStep = new Map<string, AssistantMessageNode>()
|
||||
for (const context of contexts) {
|
||||
@@ -115,46 +132,38 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
if (node.kind !== 'assistant' || node.step <= 0) continue
|
||||
assistantsByStep.set(`${node.turn}\u0000${node.step}`, node)
|
||||
}
|
||||
const attemptsByStep = new Map(
|
||||
requestAttempts.map(request => [
|
||||
`${request.turn}\u0000${request.step}`,
|
||||
request,
|
||||
]),
|
||||
const requestsByStep = new Map(
|
||||
requests
|
||||
.filter(request => request.purpose === 'assistant')
|
||||
.map(request => [
|
||||
`${request.turn}\u0000${request.step}`,
|
||||
request,
|
||||
]),
|
||||
)
|
||||
const orderedRequests = [
|
||||
...requestAttempts.map(request => ({
|
||||
...requests.map(request => ({
|
||||
seq: request.startSeq,
|
||||
kind: 'ordinary' as const,
|
||||
request,
|
||||
node: assistantsByStep.get(`${request.turn}\u0000${request.step}`),
|
||||
node: request.purpose === 'assistant'
|
||||
? assistantsByStep.get(`${request.turn}\u0000${request.step}`)
|
||||
: undefined,
|
||||
})),
|
||||
...[...assistantsByStep.entries()].flatMap(([key, node]) =>
|
||||
attemptsByStep.has(key)
|
||||
requestsByStep.has(key)
|
||||
? []
|
||||
: [{
|
||||
seq: node.seq,
|
||||
kind: 'ordinary' as const,
|
||||
request: undefined,
|
||||
node,
|
||||
}],
|
||||
),
|
||||
...compactionRequests.map(request => ({
|
||||
seq: request.startSeq,
|
||||
kind: 'compaction' as const,
|
||||
request,
|
||||
node: undefined,
|
||||
})),
|
||||
].sort((left, right) => left.seq - right.seq)
|
||||
const numbered: TrajectoryRequestNumber[] = []
|
||||
let cumulativeUsage: TrajectoryUsage | undefined
|
||||
for (const [index, entry] of orderedRequests.entries()) {
|
||||
const usage = requestUsage(
|
||||
entry.kind === 'compaction'
|
||||
? entry.request.usage
|
||||
: entry.request?.usage ?? entry.node?.usage,
|
||||
)
|
||||
const usage = requestUsage(entry.request?.usage ?? entry.node?.usage)
|
||||
cumulativeUsage = addUsage(cumulativeUsage, usage)
|
||||
if (entry.kind === 'ordinary') {
|
||||
if (entry.request?.purpose !== 'compaction') {
|
||||
const request = entry.request
|
||||
const node = entry.node
|
||||
const turn = request?.turn ?? node?.turn
|
||||
@@ -223,10 +232,10 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
step: partial.step,
|
||||
group: `Step ${partial.step}`,
|
||||
number: orderedRequests.length + 1,
|
||||
...(currentBranch.latest.prompt?.config?.provider === undefined
|
||||
...(currentBranch.latest.prompt?.config.provider === undefined
|
||||
? {}
|
||||
: { provider: currentBranch.latest.prompt.config.provider }),
|
||||
...(currentBranch.latest.prompt?.config?.model === undefined
|
||||
...(currentBranch.latest.prompt?.config.model === undefined
|
||||
? {}
|
||||
: { model: currentBranch.latest.prompt.config.model }),
|
||||
...(currentBranch.latest.prompt?.config === undefined
|
||||
@@ -238,48 +247,20 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
||||
}
|
||||
return numbered
|
||||
}, [
|
||||
compactionRequests, contexts, currentBranch.latest.prompt, nodes, partial,
|
||||
requestAttempts,
|
||||
contexts, currentBranch.latest.prompt, nodes, partial, requests,
|
||||
])
|
||||
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
|
||||
return globalRequestNumbers.filter(request =>
|
||||
request.seq === undefined
|
||||
|| trajectoryBranchContainsSeq(currentBranch, request.seq),
|
||||
)
|
||||
}, [currentBranch, globalRequestNumbers])
|
||||
const visibleRequestAttempts = useMemo(
|
||||
() => requestAttempts.filter(request =>
|
||||
requestNumbers.some(number =>
|
||||
number.purpose !== 'compaction' && number.seq === request.startSeq,
|
||||
)),
|
||||
[requestAttempts, requestNumbers],
|
||||
)
|
||||
const visibleCompactionRequests = useMemo(
|
||||
() => compactionRequests.filter(request =>
|
||||
requestNumbers.some(number =>
|
||||
number.purpose === 'compaction' && number.seq === request.startSeq,
|
||||
)),
|
||||
[compactionRequests, requestNumbers],
|
||||
)
|
||||
const visiblePromptChanges = useMemo(
|
||||
() => promptChanges.filter(change =>
|
||||
trajectoryBranchContainsSeq(currentBranch, change.seq)),
|
||||
[currentBranch, promptChanges],
|
||||
)
|
||||
const requestNumbers = globalRequestNumbers
|
||||
const turns = useMemo(
|
||||
() => deriveTrajectoryLayout({
|
||||
nodes: selectedNodes,
|
||||
partial,
|
||||
runningCalls,
|
||||
compactionRequests: visibleCompactionRequests,
|
||||
requestAttempts: visibleRequestAttempts,
|
||||
promptChanges: visiblePromptChanges,
|
||||
requests,
|
||||
callSchemas,
|
||||
codeDispatches,
|
||||
}),
|
||||
[
|
||||
selectedNodes, partial, runningCalls, visibleCompactionRequests,
|
||||
visibleRequestAttempts, visiblePromptChanges, callSchemas, codeDispatches,
|
||||
selectedNodes, partial, runningCalls, requests, callSchemas, codeDispatches,
|
||||
],
|
||||
)
|
||||
const collapsibleTurnIds = useMemo(
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
* view slot without defining a service.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's
|
||||
// owning package) must be in the program for the register calls to type.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { TrajectoryView } from './TrajectoryView.tsx'
|
||||
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
||||
import { WaterfallView } from './WaterfallView.tsx'
|
||||
|
||||
/**
|
||||
@@ -16,7 +17,7 @@ import { WaterfallView } from './WaterfallView.tsx'
|
||||
* into an undeclared slot throws — service waiting is what orders this
|
||||
* apply after the declaring one.
|
||||
*/
|
||||
export const inject = ['slots', 'conversation']
|
||||
export const inject = ['slots', 'conversation', 'sessions']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the trajectory and waterfall view tabs. The
|
||||
@@ -25,8 +26,19 @@ export const inject = ['slots', 'conversation']
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.slots.register(
|
||||
{ name: 'conversation.view', id: 'trajectory', order: 10, label: 'Trajectory' }, TrajectoryView)
|
||||
ctx.slots.register({
|
||||
name: 'conversation.view',
|
||||
id: 'trajectory',
|
||||
order: 10,
|
||||
label: 'Trajectory',
|
||||
inject: (sessionId: SessionId): TrajectoryViewInjected => {
|
||||
const session = ctx.sessions.binding(sessionId)?.session
|
||||
if (session === undefined) {
|
||||
throw new Error(`ui-trajectory: session "${sessionId}" resolved no binding`)
|
||||
}
|
||||
return { history: session.history }
|
||||
},
|
||||
}, TrajectoryView)
|
||||
ctx.slots.register(
|
||||
{ name: 'conversation.view', id: 'waterfall', order: 20, label: 'Waterfall' }, WaterfallView)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ import type {
|
||||
AssistantBlock,
|
||||
AssistantMessageNode,
|
||||
CodeSubCall,
|
||||
CompactionRequestView,
|
||||
ConversationPromptChange,
|
||||
ConversationSnapshot,
|
||||
ModelRequestView,
|
||||
RequestInspectionSnapshot,
|
||||
RequestPromptChange,
|
||||
RequestView,
|
||||
ToolResultNode,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type {
|
||||
@@ -35,10 +35,8 @@ export interface TrajectoryLayoutInput {
|
||||
nodes: ConversationSnapshot['nodes']
|
||||
partial: ConversationSnapshot['partial']
|
||||
runningCalls: ConversationSnapshot['runningCalls']
|
||||
compactionRequests?: readonly CompactionRequestView[]
|
||||
requestAttempts?: readonly ModelRequestView[]
|
||||
promptChanges?: readonly ConversationPromptChange[]
|
||||
callSchemas?: ConversationSnapshot['callSchemas']
|
||||
requests?: readonly RequestView[]
|
||||
callSchemas?: RequestInspectionSnapshot['callSchemas']
|
||||
/** run_code sub-dispatches by parent callId (sub-cells nest under the parent Tool cell). */
|
||||
codeDispatches: ConversationSnapshot['codeDispatches']
|
||||
}
|
||||
@@ -83,17 +81,18 @@ type OrderedLayoutEntry =
|
||||
| {
|
||||
kind: 'compaction'
|
||||
seq: number
|
||||
request: CompactionRequestView
|
||||
request: RequestView
|
||||
}
|
||||
| {
|
||||
kind: 'system'
|
||||
seq: number
|
||||
change: ConversationPromptChange
|
||||
request: RequestView
|
||||
change: RequestPromptChange
|
||||
}
|
||||
| {
|
||||
kind: 'request'
|
||||
seq: number
|
||||
request: ModelRequestView
|
||||
request: RequestView
|
||||
}
|
||||
|
||||
function layoutEntryOrder(entry: OrderedLayoutEntry): number {
|
||||
@@ -124,9 +123,7 @@ function inputCellDetail(node: InputNode): Pick<
|
||||
*/
|
||||
export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] {
|
||||
const {
|
||||
nodes, partial, runningCalls, compactionRequests = [], requestAttempts = [],
|
||||
promptChanges = [],
|
||||
callSchemas, codeDispatches,
|
||||
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
|
||||
} = input
|
||||
const resultByCall = indexResults(nodes)
|
||||
const callStartById = new Map<string, number>()
|
||||
@@ -193,17 +190,23 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
node,
|
||||
nodeIndex,
|
||||
})),
|
||||
...compactionRequests.map(request => ({
|
||||
kind: 'compaction' as const,
|
||||
seq: request.startSeq,
|
||||
request,
|
||||
})),
|
||||
...promptChanges.map(change => ({
|
||||
kind: 'system' as const,
|
||||
seq: change.seq,
|
||||
change,
|
||||
})),
|
||||
...requestAttempts
|
||||
...requests
|
||||
.filter(request => request.purpose === 'compaction')
|
||||
.map(request => ({
|
||||
kind: 'compaction' as const,
|
||||
seq: request.startSeq,
|
||||
request,
|
||||
})),
|
||||
...requests.flatMap(request => request.promptChange === undefined || request.prompt === undefined
|
||||
? []
|
||||
: [{
|
||||
kind: 'system' as const,
|
||||
seq: request.promptChange.seq,
|
||||
request,
|
||||
change: request.promptChange,
|
||||
}]),
|
||||
...requests
|
||||
.filter(request => request.purpose === 'assistant')
|
||||
.filter(request =>
|
||||
!representedRequests.has(`${request.turn}\u0000${request.step}`),
|
||||
)
|
||||
@@ -238,7 +241,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
continue
|
||||
}
|
||||
if (entry.kind === 'system') {
|
||||
const { change } = entry
|
||||
const { change, request } = entry
|
||||
const turn = change.kind === 'initial'
|
||||
? firstVisibleTurn(nodes, partial)
|
||||
: enclosingPromptTurn(nodes, change.seq, partial)
|
||||
@@ -249,7 +252,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
kind: 'system',
|
||||
text: promptChangeLabel(change),
|
||||
sourceSeq: change.seq,
|
||||
promptDetail: change.prompt,
|
||||
...(request.prompt === undefined ? {} : { promptDetail: request.prompt }),
|
||||
...(change.previous === undefined
|
||||
? {}
|
||||
: { previousPromptDetail: change.previous }),
|
||||
@@ -450,7 +453,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
||||
|
||||
function attachToolSchema(
|
||||
laid: LaidCell,
|
||||
callSchemas: ConversationSnapshot['callSchemas'],
|
||||
callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined,
|
||||
): void {
|
||||
if (laid.callId === undefined || callSchemas === undefined) return
|
||||
const schema = callSchemas.get(laid.callId)
|
||||
@@ -616,7 +619,7 @@ function summarizeAssistantActivity(blocks: readonly AssistantBlock[]): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
function promptChangeLabel(change: ConversationPromptChange): string {
|
||||
function promptChangeLabel(change: RequestPromptChange): string {
|
||||
if (change.kind === 'initial') return 'Initial System Prompt'
|
||||
if (change.kind === 'system') return 'System Prompt Updated'
|
||||
if (change.kind === 'tools') return 'Tools Updated'
|
||||
|
||||
Reference in New Issue
Block a user