From c5be25781cf4ac151457a966dd533c9420f5c4e6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 27 Jul 2026 18:40:30 +0800 Subject: [PATCH] feat(ui): expand request trajectory inspection --- packages/client/runtime/src/client/index.ts | 3 +- .../src/client/sessions/conversation.ts | 21 + .../src/client/sessions/fold-adapter.ts | 22 +- .../src/client/TrajectoryGroupHeader.tsx | 2 +- .../src/client/TrajectoryTable.module.css | 20 + .../src/client/TrajectoryTable.tsx | 363 ++++++++++++++---- .../src/client/TrajectoryView.tsx | 121 +++++- .../src/client/WaterfallView.tsx | 2 +- .../client/ui-trajectory/src/client/layout.ts | 115 +++--- .../src/client/trajectory-record.ts | 4 +- 10 files changed, 522 insertions(+), 151 deletions(-) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index c5fef9cc2e..a47d20c73c 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -28,7 +28,8 @@ export type { EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore, } from './contract/store.ts' export type { - AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, + AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig, + AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode, ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot, ConversationSnapshot, QueuedMessage, RunningToolCall, SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode, diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index bd75f83201..942d40ab0d 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -60,6 +60,23 @@ export interface AssistantTiming { completedTime: number } +/** Request configuration recorded in the effective header for one assistant response. */ +export interface AssistantRequestConfig { + provider: string + model: string + thinking?: string + reasoningEffort?: string + temperature?: number + maxTokens?: number + stop?: readonly string[] +} + +/** Stable provider/model identity attached to one assistant response. */ +export interface AssistantProvenanceView { + provider: string + model: string +} + /** A finalized (or interruption-frozen) assistant message. */ export interface AssistantMessageNode { kind: 'assistant' @@ -70,6 +87,8 @@ export interface AssistantMessageNode { step: number blocks: readonly AssistantBlock[] usage?: unknown + provenance?: AssistantProvenanceView + requestConfig?: AssistantRequestConfig /** Timing derived from the recorded step/chunk/message event sequence. */ timing?: AssistantTiming /** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker. @@ -211,6 +230,8 @@ export type ConversationContextOriginKind = 'compaction' | 'rewind' | 'rewrite' /** Latest complete model request header in force within one context generation. */ export interface ConversationPromptSnapshot { + /** Provider/model and sampling configuration from the latest effective request header. */ + config?: AssistantRequestConfig /** Rendered system prompt text; empty when the request had no system prompt. */ system: string /** Complete tool catalog sent with the request, including tools that were never called. */ diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index c7ac094017..6f88c678e7 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -12,7 +12,7 @@ import { } from '@deepseek-ai/dsh-session/surface' import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { - AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode, + AssistantRequestConfig, AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot, } from './conversation.ts' import { toAssistantBlocks } from './conversation.ts' @@ -43,6 +43,7 @@ function materializeNode( callIndex: ReadonlyMap, resultView: ToolResultView | null, assistantTiming?: AssistantTiming, + requestConfig?: AssistantRequestConfig, ): ConversationNode { switch (event.type) { case 'user/message': @@ -64,6 +65,11 @@ function materializeNode( kind: 'assistant', seq: event.seq, time: event.time, turn: event.data.turn, step: event.data.step, blocks: toAssistantBlocks(event.data.content), usage: event.data.usage, + provenance: { + provider: event.data.provenance.provider, + model: event.data.provenance.model, + }, + ...(requestConfig === undefined ? {} : { requestConfig }), ...(assistantTiming !== undefined ? { timing: assistantTiming } : {}), } case 'steering/message': @@ -204,6 +210,7 @@ export class FoldAdapter { this.callIdx, this.resultViews.get(seq) ?? null, event.type === 'assistant/message' ? this.assistantTiming(event) : undefined, + event.type === 'assistant/message' ? this.assistantRequestConfig(event) : undefined, ) this.nodeCache.set(seq, node) out.push(node) @@ -280,6 +287,7 @@ export class FoldAdapter { this.callIdx, this.resultViews.get(seq) ?? null, event.type === 'assistant/message' ? this.assistantTiming(event) : undefined, + event.type === 'assistant/message' ? this.assistantRequestConfig(event) : undefined, ) this.nodeCache.set(seq, node) return node @@ -312,6 +320,17 @@ export class FoldAdapter { return { stepStartTime, firstTokenTime, completedTime: event.time } } + private assistantRequestConfig( + event: SessionEvent<'assistant/message'>, + ): AssistantRequestConfig | undefined { + for (let i = event.seq; i >= this.baseSeq; i--) { + const candidate = this.padded[i] + if (candidate?.type !== 'request/header') continue + return candidate.data.header.config + } + return undefined + } + private indexCall(event: SessionEvent, view?: ToolEventView): void { if (event.type === 'tool/result') { if (view?.for === 'result') this.resultViews.set(event.seq, view.view) @@ -336,6 +355,7 @@ export class FoldAdapter { } if (event.type !== 'request/header') return this.activePrompt = { + config: event.data.header.config, system: event.data.header.system ?? '', tools: event.data.header.tools ?? [], } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx index 90252ce373..1b7b03e78e 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx @@ -5,7 +5,7 @@ import css from './TrajectoryGroupHeader.module.css' export interface TrajectoryGroupHeaderProps { /** Group title (`Message`, `Step 1`, …). */ title: string - /** Secondary summary (`49s`, `2.2s skill`, …). */ + /** Secondary summary (`49 s`, `2.2 s skill`, …). */ description?: string } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 1997faaa6f..820f7c482b 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -699,6 +699,26 @@ padding-left: 12px; } +.usagePanel { + padding: 4px 0 10px; +} + +.usageGroup + .usageGroup { + margin-top: 8px; +} + +.usageHeading { + margin: 0; + padding: 4px 14px 1px; + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-strong-13); + user-select: none; +} + +.usageGroup .overview { + padding: 0; +} + .overview dt { color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 8f24b1de78..251adb0d0a 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -5,7 +5,9 @@ import type { CSSProperties, ReactNode } from 'react' import { extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import type { + AssistantRequestConfig, ConversationPromptSnapshot, +} from '@deepseek-ai/dsh-client-runtime/client' import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock, } from './trajectory-record.ts' @@ -41,6 +43,8 @@ type DetailTab = | 'input' | 'output' | 'schema' + | 'options' + | 'usage' | 'timing' type RecordState = 'complete' | 'running' | 'error' @@ -89,6 +93,8 @@ const SYSTEM_PROMPT_TABS: readonly DetailTabItem[] = [ ] const REQUEST_TABS: readonly DetailTabItem[] = [ { id: 'overview', label: 'Summary' }, + { id: 'options', label: 'Options' }, + { id: 'usage', label: 'Usage' }, { id: 'timing', label: 'Timing' }, ] @@ -172,14 +178,6 @@ function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) {
TTFT
{ttft(metrics)}
Generation
{generationTime(metrics)}
Throughput
{throughput(metrics)}
-
-
Output tokens
-
- {!metrics.usageProvided - ? 'Usage unavailable' - : metrics.outputTokens ?? 'Output tokens unavailable'} -
-
) } @@ -188,6 +186,8 @@ function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) { export interface TrajectoryTableProps { /** Latest model request header in force for the selected context. */ prompt?: ConversationPromptSnapshot + /** Session-global request numbers for the request groups visible in this context. */ + requestNumbers?: readonly TrajectoryRequestNumber[] /** Grouped records in display order. */ turns: readonly TrajectoryTurnModel[] /** Turn ids whose rows after the first are folded into a summary. */ @@ -200,6 +200,27 @@ export interface TrajectoryTableProps { onToggleAssistant(index: number): void } +/** One context-local request identity paired with its session-global number. */ +export interface TrajectoryRequestNumber { + turn: number + step: number + number: number + provider?: string + model?: string + requestConfig?: AssistantRequestConfig + usage?: TrajectoryUsage + cumulativeUsage?: TrajectoryUsage +} + +/** Disjoint provider token buckets for one request or a session prefix. */ +export interface TrajectoryUsage { + input?: number + cacheRead?: number + cacheWrite?: number + output?: number + reasoning?: number +} + function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] { return turns.flatMap((turn) => { let firstInTurn = true @@ -223,12 +244,35 @@ function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] { }) } -function requestNumber(group: string): number | undefined { +function requestStep(group: string): number | undefined { if (!group.startsWith('Step ')) return undefined const value = Number(group.slice('Step '.length)) return Number.isInteger(value) && value > 0 ? value : undefined } +function requestKey(turn: number, group: string): string { + return `${turn}\u0000${group}` +} + +function indexRequestNumbers( + records: readonly TableRecord[], + sessionNumbers: readonly TrajectoryRequestNumber[] | undefined, +): ReadonlyMap { + const numbers = new Map() + for (const request of sessionNumbers ?? []) { + numbers.set(requestKey(request.turn, `Step ${request.step}`), request.number) + } + let next = Math.max(0, ...numbers.values()) + 1 + const boundaries = records + .filter(record => record.groupStart && requestStep(record.group) !== undefined) + .sort((left, right) => left.cell.index - right.cell.index) + for (const record of boundaries) { + const key = requestKey(record.turn, record.group) + if (!numbers.has(key)) numbers.set(key, next++) + } + return numbers +} + function summarizeTurn(records: readonly TableRecord[]): string { const userText = records .filter(record => record.cell.kind === 'user') @@ -381,6 +425,94 @@ function tokenSummary(cell: TrajectoryCellProps): ReactNode { ) } +function inputTotal(usage: TrajectoryUsage): number | undefined { + if ( + usage.input === undefined + && usage.cacheRead === undefined + && usage.cacheWrite === undefined + ) return undefined + return (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) +} + +function UsageRows({ usage }: { usage: TrajectoryUsage | undefined }) { + if (usage === undefined) return

Usage not reported

+ const totalInput = inputTotal(usage) + return ( +
+ {totalInput !== undefined && ( +
Input
{totalInput} tok
+ )} + {usage.cacheRead !== undefined && ( +
+
Cached
+
{usage.cacheRead} tok
+
+ )} + {usage.cacheWrite !== undefined && ( +
+
Cache created
+
{usage.cacheWrite} tok
+
+ )} + {usage.input !== undefined && ( +
+
Other
+
{usage.input} tok
+
+ )} + {usage.output !== undefined && ( +
Output
{usage.output} tok
+ )} + {usage.reasoning !== undefined && ( +
+
Reasoning
+
{usage.reasoning} tok
+
+ )} +
+ ) +} + +function RequestUsagePanel({ + usage, + cumulative, +}: { + usage: TrajectoryUsage | undefined + cumulative: TrajectoryUsage | undefined +}) { + return ( +
+
+

This request

+ +
+
+

Session cumulative

+ +
+
+ ) +} + +function RequestOptions({ + options, + preview = false, +}: { + options: AssistantRequestConfig | undefined + preview?: boolean +}) { + if (options === undefined) { + return

Options not recorded

+ } + return ( + + ) +} + function isMarkdownRecord(record: TableRecord): boolean { return record.cell.kind === 'user' || record.cell.kind === 'context' @@ -435,7 +567,6 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { { id: 'overview', label: 'Summary' }, { id: 'rendered', label: 'Preview' }, { id: 'source', label: 'Source' }, - { id: 'timing', label: 'Timing' }, ] } return [ @@ -1011,6 +1142,7 @@ function OverviewSection({ */ export function TrajectoryTable({ prompt, + requestNumbers: sessionRequestNumbers, turns, collapsedTurns, onToggleTurn, @@ -1026,6 +1158,7 @@ export function TrajectoryTable({ const detailsResizeDrag = useRef(null) const tabHistory = useRef>(new Set(['overview'])) const allRecords = flattenRecords(turns) + const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers) const turnRecords = collapseTurnRecords(allRecords, collapsedTurns) const records = collapseAssistantRecords(turnRecords, collapsedAssistants) const systemPromptPreview = prompt === undefined @@ -1060,26 +1193,55 @@ export function TrajectoryTable({ const selectedRequestSubtoolCalls = selectedRequestRecords.filter( record => record.cell.kind === 'subtool', ).length - const selectedRequestInputTotal = selectedRequestAssistant !== undefined - && ( - selectedRequestAssistant.cell.input !== undefined - || selectedRequestAssistant.cell.cacheRead !== undefined - || selectedRequestAssistant.cell.cacheWrite !== undefined - ) - ? (selectedRequestAssistant.cell.input ?? 0) - + (selectedRequestAssistant.cell.cacheRead ?? 0) - + (selectedRequestAssistant.cell.cacheWrite ?? 0) - : undefined + const selectedRequestInfo = selectedRequest === null + ? undefined + : sessionRequestNumbers?.find(request => request.number === selectedRequest.number) + const selectedRequestUsage = selectedRequestInfo?.usage ?? ( + selectedRequestAssistant === undefined + ? undefined + : { + ...(selectedRequestAssistant.cell.input === undefined + ? {} + : { input: selectedRequestAssistant.cell.input }), + ...(selectedRequestAssistant.cell.cacheRead === undefined + ? {} + : { cacheRead: selectedRequestAssistant.cell.cacheRead }), + ...(selectedRequestAssistant.cell.cacheWrite === undefined + ? {} + : { cacheWrite: selectedRequestAssistant.cell.cacheWrite }), + ...(selectedRequestAssistant.cell.output === undefined + ? {} + : { output: selectedRequestAssistant.cell.output }), + ...(selectedRequestAssistant.cell.think === undefined + ? {} + : { reasoning: selectedRequestAssistant.cell.think }), + } + ) + const selectedRequestCumulativeUsage = + selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage + const selectedRequestOptions = selectedRequestInfo?.requestConfig const activeTurn = selectedRequest?.turn ?? selected?.turn const selectedTabs = selectedRequest !== null - ? REQUEST_TABS + ? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined) : promptSelected ? SYSTEM_PROMPT_TABS : selected === undefined ? [] : detailTabs(selected) const selectedParents: ParentRecords = selected === undefined ? {} : parentRecords(allRecords, selected) - const hasSelectedParents = selectedParents.message !== undefined + const selectedAssistantRequest = selected?.cell.kind === 'message' + ? requestNumbers.get(requestKey(selected.turn, selected.group)) + : undefined + const selectedAssistantRequestTarget: SelectedRequest | undefined = + selected !== undefined && selectedAssistantRequest !== undefined + ? { + turn: selected.turn, + number: selectedAssistantRequest, + group: selected.group, + } + : undefined + const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined + || selectedParents.message !== undefined || selectedParents.tool !== undefined const splitStyle: TrajectorySplitStyle | undefined = toolRequestOffset === null ? undefined @@ -1109,10 +1271,13 @@ export function TrajectoryTable({ activateTab('system-prompt') } - const selectRequest = (request: SelectedRequest) => { + const selectRequest = ( + request: SelectedRequest, + tab: 'overview' | 'timing' = 'overview', + ) => { setSelectedIndex(null) setSelectedRequest(request) - activateTab('overview') + activateTab(tab) } const openRecordSummary = (target: TableRecord) => { @@ -1186,7 +1351,7 @@ export function TrajectoryTable({ const request = record.groupStart && !isCollapsedSummary && !collapsedTurns.has(record.turn) - ? requestNumber(record.group) + ? requestNumbers.get(requestKey(record.turn, record.group)) : undefined return ( Status
{statusLabel(selectedRequestState)}
-
-
Started
- -
-
-
Duration
-
- {formatElapsedSeconds( - selectedRequestAssistant?.cell.timeSeconds ?? null, - )} -
-
- {selectedRequestInputTotal !== undefined && ( + {(selectedRequestInfo?.provider + ?? selectedRequestInfo?.requestConfig?.provider) !== undefined && (
-
Input
-
{selectedRequestInputTotal} tok
+
Provider
+
+ {selectedRequestInfo?.provider + ?? selectedRequestInfo?.requestConfig?.provider} +
)} - {selectedRequestAssistant?.cell.cacheRead !== undefined && ( -
-
Cached
-
{selectedRequestAssistant.cell.cacheRead} tok
-
- )} - {selectedRequestAssistant?.cell.cacheWrite !== undefined && ( -
-
Cache created
-
{selectedRequestAssistant.cell.cacheWrite} tok
-
- )} - {selectedRequestAssistant?.cell.input !== undefined && ( -
-
Other
-
{selectedRequestAssistant.cell.input} tok
-
- )} - {selectedRequestAssistant?.cell.output !== undefined && ( + {(selectedRequestInfo?.model + ?? selectedRequestInfo?.requestConfig?.model) !== undefined && (
-
Output
-
{selectedRequestAssistant.cell.output} tok
+
Model
+
+ {selectedRequestInfo?.model + ?? selectedRequestInfo?.requestConfig?.model} +
)}
@@ -1570,23 +1711,36 @@ export function TrajectoryTable({
{selectedRequestSubtoolCalls}
)} + {selectedRequestAssistant !== undefined && ( +
+
Result
+
+ +
+
+ )}
- {selectedRequestAssistant !== undefined && ( - { openRecordSummary(selectedRequestAssistant) }} - > - + {selectedRequestOptions !== undefined && ( + { activateTab('options') }}> + )} + { activateTab('usage') }}> + + { activateTab('timing') }}> )} + {selectedRequest !== null && activeTab === 'options' && ( + + )} + {selectedRequest !== null && activeTab === 'usage' && ( + + )} {selectedRequest !== null && activeTab === 'timing' && (
- {hasSelectedParents && ( + {hasSelectedHierarchy && (
Hierarchy
+ {selectedAssistantRequestTarget !== undefined && ( + + )} {selectedParents.message !== undefined && (
)} -
Duration
{formatElapsedSeconds(selected.cell.timeSeconds)}
+ {(selected.cell.kind === 'user' || selected.cell.kind === 'context') && ( +
+
Duration
+
{formatElapsedSeconds(selected.cell.timeSeconds)}
+
+ )}
{isMarkdownRecord(selected) @@ -1696,9 +1879,21 @@ export function TrajectoryTable({ )} - { activateTab('timing') }}> - - + {selectedAssistantRequestTarget !== undefined && ( + { + selectRequest(selectedAssistantRequestTarget, 'timing') + }} + > + + + )} + {(selected.cell.kind === 'tool' || selected.cell.kind === 'subtool') && ( + { activateTab('timing') }}> + + + )}
)} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 12bc9841f0..caced65ba8 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -2,15 +2,65 @@ import { useMemo, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { ConversationContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { + AssistantMessageNode, ConversationContext, +} from '@deepseek-ai/dsh-client-runtime/client' import { ContextsPanel, contextLabel } from './ContextsPanel.tsx' -import { TrajectoryTable } from './TrajectoryTable.tsx' +import { + TrajectoryTable, + type TrajectoryRequestNumber, + type TrajectoryUsage, +} from './TrajectoryTable.tsx' import { TrajectoryToolbar } from './TrajectoryToolbar.tsx' import { deriveTrajectoryLayout } from './layout.ts' import css from './views.module.css' const EMPTY_IDS: ReadonlySet = new Set() +interface UsageLike { + inputTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number + outputTokens?: number + reasoningTokens?: number +} + +function requestUsage(value: unknown): TrajectoryUsage | undefined { + const usage = value as UsageLike | undefined + if (usage === undefined) return undefined + return { + ...(usage.inputTokens === undefined ? {} : { input: usage.inputTokens }), + ...(usage.cacheReadTokens === undefined ? {} : { cacheRead: usage.cacheReadTokens }), + ...(usage.cacheWriteTokens === undefined ? {} : { cacheWrite: usage.cacheWriteTokens }), + ...(usage.outputTokens === undefined ? {} : { output: usage.outputTokens }), + ...(usage.reasoningTokens === undefined ? {} : { reasoning: usage.reasoningTokens }), + } +} + +function addUsage( + total: TrajectoryUsage | undefined, + usage: TrajectoryUsage | undefined, +): TrajectoryUsage | undefined { + if (usage === undefined) return total + return { + ...(total?.input === undefined && usage.input === undefined + ? {} + : { input: (total?.input ?? 0) + (usage.input ?? 0) }), + ...(total?.cacheRead === undefined && usage.cacheRead === undefined + ? {} + : { cacheRead: (total?.cacheRead ?? 0) + (usage.cacheRead ?? 0) }), + ...(total?.cacheWrite === undefined && usage.cacheWrite === undefined + ? {} + : { cacheWrite: (total?.cacheWrite ?? 0) + (usage.cacheWrite ?? 0) }), + ...(total?.output === undefined && usage.output === undefined + ? {} + : { output: (total?.output ?? 0) + (usage.output ?? 0) }), + ...(total?.reasoning === undefined && usage.reasoning === undefined + ? {} + : { reasoning: (total?.reasoning ?? 0) + (usage.reasoning ?? 0) }), + } +} + function currentContextOf(contexts: readonly ConversationContext[]): ConversationContext { const context = contexts.at(-1) if (context === undefined) throw new Error('trajectory context projection must not be empty') @@ -43,6 +93,72 @@ export function TrajectoryView({ useSession }: ConvViewProps) { : contexts.find(context => context.id === selectedContextId) ?? currentContext const viewingCurrent = selectedContext.id === currentContext.id const selectedNodes = viewingCurrent ? nodes : selectedContext.nodes + const requestNumbers = useMemo(() => { + const requestsBySeq = new Map() + for (const context of contexts) { + for (const node of context.nodes) { + if (node.kind !== 'assistant' || node.step <= 0) continue + requestsBySeq.set(node.seq, node) + } + } + for (const node of nodes) { + if (node.kind !== 'assistant' || node.step <= 0) continue + requestsBySeq.set(node.seq, node) + } + const orderedRequests = [...requestsBySeq.values()] + .sort((left, right) => left.seq - right.seq) + const requestBySeq = new Map() + let cumulativeUsage: TrajectoryUsage | undefined + for (const [index, node] of orderedRequests.entries()) { + const usage = requestUsage(node.usage) + cumulativeUsage = addUsage(cumulativeUsage, usage) + requestBySeq.set(node.seq, { + turn: node.turn, + step: node.step, + number: index + 1, + ...(node.provenance?.provider === undefined + ? {} + : { provider: node.provenance.provider }), + ...(node.provenance?.model === undefined + ? {} + : { model: node.provenance.model }), + ...(node.requestConfig === undefined ? {} : { requestConfig: node.requestConfig }), + ...(usage === undefined ? {} : { usage }), + ...(cumulativeUsage === undefined ? {} : { cumulativeUsage }), + }) + } + + const selected: TrajectoryRequestNumber[] = [] + const selectedKeys = new Set() + for (const node of selectedNodes) { + if (node.kind !== 'assistant' || node.step <= 0) continue + const request = requestBySeq.get(node.seq) + if (request === undefined) continue + selected.push(request) + selectedKeys.add(`${node.turn}\u0000${node.step}`) + } + if (viewingCurrent && partial !== null && partial.step > 0) { + const key = `${partial.turn}\u0000${partial.step}` + if (!selectedKeys.has(key)) { + selected.push({ + turn: partial.turn, + step: partial.step, + number: orderedRequests.length + 1, + ...(currentContext.prompt?.config?.provider === undefined + ? {} + : { provider: currentContext.prompt.config.provider }), + ...(currentContext.prompt?.config?.model === undefined + ? {} + : { model: currentContext.prompt.config.model }), + ...(currentContext.prompt?.config === undefined + ? {} + : { requestConfig: currentContext.prompt.config }), + ...(cumulativeUsage === undefined ? {} : { cumulativeUsage }), + }) + } + } + return selected + }, [contexts, currentContext.prompt, nodes, partial, selectedNodes, viewingCurrent]) const collapsedTurns = collapsedTurnsByContext.get(selectedContext.id) ?? EMPTY_IDS const collapsedAssistants = collapsedAssistantsByContext.get(selectedContext.id) ?? EMPTY_IDS const turns = useMemo( @@ -164,6 +280,7 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index fcf92429ff..b4e4fca6a1 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -53,6 +53,15 @@ interface LaidCell { callId?: string } +interface LaidGroup { + title: string + laid: LaidCell[] +} + +interface TurnBucket { + groups: LaidGroup[] +} + /** * Fold a snapshot into turn → Message/Step groups with expanded cells. * @param input - nodes plus in-flight partial/runningCalls. @@ -70,7 +79,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const startedAt = finiteTime(call.time) if (startedAt !== null) callStartById.set(call.callId, startedAt) } - const turns = new Map }>() + const turns = new Map() let index = 0 let prevAbsTime: number | null = null let lastAssistantTurn: number | null = null @@ -78,20 +87,31 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const bucket = (turn: number) => { let entry = turns.get(turn) if (entry === undefined) { - entry = { message: [], steps: new Map() } + entry = { groups: [] } turns.set(turn, entry) } return entry } const pushMessage = (turn: number, laid: LaidCell) => { - bucket(turn).message.push(laid) + const groups = bucket(turn).groups + const last = groups.at(-1) + if (last?.title === 'Message') { + last.laid.push(laid) + return + } + groups.push({ title: 'Message', laid: [laid] }) } - const pushStep = (turn: number, step: number, laid: LaidCell) => { - const steps = bucket(turn).steps - const list = steps.get(step) ?? [] - list.push(laid) - steps.set(step, list) + const pushStep = (turn: number, step: number, laid: readonly LaidCell[]) => { + if (laid.length === 0) return + const groups = bucket(turn).groups + const title = `Step ${step}` + const existing = groups.find(group => group.title === title) + if (existing !== undefined) { + existing.laid.push(...laid) + return + } + groups.push({ title, laid: [...laid] }) } for (let i = 0; i < nodes.length; i++) { @@ -123,10 +143,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById), codeDispatches, ) - for (const laid of laidList) { - if (node.step > 0) pushStep(node.turn, node.step, laid) - else pushMessage(node.turn, laid) - } + if (node.step > 0) pushStep(node.turn, node.step, laidList) + else for (const laid of laidList) pushMessage(node.turn, laid) const last = laidList[laidList.length - 1] if (last !== undefined) index = last.cell.index prevAbsTime = finiteTime(node.time) ?? prevAbsTime @@ -153,7 +171,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T if (node.kind === 'tool-result') { if (!callEmittedInAssistant(nodes, node.callId)) { const toolName = node.call?.name - pushStep(0, 1, { + const laidList: LaidCell[] = [{ absTime: finiteTime(node.callTime ?? node.time), ...(toolName !== undefined ? { toolName } : {}), callId: node.callId, @@ -172,11 +190,12 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T timeSeconds: durationSeconds(node.time, node.callTime), startedAt: finiteTime(node.callTime), }, - }) + }] for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) { - pushStep(0, 1, laid) + laidList.push(laid) index = laid.cell.index } + pushStep(0, 1, laidList) } prevAbsTime = finiteTime(node.time) ?? prevAbsTime } @@ -195,10 +214,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T callStartById, { streaming: true }, ) - for (const laid of laidList) { - if (partial.step > 0) pushStep(partial.turn, partial.step, laid) - else pushMessage(partial.turn, laid) - } + if (partial.step > 0) pushStep(partial.turn, partial.step, laidList) + else for (const laid of laidList) pushMessage(partial.turn, laid) const last = laidList[laidList.length - 1] if (last !== undefined) index = last.cell.index } @@ -206,7 +223,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const seenCalls = collectCallIds(turns) for (const call of runningCalls) { if (seenCalls.has(call.callId)) continue - pushStep(call.turn, call.step > 0 ? call.step : 1, { + const laidList: LaidCell[] = [{ absTime: null, toolName: call.name, callId: call.callId, @@ -219,34 +236,27 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T timeSeconds: null, startedAt: finiteTime(call.time), }, - }) + }] for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) { - pushStep(call.turn, call.step > 0 ? call.step : 1, laid) + laidList.push(laid) index = laid.cell.index } + pushStep(call.turn, call.step > 0 ? call.step : 1, laidList) } // Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1. const prologue = turns.get(0) if (prologue !== undefined) { turns.delete(0) - const emptyTurn = (): { message: LaidCell[]; steps: Map } => ({ - message: [], - steps: new Map(), - }) + const emptyTurn = (): TurnBucket => ({ groups: [] }) const first = turns.get(1) ?? emptyTurn() - first.message = [...prologue.message, ...first.message] - for (const [step, cells] of prologue.steps) { - const existing = first.steps.get(step) ?? [] - first.steps.set(step, [...cells, ...existing]) - } + first.groups = [...prologue.groups, ...first.groups] turns.set(1, first) } for (const entry of turns.values()) { - for (const laid of entry.message) attachToolSchema(laid, callSchemas) - for (const laid of entry.steps.values()) { - for (const cell of laid) attachToolSchema(cell, callSchemas) + for (const group of entry.groups) { + for (const laid of group.laid) attachToolSchema(laid, callSchemas) } } @@ -267,30 +277,20 @@ function attachToolSchema( function toTurnModel( turn: number, - entry: { message: LaidCell[]; steps: Map }, + entry: TurnBucket, ): TrajectoryTurnModel { - const groups: TrajectoryGroupModel[] = [] - if (entry.message.length > 0) { - const description = groupDescription(entry.message) - groups.push({ - title: 'Message', - ...(description !== undefined ? { description } : {}), - cells: entry.message.map(l => l.cell), - }) - } - for (const step of [...entry.steps.keys()].sort((a, b) => a - b)) { - const laid = entry.steps.get(step) ?? [] + const groups = entry.groups.map(({ title, laid }): TrajectoryGroupModel => { const description = groupDescription(laid) - groups.push({ - title: `Step ${step}`, + return { + title, ...(description !== undefined ? { description } : {}), cells: laid.map(l => l.cell), - }) - } + } + }) return { turn, groups } } -/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */ +/** Wall-span duration + tool histogram, e.g. `1.5 s bash×6`. */ function groupDescription(laid: readonly LaidCell[]): string | undefined { const parts: string[] = [] // Tool rows contribute start (absTime) and end (start + own duration) so a @@ -325,8 +325,8 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined { function formatGroupDuration(seconds: number): string | undefined { if (!Number.isFinite(seconds)) return undefined const rounded = Math.round(seconds * 10) / 10 - if (Number.isInteger(rounded)) return `${rounded}s` - return `${rounded.toFixed(1)}s` + if (Number.isInteger(rounded)) return `${rounded} s` + return `${rounded.toFixed(1)} s` } /** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */ @@ -551,15 +551,12 @@ function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: st } function collectCallIds( - turns: Map }>, + turns: Map, ): Set { const ids = new Set() for (const entry of turns.values()) { - for (const laid of entry.message) { - if (laid.callId !== undefined) ids.add(laid.callId) - } - for (const list of entry.steps.values()) { - for (const laid of list) { + for (const group of entry.groups) { + for (const laid of group.laid) { if (laid.callId !== undefined) ids.add(laid.callId) } } diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 140ae8fd53..9e15f61ba1 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -80,6 +80,6 @@ export interface TrajectoryCellProps extends HTMLAttributes { export function formatElapsedSeconds(seconds: number | null): string { if (seconds === null || !Number.isFinite(seconds)) return '—' const rounded = Math.round(seconds * 10) / 10 - if (Number.isInteger(rounded)) return `${rounded}s` - return `${rounded.toFixed(1)}s` + if (Number.isInteger(rounded)) return `${rounded} s` + return `${rounded.toFixed(1)} s` }