From a51e5ea6ca90b1b8f63857bb5095ff9ba6a67e57 Mon Sep 17 00:00:00 2001 From: Mike Key Date: Mon, 24 Aug 2026 12:04:54 -0600 Subject: [PATCH 1/2] =?UTF-8?q?feat(chat):=20token=20stats=20=E2=80=94=20t?= =?UTF-8?q?ok/s,=20TTFT=20footer=20stat=20with=20settings=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/chat/MessageItem.jsx | 8 +++ frontend/src/hooks/useChatStream.js | 47 +++++++++++-- frontend/src/lib/messageShape.js | 1 + frontend/src/lib/tokenStats.js | 39 +++++++++++ frontend/src/lib/tokenStats.test.js | 70 +++++++++++++++++++ frontend/src/pages/authenticated/Settings.jsx | 16 +++++ frontend/src/state/useThemeStore.js | 8 +++ server/src/routes/chats.js | 10 +++ 8 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/tokenStats.js create mode 100644 frontend/src/lib/tokenStats.test.js diff --git a/frontend/src/components/chat/MessageItem.jsx b/frontend/src/components/chat/MessageItem.jsx index 57cc6f5..abc3e22 100644 --- a/frontend/src/components/chat/MessageItem.jsx +++ b/frontend/src/components/chat/MessageItem.jsx @@ -1,5 +1,7 @@ import { MarkdownContent } from "@/components/markdown/MarkdownRenderer"; import { extractTextContent } from "@/lib/messageUtils"; +import { formatTokenStats } from "@/lib/tokenStats"; +import { useThemeStore } from "@/state/useThemeStore"; import { memo } from "@preact/compat"; import { AlertTriangle, Brain, ChevronDown, Sparkles } from "lucide-preact"; import MessageAttachment from "./MessageAttachment"; @@ -41,6 +43,7 @@ const parseThinkingBlocks = (text) => { }; const MessageItem = memo(({ message, onStop, onRegenerate }) => { + const showTokenStats = useThemeStore((state) => state.showTokenStats); const isUser = message.role === "user"; const rawContent = extractTextContent(message); const { thinking, content } = isUser @@ -55,6 +58,7 @@ const MessageItem = memo(({ message, onStop, onRegenerate }) => { (p) => p.type === "tool-invocation" && p.state === "call" ); const sources = isUser ? [] : extractSources(message.parts); + const statsLine = !isUser && showTokenStats ? formatTokenStats(message.metadata?.stats) : ""; const toolErrors = isUser ? [] : message.parts?.filter( @@ -127,6 +131,10 @@ const MessageItem = memo(({ message, onStop, onRegenerate }) => { {sources.length > 0 && } + {statsLine && ( +

{statsLine}

+ )} + {showActions && (
{onStop && ( diff --git a/frontend/src/hooks/useChatStream.js b/frontend/src/hooks/useChatStream.js index 11e29eb..81d86a6 100644 --- a/frontend/src/hooks/useChatStream.js +++ b/frontend/src/hooks/useChatStream.js @@ -1,7 +1,13 @@ import { DefaultChatTransport } from "ai"; import { useChat as useAIChat } from "@ai-sdk/react"; -import { useMemo, useRef } from "preact/hooks"; -import { deduplicateMessages, ensureTimestamp, getMessageTimestamp } from "@/lib/messageUtils"; +import { useEffect, useMemo, useRef } from "preact/hooks"; +import { + deduplicateMessages, + ensureTimestamp, + getMessageTimestamp, + hasTextContent, +} from "@/lib/messageUtils"; +import { buildTokenStats } from "@/lib/tokenStats"; import { MESSAGE_CONSTANTS } from "@faster-chat/shared"; function trimMessageHistory(messages) { @@ -44,6 +50,11 @@ export function useChatStream({ const messageTimestampsRef = useRef(new Map()); + const timingRef = useRef({ sendAt: null, ttftMs: null }); + const resetTiming = () => { + timingRef.current = { sendAt: performance.now(), ttftMs: null }; + }; + const formattedMessages = (persistedMessages ?? []).map((msg) => ensureTimestamp(msg, messageTimestampsRef) ); @@ -98,19 +109,39 @@ export function useChatStream({ const toolParts = message.parts?.filter((p) => p.type === "tool-invocation" && p.state === "result") || []; - const metadata = toolParts.length > 0 ? { toolParts } : null; + + const { sendAt, ttftMs } = timingRef.current; + const stats = buildTokenStats({ + usage: message.metadata?.usage, + ttftMs, + durationMs: sendAt != null ? Math.round(performance.now() - sendAt) : null, + }); + + const metadata = { + ...(toolParts.length > 0 ? { toolParts } : {}), + ...(stats ? { stats } : {}), + }; if (onMessageComplete && content.trim()) { await onMessageComplete({ id: message.id, content, - metadata, + metadata: Object.keys(metadata).length > 0 ? metadata : null, createdAt: getMessageTimestamp(message), }); } }, }); + useEffect(() => { + const { sendAt, ttftMs } = timingRef.current; + if (sendAt == null || ttftMs != null) return; + const last = streamingMessages[streamingMessages.length - 1]; + if (last?.role === "assistant" && hasTextContent(last)) { + timingRef.current.ttftMs = Math.round(performance.now() - sendAt); + } + }, [streamingMessages]); + const isStreaming = status === "streaming" || status === "submitted"; const streamingMessagesWithModel = streamingMessages.map((msg) => ({ @@ -131,13 +162,19 @@ export function useChatStream({ }; if (fileIds.length > 0) message.fileIds = fileIds; messageTimestampsRef.current.set(message.id, message.createdAt); + resetTiming(); await sendMessage(message); } + async function regenerateWithTiming() { + resetTiming(); + await regenerate(); + } + return { messages, send, - regenerate, + regenerate: regenerateWithTiming, stop, status, error, diff --git a/frontend/src/lib/messageShape.js b/frontend/src/lib/messageShape.js index f09ae58..53b89d6 100644 --- a/frontend/src/lib/messageShape.js +++ b/frontend/src/lib/messageShape.js @@ -12,6 +12,7 @@ export function toCanonicalMessage(msg) { parts, fileIds: msg.fileIds || [], model: msg.model || null, + metadata: msg.metadata || null, createdAt: getMessageTimestamp(msg), }; } diff --git a/frontend/src/lib/tokenStats.js b/frontend/src/lib/tokenStats.js new file mode 100644 index 0000000..11b711f --- /dev/null +++ b/frontend/src/lib/tokenStats.js @@ -0,0 +1,39 @@ +/** + * Token performance stats for assistant messages. + * Usage comes from the provider via stream metadata; timing is measured client-side. + */ + +export function buildTokenStats({ usage, ttftMs, durationMs }) { + const outputTokens = usage?.outputTokens; + if (!outputTokens || !durationMs) return null; + + const generationMs = ttftMs != null ? Math.max(durationMs - ttftMs, 1) : durationMs; + + return { + inputTokens: usage.inputTokens ?? null, + outputTokens, + ttftMs: ttftMs ?? null, + durationMs, + tokensPerSecond: Math.round((outputTokens / (generationMs / 1000)) * 10) / 10, + }; +} + +function formatDuration(ms) { + return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`; +} + +export function formatTokenStats(stats) { + if (!stats) return ""; + + const parts = []; + if (stats.ttftMs != null) parts.push(`TTFT ${formatDuration(stats.ttftMs)}`); + if (stats.tokensPerSecond != null) parts.push(`${stats.tokensPerSecond} tok/s`); + if (stats.outputTokens != null) { + parts.push( + stats.inputTokens != null + ? `${stats.inputTokens.toLocaleString("en-US")} in · ${stats.outputTokens.toLocaleString("en-US")} out` + : `${stats.outputTokens.toLocaleString("en-US")} tokens` + ); + } + return parts.join(" · "); +} diff --git a/frontend/src/lib/tokenStats.test.js b/frontend/src/lib/tokenStats.test.js new file mode 100644 index 0000000..813bbd7 --- /dev/null +++ b/frontend/src/lib/tokenStats.test.js @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { buildTokenStats, formatTokenStats } from "./tokenStats"; + +describe("buildTokenStats", () => { + it("returns null without output tokens", () => { + expect(buildTokenStats({ usage: { inputTokens: 10 }, ttftMs: 100, durationMs: 1000 })).toBe( + null + ); + expect(buildTokenStats({ usage: null, ttftMs: 100, durationMs: 1000 })).toBe(null); + }); + + it("returns null without a duration", () => { + expect(buildTokenStats({ usage: { outputTokens: 50 }, ttftMs: 100, durationMs: null })).toBe( + null + ); + }); + + it("computes tokens/sec over generation time (excludes TTFT)", () => { + const stats = buildTokenStats({ + usage: { inputTokens: 200, outputTokens: 100, totalTokens: 300 }, + ttftMs: 500, + durationMs: 3000, + }); + expect(stats.tokensPerSecond).toBe(40); + expect(stats.ttftMs).toBe(500); + expect(stats.inputTokens).toBe(200); + expect(stats.outputTokens).toBe(100); + }); + + it("falls back to full duration when TTFT is missing", () => { + const stats = buildTokenStats({ + usage: { outputTokens: 50 }, + ttftMs: null, + durationMs: 2000, + }); + expect(stats.tokensPerSecond).toBe(25); + expect(stats.ttftMs).toBe(null); + }); + + it("guards against zero generation time", () => { + const stats = buildTokenStats({ + usage: { outputTokens: 10 }, + ttftMs: 1000, + durationMs: 1000, + }); + expect(stats.tokensPerSecond).toBe(10000); + }); +}); + +describe("formatTokenStats", () => { + it("formats sub-second TTFT in ms and seconds above", () => { + expect(formatTokenStats({ ttftMs: 420, outputTokens: 100 })).toBe("TTFT 420ms · 100 tokens"); + expect(formatTokenStats({ ttftMs: 2500, outputTokens: 100 })).toContain("TTFT 2.5s"); + }); + + it("shows in/out counts when both are known", () => { + const line = formatTokenStats({ + ttftMs: 300, + tokensPerSecond: 42.1, + inputTokens: 1240, + outputTokens: 156, + }); + expect(line).toBe("TTFT 300ms · 42.1 tok/s · 1,240 in · 156 out"); + }); + + it("omits missing values and handles null stats", () => { + expect(formatTokenStats(null)).toBe(""); + expect(formatTokenStats({ tokensPerSecond: 12.5 })).toBe("12.5 tok/s"); + }); +}); diff --git a/frontend/src/pages/authenticated/Settings.jsx b/frontend/src/pages/authenticated/Settings.jsx index bf94018..51a2730 100644 --- a/frontend/src/pages/authenticated/Settings.jsx +++ b/frontend/src/pages/authenticated/Settings.jsx @@ -30,6 +30,8 @@ const Settings = () => { const { returnToChat, isReturning } = useReturnToChat(); const showCodeLineNumbers = useThemeStore((state) => state.showCodeLineNumbers); const setShowCodeLineNumbers = useThemeStore((state) => state.setShowCodeLineNumbers); + const showTokenStats = useThemeStore((state) => state.showTokenStats); + const setShowTokenStats = useThemeStore((state) => state.setShowTokenStats); return (
@@ -100,6 +102,20 @@ const Settings = () => { aria-label="Show line numbers in code blocks" />
+ +
+
+ +

+ Show speed and token counts under assistant replies +

+
+ +
{/* Typography */} diff --git a/frontend/src/state/useThemeStore.js b/frontend/src/state/useThemeStore.js index 49759b7..2c49a88 100644 --- a/frontend/src/state/useThemeStore.js +++ b/frontend/src/state/useThemeStore.js @@ -150,6 +150,8 @@ export const useThemeStore = create( chatFontSize: "medium", // Code blocks showCodeLineNumbers: false, + // Assistant message token stats + showTokenStats: false, // Initialize theme on app start initializeTheme: async () => { @@ -241,6 +243,11 @@ export const useThemeStore = create( setShowCodeLineNumbers: (show) => { set({ showCodeLineNumbers: Boolean(show) }); }, + + // Toggle token stats under assistant messages + setShowTokenStats: (show) => { + set({ showTokenStats: Boolean(show) }); + }, }), { name: "theme-store-v3", @@ -251,6 +258,7 @@ export const useThemeStore = create( chatFont: state.chatFont, chatFontSize: state.chatFontSize, showCodeLineNumbers: state.showCodeLineNumbers, + showTokenStats: state.showTokenStats, }), } ) diff --git a/server/src/routes/chats.js b/server/src/routes/chats.js index 1780eb2..299af47 100644 --- a/server/src/routes/chats.js +++ b/server/src/routes/chats.js @@ -478,6 +478,16 @@ chatsRouter.post( console.error("Stream error:", error); return humanizeProviderError(error, providerLabel); }, + messageMetadata: ({ part }) => { + if (part.type !== "finish" || !part.totalUsage) return undefined; + return { + usage: { + inputTokens: part.totalUsage.inputTokens ?? null, + outputTokens: part.totalUsage.outputTokens ?? null, + totalTokens: part.totalUsage.totalTokens ?? null, + }, + }; + }, }); } catch (error) { console.error("Completion error:", error); From 999533b7c339d39c1a2ae82dbcaed1b9d4140066 Mon Sep 17 00:00:00 2001 From: Mike Key Date: Mon, 24 Aug 2026 12:18:31 -0600 Subject: [PATCH 2/2] chore(scaffold): closeout log for issue #35 token stats --- .../log/2026-08-24-issue-35-token-stats.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .scaffold/memory/log/2026-08-24-issue-35-token-stats.md diff --git a/.scaffold/memory/log/2026-08-24-issue-35-token-stats.md b/.scaffold/memory/log/2026-08-24-issue-35-token-stats.md new file mode 100644 index 0000000..aba8191 --- /dev/null +++ b/.scaffold/memory/log/2026-08-24-issue-35-token-stats.md @@ -0,0 +1,31 @@ +# Closeout — issue #35 token stats (2026-08-24) + +- **Origin:** GitHub Issue #35 — token stats display (tok/s, TTFT), feature, frontend-leaning. +- **Delivery:** branch `feat/token-stats`, PR opened against #35. Key files: + `server/src/routes/chats.js` (messageMetadata on finish), `frontend/src/hooks/useChatStream.js` + (timing + stats into message metadata), `frontend/src/lib/tokenStats.js`, `MessageItem.jsx` + (footer stat line), `useThemeStore.js` + `Settings.jsx` (toggle), `lib/messageShape.js` + (carry `metadata` through `toCanonicalMessage` — without this, stats would not survive reload). +- **Intent changes:** none (no review decisions at closeout time). +- **Requirements satisfied:** + - Usage captured from stream result: server attaches `{usage}` as message metadata on the + `finish` part via `toUIMessageStreamResponse({ messageMetadata })`. + - TTFT measured client-side (send → first assistant text delta) in `useChatStream`. + - Stats persisted in message `metadata.stats` (existing metadata persistence path reused). + - Footer stat line gated on `showTokenStats` setting, default off; disabled = nothing renders, + so zero layout shift. + - Stats survive reload (metadata carried through canonical message shape + DB roundtrip). +- **Remaining scope:** none. Non-goals (cost tracking, historical charts) untouched. +- **Verification ledger:** `bun run test` frontend (52 pass, incl. 7 new tokenStats tests); + `bun test` server (420 pass); `bun run format`; `bun run build`. No e2e run — UI is a + conditional text line, unit tests cover the risky logic (stats math/formatting). +- **Proof:** `frontend/src/lib/tokenStats.test.js` covers buildTokenStats (null guards, + tok/s excluding TTFT, zero-generation guard) and formatTokenStats. +- **Open / next:** PR review. Verify against a live Ollama vs cloud provider for the issue's + acceptance check. +- **Derived decisions (ratify in PR?):** + - Toggle lives in `useThemeStore` (localStorage, per-browser) matching the + `showCodeLineNumbers` pattern, not server-side app settings (which are appName/logoIcon only). + - tok/s = outputTokens / (duration − TTFT) — generation rate, excluding TTFT. + - Server sends raw usage; client composes `stats` (usage + timing) so timing stays honest + (measured where it happens).