diff --git a/README.md b/README.md index 8c5e3ab..827df80 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,37 @@ und beim Stoppen der Antwort. Läuft ein Werkzeug ohne Rückkanal (Skript, Test), wird es abgelehnt statt ungefragt ausgeführt — die Bestätigung soll sich nicht dadurch umgehen lassen, dass niemand zum Fragen da ist. +## Statistik-Leiste + +Über dem Eingabefeld läuft eine Zeile mit den Messwerten der letzten Antwort: + +``` +3.891 ↑ · 127 ↓ · 34,5 tok/s · TTFT 0,6 s · 5,2 s · 2 Runden ▬▬▬ 3.891 / 4.096 Σ 12.480 ↑ 2.143 ↓ +``` + +| Wert | Bedeutung | +|---|---| +| `↑` / `↓` | Tokens im Prompt / erzeugte Tokens | +| `tok/s` | Erzeugte Tokens durch reine Generierungszeit (`eval_duration`) | +| `TTFT` | Zeit bis zum ersten sichtbaren Token, Denken zählt mit | +| Gesamtzeit | Wanduhr inklusive Werkzeuglaufzeit | +| Runden | Nur ab 2 — jede Werkzeugrunde ist ein eigener Modellaufruf | +| Balken | Prompt gegen das Kontextfenster | +| `Σ` | Summe über den Chat, seit dem letzten Neuladen der Seite | + +Die Zahlen sind **nicht geschätzt**: bei Ollama kommen sie aus dem +Abschluss-Chunk (`prompt_eval_count`, `eval_count`, `eval_duration`), bei +OpenRouter aus dem `usage`-Block (dafür wird `stream_options.include_usage` +gesetzt) — dort zusätzlich die Kosten aus der Preisliste. Während des +Streamens gibt es diese Werte noch nicht; die Leiste zählt so lange die +eingehenden Chunks und markiert das mit `≈`. + +Der Kontextbalken rechnet gegen das **tatsächlich genutzte** Fenster aus +`GET /api/ps`, nicht gegen die im Modell deklarierte Länge. Das ist nicht +dasselbe: qwen3.5 deklariert 262144, geladen läuft es je nach Ollama-Default +mit 4096. Gegen die deklarierte Länge stünde der Balken bei 1 %, während vorne +längst abgeschnitten wird. Ab 90 % färbt er sich orange. + ## Gedächtnis (Chat-Memory) Das Gedächtnis hat drei Schichten: diff --git a/server/src/index.ts b/server/src/index.ts index e68ec58..01ea5d7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -7,7 +7,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import * as dbmod from "./db.js"; import * as mem from "./memory.js"; -import { streamChat, type OllamaOptions } from "./ollama.js"; +import { streamChat, type ChatStats, type OllamaOptions } from "./ollama.js"; import { transcribeAudio, synthesizeSpeech, MAX_AUDIO_BYTES } from "./voice.js"; import { streamOpenRouter, @@ -45,6 +45,37 @@ const CONFIRM_TIMEOUT_MS = 120_000; /** Chats pro Minute und Verbindung. Bremst Schleifen und OpenRouter-Kosten. */ const CHAT_LIMIT_PER_MIN = 30; +/** + * Effektive Kontextlänge eines geladenen Ollama-Modells. + * + * Nicht dasselbe wie die im Modell deklarierte Länge: qwen3.5 meldet 262144, + * geladen läuft es je nach Ollama-Default aber mit 4096. Für einen ehrlichen + * Füllstand zählt nur, womit das Modell tatsächlich läuft — und das steht in + * /api/ps. Kurz gecacht, weil sich das nur beim Nachladen ändert. + */ +const CONTEXT_TTL_MS = 30_000; +const contextCache = new Map(); + +async function effectiveContextLength(model: string): Promise { + const hit = contextCache.get(model); + if (hit && Date.now() - hit.at < CONTEXT_TTL_MS) return hit.value; + try { + const res = await fetch(`${OLLAMA_URL}/api/ps`); + if (!res.ok) return hit?.value; + const data = (await res.json()) as { + models?: { name?: string; model?: string; context_length?: number }[]; + }; + const entry = (data.models ?? []).find( + (m) => m.name === model || m.model === model, + ); + if (!entry?.context_length) return hit?.value; + contextCache.set(model, { value: entry.context_length, at: Date.now() }); + return entry.context_length; + } catch { + return hit?.value; + } +} + const DREAM_IDLE_MS = 180_000; const DREAM_BATCH = 10; const dreamTimers = new Map>(); @@ -542,6 +573,24 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => { onToolConfirm(name: string, args: Record) { return confirmTool(assistantRow.id, name, args); }, + async onStats(stats: ChatStats) { + // Nur bei Ollama kennt der Server das echte Fenster; bei OpenRouter + // steht die Kontextlänge in der Modellliste, die der Client schon hat. + const contextLength = + opts.provider === "ollama" + ? await effectiveContextLength(opts.model) + : undefined; + socket.send( + JSON.stringify({ + type: "stats", + messageId: assistantRow.id, + model: opts.provider === "ollama" ? opts.model : opts.openrouterModel, + provider: opts.provider, + contextLength, + ...stats, + }), + ); + }, }; try { diff --git a/server/src/ollama.ts b/server/src/ollama.ts index b402635..456104b 100644 --- a/server/src/ollama.ts +++ b/server/src/ollama.ts @@ -16,6 +16,24 @@ export interface OllamaOptions { sessionId?: string; } +/** + * Messwerte einer Antwort. Zahlen kommen aus dem Abschluss-Chunk von Ollama + * bzw. dem usage-Block von OpenRouter — nicht geschätzt. + */ +export interface ChatStats { + /** Tokens im Prompt der letzten Runde: das, was zuletzt im Kontext lag. */ + promptTokens: number; + /** Erzeugte Tokens, über alle Werkzeugrunden summiert. */ + responseTokens: number; + /** Bis zum ersten sichtbaren Token (Denken zählt mit). null, wenn keins kam. */ + ttftMs: number | null; + /** Reine Generierungszeit, ohne Prompt-Auswertung und Modell-Laden. */ + evalMs: number; + /** Wanduhr über alles, inklusive Werkzeuglaufzeit. */ + totalMs: number; + rounds: number; +} + export interface StreamCallbacks { onThinking(text: string): void; onToken(text: string): void; @@ -29,6 +47,11 @@ export interface StreamCallbacks { * Fehlt der Rückkanal, lehnt `runTool` solche Werkzeuge ab. */ onToolConfirm?(name: string, args: Record): Promise; + /** + * Messwerte, sobald die Antwort steht. Darf asynchron sein — der Aufrufer + * wartet ab, damit die Zahlen sicher vor `done` beim Client sind. + */ + onStats?(stats: ChatStats): void | Promise; } /** Muss zum OLLAMA_URL in index.ts passen — vorher war der Host hier hartkodiert. */ @@ -47,8 +70,22 @@ interface ChatChunk { }; done?: boolean; error?: string; + /** Nur im Abschluss-Chunk. Dauern in Nanosekunden. */ + prompt_eval_count?: number; + eval_count?: number; + eval_duration?: number; } +/** Rohwerte einer Runde, wie Ollama sie meldet. */ +interface RoundStats { + promptTokens: number; + responseTokens: number; + evalMs: number; + ttftMs: number | null; +} + +const NS_PER_MS = 1e6; + export interface ChatMessage { role: string; content: string; @@ -95,7 +132,23 @@ async function streamOnce( opts: OllamaOptions, cb: StreamCallbacks, signal: AbortSignal, -): Promise<{ toolCalls: ToolCall[]; content: string }> { +): Promise<{ toolCalls: ToolCall[]; content: string; stats: RoundStats }> { + const startedAt = Date.now(); + let firstTokenAt: number | null = null; + const stats: RoundStats = { + promptTokens: 0, + responseTokens: 0, + evalMs: 0, + ttftMs: null, + }; + /** Übernimmt die Zahlen aus dem Abschluss-Chunk. */ + function collect(chunk: ChatChunk) { + stats.promptTokens = chunk.prompt_eval_count ?? 0; + stats.responseTokens = chunk.eval_count ?? 0; + stats.evalMs = Math.round((chunk.eval_duration ?? 0) / NS_PER_MS); + stats.ttftMs = firstTokenAt === null ? null : firstTokenAt - startedAt; + } + const body: Record = { model: opts.model, messages, @@ -144,6 +197,9 @@ async function streamOnce( continue; } if (chunk.error) throw new Error(chunk.error); + if (chunk.message?.thinking || chunk.message?.content) { + firstTokenAt ??= Date.now(); + } if (chunk.message?.thinking) cb.onThinking(chunk.message.thinking); if (chunk.message?.content) { content += chunk.message.content; @@ -155,10 +211,13 @@ async function streamOnce( toolCalls.push({ id: call.id, name, arguments: parseArgs(call.function?.arguments) }); } } - if (chunk.done) return { toolCalls, content }; + if (chunk.done) { + collect(chunk); + return { toolCalls, content, stats }; + } } } - return { toolCalls, content }; + return { toolCalls, content, stats }; } /** @@ -181,15 +240,40 @@ export async function streamChat( ...history.map(toWire), ]; + const startedAt = Date.now(); + const total: ChatStats = { + promptTokens: 0, + responseTokens: 0, + ttftMs: null, + evalMs: 0, + totalMs: 0, + rounds: 0, + }; + /** + * Werkzeugrunden sind mehrere Ollama-Aufrufe für eine sichtbare Antwort: + * Erzeugtes wird summiert, der Prompt-Stand ist der der letzten Runde + * (die größte Belegung), TTFT zählt nur die erste Runde. + */ + function fold(round: RoundStats) { + total.rounds++; + total.promptTokens = round.promptTokens || total.promptTokens; + total.responseTokens += round.responseTokens; + total.evalMs += round.evalMs; + total.ttftMs ??= round.ttftMs; + total.totalMs = Date.now() - startedAt; + } + for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) { const lastRound = round === MAX_TOOL_ROUNDS; // In der letzten Runde ohne Werkzeuge fragen, damit eine Antwort entsteht // statt eines weiteren Aufrufwunsches. const roundOpts = lastRound ? { ...opts, tools: false } : opts; - const { toolCalls, content } = await streamOnce(messages, roundOpts, cb, signal); + const { toolCalls, content, stats } = await streamOnce(messages, roundOpts, cb, signal); + fold(stats); if (toolCalls.length === 0) { + await cb.onStats?.(total); cb.onDone(); return; } @@ -217,5 +301,6 @@ export async function streamChat( } } + await cb.onStats?.(total); cb.onDone(); } diff --git a/server/src/openrouter.ts b/server/src/openrouter.ts index 412a0f1..118d917 100644 --- a/server/src/openrouter.ts +++ b/server/src/openrouter.ts @@ -1,4 +1,5 @@ import { mimeFromBase64 } from "./images.js"; +import type { ChatStats } from "./ollama.js"; export interface OpenRouterOptions { model: string; @@ -10,6 +11,8 @@ export interface StreamCallbacks { onThinking(text: string): void; onToken(text: string): void; onDone(): void; + /** Messwerte, sobald die Antwort steht. Wird vor `done` abgewartet. */ + onStats?(stats: ChatStats): void | Promise; } export function getOpenRouterKey(): string | undefined { @@ -17,7 +20,13 @@ export function getOpenRouterKey(): string | undefined { } export async function listOpenRouterModels(): Promise< - { id: string; name: string; contextLength: number; promptPrice: number }[] + { + id: string; + name: string; + contextLength: number; + promptPrice: number; + completionPrice: number; + }[] > { const res = await fetch("https://openrouter.ai/api/v1/models"); if (!res.ok) throw new Error(`OpenRouter HTTP ${res.status}`); @@ -26,15 +35,17 @@ export async function listOpenRouterModels(): Promise< id: string; name: string; context_length: number; - pricing: { prompt: string }; + pricing: { prompt: string; completion?: string }; }[]; }; + // Preise kommen pro Token; die Anzeige rechnet in Preis je 1 Mio. Tokens. return data.data .map((m) => ({ id: m.id, name: m.name, contextLength: m.context_length, promptPrice: Number(m.pricing?.prompt ?? 0) * 1_000_000, + completionPrice: Number(m.pricing?.completion ?? 0) * 1_000_000, })) .sort((a, b) => a.name.localeCompare(b.name)); } @@ -76,6 +87,8 @@ export async function streamOpenRouter( body: JSON.stringify({ model: opts.model, stream: true, + // Ohne das kommt kein usage-Block und die Tokenzahlen blieben leer. + stream_options: { include_usage: true }, messages: [ ...(systemPrompt ? [{ role: "system", content: systemPrompt }] : []), ...history.map(toOpenAIMessage), @@ -95,6 +108,27 @@ export async function streamOpenRouter( const decoder = new TextDecoder(); let buffer = ""; + const startedAt = Date.now(); + let firstTokenAt: number | null = null; + const stats: ChatStats = { + promptTokens: 0, + responseTokens: 0, + ttftMs: null, + evalMs: 0, + totalMs: 0, + rounds: 1, + }; + /** + * OpenRouter meldet keine reine Generierungszeit. Als evalMs zählt deshalb + * die Zeit ab dem ersten Token — das ist die Spanne, über die tok/s + * überhaupt aussagekräftig ist. + */ + function finish(): void | Promise { + stats.totalMs = Date.now() - startedAt; + stats.evalMs = firstTokenAt === null ? 0 : Date.now() - firstTokenAt; + return cb.onStats?.(stats); + } + while (true) { const { value, done } = await reader.read(); if (done) break; @@ -107,6 +141,7 @@ export async function streamOpenRouter( if (!line.startsWith("data:")) continue; const payload = line.slice(5).trim(); if (payload === "[DONE]") { + await finish(); cb.onDone(); return; } @@ -116,6 +151,7 @@ export async function streamOpenRouter( finish_reason?: string | null; }[]; error?: { message?: string }; + usage?: { prompt_tokens?: number; completion_tokens?: number }; }; try { chunk = JSON.parse(payload); @@ -123,14 +159,24 @@ export async function streamOpenRouter( continue; } if (chunk.error) throw new Error(chunk.error.message ?? "OpenRouter error"); + if (chunk.usage) { + stats.promptTokens = chunk.usage.prompt_tokens ?? 0; + stats.responseTokens = chunk.usage.completion_tokens ?? 0; + } const delta = chunk.choices?.[0]?.delta; + if (delta?.reasoning || delta?.content) firstTokenAt ??= Date.now(); if (delta?.reasoning) cb.onThinking(delta.reasoning); if (delta?.content) cb.onToken(delta.content); - if (chunk.choices?.[0]?.finish_reason && !delta?.content) { + // Der usage-Block kommt erst nach dem finish_reason-Chunk. Nur wenn er + // schon da ist, darf hier abgekürzt werden — sonst bis [DONE] weiterlesen + // und die Tokenzahlen mitnehmen. + if (chunk.choices?.[0]?.finish_reason && !delta?.content && stats.promptTokens) { + await finish(); cb.onDone(); return; } } } + await finish(); cb.onDone(); } diff --git a/web/src/App.tsx b/web/src/App.tsx index 866d293..fc54d8a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { )} + + 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 ( +
+ {parts.length > 0 && ( + {parts.join(" · ")} + )} + + {fill !== null && window && ( + 0.9 ? "tight" : ""}`} + title={`Prompt der letzten Antwort gegen das tatsächlich genutzte Kontextfenster (${nf.format(window)} Tokens). Darüber hinaus wird vorne abgeschnitten.`} + > + + + + {nf.format(used)} / {nf.format(window)} + + )} + + {totals.responses > 0 && ( + + Σ {nf.format(totals.promptTokens)} ↑ {nf.format(totals.responseTokens)} ↓ + {totals.costUsd > 0 && + ` · $${totals.costUsd.toLocaleString("de-DE", { maximumFractionDigits: 4 })}`} + + )} +
+ ); +} diff --git a/web/src/styles.css b/web/src/styles.css index d57fcfe..c1b676a 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; +} diff --git a/web/src/types.ts b/web/src/types.ts index a600b53..730c39f 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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 { diff --git a/web/src/useChat.ts b/web/src/useChat.ts index da45e7d..969077f 100644 --- a/web/src/useChat.ts +++ b/web/src/useChat.ts @@ -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([]); + const [stats, setStats] = useState(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({ + promptTokens: 0, + responseTokens: 0, + responses: 0, + costUsd: 0, + }); + const priceRef = useRef<{ prompt: number; completion: number } | null>(null); const [options, setOptionsState] = useState(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,