feat: Statistik-Leiste mit Tokens, tok/s, TTFT und Kontext-Füllstand

Zeile über dem Composer mit den Messwerten der letzten Antwort plus
Session-Summe. Die Zahlen sind gemessen, nicht geschätzt.

Quellen:
- Ollama: prompt_eval_count, eval_count, eval_duration aus dem
  Abschluss-Chunk; TTFT wird beim ersten Content- oder Thinking-Chunk
  gestoppt
- OpenRouter: usage-Block, dafür stream_options.include_usage. Der Block
  kommt erst nach finish_reason, deshalb wird nur noch abgekürzt, wenn er
  schon da ist — sonst gingen die Tokenzahlen verloren
- Kosten aus promptPrice/completionPrice der Modellliste

Werkzeugrunden sind mehrere Modellaufrufe für eine sichtbare Antwort:
Erzeugtes wird summiert, der Prompt-Stand ist der der letzten Runde,
TTFT zählt nur die erste.

Kontext-Füllstand gegen GET /api/ps statt gegen die deklarierte Länge des
Modells. Das ist nicht dasselbe: qwen3.5 deklariert 262144, geladen läuft
es mit 4096. Gegen die deklarierte Länge stünde der Balken bei 1 %,
während vorne längst abgeschnitten wird.

onStats darf asynchron sein und wird vor onDone abgewartet — der
/api/ps-Lookup schob die Stats-Nachricht sonst hinter das done, und der
Client verwarf sie.

Während des Streamens zählt die Leiste eingehende Chunks als Näherung
(mit ≈ markiert); gemessen liegt das bei Prosa innerhalb weniger Prozent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AUP4R3rgq4XwVs4bVf7uh
This commit is contained in:
Jeuner 2026-08-28 14:42:45 +02:00
parent dd0ee30165
commit 1297ffcc56
9 changed files with 455 additions and 8 deletions

View file

@ -6,6 +6,7 @@ import { ChatMessage } from "./components/ChatMessage";
import { Composer } from "./components/Composer";
import { MemoryPanel } from "./components/MemoryPanel";
import { ToolConfirm } from "./components/ToolConfirm";
import { StatsBar } from "./components/StatsBar";
import type { OpenRouterModel, OllamaModel } from "./types";
const VOICE_KEY = "oxagenttwo.voiceMode";
@ -49,6 +50,22 @@ export default function App() {
}
}, [settingsOpen, chat.options.provider, orModels.length, ollamaModels.length]);
// Bei OpenRouter kennt nur der Client Preise und Kontextlänge — sie stehen
// in der Modellliste, nicht in der Antwort des Servers.
const activeOrModel =
chat.options.provider === "openrouter"
? orModels.find((m) => m.id === chat.options.openrouterModel)
: undefined;
const setModelPricing = chat.setModelPricing;
useEffect(() => {
setModelPricing(
activeOrModel
? { prompt: activeOrModel.promptPrice, completion: activeOrModel.completionPrice }
: null,
);
}, [activeOrModel, setModelPricing]);
const voiceModeRef = useRef(voiceMode);
const streamingRef = useRef(false);
const awaitingDrainRef = useRef(false);
@ -446,6 +463,13 @@ export default function App() {
</div>
)}
<StatsBar
stats={chat.stats}
live={chat.live}
totals={chat.totals}
contextLength={activeOrModel?.contextLength}
/>
<Composer
streaming={chat.streaming}
disabled={!chat.activeId}

View file

@ -0,0 +1,87 @@
import type { ChatStats, SessionTotals } from "../types";
const nf = new Intl.NumberFormat("de-DE");
function seconds(ms: number): string {
return `${(ms / 1000).toLocaleString("de-DE", { maximumFractionDigits: 1 })} s`;
}
function rate(tokens: number, ms: number): string | null {
if (!tokens || ms <= 0) return null;
const perSecond = (tokens / ms) * 1000;
return `${perSecond.toLocaleString("de-DE", { maximumFractionDigits: 1 })} tok/s`;
}
/**
* Messwerte der laufenden bzw. letzten Antwort plus Session-Summe.
*
* Während des Streamens gibt es nur die Näherung aus gezählten Chunks die
* ist mit markiert. Die exakten Zahlen kommen vom Modell selbst, sobald die
* Antwort steht.
*/
export function StatsBar({
stats,
live,
totals,
contextLength,
}: {
stats: ChatStats | null;
live: { tokens: number; startedAt: number } | null;
totals: SessionTotals;
contextLength?: number;
}) {
const parts: string[] = [];
if (live) {
const elapsed = Date.now() - live.startedAt;
parts.push(`${nf.format(live.tokens)}`);
const r = rate(live.tokens, elapsed);
if (r) parts.push(`${r}`);
parts.push(seconds(elapsed));
} else if (stats) {
parts.push(`${nf.format(stats.promptTokens)}`);
parts.push(`${nf.format(stats.responseTokens)}`);
const r = rate(stats.responseTokens, stats.evalMs);
if (r) parts.push(r);
if (stats.ttftMs !== null) parts.push(`TTFT ${seconds(stats.ttftMs)}`);
parts.push(seconds(stats.totalMs));
if (stats.rounds > 1) parts.push(`${stats.rounds} Runden`);
}
const window = stats?.contextLength ?? contextLength;
const used = stats?.promptTokens ?? 0;
const fill = window && used ? Math.min(1, used / window) : null;
if (parts.length === 0 && totals.responses === 0) return null;
return (
<div className="stats-bar">
{parts.length > 0 && (
<span className={`stats-run ${live ? "streaming" : ""}`}>{parts.join(" · ")}</span>
)}
{fill !== null && window && (
<span
className={`stats-context ${fill > 0.9 ? "tight" : ""}`}
title={`Prompt der letzten Antwort gegen das tatsächlich genutzte Kontextfenster (${nf.format(window)} Tokens). Darüber hinaus wird vorne abgeschnitten.`}
>
<span className="stats-meter">
<span className="stats-meter-fill" style={{ width: `${fill * 100}%` }} />
</span>
{nf.format(used)} / {nf.format(window)}
</span>
)}
{totals.responses > 0 && (
<span
className="stats-totals"
title={`${totals.responses} Antworten in diesem Chat, seit dem letzten Neuladen der Seite`}
>
Σ {nf.format(totals.promptTokens)} {nf.format(totals.responseTokens)}
{totals.costUsd > 0 &&
` · $${totals.costUsd.toLocaleString("de-DE", { maximumFractionDigits: 4 })}`}
</span>
)}
</div>
);
}

View file

@ -1001,3 +1001,56 @@ body {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* --- Statistik-Leiste --- */
.stats-bar {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
padding: 5px 18px;
border-top: 1px solid var(--border);
font-size: 0.76em;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.stats-run.streaming {
color: var(--accent);
}
.stats-context {
display: flex;
align-items: center;
gap: 6px;
cursor: help;
}
.stats-meter {
width: 54px;
height: 4px;
border-radius: 2px;
background: var(--border);
overflow: hidden;
}
.stats-meter-fill {
display: block;
height: 100%;
background: var(--accent-dim);
transition: width 0.3s ease;
}
.stats-context.tight {
color: var(--accent-warm);
}
.stats-context.tight .stats-meter-fill {
background: var(--accent-warm);
}
.stats-totals {
margin-left: auto;
cursor: help;
opacity: 0.75;
}

View file

@ -45,6 +45,30 @@ export interface ToolConfirmRequest {
export type ToolDecision = "allow" | "always" | "deny";
/** Messwerte einer Antwort, wie der Server sie nach `done` schickt. */
export interface ChatStats {
messageId: string;
model: string;
provider: "ollama" | "openrouter";
promptTokens: number;
responseTokens: number;
ttftMs: number | null;
evalMs: number;
totalMs: number;
rounds: number;
/** Effektives Kontextfenster; bei OpenRouter aus der Modellliste ergänzt. */
contextLength?: number;
}
/** Aufsummiert über den Chat. Lebt im Browser und ist nach Reload weg. */
export interface SessionTotals {
promptTokens: number;
responseTokens: number;
responses: number;
/** Geschätzte Kosten in USD; nur bei OpenRouter mit bekannten Preisen. */
costUsd: number;
}
export interface ChatOptions {
model: string;
think: boolean;
@ -104,7 +128,9 @@ export interface OpenRouterModel {
id: string;
name: string;
contextLength: number;
/** Preis je 1 Mio. Tokens in USD. */
promptPrice: number;
completionPrice: number;
}
export interface ModelInfo {

View file

@ -2,8 +2,10 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { ChatSocket } from "./socket";
import type {
ChatOptions,
ChatStats,
Message,
Session,
SessionTotals,
ToolConfirmRequest,
ToolDecision,
ToolEvent,
@ -56,6 +58,18 @@ export function useChat() {
// Der Server fragt Werkzeuge einzeln und nacheinander an; die Queue ist die
// Absicherung für den Fall, dass doch zwei Antworten parallel laufen.
const [toolConfirms, setToolConfirms] = useState<ToolConfirmRequest[]>([]);
const [stats, setStats] = useState<ChatStats | null>(null);
// Während des Streamens gibt es noch keine exakten Zahlen: der Server meldet
// sie erst am Ende. Bis dahin zählt die Leiste die eingehenden Chunks als
// Näherung und misst die Zeit ab dem ersten Token.
const [live, setLive] = useState<{ tokens: number; startedAt: number } | null>(null);
const [totals, setTotals] = useState<SessionTotals>({
promptTokens: 0,
responseTokens: 0,
responses: 0,
costUsd: 0,
});
const priceRef = useRef<{ prompt: number; completion: number } | null>(null);
const [options, setOptionsState] = useState<ChatOptions>(loadOptions);
const [systemPrompt, setSystemPromptState] = useState(
() => localStorage.getItem(SYSTEM_KEY) ?? "",
@ -93,6 +107,11 @@ export function useChat() {
);
} else if (t === "token") {
const text = data.text as string;
setLive((cur) =>
cur
? { ...cur, tokens: cur.tokens + 1 }
: { tokens: 1, startedAt: Date.now() },
);
setMessages((prev) =>
prev.map((m) =>
m.id === data.messageId ? { ...m, content: m.content + text } : m,
@ -122,6 +141,24 @@ export function useChat() {
);
return { ...prev, [data.messageId as string]: updated };
});
} else if (t === "stats") {
const s = data as unknown as ChatStats;
setStats(s);
setLive(null);
setTotals((prev) => {
const price = priceRef.current;
const cost =
s.provider === "openrouter" && price
? (s.promptTokens * price.prompt + s.responseTokens * price.completion) /
1_000_000
: 0;
return {
promptTokens: prev.promptTokens + s.promptTokens,
responseTokens: prev.responseTokens + s.responseTokens,
responses: prev.responses + 1,
costUsd: prev.costUsd + cost,
};
});
} else if (t === "tool-confirm") {
setToolConfirms((prev) => [
...prev,
@ -135,6 +172,7 @@ export function useChat() {
} else if (t === "done" || t === "error") {
streamingRef.current = false;
setStreaming(false);
setLive(null);
// Der Server hat jede offene Rückfrage bereits selbst entschieden.
setToolConfirms((prev) =>
prev.filter((c) => c.messageId !== (data.messageId as string)),
@ -208,6 +246,8 @@ export function useChat() {
}
streamingRef.current = true;
setStreaming(true);
setLive(null);
setStats(null);
socketRef.current?.send({
type: "chat",
sessionId: activeId,
@ -258,6 +298,12 @@ export function useChat() {
toolEvents,
toolConfirm: toolConfirms[0] ?? null,
decideToolConfirm,
stats,
live,
totals,
setModelPricing: (p: { prompt: number; completion: number } | null) => {
priceRef.current = p;
},
sendMessage,
abort,
newSession,