feat(ui): expand request trajectory inspection

This commit is contained in:
_Kerman
2026-07-27 18:40:30 +08:00
parent ffd2da6986
commit c5be25781c
10 changed files with 522 additions and 151 deletions

View File

@@ -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,

View File

@@ -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. */

View File

@@ -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<string, CallIndexEntry>,
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 ?? [],
}

View File

@@ -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
}

View File

@@ -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);
}

View File

@@ -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 }) {
<div><dt>TTFT</dt><dd>{ttft(metrics)}</dd></div>
<div><dt>Generation</dt><dd>{generationTime(metrics)}</dd></div>
<div><dt>Throughput</dt><dd>{throughput(metrics)}</dd></div>
<div>
<dt>Output tokens</dt>
<dd>
{!metrics.usageProvided
? 'Usage unavailable'
: metrics.outputTokens ?? 'Output tokens unavailable'}
</dd>
</div>
</dl>
)
}
@@ -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<string, number> {
const numbers = new Map<string, number>()
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 <p className={css.noPayload}>Usage not reported</p>
const totalInput = inputTotal(usage)
return (
<dl className={css.overview}>
{totalInput !== undefined && (
<div><dt>Input</dt><dd>{totalInput} tok</dd></div>
)}
{usage.cacheRead !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Cached</dt>
<dd>{usage.cacheRead} tok</dd>
</div>
)}
{usage.cacheWrite !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Cache created</dt>
<dd>{usage.cacheWrite} tok</dd>
</div>
)}
{usage.input !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Other</dt>
<dd>{usage.input} tok</dd>
</div>
)}
{usage.output !== undefined && (
<div><dt>Output</dt><dd>{usage.output} tok</dd></div>
)}
{usage.reasoning !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Reasoning</dt>
<dd>{usage.reasoning} tok</dd>
</div>
)}
</dl>
)
}
function RequestUsagePanel({
usage,
cumulative,
}: {
usage: TrajectoryUsage | undefined
cumulative: TrajectoryUsage | undefined
}) {
return (
<div className={css.usagePanel}>
<section className={css.usageGroup}>
<h4 className={css.usageHeading}>This request</h4>
<UsageRows usage={usage} />
</section>
<section className={css.usageGroup}>
<h4 className={css.usageHeading}>Session cumulative</h4>
<UsageRows usage={cumulative} />
</section>
</div>
)
}
function RequestOptions({
options,
preview = false,
}: {
options: AssistantRequestConfig | undefined
preview?: boolean
}) {
if (options === undefined) {
return <p className={css.noPayload}>Options not recorded</p>
}
return (
<JsonTree
data={options}
label="Request options JSON"
className={preview ? css.jsonPreview! : css.jsonPayload!}
/>
)
}
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<DetailsResizeDrag | null>(null)
const tabHistory = useRef<Set<DetailTab>>(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 (
<tr
@@ -1516,48 +1681,24 @@ export function TrajectoryTable({
<dt>Status</dt>
<dd>{statusLabel(selectedRequestState)}</dd>
</div>
<div>
<dt>Started</dt>
<StartedAtValue
timestamp={selectedRequestAnchor?.cell.startedAt ?? null}
/>
</div>
<div>
<dt>Duration</dt>
<dd>
{formatElapsedSeconds(
selectedRequestAssistant?.cell.timeSeconds ?? null,
)}
</dd>
</div>
{selectedRequestInputTotal !== undefined && (
{(selectedRequestInfo?.provider
?? selectedRequestInfo?.requestConfig?.provider) !== undefined && (
<div>
<dt>Input</dt>
<dd>{selectedRequestInputTotal} tok</dd>
<dt>Provider</dt>
<dd>
{selectedRequestInfo?.provider
?? selectedRequestInfo?.requestConfig?.provider}
</dd>
</div>
)}
{selectedRequestAssistant?.cell.cacheRead !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Cached</dt>
<dd>{selectedRequestAssistant.cell.cacheRead} tok</dd>
</div>
)}
{selectedRequestAssistant?.cell.cacheWrite !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Cache created</dt>
<dd>{selectedRequestAssistant.cell.cacheWrite} tok</dd>
</div>
)}
{selectedRequestAssistant?.cell.input !== undefined && (
<div className={css.requestTokenDetail}>
<dt>Other</dt>
<dd>{selectedRequestAssistant.cell.input} tok</dd>
</div>
)}
{selectedRequestAssistant?.cell.output !== undefined && (
{(selectedRequestInfo?.model
?? selectedRequestInfo?.requestConfig?.model) !== undefined && (
<div>
<dt>Output</dt>
<dd>{selectedRequestAssistant.cell.output} tok</dd>
<dt>Model</dt>
<dd>
{selectedRequestInfo?.model
?? selectedRequestInfo?.requestConfig?.model}
</dd>
</div>
)}
<div>
@@ -1570,23 +1711,36 @@ export function TrajectoryTable({
<dd>{selectedRequestSubtoolCalls}</dd>
</div>
)}
{selectedRequestAssistant !== undefined && (
<div>
<dt>Result</dt>
<dd className={css.overviewParentLinks}>
<button
type="button"
className={css.overviewHierarchyNavLink}
onClick={() => {
openRecordSummary(selectedRequestAssistant)
}}
>
<span>Assistant Message</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}
/>
</button>
</dd>
</div>
)}
</dl>
<div className={css.overviewSections}>
{selectedRequestAssistant !== undefined && (
<OverviewSection
label="Result"
onOpen={() => { openRecordSummary(selectedRequestAssistant) }}
>
<MarkdownRecordContent
record={selectedRequestAssistant}
rendered
preview
thinkingExpanded={thinkingExpanded}
onThinkingExpandedChange={setThinkingExpanded}
onOpenCall={openCallSummary}
/>
{selectedRequestOptions !== undefined && (
<OverviewSection label="Options" onOpen={() => { activateTab('options') }}>
<RequestOptions options={selectedRequestOptions} preview />
</OverviewSection>
)}
<OverviewSection label="Usage" onOpen={() => { activateTab('usage') }}>
<UsageRows usage={selectedRequestUsage} />
</OverviewSection>
<OverviewSection label="Timing" onOpen={() => { activateTab('timing') }}>
<RequestTiming
assistant={selectedRequestAssistant}
@@ -1596,6 +1750,15 @@ export function TrajectoryTable({
</div>
</>
)}
{selectedRequest !== null && activeTab === 'options' && (
<RequestOptions options={selectedRequestOptions} />
)}
{selectedRequest !== null && activeTab === 'usage' && (
<RequestUsagePanel
usage={selectedRequestUsage}
cumulative={selectedRequestCumulativeUsage}
/>
)}
{selectedRequest !== null && activeTab === 'timing' && (
<RequestTiming
assistant={selectedRequestAssistant}
@@ -1621,17 +1784,32 @@ export function TrajectoryTable({
{!promptSelected && selected !== undefined && selectedState !== undefined && activeTab === 'overview' && (
<>
<dl className={css.overview}>
{hasSelectedParents && (
{hasSelectedHierarchy && (
<div>
<dt>Hierarchy</dt>
<dd className={css.overviewParentLinks}>
{selectedAssistantRequestTarget !== undefined && (
<button
type="button"
className={css.overviewHierarchyNavLink}
onClick={() => {
selectRequest(selectedAssistantRequestTarget)
}}
>
<span>Request #{selectedAssistantRequestTarget.number}</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}
/>
</button>
)}
{selectedParents.message !== undefined && (
<button
type="button"
className={css.overviewHierarchyNavLink}
onClick={() => { openRecordSummary(selectedParents.message!) }}
>
<span>Parent Message</span>
<span>Assistant Message</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}
@@ -1644,7 +1822,7 @@ export function TrajectoryTable({
className={css.overviewHierarchyNavLink}
onClick={() => { openRecordSummary(selectedParents.tool!) }}
>
<span>Parent Tool Call</span>
<span>Tool Call</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}
@@ -1661,7 +1839,12 @@ export function TrajectoryTable({
{selected.cell.kind === 'message' && (
<div><dt>Tokens</dt><dd>{tokenSummary(selected.cell)}</dd></div>
)}
<div><dt>Duration</dt><dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd></div>
{(selected.cell.kind === 'user' || selected.cell.kind === 'context') && (
<div>
<dt>Duration</dt>
<dd>{formatElapsedSeconds(selected.cell.timeSeconds)}</dd>
</div>
)}
</dl>
<div className={css.overviewSections}>
{isMarkdownRecord(selected)
@@ -1696,9 +1879,21 @@ export function TrajectoryTable({
</OverviewSection>
</>
)}
<OverviewSection label="Timing" onOpen={() => { activateTab('timing') }}>
<RecordTiming record={selected} />
</OverviewSection>
{selectedAssistantRequestTarget !== undefined && (
<OverviewSection
label="Timing"
onOpen={() => {
selectRequest(selectedAssistantRequestTarget, 'timing')
}}
>
<RecordTiming record={selected} />
</OverviewSection>
)}
{(selected.cell.kind === 'tool' || selected.cell.kind === 'subtool') && (
<OverviewSection label="Timing" onOpen={() => { activateTab('timing') }}>
<RecordTiming record={selected} />
</OverviewSection>
)}
</div>
</>
)}

View File

@@ -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<number> = 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<readonly TrajectoryRequestNumber[]>(() => {
const requestsBySeq = new Map<number, AssistantMessageNode>()
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<number, TrajectoryRequestNumber>()
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<string>()
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) {
<TrajectoryTable
key={selectedContext.id}
{...selectedContext.prompt === undefined ? {} : { prompt: selectedContext.prompt }}
requestNumbers={requestNumbers}
turns={turns}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}

View File

@@ -62,7 +62,7 @@ export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & Waterfa
}}
title={lane.timing === 'measured'
/* durationMs is non-null exactly when timing is measured. */
? `${lane.name} · ${((lane.durationMs ?? 0) / 1000).toFixed(2)}s`
? `${lane.name} · ${((lane.durationMs ?? 0) / 1000).toFixed(2)} s`
: lane.timing === 'running' ? `${lane.name} · running` : `${lane.name} · duration unknown`}
/>
</div>

View File

@@ -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<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>()
const turns = new Map<number, TurnBucket>()
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<number, LaidCell[]> } => ({
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<number, LaidCell[]> },
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<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>,
turns: Map<number, TurnBucket>,
): Set<string> {
const ids = new Set<string>()
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)
}
}

View File

@@ -80,6 +80,6 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
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`
}