Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .scaffold/memory/log/2026-08-24-issue-35-token-stats.md
Original file line number Diff line number Diff line change
@@ -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).
8 changes: 8 additions & 0 deletions frontend/src/components/chat/MessageItem.jsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -127,6 +131,10 @@ const MessageItem = memo(({ message, onStop, onRegenerate }) => {

{sources.length > 0 && <SourceCitations sources={sources} />}

{statsLine && (
<p className="text-theme-text-muted mt-3 text-xs opacity-60">{statsLine}</p>
)}

{showActions && (
<div className="mt-4 flex justify-end gap-2">
{onStop && (
Expand Down
47 changes: 42 additions & 5 deletions frontend/src/hooks/useChatStream.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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)
);
Expand Down Expand Up @@ -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) => ({
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/messageShape.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function toCanonicalMessage(msg) {
parts,
fileIds: msg.fileIds || [],
model: msg.model || null,
metadata: msg.metadata || null,
createdAt: getMessageTimestamp(msg),
};
}
39 changes: 39 additions & 0 deletions frontend/src/lib/tokenStats.js
Original file line number Diff line number Diff line change
@@ -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(" · ");
}
70 changes: 70 additions & 0 deletions frontend/src/lib/tokenStats.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
16 changes: 16 additions & 0 deletions frontend/src/pages/authenticated/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="bg-theme-canvas flex h-full flex-col">
Expand Down Expand Up @@ -100,6 +102,20 @@ const Settings = () => {
aria-label="Show line numbers in code blocks"
/>
</div>

<div className="border-theme-surface mt-4 flex items-center justify-between border-t pt-4">
<div>
<label className="text-theme-text block text-sm font-medium">Token Stats</label>
<p className="text-theme-text-muted mt-0.5 text-sm">
Show speed and token counts under assistant replies
</p>
</div>
<Switch
value={showTokenStats}
onChange={setShowTokenStats}
aria-label="Show token stats under assistant replies"
/>
</div>
</div>

{/* Typography */}
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/state/useThemeStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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",
Expand All @@ -251,6 +258,7 @@ export const useThemeStore = create(
chatFont: state.chatFont,
chatFontSize: state.chatFontSize,
showCodeLineNumbers: state.showCodeLineNumbers,
showTokenStats: state.showTokenStats,
}),
}
)
Expand Down
10 changes: 10 additions & 0 deletions server/src/routes/chats.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading