Add Conversation Playground + XTTS v2 backend + VibeVoice voice fix

Conversation Playground (new section):
- WhatsApp-style chat UI with user/assistant speech bubbles
- Click-to-record mic button using MediaRecorder API
- STT → LLM streaming → TTS pipeline via SSE (POST /api/conversation/turn)
- LLM tokens stream into assistant bubble in real time
- Audio auto-plays when TTS synthesises the reply
- Right-side stats panel: STT / LLM TTFT / LLM total / TTS / Total with bar chart
- Turn history list with per-turn total time and pass/fail indicator
- Configurable: STT backend, LLM URL + model, TTS backend + voice, system prompt
- Conversation history maintained across turns (last 20 messages sent to LLM)
- GET /api/conversation/llm-models proxies model list from any OpenAI-compatible LLM

XTTS v2 backend:
- Registers xtts as a first-class TTS backend (xtts_url setting, display name,
  capabilities, health/voice discovery, OpenAI-compatible generation)
- Added XTTS URL field to Settings → Connections
- Use-as-TTS button now saves to xtts_url (not tts_url)
- Batch benchmark backend select now refreshes alongside perf/preview selectors

VibeVoice fix:
- Added /voices to _TTS_VOICE_ENDPOINTS so VibeVoice voices are discovered

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-27 23:27:57 +02:00
parent 6ab67ec973
commit 6a78be7a28
8 changed files with 738 additions and 9 deletions

191
server.py
View File

@ -52,6 +52,7 @@ _WHISPER_CPP_DEFAULT = os.environ.get("WHISPER_CPP_URL", "http://host.dock
_GROQ_STT_ENDPOINT = "https://api.groq.com/openai/v1" _GROQ_STT_ENDPOINT = "https://api.groq.com/openai/v1"
_KOKORO_DEFAULT = os.environ.get("KOKORO_URL", "http://host.docker.internal:8880/v1") _KOKORO_DEFAULT = os.environ.get("KOKORO_URL", "http://host.docker.internal:8880/v1")
_VIBEVOICE_DEFAULT = os.environ.get("VIBEVOICE_URL", "http://192.168.178.8:8027") _VIBEVOICE_DEFAULT = os.environ.get("VIBEVOICE_URL", "http://192.168.178.8:8027")
_XTTS_DEFAULT = os.environ.get("XTTS_URL", "http://host.docker.internal:8024")
_TTS_CONTAINER = os.environ.get("TTS_CONTAINER_NAME", "faster-qwen3-tts") _TTS_CONTAINER = os.environ.get("TTS_CONTAINER_NAME", "faster-qwen3-tts")
_TTS_CONTAINERS_RAW = os.environ.get("TTS_CONTAINER_NAMES", "") # comma-separated override _TTS_CONTAINERS_RAW = os.environ.get("TTS_CONTAINER_NAMES", "") # comma-separated override
_VOICE_DESIGN_MODEL = os.environ.get("VOICE_DESIGN_MODEL", "Qwen3-TTS-12Hz-1.7B-VoiceDesign") _VOICE_DESIGN_MODEL = os.environ.get("VOICE_DESIGN_MODEL", "Qwen3-TTS-12Hz-1.7B-VoiceDesign")
@ -315,7 +316,7 @@ _SETTINGS_KEYS = {
"output_dir", "voices_scan_dir", "voice_design_url", "customvoice_url", "output_dir", "voices_scan_dir", "voice_design_url", "customvoice_url",
"nvidia_router_url", "nvidia_tts_url", "nvidia_asr_url", "nvidia_clone_url", "nvidia_router_url", "nvidia_tts_url", "nvidia_asr_url", "nvidia_clone_url",
"nvidia_zeroshot_url", "nvidia_flow_url", "nvidia_zeroshot_url", "nvidia_flow_url",
"faster_whisper_url", "whisper_cpp_url", "groq_api_key", "kokoro_url", "vibevoice_url", "faster_whisper_url", "whisper_cpp_url", "groq_api_key", "kokoro_url", "vibevoice_url", "xtts_url",
"whisper_api_key", "tts_api_key", "voice_design_api_key", "elevenlabs_api_key", "whisper_api_key", "tts_api_key", "voice_design_api_key", "elevenlabs_api_key",
"tts_stability_enabled", "tts_extra_params", "tts_extra_params_by_backend", "tts_stability_enabled", "tts_extra_params", "tts_extra_params_by_backend",
# Captures settings # Captures settings
@ -445,9 +446,12 @@ def _clean_preview_backend(value: str) -> str:
"vibevoice_service": "vibevoice", "vibevoice_service": "vibevoice",
"vibe_voice": "vibevoice", "vibe_voice": "vibevoice",
"vibetts": "vibevoice", "vibetts": "vibevoice",
"xtts_v2": "xtts",
"xtts2": "xtts",
"coqui_xtts": "xtts",
} }
key = aliases.get(key, key) key = aliases.get(key, key)
return key if key in {"voice_clone", "streaming", "customvoice", "voice_design", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow", "kokoro", "vibevoice"} else "voice_clone" return key if key in {"voice_clone", "streaming", "customvoice", "voice_design", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow", "kokoro", "vibevoice", "xtts"} else "voice_clone"
def _preview_backend_base_url(settings: dict, backend: str) -> str: def _preview_backend_base_url(settings: dict, backend: str) -> str:
@ -468,6 +472,8 @@ def _preview_backend_base_url(settings: dict, backend: str) -> str:
return settings.get("kokoro_url") or _KOKORO_DEFAULT return settings.get("kokoro_url") or _KOKORO_DEFAULT
if backend == "vibevoice": if backend == "vibevoice":
return settings.get("vibevoice_url") or _VIBEVOICE_DEFAULT return settings.get("vibevoice_url") or _VIBEVOICE_DEFAULT
if backend == "xtts":
return settings.get("xtts_url") or _XTTS_DEFAULT
return settings.get("tts_url") or _TTS_DEFAULT return settings.get("tts_url") or _TTS_DEFAULT
@ -507,6 +513,7 @@ def _load_settings() -> dict:
"groq_api_key": "", "groq_api_key": "",
"kokoro_url": _KOKORO_DEFAULT, "kokoro_url": _KOKORO_DEFAULT,
"vibevoice_url": _VIBEVOICE_DEFAULT, "vibevoice_url": _VIBEVOICE_DEFAULT,
"xtts_url": _XTTS_DEFAULT,
"whisper_api_key": "", "whisper_api_key": "",
"tts_api_key": "", "tts_api_key": "",
"voice_design_api_key": "", "voice_design_api_key": "",
@ -3064,7 +3071,7 @@ def _active_library_voice_options(settings: dict) -> list[dict]:
return voices return voices
_TTS_VOICE_ENDPOINTS = ("/v1/audio/voices", "/v1/audio/list_voices", "/v1/models", "/speakers") _TTS_VOICE_ENDPOINTS = ("/v1/audio/voices", "/v1/audio/list_voices", "/v1/models", "/speakers", "/voices")
def _voice_ids_from_payload(payload) -> list: def _voice_ids_from_payload(payload) -> list:
@ -3192,6 +3199,7 @@ def _backend_display_name(backend: str, url: str) -> str:
"nvidia_flow": "NVIDIA Magpie Flow Clone", "nvidia_flow": "NVIDIA Magpie Flow Clone",
"kokoro": "Kokoro FastAPI (82M)", "kokoro": "Kokoro FastAPI (82M)",
"vibevoice": "VibeVoice TTS", "vibevoice": "VibeVoice TTS",
"xtts": "XTTS v2",
} }
port = _backend_port_label(url) port = _backend_port_label(url)
return f"{port} {names.get(backend, backend)}" if port else names.get(backend, backend) return f"{port} {names.get(backend, backend)}" if port else names.get(backend, backend)
@ -3271,6 +3279,14 @@ def _backend_capabilities(backend: str) -> dict:
"uses_wav": False, "style_aware": False, "true_streaming": False, "uses_wav": False, "style_aware": False, "true_streaming": False,
"speed": "fast", "latency": "0.21 s", "quality": "High", "ram": "varies", "speed": "fast", "latency": "0.21 s", "quality": "High", "ram": "varies",
}, },
"xtts": {
"purpose": "XTTS v2 via xtts-api-server. OpenAI-compatible endpoint with speaker selection.",
"identity": "Uses speakers registered in the XTTS server; not WAV voice cloning.",
"style": "Speaker selected by voice ID. Style instruction not supported.",
"best_for": "Local multi-speaker TTS with XTTS v2 model. Coqui/daswer123 docker setup.",
"uses_wav": False, "style_aware": False, "true_streaming": False,
"speed": "~0.3× GPU", "latency": "13 s", "quality": "High", "ram": "46 GB VRAM",
},
} }
return caps.get(_clean_preview_backend(backend), {}) return caps.get(_clean_preview_backend(backend), {})
@ -3297,7 +3313,7 @@ def _backend_available(backend: str, voices: list, health: bool) -> bool:
async def tts_backends(): async def tts_backends():
settings = _load_settings() settings = _load_settings()
items = [] items = []
for backend in ("voice_clone", "voice_design", "customvoice", "streaming", "kokoro", "vibevoice", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow"): for backend in ("voice_clone", "voice_design", "customvoice", "streaming", "kokoro", "vibevoice", "xtts", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow"):
url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/") url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/")
voices = _fetch_backend_voices(settings, backend) voices = _fetch_backend_voices(settings, backend)
health = _backend_health(url) health = _backend_health(url)
@ -3786,6 +3802,14 @@ def _preview_request_audio(text: str, voice: str, settings: dict, instruct: str
) )
if backend == "vibevoice": if backend == "vibevoice":
return _vibevoice_request_audio(text, settings) return _vibevoice_request_audio(text, settings)
if backend == "xtts":
return _tts_request_audio(
text, voice, settings, instruct,
url_override=_preview_backend_base_url(settings, "xtts"),
api_key_override=settings.get("tts_api_key", ""),
backend_override="openai",
extra_backend="xtts",
)
return _tts_request_audio(text, voice, settings, instruct) return _tts_request_audio(text, voice, settings, instruct)
@ -5149,6 +5173,165 @@ async def mcp_sse(request: Request):
) )
# ── Conversation Playground ────────────────────────────────────────────────────
@app.get("/api/conversation/llm-models")
async def conversation_llm_models(url: str = ""):
"""List models from a local LLM endpoint (Ollama / vLLM / LM Studio)."""
settings = _load_settings()
base = (url or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
try:
base = _validate_http_url(base, allow_private=True)
r = requests.get(f"{base}/models", timeout=5, headers={"Authorization": "Bearer no-key"})
if r.status_code == 200:
payload = r.json()
data = payload.get("data", []) if isinstance(payload, dict) else []
models = [
str(item["id"]) if isinstance(item, dict) and item.get("id") else str(item)
for item in data if item
]
return {"models": models, "url": base}
except Exception:
pass
return {"models": [], "url": base}
@app.post("/api/conversation/turn")
async def conversation_turn(
audio: UploadFile = File(...),
stt_backend: str = Form("configured"),
llm_url: str = Form(""),
llm_model: str = Form(""),
tts_backend: str = Form("voice_clone"),
tts_voice: str = Form(""),
system_prompt: str = Form("You are a helpful voice assistant. Keep replies short and conversational."),
history: str = Form("[]"),
):
"""Stream a full conversation turn (STT → LLM → TTS) as Server-Sent Events."""
settings = _load_settings()
eff_llm_url = (llm_url or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
suffix = Path(audio.filename or "audio.webm").suffix.lower() or ".webm"
if suffix not in _UPLOAD_EXTS:
suffix = ".webm"
tmp = TEMP_DIR / f"{uuid.uuid4().hex}_conv{suffix}"
try:
with tmp.open("wb") as f:
_copy_limited(audio.file, f, _MAX_UPLOAD_BYTES)
wav_tmp = tmp if suffix == ".wav" else _to_wav_24k(tmp)
except Exception as e:
tmp.unlink(missing_ok=True)
raise HTTPException(400, f"Audio upload failed: {e}")
try:
hist = json.loads(history) if history else []
if not isinstance(hist, list):
hist = []
except Exception:
hist = []
stt_be = _clean_stt_backend(stt_backend)
tts_be = _clean_preview_backend(tts_backend)
_tmp, _wav = tmp, wav_tmp
async def generate():
t0 = time.monotonic()
stt_ms = llm_ttft_ms = llm_total_ms = tts_ms = None
transcript = llm_text = ""
def sse(obj: dict) -> str:
return f"data: {json.dumps(obj)}\n\n"
# 1. STT
try:
t_stt = time.monotonic()
transcript, _ = await asyncio.to_thread(_transcribe_audio, _wav, settings, stt_be)
stt_ms = int((time.monotonic() - t_stt) * 1000)
yield sse({"type": "transcript", "text": transcript, "stt_ms": stt_ms})
except Exception as e:
yield sse({"type": "error", "stage": "stt", "message": str(e)})
return
finally:
for p in {_tmp, _wav}:
try:
p.unlink(missing_ok=True)
except Exception:
pass
if not transcript.strip():
yield sse({"type": "error", "stage": "stt", "message": "No speech detected."})
return
# 2. LLM stream
messages = [{"role": "system", "content": system_prompt}]
messages.extend(hist[-20:])
messages.append({"role": "user", "content": transcript})
llm_payload: dict = {"messages": messages, "stream": True, "temperature": 0.7, "max_tokens": 512}
if llm_model:
llm_payload["model"] = llm_model
try:
t_llm = time.monotonic()
llm_resp = await asyncio.to_thread(lambda: requests.post(
f"{eff_llm_url}/chat/completions", json=llm_payload,
headers={"Authorization": "Bearer no-key"}, stream=True, timeout=120,
))
llm_resp.raise_for_status()
ttft_done = False
for raw_line in llm_resp.iter_lines():
if not raw_line:
continue
line = raw_line.decode("utf-8") if isinstance(raw_line, bytes) else str(raw_line)
if not line.startswith("data:"):
continue
chunk = line[5:].strip()
if chunk == "[DONE]":
break
try:
obj = json.loads(chunk)
delta = ((obj.get("choices") or [{}])[0].get("delta") or {}).get("content") or ""
if not delta and isinstance(obj.get("message"), dict):
delta = obj["message"].get("content") or ""
if delta:
if not ttft_done:
llm_ttft_ms = int((time.monotonic() - t_llm) * 1000)
ttft_done = True
llm_text += delta
yield sse({"type": "token", "delta": delta})
except Exception:
continue
llm_total_ms = int((time.monotonic() - t_llm) * 1000)
yield sse({"type": "llm_done", "text": llm_text,
"llm_ttft_ms": llm_ttft_ms, "llm_total_ms": llm_total_ms})
except Exception as e:
yield sse({"type": "error", "stage": "llm", "message": str(e)})
return
if not llm_text.strip():
yield sse({"type": "error", "stage": "llm", "message": "LLM returned empty response."})
return
# 3. TTS
try:
t_tts = time.monotonic()
audio_bytes, mime = await asyncio.to_thread(
_preview_request_audio, llm_text, tts_voice, settings, "", tts_be
)
tts_ms = int((time.monotonic() - t_tts) * 1000)
total_ms = int((time.monotonic() - t0) * 1000)
yield sse({"type": "audio", "b64": base64.b64encode(audio_bytes).decode(), "mime": mime})
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})
except Exception as e:
yield sse({"type": "error", "stage": "tts", "message": str(e)})
return
yield sse({"type": "done"})
return StreamingResponse(generate(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
# ── Static ──────────────────────────────────────────────────────────────────── # ── Static ────────────────────────────────────────────────────────────────────
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")

View File

@ -1943,6 +1943,12 @@ async function refreshTtsBackendAvailability(selected = '') {
perfSel.innerHTML = ttsBackendOptions(prev); perfSel.innerHTML = ttsBackendOptions(prev);
perfSel.disabled = !availableTtsBackends().length; perfSel.disabled = !availableTtsBackends().length;
} }
const batchSel = $('batch-backend-select');
if (batchSel) {
const prev = batchSel.value;
batchSel.innerHTML = ttsBackendOptions(prev);
batchSel.disabled = !availableTtsBackends().length;
}
updateBackendHelp(); updateBackendHelp();
updateStyleBackendHelp(); updateStyleBackendHelp();
updateBackendDependentTabs(); updateBackendDependentTabs();
@ -1959,6 +1965,7 @@ async function loadSettings() {
$('s-groq-api-key').value = s.groq_api_key || ''; $('s-groq-api-key').value = s.groq_api_key || '';
$('s-kokoro-url').value = s.kokoro_url || ''; $('s-kokoro-url').value = s.kokoro_url || '';
$('s-vibevoice-url').value = s.vibevoice_url || ''; $('s-vibevoice-url').value = s.vibevoice_url || '';
$('s-xtts-url').value = s.xtts_url || '';
const llmUrlEl = $('s-llm-url'); if (llmUrlEl) llmUrlEl.value = s.llm_url || ''; const llmUrlEl = $('s-llm-url'); if (llmUrlEl) llmUrlEl.value = s.llm_url || '';
_appSettings = s; _appSettings = s;
$('s-tts-stream-url').value = s.tts_stream_url || ''; $('s-tts-stream-url').value = s.tts_stream_url || '';
@ -2066,6 +2073,7 @@ document.addEventListener('click', async e => { if (!e.target.closest('.s-save-b
tts_url: $('s-tts-url').value, tts_url: $('s-tts-url').value,
kokoro_url: $('s-kokoro-url').value, kokoro_url: $('s-kokoro-url').value,
vibevoice_url: $('s-vibevoice-url').value, vibevoice_url: $('s-vibevoice-url').value,
xtts_url: $('s-xtts-url')?.value || '',
llm_url: $('s-llm-url')?.value || '', llm_url: $('s-llm-url')?.value || '',
tts_stream_url: $('s-tts-stream-url').value, tts_stream_url: $('s-tts-stream-url').value,
customvoice_url: $('s-customvoice-url').value, customvoice_url: $('s-customvoice-url').value,
@ -2103,6 +2111,7 @@ document.addEventListener('click', async e => { if (!e.target.closest('.s-save-b
_appSettings.nvidia_flow_url = $('s-nvidia-flow-url').value; _appSettings.nvidia_flow_url = $('s-nvidia-flow-url').value;
_appSettings.voice_design_url = $('s-voice-design-url').value; _appSettings.voice_design_url = $('s-voice-design-url').value;
_appSettings.vibevoice_url = $('s-vibevoice-url').value; _appSettings.vibevoice_url = $('s-vibevoice-url').value;
_appSettings.xtts_url = $('s-xtts-url')?.value || '';
_appSettings.tts_stream_mode = $('s-tts-stream-mode').value; _appSettings.tts_stream_mode = $('s-tts-stream-mode').value;
_ttsStreamHealth = null; _ttsStreamHealth = null;
await refreshTtsBackendAvailability($('tts-backend-select')?.value || ''); await refreshTtsBackendAvailability($('tts-backend-select')?.value || '');
@ -7378,9 +7387,9 @@ document.querySelectorAll('.dc-refresh-btn').forEach(b => b.addEventListener('cl
$('llm-use-xtts-tts')?.addEventListener('click', () => { $('llm-use-xtts-tts')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="xtts"]')?.value.trim() const url = document.querySelector('[data-llm-local-key="xtts"]')?.value.trim()
|| 'http://localhost:8020'; || 'http://localhost:8024';
applyAndSaveSettings({ tts_url: url }); applyAndSaveSettings({ xtts_url: url });
toast('XTTS v2 URL saved → tts_url in Settings.', 'success'); toast('XTTS v2 URL saved → xtts_url. It now appears as "XTTS v2" in the TTS backend dropdown.', 'success');
}); });
const LLM_USE_MAP = { const LLM_USE_MAP = {
@ -8119,3 +8128,363 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
toast('Import failed: ' + e.message, 'error'); toast('Import failed: ' + e.message, 'error');
} }
}); });
// ── Conversation Playground ────────────────────────────────────────────────
(function initConversationPlayground() {
const chatWindow = $('conv-chat-window');
const micBtn = $('conv-mic-btn');
const micIcon = $('conv-mic-icon');
const micStatus = $('conv-mic-status');
const micTimer = $('conv-mic-timer');
const clearBtn = $('conv-clear-btn');
const sttSel = $('conv-stt-select');
const llmUrlInp = $('conv-llm-url');
const llmFetchBtn = $('conv-llm-fetch-btn');
const llmModelSel = $('conv-llm-model-select');
const ttsBkSel = $('conv-tts-backend-select');
const ttsFetchBtn = $('conv-tts-fetch-btn');
const ttsVoiceSel = $('conv-tts-voice-select');
const systemPrompt = $('conv-system-prompt');
const turnHistory = $('conv-turn-history');
if (!chatWindow || !micBtn) return;
let mediaRecorder = null;
let recChunks = [];
let recTimerInterval = null;
let recStart = 0;
let conversationHistory = [];
let turnCount = 0;
let isProcessing = false;
// ── Populate STT backends ────────────────────────────────────────────────
async function loadConvSttBackends() {
if (!sttSel) return;
try {
const d = await fetch('/api/stt-backends').then(r => r.json());
const avail = (d.backends || []).filter(b => b.available);
sttSel.innerHTML = avail.length
? avail.map(b => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).join('')
: '<option value="configured">Default (configured)</option>';
} catch(_) {
sttSel.innerHTML = '<option value="configured">Default (configured)</option>';
}
}
// ── Populate TTS backends (reuse global _ttsBackends) ───────────────────
function populateConvTtsBackends() {
if (!ttsBkSel) return;
const avail = availableTtsBackends();
ttsBkSel.innerHTML = avail.length
? avail.map(b => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).join('')
: '<option value="">No TTS backend available</option>';
}
// ── Fetch LLM models ─────────────────────────────────────────────────────
async function fetchLlmModels() {
if (!llmModelSel) return;
const url = llmUrlInp?.value.trim() || '';
llmFetchBtn.disabled = true;
try {
const d = await fetch('/api/conversation/llm-models' + (url ? '?url=' + encodeURIComponent(url) : '')).then(r => r.json());
const models = d.models || [];
llmModelSel.innerHTML = models.length
? models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('')
: '<option value="">No models found</option>';
} catch(e) {
llmModelSel.innerHTML = '<option value="">Fetch failed</option>';
} finally {
llmFetchBtn.disabled = false;
}
}
// ── Fetch TTS voices ─────────────────────────────────────────────────────
async function fetchConvTtsVoices() {
if (!ttsVoiceSel || !ttsBkSel) return;
const backend = ttsBkSel.value;
if (!backend) return;
ttsFetchBtn.disabled = true;
try {
const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
ttsVoiceSel.innerHTML = rawVoices.length
? rawVoices.map(v => { const id = backendVoiceId(v); return `<option value="${escHtml(id)}">${escHtml(id)}</option>`; }).join('')
: '<option value="">No voices</option>';
} catch(e) {
ttsVoiceSel.innerHTML = '<option value="">Fetch failed</option>';
} finally {
ttsFetchBtn.disabled = false;
}
}
// ── Chat bubble helpers ──────────────────────────────────────────────────
function timeStr() {
const now = new Date();
return now.getHours().toString().padStart(2,'0') + ':' + now.getMinutes().toString().padStart(2,'0');
}
function removeWelcome() {
const w = chatWindow.querySelector('.conv-chat-welcome');
if (w) w.remove();
}
function addBubble(role, text) {
removeWelcome();
const wrap = document.createElement('div');
wrap.className = `conv-bubble-wrap conv-bubble-wrap--${role}`;
const bubble = document.createElement('div');
bubble.className = `conv-bubble conv-bubble--${role}`;
bubble.textContent = text || '';
const meta = document.createElement('div');
meta.className = 'conv-bubble-meta';
meta.textContent = timeStr();
wrap.appendChild(bubble);
wrap.appendChild(meta);
chatWindow.appendChild(wrap);
chatWindow.scrollTop = chatWindow.scrollHeight;
return bubble;
}
function addTypingBubble() {
removeWelcome();
const wrap = document.createElement('div');
wrap.className = 'conv-bubble-wrap conv-bubble-wrap--assistant';
wrap.id = 'conv-typing-wrap';
const bubble = document.createElement('div');
bubble.className = 'conv-bubble conv-bubble--assistant';
bubble.innerHTML = '<span class="conv-typing"><span></span><span></span><span></span></span>';
wrap.appendChild(bubble);
chatWindow.appendChild(wrap);
chatWindow.scrollTop = chatWindow.scrollHeight;
return bubble;
}
function addErrorBubble(msg) {
removeWelcome();
const wrap = document.createElement('div');
wrap.className = 'conv-bubble-wrap conv-bubble-wrap--assistant';
const bubble = document.createElement('div');
bubble.className = 'conv-bubble conv-bubble--error';
bubble.innerHTML = `<span class="mdi mdi-alert-outline"></span> ${escHtml(msg)}`;
wrap.appendChild(bubble);
chatWindow.appendChild(wrap);
chatWindow.scrollTop = chatWindow.scrollHeight;
}
// ── Stats panel ──────────────────────────────────────────────────────────
function fmtMs(ms) { return ms == null ? '—' : ms >= 1000 ? (ms/1000).toFixed(2)+'s' : ms+'ms'; }
function updateStatBar(id, val, maxVal) {
const fill = $(id);
if (fill) fill.style.width = maxVal > 0 ? Math.min(100, (val / maxVal) * 100) + '%' : '0%';
}
function updateStats(stats) {
const { stt_ms, llm_ttft_ms, llm_total_ms, tts_ms, total_ms } = stats;
const max = total_ms || 1;
const set = (valId, fillId, ms) => {
const el = $(valId); if (el) el.textContent = fmtMs(ms);
updateStatBar(fillId, ms || 0, max);
};
set('cpv-stt', 'cpf-stt', stt_ms);
set('cpv-ttft', 'cpf-ttft', llm_ttft_ms);
set('cpv-llm', 'cpf-llm', llm_total_ms);
set('cpv-tts', 'cpf-tts', tts_ms);
set('cpv-total', 'cpf-total', total_ms);
}
function addHistoryItem(n, totalMs, ok) {
const empty = turnHistory?.querySelector('.conv-history-empty');
if (empty) empty.remove();
const item = document.createElement('div');
item.className = 'conv-hist-item';
const cls = ok ? 'conv-hist-ok' : 'conv-hist-err';
const icon = ok ? 'mdi-check-circle-outline' : 'mdi-alert-outline';
item.innerHTML = `<span class="conv-hist-num">#${n}</span>
<span class="${cls}"><span class="mdi ${icon}"></span></span>
<span class="conv-hist-time ${cls}">${fmtMs(totalMs)}</span>`;
turnHistory.insertBefore(item, turnHistory.firstChild);
}
// ── Recording ────────────────────────────────────────────────────────────
function startRecTimer() {
recStart = Date.now();
recTimerInterval = setInterval(() => {
const s = Math.floor((Date.now() - recStart) / 1000);
if (micTimer) micTimer.textContent = s + 's';
}, 500);
}
function stopRecTimer() {
clearInterval(recTimerInterval);
if (micTimer) micTimer.textContent = '';
}
async function startRecording() {
if (isProcessing) return;
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch(e) {
toast('Microphone access denied: ' + e.message, 'error');
return;
}
recChunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = e => { if (e.data.size > 0) recChunks.push(e.data); };
mediaRecorder.onstop = () => {
stream.getTracks().forEach(t => t.stop());
const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || 'audio/webm' });
processBlob(blob);
};
mediaRecorder.start();
micBtn.classList.add('recording');
micIcon.className = 'mdi mdi-stop';
if (micStatus) micStatus.textContent = 'Recording… click to stop';
startRecTimer();
}
function stopRecording() {
if (!mediaRecorder || mediaRecorder.state === 'inactive') return;
mediaRecorder.stop();
stopRecTimer();
micBtn.classList.remove('recording');
micBtn.classList.add('processing');
micIcon.className = 'mdi mdi-dots-horizontal';
if (micStatus) micStatus.textContent = 'Processing…';
isProcessing = true;
}
// ── Send turn via SSE ─────────────────────────────────────────────────────
async function processBlob(blob) {
turnCount++;
const turnN = turnCount;
const t0 = Date.now();
// Show user bubble with placeholder
const userBubble = addBubble('user', '…');
const assistantBubble = addTypingBubble();
let assistantText = '';
let lastStats = null;
const form = new FormData();
form.append('audio', blob, 'audio.webm');
form.append('stt_backend', sttSel?.value || 'configured');
form.append('llm_url', llmUrlInp?.value.trim() || '');
form.append('llm_model', llmModelSel?.value || '');
form.append('tts_backend', ttsBkSel?.value || 'voice_clone');
form.append('tts_voice', ttsVoiceSel?.value || '');
form.append('system_prompt', systemPrompt?.value.trim() || 'You are a helpful voice assistant.');
form.append('history', JSON.stringify(conversationHistory.slice(-20)));
try {
const resp = await fetch('/api/conversation/turn', { method: 'POST', body: form });
if (!resp.ok) throw new Error('Server error ' + resp.status);
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data:')) continue;
let evt;
try { evt = JSON.parse(line.slice(5).trim()); } catch(_) { continue; }
if (evt.type === 'transcript') {
userBubble.textContent = evt.text || '(empty)';
if (micStatus) micStatus.textContent = 'Generating reply…';
} else if (evt.type === 'token') {
if (assistantBubble.querySelector('.conv-typing')) {
assistantBubble.innerHTML = '';
}
assistantText += evt.delta;
assistantBubble.textContent = assistantText;
chatWindow.scrollTop = chatWindow.scrollHeight;
} else if (evt.type === 'llm_done') {
assistantText = evt.text || assistantText;
assistantBubble.textContent = assistantText;
if (micStatus) micStatus.textContent = 'Synthesising speech…';
} else if (evt.type === 'audio') {
const mime = evt.mime || 'audio/wav';
const binStr = atob(evt.b64);
const arr = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) arr[i] = binStr.charCodeAt(i);
const audioBlob = new Blob([arr], { type: mime });
const url = URL.createObjectURL(audioBlob);
const audio = new Audio(url);
audio.onended = () => URL.revokeObjectURL(url);
audio.play().catch(() => {});
if (micStatus) micStatus.textContent = 'Speaking…';
} else if (evt.type === 'stats') {
lastStats = evt;
updateStats(evt);
} else if (evt.type === 'done') {
conversationHistory.push({ role: 'user', content: userBubble.textContent });
conversationHistory.push({ role: 'assistant', content: assistantText });
addHistoryItem(turnN, lastStats?.total_ms ?? (Date.now() - t0), true);
if (micStatus) micStatus.textContent = 'Ready';
} else if (evt.type === 'error') {
const wrap = assistantBubble.closest('.conv-bubble-wrap');
if (wrap) wrap.remove();
addErrorBubble(`[${evt.stage?.toUpperCase() || 'ERR'}] ${evt.message}`);
addHistoryItem(turnN, Date.now() - t0, false);
if (micStatus) micStatus.textContent = 'Error — ready';
}
}
}
} catch(e) {
const wrap = assistantBubble.closest('.conv-bubble-wrap');
if (wrap) wrap.remove();
addErrorBubble(e.message);
addHistoryItem(turnN, Date.now() - t0, false);
if (micStatus) micStatus.textContent = 'Error — ready';
} finally {
isProcessing = false;
micBtn.classList.remove('processing');
micIcon.className = 'mdi mdi-microphone';
}
}
// ── Wire up events ───────────────────────────────────────────────────────
micBtn.addEventListener('click', () => {
if (isProcessing) return;
if (mediaRecorder && mediaRecorder.state === 'recording') {
stopRecording();
} else {
startRecording();
}
});
clearBtn?.addEventListener('click', () => {
conversationHistory = [];
turnCount = 0;
chatWindow.innerHTML = '<div class="conv-chat-welcome"><span class="mdi mdi-forum-outline" style="font-size:32px;opacity:.25"></span><p>Press the microphone button below and start talking.</p></div>';
if (turnHistory) turnHistory.innerHTML = '<div class="conv-history-empty">No turns yet.</div>';
['cpv-stt','cpv-ttft','cpv-llm','cpv-tts','cpv-total'].forEach(id => { const el = $(id); if(el) el.textContent='—'; });
['cpf-stt','cpf-ttft','cpf-llm','cpf-tts','cpf-total'].forEach(id => { const el = $(id); if(el) el.style.width='0%'; });
});
llmFetchBtn?.addEventListener('click', fetchLlmModels);
ttsFetchBtn?.addEventListener('click', fetchConvTtsVoices);
// Re-populate TTS when backend changes
ttsBkSel?.addEventListener('change', () => { ttsVoiceSel.innerHTML = '<option value="">— fetch voices —</option>'; });
// ── Init ─────────────────────────────────────────────────────────────────
loadConvSttBackends();
populateConvTtsBackends();
// Keep TTS backend select in sync after global backend refresh
const origRefresh = window.refreshTtsBackendAvailability;
if (typeof origRefresh === 'function') {
window.refreshTtsBackendAvailability = async function(...args) {
const result = await origRefresh.apply(this, args);
populateConvTtsBackends();
return result;
};
}
})();

View File

@ -65,6 +65,9 @@
<div class="nav-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')"> <div class="nav-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')">
<span class="nav-icon"><span class="mdi mdi-play"></span></span> Try It Out <span class="nav-icon"><span class="mdi mdi-play"></span></span> Try It Out
</div> </div>
<div class="nav-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')">
<span class="nav-icon"><span class="mdi mdi-forum-outline"></span></span> Conversation
</div>
<div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')"> <div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')">
<span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark <span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark
</div> </div>
@ -122,6 +125,7 @@
<section class="page-section" id="s-connect"></section> <section class="page-section" id="s-connect"></section>
<section class="page-section" id="s-settings"></section> <section class="page-section" id="s-settings"></section>
<section class="page-section" id="s-llms"></section> <section class="page-section" id="s-llms"></section>
<section class="page-section" id="s-conversation"></section>
</main> </main>
</div><!-- /app-shell --> </div><!-- /app-shell -->

View File

@ -1,7 +1,7 @@
(async function () { (async function () {
'use strict'; 'use strict';
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms']; const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation'];
function loadScript(src) { function loadScript(src) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {

View File

@ -18,7 +18,7 @@
llms: 's-llms' llms: 's-llms'
}; };
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms']; const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation'];
function runSideEffects(name) { function runSideEffects(name) {
if (name === 'library' && typeof loadVoiceLibrary === 'function') loadVoiceLibrary(); if (name === 'library' && typeof loadVoiceLibrary === 'function') loadVoiceLibrary();

View File

@ -0,0 +1,101 @@
<div class="section-head">
<span class="section-icon"><span class="mdi mdi-forum-outline"></span></span>
<div class="section-title">
<h2>Conversation Playground</h2>
<p>Talk to an AI voice agent. Full STT &rarr; LLM &rarr; TTS pipeline with real-time streaming and latency stats.</p>
</div>
</div>
<!-- Config bar -->
<div class="conv-config-bar card">
<div class="conv-config-row">
<div class="conv-config-group">
<label class="conv-cfg-label"><span class="mdi mdi-microphone"></span> Speech to Text</label>
<select id="conv-stt-select"><option value="configured">Checking...</option></select>
</div>
<div class="conv-config-group">
<label class="conv-cfg-label"><span class="mdi mdi-brain"></span> Language Model</label>
<div style="display:flex;gap:6px">
<input id="conv-llm-url" class="conv-url-inp" type="text" placeholder="http://localhost:11434/v1" spellcheck="false">
<button class="btn-secondary" id="conv-llm-fetch-btn" title="Fetch models"><span class="mdi mdi-refresh"></span></button>
<select id="conv-llm-model-select"><option value="">— fetch models —</option></select>
</div>
</div>
<div class="conv-config-group">
<label class="conv-cfg-label"><span class="mdi mdi-text-to-speech"></span> Text to Speech</label>
<div style="display:flex;gap:6px">
<select id="conv-tts-backend-select"><option value="">Checking...</option></select>
<button class="btn-secondary" id="conv-tts-fetch-btn" title="Fetch voices"><span class="mdi mdi-refresh"></span></button>
<select id="conv-tts-voice-select"><option value="">— fetch voices —</option></select>
</div>
</div>
<div class="conv-config-group conv-config-group--actions">
<button class="btn-secondary" id="conv-clear-btn" title="Clear conversation"><span class="mdi mdi-delete-outline"></span> Clear</button>
</div>
</div>
<div class="conv-prompt-row">
<label class="conv-cfg-label"><span class="mdi mdi-text-box-outline"></span> System prompt</label>
<textarea id="conv-system-prompt" class="conv-system-textarea" rows="1" spellcheck="false">You are a helpful voice assistant. Keep replies short and conversational.</textarea>
</div>
</div>
<!-- Main area: chat + stats -->
<div class="conv-main">
<!-- Chat window -->
<div class="conv-chat-panel">
<div class="conv-chat-window" id="conv-chat-window">
<div class="conv-chat-welcome">
<span class="mdi mdi-forum-outline" style="font-size:32px;opacity:.25"></span>
<p>Press the microphone button below and start talking.</p>
</div>
</div>
<!-- Mic bar -->
<div class="conv-mic-bar">
<div class="conv-mic-status" id="conv-mic-status">Ready</div>
<button class="conv-mic-btn" id="conv-mic-btn" title="Click to record">
<span class="mdi mdi-microphone" id="conv-mic-icon"></span>
</button>
<div class="conv-mic-timer" id="conv-mic-timer"></div>
</div>
</div>
<!-- Stats panel -->
<div class="conv-stats-panel">
<div class="conv-stats-head">Latency</div>
<div class="conv-pipeline">
<div class="conv-pipe-step" id="cps-stt">
<div class="conv-pipe-label"><span class="mdi mdi-microphone-outline"></span> STT</div>
<div class="conv-pipe-bar"><div class="conv-pipe-fill" id="cpf-stt"></div></div>
<div class="conv-pipe-val" id="cpv-stt"></div>
</div>
<div class="conv-pipe-step" id="cps-ttft">
<div class="conv-pipe-label"><span class="mdi mdi-timer-outline"></span> LLM first token</div>
<div class="conv-pipe-bar"><div class="conv-pipe-fill" id="cpf-ttft"></div></div>
<div class="conv-pipe-val" id="cpv-ttft"></div>
</div>
<div class="conv-pipe-step" id="cps-llm">
<div class="conv-pipe-label"><span class="mdi mdi-brain"></span> LLM total</div>
<div class="conv-pipe-bar"><div class="conv-pipe-fill" id="cpf-llm"></div></div>
<div class="conv-pipe-val" id="cpv-llm"></div>
</div>
<div class="conv-pipe-step" id="cps-tts">
<div class="conv-pipe-label"><span class="mdi mdi-text-to-speech"></span> TTS</div>
<div class="conv-pipe-bar"><div class="conv-pipe-fill" id="cpf-tts"></div></div>
<div class="conv-pipe-val" id="cpv-tts"></div>
</div>
<div class="conv-pipe-step conv-pipe-total" id="cps-total">
<div class="conv-pipe-label"><span class="mdi mdi-timer-check-outline"></span> Total</div>
<div class="conv-pipe-bar"><div class="conv-pipe-fill" id="cpf-total" style="background:var(--accent)"></div></div>
<div class="conv-pipe-val" id="cpv-total"></div>
</div>
</div>
<div class="conv-stats-head" style="margin-top:14px">Turn history</div>
<div class="conv-turn-history" id="conv-turn-history">
<div class="conv-history-empty">No turns yet.</div>
</div>
</div>
</div>

View File

@ -77,6 +77,11 @@
<input type="text" id="s-vibevoice-url" placeholder="http://192.168.178.8:8027"> <input type="text" id="s-vibevoice-url" placeholder="http://192.168.178.8:8027">
<span class="s-hint">Simple text-in audio-out TTS. POST /tts with {"text":"..."}.</span> <span class="s-hint">Simple text-in audio-out TTS. POST /tts with {"text":"..."}.</span>
</div> </div>
<div class="s-field">
<label>XTTS v2 URL <span class="s-label-note">(xtts-api-server)</span></label>
<input type="text" id="s-xtts-url" placeholder="http://host.docker.internal:8024">
<span class="s-hint">XTTS v2 via daswer123/xtts-api-server. Supports <code>GET /speakers</code> and <code>POST /v1/audio/speech</code>.</span>
</div>
</div> </div>
</div> </div>

View File

@ -1911,3 +1911,70 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
} }
.preview-persona-btn:hover { background: rgba(13,148,136,.12); } .preview-persona-btn:hover { background: rgba(13,148,136,.12); }
.preview-persona-btn:disabled { opacity: .5; cursor: default; } .preview-persona-btn:disabled { opacity: .5; cursor: default; }
/* ── Conversation Playground ─────────────────────────────────────────────── */
.conv-config-bar { padding: 14px 18px 10px; margin-bottom: 0; }
.conv-config-row { display: flex; gap: 14px; flex-wrap: wrap; align-items: flex-end; }
.conv-config-group { display: flex; flex-direction: column; gap: 4px; }
.conv-config-group--actions { margin-left: auto; }
.conv-cfg-label { font-size: 11px; font-weight: 700; color: var(--subtext); text-transform: uppercase; letter-spacing: .06em; }
.conv-url-inp { width: 220px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 13px; background: var(--surface); color: var(--text); }
.conv-prompt-row { display: flex; align-items: flex-start; gap: 10px; margin-top: 10px; border-top: 1px solid var(--border); padding-top: 10px; }
.conv-prompt-row .conv-cfg-label { padding-top: 7px; white-space: nowrap; }
.conv-system-textarea { flex: 1; resize: vertical; min-height: 34px; max-height: 120px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--radius); font-size: 13px; font-family: var(--font); background: var(--surface); color: var(--text); }
/* Layout */
.conv-main { display: flex; gap: 14px; margin-top: 14px; min-height: 520px; }
.conv-chat-panel { flex: 1; display: flex; flex-direction: column; gap: 0; min-width: 0; }
.conv-stats-panel { width: 280px; flex-shrink: 0; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; display: flex; flex-direction: column; gap: 8px; }
/* Chat window */
.conv-chat-window { flex: 1; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius) var(--radius) 0 0; padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; min-height: 400px; }
.conv-chat-welcome { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; flex: 1; color: var(--subtext); text-align: center; }
/* Bubbles */
.conv-bubble-wrap { display: flex; flex-direction: column; max-width: 72%; }
.conv-bubble-wrap--user { align-self: flex-end; align-items: flex-end; }
.conv-bubble-wrap--assistant { align-self: flex-start; align-items: flex-start; }
.conv-bubble { padding: 10px 14px; border-radius: 16px; font-size: 14.5px; line-height: 1.55; word-break: break-word; }
.conv-bubble--user { background: var(--accent); color: #fff; border-bottom-right-radius: 4px; }
.conv-bubble--assistant { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-bottom-left-radius: 4px; }
.conv-bubble--error { background: rgba(220,38,38,.08); color: var(--red); border: 1px solid rgba(220,38,38,.2); border-radius: 10px; padding: 8px 12px; font-size: 13px; }
.conv-bubble-meta { font-size: 11px; color: var(--subtext); margin-top: 3px; padding: 0 4px; }
/* Typing dots */
.conv-typing { display: inline-flex; gap: 4px; align-items: center; padding: 4px 2px; }
.conv-typing span { width: 7px; height: 7px; border-radius: 50%; background: var(--subtext); opacity: .4; animation: convDot 1.2s infinite; }
.conv-typing span:nth-child(2) { animation-delay: .2s; }
.conv-typing span:nth-child(3) { animation-delay: .4s; }
@keyframes convDot { 0%,80%,100% { transform: scale(.7); opacity:.3; } 40% { transform: scale(1); opacity:.9; } }
/* Mic bar */
.conv-mic-bar { background: var(--surface); border: 1px solid var(--border); border-top: none; border-radius: 0 0 var(--radius) var(--radius); padding: 12px 16px; display: flex; align-items: center; gap: 14px; }
.conv-mic-btn { width: 52px; height: 52px; border-radius: 50%; border: 2px solid var(--accent); background: var(--accent); color: #fff; font-size: 22px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background .15s, transform .1s, box-shadow .15s; flex-shrink: 0; }
.conv-mic-btn:hover { background: #1d4ed8; }
.conv-mic-btn.recording { background: var(--red); border-color: var(--red); animation: convPulse 1s infinite; }
.conv-mic-btn.processing { background: var(--yellow); border-color: var(--yellow); cursor: default; }
@keyframes convPulse { 0%,100% { box-shadow: 0 0 0 0 rgba(220,38,38,.4); } 50% { box-shadow: 0 0 0 8px rgba(220,38,38,0); } }
.conv-mic-status { flex: 1; font-size: 13px; color: var(--subtext); }
.conv-mic-timer { font-size: 13px; font-variant-numeric: tabular-nums; color: var(--red); min-width: 36px; text-align: right; }
/* Stats panel */
.conv-stats-head { font-size: 10px; font-weight: 800; color: var(--subtext); text-transform: uppercase; letter-spacing: .08em; }
.conv-pipeline { display: flex; flex-direction: column; gap: 8px; }
.conv-pipe-step { display: grid; grid-template-columns: 1fr auto; grid-template-rows: auto auto; gap: 2px 8px; }
.conv-pipe-label { grid-column: 1; font-size: 12px; color: var(--subtext); }
.conv-pipe-val { grid-column: 2; grid-row: 1 / 3; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--text); align-self: center; text-align: right; min-width: 52px; }
.conv-pipe-bar { grid-column: 1; height: 4px; background: var(--panel); border-radius: 2px; overflow: hidden; }
.conv-pipe-fill { height: 100%; background: var(--teal); border-radius: 2px; width: 0%; transition: width .4s ease; }
.conv-pipe-total .conv-pipe-label { font-weight: 700; color: var(--text); }
.conv-pipe-total .conv-pipe-val { color: var(--accent); font-size: 15px; }
/* Turn history */
.conv-turn-history { display: flex; flex-direction: column; gap: 5px; overflow-y: auto; max-height: 220px; }
.conv-history-empty { font-size: 12px; color: var(--subtext); font-style: italic; }
.conv-hist-item { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 4px 6px; border-radius: 5px; background: var(--panel); }
.conv-hist-num { font-weight: 700; color: var(--subtext); min-width: 20px; }
.conv-hist-time { font-variant-numeric: tabular-nums; font-weight: 700; margin-left: auto; }
.conv-hist-ok { color: var(--green); }
.conv-hist-err { color: var(--red); }