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:
parent
6ab67ec973
commit
6a78be7a28
191
server.py
191
server.py
@ -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.2–1 s", "quality": "High", "ram": "varies",
|
"speed": "fast", "latency": "0.2–1 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": "1–3 s", "quality": "High", "ram": "4–6 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")
|
||||||
|
|||||||
375
static/app.js
375
static/app.js
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|||||||
@ -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 -->
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
101
static/sections/s-conversation.html
Normal file
101
static/sections/s-conversation.html
Normal 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 → LLM → 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>
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user