feat(web): add durable session metrics (round 1)
This commit is contained in:
@@ -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>
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user