mirror of
https://github.com/Jeuners/agenttwo-tools.git
synced 2026-09-09 15:02:31 +02:00
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:
parent
dd0ee30165
commit
1297ffcc56
9 changed files with 455 additions and 8 deletions
31
README.md
31
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
|
Test), wird es abgelehnt statt ungefragt ausgeführt — die Bestätigung soll
|
||||||
sich nicht dadurch umgehen lassen, dass niemand zum Fragen da ist.
|
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)
|
## Gedächtnis (Chat-Memory)
|
||||||
|
|
||||||
Das Gedächtnis hat drei Schichten:
|
Das Gedächtnis hat drei Schichten:
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { readFileSync } from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import * as dbmod from "./db.js";
|
import * as dbmod from "./db.js";
|
||||||
import * as mem from "./memory.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 { transcribeAudio, synthesizeSpeech, MAX_AUDIO_BYTES } from "./voice.js";
|
||||||
import {
|
import {
|
||||||
streamOpenRouter,
|
streamOpenRouter,
|
||||||
|
|
@ -45,6 +45,37 @@ const CONFIRM_TIMEOUT_MS = 120_000;
|
||||||
/** Chats pro Minute und Verbindung. Bremst Schleifen und OpenRouter-Kosten. */
|
/** Chats pro Minute und Verbindung. Bremst Schleifen und OpenRouter-Kosten. */
|
||||||
const CHAT_LIMIT_PER_MIN = 30;
|
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<string, { value: number; at: number }>();
|
||||||
|
|
||||||
|
async function effectiveContextLength(model: string): Promise<number | undefined> {
|
||||||
|
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_IDLE_MS = 180_000;
|
||||||
const DREAM_BATCH = 10;
|
const DREAM_BATCH = 10;
|
||||||
const dreamTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
const dreamTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
@ -542,6 +573,24 @@ wss.on("connection", (socket: WebSocket, _req: IncomingMessage) => {
|
||||||
onToolConfirm(name: string, args: Record<string, unknown>) {
|
onToolConfirm(name: string, args: Record<string, unknown>) {
|
||||||
return confirmTool(assistantRow.id, name, args);
|
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 {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,24 @@ export interface OllamaOptions {
|
||||||
sessionId?: string;
|
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 {
|
export interface StreamCallbacks {
|
||||||
onThinking(text: string): void;
|
onThinking(text: string): void;
|
||||||
onToken(text: string): void;
|
onToken(text: string): void;
|
||||||
|
|
@ -29,6 +47,11 @@ export interface StreamCallbacks {
|
||||||
* Fehlt der Rückkanal, lehnt `runTool` solche Werkzeuge ab.
|
* Fehlt der Rückkanal, lehnt `runTool` solche Werkzeuge ab.
|
||||||
*/
|
*/
|
||||||
onToolConfirm?(name: string, args: Record<string, unknown>): Promise<boolean>;
|
onToolConfirm?(name: string, args: Record<string, unknown>): Promise<boolean>;
|
||||||
|
/**
|
||||||
|
* 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<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Muss zum OLLAMA_URL in index.ts passen — vorher war der Host hier hartkodiert. */
|
/** Muss zum OLLAMA_URL in index.ts passen — vorher war der Host hier hartkodiert. */
|
||||||
|
|
@ -47,8 +70,22 @@ interface ChatChunk {
|
||||||
};
|
};
|
||||||
done?: boolean;
|
done?: boolean;
|
||||||
error?: string;
|
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 {
|
export interface ChatMessage {
|
||||||
role: string;
|
role: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
|
@ -95,7 +132,23 @@ async function streamOnce(
|
||||||
opts: OllamaOptions,
|
opts: OllamaOptions,
|
||||||
cb: StreamCallbacks,
|
cb: StreamCallbacks,
|
||||||
signal: AbortSignal,
|
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<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
model: opts.model,
|
model: opts.model,
|
||||||
messages,
|
messages,
|
||||||
|
|
@ -144,6 +197,9 @@ async function streamOnce(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (chunk.error) throw new Error(chunk.error);
|
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?.thinking) cb.onThinking(chunk.message.thinking);
|
||||||
if (chunk.message?.content) {
|
if (chunk.message?.content) {
|
||||||
content += 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) });
|
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),
|
...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++) {
|
for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
|
||||||
const lastRound = round === MAX_TOOL_ROUNDS;
|
const lastRound = round === MAX_TOOL_ROUNDS;
|
||||||
// In der letzten Runde ohne Werkzeuge fragen, damit eine Antwort entsteht
|
// In der letzten Runde ohne Werkzeuge fragen, damit eine Antwort entsteht
|
||||||
// statt eines weiteren Aufrufwunsches.
|
// statt eines weiteren Aufrufwunsches.
|
||||||
const roundOpts = lastRound ? { ...opts, tools: false } : opts;
|
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) {
|
if (toolCalls.length === 0) {
|
||||||
|
await cb.onStats?.(total);
|
||||||
cb.onDone();
|
cb.onDone();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -217,5 +301,6 @@ export async function streamChat(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await cb.onStats?.(total);
|
||||||
cb.onDone();
|
cb.onDone();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { mimeFromBase64 } from "./images.js";
|
import { mimeFromBase64 } from "./images.js";
|
||||||
|
import type { ChatStats } from "./ollama.js";
|
||||||
|
|
||||||
export interface OpenRouterOptions {
|
export interface OpenRouterOptions {
|
||||||
model: string;
|
model: string;
|
||||||
|
|
@ -10,6 +11,8 @@ export interface StreamCallbacks {
|
||||||
onThinking(text: string): void;
|
onThinking(text: string): void;
|
||||||
onToken(text: string): void;
|
onToken(text: string): void;
|
||||||
onDone(): void;
|
onDone(): void;
|
||||||
|
/** Messwerte, sobald die Antwort steht. Wird vor `done` abgewartet. */
|
||||||
|
onStats?(stats: ChatStats): void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOpenRouterKey(): string | undefined {
|
export function getOpenRouterKey(): string | undefined {
|
||||||
|
|
@ -17,7 +20,13 @@ export function getOpenRouterKey(): string | undefined {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listOpenRouterModels(): Promise<
|
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");
|
const res = await fetch("https://openrouter.ai/api/v1/models");
|
||||||
if (!res.ok) throw new Error(`OpenRouter HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`OpenRouter HTTP ${res.status}`);
|
||||||
|
|
@ -26,15 +35,17 @@ export async function listOpenRouterModels(): Promise<
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
context_length: number;
|
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
|
return data.data
|
||||||
.map((m) => ({
|
.map((m) => ({
|
||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
contextLength: m.context_length,
|
contextLength: m.context_length,
|
||||||
promptPrice: Number(m.pricing?.prompt ?? 0) * 1_000_000,
|
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));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +87,8 @@ export async function streamOpenRouter(
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: opts.model,
|
model: opts.model,
|
||||||
stream: true,
|
stream: true,
|
||||||
|
// Ohne das kommt kein usage-Block und die Tokenzahlen blieben leer.
|
||||||
|
stream_options: { include_usage: true },
|
||||||
messages: [
|
messages: [
|
||||||
...(systemPrompt ? [{ role: "system", content: systemPrompt }] : []),
|
...(systemPrompt ? [{ role: "system", content: systemPrompt }] : []),
|
||||||
...history.map(toOpenAIMessage),
|
...history.map(toOpenAIMessage),
|
||||||
|
|
@ -95,6 +108,27 @@ export async function streamOpenRouter(
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = "";
|
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<void> {
|
||||||
|
stats.totalMs = Date.now() - startedAt;
|
||||||
|
stats.evalMs = firstTokenAt === null ? 0 : Date.now() - firstTokenAt;
|
||||||
|
return cb.onStats?.(stats);
|
||||||
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { value, done } = await reader.read();
|
const { value, done } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
|
|
@ -107,6 +141,7 @@ export async function streamOpenRouter(
|
||||||
if (!line.startsWith("data:")) continue;
|
if (!line.startsWith("data:")) continue;
|
||||||
const payload = line.slice(5).trim();
|
const payload = line.slice(5).trim();
|
||||||
if (payload === "[DONE]") {
|
if (payload === "[DONE]") {
|
||||||
|
await finish();
|
||||||
cb.onDone();
|
cb.onDone();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -116,6 +151,7 @@ export async function streamOpenRouter(
|
||||||
finish_reason?: string | null;
|
finish_reason?: string | null;
|
||||||
}[];
|
}[];
|
||||||
error?: { message?: string };
|
error?: { message?: string };
|
||||||
|
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
chunk = JSON.parse(payload);
|
chunk = JSON.parse(payload);
|
||||||
|
|
@ -123,14 +159,24 @@ export async function streamOpenRouter(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (chunk.error) throw new Error(chunk.error.message ?? "OpenRouter error");
|
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;
|
const delta = chunk.choices?.[0]?.delta;
|
||||||
|
if (delta?.reasoning || delta?.content) firstTokenAt ??= Date.now();
|
||||||
if (delta?.reasoning) cb.onThinking(delta.reasoning);
|
if (delta?.reasoning) cb.onThinking(delta.reasoning);
|
||||||
if (delta?.content) cb.onToken(delta.content);
|
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();
|
cb.onDone();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await finish();
|
||||||
cb.onDone();
|
cb.onDone();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { ChatMessage } from "./components/ChatMessage";
|
||||||
import { Composer } from "./components/Composer";
|
import { Composer } from "./components/Composer";
|
||||||
import { MemoryPanel } from "./components/MemoryPanel";
|
import { MemoryPanel } from "./components/MemoryPanel";
|
||||||
import { ToolConfirm } from "./components/ToolConfirm";
|
import { ToolConfirm } from "./components/ToolConfirm";
|
||||||
|
import { StatsBar } from "./components/StatsBar";
|
||||||
import type { OpenRouterModel, OllamaModel } from "./types";
|
import type { OpenRouterModel, OllamaModel } from "./types";
|
||||||
|
|
||||||
const VOICE_KEY = "oxagenttwo.voiceMode";
|
const VOICE_KEY = "oxagenttwo.voiceMode";
|
||||||
|
|
@ -49,6 +50,22 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}, [settingsOpen, chat.options.provider, orModels.length, ollamaModels.length]);
|
}, [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 voiceModeRef = useRef(voiceMode);
|
||||||
const streamingRef = useRef(false);
|
const streamingRef = useRef(false);
|
||||||
const awaitingDrainRef = useRef(false);
|
const awaitingDrainRef = useRef(false);
|
||||||
|
|
@ -446,6 +463,13 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<StatsBar
|
||||||
|
stats={chat.stats}
|
||||||
|
live={chat.live}
|
||||||
|
totals={chat.totals}
|
||||||
|
contextLength={activeOrModel?.contextLength}
|
||||||
|
/>
|
||||||
|
|
||||||
<Composer
|
<Composer
|
||||||
streaming={chat.streaming}
|
streaming={chat.streaming}
|
||||||
disabled={!chat.activeId}
|
disabled={!chat.activeId}
|
||||||
|
|
|
||||||
87
web/src/components/StatsBar.tsx
Normal file
87
web/src/components/StatsBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1001,3 +1001,56 @@ body {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,30 @@ export interface ToolConfirmRequest {
|
||||||
|
|
||||||
export type ToolDecision = "allow" | "always" | "deny";
|
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 {
|
export interface ChatOptions {
|
||||||
model: string;
|
model: string;
|
||||||
think: boolean;
|
think: boolean;
|
||||||
|
|
@ -104,7 +128,9 @@ export interface OpenRouterModel {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
contextLength: number;
|
contextLength: number;
|
||||||
|
/** Preis je 1 Mio. Tokens in USD. */
|
||||||
promptPrice: number;
|
promptPrice: number;
|
||||||
|
completionPrice: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelInfo {
|
export interface ModelInfo {
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ChatSocket } from "./socket";
|
import { ChatSocket } from "./socket";
|
||||||
import type {
|
import type {
|
||||||
ChatOptions,
|
ChatOptions,
|
||||||
|
ChatStats,
|
||||||
Message,
|
Message,
|
||||||
Session,
|
Session,
|
||||||
|
SessionTotals,
|
||||||
ToolConfirmRequest,
|
ToolConfirmRequest,
|
||||||
ToolDecision,
|
ToolDecision,
|
||||||
ToolEvent,
|
ToolEvent,
|
||||||
|
|
@ -56,6 +58,18 @@ export function useChat() {
|
||||||
// Der Server fragt Werkzeuge einzeln und nacheinander an; die Queue ist die
|
// Der Server fragt Werkzeuge einzeln und nacheinander an; die Queue ist die
|
||||||
// Absicherung für den Fall, dass doch zwei Antworten parallel laufen.
|
// Absicherung für den Fall, dass doch zwei Antworten parallel laufen.
|
||||||
const [toolConfirms, setToolConfirms] = useState<ToolConfirmRequest[]>([]);
|
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 [options, setOptionsState] = useState<ChatOptions>(loadOptions);
|
||||||
const [systemPrompt, setSystemPromptState] = useState(
|
const [systemPrompt, setSystemPromptState] = useState(
|
||||||
() => localStorage.getItem(SYSTEM_KEY) ?? "",
|
() => localStorage.getItem(SYSTEM_KEY) ?? "",
|
||||||
|
|
@ -93,6 +107,11 @@ export function useChat() {
|
||||||
);
|
);
|
||||||
} else if (t === "token") {
|
} else if (t === "token") {
|
||||||
const text = data.text as string;
|
const text = data.text as string;
|
||||||
|
setLive((cur) =>
|
||||||
|
cur
|
||||||
|
? { ...cur, tokens: cur.tokens + 1 }
|
||||||
|
: { tokens: 1, startedAt: Date.now() },
|
||||||
|
);
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((m) =>
|
prev.map((m) =>
|
||||||
m.id === data.messageId ? { ...m, content: m.content + text } : 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 };
|
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") {
|
} else if (t === "tool-confirm") {
|
||||||
setToolConfirms((prev) => [
|
setToolConfirms((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -135,6 +172,7 @@ export function useChat() {
|
||||||
} else if (t === "done" || t === "error") {
|
} else if (t === "done" || t === "error") {
|
||||||
streamingRef.current = false;
|
streamingRef.current = false;
|
||||||
setStreaming(false);
|
setStreaming(false);
|
||||||
|
setLive(null);
|
||||||
// Der Server hat jede offene Rückfrage bereits selbst entschieden.
|
// Der Server hat jede offene Rückfrage bereits selbst entschieden.
|
||||||
setToolConfirms((prev) =>
|
setToolConfirms((prev) =>
|
||||||
prev.filter((c) => c.messageId !== (data.messageId as string)),
|
prev.filter((c) => c.messageId !== (data.messageId as string)),
|
||||||
|
|
@ -208,6 +246,8 @@ export function useChat() {
|
||||||
}
|
}
|
||||||
streamingRef.current = true;
|
streamingRef.current = true;
|
||||||
setStreaming(true);
|
setStreaming(true);
|
||||||
|
setLive(null);
|
||||||
|
setStats(null);
|
||||||
socketRef.current?.send({
|
socketRef.current?.send({
|
||||||
type: "chat",
|
type: "chat",
|
||||||
sessionId: activeId,
|
sessionId: activeId,
|
||||||
|
|
@ -258,6 +298,12 @@ export function useChat() {
|
||||||
toolEvents,
|
toolEvents,
|
||||||
toolConfirm: toolConfirms[0] ?? null,
|
toolConfirm: toolConfirms[0] ?? null,
|
||||||
decideToolConfirm,
|
decideToolConfirm,
|
||||||
|
stats,
|
||||||
|
live,
|
||||||
|
totals,
|
||||||
|
setModelPricing: (p: { prompt: number; completion: number } | null) => {
|
||||||
|
priceRef.current = p;
|
||||||
|
},
|
||||||
sendMessage,
|
sendMessage,
|
||||||
abort,
|
abort,
|
||||||
newSession,
|
newSession,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue