diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d3d207..b565670 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Added
+- Conversation Playground: Metrics view (Conversation | Metrics tabs) with Pipecat-style TTFB line charts for STT, LLM first token and TTS, plus session Token Usage (prompt/completion/total, "n/a" when the backend reports none). The stream now requests `stream_options.include_usage` (dropped and retried on 400/422) and forwards `usage` in the final stats event; the TTS value is now the time to the first audio chunk instead of including the remaining LLM generation time.
- Conversation Playground: a Thinking on/off switch next to the Language Model (default off, setting `conversation_thinking`). Off sends `chat_template_kwargs.enable_thinking=false` (plus `think:false` for Ollama) with each reply request and never touches the system prompt; the empty-reply error now says to turn Thinking off.
### Fixed
diff --git a/Manual.md b/Manual.md
index c9caa06..a72d673 100644
--- a/Manual.md
+++ b/Manual.md
@@ -877,3 +877,21 @@ offline cache yet.
**Offline:** works with a local LLM; no network beyond your configured endpoint.
**Privacy:** the setting is stored in the local app settings only; nothing extra is sent anywhere.
+
+## Conversation Playground: Metrics view
+
+**Purpose:** see how fast each stage responds over a session, like the Pipecat Playground "Metrics" tab: Token Usage (prompt / completion / total) and a TTFB line chart each for STT, LLM (first token) and TTS (time to the first audio chunk, not the end of playback).
+
+**Prerequisites:** at least one completed turn in Conversation. Token counts need a backend that reports `usage` in streaming replies (Ollama, llama.cpp, vLLM and llama-swap do).
+
+**Usage:** switch the Conversation | Metrics tabs at the top (click, tap, or Left/Right/Home/End arrows). Each completed turn adds one point (X = time of day HH:MM:SS, Y = ms). Use "Show as table" under a chart for the numbers. The Clear button resets charts and token totals.
+
+**Expected result:** three charts with one point per turn (last 50 kept) and summed token counts. Voice turns include STT; typed turns have no STT point. The Latency panel in the Conversation tab is unchanged.
+
+**Errors:** a backend that sends no usage shows "n/a" (never 0). Failed turns add no point. With no turns yet, "No turns yet." is shown.
+
+**Mobile / accessibility:** charts stack on phones; 44 px tabs (tablist, aria-selected); every chart has a text summary (e.g. "LLM first token, last 142 ms, 5 points") and a table alternative; values are drawn as line plus markers, not colour only; no animation.
+
+**Offline:** works with a local LLM; no network beyond your configured endpoint.
+
+**Privacy:** metrics live only in the browser tab's memory and vanish on reload; nothing leaves the browser except the LLM call itself (which now asks the endpoint to include token usage).
diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md
index e456569..089f74f 100644
--- a/PROJECT_MAP.md
+++ b/PROJECT_MAP.md
@@ -58,6 +58,7 @@ Two self-hosted Docker instances, both port 7890:
| Voice library / clone / design | `routes/library.py`, `core/voice.py` | `library.js` | mature |
| Voice archetype gallery (40 own German-first design voices, cached previews) | `core/voice_archetypes.py`, `routes/archetypes.py` | `voice-archetypes.js`, `s-design.html` | **new** — see [architecture/voice-archetypes.md](architecture/voice-archetypes.md) |
| Read Aloud (books, PDFs) | `routes/reader.py` | `reader.js` | mature |
+| Conversation Metrics view (TTFB charts STT/LLM/TTS, token usage) | `routes/conversation.py` (`_usage_from_chunk`, `_post_llm_stream`) | `conversation.js`, `s-conversation.html` | **new 2026-10-06** — in-memory, last 50 turns |
| Audiobook casting (span mode: deterministic quote spans, LLM names speakers only; verify/recast) | `routes/conversation.py`, `core/attribution_spans.py` | `audiobook*.js`, `audiobook-spans.js`, `audiobook-cast-spans.js` | **new 2026-09-19** — see [architecture/audiobook-attribution.md](architecture/audiobook-attribution.md) |
| Characters (avatar/concept identity, appearance forms) | `routes/characters.py`, `routes/conversation.py` | `library-characters.js`, `character-sheets.js` | **reworked 2026-09-19** — see [architecture/character-visual-identity.md](architecture/character-visual-identity.md) |
| Rehearser | `routes/rehearsals_db.py` | `rehearser-*.js` | mature |
diff --git a/routes/conversation.py b/routes/conversation.py
index c8efee9..4650c3e 100644
--- a/routes/conversation.py
+++ b/routes/conversation.py
@@ -388,6 +388,28 @@ def _thinking_off_params(settings: dict, llm_url: str = "") -> dict:
return params
+def _usage_from_chunk(obj: dict) -> dict | None:
+ """Token counts from an OpenAI-style stream chunk's ``usage``, or None when absent/non-numeric."""
+ u = obj.get("usage")
+ if not isinstance(u, dict):
+ return None
+ out = {k: u[k] for k in ("prompt_tokens", "completion_tokens", "total_tokens")
+ if isinstance(u.get(k), int) and not isinstance(u.get(k), bool)}
+ return out or None
+
+
+def _post_llm_stream(url: str, payload: dict, headers: dict):
+ """POST a streaming chat request; on 400/422 retry once without the optional keys
+ (thinking switch, stream_options) that older backends reject."""
+ resp = requests.post(url, json=payload, headers=headers, stream=True, timeout=120)
+ optional = [k for k in ("chat_template_kwargs", "think", "stream_options") if k in payload]
+ if resp.status_code in (400, 422) and optional:
+ for k in optional:
+ payload.pop(k, None)
+ resp = requests.post(url, json=payload, headers=headers, stream=True, timeout=120)
+ return resp
+
+
def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str = "") -> str:
"""Inline synchronous persona rewrite; raises RuntimeError on failure."""
settings = _load_settings()
@@ -6238,21 +6260,16 @@ async def conversation_turn(
if eff_llm_model:
llm_payload["model"] = eff_llm_model
llm_payload.update(_thinking_off_params(settings, eff_llm_url))
+ llm_payload["stream_options"] = {"include_usage": True} # dropped on 400/422 by _post_llm_stream
_loop = asyncio.get_event_loop()
_token_q: asyncio.Queue[tuple[str, str] | None] = asyncio.Queue()
+ _llm_usage: dict = {} # filled by _llm_thread from the stream's usage chunk; {} = server sent none
def _llm_thread() -> None:
try:
_hdrs = {"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}
- resp = requests.post(f"{eff_llm_url}/chat/completions", json=llm_payload,
- headers=_hdrs, stream=True, timeout=120)
- if resp.status_code in (400, 422) and "chat_template_kwargs" in llm_payload:
- # backend rejects the thinking switch: ask again without it
- llm_payload.pop("chat_template_kwargs", None)
- llm_payload.pop("think", None)
- resp = requests.post(f"{eff_llm_url}/chat/completions", json=llm_payload,
- headers=_hdrs, stream=True, timeout=120)
+ resp = _post_llm_stream(f"{eff_llm_url}/chat/completions", llm_payload, _hdrs)
resp.raise_for_status()
for raw_line in resp.iter_lines():
if not raw_line:
@@ -6265,6 +6282,9 @@ async def conversation_turn(
break
try:
obj = json.loads(payload_str)
+ _u = _usage_from_chunk(obj)
+ if _u:
+ _llm_usage.update(_u)
d_obj = ((obj.get("choices") or [{}])[0].get("delta") or {})
reasoning = _reasoning_text(d_obj)
content = d_obj.get("content") or ""
@@ -6375,6 +6395,13 @@ async def conversation_turn(
tts_tasks: list[asyncio.Task] = []
tts_texts: list[str] = [] # sentence text corresponding to each task
tts_first_start: float | None = None
+ tts_first_done: list[float] = [] # when the first chunk's audio was ready (not when we got to await it)
+
+ def _add_tts(chunk: str) -> None:
+ task = _make_tts_task(chunk, tts_voice, settings, tts_be, tts_sem, tts_emotion)
+ if not tts_tasks:
+ task.add_done_callback(lambda _t: tts_first_done.append(time.monotonic()))
+ tts_tasks.append(task)
try:
while True:
@@ -6405,7 +6432,7 @@ async def conversation_turn(
if tts_first_start is None:
tts_first_start = time.monotonic()
tts_texts.append(chunk_text)
- tts_tasks.append(_make_tts_task(chunk_text, tts_voice, settings, tts_be, tts_sem, tts_emotion))
+ _add_tts(chunk_text)
except Exception as exc:
yield sse({"type": "error", "stage": "llm", "message": str(exc)})
return
@@ -6424,7 +6451,7 @@ async def conversation_turn(
if tts_first_start is None:
tts_first_start = time.monotonic()
tts_texts.append(sent_buf.strip())
- tts_tasks.append(_make_tts_task(sent_buf.strip(), tts_voice, settings, tts_be, tts_sem, tts_emotion))
+ _add_tts(sent_buf.strip())
llm_total_ms = int((time.monotonic() - t_llm) * 1000)
yield sse({"type": "llm_done", "text": llm_text,
@@ -6448,14 +6475,16 @@ async def conversation_turn(
yield sse({"type": "error", "stage": "tts", "message": str(exc)})
return
if i == 0:
- tts_ms = int((time.monotonic() - tts_start) * 1000)
+ # time to first audio chunk; the LLM may still have been streaming when it was ready
+ tts_ms = int(((tts_first_done[0] if tts_first_done else time.monotonic()) - tts_start) * 1000)
sentence_text = tts_texts[i] if i < len(tts_texts) else ""
yield sse({"type": "audio", "b64": base64.b64encode(audio_bytes).decode(),
"mime": mime, "text": sentence_text})
total_ms = int((time.monotonic() - t0) * 1000)
yield sse({"type": "stats", "stt_ms": stt_ms, "llm_ttft_ms": llm_ttft_ms,
- "llm_total_ms": llm_total_ms, "tts_ms": tts_ms, "total_ms": total_ms})
+ "llm_total_ms": llm_total_ms, "tts_ms": tts_ms, "total_ms": total_ms,
+ "usage": dict(_llm_usage) or None})
yield sse({"type": "done"})
return StreamingResponse(generate(), media_type="text/event-stream",
diff --git a/static/js/conversation.js b/static/js/conversation.js
index e8f1a72..471021c 100644
--- a/static/js/conversation.js
+++ b/static/js/conversation.js
@@ -572,6 +572,81 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
turnHistory.insertBefore(item, turnHistory.firstChild);
}
+
+ // ── Metrics view (TTFB charts + token usage; in-memory, last 50 turns) ────
+ const METRIC_MAX = 50;
+ let metricTurns = []; // {ts, stt, llm, tts} (ms, null when not measured)
+ let metricUsage = null; // {p, c, t} summed over turns whose server reported usage
+ const tr = s => (window.t ? window.t(s) : s);
+ const pad2 = n => String(n).padStart(2, '0');
+ const hms = ts => { const d = new Date(ts); return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; };
+
+ function recordMetrics(st) {
+ if (!st) return;
+ metricTurns.push({ ts: Date.now(), stt: st.stt_ms ?? null, llm: st.llm_ttft_ms ?? null, tts: st.tts_ms ?? null });
+ if (metricTurns.length > METRIC_MAX) metricTurns.shift();
+ const u = st.usage;
+ if (u && typeof u === 'object') {
+ metricUsage = metricUsage || { p: 0, c: 0, t: 0 };
+ metricUsage.p += u.prompt_tokens || 0;
+ metricUsage.c += u.completion_tokens || 0;
+ metricUsage.t += u.total_tokens ?? ((u.prompt_tokens || 0) + (u.completion_tokens || 0));
+ }
+ renderMetrics();
+ }
+
+ function chartSvg(pts, label) {
+ const W = 300, H = 140, L = 40, R = 10, T = 8, B = 22;
+ const max = Math.max(...pts.map(p => p.v), 1);
+ const t0 = pts[0].ts, span = pts[pts.length - 1].ts - t0;
+ const x = ts => L + (span ? (ts - t0) / span : 0.5) * (W - L - R);
+ const y = v => T + (1 - v / max) * (H - T - B);
+ const line = pts.map(p => `${x(p.ts).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ');
+ const dots = pts.map(p => `
${summary}
+| ${tr('Time')} | TTFB |
|---|
No turns yet.
+ +