feat(ui): expand request trajectory inspection
This commit is contained in:
@@ -28,7 +28,8 @@ export type {
|
|||||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||||
} from './contract/store.ts'
|
} from './contract/store.ts'
|
||||||
export type {
|
export type {
|
||||||
AssistantBlock, AssistantMessageNode, AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode,
|
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
|
||||||
|
AssistantTiming, CodeSubCall, ComposerPhase, ContextMessageNode,
|
||||||
ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot,
|
ConversationContext, ConversationContextOriginKind, ConversationNode, ConversationPromptSnapshot,
|
||||||
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
ConversationSnapshot, QueuedMessage, RunningToolCall,
|
||||||
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
SteeringMessageNode, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||||
|
|||||||
@@ -60,6 +60,23 @@ export interface AssistantTiming {
|
|||||||
completedTime: number
|
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. */
|
/** A finalized (or interruption-frozen) assistant message. */
|
||||||
export interface AssistantMessageNode {
|
export interface AssistantMessageNode {
|
||||||
kind: 'assistant'
|
kind: 'assistant'
|
||||||
@@ -70,6 +87,8 @@ export interface AssistantMessageNode {
|
|||||||
step: number
|
step: number
|
||||||
blocks: readonly AssistantBlock[]
|
blocks: readonly AssistantBlock[]
|
||||||
usage?: unknown
|
usage?: unknown
|
||||||
|
provenance?: AssistantProvenanceView
|
||||||
|
requestConfig?: AssistantRequestConfig
|
||||||
/** Timing derived from the recorded step/chunk/message event sequence. */
|
/** Timing derived from the recorded step/chunk/message event sequence. */
|
||||||
timing?: AssistantTiming
|
timing?: AssistantTiming
|
||||||
/** Frozen partial of an aborted turn (no finalize ever arrives): rendered with a 已停止 marker.
|
/** 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. */
|
/** Latest complete model request header in force within one context generation. */
|
||||||
export interface ConversationPromptSnapshot {
|
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. */
|
/** Rendered system prompt text; empty when the request had no system prompt. */
|
||||||
system: string
|
system: string
|
||||||
/** Complete tool catalog sent with the request, including tools that were never called. */
|
/** Complete tool catalog sent with the request, including tools that were never called. */
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '@deepseek-ai/dsh-session/surface'
|
} from '@deepseek-ai/dsh-session/surface'
|
||||||
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
import type {
|
import type {
|
||||||
AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode,
|
AssistantRequestConfig, AssistantTiming, ConversationContext, ConversationContextOriginKind, ConversationNode,
|
||||||
ConversationPromptSnapshot,
|
ConversationPromptSnapshot,
|
||||||
} from './conversation.ts'
|
} from './conversation.ts'
|
||||||
import { toAssistantBlocks } from './conversation.ts'
|
import { toAssistantBlocks } from './conversation.ts'
|
||||||
@@ -43,6 +43,7 @@ function materializeNode(
|
|||||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||||
resultView: ToolResultView | null,
|
resultView: ToolResultView | null,
|
||||||
assistantTiming?: AssistantTiming,
|
assistantTiming?: AssistantTiming,
|
||||||
|
requestConfig?: AssistantRequestConfig,
|
||||||
): ConversationNode {
|
): ConversationNode {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'user/message':
|
case 'user/message':
|
||||||
@@ -64,6 +65,11 @@ function materializeNode(
|
|||||||
kind: 'assistant', seq: event.seq, time: event.time,
|
kind: 'assistant', seq: event.seq, time: event.time,
|
||||||
turn: event.data.turn, step: event.data.step,
|
turn: event.data.turn, step: event.data.step,
|
||||||
blocks: toAssistantBlocks(event.data.content), usage: event.data.usage,
|
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 } : {}),
|
...(assistantTiming !== undefined ? { timing: assistantTiming } : {}),
|
||||||
}
|
}
|
||||||
case 'steering/message':
|
case 'steering/message':
|
||||||
@@ -204,6 +210,7 @@ export class FoldAdapter {
|
|||||||
this.callIdx,
|
this.callIdx,
|
||||||
this.resultViews.get(seq) ?? null,
|
this.resultViews.get(seq) ?? null,
|
||||||
event.type === 'assistant/message' ? this.assistantTiming(event) : undefined,
|
event.type === 'assistant/message' ? this.assistantTiming(event) : undefined,
|
||||||
|
event.type === 'assistant/message' ? this.assistantRequestConfig(event) : undefined,
|
||||||
)
|
)
|
||||||
this.nodeCache.set(seq, node)
|
this.nodeCache.set(seq, node)
|
||||||
out.push(node)
|
out.push(node)
|
||||||
@@ -280,6 +287,7 @@ export class FoldAdapter {
|
|||||||
this.callIdx,
|
this.callIdx,
|
||||||
this.resultViews.get(seq) ?? null,
|
this.resultViews.get(seq) ?? null,
|
||||||
event.type === 'assistant/message' ? this.assistantTiming(event) : undefined,
|
event.type === 'assistant/message' ? this.assistantTiming(event) : undefined,
|
||||||
|
event.type === 'assistant/message' ? this.assistantRequestConfig(event) : undefined,
|
||||||
)
|
)
|
||||||
this.nodeCache.set(seq, node)
|
this.nodeCache.set(seq, node)
|
||||||
return node
|
return node
|
||||||
@@ -312,6 +320,17 @@ export class FoldAdapter {
|
|||||||
return { stepStartTime, firstTokenTime, completedTime: event.time }
|
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 {
|
private indexCall(event: SessionEvent, view?: ToolEventView): void {
|
||||||
if (event.type === 'tool/result') {
|
if (event.type === 'tool/result') {
|
||||||
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
|
if (view?.for === 'result') this.resultViews.set(event.seq, view.view)
|
||||||
@@ -336,6 +355,7 @@ export class FoldAdapter {
|
|||||||
}
|
}
|
||||||
if (event.type !== 'request/header') return
|
if (event.type !== 'request/header') return
|
||||||
this.activePrompt = {
|
this.activePrompt = {
|
||||||
|
config: event.data.header.config,
|
||||||
system: event.data.header.system ?? '',
|
system: event.data.header.system ?? '',
|
||||||
tools: event.data.header.tools ?? [],
|
tools: event.data.header.tools ?? [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import css from './TrajectoryGroupHeader.module.css'
|
|||||||
export interface TrajectoryGroupHeaderProps {
|
export interface TrajectoryGroupHeaderProps {
|
||||||
/** Group title (`Message`, `Step 1`, …). */
|
/** Group title (`Message`, `Step 1`, …). */
|
||||||
title: string
|
title: string
|
||||||
/** Secondary summary (`49s`, `2.2s skill`, …). */
|
/** Secondary summary (`49 s`, `2.2 s skill`, …). */
|
||||||
description?: string
|
description?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -699,6 +699,26 @@
|
|||||||
padding-left: 12px;
|
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 {
|
.overview dt {
|
||||||
color: var(--dsw-alias-label-tertiary);
|
color: var(--dsw-alias-label-tertiary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import type { CSSProperties, ReactNode } from 'react'
|
|||||||
import {
|
import {
|
||||||
extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
|
extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
|
||||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
} 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 {
|
import type {
|
||||||
AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
|
AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
|
||||||
} from './trajectory-record.ts'
|
} from './trajectory-record.ts'
|
||||||
@@ -41,6 +43,8 @@ type DetailTab =
|
|||||||
| 'input'
|
| 'input'
|
||||||
| 'output'
|
| 'output'
|
||||||
| 'schema'
|
| 'schema'
|
||||||
|
| 'options'
|
||||||
|
| 'usage'
|
||||||
| 'timing'
|
| 'timing'
|
||||||
type RecordState = 'complete' | 'running' | 'error'
|
type RecordState = 'complete' | 'running' | 'error'
|
||||||
|
|
||||||
@@ -89,6 +93,8 @@ const SYSTEM_PROMPT_TABS: readonly DetailTabItem[] = [
|
|||||||
]
|
]
|
||||||
const REQUEST_TABS: readonly DetailTabItem[] = [
|
const REQUEST_TABS: readonly DetailTabItem[] = [
|
||||||
{ id: 'overview', label: 'Summary' },
|
{ id: 'overview', label: 'Summary' },
|
||||||
|
{ id: 'options', label: 'Options' },
|
||||||
|
{ id: 'usage', label: 'Usage' },
|
||||||
{ id: 'timing', label: 'Timing' },
|
{ id: 'timing', label: 'Timing' },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -172,14 +178,6 @@ function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) {
|
|||||||
<div><dt>TTFT</dt><dd>{ttft(metrics)}</dd></div>
|
<div><dt>TTFT</dt><dd>{ttft(metrics)}</dd></div>
|
||||||
<div><dt>Generation</dt><dd>{generationTime(metrics)}</dd></div>
|
<div><dt>Generation</dt><dd>{generationTime(metrics)}</dd></div>
|
||||||
<div><dt>Throughput</dt><dd>{throughput(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>
|
</dl>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -188,6 +186,8 @@ function AssistantTimingPanel({ metrics }: { metrics: AssistantMetricDetail }) {
|
|||||||
export interface TrajectoryTableProps {
|
export interface TrajectoryTableProps {
|
||||||
/** Latest model request header in force for the selected context. */
|
/** Latest model request header in force for the selected context. */
|
||||||
prompt?: ConversationPromptSnapshot
|
prompt?: ConversationPromptSnapshot
|
||||||
|
/** Session-global request numbers for the request groups visible in this context. */
|
||||||
|
requestNumbers?: readonly TrajectoryRequestNumber[]
|
||||||
/** Grouped records in display order. */
|
/** Grouped records in display order. */
|
||||||
turns: readonly TrajectoryTurnModel[]
|
turns: readonly TrajectoryTurnModel[]
|
||||||
/** Turn ids whose rows after the first are folded into a summary. */
|
/** Turn ids whose rows after the first are folded into a summary. */
|
||||||
@@ -200,6 +200,27 @@ export interface TrajectoryTableProps {
|
|||||||
onToggleAssistant(index: number): void
|
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[] {
|
function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] {
|
||||||
return turns.flatMap((turn) => {
|
return turns.flatMap((turn) => {
|
||||||
let firstInTurn = true
|
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
|
if (!group.startsWith('Step ')) return undefined
|
||||||
const value = Number(group.slice('Step '.length))
|
const value = Number(group.slice('Step '.length))
|
||||||
return Number.isInteger(value) && value > 0 ? value : undefined
|
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 {
|
function summarizeTurn(records: readonly TableRecord[]): string {
|
||||||
const userText = records
|
const userText = records
|
||||||
.filter(record => record.cell.kind === 'user')
|
.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 {
|
function isMarkdownRecord(record: TableRecord): boolean {
|
||||||
return record.cell.kind === 'user'
|
return record.cell.kind === 'user'
|
||||||
|| record.cell.kind === 'context'
|
|| record.cell.kind === 'context'
|
||||||
@@ -435,7 +567,6 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
|
|||||||
{ id: 'overview', label: 'Summary' },
|
{ id: 'overview', label: 'Summary' },
|
||||||
{ id: 'rendered', label: 'Preview' },
|
{ id: 'rendered', label: 'Preview' },
|
||||||
{ id: 'source', label: 'Source' },
|
{ id: 'source', label: 'Source' },
|
||||||
{ id: 'timing', label: 'Timing' },
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
@@ -1011,6 +1142,7 @@ function OverviewSection({
|
|||||||
*/
|
*/
|
||||||
export function TrajectoryTable({
|
export function TrajectoryTable({
|
||||||
prompt,
|
prompt,
|
||||||
|
requestNumbers: sessionRequestNumbers,
|
||||||
turns,
|
turns,
|
||||||
collapsedTurns,
|
collapsedTurns,
|
||||||
onToggleTurn,
|
onToggleTurn,
|
||||||
@@ -1026,6 +1158,7 @@ export function TrajectoryTable({
|
|||||||
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
|
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
|
||||||
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
|
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
|
||||||
const allRecords = flattenRecords(turns)
|
const allRecords = flattenRecords(turns)
|
||||||
|
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
|
||||||
const turnRecords = collapseTurnRecords(allRecords, collapsedTurns)
|
const turnRecords = collapseTurnRecords(allRecords, collapsedTurns)
|
||||||
const records = collapseAssistantRecords(turnRecords, collapsedAssistants)
|
const records = collapseAssistantRecords(turnRecords, collapsedAssistants)
|
||||||
const systemPromptPreview = prompt === undefined
|
const systemPromptPreview = prompt === undefined
|
||||||
@@ -1060,26 +1193,55 @@ export function TrajectoryTable({
|
|||||||
const selectedRequestSubtoolCalls = selectedRequestRecords.filter(
|
const selectedRequestSubtoolCalls = selectedRequestRecords.filter(
|
||||||
record => record.cell.kind === 'subtool',
|
record => record.cell.kind === 'subtool',
|
||||||
).length
|
).length
|
||||||
const selectedRequestInputTotal = selectedRequestAssistant !== undefined
|
const selectedRequestInfo = selectedRequest === null
|
||||||
&& (
|
? undefined
|
||||||
selectedRequestAssistant.cell.input !== undefined
|
: sessionRequestNumbers?.find(request => request.number === selectedRequest.number)
|
||||||
|| selectedRequestAssistant.cell.cacheRead !== undefined
|
const selectedRequestUsage = selectedRequestInfo?.usage ?? (
|
||||||
|| selectedRequestAssistant.cell.cacheWrite !== undefined
|
selectedRequestAssistant === undefined
|
||||||
)
|
? undefined
|
||||||
? (selectedRequestAssistant.cell.input ?? 0)
|
: {
|
||||||
+ (selectedRequestAssistant.cell.cacheRead ?? 0)
|
...(selectedRequestAssistant.cell.input === undefined
|
||||||
+ (selectedRequestAssistant.cell.cacheWrite ?? 0)
|
? {}
|
||||||
: 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 activeTurn = selectedRequest?.turn ?? selected?.turn
|
||||||
const selectedTabs = selectedRequest !== null
|
const selectedTabs = selectedRequest !== null
|
||||||
? REQUEST_TABS
|
? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined)
|
||||||
: promptSelected
|
: promptSelected
|
||||||
? SYSTEM_PROMPT_TABS
|
? SYSTEM_PROMPT_TABS
|
||||||
: selected === undefined ? [] : detailTabs(selected)
|
: selected === undefined ? [] : detailTabs(selected)
|
||||||
const selectedParents: ParentRecords = selected === undefined
|
const selectedParents: ParentRecords = selected === undefined
|
||||||
? {}
|
? {}
|
||||||
: parentRecords(allRecords, selected)
|
: 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
|
|| selectedParents.tool !== undefined
|
||||||
const splitStyle: TrajectorySplitStyle | undefined = toolRequestOffset === null
|
const splitStyle: TrajectorySplitStyle | undefined = toolRequestOffset === null
|
||||||
? undefined
|
? undefined
|
||||||
@@ -1109,10 +1271,13 @@ export function TrajectoryTable({
|
|||||||
activateTab('system-prompt')
|
activateTab('system-prompt')
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectRequest = (request: SelectedRequest) => {
|
const selectRequest = (
|
||||||
|
request: SelectedRequest,
|
||||||
|
tab: 'overview' | 'timing' = 'overview',
|
||||||
|
) => {
|
||||||
setSelectedIndex(null)
|
setSelectedIndex(null)
|
||||||
setSelectedRequest(request)
|
setSelectedRequest(request)
|
||||||
activateTab('overview')
|
activateTab(tab)
|
||||||
}
|
}
|
||||||
|
|
||||||
const openRecordSummary = (target: TableRecord) => {
|
const openRecordSummary = (target: TableRecord) => {
|
||||||
@@ -1186,7 +1351,7 @@ export function TrajectoryTable({
|
|||||||
const request = record.groupStart
|
const request = record.groupStart
|
||||||
&& !isCollapsedSummary
|
&& !isCollapsedSummary
|
||||||
&& !collapsedTurns.has(record.turn)
|
&& !collapsedTurns.has(record.turn)
|
||||||
? requestNumber(record.group)
|
? requestNumbers.get(requestKey(record.turn, record.group))
|
||||||
: undefined
|
: undefined
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@@ -1516,48 +1681,24 @@ export function TrajectoryTable({
|
|||||||
<dt>Status</dt>
|
<dt>Status</dt>
|
||||||
<dd>{statusLabel(selectedRequestState)}</dd>
|
<dd>{statusLabel(selectedRequestState)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{(selectedRequestInfo?.provider
|
||||||
<dt>Started</dt>
|
?? selectedRequestInfo?.requestConfig?.provider) !== undefined && (
|
||||||
<StartedAtValue
|
|
||||||
timestamp={selectedRequestAnchor?.cell.startedAt ?? null}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Duration</dt>
|
|
||||||
<dd>
|
|
||||||
{formatElapsedSeconds(
|
|
||||||
selectedRequestAssistant?.cell.timeSeconds ?? null,
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
{selectedRequestInputTotal !== undefined && (
|
|
||||||
<div>
|
<div>
|
||||||
<dt>Input</dt>
|
<dt>Provider</dt>
|
||||||
<dd>{selectedRequestInputTotal} tok</dd>
|
<dd>
|
||||||
|
{selectedRequestInfo?.provider
|
||||||
|
?? selectedRequestInfo?.requestConfig?.provider}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedRequestAssistant?.cell.cacheRead !== undefined && (
|
{(selectedRequestInfo?.model
|
||||||
<div className={css.requestTokenDetail}>
|
?? selectedRequestInfo?.requestConfig?.model) !== undefined && (
|
||||||
<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 && (
|
|
||||||
<div>
|
<div>
|
||||||
<dt>Output</dt>
|
<dt>Model</dt>
|
||||||
<dd>{selectedRequestAssistant.cell.output} tok</dd>
|
<dd>
|
||||||
|
{selectedRequestInfo?.model
|
||||||
|
?? selectedRequestInfo?.requestConfig?.model}
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
@@ -1570,23 +1711,36 @@ export function TrajectoryTable({
|
|||||||
<dd>{selectedRequestSubtoolCalls}</dd>
|
<dd>{selectedRequestSubtoolCalls}</dd>
|
||||||
</div>
|
</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>
|
</dl>
|
||||||
<div className={css.overviewSections}>
|
<div className={css.overviewSections}>
|
||||||
{selectedRequestAssistant !== undefined && (
|
{selectedRequestOptions !== undefined && (
|
||||||
<OverviewSection
|
<OverviewSection label="Options" onOpen={() => { activateTab('options') }}>
|
||||||
label="Result"
|
<RequestOptions options={selectedRequestOptions} preview />
|
||||||
onOpen={() => { openRecordSummary(selectedRequestAssistant) }}
|
|
||||||
>
|
|
||||||
<MarkdownRecordContent
|
|
||||||
record={selectedRequestAssistant}
|
|
||||||
rendered
|
|
||||||
preview
|
|
||||||
thinkingExpanded={thinkingExpanded}
|
|
||||||
onThinkingExpandedChange={setThinkingExpanded}
|
|
||||||
onOpenCall={openCallSummary}
|
|
||||||
/>
|
|
||||||
</OverviewSection>
|
</OverviewSection>
|
||||||
)}
|
)}
|
||||||
|
<OverviewSection label="Usage" onOpen={() => { activateTab('usage') }}>
|
||||||
|
<UsageRows usage={selectedRequestUsage} />
|
||||||
|
</OverviewSection>
|
||||||
<OverviewSection label="Timing" onOpen={() => { activateTab('timing') }}>
|
<OverviewSection label="Timing" onOpen={() => { activateTab('timing') }}>
|
||||||
<RequestTiming
|
<RequestTiming
|
||||||
assistant={selectedRequestAssistant}
|
assistant={selectedRequestAssistant}
|
||||||
@@ -1596,6 +1750,15 @@ export function TrajectoryTable({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{selectedRequest !== null && activeTab === 'options' && (
|
||||||
|
<RequestOptions options={selectedRequestOptions} />
|
||||||
|
)}
|
||||||
|
{selectedRequest !== null && activeTab === 'usage' && (
|
||||||
|
<RequestUsagePanel
|
||||||
|
usage={selectedRequestUsage}
|
||||||
|
cumulative={selectedRequestCumulativeUsage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{selectedRequest !== null && activeTab === 'timing' && (
|
{selectedRequest !== null && activeTab === 'timing' && (
|
||||||
<RequestTiming
|
<RequestTiming
|
||||||
assistant={selectedRequestAssistant}
|
assistant={selectedRequestAssistant}
|
||||||
@@ -1621,17 +1784,32 @@ export function TrajectoryTable({
|
|||||||
{!promptSelected && selected !== undefined && selectedState !== undefined && activeTab === 'overview' && (
|
{!promptSelected && selected !== undefined && selectedState !== undefined && activeTab === 'overview' && (
|
||||||
<>
|
<>
|
||||||
<dl className={css.overview}>
|
<dl className={css.overview}>
|
||||||
{hasSelectedParents && (
|
{hasSelectedHierarchy && (
|
||||||
<div>
|
<div>
|
||||||
<dt>Hierarchy</dt>
|
<dt>Hierarchy</dt>
|
||||||
<dd className={css.overviewParentLinks}>
|
<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 && (
|
{selectedParents.message !== undefined && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={css.overviewHierarchyNavLink}
|
className={css.overviewHierarchyNavLink}
|
||||||
onClick={() => { openRecordSummary(selectedParents.message!) }}
|
onClick={() => { openRecordSummary(selectedParents.message!) }}
|
||||||
>
|
>
|
||||||
<span>Parent Message</span>
|
<span>Assistant Message</span>
|
||||||
<IconChevronRightOutline14
|
<IconChevronRightOutline14
|
||||||
className={css.overviewHierarchyJumpIconTight}
|
className={css.overviewHierarchyJumpIconTight}
|
||||||
size={11}
|
size={11}
|
||||||
@@ -1644,7 +1822,7 @@ export function TrajectoryTable({
|
|||||||
className={css.overviewHierarchyNavLink}
|
className={css.overviewHierarchyNavLink}
|
||||||
onClick={() => { openRecordSummary(selectedParents.tool!) }}
|
onClick={() => { openRecordSummary(selectedParents.tool!) }}
|
||||||
>
|
>
|
||||||
<span>Parent Tool Call</span>
|
<span>Tool Call</span>
|
||||||
<IconChevronRightOutline14
|
<IconChevronRightOutline14
|
||||||
className={css.overviewHierarchyJumpIconTight}
|
className={css.overviewHierarchyJumpIconTight}
|
||||||
size={11}
|
size={11}
|
||||||
@@ -1661,7 +1839,12 @@ export function TrajectoryTable({
|
|||||||
{selected.cell.kind === 'message' && (
|
{selected.cell.kind === 'message' && (
|
||||||
<div><dt>Tokens</dt><dd>{tokenSummary(selected.cell)}</dd></div>
|
<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>
|
</dl>
|
||||||
<div className={css.overviewSections}>
|
<div className={css.overviewSections}>
|
||||||
{isMarkdownRecord(selected)
|
{isMarkdownRecord(selected)
|
||||||
@@ -1696,9 +1879,21 @@ export function TrajectoryTable({
|
|||||||
</OverviewSection>
|
</OverviewSection>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<OverviewSection label="Timing" onOpen={() => { activateTab('timing') }}>
|
{selectedAssistantRequestTarget !== undefined && (
|
||||||
<RecordTiming record={selected} />
|
<OverviewSection
|
||||||
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,15 +2,65 @@
|
|||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
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 { 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 { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
|
||||||
import { deriveTrajectoryLayout } from './layout.ts'
|
import { deriveTrajectoryLayout } from './layout.ts'
|
||||||
import css from './views.module.css'
|
import css from './views.module.css'
|
||||||
|
|
||||||
const EMPTY_IDS: ReadonlySet<number> = new Set()
|
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 {
|
function currentContextOf(contexts: readonly ConversationContext[]): ConversationContext {
|
||||||
const context = contexts.at(-1)
|
const context = contexts.at(-1)
|
||||||
if (context === undefined) throw new Error('trajectory context projection must not be empty')
|
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
|
: contexts.find(context => context.id === selectedContextId) ?? currentContext
|
||||||
const viewingCurrent = selectedContext.id === currentContext.id
|
const viewingCurrent = selectedContext.id === currentContext.id
|
||||||
const selectedNodes = viewingCurrent ? nodes : selectedContext.nodes
|
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 collapsedTurns = collapsedTurnsByContext.get(selectedContext.id) ?? EMPTY_IDS
|
||||||
const collapsedAssistants = collapsedAssistantsByContext.get(selectedContext.id) ?? EMPTY_IDS
|
const collapsedAssistants = collapsedAssistantsByContext.get(selectedContext.id) ?? EMPTY_IDS
|
||||||
const turns = useMemo(
|
const turns = useMemo(
|
||||||
@@ -164,6 +280,7 @@ export function TrajectoryView({ useSession }: ConvViewProps) {
|
|||||||
<TrajectoryTable
|
<TrajectoryTable
|
||||||
key={selectedContext.id}
|
key={selectedContext.id}
|
||||||
{...selectedContext.prompt === undefined ? {} : { prompt: selectedContext.prompt }}
|
{...selectedContext.prompt === undefined ? {} : { prompt: selectedContext.prompt }}
|
||||||
|
requestNumbers={requestNumbers}
|
||||||
turns={turns}
|
turns={turns}
|
||||||
collapsedTurns={collapsedTurns}
|
collapsedTurns={collapsedTurns}
|
||||||
onToggleTurn={toggleTurn}
|
onToggleTurn={toggleTurn}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function WaterfallView({ useSession, pxPerNode }: ConvViewProps & Waterfa
|
|||||||
}}
|
}}
|
||||||
title={lane.timing === 'measured'
|
title={lane.timing === 'measured'
|
||||||
/* durationMs is non-null exactly when timing is 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`}
|
: lane.timing === 'running' ? `${lane.name} · running` : `${lane.name} · duration unknown`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ interface LaidCell {
|
|||||||
callId?: string
|
callId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LaidGroup {
|
||||||
|
title: string
|
||||||
|
laid: LaidCell[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TurnBucket {
|
||||||
|
groups: LaidGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fold a snapshot into turn → Message/Step groups with expanded cells.
|
* Fold a snapshot into turn → Message/Step groups with expanded cells.
|
||||||
* @param input - nodes plus in-flight partial/runningCalls.
|
* @param input - nodes plus in-flight partial/runningCalls.
|
||||||
@@ -70,7 +79,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
|||||||
const startedAt = finiteTime(call.time)
|
const startedAt = finiteTime(call.time)
|
||||||
if (startedAt !== null) callStartById.set(call.callId, startedAt)
|
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 index = 0
|
||||||
let prevAbsTime: number | null = null
|
let prevAbsTime: number | null = null
|
||||||
let lastAssistantTurn: number | null = null
|
let lastAssistantTurn: number | null = null
|
||||||
@@ -78,20 +87,31 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
|||||||
const bucket = (turn: number) => {
|
const bucket = (turn: number) => {
|
||||||
let entry = turns.get(turn)
|
let entry = turns.get(turn)
|
||||||
if (entry === undefined) {
|
if (entry === undefined) {
|
||||||
entry = { message: [], steps: new Map() }
|
entry = { groups: [] }
|
||||||
turns.set(turn, entry)
|
turns.set(turn, entry)
|
||||||
}
|
}
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
const pushMessage = (turn: number, laid: LaidCell) => {
|
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 pushStep = (turn: number, step: number, laid: readonly LaidCell[]) => {
|
||||||
const steps = bucket(turn).steps
|
if (laid.length === 0) return
|
||||||
const list = steps.get(step) ?? []
|
const groups = bucket(turn).groups
|
||||||
list.push(laid)
|
const title = `Step ${step}`
|
||||||
steps.set(step, list)
|
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++) {
|
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),
|
expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById),
|
||||||
codeDispatches,
|
codeDispatches,
|
||||||
)
|
)
|
||||||
for (const laid of laidList) {
|
if (node.step > 0) pushStep(node.turn, node.step, laidList)
|
||||||
if (node.step > 0) pushStep(node.turn, node.step, laid)
|
else for (const laid of laidList) pushMessage(node.turn, laid)
|
||||||
else pushMessage(node.turn, laid)
|
|
||||||
}
|
|
||||||
const last = laidList[laidList.length - 1]
|
const last = laidList[laidList.length - 1]
|
||||||
if (last !== undefined) index = last.cell.index
|
if (last !== undefined) index = last.cell.index
|
||||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||||
@@ -153,7 +171,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
|||||||
if (node.kind === 'tool-result') {
|
if (node.kind === 'tool-result') {
|
||||||
if (!callEmittedInAssistant(nodes, node.callId)) {
|
if (!callEmittedInAssistant(nodes, node.callId)) {
|
||||||
const toolName = node.call?.name
|
const toolName = node.call?.name
|
||||||
pushStep(0, 1, {
|
const laidList: LaidCell[] = [{
|
||||||
absTime: finiteTime(node.callTime ?? node.time),
|
absTime: finiteTime(node.callTime ?? node.time),
|
||||||
...(toolName !== undefined ? { toolName } : {}),
|
...(toolName !== undefined ? { toolName } : {}),
|
||||||
callId: node.callId,
|
callId: node.callId,
|
||||||
@@ -172,11 +190,12 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
|||||||
timeSeconds: durationSeconds(node.time, node.callTime),
|
timeSeconds: durationSeconds(node.time, node.callTime),
|
||||||
startedAt: finiteTime(node.callTime),
|
startedAt: finiteTime(node.callTime),
|
||||||
},
|
},
|
||||||
})
|
}]
|
||||||
for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) {
|
for (const laid of expandSubCalls(codeDispatches.get(node.callId), index)) {
|
||||||
pushStep(0, 1, laid)
|
laidList.push(laid)
|
||||||
index = laid.cell.index
|
index = laid.cell.index
|
||||||
}
|
}
|
||||||
|
pushStep(0, 1, laidList)
|
||||||
}
|
}
|
||||||
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
prevAbsTime = finiteTime(node.time) ?? prevAbsTime
|
||||||
}
|
}
|
||||||
@@ -195,10 +214,8 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
|||||||
callStartById,
|
callStartById,
|
||||||
{ streaming: true },
|
{ streaming: true },
|
||||||
)
|
)
|
||||||
for (const laid of laidList) {
|
if (partial.step > 0) pushStep(partial.turn, partial.step, laidList)
|
||||||
if (partial.step > 0) pushStep(partial.turn, partial.step, laid)
|
else for (const laid of laidList) pushMessage(partial.turn, laid)
|
||||||
else pushMessage(partial.turn, laid)
|
|
||||||
}
|
|
||||||
const last = laidList[laidList.length - 1]
|
const last = laidList[laidList.length - 1]
|
||||||
if (last !== undefined) index = last.cell.index
|
if (last !== undefined) index = last.cell.index
|
||||||
}
|
}
|
||||||
@@ -206,7 +223,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
|||||||
const seenCalls = collectCallIds(turns)
|
const seenCalls = collectCallIds(turns)
|
||||||
for (const call of runningCalls) {
|
for (const call of runningCalls) {
|
||||||
if (seenCalls.has(call.callId)) continue
|
if (seenCalls.has(call.callId)) continue
|
||||||
pushStep(call.turn, call.step > 0 ? call.step : 1, {
|
const laidList: LaidCell[] = [{
|
||||||
absTime: null,
|
absTime: null,
|
||||||
toolName: call.name,
|
toolName: call.name,
|
||||||
callId: call.callId,
|
callId: call.callId,
|
||||||
@@ -219,34 +236,27 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
|||||||
timeSeconds: null,
|
timeSeconds: null,
|
||||||
startedAt: finiteTime(call.time),
|
startedAt: finiteTime(call.time),
|
||||||
},
|
},
|
||||||
})
|
}]
|
||||||
for (const laid of expandSubCalls(codeDispatches.get(call.callId), index)) {
|
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
|
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.
|
// Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1.
|
||||||
const prologue = turns.get(0)
|
const prologue = turns.get(0)
|
||||||
if (prologue !== undefined) {
|
if (prologue !== undefined) {
|
||||||
turns.delete(0)
|
turns.delete(0)
|
||||||
const emptyTurn = (): { message: LaidCell[]; steps: Map<number, LaidCell[]> } => ({
|
const emptyTurn = (): TurnBucket => ({ groups: [] })
|
||||||
message: [],
|
|
||||||
steps: new Map(),
|
|
||||||
})
|
|
||||||
const first = turns.get(1) ?? emptyTurn()
|
const first = turns.get(1) ?? emptyTurn()
|
||||||
first.message = [...prologue.message, ...first.message]
|
first.groups = [...prologue.groups, ...first.groups]
|
||||||
for (const [step, cells] of prologue.steps) {
|
|
||||||
const existing = first.steps.get(step) ?? []
|
|
||||||
first.steps.set(step, [...cells, ...existing])
|
|
||||||
}
|
|
||||||
turns.set(1, first)
|
turns.set(1, first)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of turns.values()) {
|
for (const entry of turns.values()) {
|
||||||
for (const laid of entry.message) attachToolSchema(laid, callSchemas)
|
for (const group of entry.groups) {
|
||||||
for (const laid of entry.steps.values()) {
|
for (const laid of group.laid) attachToolSchema(laid, callSchemas)
|
||||||
for (const cell of laid) attachToolSchema(cell, callSchemas)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,30 +277,20 @@ function attachToolSchema(
|
|||||||
|
|
||||||
function toTurnModel(
|
function toTurnModel(
|
||||||
turn: number,
|
turn: number,
|
||||||
entry: { message: LaidCell[]; steps: Map<number, LaidCell[]> },
|
entry: TurnBucket,
|
||||||
): TrajectoryTurnModel {
|
): TrajectoryTurnModel {
|
||||||
const groups: TrajectoryGroupModel[] = []
|
const groups = entry.groups.map(({ title, laid }): 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 description = groupDescription(laid)
|
const description = groupDescription(laid)
|
||||||
groups.push({
|
return {
|
||||||
title: `Step ${step}`,
|
title,
|
||||||
...(description !== undefined ? { description } : {}),
|
...(description !== undefined ? { description } : {}),
|
||||||
cells: laid.map(l => l.cell),
|
cells: laid.map(l => l.cell),
|
||||||
})
|
}
|
||||||
}
|
})
|
||||||
return { turn, groups }
|
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 {
|
function groupDescription(laid: readonly LaidCell[]): string | undefined {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
// Tool rows contribute start (absTime) and end (start + own duration) so a
|
// 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 {
|
function formatGroupDuration(seconds: number): string | undefined {
|
||||||
if (!Number.isFinite(seconds)) return undefined
|
if (!Number.isFinite(seconds)) return undefined
|
||||||
const rounded = Math.round(seconds * 10) / 10
|
const rounded = Math.round(seconds * 10) / 10
|
||||||
if (Number.isInteger(rounded)) return `${rounded}s`
|
if (Number.isInteger(rounded)) return `${rounded} s`
|
||||||
return `${rounded.toFixed(1)}s`
|
return `${rounded.toFixed(1)} s`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */
|
/** 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(
|
function collectCallIds(
|
||||||
turns: Map<number, { message: LaidCell[]; steps: Map<number, LaidCell[]> }>,
|
turns: Map<number, TurnBucket>,
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
const ids = new Set<string>()
|
const ids = new Set<string>()
|
||||||
for (const entry of turns.values()) {
|
for (const entry of turns.values()) {
|
||||||
for (const laid of entry.message) {
|
for (const group of entry.groups) {
|
||||||
if (laid.callId !== undefined) ids.add(laid.callId)
|
for (const laid of group.laid) {
|
||||||
}
|
|
||||||
for (const list of entry.steps.values()) {
|
|
||||||
for (const laid of list) {
|
|
||||||
if (laid.callId !== undefined) ids.add(laid.callId)
|
if (laid.callId !== undefined) ids.add(laid.callId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,6 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
|||||||
export function formatElapsedSeconds(seconds: number | null): string {
|
export function formatElapsedSeconds(seconds: number | null): string {
|
||||||
if (seconds === null || !Number.isFinite(seconds)) return '—'
|
if (seconds === null || !Number.isFinite(seconds)) return '—'
|
||||||
const rounded = Math.round(seconds * 10) / 10
|
const rounded = Math.round(seconds * 10) / 10
|
||||||
if (Number.isInteger(rounded)) return `${rounded}s`
|
if (Number.isInteger(rounded)) return `${rounded} s`
|
||||||
return `${rounded.toFixed(1)}s`
|
return `${rounded.toFixed(1)} s`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user