|
|
@@ -0,0 +1,353 @@
|
|
|
+// Session stats under the composer, split into two icon pills: a gauge pill
|
|
|
+// (turn/step counts + output speed) opening the time-and-speed dialog, and a
|
|
|
+// database pill (total tokens + cache hit) opening the token-usage dialog.
|
|
|
+// Settled-node identity prevents stream-delta updates from rerendering the row.
|
|
|
+// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
|
|
|
+// active conversation scrollport (see ConversationRoot data-conversation-scroll).
|
|
|
+
|
|
|
+import { memo, useMemo, useState } from 'react'
|
|
|
+import { createPortal } from 'react-dom'
|
|
|
+import { IconDatabaseOutline16, IconGaugeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
|
+import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
|
|
|
+import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
+// Type-only: merges the sessionStats key into SessionProjectionMap for useProjection.
|
|
|
+import type {} from '@deepseek-ai/dsh-session-stats/client'
|
|
|
+import type { TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
|
|
|
+import type { ChatViewSlotProps } from '../contract/slots.ts'
|
|
|
+import type { ChatSnapshot } from '../contract/snapshot.ts'
|
|
|
+import { formatTokensPerSecond } from './message-chrome.ts'
|
|
|
+import { assistantStepReading } from '../contract/turn-metrics.ts'
|
|
|
+import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts'
|
|
|
+import { MEASURE_STYLE, useStatDialog } from './stat-dialog.ts'
|
|
|
+import css from './StatsPills.module.css'
|
|
|
+import dialogCss from './stat-dialog.module.css'
|
|
|
+
|
|
|
+interface WindowStats {
|
|
|
+ turns: number
|
|
|
+ steps: number
|
|
|
+ /** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
|
|
|
+ llmMs: number
|
|
|
+ /** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
|
|
|
+ toolMs: number
|
|
|
+ /** Summed first-token latency over `ttftSteps`; 0 when no step records it. */
|
|
|
+ ttftMs: number
|
|
|
+ /** Steps carrying a recorded TTFT. */
|
|
|
+ ttftSteps: number
|
|
|
+ /** Summed decode wall time over steps that also report output tokens. */
|
|
|
+ decodeMs: number
|
|
|
+ /** Summed output tokens over the same decode-timed steps. */
|
|
|
+ decodeTokens: number
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Fold assistant and tool-result nodes into window-scoped display totals —
|
|
|
+ * the FALLBACK for assemblies without the `sessionStats` projection.
|
|
|
+ *
|
|
|
+ * Every displayed figure rides that durable whole-log projection (and token
|
|
|
+ * accounting rides `tokenUsage`) because the window is paged and compaction
|
|
|
+ * rewrites it; this fold answers "what is on screen" only when no projection
|
|
|
+ * value is served. Its field names deliberately mirror the projection's so
|
|
|
+ * the two swap wholesale.
|
|
|
+ * @param nodes - snapshot nodes.
|
|
|
+ * @returns fallback counts and summed wall times.
|
|
|
+ */
|
|
|
+export function deriveStats(nodes: ChatSnapshot['legacy']['nodes']): WindowStats {
|
|
|
+ const turns = new Set<number>()
|
|
|
+ let steps = 0
|
|
|
+ let llmMs = 0
|
|
|
+ let toolMs = 0
|
|
|
+ let ttftMs = 0
|
|
|
+ let ttftSteps = 0
|
|
|
+ let decodeMs = 0
|
|
|
+ let decodeTokens = 0
|
|
|
+ for (const node of nodes) {
|
|
|
+ if (node.kind === 'tool-result') {
|
|
|
+ if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if (node.kind !== 'assistant') continue
|
|
|
+ turns.add(node.turn)
|
|
|
+ steps += 1
|
|
|
+ if (node.timing !== undefined && node.timing.stepStartTime !== null) {
|
|
|
+ llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
|
|
|
+ }
|
|
|
+ const reading = assistantStepReading(node)
|
|
|
+ if (reading.ttftMs !== null) {
|
|
|
+ ttftMs += reading.ttftMs
|
|
|
+ ttftSteps += 1
|
|
|
+ }
|
|
|
+ if (reading.decodeMs !== null && reading.outputTokens !== null) {
|
|
|
+ decodeMs += reading.decodeMs
|
|
|
+ decodeTokens += reading.outputTokens
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Compact duration: 45.2s under a minute, 2m42s from there on.
|
|
|
+ * @param ms - duration in milliseconds.
|
|
|
+ * @returns display string.
|
|
|
+ */
|
|
|
+export function formatDuration(ms: number, t: ChatViewSlotProps['t']): string {
|
|
|
+ const s = ms / 1_000
|
|
|
+ if (s < 60) return t('duration.compactSeconds', { seconds: Math.round(s * 10) / 10 })
|
|
|
+ const whole = Math.round(s)
|
|
|
+ return t('duration.compactMinutes', {
|
|
|
+ minutes: Math.floor(whole / 60),
|
|
|
+ seconds: whole % 60,
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Display-ready cache-hit share of prompt-side input over the whole durable log.
|
|
|
+ * @param usage - the session's token-usage projection value.
|
|
|
+ * @returns integer text when integer rounding stays below 100, otherwise the
|
|
|
+ * minimum decimal precision that still rounds below 100; a full hit returns
|
|
|
+ * 100, and no billed input returns null.
|
|
|
+ */
|
|
|
+export function cacheHitPercent(usage: TokenUsageProjection): string | null {
|
|
|
+ const denominator = billedInputTokens(usage)
|
|
|
+ return formatCacheHitPercent(usage.cacheReadTokens, denominator)
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Sum the three disjoint prompt-side billing buckets.
|
|
|
+ * @param usage - the session's token-usage projection value.
|
|
|
+ * @returns billed input tokens.
|
|
|
+ */
|
|
|
+export function billedInputTokens(usage: TokenUsageProjection): number {
|
|
|
+ return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
|
|
|
+}
|
|
|
+
|
|
|
+/** Props: the conversation-snapshot selector plus the projection read seat. */
|
|
|
+export interface StatsPillsProps {
|
|
|
+ useChat: SnapshotSelectorHook<ChatSnapshot>
|
|
|
+ useProjection: UseProjection
|
|
|
+ /** The owning dock's locale seat. */
|
|
|
+ t: ChatViewSlotProps['t']
|
|
|
+}
|
|
|
+
|
|
|
+function exactCount(value: number, t: ChatViewSlotProps['t']): string {
|
|
|
+ return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
|
|
|
+}
|
|
|
+
|
|
|
+/** External open state one pill's dialog reads and writes (the row's exclusive slot). */
|
|
|
+type PillDialog = Pick<ReturnType<typeof useStatDialog>, 'open' | 'setOpen'>
|
|
|
+
|
|
|
+function TimePill({ stats, t, dialog }: {
|
|
|
+ stats: WindowStats
|
|
|
+ t: ChatViewSlotProps['t']
|
|
|
+ dialog: PillDialog
|
|
|
+}) {
|
|
|
+ const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
|
|
|
+ const counts = t('stats.counts', { turns: stats.turns, steps: stats.steps })
|
|
|
+ const tps = stats.decodeMs > 0
|
|
|
+ ? t('message.tokensPerSecond', {
|
|
|
+ tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
|
|
|
+ })
|
|
|
+ : null
|
|
|
+ const label = (
|
|
|
+ <span className={css.label}>
|
|
|
+ {counts}
|
|
|
+ {tps !== null && (
|
|
|
+ <>
|
|
|
+ <span className={css.sep} aria-hidden>·</span>
|
|
|
+ {tps}
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </span>
|
|
|
+ )
|
|
|
+ // A window without one timed figure has no dialog rows to show, so the pill
|
|
|
+ // stays a plain reading instead of a button opening an empty dialog.
|
|
|
+ if (stats.llmMs <= 0 && stats.toolMs <= 0 && stats.ttftSteps <= 0 && stats.decodeMs <= 0) {
|
|
|
+ return (
|
|
|
+ <span className={css.anchor}>
|
|
|
+ <span className={css.pill}>
|
|
|
+ <IconGaugeOutline16 />
|
|
|
+ {label}
|
|
|
+ </span>
|
|
|
+ </span>
|
|
|
+ )
|
|
|
+ }
|
|
|
+ return (
|
|
|
+ <span ref={rootRef} className={css.anchor}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.pill}
|
|
|
+ aria-haspopup="dialog"
|
|
|
+ aria-expanded={open}
|
|
|
+ aria-label={tps === null ? counts : `${counts} · ${tps}`}
|
|
|
+ onClick={() => { setOpen(!open) }}
|
|
|
+ >
|
|
|
+ <IconGaugeOutline16 />
|
|
|
+ {label}
|
|
|
+ </button>
|
|
|
+ {open && createPortal(
|
|
|
+ <div
|
|
|
+ ref={panelRef}
|
|
|
+ className={dialogCss.panel}
|
|
|
+ role="dialog"
|
|
|
+ aria-label={t('stats.dialog.title')}
|
|
|
+ style={pos ?? MEASURE_STYLE}
|
|
|
+ >
|
|
|
+ <div className={dialogCss.title}>
|
|
|
+ <span className={dialogCss.titleLabel}>
|
|
|
+ <IconGaugeOutline16 />
|
|
|
+ {t('stats.dialog.title')}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div className={dialogCss.titleRule} aria-hidden />
|
|
|
+ <dl className={dialogCss.details} data-session-stats-details>
|
|
|
+ {stats.llmMs > 0 && (
|
|
|
+ <>
|
|
|
+ <dt>{t('stats.dialog.llmTime')}</dt>
|
|
|
+ <dd>{formatDuration(stats.llmMs, t)}</dd>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {stats.toolMs > 0 && (
|
|
|
+ <>
|
|
|
+ <dt>{t('stats.dialog.toolTime')}</dt>
|
|
|
+ <dd>{formatDuration(stats.toolMs, t)}</dd>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {stats.ttftSteps > 0 && (
|
|
|
+ <>
|
|
|
+ <dt>{t('stats.dialog.ttft')}</dt>
|
|
|
+ <dd>{formatDuration(stats.ttftMs / stats.ttftSteps, t)}</dd>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ {stats.decodeMs > 0 && (
|
|
|
+ <>
|
|
|
+ <dt>{t('stats.dialog.speed')}</dt>
|
|
|
+ <dd>{t('message.tokensPerSecond', {
|
|
|
+ tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
|
|
|
+ })}</dd>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </dl>
|
|
|
+ </div>,
|
|
|
+ document.body,
|
|
|
+ )}
|
|
|
+ </span>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function UsagePill({ usage, t, dialog }: {
|
|
|
+ usage: TokenUsageProjection
|
|
|
+ t: ChatViewSlotProps['t']
|
|
|
+ dialog: PillDialog
|
|
|
+}) {
|
|
|
+ const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
|
|
|
+ // Same aggregate as the Turn pill's totalTokens: every prompt-side billing bucket plus output.
|
|
|
+ const total = billedInputTokens(usage) + usage.outputTokens
|
|
|
+ const totalText = t('message.turnUsage.count', { count: formatTokens(total, t) })
|
|
|
+ const cacheHit = cacheHitPercent(usage)
|
|
|
+ const cacheHitText = cacheHit !== null ? t('stats.cacheHit', { percent: cacheHit }) : null
|
|
|
+ return (
|
|
|
+ <span ref={rootRef} className={css.anchor}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.pill}
|
|
|
+ aria-haspopup="dialog"
|
|
|
+ aria-expanded={open}
|
|
|
+ aria-label={cacheHitText === null ? totalText : `${totalText} · ${cacheHitText}`}
|
|
|
+ onClick={() => { setOpen(!open) }}
|
|
|
+ >
|
|
|
+ <IconDatabaseOutline16 />
|
|
|
+ <span className={css.label}>
|
|
|
+ {totalText}
|
|
|
+ {cacheHitText !== null && (
|
|
|
+ <>
|
|
|
+ <span className={css.sep} aria-hidden>·</span>
|
|
|
+ {cacheHitText}
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ </span>
|
|
|
+ </button>
|
|
|
+ {open && createPortal(
|
|
|
+ <div
|
|
|
+ ref={panelRef}
|
|
|
+ className={dialogCss.panel}
|
|
|
+ role="dialog"
|
|
|
+ aria-label={t('stats.dialog.usageTitle')}
|
|
|
+ style={pos ?? MEASURE_STYLE}
|
|
|
+ >
|
|
|
+ <div className={dialogCss.title}>
|
|
|
+ <span className={dialogCss.titleLabel}>
|
|
|
+ <IconDatabaseOutline16 />
|
|
|
+ {t('stats.dialog.usageTitle')}
|
|
|
+ </span>
|
|
|
+ <span className={dialogCss.titleValue}>{exactCount(total, t)}</span>
|
|
|
+ </div>
|
|
|
+ <div className={dialogCss.titleRule} aria-hidden />
|
|
|
+ {/* jscpd:ignore-start -- the session-total bucket rows deliberately mirror
|
|
|
+ TurnUsagePanel's per-turn dl: same skin, different data contract (all
|
|
|
+ buckets always present here; per-turn fields are optional). */}
|
|
|
+ <dl className={dialogCss.details} data-session-stats-usage>
|
|
|
+ {cacheHit !== null && (
|
|
|
+ <>
|
|
|
+ <dt>{t('message.turnUsage.cacheHit')}</dt>
|
|
|
+ <dd>{`${cacheHit}%`}</dd>
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ <dt>{t('message.turnUsage.input')}</dt>
|
|
|
+ <dd>{exactCount(usage.uncachedInputTokens, t)}</dd>
|
|
|
+ <dt>{t('message.turnUsage.cacheRead')}</dt>
|
|
|
+ <dd>{exactCount(usage.cacheReadTokens, t)}</dd>
|
|
|
+ <dt>{t('message.turnUsage.cacheWrite')}</dt>
|
|
|
+ <dd>{exactCount(usage.cacheWriteTokens, t)}</dd>
|
|
|
+ <dt>{t('message.turnUsage.output')}</dt>
|
|
|
+ <dd>{exactCount(usage.outputTokens, t)}</dd>
|
|
|
+ </dl>
|
|
|
+ {/* jscpd:ignore-end */}
|
|
|
+ </div>,
|
|
|
+ document.body,
|
|
|
+ )}
|
|
|
+ </span>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+export const StatsPills = memo(function StatsPills({ useChat, useProjection, t }: StatsPillsProps) {
|
|
|
+ const settledNodes = useChat(s => s.legacy.nodes)
|
|
|
+ const usage = useProjection('tokenUsage')
|
|
|
+ // One exclusive slot for both dialogs: opening either pill closes the other.
|
|
|
+ const [openPill, setOpenPill] = useState<'time' | 'usage' | null>(null)
|
|
|
+ // Every figure rides the durable sessionStats projection, so paging and
|
|
|
+ // compaction cannot change any of them; an assembly without the unit falls
|
|
|
+ // back to the window-scoped fold wholesale (same field names), paid only
|
|
|
+ // while no projection value is served.
|
|
|
+ const projected = useProjection('sessionStats')
|
|
|
+ const stats = useMemo(() => projected ?? deriveStats(settledNodes), [projected, settledNodes])
|
|
|
+ // Gated on actual token activity: a session whose steps all settled without
|
|
|
+ // billing (e.g. every request failed) shows its counts without a usage pill.
|
|
|
+ const hasTokens = usage !== undefined
|
|
|
+ && (billedInputTokens(usage) > 0 || usage.outputTokens > 0)
|
|
|
+ if (stats.steps === 0 && !hasTokens) return null
|
|
|
+ // data-composer-stats: InputBar's `.root:has([data-composer-stats])` rule
|
|
|
+ // tightens the composer's bottom clearance only while this row renders.
|
|
|
+ return (
|
|
|
+ <div className={css.root} data-composer-stats>
|
|
|
+ {stats.steps > 0 && (
|
|
|
+ <TimePill
|
|
|
+ stats={stats}
|
|
|
+ t={t}
|
|
|
+ dialog={{
|
|
|
+ open: openPill === 'time',
|
|
|
+ setOpen: (open) => { setOpenPill(open ? 'time' : null) },
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ {hasTokens && (
|
|
|
+ <UsagePill
|
|
|
+ usage={usage}
|
|
|
+ t={t}
|
|
|
+ dialog={{
|
|
|
+ open: openPill === 'usage',
|
|
|
+ setOpen: (open) => { setOpenPill(open ? 'usage' : null) },
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+})
|