feat(web): turn speed metrics and composer context meter
Assistant footers and the stats line gain TTFT/tok-per-second readings folded from step timings; context occupancy moves off the stats line onto a composer ring whose panel shows a heuristic system/tools/messages breakdown from the new token-meter contextBreakdown session projection.
This commit is contained in:
@@ -16,6 +16,8 @@ import type {
|
||||
} from '../sessions/conversation-context.ts'
|
||||
import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts'
|
||||
import { PartialAccumulator } from '../sessions/partial.ts'
|
||||
import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts'
|
||||
import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts'
|
||||
|
||||
interface CallIndexEntry {
|
||||
name: string
|
||||
@@ -30,11 +32,6 @@ interface FoldedContext {
|
||||
originSeq?: number
|
||||
}
|
||||
|
||||
interface AssistantStepMetadata {
|
||||
stepStartTime: number | null
|
||||
firstTokenTime: number | null
|
||||
}
|
||||
|
||||
/** Immutable conversation projections derived only from the history source. */
|
||||
export interface ConversationHistoryProjection {
|
||||
eventNodes: readonly ConversationNode[]
|
||||
@@ -45,10 +42,6 @@ export interface ConversationHistoryProjection {
|
||||
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
|
||||
}
|
||||
|
||||
function assistantStepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
// Trajectory owns surface-window reconstruction so its immutable ledger does
|
||||
// not depend on Chat's live fold adapter or Session's mutable state.
|
||||
/* jscpd:ignore-start */
|
||||
@@ -72,18 +65,6 @@ function contextOriginKind(event: SessionEvent | undefined): ConversationContext
|
||||
return 'rewrite'
|
||||
}
|
||||
|
||||
function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return chunk.text !== ''
|
||||
case 'tool-call-delta':
|
||||
return chunk.argumentsDelta !== '' || chunk.name !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] {
|
||||
const replay: SessionEvent[] = []
|
||||
const surface = new SurfaceManager(replay)
|
||||
@@ -362,6 +343,7 @@ export function projectConversationHistory(
|
||||
contextGeneration++
|
||||
if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt)
|
||||
}
|
||||
indexAssistantStepTiming(assistantSteps, event)
|
||||
if (event.type === 'request/header') {
|
||||
activeRequestConfig = event.data.header.config
|
||||
activePrompt = {
|
||||
@@ -370,30 +352,10 @@ export function projectConversationHistory(
|
||||
tools: event.data.header.tools ?? [],
|
||||
}
|
||||
promptsByContext.set(contextGeneration, activePrompt)
|
||||
} else if (event.type === 'step/start') {
|
||||
assistantSteps.set(
|
||||
assistantStepKey(event.data.turn, event.data.step),
|
||||
{ stepStartTime: event.time, firstTokenTime: null },
|
||||
)
|
||||
} else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) {
|
||||
const key = assistantStepKey(event.data.turn, event.data.step)
|
||||
const current = assistantSteps.get(key) ?? {
|
||||
stepStartTime: null,
|
||||
firstTokenTime: null,
|
||||
}
|
||||
if (current.firstTokenTime === null) {
|
||||
assistantSteps.set(key, { ...current, firstTokenTime: event.time })
|
||||
}
|
||||
} else if (event.type === 'assistant/message') {
|
||||
assistantTimings.set(
|
||||
event.seq,
|
||||
{
|
||||
...(assistantSteps.get(assistantStepKey(event.data.turn, event.data.step)) ?? {
|
||||
stepStartTime: null,
|
||||
firstTokenTime: null,
|
||||
}),
|
||||
completedTime: event.time,
|
||||
},
|
||||
settledAssistantTiming(assistantSteps, event.data.turn, event.data.step, event.time),
|
||||
)
|
||||
if (activeRequestConfig !== undefined) {
|
||||
assistantRequestConfigs.set(event.seq, activeRequestConfig)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Shared assistant step-timing fold: both transcript projections (the live
|
||||
// window adapter and the trajectory history fold) derive AssistantTiming from
|
||||
// the same step/start -> first token delta -> assistant/message sequence, so
|
||||
// the derivation lives once here instead of drifting per projection.
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { AssistantTiming } from './conversation.ts'
|
||||
|
||||
/** Pre-finalize timing boundaries for one assistant step (start + first token). */
|
||||
export interface AssistantStepMetadata {
|
||||
stepStartTime: number | null
|
||||
firstTokenTime: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite map key for one assistant step.
|
||||
* @param turn - turn number from the event payload.
|
||||
* @param step - step number from the event payload.
|
||||
* @returns collision-free `turn`/`step` key (NUL separator).
|
||||
*/
|
||||
export function assistantStepKey(turn: number, step: number): string {
|
||||
return `${turn}\u0000${step}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a chunk carries visible model output (first-token boundary). Empty
|
||||
* deltas (heartbeats, empty tool-call frames) do not count as a first token.
|
||||
* @param chunk - the assistant/chunk payload.
|
||||
* @returns true when the chunk contains a non-empty text/reasoning/tool delta.
|
||||
*/
|
||||
export function isTokenDelta(chunk: SessionEvent<'assistant/chunk'>['data']['chunk']): boolean {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta':
|
||||
return chunk.text !== ''
|
||||
case 'tool-call-delta':
|
||||
return chunk.argumentsDelta !== '' || chunk.name !== undefined
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one event into the per-step timing index: step/start opens the entry,
|
||||
* the first non-empty token delta stamps first-token time once. Other event
|
||||
* types are no-ops.
|
||||
* @param steps - the mutable per-step index, keyed by {@link assistantStepKey}.
|
||||
* @param event - the raw window event.
|
||||
*/
|
||||
export function indexAssistantStepTiming(steps: Map<string, AssistantStepMetadata>, event: SessionEvent): void {
|
||||
if (event.type === 'step/start') {
|
||||
steps.set(
|
||||
assistantStepKey(event.data.turn, event.data.step),
|
||||
{ stepStartTime: event.time, firstTokenTime: null },
|
||||
)
|
||||
} else if (event.type === 'assistant/chunk' && isTokenDelta(event.data.chunk)) {
|
||||
const key = assistantStepKey(event.data.turn, event.data.step)
|
||||
const current = steps.get(key) ?? { stepStartTime: null, firstTokenTime: null }
|
||||
if (current.firstTokenTime === null) {
|
||||
steps.set(key, { ...current, firstTokenTime: event.time })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle one finalized assistant message's timing from its step entry; a step
|
||||
* whose start or first token fell outside the window yields null boundaries.
|
||||
* @param steps - the per-step index built by {@link indexAssistantStepTiming}.
|
||||
* @param turn - the assistant/message turn number.
|
||||
* @param step - the assistant/message step number.
|
||||
* @param completedTime - the assistant/message event timestamp (epoch ms).
|
||||
* @returns the node-ready timing record.
|
||||
*/
|
||||
export function settledAssistantTiming(
|
||||
steps: ReadonlyMap<string, AssistantStepMetadata>,
|
||||
turn: number,
|
||||
step: number,
|
||||
completedTime: number,
|
||||
): AssistantTiming {
|
||||
return {
|
||||
...(steps.get(assistantStepKey(turn, step)) ?? { stepStartTime: null, firstTokenTime: null }),
|
||||
completedTime,
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import type { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact/checkpo
|
||||
import type { ToolCallView, ToolEventView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { CommandNode, CompactionSummaryNode, ConversationNode } from './conversation.ts'
|
||||
import { toAssistantBlocks } from './conversation.ts'
|
||||
import type { AssistantStepMetadata } from './assistant-timing.ts'
|
||||
import { indexAssistantStepTiming, settledAssistantTiming } from './assistant-timing.ts'
|
||||
|
||||
/**
|
||||
* The compaction seam's checkpoint plugin, pinned to the seam's own declaration
|
||||
@@ -50,6 +52,7 @@ function materializeNode(
|
||||
event: SessionEvent,
|
||||
callIndex: ReadonlyMap<string, CallIndexEntry>,
|
||||
resultView: ToolResultView | null,
|
||||
stepTimings: ReadonlyMap<string, AssistantStepMetadata>,
|
||||
): ConversationNode {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
@@ -71,6 +74,7 @@ function materializeNode(
|
||||
kind: 'assistant', seq: event.seq, time: event.time,
|
||||
turn: event.data.turn, step: event.data.step,
|
||||
blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage,
|
||||
timing: settledAssistantTiming(stepTimings, event.data.turn, event.data.step, event.time),
|
||||
}
|
||||
case 'steering/message':
|
||||
return {
|
||||
@@ -177,6 +181,8 @@ export class TranscriptAdapter {
|
||||
/** Transcript nodes in log order; copy-on-write so a published array never mutates. */
|
||||
private projected: ConversationNode[] = []
|
||||
private callIdx = new Map<string, CallIndexEntry>()
|
||||
/** Per-step timing boundaries (step/start + first token delta), consumed when the step's assistant/message materializes. */
|
||||
private stepTimings = new Map<string, AssistantStepMetadata>()
|
||||
/** Wire result views keyed by the tool/result event's seq (views ride the envelope, not the event). */
|
||||
private resultViews = new Map<number, ToolResultView>()
|
||||
/**
|
||||
@@ -207,6 +213,7 @@ export class TranscriptAdapter {
|
||||
this.callIdx = new Map()
|
||||
this.resultViews.clear()
|
||||
this.commandIdx = new Map()
|
||||
this.stepTimings = new Map()
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i]
|
||||
/* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */
|
||||
@@ -214,6 +221,7 @@ export class TranscriptAdapter {
|
||||
this.eventIndex.set(event.seq, event)
|
||||
this.indexCall(event, views?.[i])
|
||||
this.indexCommand(event)
|
||||
indexAssistantStepTiming(this.stepTimings, event)
|
||||
}
|
||||
// Indexes first, then project: a tool/result materializes against the
|
||||
// complete call index, and a checkpoint against the complete event index.
|
||||
@@ -236,6 +244,7 @@ export class TranscriptAdapter {
|
||||
append(event: SessionEvent, view?: ToolEventView): void {
|
||||
this.eventIndex.set(event.seq, event)
|
||||
this.indexCall(event, view)
|
||||
indexAssistantStepTiming(this.stepTimings, event)
|
||||
if (this.indexCommand(event)) this.rev++
|
||||
if (!isTranscriptEvent(event)) return
|
||||
this.projected = [...this.projected, this.materialize(event)]
|
||||
@@ -274,7 +283,7 @@ export class TranscriptAdapter {
|
||||
private materialize(event: SessionEvent): ConversationNode {
|
||||
return isCompactCheckpoint(event)
|
||||
? materializeCompaction(event, this.eventIndex)
|
||||
: materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null)
|
||||
: materializeNode(event, this.callIdx, this.resultViews.get(event.seq) ?? null, this.stepTimings)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -415,4 +415,48 @@ describe('TranscriptAdapter', () => {
|
||||
expect(nodes[1]).toMatchObject({ name: 'compact', outcome: { kind: 'success', text: '已压缩' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('assistant timing', () => {
|
||||
const base = 1_700_000_000_000
|
||||
|
||||
it('derives step timing across a window rebuild (start + first token + completion)', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([
|
||||
ev.turnStart(0, 0),
|
||||
ev.user(1, '问'),
|
||||
ev.stepStart(2, 0),
|
||||
ev.chunkStart(3, 0),
|
||||
ev.chunkText(4, 0, '答'),
|
||||
ev.chunkText(5, 0, '案'),
|
||||
ev.assistant(6, 0, '答案'),
|
||||
ev.turnEnd(7, 0),
|
||||
])
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: base + 2, firstTokenTime: base + 4, completedTime: base + 6 },
|
||||
})
|
||||
})
|
||||
|
||||
it('derives the same timing on the live append path, first token winning once', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.user(0, '问')])
|
||||
adapter.append(ev.stepStart(1, 0))
|
||||
adapter.append(ev.chunkText(2, 0, '首'))
|
||||
adapter.append(ev.chunkText(3, 0, '次'))
|
||||
adapter.append(ev.assistant(4, 0, '首次'))
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: base + 1, firstTokenTime: base + 2, completedTime: base + 4 },
|
||||
})
|
||||
})
|
||||
|
||||
it('soft-falls to null boundaries when the step opening fell outside the window', () => {
|
||||
const adapter = new TranscriptAdapter()
|
||||
adapter.reset([ev.assistant(100, 0, '被切窗的答案')])
|
||||
const assistant = adapter.nodes().find(n => n.kind === 'assistant')
|
||||
expect(assistant).toMatchObject({
|
||||
timing: { stepStartTime: null, firstTokenTime: null, completedTime: base + 100 },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user