feat(web): add durable session metrics (round 1)

This commit is contained in:
Hypatia May
2026-07-28 12:10:48 +08:00
parent 2a46685414
commit 9ca0241d5d
43 changed files with 1139 additions and 97 deletions
@@ -5,48 +5,63 @@ import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/clien
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import css from './StatsLine.module.css'
interface UsageTotals {
type SessionMetrics = NonNullable<ConversationSnapshot['metrics']>
interface VisibleCounts {
turns: number
steps: number
tokens: number
cacheHitPct: number | null
}
/** Token accounting slice of assistant `usage` (typed upstream as unknown). */
interface UsageLike {
inputTokens?: number
outputTokens?: number
cacheReadTokens?: number
}
/**
* Fold assistant nodes into display totals.
* Count visible assistant turns and steps without treating the paged window
* as an accounting source.
* @param nodes - snapshot nodes.
* @returns totals; cacheHitPct null until any cache accounting arrives.
* @returns visible turn and step counts.
*/
export function deriveStats(nodes: ConversationSnapshot['nodes']): UsageTotals {
export function deriveVisibleCounts(nodes: ConversationSnapshot['nodes']): VisibleCounts {
const turns = new Set<number>()
let steps = 0
let tokens = 0
let input = 0
let cacheRead = 0
for (const node of nodes) {
if (node.kind !== 'assistant') continue
turns.add(node.turn)
steps += 1
const usage = node.usage as UsageLike | undefined
if (usage === undefined) continue
input += usage.inputTokens ?? 0
cacheRead += usage.cacheReadTokens ?? 0
tokens += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) + (usage.cacheReadTokens ?? 0)
}
const denom = input + cacheRead
return {
turns: turns.size,
steps,
tokens,
cacheHitPct: denom === 0 ? null : Math.round((cacheRead / denom) * 100),
}
return { turns: turns.size, steps }
}
/**
* Format large token values with the status surfaces' compact suffix style.
* @param value - token count or model capacity.
* @returns locale-formatted count.
*/
export function formatMetricTokens(value: number): string {
if (value < 1_000) return value.toLocaleString('en-US')
return value.toLocaleString('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}).replace('K', 'k').replace('M', 'm').replace('B', 'b')
}
/**
* Existing Web cache-hit formula over disjoint uncached and cache-read input.
* @param metrics - Host-owned durable usage.
* @returns rounded integer percent, or null when no input was billed.
*/
export function cacheHitPercent(metrics: SessionMetrics): number | null {
const denominator = metrics.uncachedInputTokens + metrics.cacheReadTokens
return denominator === 0
? null
: Math.round(metrics.cacheReadTokens / denominator * 100)
}
/**
* Current context occupancy using the TUI's integer rounding and upper clamp.
* @param metrics - Host-owned current pressure and exact route capacity.
* @returns occupancy percent, or null when either input is unavailable.
*/
export function contextPercent(metrics: SessionMetrics): number | null {
if (metrics.contextTokens === undefined || metrics.contextWindow === undefined) return null
return Math.min(100, Math.round(metrics.contextTokens / metrics.contextWindow * 100))
}
/** Props: the conversation-snapshot selector hook (handed down by ChatView). */
@@ -54,12 +69,33 @@ export interface StatsLineProps { useSession: SnapshotSelectorHook<ConversationS
export const StatsLine = memo(function StatsLine({ useSession }: StatsLineProps) {
const nodes = useSession(s => s.nodes)
const stats = useMemo(() => deriveStats(nodes), [nodes])
if (stats.steps === 0) return null
const metrics = useSession(s => s.metrics)
const counts = useMemo(() => deriveVisibleCounts(nodes), [nodes])
if (counts.steps === 0 && (
metrics === null
|| (
metrics.uncachedInputTokens === 0
&& metrics.outputTokens === 0
&& metrics.cacheReadTokens === 0
&& (metrics.contextTokens ?? 0) === 0
)
)) return null
const parts: string[] = []
if (stats.cacheHitPct !== null) parts.push(`cache hit ${stats.cacheHitPct}%`)
parts.push(`${stats.tokens.toLocaleString('en-US')} tokens`)
parts.push(`${stats.turns} turns`)
parts.push(`${stats.steps} steps`)
if (metrics === null) {
parts.push('usage unknown')
parts.push('context unknown')
} else {
parts.push(`${formatMetricTokens(metrics.uncachedInputTokens)} uncached input`)
parts.push(`${formatMetricTokens(metrics.outputTokens)} output`)
parts.push(`${formatMetricTokens(metrics.cacheReadTokens)} cache read`)
const cacheHit = cacheHitPercent(metrics)
if (cacheHit !== null) parts.push(`cache hit ${cacheHit}%`)
const context = contextPercent(metrics)
parts.push(context === null
? 'context unknown'
: `context ${context}% of ${formatMetricTokens(metrics.contextWindow as number)}`)
}
parts.push(`${counts.turns} turns`)
parts.push(`${counts.steps} steps`)
return <div className={css.root}>{parts.join(' · ')}</div>
})