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 <noreply@anthropic.com>
This commit is contained in:
parent
caee289bcc
commit
bae588375f
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 => `<circle class="cm-dot" cx="${x(p.ts).toFixed(1)}" cy="${y(p.v).toFixed(1)}" r="3"></circle>`).join('');
|
||||
const dots = pts.map(p => `<circle class="cm-dot" cx="${x(p.ts).toFixed(1)}" cy="${y(p.v).toFixed(1)}" r="4"></circle>`).join('');
|
||||
const yt = [0, max / 2, max].map(v => `<text x="${L - 4}" y="${(y(v) + 3).toFixed(1)}" text-anchor="end">${Math.round(v)}</text>`).join('');
|
||||
return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${label}">
|
||||
<line class="cm-axis" x1="${L}" y1="${T}" x2="${L}" y2="${H - B}"></line>
|
||||
<line class="cm-axis" x1="${L}" y1="${H - B}" x2="${W - R}" y2="${H - B}"></line>
|
||||
${yt}
|
||||
<text x="${L}" y="${H - B + 12}">${hms(t0)}</text>
|
||||
${span ? `<text x="${W - R}" y="${H - B + 12}" text-anchor="end">${hms(pts[pts.length - 1].ts)}</text>` : ''}
|
||||
<text x="${(L + W - R) / 2}" y="${H - B + 24}" text-anchor="middle">${tr('Time')}</text>
|
||||
<text x="${L}" y="${H - B + 14}">${hms(t0)}</text>
|
||||
${span ? `<text x="${W - R}" y="${H - B + 14}" text-anchor="end">${hms(pts[pts.length - 1].ts)}</text>` : ''}
|
||||
<text x="${(L + W - R) / 2}" y="${H - B + 27}" text-anchor="middle">${tr('Time')}</text>
|
||||
<text transform="rotate(-90)" x="${-(T + H - B) / 2}" y="9" text-anchor="middle">${tr('Time (ms)')}</text>
|
||||
<text x="${(L + W - R) / 2}" y="${H - 2}" text-anchor="middle" class="cm-legend cm-${k}">● TTFB (${k.toUpperCase()})</text>
|
||||
<text x="${(L + W - R) / 2}" y="${H - 3}" text-anchor="middle" class="cm-legend cm-${k}">● TTFB (${k.toUpperCase()})</text>
|
||||
<polyline class="cm-line cm-${k}" points="${line}"></polyline>${dots}</svg>`;
|
||||
}
|
||||
|
||||
@ -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 => `<tr><td>${hms(p.ts)}</td><td>${p.v} ms</td></tr>`).join('');
|
||||
return `<section class="conv-chart" data-metric="${k}"><h4>${title}</h4>
|
||||
${pts.length ? chartSvg(pts, summary, k) : ''}<p class="sr-only">${summary}</p>
|
||||
${pts.length ? chartSvg(pts, summary, k, W) : ''}<p class="sr-only">${summary}</p>
|
||||
<details><summary>${tr('Show as table')}</summary><table><caption class="sr-only">${summary}</caption>
|
||||
<thead><tr><th scope="col">${tr('Time')}</th><th scope="col">TTFB</th></tr></thead><tbody>${rows}</tbody></table></details></section>`;
|
||||
}).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).
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user