From bae588375f03ebc1d397e50a7c36a0160aed622a Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Tue, 6 Oct 2026 02:14:55 +0200 Subject: [PATCH] fix(conversation): size metrics charts to the column so text is legible Charts use the rendered width as viewBox (re-rendered on resize), 11px text, two x ticks, larger dot for single points. Includes docs/changelog for the mic colours, Turn history fix and chart size. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 5 ++++ Manual.md | 8 ++++++- static/js/conversation.js | 49 ++++++++++++++++++++++----------------- static/style.css | 4 ++-- 4 files changed, 42 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e25dea1..be867de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Changed +- Conversation Playground: the round microphone button is red (pressed, "Stop microphone") while the mic is hot (live or recording) and blue ("Start microphone") when off; it returns to blue on stop, auto-stop and mic errors. The pulse ring only animates when reduced motion is not requested. - Conversation Playground: the metrics (Token Usage and STT / LLM first token / TTS first audio TTFB charts) now sit in the right-hand Latency column below Turn history instead of a second "Metrics" tab; the Conversation | Metrics tablist is removed. Charts gain axis titles ("Time (ms)", "Time"), a legend and three y-ticks. +### Fixed +- Conversation Playground: Turn history rows (#n, time, error rows) were in the DOM but squeezed to zero height by the flex column once charts filled the Latency column; the list no longer shrinks. +- Conversation Playground: the metrics charts render at the column width (re-rendered on resize) so axis, tick and legend text is 11px instead of ~8px; a single point is centred with a larger dot. + ### 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. diff --git a/Manual.md b/Manual.md index 1df28f3..380f267 100644 --- a/Manual.md +++ b/Manual.md @@ -886,7 +886,7 @@ offline cache yet. **Usage:** scroll the right-hand Latency column below "Turn history" to Token Usage and the three chart boxes (STT, LLM first token, TTS first audio). Each completed turn adds one point (X = time of day HH:MM:SS, Y = "Time (ms)", legend "TTFB (STT|LLM|TTS)"). 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 and Turn history above are unchanged. +**Expected result:** three charts (sized to the column, text 11px) 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 and Turn history above are 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. @@ -895,3 +895,9 @@ offline cache yet. **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). + +### Conversation Playground: microphone button and Turn history + +**Microphone colours:** the round mic button is blue when the microphone is off and red while it is hot (live listening or recording). Screen readers hear "Start microphone" / "Stop microphone" with a pressed state, and the status text still says "Listening…" or "Mic off", so colour is never the only cue. The button returns to blue on stop, auto-stop and when microphone access fails. The pulse ring is shown only if your system does not request reduced motion. + +**Turn history:** every finished turn adds a row "#n", a status icon and the total time (for example 1.88s) at the top of the list below the Latency bars; a failed turn adds a red row. Clear empties the list. Rows are text, so they work on mobile and with screen readers. diff --git a/static/js/conversation.js b/static/js/conversation.js index 3f22d30..0ceff33 100644 --- a/static/js/conversation.js +++ b/static/js/conversation.js @@ -595,24 +595,25 @@ $('s-import-voices-file')?.addEventListener('change', async function () { renderMetrics(); } - function chartSvg(pts, label, k) { - const W = 300, H = 160, L = 46, R = 10, T = 8, B = 36; + let chartW = 0; + function chartSvg(pts, label, k, W) { + const H = 170, L = 46, R = 12, T = 10, B = 40; // W = rendered width, so SVG units are CSS px 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 => ``).join(''); + const dots = pts.map(p => ``).join(''); const yt = [0, max / 2, max].map(v => `${Math.round(v)}`).join(''); return ` ${yt} - ${hms(t0)} - ${span ? `${hms(pts[pts.length - 1].ts)}` : ''} - ${tr('Time')} + ${hms(t0)} + ${span ? `${hms(pts[pts.length - 1].ts)}` : ''} + ${tr('Time')} ${tr('Time (ms)')} - ● TTFB (${k.toUpperCase()}) + ● TTFB (${k.toUpperCase()}) ${dots}`; } @@ -622,17 +623,23 @@ $('s-import-voices-file')?.addEventListener('change', async function () { const tok = (id, v) => { const el = $(id); if (el) el.textContent = v == null ? tr('n/a') : String(v); }; tok('cm-tok-prompt', metricUsage?.p); tok('cm-tok-completion', metricUsage?.c); tok('cm-tok-total', metricUsage?.t); if (!metricTurns.length) { wrap.innerHTML = ''; return; } + const W = Math.max(200, (wrap.clientWidth || 246) - 18); // minus chart padding+border + chartW = W; wrap.innerHTML = [['stt', 'STT', 'STT'], ['llm', 'LLM', 'LLM first token'], ['tts', 'TTS', 'TTS first audio']].map(([k, title, what]) => { const pts = metricTurns.filter(m => m[k] != null).map(m => ({ ts: m.ts, v: m[k] })); const summary = pts.length ? `${tr(what)}, ${tr('last')} ${pts[pts.length - 1].v} ms, ${pts.length} ${tr('points')}` : `${tr(what)}: ${tr('no data')}`; const rows = pts.map(p => `${hms(p.ts)}${p.v} ms`).join(''); return `

${title}

- ${pts.length ? chartSvg(pts, summary, k) : ''}

${summary}

+ ${pts.length ? chartSvg(pts, summary, k, W) : ''}

${summary}

${tr('Show as table')}${rows}
${summary}
${tr('Time')}TTFB
`; }).join(''); } + if (window.ResizeObserver && $('cm-charts')) { + new ResizeObserver(() => { const w = $('cm-charts').clientWidth - 18; if (metricTurns.length && w > 0 && Math.abs(w - chartW) >= 8) renderMetrics(); }).observe($('cm-charts')); + } + // ── Recording ──────────────────────────────────────────────────────────── function startRecTimer() { recStart = Date.now(); @@ -668,6 +675,10 @@ $('s-import-voices-file')?.addEventListener('change', async function () { function setLiveMicUi(statusText) { micBtn.classList.toggle('live', liveMicEnabled); micBtn.title = liveMicEnabled ? 'Microphone is always listening - click to turn off' : 'Click to turn live microphone on'; + // Red = hot (live or recording); the label/pressed state carries it for non-colour users. + const hot = liveMicEnabled || micBtn.classList.contains('recording'); + micBtn.setAttribute('aria-pressed', hot ? 'true' : 'false'); + micBtn.setAttribute('aria-label', tr(hot ? 'Stop microphone' : 'Start microphone')); if (!isRecording() && !isProcessing) micIcon.className = 'mdi mdi-microphone'; if (micStatus && statusText) micStatus.textContent = statusText; } @@ -675,10 +686,6 @@ $('s-import-voices-file')?.addEventListener('change', async function () { function resetConversationInput(statusText = 'Ready') { liveInterimText = ''; previewTranscribing = false; - // Red = hot (live or recording); the label/pressed state carries it for non-colour users. - const hot = liveMicEnabled || micBtn.classList.contains('recording'); - micBtn.setAttribute('aria-pressed', hot ? 'true' : 'false'); - micBtn.setAttribute('aria-label', tr(hot ? 'Stop microphone' : 'Start microphone')); isProcessing = false; micBtn.classList.remove('processing', 'recording'); micIcon.className = 'mdi mdi-microphone'; @@ -843,19 +850,20 @@ $('s-import-voices-file')?.addEventListener('change', async function () { } } - async function startRecording() { - if (isProcessing) return; - stopInterruptMonitor(); // release mic stream before opening a recording stream - if (!navigator.mediaDevices?.getUserMedia) { - toast('Microphone unavailable — browser requires a secure context (HTTPS or localhost). ' + - 'Access the app via http://localhost:7890 or enable it in chrome://flags/#unsafely-treat-insecure-origin-as-secure', 'error', 8000); - return; function micOffAfterFailure() { // never leave the button red when no mic is open liveMicEnabled = false; micBtn.classList.remove('recording'); setLiveMicUi('Mic off'); } + async function startRecording() { + if (isProcessing) return; + stopInterruptMonitor(); // release mic stream before opening a recording stream + if (!navigator.mediaDevices?.getUserMedia) { + toast('Microphone unavailable — browser requires a secure context (HTTPS or localhost). ' + + 'Access the app via http://localhost:7890 or enable it in chrome://flags/#unsafely-treat-insecure-origin-as-secure', 'error', 8000); + micOffAfterFailure(); + return; } let stream; try { @@ -870,7 +878,6 @@ $('s-import-voices-file')?.addEventListener('change', async function () { previewTranscribing = false; vadHadSpeech = false; vadLastVoiceMs = 0; - micOffAfterFailure(); vadSpeechStartMs = 0; cancelNextBlob = false; recChunks = []; @@ -1025,6 +1032,7 @@ $('s-import-voices-file')?.addEventListener('change', async function () { stopRecTimer(); micBtn.classList.remove('recording'); micBtn.classList.add('processing'); + setLiveMicUi(); micIcon.className = 'mdi mdi-dots-horizontal'; if (micStatus) micStatus.textContent = 'Processing…'; isProcessing = true; @@ -1032,7 +1040,6 @@ $('s-import-voices-file')?.addEventListener('change', async function () { // ── Send turn via SSE ───────────────────────────────────────────────────── // Decode a recorded blob (webm/opus, ogg, …) in the browser and re-encode it - setLiveMicUi(); // as a 16 kHz mono WAV. This sidesteps server-side ffmpeg, whose webm/EBML // parser fails on some platforms (ARM64): "EBML header parsing failed". // Returns a WAV Blob, or null if decoding isn't possible (caller falls back). diff --git a/static/style.css b/static/style.css index 8af747a..3916146 100644 --- a/static/style.css +++ b/static/style.css @@ -6055,11 +6055,11 @@ html[data-mag-night] #toast.success { color: var(--green); } html[data-mag-night .conv-chart[data-metric="llm"] { --cm-c:#d97706; } .conv-chart[data-metric="tts"] { --cm-c:#16a34a; } .conv-chart h4 { margin:0 0 4px; font-size:13px; } -.conv-chart svg { width:100%; height:auto; display:block; } +.conv-chart svg { width:100%; height:auto; display:block; } /* viewBox = rendered width, so 1 unit = 1px */ .conv-chart .cm-axis { stroke:var(--border); } .conv-chart .cm-line { stroke:var(--cm-c); fill:none; stroke-width:2; stroke-linejoin:round; } .conv-chart .cm-dot { fill:var(--surface); stroke:var(--cm-c); stroke-width:2; } -.conv-chart text { fill:var(--subtext); font-size:10px; } +.conv-chart text { fill:var(--subtext); font-size:11px; } .conv-chart text.cm-legend { fill:var(--cm-c); } .conv-chart details summary { cursor:pointer; min-height:44px; display:flex; align-items:center; font-size:12px; color:var(--subtext); } .conv-chart table { width:100%; font-size:12px; border-collapse:collapse; font-variant-numeric:tabular-nums; }