From ef073b8e750aa5a583169f48897d0b56c49266f4 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:56:10 -0700 Subject: [PATCH 1/2] fix(chat): don't count tool waits in output speed The live tok/s reading was averaging in time spent waiting on tools, so a fast model looked slow whenever a command was running. Pause the clock while a tool is in flight, hold the last generating rate, and keep a session average of tokens over generating time only. Show that average in Session Details, the composer token popover, and the bottom-left status bar. --- .../chat/composer-context-usage.tsx | 21 ++- .../conversations/session-details-content.tsx | 27 +++- src/components/layout/status-bar-stats.tsx | 23 +++- src/components/message/live-turn-stats.tsx | 7 +- src/components/message/message-list-view.tsx | 1 + src/hooks/use-session-output-speed.ts | 23 ++++ src/hooks/use-token-output-speed.test.ts | 116 +++++++++++++--- src/hooks/use-token-output-speed.ts | 86 ++++++++++-- src/i18n/messages/ar.json | 10 +- src/i18n/messages/de.json | 10 +- src/i18n/messages/en.json | 10 +- src/i18n/messages/es.json | 10 +- src/i18n/messages/fr.json | 10 +- src/i18n/messages/ja.json | 10 +- src/i18n/messages/ko.json | 10 +- src/i18n/messages/pt.json | 10 +- src/i18n/messages/zh-CN.json | 10 +- src/i18n/messages/zh-TW.json | 10 +- src/lib/session-output-speed.test.ts | 52 +++++++ src/lib/session-output-speed.ts | 128 ++++++++++++++++++ src/lib/token-speed.test.ts | 49 ++++++- src/lib/token-speed.ts | 77 +++++++++-- 22 files changed, 622 insertions(+), 88 deletions(-) create mode 100644 src/hooks/use-session-output-speed.ts create mode 100644 src/lib/session-output-speed.test.ts create mode 100644 src/lib/session-output-speed.ts diff --git a/src/components/chat/composer-context-usage.tsx b/src/components/chat/composer-context-usage.tsx index 9f4a7ff212..fbe6c3412d 100644 --- a/src/components/chat/composer-context-usage.tsx +++ b/src/components/chat/composer-context-usage.tsx @@ -8,6 +8,8 @@ import { useTabStore } from "@/contexts/tab-context" import { useConversationRuntimeStore } from "@/stores/conversation-runtime-store" import { formatTokenCount } from "@/lib/token-format" import { formatContextWindowPercent } from "@/lib/context-window" +import { useSessionOutputSpeed } from "@/hooks/use-session-output-speed" +import { formatTokPerSec } from "@/lib/token-speed" import { Popover, PopoverContent, @@ -46,6 +48,7 @@ export function ComposerContextUsage({ tabId }: { tabId: string | null }) { : null ) const usage = sessionStats?.total_usage + const outputSpeed = useSessionOutputSpeed(runtimeConversationId) const subscribeConn = useCallback( (cb: () => void) => { @@ -117,8 +120,9 @@ export function ComposerContextUsage({ tabId }: { tabId: string | null }) { } const hasTokenSection = rows.length > 0 + const hasOutputSpeed = outputSpeed != null - if (!hasContext && !hasTokenSection) return null + if (!hasContext && !hasTokenSection && !hasOutputSpeed) return null // Native hover hint mirroring the popover's headline (the popover stays for // the full breakdown on click). @@ -231,6 +235,21 @@ export function ComposerContextUsage({ tabId }: { tabId: string | null }) { ) : null} + {hasOutputSpeed ? ( +
+ {t("outputSpeed")} + + {formatTokPerSec(outputSpeed.averageTps)} + +
+ ) : null} ) diff --git a/src/components/conversations/session-details-content.tsx b/src/components/conversations/session-details-content.tsx index 2e91cad733..a3d641adb0 100644 --- a/src/components/conversations/session-details-content.tsx +++ b/src/components/conversations/session-details-content.tsx @@ -21,6 +21,8 @@ import { import { getFolderConversation } from "@/lib/api" import { useCopiedFlag } from "@/hooks/use-copied-flag" import { pickModelFromTurns } from "./active-session-details" +import { useSessionOutputSpeed } from "@/hooks/use-session-output-speed" +import { formatTokPerSec } from "@/lib/token-speed" import { AgentIcon } from "@/components/agent-icon" import { ConversationStatusDot } from "./conversation-status-dot" @@ -141,15 +143,17 @@ export function InfoItem({ children, className, valueClassName, + title, }: { /** Usually plain text; a node so a caller can hang a badge off the label. */ label: ReactNode children: ReactNode className?: string valueClassName?: string + title?: string }) { return ( -
+
{label}
@@ -312,6 +316,7 @@ export function SessionDetailsContent({ ctxMax ) const durationMs = resolveSessionDurationMs(summary, stats) + const outputSpeed = useSessionOutputSpeed(summary.id) // Never coerce an unknown `used` to 0 — some parsers infer the model's // context cap without any usage figure, so render "— / max" rather than a // bogus "0 / max". @@ -324,11 +329,12 @@ export function SessionDetailsContent({ ? formatTokenCount(ctxUsed) : null const hasTokenInfo = - stats != null && - (totalTokens != null || - usage != null || - contextWindowValue != null || - durationMs > 0) + outputSpeed != null || + (stats != null && + (totalTokens != null || + usage != null || + contextWindowValue != null || + durationMs > 0)) const numeric = "font-mono tabular-nums" @@ -434,6 +440,15 @@ export function SessionDetailsContent({ {formatDuration(durationMs)} )} + {outputSpeed != null && ( + + {formatTokPerSec(outputSpeed.averageTps)} + + )} ) : (
{t("noStats")}
diff --git a/src/components/layout/status-bar-stats.tsx b/src/components/layout/status-bar-stats.tsx index 914d5a026f..5b0c88540a 100644 --- a/src/components/layout/status-bar-stats.tsx +++ b/src/components/layout/status-bar-stats.tsx @@ -1,10 +1,13 @@ "use client" -import { ChartNoAxesColumn, MonitorCloud } from "lucide-react" +import { ChartNoAxesColumn, MonitorCloud, Plane } from "lucide-react" import { useTranslations } from "next-intl" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { useRemoteConnection } from "@/contexts/remote-connection-context" import { useWorkbenchRoute } from "@/contexts/workbench-route-context" +import { useTabStore } from "@/stores/tab-store" +import { useSessionOutputSpeed } from "@/hooks/use-session-output-speed" +import { formatTokPerSec } from "@/lib/token-speed" import { cn } from "@/lib/utils" /** @@ -27,8 +30,15 @@ export function StatusBarStats() { // codeg-server); local windows have no RemoteConnection in context. const remoteConnection = useRemoteConnection()?.connection ?? null const { routeId, setRoute } = useWorkbenchRoute() + const speedConversationId = useTabStore((s) => { + const tab = s.tabs.find((item) => item.id === s.activeTabId) + if (!tab || tab.kind !== "conversation") return null + return tab.runtimeConversationId ?? tab.conversationId ?? null + }) + const outputSpeed = useSessionOutputSpeed(speedConversationId) + const tSpeed = useTranslations("Folder.statusBar.tokens") - if (!remoteConnection && !stats) return null + if (!remoteConnection && !stats && outputSpeed == null) return null return (
@@ -59,6 +69,15 @@ export function StatusBarStats() { )} + {outputSpeed != null && ( + + + )}
) } diff --git a/src/components/message/live-turn-stats.tsx b/src/components/message/live-turn-stats.tsx index ee52545331..108f25e87c 100644 --- a/src/components/message/live-turn-stats.tsx +++ b/src/components/message/live-turn-stats.tsx @@ -16,11 +16,13 @@ import { FilePenLine, Plane, Timer } from "lucide-react" import type { AgentType } from "@/lib/types" import { AgentIcon } from "@/components/agent-icon" import { useTokenOutputSpeed } from "@/hooks/use-token-output-speed" +import { formatTokPerSec } from "@/lib/token-speed" interface LiveTurnStatsProps { message: LiveMessage agentType: AgentType isStreaming?: boolean + conversationId?: number | null } interface LineChangeStats { @@ -311,12 +313,13 @@ export function LiveTurnStats({ message, agentType, isStreaming = true, + conversationId = null, }: LiveTurnStatsProps) { const locale = useLocale() const t = useTranslations("Folder.chat.liveTurnStats") const [elapsed, setElapsed] = useState(() => Date.now() - message.startedAt) const editStats = useMemo(() => extractLiveEditStats(message), [message]) - const tps = useTokenOutputSpeed(message) + const tps = useTokenOutputSpeed(message, { conversationId }) const compactNumberFormatter = useMemo( () => new Intl.NumberFormat(locale, { @@ -392,7 +395,7 @@ export function LiveTurnStats({ aria-label={t("outputSpeedAria")} className="h-3 w-3 shrink-0" /> - {tps.toFixed(1)} tok/s + {formatTokPerSec(tps)} )} diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index e9ac79c78d..fddda7689e 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -1154,6 +1154,7 @@ export function MessageListView({ message={liveMessage} agentType={agentType} isStreaming={connStatus === "prompting"} + conversationId={conversationId} /> )} {/* Shared overlay stack pinned to the inline-start edge (top-left in LTR, diff --git a/src/hooks/use-session-output-speed.ts b/src/hooks/use-session-output-speed.ts new file mode 100644 index 0000000000..9dfb8fc8d7 --- /dev/null +++ b/src/hooks/use-session-output-speed.ts @@ -0,0 +1,23 @@ +"use client" + +import { useCallback, useSyncExternalStore } from "react" + +import { + getSessionOutputSpeed, + subscribeSessionOutputSpeed, + type SessionOutputSpeed, +} from "@/lib/session-output-speed" + +export function useSessionOutputSpeed( + conversationId: number | null | undefined +): SessionOutputSpeed | null { + const subscribe = useCallback( + (onStoreChange: () => void) => subscribeSessionOutputSpeed(onStoreChange), + [] + ) + const getSnapshot = useCallback( + () => getSessionOutputSpeed(conversationId), + [conversationId] + ) + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot) +} diff --git a/src/hooks/use-token-output-speed.test.ts b/src/hooks/use-token-output-speed.test.ts index cf4a182dff..a48c2f1f23 100644 --- a/src/hooks/use-token-output-speed.test.ts +++ b/src/hooks/use-token-output-speed.test.ts @@ -6,35 +6,57 @@ import type { LiveContentBlock, LiveMessage, } from "@/contexts/acp-connections-context" +import { + getSessionOutputSpeed, + resetSessionOutputSpeedForTests, +} from "@/lib/session-output-speed" function msg(blocks: LiveContentBlock[], id = "live-1"): LiveMessage { return { id, role: "assistant", content: blocks, startedAt: 0 } } -/** A completed tool call — content the turn carries but never scores. */ -const TOOL_BLOCK: LiveContentBlock = { - type: "tool_call", - info: { - tool_call_id: "tc-1", - title: "tool", - kind: "tool", - status: "completed", - content: null, - raw_input: null, - raw_output_chunks: [], - raw_output_total_bytes: 0, - locations: null, - meta: null, - images: [], - }, +function toolBlock( + status: "pending" | "in_progress" | "completed" +): LiveContentBlock { + return { + type: "tool_call", + info: { + tool_call_id: "tc-1", + title: "tool", + kind: "tool", + status, + content: null, + raw_input: null, + raw_output_chunks: [], + raw_output_total_bytes: 0, + locations: null, + meta: null, + images: [], + }, + } } +/** A completed tool call — content the turn carries but never scores. */ +const TOOL_BLOCK: LiveContentBlock = toolBlock("completed") +const RUNNING_TOOL: LiveContentBlock = toolBlock("in_progress") + let fakeNow = 0 -function mount(message: LiveMessage) { +function mount( + message: LiveMessage, + options: { conversationId?: number; waiting?: boolean } = {} +) { return renderHook( - ({ message }: { message: LiveMessage }) => useTokenOutputSpeed(message), - { initialProps: { message } } + ({ + message, + conversationId, + waiting, + }: { + message: LiveMessage + conversationId?: number + waiting?: boolean + }) => useTokenOutputSpeed(message, { conversationId, waiting }), + { initialProps: { message, ...options } } ) } @@ -55,6 +77,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks() vi.useRealTimers() + resetSessionOutputSpeedForTests() }) describe("useTokenOutputSpeed", () => { @@ -152,9 +175,9 @@ describe("useTokenOutputSpeed", () => { expect(result.current).toBeCloseTo(200) }) - it("decays through a silent gap instead of freezing the reading", () => { - // No new content at all — a quiet long-running tool, a retry backoff, or a - // permission prompt blocking the turn. The reading has to fall. + it("decays through a silent generating gap instead of freezing the reading", () => { + // No new content and no in-flight tool — a slow decode, not a wait. The + // reading has to fall so a genuinely slow stream is not reported as fast. const { result, rerender } = mount( msg([{ type: "text", text: "a".repeat(80) }]) ) @@ -167,6 +190,55 @@ describe("useTokenOutputSpeed", () => { expect(result.current as number).toBeLessThan(5) }) + it("holds the reading while a tool is running", () => { + const { result, rerender } = mount( + msg([{ type: "text", text: "a".repeat(80) }]) + ) + rerender({ + message: msg([{ type: "text", text: "a".repeat(280) }]), + }) + tick() + expect(result.current).toBeCloseTo(100) + + rerender({ + message: msg([{ type: "text", text: "a".repeat(280) }, RUNNING_TOOL]), + }) + for (let i = 0; i < 20; i++) tick() + expect(result.current).toBeCloseTo(100) + }) + + it("does not fold a tool wait into the next generating sample", () => { + const { result, rerender } = mount( + msg([{ type: "text", text: "a".repeat(80) }]), + { conversationId: 9 } + ) + rerender({ + message: msg([{ type: "text", text: "a".repeat(280) }]), + conversationId: 9, + }) + tick() + expect(result.current).toBeCloseTo(100) + + rerender({ + message: msg([{ type: "text", text: "a".repeat(280) }, RUNNING_TOOL]), + conversationId: 9, + }) + for (let i = 0; i < 20; i++) tick() + + // 200 new chars = 50 tokens in the 500ms sample after the wait. + rerender({ + message: msg([{ type: "text", text: "a".repeat(480) }]), + conversationId: 9, + }) + tick() + expect(result.current as number).toBeGreaterThan(50) + const session = getSessionOutputSpeed(9) + expect(session).not.toBeNull() + // Generating time is the two 500ms windows, not the 10s wait. + expect(session!.generatingMs).toBeLessThan(2000) + expect(session!.averageTps).toBeGreaterThan(50) + }) + it("repaints on its own cadence, not on every delta", () => { const { result, rerender } = mount( msg([{ type: "text", text: "a".repeat(80) }]) diff --git a/src/hooks/use-token-output-speed.ts b/src/hooks/use-token-output-speed.ts index a7fc94dde8..b40803c845 100644 --- a/src/hooks/use-token-output-speed.ts +++ b/src/hooks/use-token-output-speed.ts @@ -3,14 +3,17 @@ import { useEffect, useRef, useState } from "react" import type { LiveMessage } from "@/contexts/acp-connections-context" +import { + commitLiveSessionOutputSpeed, + migrateSessionOutputSpeed, + setLiveSessionOutputSpeed, +} from "@/lib/session-output-speed" import { TokenCountAccumulator, TokenSpeedTracker } from "@/lib/token-speed" /** * Sample period. One timer does all three jobs the reading needs — take a - * measurement, repaint, and decay through a silent gap — so the gauge ticks at - * a steady 2 Hz whether or not the wire is busy. Without a clock of its own the - * reading would freeze rather than fall through a quiet long-running tool, a - * retry backoff, or a permission / question prompt blocking the turn. + * measurement, repaint, and notice a tool wait — so the gauge ticks at a + * steady 2 Hz whether or not the wire is busy. * * The connection dispatches far more often than this (once per wire envelope), * but the smoother is time-constant based rather than per-event, so a coarser @@ -20,29 +23,61 @@ const SAMPLE_MS = 500 /** Clamp, so a nonsense spike can never stretch the row it renders in. */ const MAX_TPS = 999.9 +export interface TokenOutputSpeedOptions { + /** Runtime conversation id the session average is stored under. */ + conversationId?: number | null + /** + * Extra "not generating" signal from outside the message (a permission + * prompt, a blocking question). In-flight tool calls on the message are + * detected here already. + */ + waiting?: boolean +} + +function liveMessageIsWaitingOnTool(message: LiveMessage): boolean { + return message.content.some( + (block) => + block.type === "tool_call" && + (block.info.status === "pending" || block.info.status === "in_progress") + ) +} + /** * Live token-output-speed for one streaming turn, in tok/s. A local estimate * from the message's `text` + `thinking` blocks — root agent only, since * sub-agent output belongs to its own capsule rather than to this turn's rate. * + * Time spent waiting on an in-flight tool (or `waiting`) is not generation + * and is excluded from both the live EWMA and the session average. + * * `null` means "nothing to show": the turn has produced no output yet, or the - * reading doesn't cover a meaningful slice of wall clock yet. The caller is - * expected to mount this for the life of one turn and unmount when it ends. + * reading doesn't cover a meaningful slice of generating time yet. The caller + * is expected to mount this for the life of one turn and unmount when it ends. * * TODO: root-agent only; give sub-agent cards their own reading if cross-agent * delegation speed becomes something users ask about. */ -export function useTokenOutputSpeed(message: LiveMessage): number | null { +export function useTokenOutputSpeed( + message: LiveMessage, + options: TokenOutputSpeedOptions = {} +): number | null { const [tps, setTps] = useState(null) + const { conversationId = null, waiting = false } = options - // The sampler below runs off a timer rather than off this component's - // render, so it reads the message through a ref. Written in an effect (never - // during render); being one commit behind costs at most a frame, which the - // cumulative counts absorb on the next sample anyway. const messageRef = useRef(message) + const waitingRef = useRef(waiting) + const conversationIdRef = useRef(conversationId) useEffect(() => { messageRef.current = message + waitingRef.current = waiting }) + useEffect(() => { + const prev = conversationIdRef.current + if (prev != null && conversationId != null && prev !== conversationId) { + migrateSessionOutputSpeed(prev, conversationId) + } + conversationIdRef.current = conversationId + }, [conversationId]) useEffect(() => { const counts = new TokenCountAccumulator() @@ -55,6 +90,23 @@ export function useTokenOutputSpeed(message: LiveMessage): number | null { */ let emptyAt: number | null = null + const publishSession = () => { + const id = conversationIdRef.current + if (id == null) return + setLiveSessionOutputSpeed( + id, + tracker.generatingTokenCount, + tracker.generatingElapsedMs + ) + } + + const commitSession = () => { + const id = conversationIdRef.current + if (id == null) return + publishSession() + commitLiveSessionOutputSpeed(id) + } + /** Cumulative estimated tokens. Only appended text is scanned. */ const measure = (): number => { const live = messageRef.current @@ -62,6 +114,7 @@ export function useTokenOutputSpeed(message: LiveMessage): number | null { // appending to it, so the accumulator's cached per-block prefixes can // describe entirely different text — re-seat on an identity change. if (live.id !== messageId) { + if (messageId != null) commitSession() messageId = live.id counts.reset() tracker.reset() @@ -112,14 +165,21 @@ export function useTokenOutputSpeed(message: LiveMessage): number | null { emptyAt = null } + const pause = + waitingRef.current || liveMessageIsWaitingOnTool(messageRef.current) + // `null` is "still seeding / warming up", not "zero" — hold the last // reading rather than blinking out. - const rate = tracker.observe(total, now) + const rate = tracker.observe(total, now, { pause }) + publishSession() if (rate == null) return setTps(Math.min(Math.max(rate, 0), MAX_TPS)) }, SAMPLE_MS) - return () => clearInterval(timer) + return () => { + clearInterval(timer) + commitSession() + } }, []) return tps diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index f4d082c807..7edf7de780 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1829,6 +1829,8 @@ "cacheRead": "قراءة الذاكرة", "contextWindow": "نافذة السياق", "duration": "المدة", + "outputSpeed": "متوسط سرعة الإخراج", + "outputSpeedTooltip": "المتوسط أثناء كتابة النموذج. وقت انتظار الأدوات غير محسوب.", "loadingStats": "جارٍ تحميل استخدام الرموز…", "loadFailed": "فشل تحميل استخدام الرموز", "noStats": "لا يوجد استخدام مسجّل", @@ -1924,7 +1926,9 @@ "output": "إخراج", "cacheRead": "قراءة الكاش", "cacheWrite": "كتابة الكاش", - "total": "الإجمالي" + "total": "الإجمالي", + "outputSpeed": "سرعة الإخراج", + "outputSpeedTooltip": "المتوسط أثناء كتابة النموذج. وقت انتظار الأدوات غير محسوب." }, "quickActions": { "title": "إجراءات سريعة", @@ -3101,8 +3105,8 @@ "elapsedHours": "{value}س", "elapsedMinutes": "{value}د", "elapsedSeconds": "{value}ث", - "outputSpeedAria": "السرعة التقديرية للإخراج", - "outputSpeedTooltip": "السرعة التقديرية للإخراج (نص + تفكير)" + "outputSpeedAria": "سرعة الإخراج", + "outputSpeedTooltip": "أثناء كتابة النموذج. وقت انتظار الأدوات غير محسوب." }, "jsonTree": { "viewRaw": "عرض JSON الخام", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 9501547529..f29bcc7ba0 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1829,6 +1829,8 @@ "cacheRead": "Cache gelesen", "contextWindow": "Kontextfenster", "duration": "Dauer", + "outputSpeed": "Mittl. Ausgabegeschwindigkeit", + "outputSpeedTooltip": "Durchschnitt, während das Modell schreibt. Wartezeit auf Tools zählt nicht.", "loadingStats": "Token-Nutzung wird geladen…", "loadFailed": "Token-Nutzung konnte nicht geladen werden", "noStats": "Keine Nutzung erfasst", @@ -1924,7 +1926,9 @@ "output": "Ausgabe", "cacheRead": "Cache lesen", "cacheWrite": "Cache schreiben", - "total": "Gesamt" + "total": "Gesamt", + "outputSpeed": "Ausgabegeschwindigkeit", + "outputSpeedTooltip": "Durchschnitt, während das Modell schreibt. Wartezeit auf Tools zählt nicht." }, "quickActions": { "title": "Schnellaktionen", @@ -3101,8 +3105,8 @@ "elapsedHours": "{value} Std", "elapsedMinutes": "{value} Min", "elapsedSeconds": "{value} Sek", - "outputSpeedAria": "Geschätzte Ausgabegeschwindigkeit", - "outputSpeedTooltip": "Geschätzte Ausgabegeschwindigkeit (Text + Denken)" + "outputSpeedAria": "Ausgabegeschwindigkeit", + "outputSpeedTooltip": "Während das Modell schreibt. Wartezeit auf Tools zählt nicht." }, "jsonTree": { "viewRaw": "Rohes JSON anzeigen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index b707982aaf..0762f4e430 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1829,6 +1829,8 @@ "cacheRead": "Cache Read", "contextWindow": "Context Window", "duration": "Duration", + "outputSpeed": "Avg. output speed", + "outputSpeedTooltip": "Average while the model is writing. Time spent waiting on tools is not counted.", "loadingStats": "Loading token usage…", "loadFailed": "Failed to load token usage", "noStats": "No usage recorded", @@ -1924,7 +1926,9 @@ "output": "Output", "cacheRead": "Cache Read", "cacheWrite": "Cache Write", - "total": "Total" + "total": "Total", + "outputSpeed": "Output speed", + "outputSpeedTooltip": "Average while the model is writing. Time spent waiting on tools is not counted." }, "quickActions": { "title": "Quick actions", @@ -3101,8 +3105,8 @@ "elapsedHours": "{value}h", "elapsedMinutes": "{value}m", "elapsedSeconds": "{value}s", - "outputSpeedAria": "Estimated output speed", - "outputSpeedTooltip": "Estimated output speed (text + thinking)" + "outputSpeedAria": "Output speed", + "outputSpeedTooltip": "While the model is writing. Time spent waiting on tools is not counted." }, "jsonTree": { "viewRaw": "Show raw JSON", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 5ec92aedbd..74248b08ed 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1829,6 +1829,8 @@ "cacheRead": "Caché leída", "contextWindow": "Ventana de contexto", "duration": "Duración", + "outputSpeed": "Vel. de salida media", + "outputSpeedTooltip": "Media mientras el modelo escribe. El tiempo de espera de herramientas no cuenta.", "loadingStats": "Cargando uso de tokens…", "loadFailed": "Error al cargar el uso de tokens", "noStats": "Sin uso registrado", @@ -1924,7 +1926,9 @@ "output": "Salida", "cacheRead": "Lectura de caché", "cacheWrite": "Escritura de caché", - "total": "Total de tokens" + "total": "Total de tokens", + "outputSpeed": "Velocidad de salida", + "outputSpeedTooltip": "Media mientras el modelo escribe. El tiempo de espera de herramientas no cuenta." }, "quickActions": { "title": "Acciones rápidas", @@ -3101,8 +3105,8 @@ "elapsedHours": "{value} h", "elapsedMinutes": "{value} min", "elapsedSeconds": "{value} s", - "outputSpeedAria": "Velocidad de salida estimada", - "outputSpeedTooltip": "Velocidad de salida estimada (texto + razonamiento)" + "outputSpeedAria": "Velocidad de salida", + "outputSpeedTooltip": "Mientras el modelo escribe. El tiempo de espera de herramientas no cuenta." }, "jsonTree": { "viewRaw": "Ver JSON sin formato", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 85781a8bf0..6fcaf37180 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1829,6 +1829,8 @@ "cacheRead": "Cache lu", "contextWindow": "Fenêtre de contexte", "duration": "Durée", + "outputSpeed": "Vitesse de sortie moy.", + "outputSpeedTooltip": "Moyenne pendant que le modèle écrit. L'attente des outils n'est pas comptée.", "loadingStats": "Chargement de l'utilisation des tokens…", "loadFailed": "Échec du chargement de l'utilisation des tokens", "noStats": "Aucune utilisation enregistrée", @@ -1924,7 +1926,9 @@ "output": "Sortie", "cacheRead": "Lecture cache", "cacheWrite": "Écriture cache", - "total": "Total des tokens" + "total": "Total des tokens", + "outputSpeed": "Vitesse de sortie", + "outputSpeedTooltip": "Moyenne pendant que le modèle écrit. L'attente des outils n'est pas comptée." }, "quickActions": { "title": "Actions rapides", @@ -3101,8 +3105,8 @@ "elapsedHours": "{value} h", "elapsedMinutes": "{value} min", "elapsedSeconds": "{value} s", - "outputSpeedAria": "Vitesse de sortie estimée", - "outputSpeedTooltip": "Vitesse de sortie estimée (texte + raisonnement)" + "outputSpeedAria": "Vitesse de sortie", + "outputSpeedTooltip": "Pendant que le modèle écrit. L'attente des outils n'est pas comptée." }, "jsonTree": { "viewRaw": "Afficher le JSON brut", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 55be21f779..5aa9f508f1 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1829,6 +1829,8 @@ "cacheRead": "キャッシュ読取", "contextWindow": "コンテキストウィンドウ", "duration": "所要時間", + "outputSpeed": "平均出力速度", + "outputSpeedTooltip": "モデルが書いている間の平均速度。ツール待ち時間は含めません。", "loadingStats": "トークン使用量を読み込み中…", "loadFailed": "トークン使用量の読み込みに失敗しました", "noStats": "使用量の記録なし", @@ -1924,7 +1926,9 @@ "output": "出力", "cacheRead": "キャッシュ読み取り", "cacheWrite": "キャッシュ書き込み", - "total": "合計" + "total": "合計", + "outputSpeed": "出力速度", + "outputSpeedTooltip": "モデルが書いている間の平均速度。ツール待ち時間は含めません。" }, "quickActions": { "title": "クイック操作", @@ -3101,8 +3105,8 @@ "elapsedHours": "{value}時間", "elapsedMinutes": "{value}分", "elapsedSeconds": "{value}秒", - "outputSpeedAria": "推定出力速度", - "outputSpeedTooltip": "推定出力速度(テキスト + 思考)" + "outputSpeedAria": "出力速度", + "outputSpeedTooltip": "モデルが書いている間の速度。ツール待ち時間は含めません。" }, "jsonTree": { "viewRaw": "生の JSON を表示", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index ed59bc9bd9..1942cdff57 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1829,6 +1829,8 @@ "cacheRead": "캐시 읽기", "contextWindow": "컨텍스트 창", "duration": "소요 시간", + "outputSpeed": "평균 출력 속도", + "outputSpeedTooltip": "모델이 쓰는 동안의 평균 속도. 도구 대기 시간은 포함하지 않습니다.", "loadingStats": "토큰 사용량 불러오는 중…", "loadFailed": "토큰 사용량을 불러오지 못했습니다", "noStats": "사용량 기록 없음", @@ -1924,7 +1926,9 @@ "output": "출력", "cacheRead": "캐시 읽기", "cacheWrite": "캐시 쓰기", - "total": "합계" + "total": "합계", + "outputSpeed": "출력 속도", + "outputSpeedTooltip": "모델이 쓰는 동안의 평균 속도. 도구 대기 시간은 포함하지 않습니다." }, "quickActions": { "title": "빠른 작업", @@ -3101,8 +3105,8 @@ "elapsedHours": "{value}시간", "elapsedMinutes": "{value}분", "elapsedSeconds": "{value}초", - "outputSpeedAria": "예상 출력 속도", - "outputSpeedTooltip": "예상 출력 속도(텍스트 + 추론)" + "outputSpeedAria": "출력 속도", + "outputSpeedTooltip": "모델이 쓰는 동안의 속도. 도구 대기 시간은 포함하지 않습니다." }, "jsonTree": { "viewRaw": "원본 JSON 보기", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index f957a119c9..b0c64af681 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1829,6 +1829,8 @@ "cacheRead": "Cache lido", "contextWindow": "Janela de contexto", "duration": "Duração", + "outputSpeed": "Vel. média de saída", + "outputSpeedTooltip": "Média enquanto o modelo escreve. A espera por ferramentas não conta.", "loadingStats": "Carregando uso de tokens…", "loadFailed": "Falha ao carregar o uso de tokens", "noStats": "Nenhum uso registrado", @@ -1924,7 +1926,9 @@ "output": "Saída", "cacheRead": "Leitura de cache", "cacheWrite": "Gravação de cache", - "total": "Total de tokens" + "total": "Total de tokens", + "outputSpeed": "Velocidade de saída", + "outputSpeedTooltip": "Média enquanto o modelo escreve. A espera por ferramentas não conta." }, "quickActions": { "title": "Ações rápidas", @@ -3101,8 +3105,8 @@ "elapsedHours": "{value} h", "elapsedMinutes": "{value} min", "elapsedSeconds": "{value} s", - "outputSpeedAria": "Velocidade de saída estimada", - "outputSpeedTooltip": "Velocidade de saída estimada (texto + raciocínio)" + "outputSpeedAria": "Velocidade de saída", + "outputSpeedTooltip": "Enquanto o modelo escreve. A espera por ferramentas não conta." }, "jsonTree": { "viewRaw": "Ver JSON bruto", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 933fbdd0e2..caf690e57e 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1829,6 +1829,8 @@ "cacheRead": "缓存读取", "contextWindow": "上下文窗口", "duration": "时长", + "outputSpeed": "平均输出速度", + "outputSpeedTooltip": "模型正在写时的平均速度。等待工具完成的时间不计入。", "loadingStats": "正在加载 Token 用量…", "loadFailed": "加载 Token 用量失败", "noStats": "暂无用量记录", @@ -1924,7 +1926,9 @@ "output": "输出", "cacheRead": "缓存读取", "cacheWrite": "缓存写入", - "total": "总计" + "total": "总计", + "outputSpeed": "输出速度", + "outputSpeedTooltip": "模型正在写时的平均速度。等待工具完成的时间不计入。" }, "quickActions": { "title": "快捷操作", @@ -3101,8 +3105,8 @@ "elapsedHours": "{value} 小时", "elapsedMinutes": "{value} 分钟", "elapsedSeconds": "{value} 秒", - "outputSpeedAria": "估算输出速度", - "outputSpeedTooltip": "估算输出速度(正文 + 思考)" + "outputSpeedAria": "输出速度", + "outputSpeedTooltip": "模型正在写时的速度。等待工具完成的时间不计入。" }, "jsonTree": { "viewRaw": "查看原始 JSON", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 0b38589a41..15a17ef335 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1829,6 +1829,8 @@ "cacheRead": "快取讀取", "contextWindow": "上下文視窗", "duration": "時長", + "outputSpeed": "平均輸出速度", + "outputSpeedTooltip": "模型正在寫時的平均速度。等待工具完成的時間不計入。", "loadingStats": "正在載入 Token 用量…", "loadFailed": "載入 Token 用量失敗", "noStats": "尚無用量記錄", @@ -1924,7 +1926,9 @@ "output": "輸出", "cacheRead": "快取讀取", "cacheWrite": "快取寫入", - "total": "總計" + "total": "總計", + "outputSpeed": "輸出速度", + "outputSpeedTooltip": "模型正在寫時的平均速度。等待工具完成的時間不計入。" }, "quickActions": { "title": "快捷操作", @@ -3101,8 +3105,8 @@ "elapsedHours": "{value} 小時", "elapsedMinutes": "{value} 分鐘", "elapsedSeconds": "{value} 秒", - "outputSpeedAria": "估算輸出速度", - "outputSpeedTooltip": "估算輸出速度(正文 + 思考)" + "outputSpeedAria": "輸出速度", + "outputSpeedTooltip": "模型正在寫時的速度。等待工具完成的時間不計入。" }, "jsonTree": { "viewRaw": "檢視原始 JSON", diff --git a/src/lib/session-output-speed.test.ts b/src/lib/session-output-speed.test.ts new file mode 100644 index 0000000000..9461c974e0 --- /dev/null +++ b/src/lib/session-output-speed.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it } from "vitest" + +import { + commitLiveSessionOutputSpeed, + getSessionOutputSpeed, + migrateSessionOutputSpeed, + resetSessionOutputSpeedForTests, + setLiveSessionOutputSpeed, +} from "./session-output-speed" + +afterEach(() => { + resetSessionOutputSpeedForTests() +}) + +describe("session output speed", () => { + it("hides a reading until generating time is meaningful", () => { + setLiveSessionOutputSpeed(1, 10, 100) + expect(getSessionOutputSpeed(1)).toBeNull() + }) + + it("reports tokens over generating time", () => { + setLiveSessionOutputSpeed(1, 100, 1000) + const snap = getSessionOutputSpeed(1) + expect(snap?.averageTps).toBeCloseTo(100) + expect(snap?.generatingMs).toBe(1000) + expect(snap?.outputTokens).toBe(100) + }) + + it("replaces the live slot instead of stacking samples", () => { + setLiveSessionOutputSpeed(1, 50, 500) + setLiveSessionOutputSpeed(1, 100, 1000) + expect(getSessionOutputSpeed(1)?.outputTokens).toBe(100) + }) + + it("commits a finished turn so the next live slot cannot overwrite it", () => { + setLiveSessionOutputSpeed(1, 100, 1000) + commitLiveSessionOutputSpeed(1) + setLiveSessionOutputSpeed(1, 50, 1000) + const snap = getSessionOutputSpeed(1) + expect(snap?.outputTokens).toBe(150) + expect(snap?.generatingMs).toBe(2000) + expect(snap?.averageTps).toBeCloseTo(75) + }) + + it("migrates a virtual runtime id onto the persisted conversation", () => { + setLiveSessionOutputSpeed(-7, 80, 1000) + commitLiveSessionOutputSpeed(-7) + migrateSessionOutputSpeed(-7, 42) + expect(getSessionOutputSpeed(-7)).toBeNull() + expect(getSessionOutputSpeed(42)?.outputTokens).toBe(80) + }) +}) diff --git a/src/lib/session-output-speed.ts b/src/lib/session-output-speed.ts new file mode 100644 index 0000000000..adc59d49ab --- /dev/null +++ b/src/lib/session-output-speed.ts @@ -0,0 +1,128 @@ +/** + * Per-conversation generating-only output speed. + * + * Live tok/s is a one-turn EWMA. This store keeps the session average: + * estimated output tokens / generating milliseconds, with tool waits (and + * any other `pause`) excluded. The live turn writes its current generating + * totals into a replaceable "live" slot so a 2 Hz sample does not + * double-count; committing that slot at turn end (or unmount) folds it + * into the session. + * + * In-memory only: a reload has no generating clock to recover from the + * transcript, so we would rather show nothing than a wall-clock rate that + * includes every tool wait. + */ + +export interface SessionOutputSpeed { + averageTps: number + generatingMs: number + outputTokens: number +} + +const WARMUP_MS = 300 + +type Slot = { + committedTokens: number + committedMs: number + liveTokens: number + liveMs: number +} + +const slots = new Map() +const listeners = new Set<() => void>() + +function emit(): void { + for (const listener of listeners) listener() +} + +function slotOf(conversationId: number): Slot { + let slot = slots.get(conversationId) + if (!slot) { + slot = { + committedTokens: 0, + committedMs: 0, + liveTokens: 0, + liveMs: 0, + } + slots.set(conversationId, slot) + } + return slot +} + +function snapshotOf(slot: Slot): SessionOutputSpeed | null { + const generatingMs = slot.committedMs + slot.liveMs + const outputTokens = slot.committedTokens + slot.liveTokens + if (generatingMs < WARMUP_MS) return null + return { + averageTps: outputTokens / (generatingMs / 1000), + generatingMs, + outputTokens, + } +} + +/** Replace the current turn's generating totals (not additive). */ +export function setLiveSessionOutputSpeed( + conversationId: number, + generatingTokens: number, + generatingMs: number +): void { + const slot = slotOf(conversationId) + if (slot.liveTokens === generatingTokens && slot.liveMs === generatingMs) { + return + } + slot.liveTokens = generatingTokens + slot.liveMs = generatingMs + emit() +} + +/** Fold the live turn into the session and clear the live slot. */ +export function commitLiveSessionOutputSpeed(conversationId: number): void { + const slot = slots.get(conversationId) + if (!slot) return + if (slot.liveTokens === 0 && slot.liveMs === 0) return + slot.committedTokens += slot.liveTokens + slot.committedMs += slot.liveMs + slot.liveTokens = 0 + slot.liveMs = 0 + emit() +} + +/** Follow a virtual runtime id that reconciled to a persisted conversation. */ +export function migrateSessionOutputSpeed( + fromConversationId: number, + toConversationId: number +): void { + if (fromConversationId === toConversationId) return + const from = slots.get(fromConversationId) + if (!from) return + const to = slots.get(toConversationId) + if (!to) { + slots.set(toConversationId, from) + } else { + to.committedTokens += from.committedTokens + from.liveTokens + to.committedMs += from.committedMs + from.liveMs + } + slots.delete(fromConversationId) + emit() +} + +export function getSessionOutputSpeed( + conversationId: number | null | undefined +): SessionOutputSpeed | null { + if (conversationId == null) return null + const slot = slots.get(conversationId) + return slot ? snapshotOf(slot) : null +} + +export function subscribeSessionOutputSpeed(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +/** Test helper: drop every session so cases cannot leak into each other. */ +export function resetSessionOutputSpeedForTests(): void { + slots.clear() + emit() +} diff --git a/src/lib/token-speed.test.ts b/src/lib/token-speed.test.ts index 1da75faa74..e1834ff2f3 100644 --- a/src/lib/token-speed.test.ts +++ b/src/lib/token-speed.test.ts @@ -2,10 +2,18 @@ import { describe, expect, it } from "vitest" import { countChars, estimateTokens, + formatTokPerSec, TokenCountAccumulator, TokenSpeedTracker, } from "./token-speed" +describe("formatTokPerSec", () => { + it("matches the live-turn one-decimal tok/s label", () => { + expect(formatTokPerSec(47.23)).toBe("47.2 tok/s") + expect(formatTokPerSec(0)).toBe("0.0 tok/s") + }) +}) + describe("estimateTokens", () => { it("counts latin at 4 chars per token", () => { expect(estimateTokens("hello world")).toBeCloseTo(10 / 4) @@ -178,11 +186,47 @@ describe("TokenSpeedTracker", () => { expect(rate as number).toBeLessThan(200) }) - it("decays toward zero over a long pause", () => { + it("holds the reading across a paused wait instead of decaying", () => { + const tracker = new TokenSpeedTracker() + tracker.observe(0, 0) + const generating = tracker.observe(100, 1000) + expect(generating).toBeCloseTo(100) + // 9s of tool wait: same token count, pause=true. + const held = tracker.observe(100, 10_000, { pause: true }) + expect(held).toBeCloseTo(generating as number) + }) + + it("does not dilute the next generating sample by a paused wait", () => { + const tracker = new TokenSpeedTracker() + tracker.observe(0, 0) + tracker.observe(100, 1000) + tracker.observe(100, 10_000, { pause: true }) + // 50 tokens in the 1s after the wait — 50 tok/s, not 50 / 10s. + const resumed = tracker.observe(150, 11_000) + expect(resumed).toBeGreaterThan(40) + expect(resumed as number).toBeLessThan(80) + }) + + it("counts a slow generating gap (no pause) as real time", () => { + const tracker = new TokenSpeedTracker() + tracker.observe(0, 0) + tracker.observe(100, 1000) + // 1s of silence while still generating (slow decode) must pull the rate. + const slowed = tracker.observe(100, 2000) + expect(slowed).toBeLessThan(80) + expect(slowed as number).toBeGreaterThan(20) + }) + + it("reports generating-only average excluding paused waits", () => { const tracker = new TokenSpeedTracker() tracker.observe(0, 0) tracker.observe(100, 1000) - expect(tracker.observe(100, 10_000)).toBeLessThan(1) + tracker.observe(100, 10_000, { pause: true }) + tracker.observe(200, 11_000) + // 200 tokens over 2s generating, not 11s wall. + expect(tracker.average()).toBeCloseTo(100) + expect(tracker.generatingElapsedMs).toBe(2000) + expect(tracker.generatingTokenCount).toBeCloseTo(200) }) it("ignores non-positive time deltas", () => { @@ -205,5 +249,6 @@ describe("TokenSpeedTracker", () => { tracker.observe(100, 1000) tracker.reset() expect(tracker.observe(0, 2000)).toBeNull() + expect(tracker.average()).toBeNull() }) }) diff --git a/src/lib/token-speed.ts b/src/lib/token-speed.ts index ebed8107bf..5210844aac 100644 --- a/src/lib/token-speed.ts +++ b/src/lib/token-speed.ts @@ -185,23 +185,38 @@ export class TokenCountAccumulator { } } +export interface TokenSpeedObserveOptions { + /** + * True while the turn is blocked on something that is not generation + * (an in-flight tool, a permission prompt, a question). Idle time is + * skipped: the clock advances so the next generating sample is not + * diluted, and the EWMA holds instead of decaying toward zero. + */ + pause?: boolean +} + /** * First-order low-pass over instantaneous token rates. Time-constant based * (rather than per-sample alpha) so the reading doesn't depend on how regularly - * the caller samples, and so a bursty thinking stream followed by a tool pause - * decays toward zero instead of pinning the old reading. + * the caller samples. + * + * Generation time is the only time that counts. A tool wait, a permission + * prompt, or any other pause is `observe(..., { pause: true })`: the last + * reading is held and the idle span is excluded from the next sample's `dt`. + * Short gaps *while generating* (batched deltas, a slow decode) still feed a + * zero instant so a genuinely slow stream is not mistaken for a fast one. * * The accumulated weight is tracked alongside the average and divided back out - * (the bias correction Adam uses). Without it the filter cold-starts from its - * first sample, and a turn's first sample is routinely near-zero — it lands on - * whatever fraction of a batched content delta had arrived by then. That made - * the reading open at `0.0` and crawl toward the truth over several seconds. - * With the correction it is the turn's cumulative average until `TAU_MS` has + * (bias correction). Without it the filter cold-starts from its first sample, + * and a turn's first sample is routinely near-zero — it lands on whatever + * fraction of a batched content delta had arrived by then. That made the + * reading open at `0.0` and crawl toward the truth over several seconds. With + * the correction it is the turn's cumulative average until `TAU_MS` has * elapsed, and an exponential window after that. */ export class TokenSpeedTracker { private static readonly TAU_MS = 1500 - /** Hold the reading back until it covers a meaningful slice of wall clock. */ + /** Hold the reading back until it covers a meaningful slice of generating time. */ private static readonly WARMUP_MS = 300 private lastTime: number | null = null @@ -209,6 +224,8 @@ export class TokenSpeedTracker { private elapsed = 0 private ewma = 0 private weight = 0 + private generatingMs = 0 + private generatingTokens = 0 reset(): void { this.lastTime = null @@ -216,11 +233,36 @@ export class TokenSpeedTracker { this.elapsed = 0 this.ewma = 0 this.weight = 0 + this.generatingMs = 0 + this.generatingTokens = 0 + } + + /** Generating-only elapsed time, excluding pauses. */ + get generatingElapsedMs(): number { + return this.generatingMs + } + + /** Tokens observed during generating windows. */ + get generatingTokenCount(): number { + return this.generatingTokens + } + + /** + * Tokens / generating-seconds for this tracker (one turn). `null` until + * enough generating time has accumulated to be meaningful. + */ + average(): number | null { + if (this.generatingMs < TokenSpeedTracker.WARMUP_MS) return null + return this.generatingTokens / (this.generatingMs / 1000) } /** Feed the cumulative estimated token count at `nowMs`; returns smoothed * tok/s, or `null` while seeding / warming up. */ - observe(totalTokens: number, nowMs: number): number | null { + observe( + totalTokens: number, + nowMs: number, + options: TokenSpeedObserveOptions = {} + ): number | null { if (this.lastTime == null) { this.lastTime = nowMs this.lastTokens = totalTokens @@ -228,10 +270,20 @@ export class TokenSpeedTracker { } const dt = nowMs - this.lastTime if (dt <= 0) return this.read() - const instant = (totalTokens - this.lastTokens) / (dt / 1000) + if (options.pause) { + // Hold the last reading and drop this span so a later generating + // sample is not tokens / (generate + wait). + this.lastTime = nowMs + this.lastTokens = totalTokens + return this.read() + } + const delta = totalTokens - this.lastTokens + const instant = delta / (dt / 1000) this.lastTime = nowMs this.lastTokens = totalTokens this.elapsed += dt + this.generatingMs += dt + if (delta > 0) this.generatingTokens += delta const alpha = 1 - Math.exp(-dt / TokenSpeedTracker.TAU_MS) this.ewma = this.ewma * (1 - alpha) + instant * alpha // Stays exactly equal to `1 - exp(-elapsed / TAU_MS)`. @@ -246,3 +298,8 @@ export class TokenSpeedTracker { return this.ewma / this.weight } } + +/** Format a tok/s reading the same way the live turn row does. */ +export function formatTokPerSec(tps: number): string { + return `${tps.toFixed(1)} tok/s` +} From 496d65d83bbc516c3731006d20439ace47704b4c Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:03:51 -0700 Subject: [PATCH 2/2] fix(chat): show average tok/s in session details and Token Usage Keep the live generating rate off the status bar. Persist the average on the session itself (Session Details) and on the Token Usage page: a generation-time headline plus tok/s on each heaviest-session row. --- src-tauri/src/commands/token_usage.rs | 72 +++++++++++++---- src-tauri/src/models/token_usage.rs | 3 + .../chat/composer-context-usage.tsx | 21 +---- .../conversations/session-details-content.tsx | 15 ++-- src/components/layout/status-bar-stats.tsx | 23 +----- .../token-usage/token-usage-page.tsx | 78 ++++++++++++------- src/i18n/messages/ar.json | 6 +- src/i18n/messages/de.json | 6 +- src/i18n/messages/en.json | 6 +- src/i18n/messages/es.json | 6 +- src/i18n/messages/fr.json | 6 +- src/i18n/messages/ja.json | 6 +- src/i18n/messages/ko.json | 6 +- src/i18n/messages/pt.json | 6 +- src/i18n/messages/zh-CN.json | 6 +- src/i18n/messages/zh-TW.json | 6 +- src/lib/token-speed.test.ts | 13 ++++ src/lib/token-speed.ts | 13 ++++ src/lib/types.ts | 3 + 19 files changed, 182 insertions(+), 119 deletions(-) diff --git a/src-tauri/src/commands/token_usage.rs b/src-tauri/src/commands/token_usage.rs index c48009ce26..550974fd35 100644 --- a/src-tauri/src/commands/token_usage.rs +++ b/src-tauri/src/commands/token_usage.rs @@ -323,6 +323,14 @@ fn bucket_key(date: NaiveDate, bucket: TokenUsageBucket) -> String { // ─── Aggregation ──────────────────────────────────────────────────────── +/// Per-conversation running sums, keyed by conversation id: +/// `(total_tokens, output_tokens, duration_ms, turns, last_activity, agent, folder_id)`. +type ConversationAcc = (u64, u64, u64, u64, DateTime, String, i32); + +/// [`ConversationAcc`] with the conversation id folded in as the first element, +/// so the top-conversations list can be sorted as one flat tuple. +type ConversationTotals = (i32, u64, u64, u64, u64, DateTime, String, i32); + /// Running sums for one group (a bucket, or one slice of a breakdown). #[derive(Debug, Default, Clone)] struct Acc { @@ -416,7 +424,7 @@ pub(crate) fn aggregate_report( let mut by_agent: HashMap = HashMap::new(); let mut by_model: HashMap = HashMap::new(); let mut heat: HashMap<(u8, u8), (u64, u64)> = HashMap::new(); - let mut per_conversation: HashMap, String, i32)> = HashMap::new(); + let mut per_conversation: HashMap = HashMap::new(); let mut active_dates: HashSet = HashSet::new(); let mut first_activity: Option> = None; let mut last_activity: Option> = None; @@ -452,6 +460,8 @@ pub(crate) fn aggregate_report( cell.1 += 1; let entry = per_conversation.entry(row.conversation_id).or_insert(( + 0, + 0, 0, 0, row.occurred_at, @@ -459,9 +469,11 @@ pub(crate) fn aggregate_report( row.folder_id, )); entry.0 += row.total_tokens.max(0) as u64; - entry.1 += 1; - if row.occurred_at > entry.2 { - entry.2 = row.occurred_at; + entry.1 += row.output_tokens.max(0) as u64; + entry.2 += row.duration_ms.max(0) as u64; + entry.3 += 1; + if row.occurred_at > entry.4 { + entry.4 = row.occurred_at; } active_dates.insert(date); @@ -518,10 +530,10 @@ pub(crate) fn aggregate_report( .collect(); heatmap.sort_by_key(|a| (a.weekday, a.hour)); - let mut top: Vec<(i32, u64, u64, DateTime, String, i32)> = per_conversation + let mut top: Vec = per_conversation .into_iter() - .map(|(id, (tokens, turns, last, agent, folder_id))| { - (id, tokens, turns, last, agent, folder_id) + .map(|(id, (tokens, output, duration_ms, turns, last, agent, folder_id))| { + (id, tokens, output, duration_ms, turns, last, agent, folder_id) }) .collect(); // Ties broken by id so the list is stable across identical requests. @@ -530,15 +542,19 @@ pub(crate) fn aggregate_report( let top_conversations: Vec = top .into_iter() .map( - |(id, tokens, turns, last, agent, folder_id)| TokenUsageConversationItem { - conversation_id: id, - // Filled by the command layer, which owns the DB handle. - title: None, - agent_type: agent, - folder_label: opts.folder_labels.get(&folder_id).cloned(), - total_tokens: tokens, - turn_count: turns, - last_activity_at: last, + |(id, tokens, output, duration_ms, turns, last, agent, folder_id)| { + TokenUsageConversationItem { + conversation_id: id, + // Filled by the command layer, which owns the DB handle. + title: None, + agent_type: agent, + folder_label: opts.folder_labels.get(&folder_id).cloned(), + total_tokens: tokens, + output_tokens: output, + turn_count: turns, + duration_ms, + last_activity_at: last, + } }, ) .collect(); @@ -1625,6 +1641,30 @@ mod tests { assert_eq!(report.by_model[0].key, UNKNOWN_MODEL); } + #[test] + fn top_conversations_carry_output_tokens_and_duration() { + let labels = HashMap::new(); + let mut heavy = row("2026-08-01T10:00:00Z", 100); + heavy.output_tokens = 40; + heavy.duration_ms = 2000; + let mut light = row("2026-08-01T11:00:00Z", 10); + light.conversation_id = 2; + light.output_tokens = 5; + light.duration_ms = 500; + + let report = aggregate_report( + &[heavy, light], + &[], + &opts(&labels, TokenUsageBucket::Day, 0, None, None), + ); + assert_eq!(report.top_conversations[0].conversation_id, 1); + assert_eq!(report.top_conversations[0].output_tokens, 40); + assert_eq!(report.top_conversations[0].duration_ms, 2000); + assert_eq!(report.top_conversations[1].conversation_id, 2); + assert_eq!(report.top_conversations[1].output_tokens, 5); + assert_eq!(report.top_conversations[1].duration_ms, 500); + } + #[test] fn heatmap_uses_local_weekday_and_hour() { let labels = HashMap::new(); diff --git a/src-tauri/src/models/token_usage.rs b/src-tauri/src/models/token_usage.rs index ee4eb8a49f..a2e71f849f 100644 --- a/src-tauri/src/models/token_usage.rs +++ b/src-tauri/src/models/token_usage.rs @@ -154,7 +154,10 @@ pub struct TokenUsageConversationItem { pub agent_type: String, pub folder_label: Option, pub total_tokens: u64, + pub output_tokens: u64, pub turn_count: u64, + /// Summed recorded generation time of the counted turns. + pub duration_ms: u64, pub last_activity_at: DateTime, } diff --git a/src/components/chat/composer-context-usage.tsx b/src/components/chat/composer-context-usage.tsx index fbe6c3412d..9f4a7ff212 100644 --- a/src/components/chat/composer-context-usage.tsx +++ b/src/components/chat/composer-context-usage.tsx @@ -8,8 +8,6 @@ import { useTabStore } from "@/contexts/tab-context" import { useConversationRuntimeStore } from "@/stores/conversation-runtime-store" import { formatTokenCount } from "@/lib/token-format" import { formatContextWindowPercent } from "@/lib/context-window" -import { useSessionOutputSpeed } from "@/hooks/use-session-output-speed" -import { formatTokPerSec } from "@/lib/token-speed" import { Popover, PopoverContent, @@ -48,7 +46,6 @@ export function ComposerContextUsage({ tabId }: { tabId: string | null }) { : null ) const usage = sessionStats?.total_usage - const outputSpeed = useSessionOutputSpeed(runtimeConversationId) const subscribeConn = useCallback( (cb: () => void) => { @@ -120,9 +117,8 @@ export function ComposerContextUsage({ tabId }: { tabId: string | null }) { } const hasTokenSection = rows.length > 0 - const hasOutputSpeed = outputSpeed != null - if (!hasContext && !hasTokenSection && !hasOutputSpeed) return null + if (!hasContext && !hasTokenSection) return null // Native hover hint mirroring the popover's headline (the popover stays for // the full breakdown on click). @@ -235,21 +231,6 @@ export function ComposerContextUsage({ tabId }: { tabId: string | null }) {
) : null} - {hasOutputSpeed ? ( -
- {t("outputSpeed")} - - {formatTokPerSec(outputSpeed.averageTps)} - -
- ) : null} ) diff --git a/src/components/conversations/session-details-content.tsx b/src/components/conversations/session-details-content.tsx index a3d641adb0..52f27116a2 100644 --- a/src/components/conversations/session-details-content.tsx +++ b/src/components/conversations/session-details-content.tsx @@ -22,7 +22,7 @@ import { getFolderConversation } from "@/lib/api" import { useCopiedFlag } from "@/hooks/use-copied-flag" import { pickModelFromTurns } from "./active-session-details" import { useSessionOutputSpeed } from "@/hooks/use-session-output-speed" -import { formatTokPerSec } from "@/lib/token-speed" +import { averageOutputTps, formatTokPerSec } from "@/lib/token-speed" import { AgentIcon } from "@/components/agent-icon" import { ConversationStatusDot } from "./conversation-status-dot" @@ -316,7 +316,12 @@ export function SessionDetailsContent({ ctxMax ) const durationMs = resolveSessionDurationMs(summary, stats) - const outputSpeed = useSessionOutputSpeed(summary.id) + const liveOutputSpeed = useSessionOutputSpeed(summary.id) + const recordedOutputTps = averageOutputTps( + usage?.output_tokens ?? 0, + stats?.total_duration_ms ?? 0 + ) + const outputTps = liveOutputSpeed?.averageTps ?? recordedOutputTps // Never coerce an unknown `used` to 0 — some parsers infer the model's // context cap without any usage figure, so render "— / max" rather than a // bogus "0 / max". @@ -329,7 +334,7 @@ export function SessionDetailsContent({ ? formatTokenCount(ctxUsed) : null const hasTokenInfo = - outputSpeed != null || + outputTps != null || (stats != null && (totalTokens != null || usage != null || @@ -440,13 +445,13 @@ export function SessionDetailsContent({ {formatDuration(durationMs)} )} - {outputSpeed != null && ( + {outputTps != null && ( - {formatTokPerSec(outputSpeed.averageTps)} + {formatTokPerSec(outputTps)} )} diff --git a/src/components/layout/status-bar-stats.tsx b/src/components/layout/status-bar-stats.tsx index 5b0c88540a..914d5a026f 100644 --- a/src/components/layout/status-bar-stats.tsx +++ b/src/components/layout/status-bar-stats.tsx @@ -1,13 +1,10 @@ "use client" -import { ChartNoAxesColumn, MonitorCloud, Plane } from "lucide-react" +import { ChartNoAxesColumn, MonitorCloud } from "lucide-react" import { useTranslations } from "next-intl" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { useRemoteConnection } from "@/contexts/remote-connection-context" import { useWorkbenchRoute } from "@/contexts/workbench-route-context" -import { useTabStore } from "@/stores/tab-store" -import { useSessionOutputSpeed } from "@/hooks/use-session-output-speed" -import { formatTokPerSec } from "@/lib/token-speed" import { cn } from "@/lib/utils" /** @@ -30,15 +27,8 @@ export function StatusBarStats() { // codeg-server); local windows have no RemoteConnection in context. const remoteConnection = useRemoteConnection()?.connection ?? null const { routeId, setRoute } = useWorkbenchRoute() - const speedConversationId = useTabStore((s) => { - const tab = s.tabs.find((item) => item.id === s.activeTabId) - if (!tab || tab.kind !== "conversation") return null - return tab.runtimeConversationId ?? tab.conversationId ?? null - }) - const outputSpeed = useSessionOutputSpeed(speedConversationId) - const tSpeed = useTranslations("Folder.statusBar.tokens") - if (!remoteConnection && !stats && outputSpeed == null) return null + if (!remoteConnection && !stats) return null return (
@@ -69,15 +59,6 @@ export function StatusBarStats() { )} - {outputSpeed != null && ( - - - )}
) } diff --git a/src/components/token-usage/token-usage-page.tsx b/src/components/token-usage/token-usage-page.tsx index 841ff8e90f..03977f64ff 100644 --- a/src/components/token-usage/token-usage-page.tsx +++ b/src/components/token-usage/token-usage-page.tsx @@ -54,6 +54,7 @@ import { toErrorMessage } from "@/lib/app-error" import { getAgentLabel } from "@/lib/custom-agents" import { subscribe } from "@/lib/platform" import { formatTokenCount } from "@/lib/token-format" +import { averageOutputTps, formatTokPerSec } from "@/lib/token-speed" import { averagePerActiveDay, averagePerConversation, @@ -561,6 +562,9 @@ export function TokenUsagePage() { const totals = report?.totals const cache = totals ? cacheHitRate(totals) : null + const averageTps = totals + ? averageOutputTps(totals.output_tokens, totals.duration_ms) + : null const heat = useMemo( () => buildHeatMatrix(report?.heatmap ?? []), [report?.heatmap] @@ -1204,9 +1208,15 @@ export function TokenUsagePage() { className="border-s border-t lg:border-t-0" label={t("tileGenTime")} value={formatDuration(totals.duration_ms)} - hint={`${t("avgPerActiveDay")} ${formatTokenCount( - Math.round(averagePerActiveDay(totals)) - )}`} + hint={ + averageTps != null + ? t("avgOutputSpeed", { + speed: formatTokPerSec(averageTps), + }) + : `${t("avgPerActiveDay")} ${formatTokenCount( + Math.round(averagePerActiveDay(totals)) + )}` + } /> @@ -1384,31 +1394,45 @@ export function TokenUsagePage() {

) : (
    - {report.top_conversations.map((c, i) => ( -
  1. -
  2. - {String(i + 1).padStart(2, "0")} - - - {c.title || t("untitledSession")} - - - {c.folder_label} - - - {getAgentLabel(c.agent_type as AgentType)} - - - {formatTokenCount(c.total_tokens)} - -
  3. - ))} + + + {c.title || t("untitledSession")} + + + {c.folder_label} + + + {getAgentLabel(c.agent_type as AgentType)} + + + {formatTokenCount(c.total_tokens)} + + {sessionTps != null && ( + + {formatTokPerSec(sessionTps)} + + )} + + ) + })}
)} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 7edf7de780..ccf90c34f7 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1926,9 +1926,7 @@ "output": "إخراج", "cacheRead": "قراءة الكاش", "cacheWrite": "كتابة الكاش", - "total": "الإجمالي", - "outputSpeed": "سرعة الإخراج", - "outputSpeedTooltip": "المتوسط أثناء كتابة النموذج. وقت انتظار الأدوات غير محسوب." + "total": "الإجمالي" }, "quickActions": { "title": "إجراءات سريعة", @@ -5095,6 +5093,8 @@ "tileTurns": "الأدوار", "tileActiveDays": "الأيام النشطة", "tileGenTime": "زمن التوليد", + "avgOutputSpeed": "متوسط {speed}", + "outputSpeedTooltip": "المتوسط أثناء كتابة النموذج. وقت انتظار الأدوات غير محسوب.", "vsPrevious": "مقارنة بالفترة السابقة", "deltaNew": "جديد", "trendTitle": "الاستهلاك عبر الزمن", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index f29bcc7ba0..5d5a8a6f8d 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1926,9 +1926,7 @@ "output": "Ausgabe", "cacheRead": "Cache lesen", "cacheWrite": "Cache schreiben", - "total": "Gesamt", - "outputSpeed": "Ausgabegeschwindigkeit", - "outputSpeedTooltip": "Durchschnitt, während das Modell schreibt. Wartezeit auf Tools zählt nicht." + "total": "Gesamt" }, "quickActions": { "title": "Schnellaktionen", @@ -5095,6 +5093,8 @@ "tileTurns": "Turns", "tileActiveDays": "Aktive Tage", "tileGenTime": "Generierungszeit", + "avgOutputSpeed": "Ø {speed}", + "outputSpeedTooltip": "Durchschnitt, während das Modell schreibt. Wartezeit auf Tools zählt nicht.", "vsPrevious": "ggü. Vorperiode", "deltaNew": "neu", "trendTitle": "Verbrauch im Zeitverlauf", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 0762f4e430..2e196e24d5 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1926,9 +1926,7 @@ "output": "Output", "cacheRead": "Cache Read", "cacheWrite": "Cache Write", - "total": "Total", - "outputSpeed": "Output speed", - "outputSpeedTooltip": "Average while the model is writing. Time spent waiting on tools is not counted." + "total": "Total" }, "quickActions": { "title": "Quick actions", @@ -5095,6 +5093,8 @@ "tileTurns": "Turns", "tileActiveDays": "Active days", "tileGenTime": "Generation time", + "avgOutputSpeed": "Avg. {speed}", + "outputSpeedTooltip": "Average while the model is writing. Time spent waiting on tools is not counted.", "vsPrevious": "vs. previous period", "deltaNew": "new", "trendTitle": "Usage over time", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 74248b08ed..5a72be1491 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1926,9 +1926,7 @@ "output": "Salida", "cacheRead": "Lectura de caché", "cacheWrite": "Escritura de caché", - "total": "Total de tokens", - "outputSpeed": "Velocidad de salida", - "outputSpeedTooltip": "Media mientras el modelo escribe. El tiempo de espera de herramientas no cuenta." + "total": "Total de tokens" }, "quickActions": { "title": "Acciones rápidas", @@ -5095,6 +5093,8 @@ "tileTurns": "Turnos", "tileActiveDays": "Días activos", "tileGenTime": "Tiempo de generación", + "avgOutputSpeed": "Media {speed}", + "outputSpeedTooltip": "Media mientras el modelo escribe. El tiempo de espera de herramientas no cuenta.", "vsPrevious": "frente al periodo anterior", "deltaNew": "nuevo", "trendTitle": "Uso en el tiempo", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 6fcaf37180..89507fe0e1 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1926,9 +1926,7 @@ "output": "Sortie", "cacheRead": "Lecture cache", "cacheWrite": "Écriture cache", - "total": "Total des tokens", - "outputSpeed": "Vitesse de sortie", - "outputSpeedTooltip": "Moyenne pendant que le modèle écrit. L'attente des outils n'est pas comptée." + "total": "Total des tokens" }, "quickActions": { "title": "Actions rapides", @@ -5095,6 +5093,8 @@ "tileTurns": "Tours", "tileActiveDays": "Jours actifs", "tileGenTime": "Temps de génération", + "avgOutputSpeed": "Moy. {speed}", + "outputSpeedTooltip": "Moyenne pendant que le modèle écrit. L'attente des outils n'est pas comptée.", "vsPrevious": "vs période précédente", "deltaNew": "nouveau", "trendTitle": "Consommation dans le temps", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 5aa9f508f1..99391af855 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1926,9 +1926,7 @@ "output": "出力", "cacheRead": "キャッシュ読み取り", "cacheWrite": "キャッシュ書き込み", - "total": "合計", - "outputSpeed": "出力速度", - "outputSpeedTooltip": "モデルが書いている間の平均速度。ツール待ち時間は含めません。" + "total": "合計" }, "quickActions": { "title": "クイック操作", @@ -5095,6 +5093,8 @@ "tileTurns": "ターン数", "tileActiveDays": "稼働日数", "tileGenTime": "生成時間", + "avgOutputSpeed": "平均 {speed}", + "outputSpeedTooltip": "モデルが書いている間の平均速度。ツール待ち時間は含めません。", "vsPrevious": "前期間との比較", "deltaNew": "新規", "trendTitle": "使用量の推移", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 1942cdff57..844f70a1c5 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1926,9 +1926,7 @@ "output": "출력", "cacheRead": "캐시 읽기", "cacheWrite": "캐시 쓰기", - "total": "합계", - "outputSpeed": "출력 속도", - "outputSpeedTooltip": "모델이 쓰는 동안의 평균 속도. 도구 대기 시간은 포함하지 않습니다." + "total": "합계" }, "quickActions": { "title": "빠른 작업", @@ -5095,6 +5093,8 @@ "tileTurns": "대화 턴", "tileActiveDays": "활동 일수", "tileGenTime": "생성 시간", + "avgOutputSpeed": "평균 {speed}", + "outputSpeedTooltip": "모델이 쓰는 동안의 평균 속도. 도구 대기 시간은 포함하지 않습니다.", "vsPrevious": "이전 기간 대비", "deltaNew": "신규", "trendTitle": "사용량 추이", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index b0c64af681..5200ab6971 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1926,9 +1926,7 @@ "output": "Saída", "cacheRead": "Leitura de cache", "cacheWrite": "Gravação de cache", - "total": "Total de tokens", - "outputSpeed": "Velocidade de saída", - "outputSpeedTooltip": "Média enquanto o modelo escreve. A espera por ferramentas não conta." + "total": "Total de tokens" }, "quickActions": { "title": "Ações rápidas", @@ -5095,6 +5093,8 @@ "tileTurns": "Turnos", "tileActiveDays": "Dias ativos", "tileGenTime": "Tempo de geração", + "avgOutputSpeed": "Média {speed}", + "outputSpeedTooltip": "Média enquanto o modelo escreve. A espera por ferramentas não conta.", "vsPrevious": "vs. período anterior", "deltaNew": "novo", "trendTitle": "Uso ao longo do tempo", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index caf690e57e..1fcffeb79b 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1926,9 +1926,7 @@ "output": "输出", "cacheRead": "缓存读取", "cacheWrite": "缓存写入", - "total": "总计", - "outputSpeed": "输出速度", - "outputSpeedTooltip": "模型正在写时的平均速度。等待工具完成的时间不计入。" + "total": "总计" }, "quickActions": { "title": "快捷操作", @@ -5095,6 +5093,8 @@ "tileTurns": "对话轮次", "tileActiveDays": "活跃天数", "tileGenTime": "生成时长", + "avgOutputSpeed": "平均 {speed}", + "outputSpeedTooltip": "模型正在写时的平均速度。等待工具完成的时间不计入。", "vsPrevious": "对比上一周期", "deltaNew": "新增", "trendTitle": "用量趋势", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 15a17ef335..51932dd1e6 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1926,9 +1926,7 @@ "output": "輸出", "cacheRead": "快取讀取", "cacheWrite": "快取寫入", - "total": "總計", - "outputSpeed": "輸出速度", - "outputSpeedTooltip": "模型正在寫時的平均速度。等待工具完成的時間不計入。" + "total": "總計" }, "quickActions": { "title": "快捷操作", @@ -5095,6 +5093,8 @@ "tileTurns": "對話輪次", "tileActiveDays": "活躍天數", "tileGenTime": "生成時長", + "avgOutputSpeed": "平均 {speed}", + "outputSpeedTooltip": "模型正在寫時的平均速度。等待工具完成的時間不計入。", "vsPrevious": "對比上一週期", "deltaNew": "新增", "trendTitle": "用量趨勢", diff --git a/src/lib/token-speed.test.ts b/src/lib/token-speed.test.ts index e1834ff2f3..5b52025959 100644 --- a/src/lib/token-speed.test.ts +++ b/src/lib/token-speed.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest" import { + averageOutputTps, countChars, estimateTokens, formatTokPerSec, @@ -14,6 +15,18 @@ describe("formatTokPerSec", () => { }) }) +describe("averageOutputTps", () => { + it("divides output tokens by recorded generation seconds", () => { + expect(averageOutputTps(100, 1000)).toBeCloseTo(100) + }) + + it("returns null until both sides are meaningful", () => { + expect(averageOutputTps(0, 1000)).toBeNull() + expect(averageOutputTps(100, 0)).toBeNull() + expect(averageOutputTps(100, 200)).toBeNull() + }) +}) + describe("estimateTokens", () => { it("counts latin at 4 chars per token", () => { expect(estimateTokens("hello world")).toBeCloseTo(10 / 4) diff --git a/src/lib/token-speed.ts b/src/lib/token-speed.ts index 5210844aac..b4eb691038 100644 --- a/src/lib/token-speed.ts +++ b/src/lib/token-speed.ts @@ -303,3 +303,16 @@ export class TokenSpeedTracker { export function formatTokPerSec(tps: number): string { return `${tps.toFixed(1)} tok/s` } + +/** + * Session/dashboard average: output tokens over recorded generation time. + * `null` until both sides are meaningful, so a 0ms or 0-output row does not + * render as `0.0 tok/s`. + */ +export function averageOutputTps( + outputTokens: number, + durationMs: number +): number | null { + if (!(outputTokens > 0) || durationMs < 300) return null + return outputTokens / (durationMs / 1000) +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 390454c217..d81a2fcaae 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -2042,7 +2042,10 @@ export interface TokenUsageConversationItem { agent_type: string folder_label: string | null total_tokens: number + output_tokens: number turn_count: number + /** Summed recorded generation time of the counted turns. */ + duration_ms: number last_activity_at: string }