feat(voices): redesigned My Voices list, working copy, engine-aware TTS availability

My Voices
- Top bar with search, filter toggle (active-count badge), synth-source drawer, group and table buttons;
  filters/sort in a collapsible labelled panel; one-line sort header with select-all/none checkbox
- Groups chip list is a collapsed accordion; bottom bar is Refresh/Sync/Import plus one Actions (N) menu
  (Edit / Audio / Share / Danger), disabled until voices are checked; touch targets stay 44px
- Copy selected works again (copyText was no longer exported), shows "N voice names copied to clipboard"
  and falls back to a copy dialog when the async clipboard is unavailable

TTS / engines
- Health probe works for /v1 card URLs and, behind a LiteLLM gateway, checks the selected model's own
  upstream so stopped containers are not reported as available
- Unhiding a voice publishes a missing entry to the engine's voices.json (no more silent default-voice fallback)
- Status-bar TTS picker also drives the My Voices and STT-test selectors; Use as TTS refreshes the status bar

Includes the engine connect/disconnect, custom TTS, STT footer and magazine auth work that was already
in the working tree, with their tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-11 04:44:27 +02:00
parent b365894e44
commit 0551b6651f
32 changed files with 3047 additions and 354 deletions

View File

@ -7,15 +7,24 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Added ### Added
- **My Voices list header:** a sort header (select-all/none checkbox with a partial-selection state, then sortable Name / Lang / Gender / Speed / Rating with the active column highlighted) sits directly above the voice list, and the filter row starts with a filter icon. The full table header still takes over in table view.
- **QA: Python tests in a throwaway container** (`npm run qa -- --only pyunit --container`): runs the unit tests on production's Python 3.11 in a no-network, read-only container built from the app image on a writable throwaway copy of the read-only mounted repo (`docker/Dockerfile.test` adds only `httpx2` and `nodejs`); opt-in, SKIPPED when docker or the image is missing. - **QA: Python tests in a throwaway container** (`npm run qa -- --only pyunit --container`): runs the unit tests on production's Python 3.11 in a no-network, read-only container built from the app image on a writable throwaway copy of the read-only mounted repo (`docker/Dockerfile.test` adds only `httpx2` and `nodejs`); opt-in, SKIPPED when docker or the image is missing.
- **Sonar triage `--drain`:** works through open agent issues one at a time, fires the multica webhook, waits for the agent's answer on GitHub and resends once if nothing arrives; stops on a stalled issue. - **Sonar triage `--drain`:** works through open agent issues one at a time, fires the multica webhook, waits for the agent's answer on GitHub and resends once if nothing arrives; stops on a stalled issue.
- **Sonar triage can fire a multica webhook autopilot** after each GitHub issue (URL from `~/.config/multica-webhook` or `MULTICA_WEBHOOK_URL`, passed to curl on stdin only); the multica CLI stays as fallback. - **Sonar triage can fire a multica webhook autopilot** after each GitHub issue (URL from `~/.config/multica-webhook` or `MULTICA_WEBHOOK_URL`, passed to curl on stdin only); the multica CLI stays as fallback.
### Changed ### Changed
- **My Voices bottom bar simplified:** Refresh, Sync and Import bundle stay as icon buttons (labels as tooltips / for screen readers) so the whole bar fits one row; every selection-based action now lives in one **Actions (N)** menu grouped Edit / Audio / Share / Danger (disabled until voices are checked; closes on pick, outside click or Esc).
- **My Voices preview text / synth source** ("Synth uses" Preview text / Reference transcript + sample sentence) moved from its own always-visible card into a drawer behind an icon in the list's top bar (state remembered; the icon tooltip shows the active mode). In the stacked/mobile layout the voice list now has a bounded height and the page scrolls, so opening both drawers no longer squeezes the rows or clips the bottom toolbar.
- **My Voices filter area redesigned:** one search row with a Filters button (badge = number of active filters) plus group-by-tag / table-view buttons. Language, gender, type, tag, rating, group, sort field/order, "Show disabled", "Clear filters" and the Groups accordion moved into a labelled, collapsible panel (remembered per browser); the sort header above the list stays on one line.
- **My Voices list:** the Groups chip list is a collapsed accordion (summary shows the group count and the active group, with "Show all"); the bulk actions (Set tag, Set source, Hide, Unhide, Rate, Delete) moved from the selection bar into the bottom toolbar; every selection-based button (Export selected, Copy selected, Precompute, Calc dB, Benchmark, Batch seeds, Verify, Set tag, Set source, Hide, Unhide, Rate, Delete) is disabled, with a tooltip, until voices are checked; only Refresh, Sync and Import bundle are always enabled; the redundant All / None buttons are gone (use the header checkbox).
- QA: the Sonar triage script groups medium/large findings per rule instead of per (rule, file): one GitHub issue per rule with a file/line table (max 40 rows, 'and N more'; split into 'part n/m' beyond 40 locations); dedupe marker still carries all Sonar keys. - QA: the Sonar triage script groups medium/large findings per rule instead of per (rule, file): one GitHub issue per rule with a file/line table (max 40 rows, 'and N more'; split into 'part n/m' beyond 40 locations); dedupe marker still carries all Sonar keys.
- Conversation Playground: the round microphone button is red (pressed, "Stop microphone") while the mic is hot (live or recording) and blue ("Start microphone") when off; it returns to blue on stop, auto-stop and mic errors. The pulse ring only animates when reduced motion is not requested. - Conversation Playground: the round microphone button is red (pressed, "Stop microphone") while the mic is hot (live or recording) and blue ("Start microphone") when off; it returns to blue on stop, auto-stop and mic errors. The pulse ring only animates when reduced motion is not requested.
- Conversation Playground: the metrics (Token Usage and STT / LLM first token / TTS first audio TTFB charts) now sit in the right-hand Latency column below Turn history instead of a second "Metrics" tab; the Conversation | Metrics tablist is removed. Charts gain axis titles ("Time (ms)", "Time"), a legend and three y-ticks. - Conversation Playground: the metrics (Token Usage and STT / LLM first token / TTS first audio TTFB charts) now sit in the right-hand Latency column below Turn history instead of a second "Metrics" tab; the Conversation | Metrics tablist is removed. Charts gain axis titles ("Time (ms)", "Time"), a legend and three y-ticks.
### Fixed ### Fixed
- **Status-bar TTS picker did not drive My Voices "Play":** choosing an engine in the status bar updated the preview/reader/rehearser selectors but not the My Voices (library) one, so Play kept using the previously selected engine (e.g. a stopped VoiceClone) and failed with a 500. It now also sets the library and Speech-to-Text-test selectors, and the toast names the engine.
- **Unhidden voices fell back to the default voice:** unhiding moved the files but never registered the voice in the TTS engine's `voices.json` (only saving a transcript or importing did), so the engine logged "Voice … not configured; falling back to default voice" and spoke with another voice. Unhiding now publishes a missing entry from the voice's reference transcript; existing entries (and their cached speaker embeddings) are left alone.
- **"No TTS backend" although engines show Connected:** the TTS health probe appended `/health` to the card URL (`…:4000/v1/health` = 404), so every custom TTS engine behind a LiteLLM gateway was judged unreachable. Card URLs ending in `/v1` are now probed from the server root, and for engines behind a LiteLLM gateway the probe asks the gateway (`/model/info`, cached 30 s) for the selected model's upstream and checks that engine itself, so a stopped VoiceClone container is shown as unavailable even though the gateway is up (the gateway's unauthenticated `/health/liveliness` is only the fallback); and "Use as TTS / STT" refreshes the status bar immediately.
- **Copy buttons did nothing** (My Voices "Copy selected", Connect "Copy active voices", engine/voice-inspector/voice-source copy): `copyText` was no longer exported from utils.js after the module scoping, so the click threw `ReferenceError`. It is exported again, now reports whether the copy succeeded, and "Copy selected" shows a copy icon, a "N voice names copied to clipboard" message, and a dialog with the pre-selected list when the async clipboard API is unavailable or refused (plain-HTTP LAN origin, embedded webviews), since the legacy fallback cannot be verified.
- Sonar: removed 43 small Sonar findings in 20 files: unused variables and dead helper functions (frontend), unused locals and a misread comment (backend); behaviour unchanged. 7 further small findings were deliberately left (used via globals, or entangled with other dead code). - Sonar: removed 43 small Sonar findings in 20 files: unused variables and dead helper functions (frontend), unused locals and a misread comment (backend); behaviour unchanged. 7 further small findings were deliberately left (used via globals, or entangled with other dead code).
- Conversation Playground: Turn history rows (#n, time, error rows) were in the DOM but squeezed to zero height by the flex column once charts filled the Latency column; the list no longer shrinks. - Conversation Playground: Turn history rows (#n, time, error rows) were in the DOM but squeezed to zero height by the flex column once charts filled the Latency column; the list no longer shrinks.
- Conversation Playground: the metrics charts render at the column width (re-rendered on resize) so axis, tick and legend text is 11px instead of ~8px; a single point is centred with a larger dot. - Conversation Playground: the metrics charts render at the column width (re-rendered on resize) so axis, tick and legend text is 11px instead of ~8px; a single point is centred with a larger dot.

View File

@ -44,12 +44,12 @@ _SETTINGS_KEYS = {
# Magazine layout is a vision task. Kept separate from the general text # Magazine layout is a vision task. Kept separate from the general text
# LLM so page images never accidentally go to a text-only model. # LLM so page images never accidentally go to a text-only model.
"magazine_layout_url", "magazine_layout_model", "magazine_layout_url", "magazine_layout_model",
"vision_ocr_url", "vision_ocr_model", "vision_ocr_enabled", "vision_ocr_url", "vision_ocr_model", "vision_ocr_enabled", "vision_ocr_api_key", "vision_ocr_fallback_api_key",
"inline_tags_enabled", "conversation_thinking", "inline_tags_enabled", "conversation_thinking",
"vision_ocr_fallback_url", "vision_ocr_fallback_model", "vision_ocr_fallback_url", "vision_ocr_fallback_model",
# OPDS catalogues to search for magazine issues: [{"name": ..., "url": ...}], no credentials. # OPDS catalogues to search for magazine issues: [{"name": ..., "url": ...}], no credentials.
"magazine_opds_catalogs", "magazine_opds_catalogs",
"engine_local_urls", "engine_container_names", "engine_api_keys", "custom_engine_cards", "engine_local_urls", "engine_container_names", "engine_api_keys", "engine_models", "stt_models", "tts_models", "custom_engine_cards", "engine_disabled", "engine_active",
# Character portrait generation (cloud APIs + local ComfyUI) # Character portrait generation (cloud APIs + local ComfyUI)
"image_gen_provider", "image_gen_model", "image_gen_provider", "image_gen_model",
"comfyui_url", "comfyui_workflow", "comfyui_prompt_node_id", "comfyui_url", "comfyui_workflow", "comfyui_prompt_node_id",
@ -140,6 +140,8 @@ def _clean_tts_param_dict(raw, fallback: dict | None = None) -> dict:
def _clean_preview_backend(value: str) -> str: def _clean_preview_backend(value: str) -> str:
if str(value or "").startswith("custom:"):
return str(value)
key = re.sub(r"[^a-z0-9]+", "_", str(value or "voice_clone").lower()).strip("_") key = re.sub(r"[^a-z0-9]+", "_", str(value or "voice_clone").lower()).strip("_")
aliases = { aliases = {
"clone": "voice_clone", "clone": "voice_clone",
@ -222,6 +224,8 @@ def _strip_tts_extra_params(payload: dict) -> dict:
def _post_tts_with_fallback(endpoint: str, payload: dict, headers: dict, **kwargs) -> requests.Response: def _post_tts_with_fallback(endpoint: str, payload: dict, headers: dict, **kwargs) -> requests.Response:
from core.provider_health import provider_request_params
payload = provider_request_params(endpoint, payload)
resp = request_with_retries("POST", endpoint, json=payload, headers=headers, **kwargs) resp = request_with_retries("POST", endpoint, json=payload, headers=headers, **kwargs)
fallback = _strip_tts_extra_params(payload) fallback = _strip_tts_extra_params(payload)
if resp.status_code in {400, 404, 415, 422} and fallback != payload: if resp.status_code in {400, 404, 415, 422} and fallback != payload:
@ -235,7 +239,44 @@ def _post_tts_with_fallback(endpoint: str, payload: dict, headers: dict, **kwarg
return resp return resp
def _engine_card_api_key(settings: dict, name: str) -> str:
"""Resolve stable IDs and credentials saved under their own legacy name."""
keys = settings.get("engine_api_keys") or {}
if name in keys:
return str(keys[name] or "").strip()
card = next((card for card in settings.get("custom_engine_cards", [])
if str(card.get("id") or card.get("name")) == name), None)
return str(keys.get(card.get("name")) or "").strip() if card else ""
def _tts_api_key(settings: dict, url: str, name: str = "") -> str:
"""Keep card credentials first and global credentials on their endpoint."""
from core.provider_health import provider_key_for_url
card_key = _engine_card_api_key(settings, name) if name else ""
if card_key:
return card_key
normalized = str(url or "").strip().rstrip("/").removesuffix("/v1")
fallback = ""
for prefix in ("tts", "llm"):
configured = str(settings.get(f"{prefix}_url") or "").strip().rstrip("/").removesuffix("/v1")
if normalized and normalized == configured:
fallback = str(settings.get(f"{prefix}_api_key") or "").strip()
if fallback:
break
return fallback if name else provider_key_for_url(settings, url, fallback) or fallback
def _preview_backend_base_url(settings: dict, backend: str) -> str: def _preview_backend_base_url(settings: dict, backend: str) -> str:
if backend.startswith("custom:"):
identity = backend.removeprefix("custom:")
card = next((card for card in settings.get("custom_engine_cards", [])
if str(card.get("id") or card.get("name")) == identity and card.get("role") == "tts"), None)
if not card:
return ""
urls = settings.get("engine_local_urls") or {}
legacy_name = str(card.get("name") or "")
return urls.get("dc-" + identity) or urls.get(identity) or urls.get("dc-" + legacy_name) or urls.get(legacy_name) or card.get("url") or ""
backend = _clean_preview_backend(backend) backend = _clean_preview_backend(backend)
if backend == "streaming": if backend == "streaming":
return settings.get("tts_stream_url") or settings.get("tts_url") or _TTS_STREAM_DEFAULT return settings.get("tts_stream_url") or settings.get("tts_url") or _TTS_STREAM_DEFAULT
@ -273,10 +314,17 @@ def _normalize_settings(s: dict) -> dict:
s["tts_stream_mode"] = "auto" s["tts_stream_mode"] = "auto"
if not isinstance(s.get("engine_local_urls"), dict): if not isinstance(s.get("engine_local_urls"), dict):
s["engine_local_urls"] = {} s["engine_local_urls"] = {}
if not isinstance(s.get("engine_disabled"), dict):
s["engine_disabled"] = {}
if not isinstance(s.get("engine_active"), dict):
s["engine_active"] = {}
if not isinstance(s.get("engine_container_names"), dict): if not isinstance(s.get("engine_container_names"), dict):
s["engine_container_names"] = {} s["engine_container_names"] = {}
if not isinstance(s.get("engine_api_keys"), dict): if not isinstance(s.get("engine_api_keys"), dict):
s["engine_api_keys"] = {} s["engine_api_keys"] = {}
for key in ("engine_models", "stt_models", "tts_models"):
if not isinstance(s.get(key), dict):
s[key] = {}
if not isinstance(s.get("custom_engine_cards"), list): if not isinstance(s.get("custom_engine_cards"), list):
s["custom_engine_cards"] = [] s["custom_engine_cards"] = []
if not isinstance(s.get("magazine_opds_catalogs"), list): if not isinstance(s.get("magazine_opds_catalogs"), list):
@ -353,8 +401,15 @@ def _load_settings() -> dict:
"vision_ocr_fallback_url": _VISION_OCR_FALLBACK_DEFAULT, "vision_ocr_fallback_url": _VISION_OCR_FALLBACK_DEFAULT,
"vision_ocr_fallback_model": _VISION_OCR_FALLBACK_MODEL_DEFAULT, "vision_ocr_fallback_model": _VISION_OCR_FALLBACK_MODEL_DEFAULT,
"engine_local_urls": {}, "engine_local_urls": {},
"engine_disabled": {},
"engine_active": {},
"engine_container_names": {}, "engine_container_names": {},
"engine_api_keys": {}, "engine_api_keys": {},
"engine_models": {},
"stt_models": {},
"tts_models": {},
"vision_ocr_api_key": "",
"vision_ocr_fallback_api_key": "",
"custom_engine_cards": [], "custom_engine_cards": [],
"magazine_opds_catalogs": [], "magazine_opds_catalogs": [],
"image_gen_provider": "", "image_gen_provider": "",

View File

@ -1269,6 +1269,24 @@ def glued_repetition_share(text: str) -> float:
return covered / len(text) return covered / len(text)
from core.provider_health import provider_request_params
def _vision_auth_headers(base_url: str) -> dict[str, str]:
"""Use only the credential belonging to this configured vision endpoint."""
from core.config import _load_settings
settings = _load_settings()
for prefix in ("vision_ocr", "vision_ocr_fallback"):
configured = str(settings.get(f"{prefix}_url") or "").strip().rstrip("/")
if configured and not urlsplit(configured).path:
configured += "/v1"
if configured == base_url:
key = str(settings.get(f"{prefix}_api_key") or "").strip()
return {"Authorization": f"Bearer {key}"} if key else {}
return {}
def vision_ocr_page(png_path: Path, base_url: str, model: str, def vision_ocr_page(png_path: Path, base_url: str, model: str,
timeout: float = 150.0) -> list[dict[str, Any]] | None: timeout: float = 150.0) -> list[dict[str, Any]] | None:
"""OCR one rendered page with a connected vision-capable OCR engine. """OCR one rendered page with a connected vision-capable OCR engine.
@ -1336,7 +1354,7 @@ def vision_ocr_page(png_path: Path, base_url: str, model: str,
def _fetch() -> Any: def _fetch() -> Any:
resp = request_with_retries( resp = request_with_retries(
"POST", f"{base_url.rstrip('/')}/chat/completions", "POST", f"{base_url.rstrip('/')}/chat/completions",
json=payload, timeout=timeout, attempts=2, json=provider_request_params(base_url, payload, role="ocr"), timeout=timeout, attempts=2, headers=_vision_auth_headers(base_url),
) )
resp.raise_for_status() resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"] return resp.json()["choices"][0]["message"]["content"]
@ -1648,7 +1666,7 @@ def describe_visual_region(image_path: Path, region_type: str, base_url: str,
def _fetch() -> Any: def _fetch() -> Any:
resp = request_with_retries( resp = request_with_retries(
"POST", f"{base_url.rstrip('/')}/chat/completions", "POST", f"{base_url.rstrip('/')}/chat/completions",
json=payload, timeout=timeout, attempts=2, json=provider_request_params(base_url, payload, role="ocr"), timeout=timeout, attempts=2, headers=_vision_auth_headers(base_url),
) )
resp.raise_for_status() resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"] return resp.json()["choices"][0]["message"]["content"]

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Iterable from typing import Any, Iterable
from urllib.parse import urlsplit
import requests import requests
@ -45,3 +46,140 @@ def probe_provider(
except Exception as exc: except Exception as exc:
last_error = str(exc) last_error = str(exc)
return {'ok': False, 'error': last_error} return {'ok': False, 'error': last_error}
def provider_key_for_url(settings: dict, url: str, fallback: str = "") -> str:
"""Resolve saved card credentials for the endpoint actually being called."""
normalized = str(url).rstrip("/").removesuffix("/v1")
cards = dict(settings.get("engine_local_urls") or {})
for card in settings.get("custom_engine_cards") or []:
cards[str(card.get("id") or card.get("name") or "")] = card.get("url", "")
keys = settings.get("engine_api_keys") or {}
for name, card_url in cards.items():
if str(card_url).rstrip("/").removesuffix("/v1") == normalized:
return str(keys.get(name.removeprefix("dc-")) or keys.get(name) or "")
return fallback
def _litellm_model_aliases(base: str, key: str) -> list[str]:
"""Names of a LiteLLM gateway's router aliases (Model Group Alias), which its /models list omits.
Best effort: the settings endpoint needs an admin-capable key, and other gateways do not have it.
"""
root = base[:-3] if base.endswith("/v1") else base
try:
r = requests.get(f"{root}/router/settings", timeout=5, headers={"Authorization": f"Bearer {key}"})
if r.status_code != 200:
return []
data = r.json()
except Exception:
return []
if not isinstance(data, dict):
return []
for holder in (data.get("current_values"), data.get("router_settings"), data):
aliases = holder.get("model_group_alias") if isinstance(holder, dict) else None
if isinstance(aliases, dict):
return [str(name) for name in aliases if name]
return []
_PROVIDER_REQUIRED_FIELDS = {
'model', 'messages', 'input', 'text', 'voice', 'stream', 'response_format',
'file', 'files', 'audio', 'audio_prompt', 'audio_prompt_transcript', 'references',
'url', 'base_url', 'endpoint', 'headers', 'method',
}
_PROVIDER_SECRET_FIELDS = {
'apikey', 'authorization', 'proxyauthorization', 'password', 'passwd', 'secret',
'token', 'accesstoken', 'refreshtoken', 'privatekey', 'credentials', 'credential',
'xapikey', 'accesskey', 'secretkey',
}
def validate_provider_params(params: Any) -> None:
"""Keep credentials and required request fields out of optional metadata."""
if not isinstance(params, dict):
raise ValueError('Additional parameters must be a JSON object')
if any(key in _PROVIDER_REQUIRED_FIELDS for key in params):
raise ValueError('Additional parameters cannot replace required request fields')
def check(value):
if isinstance(value, dict):
for key, item in value.items():
normalized = ''.join(character for character in str(key).lower() if character.isalnum())
if normalized in _PROVIDER_SECRET_FIELDS or normalized == 'headers' or normalized.endswith(('apikey', 'authorization', 'password', 'secret', 'token', 'secretkey', 'privatekey')):
raise ValueError('Credentials must use the API key field')
check(item)
elif isinstance(value, list):
for item in value:
check(item)
check(params)
def engine_disconnected(settings: dict, url: str, role: str = "", identity: str = "") -> bool:
"""Use explicit selection first; infer legacy endpoints only when unambiguous."""
base = str(url).rstrip('/').removesuffix('/v1')
urls = dict(settings.get('engine_local_urls') or {})
urls.update({str(name).removeprefix('dc-'): value for name, value in list(urls.items()) if str(name).startswith('dc-')})
urls = {name: value for name, value in urls.items() if not str(name).startswith('dc-')}
from routes.docker import _LOCAL_CONTAINER_DEFS
roles = {card['name']: card['role'] for card in _LOCAL_CONTAINER_DEFS}
parsed = urlsplit(base)
if parsed.hostname in {'localhost', '127.0.0.1', '::1', 'host.docker.internal'}:
for card in _LOCAL_CONTAINER_DEFS:
if card['port'] == parsed.port:
urls.setdefault(card['name'], base)
for card in settings.get('custom_engine_cards') or []:
name = str(card.get('id') or card.get('name') or '')
roles[name] = str(card.get('role') or '')
urls.setdefault(name, card.get('url'))
if not identity:
for setting, selected in (settings.get('engine_active') or {}).items():
if str(settings.get(setting) or '').rstrip('/').removesuffix('/v1') == base:
if setting == {'llm': 'llm_url', 'ocr': 'vision_ocr_url'}.get(role):
selected_url = urls.get(selected)
if not selected_url or str(selected_url).rstrip('/').removesuffix('/v1') == base:
identity = selected
break
if not identity:
matches = [name for name, value in urls.items() if (not role or not roles.get(name) or role in roles[name].split('+')) and str(value).rstrip('/').removesuffix('/v1') == base]
if len(matches) == 1:
identity = matches[0]
return (settings.get('engine_disabled') or {}).get(identity) is True
def provider_request_params(url: str, payload: dict, settings: dict | None = None, role: str = "", identity: str = "") -> dict:
"""Merge custom endpoint options while preserving required request fields."""
if settings is None:
from core.config import _load_settings
settings = _load_settings()
base = str(url).rstrip('/')
routes = {'/audio/transcriptions': 'stt', '/audio/speech': 'tts', '/chat/completions': 'llm', '/transcribe': 'stt', '/tts': 'tts'}
for suffix, route_role in routes.items():
if base.endswith(suffix):
base = base[:-len(suffix)]
role = role or route_role
break
base = base.removesuffix('/v1')
if engine_disconnected(settings, base, role, identity):
raise ValueError('Engine is disconnected; connect it before use')
candidates = []
urls = settings.get('engine_local_urls') or {}
for card in settings.get('custom_engine_cards') or []:
card_role = str(card.get('role') or '')
if role and card_role and role not in card_role.split('+'):
continue
card_identity = str(card.get('id') or card.get('name') or '')
if identity and identity != card_identity:
continue
legacy_name = str(card.get('name') or '')
configured = urls.get('dc-' + card_identity) or urls.get(card_identity) or urls.get('dc-' + legacy_name) or urls.get(legacy_name) or card.get('url') or ''
if str(configured).rstrip('/').removesuffix('/v1') == base:
candidates.append(card)
if len(candidates) != 1:
return payload
params = candidates[0].get('params') or {}
try:
validate_provider_params(params)
except ValueError:
return payload
return {**payload, **params, **{key: value for key, value in payload.items() if key in _PROVIDER_REQUIRED_FIELDS}}

View File

@ -39,6 +39,8 @@ def _clean_route_sound(value: str) -> str:
def _clean_route_backend(value: str) -> str: def _clean_route_backend(value: str) -> str:
if str(value or "").startswith("custom:"):
return str(value)
key = re.sub(r"[^a-z0-9]+", "_", str(value or "voice_clone").lower()).strip("_") key = re.sub(r"[^a-z0-9]+", "_", str(value or "voice_clone").lower()).strip("_")
aliases = { aliases = {
"clone": "voice_clone", "clone": "voice_clone",

View File

@ -7,11 +7,14 @@ import time
from pathlib import Path from pathlib import Path
import requests import requests
from core.provider_health import provider_key_for_url, provider_request_params
from pydub import AudioSegment from pydub import AudioSegment
from core.config import ( from core.config import (
_clean_preview_backend, _clean_preview_backend,
_preview_backend_base_url, _preview_backend_base_url,
_tts_api_key,
_tts_extra_params, _tts_extra_params,
_apply_tts_extra_params, _apply_tts_extra_params,
_post_tts_with_fallback, _post_tts_with_fallback,
@ -119,6 +122,26 @@ def _audio_duration_from_bytes(audio: bytes, media_type: str) -> float | None:
# ── TTS request config & audio ──────────────────────────────────────────────── # ── TTS request config & audio ────────────────────────────────────────────────
def _tts_selected_model(settings: dict, keys: str | list[str], default: str) -> str:
if isinstance(keys, str):
keys = [keys]
tts_models = settings.get("tts_models") or {}
for key in keys:
if model := tts_models.get(key):
return str(model)
engine_models = settings.get("engine_models") or {}
for key in keys:
if model := engine_models.get(key):
return str(model)
if key in engine_models:
continue
card = next((card for card in settings.get("custom_engine_cards", [])
if str(card.get("id") or card.get("name")) == key), None)
if card and (model := engine_models.get(card.get("name"))):
return str(model)
return default
def _tts_request_config( def _tts_request_config(
text: str, text: str,
voice: str, voice: str,
@ -133,17 +156,35 @@ def _tts_request_config(
from core.constants import _TTS_DEFAULT from core.constants import _TTS_DEFAULT
from core.validation import _validate_http_url from core.validation import _validate_http_url
tts_url = _validate_http_url(url_override or settings.get("tts_url", _TTS_DEFAULT), allow_private=True).rstrip("/")
backend = backend_override or settings.get("tts_backend", "openai") backend = backend_override or settings.get("tts_backend", "openai")
tts_key = (api_key_override if api_key_override is not None else settings.get("tts_api_key", "")).strip() custom_id = backend.removeprefix("custom:") if backend.startswith("custom:") else ""
source_url = _preview_backend_base_url(settings, backend) if backend.startswith("custom:") else url_override or settings.get("tts_url", _TTS_DEFAULT)
tts_url = _validate_http_url(source_url, allow_private=True).rstrip("/")
tts_key = str(api_key_override).strip() if api_key_override is not None else _tts_api_key(settings, tts_url, custom_id)
tts_hdrs = {"Authorization": f"Bearer {tts_key}"} if tts_key else {} tts_hdrs = {"Authorization": f"Bearer {tts_key}"} if tts_key else {}
if backend == "localai": if backend == "localai":
endpoint, payload = f"{tts_url}/tts", {"input": text, "model": voice, "response_format": response_format} endpoint, payload = f"{tts_url}/tts", {"input": text, "model": voice, "response_format": response_format}
elif backend == "pocket": elif backend == "pocket":
endpoint, payload = f"{tts_url}/v1/audio/speech", {"input": text, "voice": voice, "response_format": response_format} endpoint, payload = f"{tts_url.removesuffix('/v1')}/v1/audio/speech", {"input": text, "voice": voice, "response_format": response_format}
else: else:
endpoint, payload = f"{tts_url}/v1/audio/speech", {"model": "tts-1", "input": text, "voice": voice, "response_format": response_format} backend_to_key = {
"streaming": "tts_stream_url",
"customvoice": "customvoice_url",
"nvidia_magpie": "nvidia_tts_url",
"kokoro": "kokoro_url",
"vibevoice": "vibevoice_url",
"xtts": "xtts_url",
}
if backend.startswith("custom:"):
c_id = backend.removeprefix("custom:")
model = _tts_selected_model(settings, c_id, "tts-1") # engine_models uses the card ID!
endpoint, payload = f"{tts_url.removesuffix('/v1')}/v1/audio/speech", {"model": model, "input": text, "voice": voice, "response_format": response_format}
else:
bkey = backend_to_key.get(backend, "tts_url")
# Fall back to engine_models too if tts_models missed it (we can check both)
model = settings.get("tts_models", {}).get(bkey) or _tts_selected_model(settings, [bkey, "tts_url"], "tts-1")
endpoint, payload = f"{tts_url.removesuffix('/v1')}/v1/audio/speech", {"model": model, "input": text, "voice": voice, "response_format": response_format}
if instruct.strip(): if instruct.strip():
payload["instruct"] = instruct.strip() payload["instruct"] = instruct.strip()
# Include language derived from voice ID prefix so the TTS server knows what # Include language derived from voice ID prefix so the TTS server knows what
@ -157,7 +198,7 @@ def _tts_request_config(
_apply_tts_extra_params(payload, settings, _eff_backend) _apply_tts_extra_params(payload, settings, _eff_backend)
if _eff_backend in ("voice_clone", "streaming", "customvoice"): if _eff_backend in ("voice_clone", "streaming", "customvoice"):
_apply_voice_pinned_seed(payload, voice, settings) _apply_voice_pinned_seed(payload, voice, settings)
return endpoint, payload, tts_hdrs return endpoint, provider_request_params(tts_url, payload, settings, "tts", custom_id), tts_hdrs
def _tts_request_audio( def _tts_request_audio(
@ -191,12 +232,16 @@ def _tts_stream_request_config(text: str, voice: str, settings: dict, instruct:
stream_url = settings.get("tts_stream_url") or settings.get("tts_url") or _TTS_STREAM_DEFAULT stream_url = settings.get("tts_stream_url") or settings.get("tts_url") or _TTS_STREAM_DEFAULT
tts_url = _validate_http_url(stream_url, allow_private=True).rstrip("/") tts_url = _validate_http_url(stream_url, allow_private=True).rstrip("/")
tts_key = settings.get("tts_api_key", "").strip() tts_key = settings.get("tts_api_key", "").strip()
fallback_url = str(settings.get("tts_url") or "").rstrip("/").removesuffix("/v1")
if tts_url.rstrip("/").removesuffix("/v1") != fallback_url:
tts_key = ""
tts_key = provider_key_for_url(settings, tts_url, tts_key)
tts_hdrs = {"Authorization": f"Bearer {tts_key}"} if tts_key else {} tts_hdrs = {"Authorization": f"Bearer {tts_key}"} if tts_key else {}
payload = {"model": "tts-1", "input": text, "voice": voice, "response_format": "wav"} payload = {"model": _tts_selected_model(settings, ["tts_stream_url", "tts_url"], "tts-1"), "input": text, "voice": voice, "response_format": "wav"}
if instruct.strip(): if instruct.strip():
payload["instruct"] = instruct.strip() payload["instruct"] = instruct.strip()
_apply_tts_extra_params(payload, settings, "streaming") _apply_tts_extra_params(payload, settings, "streaming")
return f"{tts_url}/v1/audio/speech", payload, tts_hdrs return f"{tts_url.removesuffix('/v1')}/v1/audio/speech", provider_request_params(tts_url, payload, settings, "tts"), tts_hdrs
def _open_tts_stream_response(text: str, voice: str, settings: dict, instruct: str = "") -> requests.Response: def _open_tts_stream_response(text: str, voice: str, settings: dict, instruct: str = "") -> requests.Response:
@ -265,9 +310,10 @@ def _voice_design_request_audio(
from core.validation import _validate_http_url from core.validation import _validate_http_url
vd_url = _validate_http_url(settings.get("voice_design_url") or _VOICE_DESIGN_DEFAULT, allow_private=True).rstrip("/") vd_url = _validate_http_url(settings.get("voice_design_url") or _VOICE_DESIGN_DEFAULT, allow_private=True).rstrip("/")
vd_key = (settings.get("voice_design_api_key") or settings.get("tts_api_key", "")).strip() vd_key = (settings.get("voice_design_api_key") or settings.get("tts_api_key", "")).strip()
vd_key = provider_key_for_url(settings, vd_url, vd_key)
vd_hdrs = {"Authorization": f"Bearer {vd_key}"} if vd_key else {} vd_hdrs = {"Authorization": f"Bearer {vd_key}"} if vd_key else {}
payload = { payload = {
"model": _VOICE_DESIGN_MODEL, "model": _tts_selected_model(settings, "voice_design_url", _VOICE_DESIGN_MODEL),
"input": text, "input": text,
"instruct": _apply_voice_design_gender(instruct, gender), "instruct": _apply_voice_design_gender(instruct, gender),
"language": language, "language": language,
@ -293,9 +339,10 @@ def _voice_design_voice_request_audio(
from core.validation import _validate_http_url from core.validation import _validate_http_url
vd_url = _validate_http_url(settings.get("voice_design_url") or _VOICE_DESIGN_DEFAULT, allow_private=True).rstrip("/") vd_url = _validate_http_url(settings.get("voice_design_url") or _VOICE_DESIGN_DEFAULT, allow_private=True).rstrip("/")
vd_key = (settings.get("voice_design_api_key") or settings.get("tts_api_key", "")).strip() vd_key = (settings.get("voice_design_api_key") or settings.get("tts_api_key", "")).strip()
vd_key = provider_key_for_url(settings, vd_url, vd_key)
vd_hdrs = {"Authorization": f"Bearer {vd_key}"} if vd_key else {} vd_hdrs = {"Authorization": f"Bearer {vd_key}"} if vd_key else {}
payload = { payload = {
"model": _VOICE_DESIGN_MODEL, "model": _tts_selected_model(settings, "voice_design_url", _VOICE_DESIGN_MODEL),
"input": text, "input": text,
"voice": voice, "voice": voice,
"response_format": "wav", "response_format": "wav",
@ -341,7 +388,7 @@ def _nvidia_clone_request_audio(
mode = "flow" if str(mode).lower().endswith("flow") else "zeroshot" mode = "flow" if str(mode).lower().endswith("flow") else "zeroshot"
backend = "nvidia_flow" if mode == "flow" else "nvidia_zeroshot" backend = "nvidia_flow" if mode == "flow" else "nvidia_zeroshot"
base_url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/") base_url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/")
key = (settings.get("voice_design_api_key") or settings.get("tts_api_key", "")).strip() key = provider_key_for_url(settings, base_url)
headers = {"Authorization": f"Bearer {key}"} if key else {} headers = {"Authorization": f"Bearer {key}"} if key else {}
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT)) scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
audio_path = _find_voice_audio(voice, scan_dir) audio_path = _find_voice_audio(voice, scan_dir)
@ -493,7 +540,9 @@ def _fishspeech_request_audio(
# room to vary). Every other backend already routes through this same helper; Fish # room to vary). Every other backend already routes through this same helper; Fish
# was the one exception. # was the one exception.
_apply_tts_extra_params(payload, settings, "fishspeech") _apply_tts_extra_params(payload, settings, "fishspeech")
resp = _post_tts_with_fallback(f"{base_url}/v1/tts", payload, {}, timeout=180) key = provider_key_for_url(settings, base_url)
headers = {"Authorization": f"Bearer {key}"} if key else {}
resp = _post_tts_with_fallback(f"{base_url}/v1/tts", payload, headers, timeout=180)
resp.raise_for_status() resp.raise_for_status()
audio = resp.content audio = resp.content
if not audio or len(audio) < 256: if not audio or len(audio) < 256:
@ -506,7 +555,9 @@ def _fishspeech_request_audio(
def _vibevoice_request_audio(text: str, settings: dict) -> tuple[bytes, str]: def _vibevoice_request_audio(text: str, settings: dict) -> tuple[bytes, str]:
url = _preview_backend_base_url(settings, "vibevoice").rstrip("/") url = _preview_backend_base_url(settings, "vibevoice").rstrip("/")
resp = requests.post(f"{url}/tts", json={"text": text}, timeout=60) key = provider_key_for_url(settings, url)
headers = {"Authorization": f"Bearer {key}"} if key else {}
resp = requests.post(f"{url}/tts", json=provider_request_params(url, {"text": text}, settings, "tts"), headers=headers, timeout=60)
resp.raise_for_status() resp.raise_for_status()
audio = resp.content audio = resp.content
if not audio: if not audio:
@ -525,6 +576,8 @@ def _preview_request_audio(
backend: str = "voice_clone", backend: str = "voice_clone",
) -> tuple[bytes, str]: ) -> tuple[bytes, str]:
backend = _clean_preview_backend(backend) backend = _clean_preview_backend(backend)
if backend.startswith("custom:"):
return _tts_request_audio(text, voice, settings, instruct, backend_override=backend, extra_backend=backend)
if backend == "streaming": if backend == "streaming":
resp = _open_tts_stream_response(text, voice, settings, instruct) resp = _open_tts_stream_response(text, voice, settings, instruct)
audio, media_type = _read_tts_stream_response(resp) audio, media_type = _read_tts_stream_response(resp)
@ -535,7 +588,7 @@ def _preview_request_audio(
return _tts_request_audio( return _tts_request_audio(
text, voice, settings, instruct, text, voice, settings, instruct,
_preview_backend_base_url(settings, "customvoice"), _preview_backend_base_url(settings, "customvoice"),
settings.get("tts_api_key", ""), None,
"openai", "openai",
"customvoice", "customvoice",
) )
@ -554,7 +607,7 @@ def _preview_request_audio(
return _tts_request_audio( return _tts_request_audio(
text, voice, settings, instruct, text, voice, settings, instruct,
_preview_backend_base_url(settings, "nvidia_magpie"), _preview_backend_base_url(settings, "nvidia_magpie"),
settings.get("tts_api_key", ""), None,
"nvidia_magpie", "nvidia_magpie",
"nvidia_magpie", "nvidia_magpie",
) )
@ -566,7 +619,7 @@ def _preview_request_audio(
return _tts_request_audio( return _tts_request_audio(
text, voice, settings, instruct, text, voice, settings, instruct,
url_override=_preview_backend_base_url(settings, "kokoro"), url_override=_preview_backend_base_url(settings, "kokoro"),
api_key_override=settings.get("tts_api_key", ""), api_key_override=None,
backend_override="openai", backend_override="openai",
extra_backend="kokoro", extra_backend="kokoro",
) )
@ -578,7 +631,7 @@ def _preview_request_audio(
return _tts_request_audio( return _tts_request_audio(
text, voice, settings, instruct, text, voice, settings, instruct,
url_override=_preview_backend_base_url(settings, "xtts"), url_override=_preview_backend_base_url(settings, "xtts"),
api_key_override=settings.get("tts_api_key", ""), api_key_override=None,
backend_override="openai", backend_override="openai",
extra_backend="xtts", extra_backend="xtts",
) )

View File

@ -398,9 +398,13 @@ def _usage_from_chunk(obj: dict) -> dict | None:
return out or None return out or None
from core.provider_health import provider_request_params
def _post_llm_stream(url: str, payload: dict, headers: dict): def _post_llm_stream(url: str, payload: dict, headers: dict):
"""POST a streaming chat request; on 400/422 retry once without the optional keys """POST a streaming chat request; on 400/422 retry once without the optional keys
(thinking switch, stream_options) that older backends reject.""" (thinking switch, stream_options) that older backends reject."""
payload = provider_request_params(url, payload)
resp = requests.post(url, json=payload, headers=headers, stream=True, timeout=120) resp = requests.post(url, json=payload, headers=headers, stream=True, timeout=120)
optional = [k for k in ("chat_template_kwargs", "think", "stream_options") if k in payload] optional = [k for k in ("chat_template_kwargs", "think", "stream_options") if k in payload]
if resp.status_code in (400, 422) and optional: if resp.status_code in (400, 422) and optional:
@ -429,6 +433,7 @@ def _rewrite_with_persona_sync(text: str, persona: str, llm_url: str, model: str
if model: if model:
payload["model"] = model payload["model"] = model
payload.update(_thinking_off_params(settings, llm_url)) payload.update(_thinking_off_params(settings, llm_url))
payload = provider_request_params(llm_url, payload, settings, "llm")
url = f"{llm_url.rstrip('/')}/chat/completions" url = f"{llm_url.rstrip('/')}/chat/completions"
hdrs = {"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"} hdrs = {"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}
resp = requests.post(url, json=payload, headers=hdrs, timeout=60) resp = requests.post(url, json=payload, headers=hdrs, timeout=60)
@ -488,6 +493,7 @@ def _llm_chat_completion_urls(llm_url: str) -> list[str]:
def _post_llm_chat_completion(llm_url: str, payload: dict, headers: dict, timeout: float): def _post_llm_chat_completion(llm_url: str, payload: dict, headers: dict, timeout: float):
payload = provider_request_params(llm_url, payload, role="llm")
last_resp = None last_resp = None
last_exc = None last_exc = None
for url in _llm_chat_completion_urls(llm_url): for url in _llm_chat_completion_urls(llm_url):
@ -745,7 +751,7 @@ async def refine_text(request: Request):
resp = await asyncio.to_thread( resp = await asyncio.to_thread(
requests.post, requests.post,
f"{llm_url}/chat/completions", f"{llm_url}/chat/completions",
json=payload, json=provider_request_params(f"{llm_url}/chat/completions", payload),
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}, headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=60, timeout=60,
) )
@ -807,7 +813,7 @@ async def rewrite_with_persona(request: Request):
resp = await asyncio.to_thread( resp = await asyncio.to_thread(
requests.post, requests.post,
f"{llm_url}/chat/completions", f"{llm_url}/chat/completions",
json=payload, json=provider_request_params(f"{llm_url}/chat/completions", payload),
headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}, headers={"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=600, timeout=600,
) )
@ -897,7 +903,7 @@ async def analyze_characters(request: Request):
payload["model"] = model payload["model"] = model
resp = requests.post( resp = requests.post(
f"{llm_url}/chat/completions", f"{llm_url}/chat/completions",
json=payload, json=provider_request_params(f"{llm_url}/chat/completions", payload),
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"},
timeout=600, timeout=600,
) )
@ -1045,7 +1051,7 @@ async def match_characters_voices(request: Request):
try: try:
resp = await asyncio.to_thread( resp = await asyncio.to_thread(
requests.post, requests.post,
f"{llm_url}/chat/completions", json=payload, f"{llm_url}/chat/completions", json=provider_request_params(f"{llm_url}/chat/completions", payload),
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600, headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
) )
resp.raise_for_status() resp.raise_for_status()
@ -1571,7 +1577,7 @@ async def character_sheets_stream(request: Request):
for u in urls: for u in urls:
try: try:
upstream = requests.post( upstream = requests.post(
u, json=payload, headers=headers, u, json=provider_request_params(u, payload), headers=headers,
timeout=(15, prep["timeout_seconds"]), stream=True, timeout=(15, prep["timeout_seconds"]), stream=True,
) )
if upstream.status_code in (404, 405) and u != urls[-1]: if upstream.status_code in (404, 405) and u != urls[-1]:
@ -1696,7 +1702,7 @@ async def character_deep_analysis(request: Request):
try: try:
resp = await asyncio.to_thread( resp = await asyncio.to_thread(
requests.post, requests.post,
f"{llm_url}/chat/completions", json=payload, f"{llm_url}/chat/completions", json=provider_request_params(f"{llm_url}/chat/completions", payload),
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600, headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
) )
resp.raise_for_status() resp.raise_for_status()
@ -3607,7 +3613,7 @@ async def character_generate_prompts(request: Request):
payload["model"] = model payload["model"] = model
try: try:
resp = requests.post( resp = requests.post(
f"{llm_url}/chat/completions", json=payload, f"{llm_url}/chat/completions", json=provider_request_params(f"{llm_url}/chat/completions", payload),
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600, headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
) )
resp.raise_for_status() resp.raise_for_status()
@ -3806,7 +3812,7 @@ async def audiobook_consistency_check(request: Request):
def _call() -> dict: def _call() -> dict:
resp = requests.post( resp = requests.post(
f"{llm_url}/chat/completions", json=payload, f"{llm_url}/chat/completions", json=provider_request_params(f"{llm_url}/chat/completions", payload),
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600, headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
) )
resp.raise_for_status() resp.raise_for_status()
@ -5384,7 +5390,7 @@ async def attribute_dialogue_stream(request: Request):
for u in urls: for u in urls:
try: try:
upstream = requests.post( upstream = requests.post(
u, json=payload, headers=headers, u, json=provider_request_params(u, payload), headers=headers,
timeout=(15, prep["timeout_seconds"]), stream=True, timeout=(15, prep["timeout_seconds"]), stream=True,
) )
if upstream.status_code in (404, 405) and u != urls[-1]: if upstream.status_code in (404, 405) and u != urls[-1]:
@ -5598,6 +5604,7 @@ async def audio_effects(request: Request):
_EXPORT_SKIP_KEYS = { _EXPORT_SKIP_KEYS = {
"groq_api_key", "whisper_api_key", "tts_api_key", "voice_design_api_key", "groq_api_key", "whisper_api_key", "tts_api_key", "voice_design_api_key",
"elevenlabs_api_key", "llm_api_key", "engine_api_keys", "elevenlabs_api_key", "llm_api_key", "engine_api_keys",
"vision_ocr_api_key", "vision_ocr_fallback_api_key",
} }
_IMPORT_ALLOWED_SUFFIXES = set(_AUDIO_EXTS + [".reference.txt", ".meta.json", ".jpg", ".jpeg", ".png", ".webp"]) _IMPORT_ALLOWED_SUFFIXES = set(_AUDIO_EXTS + [".reference.txt", ".meta.json", ".jpg", ".jpeg", ".png", ".webp"])
@ -6058,26 +6065,7 @@ async def mcp_sse(request: Request):
# ── Conversation playground ─────────────────────────────────────────────────── # ── Conversation playground ───────────────────────────────────────────────────
def _litellm_model_aliases(base: str, key: str) -> list[str]: from core.provider_health import _litellm_model_aliases
"""Names of a LiteLLM gateway's router aliases (Model Group Alias), which its /models list omits.
Best effort: the settings endpoint needs an admin-capable key, and other gateways do not have it.
"""
root = base[:-3] if base.endswith("/v1") else base
try:
r = requests.get(f"{root}/router/settings", timeout=5, headers={"Authorization": f"Bearer {key}"})
if r.status_code != 200:
return []
data = r.json()
except Exception:
return []
if not isinstance(data, dict):
return []
for holder in (data.get("current_values"), data.get("router_settings"), data):
aliases = holder.get("model_group_alias") if isinstance(holder, dict) else None
if isinstance(aliases, dict):
return [str(name) for name in aliases if name]
return []
@router.get("/api/conversation/llm-models") @router.get("/api/conversation/llm-models")
@ -6089,6 +6077,9 @@ async def conversation_llm_models(
from core.validation import _validate_http_url from core.validation import _validate_http_url
settings = _load_settings() settings = _load_settings()
base = (url or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/") base = (url or settings.get("llm_url") or "http://localhost:11434/v1").rstrip("/")
from core.provider_health import engine_disconnected
if engine_disconnected(settings, base, "llm"):
return {"models": [], "url": base, "error": "Disconnected", "connected": False}
key_to_use = provider_api_key or settings.get("llm_api_key") or "sk-dummy-key" key_to_use = provider_api_key or settings.get("llm_api_key") or "sk-dummy-key"
try: try:
base = _validate_http_url(base, allow_private=True) base = _validate_http_url(base, allow_private=True)

View File

@ -4,15 +4,15 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote, urlsplit
import requests import requests
from fastapi import APIRouter, Header, HTTPException, Request from fastapi import APIRouter, Header, HTTPException, Request
from core.docker_client import _docker_available, _docker_get_json, _docker_post from core.docker_client import _docker_available, _docker_get_json, _docker_post
from core.config import _load_settings, _save_settings, _normalize_settings from core.config import _load_settings, _save_settings, _normalize_settings, _tts_api_key, _engine_card_api_key
from core.validation import _normalize_service_url from core.validation import _normalize_service_url
from core.provider_health import probe_provider from core.provider_health import probe_provider, _litellm_model_aliases
router = APIRouter() router = APIRouter()
@ -123,6 +123,71 @@ async def restart_local_container(name: str):
return {"ok": True, "name": name, **_container_status(name)} return {"ok": True, "name": name, **_container_status(name)}
def _engine_api_key(settings: dict, name: str, role: str, supplied: str = "", url: str = "") -> str:
card_key = _engine_card_api_key(settings, name)
if role == "tts":
return supplied or _tts_api_key(settings, url, name) or "sk-dummy-key"
if name:
return supplied or card_key or "sk-dummy-key"
fallback = {
"anthropic": "llm_api_key", "llm": "llm_api_key", "openai": "llm_api_key",
"ollama": "llm_api_key", "stt": "whisper_api_key", "tts": "tts_api_key",
"ocr": "vision_ocr_api_key",
}.get(role, "")
key = supplied or card_key or settings.get(fallback, "")
return key or "sk-dummy-key"
def _engine_headers(key: str, role: str) -> dict:
if role == "anthropic":
return {"x-api-key": key, "anthropic-version": "2023-06-01"}
return {"Authorization": f"Bearer {key}"}
@router.post("/api/local-containers/{name}/connection")
async def engine_connection(name: str, request: Request):
body = await request.json()
if not isinstance(body, dict) or not isinstance(body.get("connected"), bool):
raise HTTPException(400, "connected must be a boolean")
settings = _load_settings()
known = {card["name"] for card in _LOCAL_CONTAINER_DEFS}
known.update(str(card.get("id") or card.get("name")) for card in settings.get("custom_engine_cards", []))
if name not in known:
raise HTTPException(404, "Unknown engine")
active = dict(settings.get("engine_active") or {})
urls = dict(settings.get("engine_local_urls") or {})
urls.update({str(identity).removeprefix("dc-"): url for identity, url in list(urls.items()) if str(identity).startswith("dc-")})
urls = {identity: url for identity, url in urls.items() if not str(identity).startswith("dc-")}
roles = {card["name"]: card["role"] for card in _LOCAL_CONTAINER_DEFS}
for card in settings.get("custom_engine_cards", []):
identity = str(card.get("id") or card.get("name"))
urls.setdefault(identity, card.get("url"))
roles[identity] = card.get("role", "")
def endpoint(url):
return str(url or "").rstrip("/").removesuffix("/v1")
for role, setting in (("llm", "llm_url"), ("ocr", "vision_ocr_url")):
selected_url = endpoint(settings.get(setting))
if setting not in active and selected_url:
candidates = dict(urls)
parsed = urlsplit(selected_url)
if parsed.hostname in {"localhost", "127.0.0.1", "::1", "host.docker.internal"}:
for card in _LOCAL_CONTAINER_DEFS:
if card["port"] == parsed.port:
candidates.setdefault(card["name"], selected_url)
matches = [identity for identity, url in candidates.items() if role in roles.get(identity, "").split("+") and endpoint(url) == selected_url]
if matches == [name]:
active[setting] = name
settings["engine_active"] = active
disabled = dict(settings.get("engine_disabled") or {})
if body["connected"]:
disabled.pop(name, None)
else:
disabled[name] = True
settings["engine_disabled"] = disabled
_save_settings(_normalize_settings(settings))
return {"ok": True, "connected": body["connected"]}
@router.get("/api/probe-url") @router.get("/api/probe-url")
async def probe_url(url: str, type: str = "", name: str = "", provider_api_key: str = Header("", alias="X-Provider-API-Key")): async def probe_url(url: str, type: str = "", name: str = "", provider_api_key: str = Header("", alias="X-Provider-API-Key")):
"""Server-side API probe — checks service-specific endpoints and validates JSON responses.""" """Server-side API probe — checks service-specific endpoints and validates JSON responses."""
@ -132,30 +197,8 @@ async def probe_url(url: str, type: str = "", name: str = "", provider_api_key:
base = base[: -len(_suffix)] base = base[: -len(_suffix)]
break break
settings = _load_settings() settings = _load_settings()
# Key precedence: key_to_use = _engine_api_key(settings, name, type, provider_api_key, base)
# 1. A key supplied explicitly by the browser for this probe (e.g. the hdrs = {"User-Agent": "TTS-Voice-Creator/probe", **_engine_headers(key_to_use, type)}
# user just typed/edited it and it isn't saved yet).
# 2. The key saved for this specific engine card (settings.engine_api_keys[name]).
# Card values are masked to "__configured__" before ever reaching the
# browser, so on a normal page load the browser has no real secret to
# forward — the backend must resolve it itself from `name`.
# 3. The old global llm_api_key, which may belong to a different
# gateway after that card's own key was rotated.
card_key = (settings.get("engine_api_keys") or {}).get(name) if name else None
key_to_use = provider_api_key or card_key or {
"anthropic": settings.get("llm_api_key"),
"llm": settings.get("llm_api_key"),
"openai": settings.get("llm_api_key"),
"stt": settings.get("groq_api_key") or settings.get("whisper_api_key"),
"tts": settings.get("tts_api_key"),
}.get(type) or "sk-dummy-key"
# Anthropic doesn't speak the OpenAI-style Bearer auth every other card
# here uses — it needs x-api-key + an anthropic-version header, so the
# generic Bearer probe below would 401 even with a perfectly valid key.
if type == "anthropic":
hdrs = {"User-Agent": "TTS-Voice-Creator/probe", "x-api-key": key_to_use, "anthropic-version": "2023-06-01"}
else:
hdrs = {"User-Agent": "TTS-Voice-Creator/probe", "Authorization": f"Bearer {key_to_use}"}
if type == "anthropic": if type == "anthropic":
checks = [("/v1/models", "data")] checks = [("/v1/models", "data")]
@ -164,7 +207,7 @@ async def probe_url(url: str, type: str = "", name: str = "", provider_api_key:
elif type == "stt": elif type == "stt":
checks = [("/health", None), ("/v1/models", "data"), ("/v1/audio/transcriptions", None)] checks = [("/health", None), ("/v1/models", "data"), ("/v1/audio/transcriptions", None)]
elif type == "tts": elif type == "tts":
checks = [("/health", None), ("/v1/health", None), ("/v1/audio/voices", None), ("/speakers", None), ("/voices", None)] checks = [("/health", None), ("/v1/models", "data"), ("/v1/health", None), ("/v1/audio/voices", None), ("/speakers", None), ("/voices", None)]
elif type == "ocr": elif type == "ocr":
checks = [("/health", None), ("/v1/models", "data"), ("/v1/chat/completions", None), ("/parse", None), ("/ocr", None)] checks = [("/health", None), ("/v1/models", "data"), ("/v1/chat/completions", None), ("/parse", None), ("/ocr", None)]
elif type == "comfyui": elif type == "comfyui":
@ -172,7 +215,7 @@ async def probe_url(url: str, type: str = "", name: str = "", provider_api_key:
else: else:
checks = [("", None)] checks = [("", None)]
result = probe_provider(base, checks, headers=hdrs, timeout=5) result = await asyncio.to_thread(probe_provider, base, checks, headers=hdrs, timeout=5)
return {key: value for key, value in result.items() if key != 'payload'} return {key: value for key, value in result.items() if key != 'payload'}
@ -193,12 +236,15 @@ async def use_as_llm(name: str, request: Request, provider_api_key: str = Header
raise HTTPException(400, "URL is required") raise HTTPException(400, "URL is required")
s = _load_settings() s = _load_settings()
card_key = (s.get("engine_api_keys") or {}).get(name) card_key = _engine_card_api_key(s, name)
key_to_use = provider_api_key or card_key or "" key_to_use = provider_api_key or card_key or ""
s["llm_url"] = url s["llm_url"] = url
s["engine_active"] = {**(s.get("engine_active") or {}), "llm_url": name}
s["engine_disabled"] = {key: value for key, value in (s.get("engine_disabled") or {}).items() if key != name}
s["llm_api_key"] = key_to_use
s["llm_model"] = str(body.get("model") or "").strip()
if key_to_use: if key_to_use:
s["llm_api_key"] = key_to_use
if provider_api_key: if provider_api_key:
# Keep this card's own saved key in sync with what was just typed. # Keep this card's own saved key in sync with what was just typed.
engine_keys = dict(s.get("engine_api_keys") or {}) engine_keys = dict(s.get("engine_api_keys") or {})
@ -209,15 +255,68 @@ async def use_as_llm(name: str, request: Request, provider_api_key: str = Header
return {"ok": True, "llm_url": url, "key_set": bool(key_to_use)} return {"ok": True, "llm_url": url, "key_set": bool(key_to_use)}
@router.post("/api/local-containers/{name}/use-as-engine")
async def use_as_engine(name: str, request: Request, provider_api_key: str = Header("", alias="X-Provider-API-Key")):
body = await request.json()
if not isinstance(body, dict):
raise HTTPException(400, "JSON object is required")
role = str(body.get("role") or "")
setting_key = str(body.get("setting_key") or "")
targets = {
"stt": {"whisper_url", "faster_whisper_url", "whisper_cpp_url", "nvidia_asr_url", "nvidia_router_url"},
"tts": {"tts_url", "tts_stream_url", "voice_design_url", "customvoice_url", "kokoro_url", "vibevoice_url", "xtts_url", "fishspeech_url", "nvidia_tts_url", "nvidia_clone_url", "nvidia_zeroshot_url", "nvidia_flow_url"},
"ocr": {"vision_ocr_url"},
}
if setting_key not in targets.get(role, set()):
raise HTTPException(400, "Unsupported engine setting")
url = _normalize_service_url(str(body.get("url") or "")).strip()
if not url:
raise HTTPException(400, "URL is required")
settings = _load_settings()
key = provider_api_key or _engine_card_api_key(settings, name)
if role == "tts" and not key:
key = _tts_api_key(settings, url, name)
settings[setting_key] = url
settings["engine_active"] = {**(settings.get("engine_active") or {}), setting_key: name}
settings["engine_disabled"] = {key: value for key, value in (settings.get("engine_disabled") or {}).items() if key != name}
settings["engine_local_urls"] = {**(settings.get("engine_local_urls") or {}), name: url}
credential_setting = "voice_design_api_key" if setting_key == "voice_design_url" else {
"stt": "whisper_api_key", "tts": "tts_api_key", "ocr": "vision_ocr_api_key",
}[role]
if setting_key in {"whisper_url", "tts_url", "voice_design_url", "vision_ocr_url"}:
settings[credential_setting] = key
settings["engine_api_keys"] = {**(settings.get("engine_api_keys") or {}), name: key}
model = str(body.get("model") or "").strip()
settings["engine_models"] = {**(settings.get("engine_models") or {}), name: model}
if role == "ocr":
from core.constants import _VISION_OCR_MODEL_DEFAULT
settings["vision_ocr_model"] = model or _VISION_OCR_MODEL_DEFAULT
elif role == "tts":
settings["tts_models"] = {**(settings.get("tts_models") or {}), setting_key: model}
else:
backend = {"whisper_url": "configured", "faster_whisper_url": "faster_whisper", "whisper_cpp_url": "whisper_cpp", "nvidia_asr_url": "nvidia_parakeet", "nvidia_router_url": "nvidia_router"}[setting_key]
settings["stt_models"] = {**(settings.get("stt_models") or {}), backend: model}
if body.get("activate") is True:
if role == "stt":
custom = any(str(card.get("id") or card.get("name")) == name for card in settings.get("custom_engine_cards", []))
settings["stt_preferred_backend"] = "custom:" + name if custom else backend
elif role == "tts":
settings["tts_backend"] = "openai"
elif role == "ocr":
settings["vision_ocr_enabled"] = True
_save_settings(_normalize_settings(settings))
return {"ok": True, "key_set": bool(key)}
@router.get("/api/engine-models") @router.get("/api/engine-models")
async def engine_models(url: str, type: str = "", provider_api_key: str = Header("", alias="X-Provider-API-Key")): async def engine_models(url: str, type: str = "", name: str = "", provider_api_key: str = Header("", alias="X-Provider-API-Key")):
"""Return model IDs from an OpenAI-style engine URL without exposing host-only URLs to the browser.""" """Return model IDs from an OpenAI-style engine URL without exposing host-only URLs to the browser."""
base = _normalize_service_url(url).rstrip("/") base = _normalize_service_url(url).rstrip("/")
if not base: if not base:
raise HTTPException(400, "URL is required") raise HTTPException(400, "URL is required")
candidates: list[str] candidates: list[str] = []
if base.endswith("/v1"): if base.endswith("/v1"):
candidates = [base + "/models", base[:-3].rstrip("/") + "/v1/models"] candidates = [base + "/models", base[:-3].rstrip("/") + "/models"]
else: else:
candidates = [base.rstrip("/") + "/v1/models", base.rstrip("/") + "/models"] candidates = [base.rstrip("/") + "/v1/models", base.rstrip("/") + "/models"]
if type == "ollama": if type == "ollama":
@ -226,10 +325,10 @@ async def engine_models(url: str, type: str = "", provider_api_key: str = Header
seen: set[str] = set() seen: set[str] = set()
models: list[str] = [] models: list[str] = []
settings = _load_settings() settings = _load_settings()
key_to_use = settings.get("llm_api_key") or provider_api_key or "sk-dummy-key" key_to_use = _engine_api_key(settings, name, type, provider_api_key, base)
for endpoint in candidates: for endpoint in candidates:
try: try:
r = await asyncio.to_thread(requests.get, endpoint, timeout=6, headers={"Authorization": f"Bearer {key_to_use}", "User-Agent": "TTS-Voice-Creator/models"}) r = await asyncio.to_thread(requests.get, endpoint, timeout=6, headers={**_engine_headers(key_to_use, type), "User-Agent": "TTS-Voice-Creator/models"})
if r.status_code >= 400: if r.status_code >= 400:
last_err = f"HTTP {r.status_code} from {endpoint}" last_err = f"HTTP {r.status_code} from {endpoint}"
continue continue
@ -253,6 +352,9 @@ async def engine_models(url: str, type: str = "", provider_api_key: str = Header
seen.add(mid) seen.add(mid)
models.append(mid) models.append(mid)
if models: if models:
if type in {"llm", "openai", "ollama"}:
aliases = await asyncio.to_thread(_litellm_model_aliases, base, key_to_use)
models = [alias for alias in aliases if alias not in models] + models
return {"ok": True, "url": base, "endpoint": endpoint, "models": models} return {"ok": True, "url": base, "endpoint": endpoint, "models": models}
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
last_err = "Timeout fetching models" last_err = "Timeout fetching models"

View File

@ -460,6 +460,13 @@ async def update_voice_meta(request: Request):
wav = _move_voice_package(wav, _active_voices_dir(settings) if enabled else _hidden_voices_dir(settings)) wav = _move_voice_package(wav, _active_voices_dir(settings) if enabled else _hidden_voices_dir(settings))
meta = _load_meta(wav) meta = _load_meta(wav)
meta["enabled"] = enabled meta["enabled"] = enabled
if enabled:
# Unhiding must make the voice usable: the engine only knows voices listed in its voices.json.
try:
from routes.tts import publish_voice_clone_if_missing
await asyncio.to_thread(publish_voice_clone_if_missing, wav, str(meta.get("lang") or ""), settings)
except Exception:
pass # best-effort; the voice is already enabled in our own library
for field in ("note", "rating", "flag", "gender", "age", "lang", "language", "loudness", "persona", "origin", "group", for field in ("note", "rating", "flag", "gender", "age", "lang", "language", "loudness", "persona", "origin", "group",
"name", "tag", "source", "avatar", "voice_design_prompt"): "name", "tag", "source", "avatar", "voice_design_prompt"):

View File

@ -14,6 +14,7 @@ router = APIRouter()
_SECRET_SETTING_KEYS = { _SECRET_SETTING_KEYS = {
"groq_api_key", "whisper_api_key", "tts_api_key", "voice_design_api_key", "groq_api_key", "whisper_api_key", "tts_api_key", "voice_design_api_key",
"elevenlabs_api_key", "llm_api_key", "engine_api_keys", "external_api_key", "elevenlabs_api_key", "llm_api_key", "engine_api_keys", "external_api_key",
"vision_ocr_api_key", "vision_ocr_fallback_api_key",
} }
_SECRET_MASK = "__configured__" _SECRET_MASK = "__configured__"
@ -55,7 +56,21 @@ async def regenerate_api_key(response: Response):
@router.post("/api/settings") @router.post("/api/settings")
async def post_settings(request: Request): async def post_settings(request: Request):
data = await request.json() data = await request.json()
if not isinstance(data, dict):
raise HTTPException(400, "JSON object is required")
if "custom_engine_cards" in data:
from core.provider_health import validate_provider_params
cards = data["custom_engine_cards"]
if not isinstance(cards, list) or any(not isinstance(card, dict) for card in cards):
raise HTTPException(400, "Custom engine cards must be a list of objects")
for card in cards:
try:
validate_provider_params(card.get("params", {}))
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
s = _load_settings() s = _load_settings()
previous_cards = {str(card.get("id") or card.get("name") or ""): card for card in s.get("custom_engine_cards", []) if isinstance(card, dict)}
previous_maps = {key: dict(s.get(key) or {}) for key in ("engine_api_keys", "engine_models", "engine_local_urls")}
clear = {str(k) for k in (data.get("clear_secrets") or [])} if isinstance(data.get("clear_secrets"), list) else set() clear = {str(k) for k in (data.get("clear_secrets") or [])} if isinstance(data.get("clear_secrets"), list) else set()
for key, value in data.items(): for key, value in data.items():
if key not in _SETTINGS_KEYS: if key not in _SETTINGS_KEYS:
@ -76,6 +91,27 @@ async def post_settings(request: Request):
if value in (None, "") and key not in clear: if value in (None, "") and key not in clear:
continue continue
s[key] = value s[key] = value
if isinstance(data.get("custom_engine_cards"), list):
for card in data["custom_engine_cards"]:
if not isinstance(card, dict):
continue
identity = str(card.get("id") or card.get("name") or "")
previous = previous_cards.get(identity)
if not previous:
continue
old_name = str(previous.get("name") or "")
for key in previous_maps:
source = "dc-" + old_name if key == "engine_local_urls" else old_name
target = "dc-" + identity if key == "engine_local_urls" else identity
values = dict(s.get(key) or {})
if key == "engine_api_keys" and (identity in clear or old_name in clear):
continue
if target not in values:
if target in previous_maps[key]:
values[target] = previous_maps[key][target]
elif source in previous_maps[key]:
values[target] = values.get(source, previous_maps[key][source])
s[key] = values
s = _normalize_settings(s) s = _normalize_settings(s)
_save_settings(s) _save_settings(s)
return {"ok": True} return {"ok": True}

View File

@ -21,7 +21,7 @@ from core.constants import (
) )
from core.registry import _registry_get, TEMP_DIR from core.registry import _registry_get, TEMP_DIR
from core.validation import _validate_http_url, _copy_limited, _validated_upload_suffix from core.validation import _validate_http_url, _copy_limited, _validated_upload_suffix
from core.provider_health import probe_provider from core.provider_health import probe_provider, provider_key_for_url, provider_request_params, engine_disconnected
from core.audio import _to_wav_16k from core.audio import _to_wav_16k
from core.database import background_job_create, background_job_get, background_job_update from core.database import background_job_create, background_job_get, background_job_update
from core.voice import _AUDIO_EXTS, _UPLOAD_EXTS from core.voice import _AUDIO_EXTS, _UPLOAD_EXTS
@ -152,7 +152,9 @@ def _stt_backend_url(settings: dict, backend: str) -> str:
custom_id = backend[len("custom:"):] custom_id = backend[len("custom:"):]
for card in settings.get("custom_engine_cards", []): for card in settings.get("custom_engine_cards", []):
if str(card.get("id", card.get("name", ""))) == custom_id: if str(card.get("id", card.get("name", ""))) == custom_id:
return card.get("url", "") urls = settings.get("engine_local_urls") or {}
name = str(card.get("name") or "")
return urls.get("dc-" + custom_id) or urls.get(custom_id) or urls.get("dc-" + name) or urls.get(name) or card.get("url", "")
return "" return ""
if backend == "nvidia_parakeet": if backend == "nvidia_parakeet":
return settings.get("nvidia_asr_url") or _NVIDIA_ASR_DEFAULT return settings.get("nvidia_asr_url") or _NVIDIA_ASR_DEFAULT
@ -183,8 +185,18 @@ def _stt_backend_configured(settings: dict, backend: str) -> bool:
return any(str(settings.get(key) or '').strip() for key in keys) return any(str(settings.get(key) or '').strip() for key in keys)
def _stt_backend_model(backend: str) -> str: def _stt_backend_model(backend: str, settings: dict | None = None) -> str:
backend = _clean_stt_backend(backend) backend = _clean_stt_backend(backend)
settings = settings if settings is not None else _load_settings()
selected = (settings.get("stt_models") or {}).get(backend)
if backend.startswith("custom:"):
identity = backend[7:]
card = next((card for card in settings.get("custom_engine_cards", [])
if str(card.get("id") or card.get("name") or "") == identity), {})
models = settings.get("engine_models") or {}
selected = models.get(identity, models.get(card.get("name")) or selected)
if selected:
return str(selected)
if backend in {"nvidia_parakeet", "nvidia_router"}: if backend in {"nvidia_parakeet", "nvidia_router"}:
return "whisper-1" return "whisper-1"
if backend == "whisper_cpp": if backend == "whisper_cpp":
@ -228,7 +240,24 @@ def _stt_backend_label(backend: str, url: str) -> str:
def _stt_backend_api_key(settings: dict, backend: str) -> str: def _stt_backend_api_key(settings: dict, backend: str) -> str:
if backend == "groq_whisper": if backend == "groq_whisper":
return settings.get("groq_api_key", "").strip() return settings.get("groq_api_key", "").strip()
return settings.get("whisper_api_key", "").strip() if backend.startswith("custom:"):
from core.config import _engine_card_api_key
return _engine_card_api_key(settings, backend[7:])
setting = {"configured": "whisper_url", "faster_whisper": "faster_whisper_url", "whisper_cpp": "whisper_cpp_url", "nvidia_parakeet": "nvidia_asr_url", "nvidia_router": "nvidia_router_url"}.get(backend)
selected = (settings.get("engine_active") or {}).get(setting)
if selected:
from core.config import _engine_card_api_key
card_key = _engine_card_api_key(settings, selected)
if card_key:
return card_key
if any(selected == str(card.get("id") or card.get("name") or "") for card in settings.get("custom_engine_cards", [])):
return ""
url = _stt_backend_url(settings, backend).rstrip("/").removesuffix("/v1")
configured_url = str(settings.get("whisper_url") or "").rstrip("/").removesuffix("/v1")
return str(settings.get("whisper_api_key") or "").strip() if url == configured_url else ""
url = _stt_backend_url(settings, backend)
fallback = settings.get("whisper_api_key", "") if url.rstrip("/") == str(settings.get("whisper_url") or "").rstrip("/") else ""
return provider_key_for_url(settings, url, fallback).strip()
def _make_minimal_wav(duration_ms: int = 500, sample_rate: int = 16000) -> bytes: def _make_minimal_wav(duration_ms: int = 500, sample_rate: int = 16000) -> bytes:
@ -244,7 +273,7 @@ def _make_minimal_wav(duration_ms: int = 500, sample_rate: int = 16000) -> bytes
return header + data return header + data
def _stt_backend_health(url: str) -> dict: def _stt_backend_health(url: str, api_key: str = "") -> dict:
base = _validate_http_url(url, allow_private=True).rstrip("/") base = _validate_http_url(url, allow_private=True).rstrip("/")
# Users commonly paste an OpenAI-style `/v1` base URL. WhisperX exposes # Users commonly paste an OpenAI-style `/v1` base URL. WhisperX exposes
# health at the service root, while its transcription route is under # health at the service root, while its transcription route is under
@ -252,10 +281,11 @@ def _stt_backend_health(url: str) -> dict:
# `/v1/v1/models`, incorrectly marking a healthy WhisperX service down. # `/v1/v1/models`, incorrectly marking a healthy WhisperX service down.
api_base = base[:-3].rstrip("/") if base.endswith("/v1") else base api_base = base[:-3].rstrip("/") if base.endswith("/v1") else base
models: list[str] = [] models: list[str] = []
health = probe_provider(api_base, [("/health", None)], timeout=2) headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
health = probe_provider(api_base, [("/health", None)], headers=headers, timeout=2)
models_result = probe_provider( models_result = probe_provider(
api_base, [("/v1/models", "data")], api_base, [("/v1/models", "data")],
headers={"Authorization": "Bearer sk-dummy-key"}, timeout=3, headers=headers, timeout=3,
) )
ok = bool(health['ok'] or models_result['ok']) ok = bool(health['ok'] or models_result['ok'])
payload = models_result.get('payload') payload = models_result.get('payload')
@ -297,9 +327,11 @@ def _transcription_text_from_response(resp: requests.Response) -> str:
def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") -> tuple[str, str]: def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") -> tuple[str, str]:
backend = _clean_stt_backend(backend) backend = _clean_stt_backend(backend)
stt_url = _validate_http_url(_stt_backend_url(settings, backend), allow_private=True).rstrip("/") stt_url = _validate_http_url(_stt_backend_url(settings, backend), allow_private=True).rstrip("/")
if _stt_disconnected(settings, backend, stt_url):
raise HTTPException(409, "STT engine is disconnected; connect it before use")
stt_key = _stt_backend_api_key(settings, backend) stt_key = _stt_backend_api_key(settings, backend)
hdrs = {"Authorization": f"Bearer {stt_key}"} if stt_key else {} hdrs = {"Authorization": f"Bearer {stt_key}"} if stt_key else {}
model = _stt_backend_model(backend) model = _stt_backend_model(backend, settings)
# Accept both `http://host:port` and OpenAI-style `http://host:port/v1`. # Accept both `http://host:port` and OpenAI-style `http://host:port/v1`.
# WhisperX's native fallback remains rooted at `/transcribe`. # WhisperX's native fallback remains rooted at `/transcribe`.
if stt_url.endswith("/v1"): if stt_url.endswith("/v1"):
@ -315,7 +347,7 @@ def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") ->
resp = requests.post( resp = requests.post(
endpoint, endpoint,
files={"file": ("audio.wav", f, "audio/wav")}, files={"file": ("audio.wav", f, "audio/wav")},
data={"model": model, "response_format": "text"}, data={key: json.dumps(value) if isinstance(value, (dict, list)) else value for key, value in provider_request_params(stt_url, {"model": model, "response_format": "text"}, settings, "stt", backend[7:] if backend.startswith("custom:") else "").items()},
headers=hdrs, headers=hdrs,
timeout=_STT_REQUEST_TIMEOUT, timeout=_STT_REQUEST_TIMEOUT,
) )
@ -328,7 +360,7 @@ def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") ->
resp = requests.post( resp = requests.post(
f"{stt_url}{path}", f"{stt_url}{path}",
files={"file": ("audio.wav", f, "audio/wav")}, files={"file": ("audio.wav", f, "audio/wav")},
data={"model": "whisper-1", "response_format": "text"}, data={key: json.dumps(value) if isinstance(value, (dict, list)) else value for key, value in provider_request_params(stt_url, {"model": "whisper-1", "response_format": "text"}, settings, "stt", backend[7:] if backend.startswith("custom:") else "").items()},
headers=hdrs, headers=hdrs,
timeout=60, timeout=60,
) )
@ -487,6 +519,43 @@ async def _default_stt_benchmark_engines() -> list[dict]:
# ── STT routes ──────────────────────────────────────────────────────────────── # ── STT routes ────────────────────────────────────────────────────────────────
def _stt_backend_engine_id(settings: dict, backend: str, url: str = "") -> str:
identities = {"faster_whisper": "faster-whisper", "whisper_cpp": "whisper-cpp", "nvidia_parakeet": "parakeet-stt-multi-9011", "nvidia_router": "nvidia-parakeet-asr"}
setting = {"configured": "whisper_url", "faster_whisper": "faster_whisper_url", "whisper_cpp": "whisper_cpp_url", "nvidia_parakeet": "nvidia_asr_url", "nvidia_router": "nvidia_router_url"}.get(backend)
if backend.startswith("custom:"):
return backend[7:]
selected = (settings.get("engine_active") or {}).get(setting)
if selected:
return selected
from routes.docker import _LOCAL_CONTAINER_DEFS
for card in _LOCAL_CONTAINER_DEFS:
if card["role"] == "stt" and str(card["port"]) == _backend_port_label(url):
return card["name"]
return identities.get(backend, "")
def _stt_disconnected(settings: dict, backend: str, url: str) -> bool:
from routes.docker import _LOCAL_CONTAINER_DEFS
native_identity = _stt_backend_engine_id({}, backend)
identity = _stt_backend_engine_id(settings, backend, url)
selected_native = any(card["name"] == identity and card["role"] == "stt" for card in _LOCAL_CONTAINER_DEFS)
if not selected_native and (settings.get("engine_disabled") or {}).get(native_identity) is True:
return True
return engine_disconnected(settings, url, "stt", identity)
def _stt_backend_title(settings: dict, backend: str, url: str) -> str:
from routes.docker import _LOCAL_CONTAINER_DEFS
setting = {"configured": "whisper_url", "faster_whisper": "faster_whisper_url", "whisper_cpp": "whisper_cpp_url", "nvidia_parakeet": "nvidia_asr_url", "nvidia_router": "nvidia_router_url"}.get(backend)
selected = (settings.get("engine_active") or {}).get(setting)
for card in settings.get("custom_engine_cards", []):
if selected and selected == str(card.get("id") or card.get("name") or ""):
return card.get("label") or card.get("name") or "Custom STT"
for card in _LOCAL_CONTAINER_DEFS:
if card["name"] == selected or (not selected and card["role"] == "stt" and str(card["port"]) == _backend_port_label(url)):
return card["label"]
return "Configured STT" if backend == "configured" else _stt_backend_label(backend, "")
@router.get("/api/stt-backends") @router.get("/api/stt-backends")
async def stt_backends(): async def stt_backends():
settings = _load_settings() settings = _load_settings()
@ -501,36 +570,47 @@ async def stt_backends():
continue continue
seen_urls.add(key) seen_urls.add(key)
api_key = _stt_backend_api_key(settings, backend) api_key = _stt_backend_api_key(settings, backend)
if backend == "groq_whisper": disconnected = _stt_disconnected(settings, backend, url)
if disconnected:
ok, models, health_detail = False, [], {"ok": False, "error": "Disconnected"}
elif backend == "groq_whisper":
ok = bool(api_key) ok = bool(api_key)
health_detail = {"ok": ok}
models: list[str] = ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"] models: list[str] = ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"]
else: else:
health_detail = _stt_backend_health(url) health_detail = _stt_backend_health(url, api_key)
ok, models = health_detail.get("ok", False), health_detail.get("models", []) ok, models = health_detail.get("ok", False), health_detail.get("models", [])
items.append({ items.append({
"id": backend, "id": backend,
"engine_id": _stt_backend_engine_id(settings, backend, url),
"label": _stt_backend_label(backend, url), "label": _stt_backend_label(backend, url),
"title": _stt_backend_title(settings, backend, url),
"url": url, "url": url,
"port": _backend_port_label(url), "port": _backend_port_label(url),
"available": ok, "available": ok,
"connected": not disconnected,
"configured": _stt_backend_configured(settings, backend), "configured": _stt_backend_configured(settings, backend),
"health": {key: health_detail.get(key) for key in ("ok", "status", "endpoint", "error") if health_detail.get(key) is not None}, "health": {key: health_detail.get(key) for key in ("ok", "status", "endpoint", "error") if health_detail.get(key) is not None},
"model": _stt_backend_model(backend), "model": _stt_backend_model(backend, settings),
"models": models, "models": models,
"metrics": _STT_BACKEND_METRICS.get(backend, {}), "metrics": _STT_BACKEND_METRICS.get(backend, {}),
}) })
for card in settings.get("custom_engine_cards", []): for card in settings.get("custom_engine_cards", []):
if card.get("role") not in ("stt", "stt+tts"): if card.get("role") not in ("stt", "stt+tts"):
continue continue
raw_url = card.get("url", "").strip()
url = _validate_http_url(raw_url, allow_private=True).rstrip("/")
if not url:
continue
card_id = "custom:" + str(card.get("id", card.get("name", ""))) card_id = "custom:" + str(card.get("id", card.get("name", "")))
raw_url = _stt_backend_url(settings, card_id).strip()
if not raw_url:
continue
try:
url = _validate_http_url(raw_url, allow_private=True).rstrip("/")
except HTTPException:
continue
if (card_id, url) in seen_urls: if (card_id, url) in seen_urls:
continue continue
seen_urls.add((card_id, url)) seen_urls.add((card_id, url))
health_detail = _stt_backend_health(url) disconnected = _stt_disconnected(settings, card_id, url)
health_detail = {"ok": False, "error": "Disconnected"} if disconnected else _stt_backend_health(url, _stt_backend_api_key(settings, card_id))
ok, models = health_detail.get("ok", False), health_detail.get("models", []) ok, models = health_detail.get("ok", False), health_detail.get("models", [])
port = _backend_port_label(url) port = _backend_port_label(url)
label = card.get("label") or card.get("name") or "Custom STT" label = card.get("label") or card.get("name") or "Custom STT"
@ -538,13 +618,16 @@ async def stt_backends():
label = f"{port} {label}" label = f"{port} {label}"
items.append({ items.append({
"id": card_id, "id": card_id,
"engine_id": card_id[7:],
"label": label, "label": label,
"title": card.get("label") or card.get("name") or "Custom STT",
"url": url, "url": url,
"port": port, "port": port,
"available": ok, "available": ok,
"connected": not disconnected,
"configured": _stt_backend_configured(settings, card_id), "configured": _stt_backend_configured(settings, card_id),
"health": {key: health_detail.get(key) for key in ("ok", "status", "endpoint", "error") if health_detail.get(key) is not None}, "health": {key: health_detail.get(key) for key in ("ok", "status", "endpoint", "error") if health_detail.get(key) is not None},
"model": "whisper-1", "model": _stt_backend_model(card_id, settings),
"models": models, "models": models,
"metrics": {}, "metrics": {},
}) })

View File

@ -21,7 +21,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response, StreamingResponse from fastapi.responses import Response, StreamingResponse
from core.tasks import spawn from core.tasks import spawn
from core.config import _load_settings, _clean_preview_backend, _preview_backend_base_url from core.config import _load_settings, _clean_preview_backend, _preview_backend_base_url, _tts_api_key
from core.constants import ( from core.constants import (
_VOICES_DIR_DEFAULT, _TTS_CONTAINER, _TTS_CONTAINERS_RAW, _VOICES_DIR_DEFAULT, _TTS_CONTAINER, _TTS_CONTAINERS_RAW,
_routing_log_add, CONFIG_DIR, _VOICE_PEAK_DBFS, _routing_log_add, CONFIG_DIR, _VOICE_PEAK_DBFS,
@ -42,7 +42,7 @@ from core.voice import (
from core.voice_index import indexed_voices, refresh_voice_index_background from core.voice_index import indexed_voices, refresh_voice_index_background
from core.validation import _validate_http_url from core.validation import _validate_http_url
from core import ssml_lite from core import ssml_lite
from core.provider_health import probe_provider from core.provider_health import probe_provider, engine_disconnected
from core.registry import _registry_put, TEMP_DIR from core.registry import _registry_put, TEMP_DIR
from core.tts_helpers import ( from core.tts_helpers import (
_tts_request_audio, _open_tts_stream_response, _read_tts_stream_response, _tts_request_audio, _open_tts_stream_response, _read_tts_stream_response,
@ -214,7 +214,7 @@ def _backend_port_label(url: str) -> str:
return "" return ""
def _backend_display_name(backend: str, url: str) -> str: def _backend_display_name(backend: str, url: str, settings: dict = None) -> str:
names = { names = {
"voice_clone": "Voice Clone/Base (WAV File)", "voice_clone": "Voice Clone/Base (WAV File)",
"voice_design": "Voice Design", "voice_design": "Voice Design",
@ -228,11 +228,42 @@ def _backend_display_name(backend: str, url: str) -> str:
"xtts": "XTTS v2", "xtts": "XTTS v2",
"fishspeech": "Fish Audio S2 Pro (Clone)", "fishspeech": "Fish Audio S2 Pro (Clone)",
} }
port = _backend_port_label(url)
return f"{port} {names.get(backend, backend)}" if port else names.get(backend, backend) prefix = ""
if settings:
from core.tts_helpers import _tts_selected_model
bkeys = {
"voice_clone": "tts_url",
"voice_design": "voice_design_url",
"streaming": "tts_stream_url",
"customvoice": "customvoice_url",
"nvidia_magpie": "nvidia_tts_url",
"kokoro": "kokoro_url",
"vibevoice": "vibevoice_url",
"xtts": "xtts_url",
"fishspeech": "fishspeech_url"
}
if bkey := bkeys.get(backend):
model = _tts_selected_model(settings, [bkey, "tts_url"], "")
if model and model not in ("tts-1", "tts-1-hd"):
prefix = model
if not prefix:
prefix = _backend_port_label(url)
return f"{prefix} {names.get(backend, backend)}" if prefix else names.get(backend, backend)
def _backend_capabilities(backend: str) -> dict: def _backend_capabilities(backend: str) -> dict:
if backend.startswith("custom:"):
return {
"purpose": "Custom TTS Engine",
"identity": "Custom configured engine behavior.",
"style": "Behavior depends on the underlying engine.",
"best_for": "Using custom externally configured APIs.",
"uses_wav": False, "style_aware": False, "true_streaming": False,
"speed": "varies", "latency": "varies", "quality": "varies", "ram": "varies",
}
caps = { caps = {
"voice_clone": { "voice_clone": {
"purpose": "Clone a speaker from a short WAV/reference clip.", "purpose": "Clone a speaker from a short WAV/reference clip.",
@ -336,8 +367,75 @@ def _backend_capabilities(backend: str) -> dict:
return caps.get(_clean_preview_backend(backend), {}) return caps.get(_clean_preview_backend(backend), {})
def _backend_health_detail(url: str) -> dict: _TTS_BACKEND_CARDS = {
return probe_provider(url, [("/health", None), ("/v1/health", None), ("/v1/audio/list_voices", None)], timeout=2) "voice_clone": "tts-faster-qwen3-tts-voiceclone",
"voice_design": "tts-faster-qwen3-tts-voicedesign",
"customvoice": "tts-faster-qwen3-tts-customvoice",
"streaming": "tts-faster-qwen3-tts-streaming",
"fishspeech": "fish-speech-api",
"nvidia_magpie": "nvidia-magpie-tts",
"nvidia_zeroshot": "nvidia-zeroshot-nim",
"nvidia_flow": "nvidia-flow-nim",
"kokoro": "kokoro-tts",
"vibevoice": "vibevoice-msft-tts",
"xtts": "xtts-dgx-spark",
}
def _backend_card_title(backend: str) -> str:
from routes.docker import _LOCAL_CONTAINER_DEFS
return next((card["label"] for card in _LOCAL_CONTAINER_DEFS
if card["name"] == _TTS_BACKEND_CARDS.get(backend)), backend)
_GATEWAY_UPSTREAMS: dict[str, tuple[float, dict[str, str]]] = {}
_GATEWAY_UPSTREAM_TTL = 30.0
def _gateway_upstream(root: str, api_key: str, model: str) -> str:
"""api_base of ``model`` on a LiteLLM gateway, or "" when unknown / not a gateway.
The gateway being alive says nothing about the engine behind a given model, so the
health probe checks that model's own upstream. Cached briefly: /model/info lists every
model and the status bar polls often. ponytail: duplicate model_name deployments, the
last one wins.
"""
if not (api_key and model):
return ""
now = time.time()
cached = _GATEWAY_UPSTREAMS.get(root)
if not cached or now - cached[0] > _GATEWAY_UPSTREAM_TTL:
mapping: dict[str, str] = {}
try:
r = requests.get(root + "/model/info", headers={"Authorization": f"Bearer {api_key}"}, timeout=3)
if r.status_code == 200:
for m in (r.json().get("data") or []):
base = (m.get("litellm_params") or {}).get("api_base")
if m.get("model_name") and base:
mapping[str(m["model_name"])] = str(base)
except Exception:
pass
cached = _GATEWAY_UPSTREAMS[root] = (now, mapping)
return cached[1].get(model, "")
def _backend_health_detail(url: str, api_key: str = "", model: str = "") -> dict:
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
# Card URLs usually end in /v1 (OpenAI-compatible base); health endpoints live at the
# server root, so probe from the root.
root = url.rstrip("/").removesuffix("/v1")
checks = [("/health", None), ("/v1/health", None), ("/v1/audio/list_voices", None)]
upstream = _gateway_upstream(root, api_key, model)
if upstream:
detail = probe_provider(upstream.rstrip("/").removesuffix("/v1"), checks, timeout=2)
# A refused connection is definitive (engine container is down); anything else
# (DNS, timeout) may just mean the upstream is not routable from here, so fall back.
if detail.get("ok") or detail.get("error") == "Connection refused":
return detail
# /health/liveliness is the unauthenticated liveness route of a LiteLLM gateway
# (its /health and /v1/models need a key): the gateway itself is up.
return probe_provider(root, checks + [("/health/liveliness", None)], headers=headers, timeout=2)
def _backend_health(url: str) -> bool: def _backend_health(url: str) -> bool:
@ -416,6 +514,28 @@ def sync_voice_clone_reference(audio: Path, transcript: str, language: str, sett
time.sleep(0.35) time.sleep(0.35)
def publish_voice_clone_if_missing(audio: Path, language: str, settings: dict) -> bool:
"""Publish a voice that the engine's voices.json does not know yet (e.g. just unhidden).
Existing entries are left alone: syncing drops the cached speaker embedding, which
would force a needless re-warm. Returns True when a new entry was written.
"""
voices_path = _seed_finder_voices_json_path(settings)
if not voices_path.exists():
return False
try:
known = json.loads(voices_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return False
if not isinstance(known, dict) or audio.stem in known:
return False
_has_ref, transcript = _read_reference_text(audio)
if not transcript:
return False
sync_voice_clone_reference(audio, transcript, language, settings)
return True
def _seed_finder_voice_config(voice: str, voices: dict, settings: dict) -> dict: def _seed_finder_voice_config(voice: str, voices: dict, settings: dict) -> dict:
cfg = dict(voices.get(voice) or {}) cfg = dict(voices.get(voice) or {})
if cfg.get("ref_audio"): if cfg.get("ref_audio"):
@ -573,6 +693,10 @@ def _fetch_backend_voices(settings: dict, backend: str) -> list:
return _active_library_voice_options(settings) return _active_library_voice_options(settings)
tts_url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/") tts_url = _validate_http_url(_preview_backend_base_url(settings, backend), allow_private=True).rstrip("/")
key = (settings.get("voice_design_api_key") if backend == "voice_design" else settings.get("tts_api_key")) or "" key = (settings.get("voice_design_api_key") if backend == "voice_design" else settings.get("tts_api_key")) or ""
from core.provider_health import provider_key_for_url
if backend != "voice_design" and tts_url != str(settings.get("tts_url") or "").rstrip("/"):
key = ""
key = _tts_api_key(settings, tts_url, backend.removeprefix("custom:")) if backend.startswith("custom:") else provider_key_for_url(settings, tts_url, key)
tts_hdrs = {"Authorization": f"Bearer {key.strip()}"} if key.strip() else {} tts_hdrs = {"Authorization": f"Bearer {key.strip()}"} if key.strip() else {}
for ep in _TTS_VOICE_ENDPOINTS: for ep in _TTS_VOICE_ENDPOINTS:
try: try:
@ -643,16 +767,22 @@ async def tts_backends():
items = [] items = []
for backend in ("voice_clone", "voice_design", "customvoice", "streaming", "fishspeech", "kokoro", "vibevoice", "xtts", "nvidia_magpie", "nvidia_zeroshot", "nvidia_flow"): for backend in ("voice_clone", "voice_design", "customvoice", "streaming", "fishspeech", "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) disabled = (settings.get("engine_disabled") or {}).get(_TTS_BACKEND_CARDS[backend]) is True
health_detail = _backend_health_detail(url) setting_key = {"voice_clone": "tts_url", "voice_design": "voice_design_url", "customvoice": "customvoice_url", "streaming": "tts_stream_url", "fishspeech": "fishspeech_url", "nvidia_magpie": "nvidia_tts_url", "nvidia_zeroshot": "nvidia_zeroshot_url", "nvidia_flow": "nvidia_flow_url", "kokoro": "kokoro_url", "vibevoice": "vibevoice_url", "xtts": "xtts_url"}[backend]
selected = (settings.get("engine_active") or {}).get(setting_key, "")
disabled = disabled or engine_disconnected(settings, url, "tts", selected)
voices = [] if disabled else _fetch_backend_voices(settings, backend)
health_detail = {"ok": False} if disabled else _backend_health_detail(url)
health = bool(health_detail.get('ok')) health = bool(health_detail.get('ok'))
available = _backend_available(backend, voices, health) available = _backend_available(backend, voices, health)
items.append({ items.append({
"id": backend, "id": backend,
"label": _backend_display_name(backend, url), "label": _backend_display_name(backend, url, settings),
"title": _backend_card_title(backend),
"url": url, "url": url,
"port": _backend_port_label(url), "port": _backend_port_label(url),
"available": available, "available": available,
"disabled": disabled,
"configured": _backend_configured(settings, backend, voices), "configured": _backend_configured(settings, backend, voices),
"health": { "health": {
"ok": health, "ok": health,
@ -663,6 +793,42 @@ async def tts_backends():
"voice_count": len(voices) if isinstance(voices, list) else 0, "voice_count": len(voices) if isinstance(voices, list) else 0,
**_backend_capabilities(backend), **_backend_capabilities(backend),
}) })
for custom in settings.get("custom_engine_cards", []):
if custom.get("role") != "tts":
continue
c_id = custom.get("id") or custom.get("name")
backend_id = f"custom:{c_id}"
try:
url = _validate_http_url(_preview_backend_base_url(settings, backend_id), allow_private=True).rstrip("/")
except (ValueError, HTTPException):
continue
from core.tts_helpers import _tts_selected_model
model = _tts_selected_model(settings, str(c_id), "")
label = f"{model} (Custom)" if model else f"{custom.get('label') or c_id} (Custom)"
disabled = (settings.get("engine_disabled") or {}).get(str(c_id)) is True
voices = [] if disabled else _fetch_backend_voices(settings, backend_id)
health_detail = {"ok": False} if disabled else _backend_health_detail(url, _tts_api_key(settings, url, str(c_id)), model)
health = bool(health_detail.get('ok'))
available = _backend_available(backend_id, voices, health)
items.append({
"id": backend_id,
"label": label,
"title": custom.get("label") or custom.get("name") or str(c_id),
"url": url,
"port": _backend_port_label(url),
"available": available,
"disabled": disabled,
"configured": True,
"health": {
"ok": health,
"status": health_detail.get("status"),
"endpoint": health_detail.get("endpoint"),
"error": health_detail.get("error"),
},
"voice_count": len(voices) if isinstance(voices, list) else 0,
**_backend_capabilities(backend_id),
})
return {"backends": items} return {"backends": items}
@ -1214,7 +1380,7 @@ async def openai_speech_proxy(request: Request):
audio, media_type = await _bounded_tts_call( audio, media_type = await _bounded_tts_call(
_tts_request_audio, text, voice, settings, style_instruction, _tts_request_audio, text, voice, settings, style_instruction,
_preview_backend_base_url(settings, "nvidia_magpie"), _preview_backend_base_url(settings, "nvidia_magpie"),
settings.get("tts_api_key", ""), "nvidia_magpie", "nvidia_magpie", None, "nvidia_magpie", "nvidia_magpie",
) )
elif backend in {"nvidia_zeroshot", "nvidia_flow"}: elif backend in {"nvidia_zeroshot", "nvidia_flow"}:
audio, media_type = await _bounded_tts_call( audio, media_type = await _bounded_tts_call(
@ -1223,6 +1389,10 @@ async def openai_speech_proxy(request: Request):
str(data.get("audio_prompt_transcript") or ""), str(data.get("audio_prompt_transcript") or ""),
str(data.get("language") or ""), str(data.get("language") or ""),
) )
elif backend.startswith("custom:"):
audio, media_type = await _bounded_tts_call(
_preview_request_audio, text, voice, settings, style_instruction, backend,
)
elif backend == "fishspeech": elif backend == "fishspeech":
audio, media_type = await _bounded_tts_call( audio, media_type = await _bounded_tts_call(
_fishspeech_request_audio, text, voice, settings, _fishspeech_request_audio, text, voice, settings,

View File

@ -73,11 +73,21 @@ export function triage(issues, cfg) {
const countBy = (xs, f) => xs.reduce((m, x) => (m[f(x)] = (m[f(x)] || 0) + 1, m), {}); const countBy = (xs, f) => xs.reduce((m, x) => (m[f(x)] = (m[f(x)] || 0) + 1, m), {});
export const IMPACT_RANK = { BLOCKER: 0, HIGH: 1, MEDIUM: 2, LOW: 3, INFO: 4 };
export function impactSeverity(issue) {
const severities = (issue.impacts || []).map((i) => i.severity);
return severities.sort((a, b) => (IMPACT_RANK[a] ?? 5) - (IMPACT_RANK[b] ?? 5))[0];
}
export function parseImpactSeverities(value) {
const severities = (value || '').split(',');
if (!severities.length || severities.some((s) => !Object.hasOwn(IMPACT_RANK, s))) throw new Error('--impact-severities requires BLOCKER,HIGH,MEDIUM,LOW or INFO');
return new Set(severities);
}
export const MAX_LOCATIONS = 40; export const MAX_LOCATIONS = 40;
export const REFACTOR_RULES = new Set(['python:S3776', 'javascript:S3776']); // cognitive complexity: own lane and agent export const REFACTOR_RULES = new Set(['python:S3776', 'javascript:S3776']); // cognitive complexity: own lane and agent
// One group per rule; a rule with more than `size` locations is split into numbered parts. // One group per rule; a rule with more than `size` locations is split into numbered parts.
export function groupIssues(items, size = MAX_LOCATIONS) { export function groupIssues(items, size = MAX_LOCATIONS, severityFirst = false) {
const byRule = new Map(); const byRule = new Map();
for (const i of items) (byRule.get(i.rule) || byRule.set(i.rule, []).get(i.rule)).push(i); for (const i of items) (byRule.get(i.rule) || byRule.set(i.rule, []).get(i.rule)).push(i);
const out = []; const out = [];
@ -99,7 +109,8 @@ export function groupIssues(items, size = MAX_LOCATIONS) {
const SEV_RANK = { BLOCKER: 0, CRITICAL: 1, MAJOR: 2, MINOR: 3, INFO: 4 }; const SEV_RANK = { BLOCKER: 0, CRITICAL: 1, MAJOR: 2, MINOR: 3, INFO: 4 };
const NOISY = new Set(['javascript:S3358', 'python:S1192', 'python:S8415', 'javascript:S5850', 'python:S5850', 'javascript:S2189']); // triage: low value or probable false positive, file last const NOISY = new Set(['javascript:S3358', 'python:S1192', 'python:S8415', 'javascript:S5850', 'python:S5850', 'javascript:S2189']); // triage: low value or probable false positive, file last
const rank = (g) => [NOISY.has(g.rule) ? 1 : 0, Math.min(...g.issues.map((i) => TYPE_RANK[i.type] ?? 3)), Math.min(...g.issues.map((i) => SEV_RANK[i.severity] ?? 5))]; const rank = (g) => [NOISY.has(g.rule) ? 1 : 0, Math.min(...g.issues.map((i) => TYPE_RANK[i.type] ?? 3)), Math.min(...g.issues.map((i) => SEV_RANK[i.severity] ?? 5))];
out.sort((a, b) => { const x = rank(a), y = rank(b); return x[0] - y[0] || x[1] - y[1] || x[2] - y[2] || a.rule.localeCompare(b.rule) || a.part - b.part; }); out.sort((a, b) => { const x = rank(a), y = rank(b); const impact = (g) => Math.min(...g.issues.map((i) => IMPACT_RANK[impactSeverity(i)] ?? 5));
return (severityFirst ? impact(a) - impact(b) : 0) || x[0] - y[0] || x[1] - y[1] || x[2] - y[2] || a.rule.localeCompare(b.rule) || a.part - b.part; });
return out; return out;
} }
@ -149,10 +160,11 @@ export function postWebhook(url, payload) {
function ghIssues(groups, apply, cap, log, lane = 'fix', noHook = false) { function ghIssues(groups, apply, cap, log, lane = 'fix', noHook = false) {
const agentLabel = lane === 'refactor' ? 'agent-refactor' : 'agent-fix'; const agentLabel = lane === 'refactor' ? 'agent-refactor' : 'agent-fix';
if (run('gh', ['auth', 'status']).status !== 0) { log('gh unauthenticated: skipping GitHub issues (run `gh auth login`).'); return []; } if (run('gh', ['auth', 'status']).status !== 0) { if (apply) throw new Error('gh unauthenticated'); log('gh unauthenticated: skipping GitHub issues (run `gh auth login`).'); return []; }
const list = run('gh', ['issue', 'list', '--state', 'open', '--label', 'sonar', '--limit', '300', '--json', 'number,body']); const list = run('gh', ['issue', 'list', '--state', 'open', '--label', 'sonar', '--limit', '300', '--json', 'number,body']);
// closed fixed-local issues also suppress (Sonar keys stay open until a re-scan) // closed fixed-local issues also suppress (Sonar keys stay open until a re-scan)
const fixed = run('gh', ['issue', 'list', '--state', 'closed', '--label', 'fixed-local', '--limit', '300', '--json', 'number,body']); const fixed = run('gh', ['issue', 'list', '--state', 'closed', '--label', 'fixed-local', '--limit', '300', '--json', 'number,body']);
if (apply && [list, fixed].some((r) => r.status !== 0)) throw new Error('gh issue list failed');
const bodies = [list, fixed].flatMap((l) => (l.status === 0 ? JSON.parse(l.stdout).map((x) => x.body) : [])); const bodies = [list, fixed].flatMap((l) => (l.status === 0 ? JSON.parse(l.stdout).map((x) => x.body) : []));
const todo = groups.filter((g) => !hasMarker(bodies, g.keys)); const todo = groups.filter((g) => !hasMarker(bodies, g.keys));
log(`GitHub: ${groups.length - todo.length} duplicate(s) skipped, ${Math.min(todo.length, cap)} of ${todo.length} to create (cap ${cap}).`); log(`GitHub: ${groups.length - todo.length} duplicate(s) skipped, ${Math.min(todo.length, cap)} of ${todo.length} to create (cap ${cap}).`);
@ -161,7 +173,7 @@ function ghIssues(groups, apply, cap, log, lane = 'fix', noHook = false) {
for (const g of todo.slice(0, cap)) { for (const g of todo.slice(0, cap)) {
if (!apply) { log(` [dry-run] would create: ${g.title}`); continue; } if (!apply) { log(` [dry-run] would create: ${g.title}`); continue; }
const r = run('gh', ['issue', 'create', '--title', g.title, '--label', 'sonar', '--label', agentLabel, '--body-file', '-'], issueBody(g)); const r = run('gh', ['issue', 'create', '--title', g.title, '--label', 'sonar', '--label', agentLabel, '--body-file', '-'], issueBody(g));
if (r.status !== 0) { log(` gh create failed for ${g.title}`); continue; } if (r.status !== 0) throw new Error(`gh create failed for ${g.title}`);
const url = r.stdout.trim().split('\n').pop(); const url = r.stdout.trim().split('\n').pop();
log(` created ${url}`); log(` created ${url}`);
// Mechanism 1: a multica autopilot webhook (URL is a secret: env MULTICA_WEBHOOK_URL or ~/.config/multica-webhook). // Mechanism 1: a multica autopilot webhook (URL is a secret: env MULTICA_WEBHOOK_URL or ~/.config/multica-webhook).
@ -188,15 +200,15 @@ export async function drain(log, { lane = 'fix', timeoutMin = 60, resends = 1, p
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms))); const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
const hook = deps.webhookUrl ? deps.webhookUrl() : webhookUrl(process.env, undefined, lane); const hook = deps.webhookUrl ? deps.webhookUrl() : webhookUrl(process.env, undefined, lane);
const post = deps.postWebhook || postWebhook; const post = deps.postWebhook || postWebhook;
if (!hook) { log('no webhook configured for this lane'); return { done: [], stalled: null }; } if (!hook) throw new Error('no webhook configured for this lane');
const label = lane === 'refactor' ? 'agent-refactor' : 'agent-fix'; const label = lane === 'refactor' ? 'agent-refactor' : 'agent-fix';
const listed = sh('gh', ['issue', 'list', '--state', 'open', '--label', 'sonar', '--label', label, '--limit', '100', '--json', 'number,title,body,url']); const listed = sh('gh', ['issue', 'list', '--state', 'open', '--label', 'sonar', '--label', label, '--limit', '100', '--json', 'number,title,body,url']);
if (listed.status !== 0) { log('gh issue list failed'); return { done: [], stalled: null }; } if (listed.status !== 0) throw new Error('gh issue list failed');
const todo = JSON.parse(listed.stdout).sort((x, y) => x.number - y.number); const todo = JSON.parse(listed.stdout).sort((x, y) => x.number - y.number);
const done = []; const done = [];
const view = (n) => JSON.parse(sh('gh', ['issue', 'view', String(n), '--json', 'state,comments,labels']).stdout || '{}'); const view = (n) => { const r = sh('gh', ['issue', 'view', String(n), '--json', 'state,comments,labels']); if (r.status !== 0) throw new Error('gh issue view failed'); return JSON.parse(r.stdout); };
for (const it of todo) { for (const it of todo) {
const base = (view(it.number).comments || []).length; view(it.number); // Fail before delivery if GitHub cannot be read.
let sent = 0, answered = false; let sent = 0, answered = false;
while (!answered && sent <= resends) { while (!answered && sent <= resends) {
const w = post(hook, { source: 'sonar-triage', title: it.title, github_issue_url: it.url, body: it.body }); const w = post(hook, { source: 'sonar-triage', title: it.title, github_issue_url: it.url, body: it.body });
@ -206,7 +218,7 @@ export async function drain(log, { lane = 'fix', timeoutMin = 60, resends = 1, p
while (Date.now() < deadline) { while (Date.now() < deadline) {
await sleep(pollSec * 1000); await sleep(pollSec * 1000);
const v = view(it.number); const v = view(it.number);
if (v.state === 'CLOSED' || (v.comments || []).length > base || (v.labels || []).some((l) => l.name === 'fixed-local')) { answered = true; break; } if (v.state === 'CLOSED' || (v.labels || []).some((l) => l.name === 'fixed-local')) { answered = true; break; }
} }
if (!answered) log(`#${it.number}: no answer within ${timeoutMin} min`); if (!answered) log(`#${it.number}: no answer within ${timeoutMin} min`);
} }
@ -247,6 +259,7 @@ export async function main(argv, deps = {}) {
else if (f === '--no-webhook') a.noHook = true; else if (f === '--no-webhook') a.noHook = true;
else if (f === '--timeout') a.timeout = +argv[++i]; else if (f === '--timeout') a.timeout = +argv[++i];
else if (f === '--resends') a.resends = +argv[++i]; else if (f === '--resends') a.resends = +argv[++i];
else if (f === '--impact-severities') a.impactSeverities = parseImpactSeverities(argv[++i]);
else if (f === '--exclude-rules') a.exclude = (argv[++i] || '').split(',').filter(Boolean); else if (f === '--exclude-rules') a.exclude = (argv[++i] || '').split(',').filter(Boolean);
else throw new Error(`unknown flag ${f}`); else throw new Error(`unknown flag ${f}`);
} }
@ -254,13 +267,16 @@ export async function main(argv, deps = {}) {
if (!a.apply) { log('--drain needs --apply (it fires webhooks).'); return {}; } if (!a.apply) { log('--drain needs --apply (it fires webhooks).'); return {}; }
return drain(log, { lane: a.lane || 'fix', timeoutMin: a.timeout || 60, resends: a.resends ?? 1, deps: deps.drainDeps }); return drain(log, { lane: a.lane || 'fix', timeoutMin: a.timeout || 60, resends: a.resends ?? 1, deps: deps.drainDeps });
} }
if (a.impactSeverities && (a.exclude?.length || a.lane === 'refactor' || a.fixSmall)) throw new Error('--impact-severities cannot be combined with exclusions, refactor lane or --fix-small');
const cfg = deps.cfg || loadConfig(); const cfg = deps.cfg || loadConfig();
if (a.list) { log('Allowlisted (small) rules:'); for (const r of cfg.allow) log(` ${r}`); log('Skip-list:'); for (const s of cfg.skip) log(` ${s.rule || '*'} ${s.file || ''} ${s.message || ''} -- ${s.reason}`); return; } if (a.list) { log('Allowlisted (small) rules:'); for (const r of cfg.allow) log(` ${r}`); log('Skip-list:'); for (const s of cfg.skip) log(` ${s.rule || '*'} ${s.file || ''} ${s.message || ''} -- ${s.reason}`); return; }
const tokFile = join(homedir(), '.config', 'sonar-token'); const tokFile = join(homedir(), '.config', 'sonar-token');
const token = deps.token || process.env.SONAR_TOKEN || (existsSync(tokFile) ? readFileSync(tokFile, 'utf8').trim() : ''); const token = deps.token || process.env.SONAR_TOKEN || (existsSync(tokFile) ? readFileSync(tokFile, 'utf8').trim() : '');
if (!token) throw new Error('no token: put it in ~/.config/sonar-token (or SONAR_TOKEN)'); if (!token) throw new Error('no token: put it in ~/.config/sonar-token (or SONAR_TOKEN)');
const issues = await fetchIssues({ fetchFn: deps.fetchFn, token, newCode: a.newCode, max: a.max }); const fetched = await fetchIssues({ fetchFn: deps.fetchFn, token, newCode: a.newCode, max: a.max });
const issues = a.impactSeverities ? fetched.filter((i) => a.impactSeverities.has(impactSeverity(i))) : fetched;
const res = triage(issues, cfg); const res = triage(issues, cfg);
if (a.impactSeverities) log(`Selected impact findings: ${issues.length}; scan freshness has not been verified.`);
log(`Mode: ${a.apply ? 'APPLY' : 'dry-run'} | scope: ${a.newCode ? 'new code' : 'all'} | issues: ${issues.length}`); log(`Mode: ${a.apply ? 'APPLY' : 'dry-run'} | scope: ${a.newCode ? 'new code' : 'all'} | issues: ${issues.length}`);
for (const c of ['small', 'medium', 'large', 'skipped']) { for (const c of ['small', 'medium', 'large', 'skipped']) {
log(`\n${c.toUpperCase()} (${res[c].length})`); log(`\n${c.toUpperCase()} (${res[c].length})`);
@ -270,13 +286,13 @@ export async function main(argv, deps = {}) {
if (deps.noActions) return res; if (deps.noActions) return res;
const lane = a.lane || 'fix'; const lane = a.lane || 'fix';
// refactor lane: cognitive-complexity findings only, one GitHub issue per function (reviewed and reverted per function), cap 3. // refactor lane: cognitive-complexity findings only, one GitHub issue per function (reviewed and reverted per function), cap 3.
const all = [...res.medium, ...res.large]; const all = a.impactSeverities ? Object.values(res).flat() : [...res.medium, ...res.large];
const groups = (lane === 'refactor' const groups = (lane === 'refactor'
? groupIssues(all.filter((i) => REFACTOR_RULES.has(i.rule)), 1) ? groupIssues(all.filter((i) => REFACTOR_RULES.has(i.rule)), 1)
: groupIssues(all)).filter((g) => !(a.exclude || []).includes(g.rule)); : groupIssues(all, MAX_LOCATIONS, !!a.impactSeverities)).filter((g) => !(a.exclude || []).includes(g.rule));
const cap = lane === 'refactor' && !a.capSet ? 3 : a.cap; const cap = lane === 'refactor' && !a.capSet ? 3 : a.cap;
log(`\n${lane === 'refactor' ? 'Refactor' : 'Medium+'} groups (${lane === 'refactor' ? 'one per function' : 'per rule, max 40 locations'}): ${groups.length}`); log(`\n${lane === 'refactor' ? 'Refactor' : 'Medium+'} groups (${lane === 'refactor' ? 'one per function' : 'per rule, max 40 locations'}): ${groups.length}`);
ghIssues(groups, a.apply, cap, log, lane, !!a.noHook); (deps.ghIssues || ghIssues)(groups, a.apply, cap, log, lane, !!a.noHook);
if (a.fixSmall) { if (a.apply) fixSmall(res.small, log); else log('fix-small needs --apply (dry-run).'); } if (a.fixSmall) { if (a.apply) fixSmall(res.small, log); else log('fix-small needs --apply (dry-run).'); }
return res; return res;
} }

File diff suppressed because one or more lines are too long

View File

@ -70,20 +70,12 @@
card?.classList.add('llm-local-card-online'); card?.classList.add('llm-local-card-online');
} }
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
if (btn.classList.contains('ok')) {
btn.innerHTML = '<span class="mdi mdi-lan-connect"></span> Connect';
btn.className = 'llm-cloud-connect-btn llm-local-ping';
card?.classList.remove('llm-local-card-online');
localStorage.removeItem('llm-cloud-con-' + key);
return;
}
const apiKeyInp = card?.querySelector(`.llm-input[data-llm-key="${CSS.escape(key)}"]`); const apiKeyInp = card?.querySelector(`.llm-input[data-llm-key="${CSS.escape(key)}"]`);
const apiKey = apiKeyInp?.value.trim() || ''; const apiKey = apiKeyInp?.value.trim() || '';
if (!apiKey) { toast('Enter an API key first', 'error'); return; }
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Connecting…'; btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Connecting…';
try { try {
const d = await probeUrl(endpoint, probeType, apiKey); const d = await probeUrl(endpoint, probeType, apiKey, key);
if (d.ok) { if (d.ok) {
btn.innerHTML = '<span class="mdi mdi-check-network"></span> Connected'; btn.innerHTML = '<span class="mdi mdi-check-network"></span> Connected';
btn.className = 'llm-local-ping llm-cloud-connect-btn ok'; btn.className = 'llm-local-ping llm-cloud-connect-btn ok';
@ -114,7 +106,6 @@
const card = btn.closest('.llm-card'); const card = btn.closest('.llm-card');
const apiKeyInp = card?.querySelector(`.llm-input[data-llm-key="${CSS.escape(key)}"]`); const apiKeyInp = card?.querySelector(`.llm-input[data-llm-key="${CSS.escape(key)}"]`);
const apiKey = apiKeyInp?.value.trim() || ''; const apiKey = apiKeyInp?.value.trim() || '';
if (!apiKey) { toast('Enter an API key first', 'error'); return; }
if (btn.dataset.llmIncompatible && typeof confirmDialog === 'function') { if (btn.dataset.llmIncompatible && typeof confirmDialog === 'function') {
const proceed = await confirmDialog( const proceed = await confirmDialog(
"Anthropic doesn't speak the OpenAI-compatible /chat/completions format every LLM call in this app uses — setting it active will very likely break generation until Anthropic support is added. Set it anyway?", "Anthropic doesn't speak the OpenAI-compatible /chat/completions format every LLM call in this app uses — setting it active will very likely break generation until Anthropic support is added. Set it anyway?",
@ -122,13 +113,20 @@
); );
if (!proceed) return; if (!proceed) return;
} }
const patch = { llm_url: url, llm_api_key: apiKey }; try {
if (model) patch.llm_model = model; const headers = { 'Content-Type': 'application/json' };
await applyAndSaveSettings(patch); if (apiKey) headers['X-Provider-API-Key'] = apiKey;
document.querySelectorAll('.llm-cloud-use-btn.active').forEach(b => b.classList.remove('active')); const response = await fetch(`/api/local-containers/${encodeURIComponent(key)}/use-as-llm`, {
btn.classList.add('active'); method: 'POST', headers, body: JSON.stringify({ url, model: card?.querySelector('.cloud-model-select')?.value || model }),
window.syncActiveLlmPanel?.(url, apiKey); });
toast(`✓ Active LLM set to ${card?.querySelector('.llm-card-name')?.textContent || key}`, 'success'); const result = await response.json();
if (!response.ok || !result.ok) { toast(result.error || 'Could not activate LLM', 'error'); return; }
Object.assign(_appSettings || (_appSettings = {}), await fetch('/api/settings').then(r => r.json()));
document.querySelectorAll('.llm-cloud-use-btn.active').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
window.syncActiveLlmPanel?.(url, apiKey);
toast(`✓ Active LLM set to ${card?.querySelector('.llm-card-name')?.textContent || key}`, 'success');
} catch (e) { toast('Could not activate LLM: ' + e.message, 'error'); }
}); });
}); });
@ -192,7 +190,7 @@
const type = cardType(card); const type = cardType(card);
const apiKey = typeof window.engineApiKeyValue === 'function' const apiKey = typeof window.engineApiKeyValue === 'function'
? window.engineApiKeyValue(key) : ''; ? window.engineApiKeyValue(key) : '';
const d = await probeUrl(rawUrl, type, apiKey); const d = await probeUrl(rawUrl, type, apiKey, key);
applyCardState(card, key, d.ok, !d.ok); applyCardState(card, key, d.ok, !d.ok);
if (d.ok) { if (d.ok) {
toast(`✓ ${type.toUpperCase() || 'Service'} reachable — ${d.endpoint}`, 'success'); toast(`✓ ${type.toUpperCase() || 'Service'} reachable — ${d.endpoint}`, 'success');
@ -230,6 +228,109 @@
window.applyAndSaveSettings = applyAndSaveSettings; window.applyAndSaveSettings = applyAndSaveSettings;
window.probeUrl = probeUrl; window.probeUrl = probeUrl;
async function fetchEngineModels(url, type, apiKey = '', name = '') {
const params = new URLSearchParams({ url, type, name });
return fetch('/api/engine-models?' + params, {
headers: apiKey ? { 'X-Provider-API-Key': apiKey } : {},
}).then(r => r.json());
}
document.querySelectorAll('.llm-card').forEach(card => {
const keyInp = card.querySelector('.llm-input[data-llm-key]');
if (!keyInp) return;
const key = keyInp.dataset.llmKey;
const useBtn = card.querySelector('.llm-cloud-use-btn');
const groqStt = key === 'groq_stt';
if (!useBtn && !groqStt) {
if (['hf_stt', 'assemblyai', 'fish_audio'].includes(key)) {
const note = document.createElement('div');
note.className = 'llm-info-note';
note.textContent = 'Provider-native transcription or speech API; connection testing and model discovery are not integrated.';
card.appendChild(note);
}
return;
}
const url = useBtn?.dataset.llmEndpoint || 'https://api.groq.com/openai/v1';
const type = groqStt ? 'stt' : useBtn.dataset.llmProbeType || (useBtn.dataset.llmIncompatible ? 'anthropic' : 'llm');
const controls = document.createElement('div');
controls.className = 'llm-field-row';
controls.innerHTML = '<select class="llm-input cloud-model-select" aria-label="Provider model"><option value="">— load models —</option></select><button type="button" class="llm-use-btn">Load models</button>';
const select = controls.querySelector('select');
const loadBtn = controls.querySelector('button');
const wanted = _appSettings?.engine_models?.[key] || useBtn?.dataset.llmModel || '';
if (wanted) select.innerHTML = `<option value="${escHtml(wanted)}">${escHtml(wanted)}</option>`;
loadBtn.addEventListener('click', async () => {
loadBtn.disabled = true;
try {
const d = await fetchEngineModels(url, type, keyInp.value.trim(), key);
if (!d.ok) { toast(d.error || 'Could not load models', 'error'); return; }
const old = select.value;
const models = d.models || [];
select.innerHTML = '<option value="">— select a model —</option>' + models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('');
if (models.includes(old)) select.value = old;
} catch (e) { toast('Could not load models: ' + e.message, 'error'); }
finally { loadBtn.disabled = false; }
});
select.addEventListener('change', () => applyAndSaveSettings({ engine_models: { ...(_appSettings?.engine_models || {}), [key]: select.value } }));
if (groqStt) {
const testBtn = document.createElement('button');
testBtn.type = 'button'; testBtn.className = 'llm-use-btn'; testBtn.textContent = 'Test connection';
testBtn.addEventListener('click', async () => {
testBtn.disabled = true;
try {
const d = await probeUrl(url, 'stt', keyInp.value.trim(), key);
toast(d.ok ? 'Service reachable' : d.error || 'Connection failed', d.ok ? 'success' : 'error');
} catch (e) { toast('Connection failed: ' + e.message, 'error'); }
finally { testBtn.disabled = false; }
});
const activateBtn = document.createElement('button');
activateBtn.type = 'button'; activateBtn.className = 'llm-use-btn'; activateBtn.textContent = 'Use as STT';
activateBtn.addEventListener('click', async () => {
const headers = { 'Content-Type': 'application/json' };
if (keyInp.value.trim()) headers['X-Provider-API-Key'] = keyInp.value.trim();
try {
const d = await fetch(`/api/local-containers/${key}/use-as-engine`, { method: 'POST', headers,
body: JSON.stringify({ url, role: 'stt', setting_key: 'whisper_url', model: select.value }) }).then(r => r.json());
if (d.ok) {
Object.assign(_appSettings || (_appSettings = {}), await fetch('/api/settings').then(r => r.json()));
$('s-whisper-url').value = url;
}
toast(d.ok ? 'Active STT updated' : d.error || 'Activation failed', d.ok ? 'success' : 'error');
} catch (e) { toast('Activation failed: ' + e.message, 'error'); }
});
controls.append(testBtn, activateBtn);
}
card.appendChild(controls);
});
['s-stt-test-connection', 's-stt-load-models'].forEach(id => {
$(id)?.addEventListener('click', async () => {
const url = $('s-whisper-url').value.trim();
const apiKey = $('s-stt-api-key').value.trim();
const status = $('s-stt-connection-status');
const btn = $(id);
if (!url) { status.textContent = 'Enter an endpoint first'; return; }
btn.disabled = true;
status.textContent = 'Checking…';
try {
const d = id === 's-stt-load-models'
? await fetchEngineModels(url, 'stt', apiKey)
: await probeUrl(url, 'stt', apiKey);
if (!d.ok) { status.textContent = d.error || 'Connection failed'; return; }
status.textContent = 'Service reachable';
if (id === 's-stt-load-models') {
const select = $('s-stt-model');
const wanted = select.value;
const models = d.models || [];
select.innerHTML = '<option value="">— select a model —</option>' + models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('');
if (models.includes(wanted)) select.value = wanted;
status.textContent = models.length ? `${models.length} models loaded` : 'No models advertised';
}
} catch (e) { status.textContent = 'Connection failed: ' + e.message; }
finally { btn.disabled = false; }
});
});
$('llm-use-faster-whisper-stt')?.addEventListener('click', () => { $('llm-use-faster-whisper-stt')?.addEventListener('click', () => {
const url = document.querySelector('[data-llm-local-key="faster-whisper"]')?.value.trim() const url = document.querySelector('[data-llm-local-key="faster-whisper"]')?.value.trim()
|| 'http://host.docker.internal:8000'; || 'http://host.docker.internal:8000';
@ -315,6 +416,14 @@
}; };
} }
function activeEngineDisconnected(settingKey, url, modelSel, setStatus) {
const identity = _appSettings?.engine_active?.[settingKey];
if (url !== _appSettings?.[settingKey] || _appSettings?.engine_disabled?.[identity] !== true) return false;
modelSel.innerHTML = '<option value="">Disconnected</option>';
setStatus('Disconnected', 'err');
return true;
}
// ── Active Language Model (global picker) ─────────────────────────────── // ── Active Language Model (global picker) ───────────────────────────────
(function initActiveLlmPanel() { (function initActiveLlmPanel() {
upgradeModelSelect('llm-active-model'); upgradeModelSelect('llm-active-model');
@ -329,12 +438,13 @@
async function fetchModels(url, apiKey) { async function fetchModels(url, apiKey) {
url = (url || urlInp.value.trim()); url = (url || urlInp.value.trim());
if (!url) { modelSel.innerHTML = '<option value="">— set an endpoint —</option>'; setStatus(''); return; } if (!url) { modelSel.innerHTML = '<option value="">— set an endpoint —</option>'; setStatus(''); return; }
if (activeEngineDisconnected('llm_url', url, modelSel, setStatus)) return;
const want = (_appSettings && _appSettings.llm_model) || ''; const want = (_appSettings && _appSettings.llm_model) || '';
setStatus('Loading models…'); setStatus('Loading models…');
if (refreshBtn) refreshBtn.disabled = true; if (refreshBtn) refreshBtn.disabled = true;
try { try {
const fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url); const d = await fetchEngineModels(url, 'llm', apiKey);
const d = await fetch(fetchUrl).then(r => r.json()); if (activeEngineDisconnected('llm_url', url, modelSel, setStatus)) return;
if (d.error) { if (d.error) {
let msg = 'Failed'; let msg = 'Failed';
try { try {
@ -468,11 +578,13 @@
async function fetchModels(url) { async function fetchModels(url) {
url = (url || urlInp.value.trim()); url = (url || urlInp.value.trim());
if (!url) { modelSel.innerHTML = '<option value="">— set an endpoint —</option>'; setStatus(''); return; } if (!url) { modelSel.innerHTML = '<option value="">— set an endpoint —</option>'; setStatus(''); return; }
if (activeEngineDisconnected('vision_ocr_url', url, modelSel, setStatus)) return;
const want = (_appSettings && _appSettings.vision_ocr_model) || ''; const want = (_appSettings && _appSettings.vision_ocr_model) || '';
setStatus('Loading models…'); setStatus('Loading models…');
if (refreshBtn) refreshBtn.disabled = true; if (refreshBtn) refreshBtn.disabled = true;
try { try {
const d = await fetch('/api/conversation/llm-models?url=' + encodeURIComponent(url)).then(r => r.json()); const d = await fetchEngineModels(url, 'ocr');
if (activeEngineDisconnected('vision_ocr_url', url, modelSel, setStatus)) return;
if (d.error) { if (d.error) {
let msg = d.error; let msg = d.error;
try { const j = JSON.parse(d.error); msg = j.error?.message || j.detail || d.error; } catch (_) {} try { const j = JSON.parse(d.error); msg = j.error?.message || j.detail || d.error; } catch (_) {}

View File

@ -335,17 +335,17 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
// ── Populate STT backends ──────────────────────────────────────────────── // ── Populate STT backends ────────────────────────────────────────────────
async function loadConvSttBackends() { async function loadConvSttBackends() {
if (!sttSel) return; if (!sttSel) return;
const prev = sttSel.value; const prev = _appSettings?.stt_preferred_backend || sttSel.value;
try { try {
const d = await fetch('/api/stt-backends').then(r => r.json()); const d = await fetch('/api/stt-backends').then(r => r.json());
const all = d.backends || []; const all = sttBackendChoices(d.backends || [], prev);
if (!all.length) { if (!all.length) {
sttSel.innerHTML = '<option value="configured">Default (configured)</option>'; sttSel.innerHTML = '<option value="configured">Default (configured)</option>';
return; return;
} }
sttSel.innerHTML = all.map(b => { sttSel.innerHTML = all.map(b => {
const icon = b.available ? '✓' : '✗'; const icon = b.available ? '✓' : '✗';
const label = `${icon} ${escHtml(b.label)}`; const label = `${icon} ${escHtml(b.title || b.label || b.id)}`;
const disabled = !b.available; const disabled = !b.available;
return `<option value="${escHtml(b.id)}"${disabled ? ' disabled' : ''}>${label}</option>`; return `<option value="${escHtml(b.id)}"${disabled ? ' disabled' : ''}>${label}</option>`;
}).join(''); }).join('');

View File

@ -253,6 +253,7 @@ function loadCustomEngineCards() {
return merged; return merged;
} }
function saveCustomEngineCards(cards) { function saveCustomEngineCards(cards) {
cards = cards.map(({ apiKey, api_key, ...card }) => card);
localStorage.setItem('engines-custom-cards', JSON.stringify(cards)); localStorage.setItem('engines-custom-cards', JSON.stringify(cards));
if (_appSettings) _appSettings.custom_engine_cards = cards; if (_appSettings) _appSettings.custom_engine_cards = cards;
_patchSettings({ custom_engine_cards: cards }); _patchSettings({ custom_engine_cards: cards });
@ -288,26 +289,107 @@ const DC_ROLE_USE = {
ocr: { settingKey: 'vision_ocr_url', label: 'Use as OCR' }, ocr: { settingKey: 'vision_ocr_url', label: 'Use as OCR' },
}; };
async function activateEngine(id, role, settingKey, url, model, apiKey = '') {
const headers = { 'Content-Type': 'application/json' };
if (apiKey) headers['X-Provider-API-Key'] = apiKey;
const body = role === 'llm' ? { url, model } : { url, model, role, setting_key: settingKey, activate: true };
const response = await fetch(`/api/local-containers/${encodeURIComponent(id)}/use-as-${role === 'llm' ? 'llm' : 'engine'}`, {
method: 'POST', headers, body: JSON.stringify(body),
});
const result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || 'Activation failed');
_appSettings[settingKey] = url;
try { Object.assign(_appSettings, await fetch('/api/settings').then(r => r.json())); } catch (_) { /* Activation is already saved. */ }
try {
if (role === 'tts') {
const custom = (_appSettings.custom_engine_cards || []).some(card => String(card.id || card.name) === id);
await window.refreshTtsBackendAvailability?.(custom ? 'custom:' + id : '');
}
if (role === 'stt') {
await window.refreshSttBackends?.(_appSettings.stt_preferred_backend || '');
window.syncPreferredSttBackend?.(_appSettings);
}
} catch (_) { /* Optional picker refresh does not undo saved activation. */ }
if (role === 'llm') window.syncActiveLlmPanel?.(url);
if (role === 'ocr') window.syncActiveOcrPanel?.(url);
try { await window.refreshStatusBarEngines?.({ force: true }); } catch (_) { /* status bar catches up on its next poll */ }
}
async function setEngineConnection(id, connected, role) {
const response = await fetch(`/api/local-containers/${encodeURIComponent(id)}/connection`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ connected }),
});
const result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || 'Connection change failed');
const disabled = { ...(_appSettings?.engine_disabled || {}) };
if (connected) delete disabled[id]; else disabled[id] = true;
_appSettings.engine_disabled = disabled;
try {
Object.assign(_appSettings, await fetch('/api/settings').then(r => r.json()));
await window.refreshTtsBackendAvailability?.();
if (role === 'stt') {
await window.refreshSttBackends?.();
window.syncPreferredSttBackend?.(_appSettings);
}
if (role === 'llm') window.syncActiveLlmPanel?.(_appSettings.llm_url || '');
if (role === 'ocr') window.syncActiveOcrPanel?.(_appSettings.vision_ocr_url || '');
await window.refreshStatusBarEngines?.({ force: true });
} catch (_) { /* The connection change is already saved. */ }
}
let _inlineEngineCard = null;
function closeEngineEditor() {
const dlg = $('add-engine-dlg');
if (_inlineEngineCard) {
dlg.appendChild(_inlineEngineCard.querySelector('.add-engine-form'));
_inlineEngineCard.classList.remove('dc-editing');
const editButton = _inlineEngineCard.querySelector('.dc-edit-btn');
_inlineEngineCard = null;
editButton?.focus();
}
dlg.dataset.requestId = String(Number(dlg.dataset.requestId || 0) + 1);
delete dlg.dataset.editId;
$('aed-api-key').value = '';
dlg.close();
}
window.openAddEngineDialog = function(role) { window.openAddEngineDialog = function(role) {
if ($('aed-save')?.disabled) return;
const dlg = document.getElementById('add-engine-dlg'); const dlg = document.getElementById('add-engine-dlg');
if (!dlg) return; if (!dlg) return;
delete dlg.dataset.editId; closeEngineEditor();
dlg.dataset.cardId = 'custom-' + (crypto.randomUUID?.() || Date.now());
dlg.dataset.requestId = String(Number(dlg.dataset.requestId || 0) + 1);
delete dlg.dataset.probeName;
const titleEl = dlg.querySelector('.add-engine-title'); const titleEl = dlg.querySelector('.add-engine-title');
if (titleEl) titleEl.textContent = ' Add Custom Engine'; if (titleEl) titleEl.textContent = ' Add Custom Engine';
const saveBtn = document.getElementById('aed-save'); const saveBtn = document.getElementById('aed-save');
if (saveBtn) saveBtn.textContent = 'Add Card'; if (saveBtn) saveBtn.textContent = 'Add Card';
const roleEl = document.getElementById('aed-role'); const roleEl = document.getElementById('aed-role');
if (roleEl && role) roleEl.value = role; if (roleEl && role) roleEl.value = role;
['aed-name', 'aed-url', 'aed-container', 'aed-desc'].forEach(id => { ['aed-name', 'aed-url', 'aed-container', 'aed-desc', 'aed-api-key', 'aed-model'].forEach(id => {
const el = document.getElementById(id); if (el) el.value = ''; const el = document.getElementById(id); if (el) el.value = '';
}); });
$('aed-api-key').placeholder = 'API key (optional)';
$('aed-models').innerHTML = '';
$('aed-status').textContent = '';
$('aed-params').value = '{}';
$('aed-active').checked = false;
dlg.showModal(); dlg.showModal();
}; };
function editCustomEngineCard(card) { function editCustomEngineCard(card) {
if ($('aed-save')?.disabled) return;
const dlg = document.getElementById('add-engine-dlg'); const dlg = document.getElementById('add-engine-dlg');
if (!dlg) return; if (!dlg) return;
closeEngineEditor();
const cardElement = Array.from(document.querySelectorAll('.dc-edit-btn')).find(button => button.dataset.dcCustomId === String(card.id || card.name))?.closest('.llm-local-card');
if (!cardElement) return;
dlg.dataset.editId = String(card.id || card.name); dlg.dataset.editId = String(card.id || card.name);
dlg.dataset.cardId = dlg.dataset.editId;
dlg.dataset.requestId = String(Number(dlg.dataset.requestId || 0) + 1);
dlg.dataset.probeName = _appSettings?.engine_api_keys?.[dlg.dataset.cardId] ? dlg.dataset.cardId : card.name;
const titleEl = dlg.querySelector('.add-engine-title'); const titleEl = dlg.querySelector('.add-engine-title');
if (titleEl) titleEl.textContent = ' Edit Engine'; if (titleEl) titleEl.textContent = ' Edit Engine';
const saveBtn = document.getElementById('aed-save'); const saveBtn = document.getElementById('aed-save');
@ -316,18 +398,76 @@ function editCustomEngineCard(card) {
if (roleEl) roleEl.value = card.role || 'tts'; if (roleEl) roleEl.value = card.role || 'tts';
const setVal = (id, val) => { const el = document.getElementById(id); if (el) el.value = val || ''; }; const setVal = (id, val) => { const el = document.getElementById(id); if (el) el.value = val || ''; };
setVal('aed-name', card.label || card.name); setVal('aed-name', card.label || card.name);
setVal('aed-url', card.url); setVal('aed-url', cardElement.querySelector('.dc-url-inp')?.value || card.url);
setVal('aed-container', card.containerName); setVal('aed-container', cardElement.querySelector('.dc-container-name-inp')?.value ?? card.containerName);
setVal('aed-desc', card.description); setVal('aed-desc', card.description);
dlg.showModal(); setVal('aed-api-key', '');
setVal('aed-model', _appSettings?.engine_models?.[dlg.dataset.cardId] || _appSettings?.engine_models?.[card.name]);
$('aed-api-key').placeholder = _appSettings?.engine_api_keys?.[dlg.dataset.cardId] || _appSettings?.engine_api_keys?.[card.name]
? 'Configured — enter a new key to replace' : 'API key (optional)';
$('aed-models').innerHTML = '';
$('aed-status').textContent = '';
$('aed-params').value = JSON.stringify(card.params || {}, null, 2);
$('aed-active').checked = card.role === 'stt'
? _appSettings?.stt_preferred_backend === 'custom:' + dlg.dataset.cardId
: _appSettings?.[DC_ROLE_USE[card.role]?.settingKey] === card.url && (card.role !== 'ocr' || !!_appSettings?.vision_ocr_enabled);
_inlineEngineCard = cardElement;
cardElement.classList.add('dc-editing');
cardElement.appendChild(dlg.querySelector('.add-engine-form'));
$('aed-name').focus();
} }
(function initCustomEngineDialog() { (function initCustomEngineDialog() {
const dlg = document.getElementById('add-engine-dlg'); const dlg = document.getElementById('add-engine-dlg');
if (!dlg) return; if (!dlg) return;
document.getElementById('aed-cancel')?.addEventListener('click', () => { delete dlg.dataset.editId; dlg.close(); }); function readParams() {
dlg.addEventListener('click', e => { if (e.target === dlg) { delete dlg.dataset.editId; dlg.close(); } }); let params;
document.getElementById('aed-save')?.addEventListener('click', () => { try { params = JSON.parse($('aed-params').value.trim() || '{}'); }
catch (_) { throw new Error('Additional parameters must be valid JSON'); }
if (!params || Array.isArray(params) || typeof params !== 'object') throw new Error('Additional parameters must be a JSON object');
const reserved = new Set(['model', 'messages', 'input', 'text', 'voice', 'stream', 'response_format', 'file', 'files', 'audio', 'audio_prompt', 'audio_prompt_transcript', 'references', 'url', 'base_url', 'endpoint', 'headers', 'method']);
if (Object.keys(params).some(key => reserved.has(key))) throw new Error('Additional parameters cannot replace connection, model, or request input fields');
function hasCredentials(value) {
return value && typeof value === 'object' && Object.entries(value).some(([key, child]) =>
/(apikeys?|authorization|password|passwd|secret|secretkey|accesskey|token|privatekey|credentials?|headers)$/.test(key.toLowerCase().replace(/[^a-z]/g, '')) || hasCredentials(child));
}
if (hasCredentials(params)) throw new Error('Keep credentials in the API key field, outside additional parameters');
return params;
}
document.getElementById('aed-cancel')?.addEventListener('click', () => { if (!$('aed-save').disabled) closeEngineEditor(); });
dlg.addEventListener('click', e => { if (e.target === dlg && !$('aed-save').disabled) closeEngineEditor(); });
dlg.addEventListener('cancel', e => { if ($('aed-save').disabled) e.preventDefault(); });
['aed-url', 'aed-role', 'aed-api-key'].forEach(id => $(id)?.addEventListener('input', () => {
dlg.dataset.requestId = String(Number(dlg.dataset.requestId || 0) + 1);
$('aed-models').innerHTML = '';
$('aed-status').textContent = '';
}));
['aed-test', 'aed-load-models'].forEach(id => $(id)?.addEventListener('click', async () => {
const requestId = dlg.dataset.requestId = String(Number(dlg.dataset.requestId || 0) + 1);
const url = $('aed-url').value.trim();
if (!url) { $('aed-status').textContent = 'URL is required'; return; }
try { readParams(); } catch (e) { $('aed-status').textContent = e.message; return; }
const apiKey = $('aed-api-key').value.trim();
$(id).disabled = true;
$('aed-status').textContent = 'Checking…';
try {
const route = id === 'aed-test' ? '/api/probe-url?' : '/api/engine-models?';
const response = await fetch(route + new URLSearchParams({ url, type: $('aed-role').value, name: dlg.dataset.probeName || dlg.dataset.cardId }), {
headers: apiKey ? { 'X-Provider-API-Key': apiKey } : {},
});
const result = await response.json();
if (!(dlg.open || _inlineEngineCard) || dlg.dataset.requestId !== requestId) return;
if (!response.ok || !result.ok) { $('aed-status').textContent = result.error || 'Request failed'; return; }
$('aed-status').textContent = 'Service reachable';
if (id === 'aed-load-models') {
const models = result.models || [];
$('aed-models').innerHTML = models.map(model => `<option value="${escHtml(model)}"></option>`).join('');
$('aed-status').textContent = models.length ? `${models.length} models loaded — select or enter an ID` : 'No models advertised — enter an ID manually';
}
} catch (e) { if ((dlg.open || _inlineEngineCard) && dlg.dataset.requestId === requestId) $('aed-status').textContent = 'Request failed: ' + e.message; }
finally { $(id).disabled = false; }
}));
document.getElementById('aed-save')?.addEventListener('click', async () => {
const name = document.getElementById('aed-name')?.value.trim(); const name = document.getElementById('aed-name')?.value.trim();
const role = document.getElementById('aed-role')?.value; const role = document.getElementById('aed-role')?.value;
const url = document.getElementById('aed-url')?.value.trim(); const url = document.getElementById('aed-url')?.value.trim();
@ -335,27 +475,47 @@ function editCustomEngineCard(card) {
const desc = document.getElementById('aed-desc')?.value.trim() || ''; const desc = document.getElementById('aed-desc')?.value.trim() || '';
if (!name) { toast('Name is required', 'error'); return; } if (!name) { toast('Name is required', 'error'); return; }
if (!url) { toast('URL is required', 'error'); return; } if (!url) { toast('URL is required', 'error'); return; }
let params;
try { params = readParams(); } catch (e) { $('aed-status').textContent = e.message; return; }
const editId = dlg.dataset.editId; const editId = dlg.dataset.editId;
if (editId) { const id = dlg.dataset.cardId || editId || 'custom-' + Date.now();
const cards = loadCustomEngineCards().map(c => const cards = editId ? loadCustomEngineCards().map(c =>
String(c.id || c.name) === editId String(c.id || c.name) === editId
? { ...c, name, label: name, role, url, containerName, description: desc } ? { ...c, id, name, label: name, role, url, containerName, description: desc, params }
: c : c
); ) : [...loadCustomEngineCards(), { id, name, label: name, role, url, containerName, description: desc, params, isCustom: true }];
saveCustomEngineCards(cards); const apiKey = $('aed-api-key').value.trim();
delete dlg.dataset.editId; const model = $('aed-model').value.trim();
dlg.close(); const useActive = $('aed-active').checked;
const metadata = cards.map(({ apiKey, api_key, ...card }) => card);
const patch = {
custom_engine_cards: metadata,
engine_models: { ...(_appSettings?.engine_models || {}), [id]: model },
engine_local_urls: { ...(_appSettings?.engine_local_urls || {}), ['dc-' + id]: url },
};
if (apiKey) patch.engine_api_keys = { [id]: apiKey };
$('aed-save').disabled = true;
$('aed-cancel').disabled = true;
try {
const response = await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
if (!response.ok) throw new Error('Could not save engine');
localStorage.setItem('engines-custom-cards', JSON.stringify(metadata));
localStorage.setItem('dc-url-' + id, url);
Object.assign(_appSettings || (_appSettings = {}), { custom_engine_cards: metadata, engine_models: patch.engine_models, engine_local_urls: patch.engine_local_urls });
if (apiKey) _appSettings.engine_api_keys = { ...(_appSettings.engine_api_keys || {}), [id]: '__configured__' };
try { Object.assign(_appSettings, await fetch('/api/settings').then(r => r.json())); } catch (_) { /* The save already succeeded. */ }
$('aed-api-key').value = '';
closeEngineEditor();
loadLocalContainers(); loadLocalContainers();
toast(`"${name}" updated`, 'success'); toast(`"${name}" ${editId ? 'updated' : 'added'}`, 'success');
} else { if (useActive) {
const card = { id: 'custom-' + Date.now(), name, label: name, role, url, containerName, description: desc, isCustom: true }; try {
const cards = loadCustomEngineCards(); await activateEngine(id, role, DC_ROLE_USE[role].settingKey, url, model, apiKey);
cards.push(card); toast(`"${name}" is active`, 'success');
saveCustomEngineCards(cards); } catch (_) { toast(`"${name}" saved; activation failed. Use the card's activation button to retry.`, 'error'); }
dlg.close(); }
loadLocalContainers(); } catch (e) { toast(e.message, 'error'); }
toast(`"${name}" added`, 'success'); finally { $('aed-save').disabled = false; $('aed-cancel').disabled = false; }
}
}); });
})(); })();
@ -400,6 +560,7 @@ async function fetchLocalContainersWithRetry() {
} }
async function loadLocalContainers() { async function loadLocalContainers() {
if (_inlineEngineCard) return;
const seq = ++_localContainersLoadSeq; const seq = ++_localContainersLoadSeq;
const gridTts = $('dc-grid-tts'); const gridTts = $('dc-grid-tts');
const gridStt = $('dc-grid-stt'); const gridStt = $('dc-grid-stt');
@ -438,6 +599,8 @@ async function loadLocalContainers() {
window.loadLocalContainers = loadLocalContainers; window.loadLocalContainers = loadLocalContainers;
function renderLocalContainers(containers) { function renderLocalContainers(containers) {
if (_inlineEngineCard) return;
containers = containers.map(c => c.isCustom ? { ...c, legacyName: c.name, name: c.id || c.name, label: c.label || c.name } : c);
const gridTts = $('dc-grid-tts'); const gridTts = $('dc-grid-tts');
const gridStt = $('dc-grid-stt'); const gridStt = $('dc-grid-stt');
const gridLlm = $('dc-grid-llm'); const gridLlm = $('dc-grid-llm');
@ -524,6 +687,7 @@ function renderLocalContainers(containers) {
<input class="llm-local-url-inp dc-url-inp" type="text" spellcheck="false" <input class="llm-local-url-inp dc-url-inp" type="text" spellcheck="false"
placeholder="${escHtml(defaultUrl)}" placeholder="${escHtml(defaultUrl)}"
data-dc-url-key="${n}" data-dc-url-key="${n}"
data-dc-legacy-name="${escHtml(c.legacyName || c.name)}"
data-dc-url-default="${escHtml(defaultUrl)}"> data-dc-url-default="${escHtml(defaultUrl)}">
</div>`; </div>`;
@ -541,27 +705,36 @@ function renderLocalContainers(containers) {
data-cn-key="${n}"> data-cn-key="${n}">
</div>`; </div>`;
// API key row — only for LLMs // Authentication is optional for every supported engine role.
const savedApiKey = isCustom const savedApiKey = isCustom
? ((typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(c.name, 'dc-apikey-' + c.name) : '') || c.apiKey || '') ? ((typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(c.name, 'dc-apikey-' + c.name) : '') || '')
: ((typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(c.name) : '') || ''); : ((typeof window.engineApiKeyValue === 'function' ? window.engineApiKeyValue(c.name) : '') || '');
const apiKeyRowHtml = (c.role === 'llm' || c.name === 'litellm') ? ` const apiKeyRowHtml = `
<div class="llm-local-url"> <div class="llm-local-url">
<span class="llm-local-url-label" title="API Key"><span class="mdi mdi-key-outline"></span></span> <span class="llm-local-url-label" title="API Key"><span class="mdi mdi-key-outline"></span></span>
<input class="llm-local-url-inp dc-apikey-inp" type="password" <input class="llm-local-url-inp dc-apikey-inp" type="password"
placeholder="API key (optional)" spellcheck="false" placeholder="API key (optional)" spellcheck="false"
value="${escHtml(savedApiKey)}" value="${escHtml(savedApiKey)}"
data-dc-apikey-key="${n}"> data-dc-apikey-key="${n}">
</div>` : ''; </div>`;
const savedModel = _appSettings?.engine_models?.[c.name] || _appSettings?.engine_models?.[c.legacyName] || '';
const modelRowHtml = `
<div class="llm-local-url">
<span class="llm-local-url-label">Model</span>
<input class="llm-local-url-inp dc-model-inp" list="dc-models-${n}" aria-label="${escHtml(c.label || c.name)} model" value="${escHtml(savedModel)}" placeholder="Model ID">
<datalist id="dc-models-${n}"></datalist>
<button class="llm-use-btn dc-models-btn" type="button">Load models</button>
</div>`;
// Use-as button — right-aligned, clearly labelled as the activation action // Use-as button — right-aligned, clearly labelled as the activation action
const useEntry = DC_USE_MAP[c.name] || DC_ROLE_USE[c.role]; const useEntry = DC_USE_MAP[c.name] || DC_ROLE_USE[c.role];
const useLabel = c.role === 'tts' ? 'Use as TTS' : useEntry?.label;
const useBtn = useEntry const useBtn = useEntry
? `<button class="llm-use-btn dc-use-btn" ? `<button class="llm-use-btn dc-use-btn"
data-dc-use-key="${escHtml(useEntry.settingKey)}" data-dc-use-key="${escHtml(useEntry.settingKey)}"
data-dc-name="${n}" data-dc-name="${n}"
title="Saves this URL to Settings and makes it available in the ${escHtml(useEntry.label.replace('Use as ', ''))} dropdown. Also applied automatically when you Connect."> title="Makes this URL and model active in the ${escHtml(useLabel.replace('Use as ', ''))} dropdown.">
<span class="mdi mdi-arrow-right-circle-outline"></span> ${escHtml(useEntry.label)} <span class="mdi mdi-arrow-right-circle-outline"></span> ${escHtml(useLabel)}
</button>` </button>`
: ''; : '';
@ -586,6 +759,7 @@ function renderLocalContainers(containers) {
const controlsRowHtml = ` const controlsRowHtml = `
<div class="dc-controls-row"> <div class="dc-controls-row">
<button class="llm-local-ping dc-connect-btn" data-dc-url-key="${n}"><span class="mdi mdi-lan-connect"></span> Connect</button> <button class="llm-local-ping dc-connect-btn" data-dc-url-key="${n}"><span class="mdi mdi-lan-connect"></span> Connect</button>
<button class="llm-use-btn dc-disconnect-btn" data-dc-name="${n}" type="button" hidden>Disconnect</button>
<div class="dc-ctrl-group">${dockerBtnsSpan}${githubBtn}${editBtn}${deleteBtn}</div> <div class="dc-ctrl-group">${dockerBtnsSpan}${githubBtn}${editBtn}${deleteBtn}</div>
${useBtn} ${useBtn}
</div>`; </div>`;
@ -622,7 +796,7 @@ function renderLocalContainers(containers) {
<span class="llm-local-compat ${stCls}">${escHtml(stLabel)}</span> <span class="llm-local-compat ${stCls}">${escHtml(stLabel)}</span>
</div> </div>
${metricsHtml} ${metricsHtml}
${c.description ? `<p class="llm-local-desc">${escHtml(c.description)}</p>` : ''}${urlRowHtml}${apiKeyRowHtml}${containerNameRowHtml}${controlsRowHtml}${snippetHtml} ${c.description ? `<p class="llm-local-desc">${escHtml(c.description)}</p>` : ''}${urlRowHtml}${apiKeyRowHtml}${modelRowHtml}${containerNameRowHtml}${controlsRowHtml}${snippetHtml}
</div>`; </div>`;
} }
@ -673,32 +847,33 @@ function renderLocalContainers(containers) {
// Helpers — set connected / disconnected visual state on a card // Helpers — set connected / disconnected visual state on a card
function setConnected(btn, card, useBtn, connected) { function setConnected(btn, card, useBtn, connected) {
const disconnectBtn = card?.querySelector('.dc-disconnect-btn');
if (disconnectBtn) disconnectBtn.hidden = !connected;
if (connected) { if (connected) {
btn.innerHTML = '<span class="mdi mdi-check-network"></span> Connected'; btn.innerHTML = '<span class="mdi mdi-check-network"></span> Connected';
btn.className = 'llm-local-ping dc-connect-btn ok'; btn.className = 'llm-local-ping dc-connect-btn ok';
btn.title = 'Reachable — click to disconnect'; btn.title = 'Reachable — click to test again';
if (card) card.classList.add('llm-local-card-online'); if (card) card.classList.add('llm-local-card-online');
if (useBtn) useBtn.classList.add('active');
} else { } else {
btn.innerHTML = '<span class="mdi mdi-lan-connect"></span> Connect'; btn.innerHTML = '<span class="mdi mdi-lan-connect"></span> Connect';
btn.className = 'llm-local-ping dc-connect-btn'; btn.className = 'llm-local-ping dc-connect-btn';
btn.title = 'Test connection to this service'; btn.title = 'Test connection to this service';
if (card) card.classList.remove('llm-local-card-online'); if (card) card.classList.remove('llm-local-card-online');
if (useBtn) useBtn.classList.remove('active');
} }
} }
// URL input — server settings first, localStorage fallback; write to both on change // URL input — server settings first, localStorage fallback; write to both on change
grid.querySelectorAll('.dc-url-inp[data-dc-url-key]').forEach(inp => { grid.querySelectorAll('.dc-url-inp[data-dc-url-key]').forEach(inp => {
const key = inp.dataset.dcUrlKey; const key = inp.dataset.dcUrlKey;
const fromSrv = _appSettings?.engine_local_urls?.['dc-' + key]; const legacy = inp.dataset.dcLegacyName;
const fromLS = localStorage.getItem('dc-url-' + key); const fromSrv = _appSettings?.engine_local_urls?.['dc-' + key] || _appSettings?.engine_local_urls?.['dc-' + legacy];
const fromLS = localStorage.getItem('dc-url-' + key) || localStorage.getItem('dc-url-' + legacy);
if (fromSrv || fromLS) inp.value = fromSrv || fromLS; if (fromSrv || fromLS) inp.value = fromSrv || fromLS;
inp.addEventListener('input', () => { inp.addEventListener('input', () => {
localStorage.setItem('dc-url-' + key, inp.value); localStorage.setItem('dc-url-' + key, inp.value);
if (window._saveEngineLocalUrls) window._saveEngineLocalUrls(); if (window._saveEngineLocalUrls) window._saveEngineLocalUrls();
}); });
if (localStorage.getItem('dc-con-' + key) === '1') { if (_appSettings?.engine_disabled?.[key] !== true && localStorage.getItem('dc-con-' + key) === '1') {
const card = inp.closest('.llm-local-card'); const card = inp.closest('.llm-local-card');
const btn = card?.querySelector(`.dc-connect-btn[data-dc-url-key="${CSS.escape(key)}"]`); const btn = card?.querySelector(`.dc-connect-btn[data-dc-url-key="${CSS.escape(key)}"]`);
const useBtn = card?.querySelector('.dc-use-btn'); const useBtn = card?.querySelector('.dc-use-btn');
@ -706,6 +881,50 @@ function renderLocalContainers(containers) {
} }
}); });
grid.querySelectorAll('.dc-disconnect-btn').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
try {
const key = btn.dataset.dcName;
const card = btn.closest('.llm-local-card');
await setEngineConnection(key, false, cardType(card));
localStorage.removeItem('dc-con-' + key);
const useBtn = card.querySelector('.dc-use-btn');
setConnected(card.querySelector('.dc-connect-btn'), card, useBtn, false);
useBtn?.classList.remove('active');
toast('Engine disconnected', 'success');
} catch (e) { toast('Disconnect failed: ' + e.message, 'error'); }
finally { btn.disabled = false; }
});
});
grid.querySelectorAll('.dc-models-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const card = btn.closest('.llm-local-card');
const inp = card.querySelector('.dc-url-inp');
const key = inp.dataset.dcUrlKey;
const url = inp.value.trim() || inp.placeholder;
const apiKey = card.querySelector('.dc-apikey-inp').value.trim();
if (!url) { toast('Enter a URL first', 'error'); return; }
btn.disabled = true;
try {
const params = new URLSearchParams({ url, type: cardType(card), name: key });
const d = await fetch('/api/engine-models?' + params, { headers: apiKey ? { 'X-Provider-API-Key': apiKey } : {} }).then(r => r.json());
if (!d.ok) { toast(d.error || 'Could not load models', 'error'); return; }
const models = d.models || [];
card.querySelector('datalist').innerHTML = models.map(model => `<option value="${escHtml(model)}"></option>`).join('');
toast(models.length ? `Loaded ${models.length} models` : 'No models advertised — enter an ID manually', 'success');
} catch (e) { toast('Failed to load models: ' + e.message, 'error'); }
finally { btn.disabled = false; }
});
});
grid.querySelectorAll('.dc-model-inp').forEach(inp => {
inp.addEventListener('change', async () => {
const key = inp.closest('.llm-local-card').querySelector('.dc-url-inp').dataset.dcUrlKey;
await window.applyAndSaveSettings({ engine_models: { ...(_appSettings?.engine_models || {}), [key]: inp.value.trim() } });
});
});
// API key input — save only to server-backed engine_api_keys // API key input — save only to server-backed engine_api_keys
grid.querySelectorAll('.dc-apikey-inp[data-dc-apikey-key]').forEach(inp => { grid.querySelectorAll('.dc-apikey-inp[data-dc-apikey-key]').forEach(inp => {
const key = inp.dataset.dcApikeyKey; const key = inp.dataset.dcApikeyKey;
@ -725,15 +944,6 @@ function renderLocalContainers(containers) {
// Connect / Disconnect — probe URL, auto-apply setting, show state // Connect / Disconnect — probe URL, auto-apply setting, show state
grid.querySelectorAll('.dc-connect-btn[data-dc-url-key]').forEach(btn => { grid.querySelectorAll('.dc-connect-btn[data-dc-url-key]').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
// Disconnect if already connected
if (btn.classList.contains('ok')) {
const key = btn.dataset.dcUrlKey;
const card = btn.closest('.llm-local-card');
const useBtn = card?.querySelector('.dc-use-btn');
setConnected(btn, card, useBtn, false);
localStorage.removeItem('dc-con-' + key);
return;
}
const key = btn.dataset.dcUrlKey; const key = btn.dataset.dcUrlKey;
const card = btn.closest('.llm-local-card'); const card = btn.closest('.llm-local-card');
const inp = card?.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(key)}"]`); const inp = card?.querySelector(`.dc-url-inp[data-dc-url-key="${CSS.escape(key)}"]`);
@ -749,18 +959,10 @@ function renderLocalContainers(containers) {
const type = card ? cardType(card) : ''; const type = card ? cardType(card) : '';
const d = await window.probeUrl(rawUrl, type, apiKey, key); const d = await window.probeUrl(rawUrl, type, apiKey, key);
if (d.ok) { if (d.ok) {
await setEngineConnection(key, true, type);
setConnected(btn, card, useBtn, true); setConnected(btn, card, useBtn, true);
localStorage.setItem('dc-con-' + key, '1'); localStorage.setItem('dc-con-' + key, '1');
// Auto-apply URL to settings so it appears in dropdowns immediately. toast('✓ Service reachable', 'success');
// Reuses the "Use as" button's own click handler (rather than
// duplicating its logic) so LLM cards go through the same
// credential-aware /use-as-llm endpoint Connect just verified.
if (useBtn) {
useBtn.click();
toast(`✓ Connected and set as active ${useBtn.textContent.replace('Use as ', '').trim()}`, 'success');
} else {
toast('✓ Service reachable', 'success');
}
} else { } else {
setConnected(btn, card, useBtn, false); setConnected(btn, card, useBtn, false);
localStorage.removeItem('dc-con-' + key); localStorage.removeItem('dc-con-' + key);
@ -789,32 +991,17 @@ function renderLocalContainers(containers) {
const url = inp?.value.trim() || inp?.placeholder || ''; const url = inp?.value.trim() || inp?.placeholder || '';
const apiKey = apikeyInp?.value.trim() || ''; const apiKey = apikeyInp?.value.trim() || '';
if (!url) { toast('Enter a URL first', 'error'); return; } if (!url) { toast('Enter a URL first', 'error'); return; }
if (settingKey === 'llm_url') {
// Goes through a dedicated endpoint (not applyAndSaveSettings) so the const role = cardType(card);
// backend can fall back to this card's own saved key — the browser const model = card?.querySelector('.dc-model-inp')?.value.trim() || '';
// only has a real key in hand here if the user just typed one.
try { try {
const headers = { 'Content-Type': 'application/json' }; await activateEngine(urlKey, role, settingKey, url, model, apiKey);
if (apiKey) headers['X-Provider-API-Key'] = apiKey;
const r = await fetch(`/api/local-containers/${encodeURIComponent(urlKey)}/use-as-llm`, {
method: 'POST', headers, body: JSON.stringify({ url }),
});
const d = await r.json();
if (!r.ok || !d.ok) { toast(d.error || 'Failed to set active LLM', 'error'); return; }
if (_appSettings) { _appSettings.llm_url = url; if (d.key_set) _appSettings.llm_api_key = '__configured__'; }
if (window.syncActiveLlmPanel) window.syncActiveLlmPanel(url, apiKey);
btn.classList.add('active');
toast(`✓ ${btn.textContent.trim()} — now available in the backend dropdown`, 'success');
} catch (e) { toast('Failed to set active LLM: ' + e.message, 'error'); }
return;
}
if (window.applyAndSaveSettings) {
const patch = { [settingKey]: url };
await window.applyAndSaveSettings(patch);
if (settingKey === 'vision_ocr_url' && window.syncActiveOcrPanel) window.syncActiveOcrPanel(url);
btn.classList.add('active'); btn.classList.add('active');
setConnected(card.querySelector('.dc-connect-btn'), card, btn, true);
localStorage.setItem('dc-con-' + urlKey, '1');
toast(`✓ ${btn.textContent.trim()} — now available in the backend dropdown`, 'success'); toast(`✓ ${btn.textContent.trim()} — now available in the backend dropdown`, 'success');
} } catch (e) { toast('Failed to set active engine: ' + e.message, 'error'); }
}); });
}); });

View File

@ -41,7 +41,7 @@ function ttsBackendOptions(selected = '') {
const backends = availableTtsBackends(); const backends = availableTtsBackends();
if (!backends.length) return '<option value="">No TTS backend available</option>'; if (!backends.length) return '<option value="">No TTS backend available</option>';
const current = selected || backends[0].id; const current = selected || backends[0].id;
return backends.map(b => `<option value="${escHtml(b.id)}" ${b.id === current ? 'selected' : ''}>${escHtml(b.label)}</option>`).join(''); return backends.map(b => `<option value="${escHtml(b.id)}" ${b.id === current ? 'selected' : ''}>${escHtml(b.title || b.label || b.id)}</option>`).join('');
} }
function styleBackendOptions(selected = 'customvoice', preferStyleAware = false) { function styleBackendOptions(selected = 'customvoice', preferStyleAware = false) {
@ -51,7 +51,7 @@ function styleBackendOptions(selected = 'customvoice', preferStyleAware = false)
: preferStyleAware : preferStyleAware
? (backends.find(b => b.style_aware)?.id || backends[0].id) ? (backends.find(b => b.style_aware)?.id || backends[0].id)
: backends[0].id; : backends[0].id;
return backends.map(b => `<option value="${escHtml(b.id)}" ${b.id === preferred ? 'selected' : ''}>${escHtml(b.label)}</option>`).join(''); return backends.map(b => `<option value="${escHtml(b.id)}" ${b.id === preferred ? 'selected' : ''}>${escHtml(b.title || b.label || b.id)}</option>`).join('');
} }
function backendById(id) { function backendById(id) {
@ -178,7 +178,8 @@ function updateBackendDependentTabs() {
if (tooltip) tooltip.textContent = ok ? originalTooltip : `${originalTooltip}\n\n${disabledBackendTabMessage(tab)}`; if (tooltip) tooltip.textContent = ok ? originalTooltip : `${originalTooltip}\n\n${disabledBackendTabMessage(tab)}`;
}); });
const active = document.querySelector('.tab.active'); const active = document.querySelector('.tab.active');
if (!active || active.classList.contains('backend-unavailable')) { // Sidebar pages own navigation; their hidden legacy tabs can retain a stale active tab.
if (!document.querySelector('.page-section.is-active') && (!active || active.classList.contains('backend-unavailable'))) {
const first = document.querySelector('.tab:not(.backend-unavailable)'); const first = document.querySelector('.tab:not(.backend-unavailable)');
if (first) switchTab(first.dataset.tab); if (first) switchTab(first.dataset.tab);
} }
@ -507,12 +508,37 @@ function _populateImageGenModelSelect(selectEl, provider, savedModel) {
} }
} }
window.syncPreferredSttBackend = function (s) {
const select = $('s-stt-preferred-backend');
if (!select) return;
select.querySelectorAll('[data-custom-engine]').forEach(option => option.remove());
for (const card of s.custom_engine_cards || []) {
if (card.role !== 'stt' && card.role !== 'stt+tts') continue;
const value = 'custom:' + (card.id || card.name);
if (Array.from(select.options).some(option => option.value === value)) continue;
const option = new Option(card.label || card.name, value);
option.dataset.customEngine = '1';
select.add(option);
}
if (s.stt_preferred_backend?.startsWith('custom:') && !Array.from(select.options).some(option => option.value === s.stt_preferred_backend)) {
const option = new Option(s.stt_preferred_backend, s.stt_preferred_backend);
option.dataset.customEngine = '1';
select.add(option);
}
select.value = s.stt_preferred_backend || '';
};
async function loadSettings() { async function loadSettings() {
const s = await fetch('/api/settings').then(r => r.json()); const s = await fetch('/api/settings').then(r => r.json());
const sv = (id, val) => { const el = $(id); if (el) el.value = val; }; const sv = (id, val) => { const el = $(id); if (el) el.value = val; };
const svSecret = (id, val) => { const el = $(id); if (el) { el.value = ''; el.placeholder = val === '__configured__' ? 'Configured — enter a new key to replace' : ''; } }; const svSecret = (id, val) => { const el = $(id); if (el) { el.value = ''; el.placeholder = val === '__configured__' ? 'Configured — enter a new key to replace' : ''; } };
sv('s-whisper-url', s.whisper_url || ''); sv('s-whisper-url', s.whisper_url || '');
svSecret('s-whisper-key', s.whisper_api_key); svSecret('s-whisper-key', s.whisper_api_key);
svSecret('s-stt-api-key', s.whisper_api_key);
if ($('s-stt-model')) {
const model = s.stt_models?.configured || '';
$('s-stt-model').innerHTML = `<option value="${escHtml(model)}">${escHtml(model || '— load models —')}</option>`;
}
sv('s-tts-url', s.tts_url || ''); sv('s-tts-url', s.tts_url || '');
sv('s-faster-whisper-url', s.faster_whisper_url || ''); sv('s-faster-whisper-url', s.faster_whisper_url || '');
sv('s-whisper-cpp-url', s.whisper_cpp_url || ''); sv('s-whisper-cpp-url', s.whisper_cpp_url || '');
@ -748,7 +774,7 @@ async function loadSettings() {
if (window.populateLangSelect) window.populateLangSelect(); if (window.populateLangSelect) window.populateLangSelect();
// Captures settings // Captures settings
const sttLang = $('s-stt-language'); if (sttLang) sttLang.value = s.stt_language || ''; const sttLang = $('s-stt-language'); if (sttLang) sttLang.value = s.stt_language || '';
const sttPref = $('s-stt-preferred-backend'); if (sttPref) sttPref.value = s.stt_preferred_backend || ''; window.syncPreferredSttBackend(s);
const autoRef = $('s-auto-refine'); if (autoRef) autoRef.value = s.auto_refine || 'off'; const autoRef = $('s-auto-refine'); if (autoRef) autoRef.value = s.auto_refine || 'off';
const refModel = $('s-refine-model'); if (refModel) refModel.value = s.refine_model || ''; const refModel = $('s-refine-model'); if (refModel) refModel.value = s.refine_model || '';
const rfFill = $('s-refine-fillers'); if (rfFill) rfFill.checked = s.refine_fillers !== false; const rfFill = $('s-refine-fillers'); if (rfFill) rfFill.checked = s.refine_fillers !== false;
@ -818,7 +844,8 @@ document.addEventListener('click', async e => { if (!e.target.closest('.s-save-b
await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ body: JSON.stringify({
whisper_url: $('s-whisper-url').value, whisper_url: $('s-whisper-url').value,
whisper_api_key: $('s-whisper-key').value, whisper_api_key: $('s-stt-api-key')?.value || $('s-whisper-key').value,
stt_models: { ...(_appSettings?.stt_models || {}), configured: $('s-stt-model')?.value || '' },
faster_whisper_url: $('s-faster-whisper-url').value, faster_whisper_url: $('s-faster-whisper-url').value,
whisper_cpp_url: $('s-whisper-cpp-url').value, whisper_cpp_url: $('s-whisper-cpp-url').value,
groq_api_key: $('s-groq-api-key').value, groq_api_key: $('s-groq-api-key').value,

View File

@ -16,12 +16,13 @@ function sttTtsSelectedSttBackend() {
} }
function sttBackendOptionHtml(selected = 'configured') { function sttBackendOptionHtml(selected = 'configured') {
if (!_sttBackends.length) return '<option value="configured">Configured Whisper/STT</option>'; const choices = sttBackendChoices(_sttBackends, selected);
const preferred = _sttBackends.some(b => b.id === selected && b.available) ? selected : (_sttBackends.find(b => b.available)?.id || selected); if (!choices.length) return '<option value="configured">Configured Whisper/STT</option>';
return _sttBackends.map(b => { const preferred = choices.some(b => b.id === selected && b.available) ? selected : (choices.find(b => b.available)?.id || selected);
return choices.map(b => {
const suffix = b.available ? '' : ' (unavailable)'; const suffix = b.available ? '' : ' (unavailable)';
const disabled = b.available ? '' : ' disabled'; const disabled = b.available ? '' : ' disabled';
return `<option value="${escHtml(b.id)}" ${b.id === preferred ? 'selected' : ''}${disabled}>${escHtml(b.label + suffix)}</option>`; return `<option value="${escHtml(b.id)}" ${b.id === preferred ? 'selected' : ''}${disabled}>${escHtml((b.title || b.label || b.id) + suffix)}</option>`;
}).join(''); }).join('');
} }
@ -37,14 +38,14 @@ function updateSttBackendHelp() {
async function refreshSttBackends(selected = '') { async function refreshSttBackends(selected = '') {
try { try {
const d = await fetch('/api/stt-backends').then(r => r.json()); const d = await fetch('/api/stt-backends').then(r => r.json());
_sttBackends = (d.backends || []).filter(b => b && b.id); _sttBackends = (d.backends || []).filter(b => b && b.id).map(b => b.connected === false ? { ...b, available: false } : b);
} catch (_) { } catch (_) {
_sttBackends = []; _sttBackends = [];
} }
['stt-tts-stt-backend', 'clone-stt-backend'].forEach(id => { ['conv-stt-select', 'stt-tts-stt-backend', 'clone-stt-backend'].forEach(id => {
const sel = $(id); const sel = $(id);
if (!sel) return; if (!sel) return;
const prev = selected || sel.value || 'configured'; const prev = selected || (typeof _appSettings !== 'undefined' && _appSettings?.stt_preferred_backend) || sel.value || 'configured';
sel.innerHTML = sttBackendOptionHtml(prev); sel.innerHTML = sttBackendOptionHtml(prev);
sel.disabled = !_sttBackends.some(b => b.available); sel.disabled = !_sttBackends.some(b => b.available);
}); });

View File

@ -115,7 +115,7 @@ function statusActiveTts() {
try { try {
all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : []; all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : [];
} catch (_) {} } catch (_) {}
const labels = all.map(b => b.label || b.id); const labels = all.map(b => b.title || b.label || b.id);
return { return {
kind: 'tts', kind: 'tts',
label: 'TTS', label: 'TTS',
@ -125,21 +125,33 @@ function statusActiveTts() {
}; };
} }
function sttBackendChoices(backends, selected = '') {
const all = backends.filter(b => b && b.id).map(b => b.connected === false ? { ...b, available: false } : b);
const winners = new Map();
for (const b of all) {
if (!b.available || !b.engine_id) continue;
const previous = winners.get(b.engine_id);
if (!previous || b.id === selected || (previous.id !== selected && b.id === `custom:${b.engine_id}`)) winners.set(b.engine_id, b);
}
return all.filter(b => !b.available || !b.engine_id || winners.get(b.engine_id) === b);
}
function statusActiveStt() { function statusActiveStt() {
let all = []; let all = [];
try { try {
all = Array.isArray(_sttBackends) ? _sttBackends : []; all = Array.isArray(_sttBackends) ? _sttBackends : [];
} catch (_) {} } catch (_) {}
const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value const preferred = (typeof _appSettings !== 'undefined' && _appSettings?.stt_preferred_backend)
|| (typeof _appSettings !== 'undefined' && _appSettings?.stt_preferred_backend) || ''; || $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value || '';
all = sttBackendChoices(all, preferred);
const available = all.filter(b => b.available); const available = all.filter(b => b.available);
const chosen = (preferred && all.find(b => b.id === preferred)) || available[0] || all[0] || null; const chosen = (preferred && all.find(b => b.id === preferred)) || available[0] || all[0] || null;
return { return {
kind: 'stt', kind: 'stt',
label: 'STT', label: 'STT',
ok: !!(chosen && chosen.available), ok: !!(chosen && chosen.available),
value: chosen ? (chosen.label || chosen.model || chosen.id) : 'No STT backend', value: chosen ? (chosen.title || chosen.label || chosen.id) : 'No STT backend',
title: chosen ? `${chosen.label || chosen.id} · ${chosen.url || ''}` : 'No STT backend configured', title: chosen ? `${chosen.title || chosen.label || chosen.id} · ${chosen.url || ''}` : 'No STT backend configured',
}; };
} }
@ -149,6 +161,9 @@ function statusActiveStt() {
function statusLlmTarget() { function statusLlmTarget() {
let settings = {}; let settings = {};
try { settings = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings : {}; } catch (_) {} try { settings = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings : {}; } catch (_) {}
if (settings.engine_disabled?.[settings.engine_active?.llm_url] === true) {
return { url: '', model: '', apiKey: '', disconnected: true };
}
return { return {
url: statusCleanUrl(settings.llm_url || $('reh-llm-url')?.value || $('conv-llm-url')?.value || ''), url: statusCleanUrl(settings.llm_url || $('reh-llm-url')?.value || $('conv-llm-url')?.value || ''),
model: settings.llm_model || $('reh-llm-model')?.value || $('conv-llm-model-select')?.value || '', model: settings.llm_model || $('reh-llm-model')?.value || $('conv-llm-model-select')?.value || '',
@ -157,14 +172,14 @@ function statusLlmTarget() {
} }
function statusActiveLlm() { function statusActiveLlm() {
const { url, model } = statusLlmTarget(); const { url, model, disconnected } = statusLlmTarget();
const same = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model; const same = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model;
const ok = !!url && same && STATUS_LLM_CACHE.ok; const ok = !!url && same && STATUS_LLM_CACHE.ok;
return { return {
kind: 'llm', kind: 'llm',
label: 'LLM', label: 'LLM',
ok, ok,
value: model || statusShortUrl(url) || 'No LLM configured', value: disconnected ? 'Disconnected' : model || statusShortUrl(url) || 'No LLM configured',
title: url ? `${model || 'default model'} · ${url}` : 'No active LLM endpoint configured', title: url ? `${model || 'default model'} · ${url}` : 'No active LLM endpoint configured',
}; };
} }
@ -247,8 +262,8 @@ function _statusApplyToSelects(ids, value) {
} }
async function _statusOpenLlmFlyup(anchor) { async function _statusOpenLlmFlyup(anchor) {
const { url, model } = statusLlmTarget(); const { url, model, disconnected } = statusLlmTarget();
if (!url) { _statusFlyupList(anchor, [], null, 'No LLM endpoint configured', 'llm'); return; } if (!url) { _statusFlyupList(anchor, [], null, disconnected ? 'LLM disconnected — use Connect to restore' : 'No LLM endpoint configured', 'llm'); return; }
_statusFlyupList(anchor, [], null, 'Loading models…', 'llm'); _statusFlyupList(anchor, [], null, 'Loading models…', 'llm');
let models = []; let models = [];
try { try {
@ -257,10 +272,13 @@ async function _statusOpenLlmFlyup(anchor) {
models = Array.isArray(data.models) ? data.models : []; models = Array.isArray(data.models) ? data.models : [];
} catch (_) { logErr('status-llm-models', _); } } catch (_) { logErr('status-llm-models', _); }
if (!_statusFlyup || _statusFlyup.dataset.forKind !== 'llm') return; // closed/switched while awaiting if (!_statusFlyup || _statusFlyup.dataset.forKind !== 'llm') return; // closed/switched while awaiting
if (statusLlmTarget().disconnected) {
_statusFlyupList(anchor, [], null, 'LLM disconnected — use Connect to restore', 'llm'); return;
}
_statusFlyupList(anchor, models.map(m => ({ id: m, label: m, active: m === model })), (picked) => { _statusFlyupList(anchor, models.map(m => ({ id: m, label: m, active: m === model })), (picked) => {
if (typeof _appSettings !== 'undefined') _appSettings.llm_model = picked; if (typeof _appSettings !== 'undefined') _appSettings.llm_model = picked;
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ llm_model: picked }) }).catch(() => {}); fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ llm_model: picked }) }).catch(() => {});
_statusApplyToSelects(['reh-llm-model', 'conv-llm-model-select'], picked); _statusApplyToSelects(['llm-active-model', 'reh-llm-model', 'conv-llm-model-select'], picked);
refreshStatusBarEngines({ force: true }); refreshStatusBarEngines({ force: true });
toast('LLM model set to ' + picked, 'success'); toast('LLM model set to ' + picked, 'success');
}, 'No models found at this endpoint', 'llm'); }, 'No models found at this endpoint', 'llm');
@ -268,10 +286,10 @@ async function _statusOpenLlmFlyup(anchor) {
function _statusOpenSttFlyup(anchor) { function _statusOpenSttFlyup(anchor) {
const all = Array.isArray(_sttBackends) ? _sttBackends : []; const all = Array.isArray(_sttBackends) ? _sttBackends : [];
const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value const preferred = (typeof _appSettings !== 'undefined' && _appSettings?.stt_preferred_backend)
|| (typeof _appSettings !== 'undefined' && _appSettings?.stt_preferred_backend) || ''; || $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value || '';
_statusFlyupList(anchor, all.filter(b => b.available).map(b => ({ _statusFlyupList(anchor, sttBackendChoices(all, preferred).filter(b => b.available).map(b => ({
id: b.id, label: b.label || b.id, active: b.id === preferred, id: b.id, label: b.title || b.label || b.id, active: b.id === preferred,
})), (picked) => { })), (picked) => {
// Persisting this (not just pushing it into a few unrelated <select>s on // Persisting this (not just pushing it into a few unrelated <select>s on
// other pages) is what makes the pick actually take effect anywhere that // other pages) is what makes the pick actually take effect anywhere that
@ -282,6 +300,7 @@ function _statusOpenSttFlyup(anchor) {
if (typeof _appSettings !== 'undefined') _appSettings.stt_preferred_backend = picked; if (typeof _appSettings !== 'undefined') _appSettings.stt_preferred_backend = picked;
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stt_preferred_backend: picked }) }).catch(() => {}); fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stt_preferred_backend: picked }) }).catch(() => {});
_statusApplyToSelects(['conv-stt-select', 'stt-tts-stt-backend', 'clone-stt-backend'], picked); _statusApplyToSelects(['conv-stt-select', 'stt-tts-stt-backend', 'clone-stt-backend'], picked);
window.syncPreferredSttBackend?.(_appSettings);
updateStatusBar(); updateStatusBar();
toast('STT backend set to ' + picked, 'success'); toast('STT backend set to ' + picked, 'success');
}, 'No reachable STT backend', 'stt'); }, 'No reachable STT backend', 'stt');
@ -289,13 +308,14 @@ function _statusOpenSttFlyup(anchor) {
function _statusOpenTtsFlyup(anchor) { function _statusOpenTtsFlyup(anchor) {
const all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : []; const all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : [];
const preferred = $('tts-backend-select')?.value || $('reader-backend-select')?.value || $('reh-backend-select')?.value || ''; const preferred = $('library-tts-backend-select')?.value || $('tts-backend-select')?.value || $('reader-backend-select')?.value || $('reh-backend-select')?.value || '';
_statusFlyupList(anchor, all.map(b => ({ _statusFlyupList(anchor, all.map(b => ({
id: b.id, label: b.label || b.id, active: b.id === preferred, id: b.id, label: b.title || b.label || b.id, active: b.id === preferred,
})), (picked) => { })), (picked) => {
_statusApplyToSelects(['tts-backend-select', 'reader-backend-select', 'reh-backend-select'], picked); _statusApplyToSelects(['tts-backend-select', 'library-tts-backend-select', 'stt-tts-backend-select', 'reader-backend-select', 'reh-backend-select'], picked);
updateStatusBar(); updateStatusBar();
toast('TTS backend set to ' + picked, 'success'); const chosen = all.find(b => b.id === picked);
toast('TTS backend set to ' + ((chosen && (chosen.title || chosen.label)) || picked), 'success');
}, 'No reachable TTS backend', 'tts'); }, 'No reachable TTS backend', 'tts');
} }
@ -372,18 +392,49 @@ async function runPool(items, worker, concurrency, onProgress) {
await Promise.all(Array.from({ length: Math.min(effectiveConcurrency, arr.length) }, run)); await Promise.all(Array.from({ length: Math.min(effectiveConcurrency, arr.length) }, run));
} }
window.runPool = runPool; window.runPool = runPool;
// Resolves true only when the async Clipboard API confirmed the write. The legacy
// execCommand fallback is still attempted but cannot be verified (it reports
// success in embedded webviews that discard the copy), so it resolves false and
// callers show the text via copyTextDialog().
async function copyText(text) { async function copyText(text) {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
return true;
} catch(e) { } catch(e) {
const ta = document.createElement('textarea'); const ta = document.createElement('textarea');
ta.value = text; ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(ta); document.body.appendChild(ta);
ta.select(); ta.select();
document.execCommand('copy'); let ok = false;
try { document.execCommand('copy'); } catch (_) {}
ta.remove(); ta.remove();
return false;
} }
} }
// Last-resort manual copy: the text in a pre-selected box the user can Ctrl+C.
function copyTextDialog(text, title = 'Copy to clipboard') {
const ov = document.createElement('div');
ov.className = 'audiobook-overlay';
const uid = 'ct-' + Math.random().toString(36).slice(2, 9);
ov.innerHTML = `<div class="audiobook-box" role="dialog" aria-modal="true" aria-labelledby="${uid}-t">
<div class="audiobook-title" id="${uid}-t"><span class="mdi mdi-content-copy" aria-hidden="true"></span> ${escHtml(title)}</div>
<div class="audiobook-msg">The browser blocked automatic copying. Press Ctrl+C (Cmd+C) to copy:</div>
<textarea readonly rows="5" style="width:100%" aria-label="Text to copy"></textarea>
<div class="audiobook-actions"><button type="button" class="btn-primary btn-sm">Close</button></div>
</div>`;
const ta = ov.querySelector('textarea');
ta.value = text;
const close = () => { ov.remove(); document.removeEventListener('keydown', onKey); };
const onKey = e => { if (e.key === 'Escape') close(); };
document.addEventListener('keydown', onKey);
ov.querySelector('button').addEventListener('click', close);
ov.addEventListener('click', e => { if (e.target === ov) close(); });
document.body.appendChild(ov);
ta.focus();
ta.select();
}
function fmtDuration(sec) { function fmtDuration(sec) {
if (sec == null || Number.isNaN(Number(sec))) return '-'; if (sec == null || Number.isNaN(Number(sec))) return '-';
const total = Math.max(0, Math.round(Number(sec))); const total = Math.max(0, Math.round(Number(sec)));
@ -1432,8 +1483,8 @@ else initLangSelects(document);
})(); })();
Object.assign(root, { Object.assign(root, {
$, avatarColor, toast, confirmDialog, status, ensureStatusBar, updateStatusBar, $, avatarColor, toast, confirmDialog, status, ensureStatusBar, updateStatusBar, sttBackendChoices,
escHtml, debounce, logErr, ttvcIsLowMemoryDevice, fmtDuration, loadingMarkup, escHtml, copyText, copyTextDialog, debounce, logErr, ttvcIsLowMemoryDevice, fmtDuration, loadingMarkup,
setBusyButton, enhanceAccessibility, applyTheme, cc2flag, ccDisplay, setBusyButton, enhanceAccessibility, applyTheme, cc2flag, ccDisplay,
setPreviewLang, openPreviewLangPicker, createSearchablePicker, getStoredTags, setPreviewLang, openPreviewLangPicker, createSearchablePicker, getStoredTags,
addStoredTag, scheduleVoiceLibraryLoad, disabledBackendTabMessage, switchTab, addStoredTag, scheduleVoiceLibraryLoad, disabledBackendTabMessage, switchTab,

View File

@ -164,10 +164,13 @@ function toggleSortDir() {
} }
function syncSortHeaders() { function syncSortHeaders() {
document.querySelectorAll('.vl-header [data-sort], .vl-table-header [data-sort]').forEach(el => { document.querySelectorAll('.vl-header [data-sort], .vl-table-header [data-sort], .vl-compact-header [data-sort]').forEach(el => {
el.classList.remove('sort-asc', 'sort-desc'); el.classList.remove('sort-asc', 'sort-desc');
if (el.dataset.sort === _sortField) el.removeAttribute('aria-sort');
if (el.dataset.sort === _sortField) {
el.classList.add(_sortDir === 1 ? 'sort-asc' : 'sort-desc'); el.classList.add(_sortDir === 1 ? 'sort-asc' : 'sort-desc');
el.setAttribute('aria-sort', _sortDir === 1 ? 'ascending' : 'descending');
}
}); });
const sel = document.getElementById('voice-sort-field'); const sel = document.getElementById('voice-sort-field');
if (sel && sel.value !== _sortField) sel.value = _sortField; if (sel && sel.value !== _sortField) sel.value = _sortField;
@ -1119,11 +1122,16 @@ $('benchmark-confirm-start')?.addEventListener('click', async () => {
$('copy-active-voices-btn').addEventListener('click', async () => { $('copy-active-voices-btn').addEventListener('click', async () => {
const useSelected = _bulkSelected && _bulkSelected.size > 0; const useSelected = _bulkSelected && _bulkSelected.size > 0;
const ids = useSelected ? [..._bulkSelected] : activeVoiceIds(); const ids = useSelected ? [..._bulkSelected] : activeVoiceIds();
const label = useSelected ? `${ids.length} selected` : `${ids.length} active`;
if (!ids.length) { toast('No voices to copy', 'error'); return; } if (!ids.length) { toast('No voices to copy', 'error'); return; }
await copyText(ids.join(', ')); const text = ids.join(', ');
toast('Copied ' + label + ' voices', 'success'); if (await copyText(text)) {
status('Copied ' + label + ' voices to clipboard'); const msg = `${ids.length} voice name${ids.length === 1 ? '' : 's'} copied to clipboard`;
toast(msg, 'success');
status(msg);
} else {
// Async clipboard unavailable (plain-HTTP LAN origin, embedded webview): show the list to copy by hand.
copyTextDialog(text, `${ids.length} voice name${ids.length === 1 ? '' : 's'}`);
}
}); });
// Round-trip verify: synthesize each voice's own reference line (or a // Round-trip verify: synthesize each voice's own reference line (or a
@ -3456,15 +3464,22 @@ function renderVoiceGroupsBar() {
if (!names.length) { bar.hidden = true; bar.innerHTML = ''; return; } if (!names.length) { bar.hidden = true; bar.innerHTML = ''; return; }
bar.hidden = false; bar.hidden = false;
const active = window._voiceGroupFilter || ''; const active = window._voiceGroupFilter || '';
// Collapsed by default (accordion): the chip list is long and duplicates the
// "All groups" dropdown; the summary line shows the count and the active group.
bar.innerHTML = bar.innerHTML =
`<span class="vg-label"><span class="mdi mdi-folder-multiple-outline"></span> Groups</span>` + `<details class="vg-acc"${window._vgOpen ? ' open' : ''}>
<summary class="vg-label"><span class="mdi mdi-folder-multiple-outline" aria-hidden="true"></span> Groups
<span class="vg-count">${names.length}</span>
${active ? `<span class="vg-active">${escHtml(active)}</span><button type="button" class="vg-clear" title="Clear group filter">Show all</button>` : ''}
</summary>
<div class="vg-chips">` +
names.map(g => names.map(g =>
`<span class="vg-chip${g===active?' active':''}" data-group="${escHtml(g)}"> `<span class="vg-chip${g===active?' active':''}" data-group="${escHtml(g)}">
${escHtml(g)} <span class="vg-count">${groups[g]}</span> ${escHtml(g)} <span class="vg-count">${groups[g]}</span>
<button class="vg-del" data-group="${escHtml(g)}" title="Move all ${groups[g]} voices in this group to Voice Trash">&times;</button> <button class="vg-del" data-group="${escHtml(g)}" title="Move all ${groups[g]} voices in this group to Voice Trash">&times;</button>
</span>` </span>`
).join('') + ).join('') + `</div></details>`;
(active ? `<button class="vg-clear" title="Clear group filter">Show all</button>` : ''); bar.querySelector('details').addEventListener('toggle', e => { window._vgOpen = e.target.open; });
bar.querySelectorAll('.vg-chip').forEach(chip => { bar.querySelectorAll('.vg-chip').forEach(chip => {
chip.addEventListener('click', e => { chip.addEventListener('click', e => {
@ -3478,7 +3493,8 @@ function renderVoiceGroupsBar() {
bar.querySelectorAll('.vg-del').forEach(btn => { bar.querySelectorAll('.vg-del').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); deleteVoiceGroup(btn.dataset.group); }); btn.addEventListener('click', e => { e.stopPropagation(); deleteVoiceGroup(btn.dataset.group); });
}); });
bar.querySelector('.vg-clear')?.addEventListener('click', () => { bar.querySelector('.vg-clear')?.addEventListener('click', e => {
e.preventDefault(); e.stopPropagation();
window._voiceGroupFilter = ''; window._voiceGroupFilter = '';
const groupSel = $('library-filter-group'); if (groupSel) groupSel.value = ''; const groupSel = $('library-filter-group'); if (groupSel) groupSel.value = '';
renderVoiceList(); renderVoiceList();
@ -3528,18 +3544,25 @@ function _bulkUpdate() {
const count = $('vl-bulk-count'); const count = $('vl-bulk-count');
const n = _bulkSelected.size; const n = _bulkSelected.size;
if (bar) bar.hidden = n === 0; if (bar) bar.hidden = n === 0;
const ab = $('vl-actions-btn');
if (ab) {
ab.disabled = n === 0;
ab.title = n === 0 ? 'Check one or more voices to enable actions' : `Actions on ${n} checked voice${n === 1 ? '' : 's'}`;
const cnt = $('vl-actions-n'); if (cnt) cnt.textContent = n ? `(${n})` : '';
if (n === 0) _setActionsMenu(false);
}
if (count) count.textContent = `${n} selected`; if (count) count.textContent = `${n} selected`;
// Sync checkboxes in the DOM to match the set // Sync checkboxes in the DOM to match the set
document.querySelectorAll('.vl-bulk-cb').forEach(cb => { document.querySelectorAll('.vl-bulk-cb').forEach(cb => {
cb.checked = _bulkSelected.has(cb.dataset.id); cb.checked = _bulkSelected.has(cb.dataset.id);
}); });
const headerCb = document.getElementById('vl-select-all-header'); const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id);
if (headerCb) { const nSel = rows.filter(r => _bulkSelected.has(r.dataset.id)).length;
const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id); document.querySelectorAll('.vl-select-all-cb').forEach(cb => {
const allSelected = rows.length > 0 && rows.every(r => _bulkSelected.has(r.dataset.id)); cb.checked = rows.length > 0 && nSel === rows.length;
headerCb.checked = allSelected; cb.indeterminate = nSel > 0 && nSel < rows.length;
} });
} }
function _bulkToggle(id, checked) { function _bulkToggle(id, checked) {
@ -3552,8 +3575,69 @@ const _origRenderVoiceList = renderVoiceList;
renderVoiceList = function() { renderVoiceList = function() {
_origRenderVoiceList.apply(this, arguments); _origRenderVoiceList.apply(this, arguments);
_bulkInjectCheckboxes(); _bulkInjectCheckboxes();
_bulkUpdate();
_updateFilterBadge();
}; };
// Actions menu (bottom bar): one button instead of a dozen; closes on pick, outside click or Esc.
function _setActionsMenu(open) {
const m = $('vl-actions-menu'), b = $('vl-actions-btn');
if (!m || !b) return;
m.hidden = !open;
b.setAttribute('aria-expanded', String(open));
}
$('vl-actions-btn')?.addEventListener('click', () => _setActionsMenu($('vl-actions-menu').hidden));
$('vl-actions-menu')?.addEventListener('click', e => { if (e.target.closest('button')) _setActionsMenu(false); });
document.addEventListener('click', e => { if (!e.target.closest('.vl-actions')) _setActionsMenu(false); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !$('vl-actions-menu')?.hidden) { _setActionsMenu(false); $('vl-actions-btn')?.focus(); }
});
// Preview-text / synth-source drawer: an icon in the top bar instead of a permanent card.
function _synthModeLabel() {
return document.querySelector('#vl-synth-mode-seg .vl-synth-seg-btn.active')?.textContent.trim() || 'Preview text';
}
function _refreshSynthToggle() {
const t = $('vl-synth-toggle');
if (t) t.title = `Synth uses: ${_synthModeLabel()} — click to ${$('vl-synth-drawer')?.hidden ? 'edit' : 'hide'}`;
}
function _setSynthDrawer(open) {
const d = $('vl-synth-drawer'), t = $('vl-synth-toggle');
if (!d || !t) return;
d.hidden = !open;
t.setAttribute('aria-expanded', String(open));
t.classList.toggle('active', open);
_refreshSynthToggle();
try { localStorage.setItem('vl-synth-open', open ? '1' : '0'); } catch (_) {}
}
$('vl-synth-toggle')?.addEventListener('click', () => _setSynthDrawer($('vl-synth-drawer').hidden));
document.addEventListener('click', e => { if (e.target.closest('#vl-synth-mode-seg')) setTimeout(_refreshSynthToggle, 0); });
try { _setSynthDrawer(localStorage.getItem('vl-synth-open') === '1'); } catch (_) {}
// Filter panel: collapsed by default; the badge counts the filters that are narrowing the list.
function _updateFilterBadge() {
const badge = $('vl-filter-badge');
if (!badge) return;
const val = id => ($(id)?.value || '').trim();
const n = ['library-filter-lang', 'library-filter-sex', 'library-filter-type', 'library-filter-tag',
'library-filter-rating', 'library-filter-group'].filter(id => val(id)).length
+ ($('show-disabled-cb')?.checked ? 1 : 0) + (val('library-filter-text') ? 1 : 0);
badge.textContent = String(n);
badge.hidden = n === 0;
const t = $('vl-filter-toggle');
if (t) t.title = n ? `Filters (${n} active)` : 'Show filters and sorting';
}
function _setFilterPanel(open) {
const panel = $('vl-filter-panel'), t = $('vl-filter-toggle');
if (!panel || !t) return;
panel.hidden = !open;
t.setAttribute('aria-expanded', String(open));
t.classList.toggle('active', open);
try { localStorage.setItem('vl-filter-open', open ? '1' : '0'); } catch (_) {}
}
$('vl-filter-toggle')?.addEventListener('click', () => _setFilterPanel($('vl-filter-panel').hidden));
try { _setFilterPanel(localStorage.getItem('vl-filter-open') === '1'); } catch (_) {}
// Shift-click range select: remembers the last checkbox actually clicked // Shift-click range select: remembers the last checkbox actually clicked
// (not just toggled programmatically) so a shift-click on a later row can // (not just toggled programmatically) so a shift-click on a later row can
// fill in everything between them — the same convention as a file manager // fill in everything between them — the same convention as a file manager
@ -3598,17 +3682,11 @@ function _bulkInjectCheckboxes() {
// ── Toolbar button wiring ─────────────────────────────────────────────────── // ── Toolbar button wiring ───────────────────────────────────────────────────
$('vl-bulk-select-all')?.addEventListener('click', () => {
document.querySelectorAll('#voice-list .vl-row').forEach(row => {
if (row.dataset.id) _bulkSelected.add(row.dataset.id);
});
_bulkUpdate();
});
// Always-visible Select-all toggle in the table header (the bulk bar only appears once // Always-visible Select-all toggle in the table header (the bulk bar only appears once
// something is selected, so this is the entry point). Selects every visible row, or // something is selected, so this is the entry point). Selects every visible row, or
// clears the selection if they're all already selected. // clears the selection if they're all already selected.
$('vl-select-all-header')?.addEventListener('change', (e) => { document.addEventListener('change', (e) => {
if (!e.target.classList.contains('vl-select-all-cb')) return;
const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id); const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id);
if (e.target.checked) { if (e.target.checked) {
rows.forEach(r => _bulkSelected.add(r.dataset.id)); rows.forEach(r => _bulkSelected.add(r.dataset.id));
@ -3618,11 +3696,6 @@ $('vl-select-all-header')?.addEventListener('change', (e) => {
_bulkUpdate(); _bulkUpdate();
}); });
$('vl-bulk-deselect')?.addEventListener('click', () => {
_bulkSelected.clear();
_bulkUpdate();
});
$('vl-bulk-hide')?.addEventListener('click', async () => { $('vl-bulk-hide')?.addEventListener('click', async () => {
if (!_bulkSelected.size) return; if (!_bulkSelected.size) return;
const ids = [..._bulkSelected]; const ids = [..._bulkSelected];

View File

@ -431,6 +431,15 @@
<label>Active STT URL</label> <label>Active STT URL</label>
<input type="text" id="s-whisper-url" placeholder="http://host.docker.internal:8010"> <input type="text" id="s-whisper-url" placeholder="http://host.docker.internal:8010">
<span class="s-hint">Default recognition endpoint. Expected: <code>POST /v1/audio/transcriptions</code>.</span> <span class="s-hint">Default recognition endpoint. Expected: <code>POST /v1/audio/transcriptions</code>.</span>
<label for="s-stt-api-key">API key</label>
<input type="password" id="s-stt-api-key" autocomplete="off" placeholder="API key (optional)">
<label for="s-stt-model">Model</label>
<select id="s-stt-model"><option value="">— load models —</option></select>
<div class="btn-row settings-mini-actions">
<button class="btn-secondary" id="s-stt-test-connection" type="button">Test connection</button>
<button class="btn-secondary" id="s-stt-load-models" type="button">Load models</button>
<span id="s-stt-connection-status" role="status"></span>
</div>
<div class="btn-row settings-mini-actions"> <div class="btn-row settings-mini-actions">
<button class="btn-secondary" id="s-use-parakeet-asr" type="button">Use Parakeet</button> <button class="btn-secondary" id="s-use-parakeet-asr" type="button">Use Parakeet</button>
<button class="btn-secondary" id="s-use-nvidia-router" type="button">Use router</button> <button class="btn-secondary" id="s-use-nvidia-router" type="button">Use router</button>
@ -844,7 +853,7 @@
</div><!-- /ocr --> </div><!-- /ocr -->
<!-- ── Add Custom Engine dialog ─────────────────────────────── --> <!-- ── Add Custom Engine dialog ─────────────────────────────── -->
<dialog id="add-engine-dlg" class="add-engine-dlg"> <dialog id="add-engine-dlg" class="add-engine-dlg" style="max-height:90dvh;overflow-y:auto">
<div class="add-engine-form"> <div class="add-engine-form">
<h3 class="add-engine-title"><span class="mdi mdi-plus-circle-outline"></span> Add Custom Engine</h3> <h3 class="add-engine-title"><span class="mdi mdi-plus-circle-outline"></span> Add Custom Engine</h3>
<div class="add-engine-field"> <div class="add-engine-field">
@ -864,6 +873,28 @@
<label for="aed-url">URL</label> <label for="aed-url">URL</label>
<input id="aed-url" type="text" placeholder="http://192.168.1.100:8080" spellcheck="false"> <input id="aed-url" type="text" placeholder="http://192.168.1.100:8080" spellcheck="false">
</div> </div>
<div class="add-engine-field">
<label for="aed-api-key">API key <span class="add-engine-opt">(optional)</span></label>
<input id="aed-api-key" type="password" autocomplete="off" placeholder="API key (optional)">
</div>
<div class="add-engine-field">
<label for="aed-model">Model <span class="add-engine-opt">(optional — select or enter an ID)</span></label>
<input id="aed-model" type="text" list="aed-models" placeholder="Model ID" spellcheck="false">
<datalist id="aed-models"></datalist>
<div class="btn-row settings-mini-actions">
<button id="aed-test" type="button" class="btn-secondary">Test connection</button>
<button id="aed-load-models" type="button" class="btn-secondary">Load models</button>
</div>
<span id="aed-status" role="status"></span>
</div>
<div class="add-engine-field">
<label for="aed-params">Additional parameters (JSON)</label>
<textarea id="aed-params" rows="3" spellcheck="false" placeholder='{"temperature":0.7}'></textarea>
<span class="add-engine-opt">Optional request parameters. Keep credentials in the API key field.</span>
</div>
<div class="add-engine-field">
<label><input id="aed-active" type="checkbox"> Use connection actively after saving</label>
</div>
<div class="add-engine-field"> <div class="add-engine-field">
<label for="aed-container">Docker container name <span class="add-engine-opt">(optional — enables Stop / Start / Restart)</span></label> <label for="aed-container">Docker container name <span class="add-engine-opt">(optional — enables Stop / Start / Restart)</span></label>
<input id="aed-container" type="text" placeholder="my-tts-container" spellcheck="false"> <input id="aed-container" type="text" placeholder="my-tts-container" spellcheck="false">

View File

@ -17,9 +17,19 @@
<!-- LEFT: compact voice list — two cards --> <!-- LEFT: compact voice list — two cards -->
<div class="voices-list-pane"> <div class="voices-list-pane">
<!-- Card 1: TTS synth mode + preview sentence --> <!-- Card 2: filters + sort + voice list + toolbar -->
<div class="vl-card vl-card-synth"> <div class="vl-card vl-card-list">
<div class="vl-synth-panel"> <!-- Top bar: search + filter toggle + view buttons. Filters/sort live in a collapsible panel. -->
<div class="vl-topbar">
<input type="search" id="library-filter-text" placeholder="Search voices&#x2026;" aria-label="Search voices" autocomplete="off" class="vl-search vl-search-main">
<button id="vl-synth-toggle" class="vl-sort-dir-btn" type="button" aria-expanded="false" aria-controls="vl-synth-drawer" title="Preview text &amp; synth source" aria-label="Preview text and synth source"><span class="mdi mdi-account-voice" aria-hidden="true"></span></button>
<button id="vl-filter-toggle" class="vl-sort-dir-btn vl-filter-toggle" type="button" aria-expanded="false" aria-controls="vl-filter-panel" title="Show filters and sorting"><span class="mdi mdi-filter-variant" aria-hidden="true"></span><span class="vl-filter-badge" id="vl-filter-badge" hidden>0</span><span class="sr-only"> Filters</span></button>
<button id="voice-group-tag-btn" class="vl-sort-dir-btn" type="button" title="Group into virtual folders by tag" aria-label="Group by tag"><span class="mdi mdi-folder-outline" aria-hidden="true"></span></button>
<button id="voice-table-view-btn" class="vl-sort-dir-btn" type="button" title="Toggle full-width table view" aria-label="Table view"><span class="mdi mdi-table" aria-hidden="true"></span></button>
<button id="voice-table-edit-btn" class="vl-sort-dir-btn" type="button" title="Toggle table edit mode" aria-label="Table edit mode" style="display:none"><span class="mdi mdi-pencil-box-outline" aria-hidden="true"></span></button>
</div>
<div class="vl-synth-drawer" id="vl-synth-drawer" hidden>
<div class="vl-synth-panel">
<div class="vl-synth-mode-row"> <div class="vl-synth-mode-row">
<span class="vl-synth-icon"><span class="mdi mdi-play"></span><span class="mdi mdi-play"></span></span> <span class="vl-synth-icon"><span class="mdi mdi-play"></span><span class="mdi mdi-play"></span></span>
<span class="vl-synth-label">Synth uses</span> <span class="vl-synth-label">Synth uses</span>
@ -44,17 +54,15 @@
></textarea> ></textarea>
</div> </div>
</div> </div>
</div>
</div> </div>
</div><!-- /vl-card-synth --> <div class="vl-filter-panel" id="vl-filter-panel" role="group" aria-label="Filter and sort voices" hidden>
<div class="vl-filter-grid">
<!-- Card 2: filters + sort + voice list + toolbar --> <label>Language <select id="library-filter-lang"><option value="">All</option></select></label>
<div class="vl-card vl-card-list"> <label>Gender <select id="library-filter-sex"><option value="">All</option></select></label>
<div class="vl-filters"> <label>Type <select id="library-filter-type"><option value="">All types</option></select></label>
<select id="library-filter-lang" title="Language"><option value="">All</option></select> <label>Tag <select id="library-filter-tag"><option value="">All tags</option></select></label>
<select id="library-filter-sex" title="Gender"><option value="">All</option></select> <label>Rating <select id="library-filter-rating" >
<select id="library-filter-type" title="Type"><option value="">All types</option></select>
<select id="library-filter-tag" title="Tag"><option value="">All tags</option></select>
<select id="library-filter-rating" title="Rating">
<option value="">All ratings</option> <option value="">All ratings</option>
<option value="5">★★★★★</option> <option value="5">★★★★★</option>
<option value="4">★★★★+</option> <option value="4">★★★★+</option>
@ -62,15 +70,9 @@
<option value="2">★★+</option> <option value="2">★★+</option>
<option value="1">★+</option> <option value="1">★+</option>
<option value="0">Unrated</option> <option value="0">Unrated</option>
</select> </select></label>
<select id="library-filter-group" title="Group"><option value="">All groups</option></select> <label>Group <select id="library-filter-group"><option value="">All groups</option></select></label>
<button id="library-clear-filters" class="vl-sort-dir-btn" title="Clear all filters"><span class="mdi mdi-filter-off-outline"></span></button> <label>Sort by <select id="voice-sort-field" title="Sort field">
<label class="vl-disabled-label"><input type="checkbox" id="show-disabled-cb"> Disabled</label>
</div>
<div class="vl-sort-bar">
<input type="search" id="library-filter-text" placeholder="&#x1F50D; Search voices&#x2026;" autocomplete="off" class="vl-search vl-search-main">
<span class="vl-sort-label">Sort</span>
<select id="voice-sort-field" title="Sort field">
<option value="id">Name</option> <option value="id">Name</option>
<option value="flag">Language</option> <option value="flag">Language</option>
<option value="gender">Gender</option> <option value="gender">Gender</option>
@ -83,11 +85,14 @@
<option value="note">Note</option> <option value="note">Note</option>
<option value="tag">Tags</option> <option value="tag">Tags</option>
<option value="enabled">Active</option> <option value="enabled">Active</option>
</select> </select></label>
<button id="voice-sort-dir" class="vl-sort-dir-btn" title="Toggle sort direction"><span class="mdi mdi-arrow-up"></span></button> <label>Order <button id="voice-sort-dir" class="vl-sort-dir-btn vl-sort-dir-wide" type="button" title="Toggle sort direction"><span class="mdi mdi-arrow-up" aria-hidden="true"></span></button></label>
<button id="voice-group-tag-btn" class="vl-sort-dir-btn" title="Group into virtual folders by tag"><span class="mdi mdi-folder-outline"></span></button> </div>
<button id="voice-table-view-btn" class="vl-sort-dir-btn" title="Toggle full-width table view"><span class="mdi mdi-table"></span></button> <div class="vl-filter-actions">
<button id="voice-table-edit-btn" class="vl-sort-dir-btn" title="Toggle table edit mode" style="display:none"><span class="mdi mdi-pencil-box-outline"></span></button> <label class="vl-disabled-label"><input type="checkbox" id="show-disabled-cb"> Show disabled</label>
<button id="library-clear-filters" class="btn-secondary vl-tb-btn" type="button" title="Clear all filters"><span class="mdi mdi-filter-off-outline" aria-hidden="true"></span> Clear filters</button>
</div>
<div id="voice-groups-bar" class="voice-groups-bar" hidden></div>
</div> </div>
<div class="benchmark-confirm" id="benchmark-confirm" hidden role="group" aria-live="polite"> <div class="benchmark-confirm" id="benchmark-confirm" hidden role="group" aria-live="polite">
@ -97,25 +102,14 @@
<button class="benchmark-confirm-start" id="benchmark-confirm-start" type="button">Start benchmark</button> <button class="benchmark-confirm-start" id="benchmark-confirm-start" type="button">Start benchmark</button>
</div> </div>
<div id="voice-groups-bar" class="voice-groups-bar" hidden></div>
<!-- Bulk-edit toolbar — appears when voices are selected --> <!-- Bulk-edit toolbar — appears when voices are selected -->
<div class="vl-bulk-bar" id="vl-bulk-bar" hidden> <div class="vl-bulk-bar" id="vl-bulk-bar" hidden>
<span class="vl-bulk-count" id="vl-bulk-count">0 selected</span> <span class="vl-bulk-count" id="vl-bulk-count" role="status">0 selected</span>
<button class="vl-bulk-btn" id="vl-bulk-select-all" title="Select all visible"><span class="mdi mdi-checkbox-multiple-marked-outline"></span> All</button>
<button class="vl-bulk-btn" id="vl-bulk-deselect" title="Clear selection"><span class="mdi mdi-checkbox-multiple-blank-outline"></span> None</button>
<div class="vl-bulk-sep"></div>
<button class="vl-bulk-btn" id="vl-bulk-tag" title="Add a tag to the selected voices, keeping their existing tags"><span class="mdi mdi-tag-outline"></span> Set tag</button>
<button class="vl-bulk-btn" id="vl-bulk-source" title="Set source / origin on selected voices"><span class="mdi mdi-database-outline"></span> Set source</button>
<button class="vl-bulk-btn" id="vl-bulk-hide" title="Disable (hide) selected voices"><span class="mdi mdi-eye-off-outline"></span> Hide</button>
<button class="vl-bulk-btn" id="vl-bulk-unhide" title="Enable (unhide) selected voices"><span class="mdi mdi-eye-outline"></span> Unhide</button>
<button class="vl-bulk-btn" id="vl-bulk-rating" title="Set star rating on selected"><span class="mdi mdi-star-outline"></span> Rate</button>
<div class="vl-bulk-sep"></div>
<button class="vl-bulk-btn vl-bulk-danger" id="vl-bulk-delete" title="Delete selected voices permanently"><span class="mdi mdi-delete-outline"></span> Delete</button>
</div> </div>
<div class="vl-table-header" id="vl-table-header"> <div class="vl-table-header" id="vl-table-header">
<div style="display:flex; justify-content:center; align-items:center;"><input type="checkbox" id="vl-select-all-header" title="Select all visible voices"></div> <div style="display:flex; justify-content:center; align-items:center;"><input type="checkbox" id="vl-select-all-header" class="vl-select-all-cb" title="Select all visible voices" aria-label="Select all visible voices"></div>
<div>Img</div> <div>Img</div>
<div>Play</div> <div>Play</div>
<div class="vl-th-sortable" data-sort="id">Name <span class="mdi mdi-sort"></span></div> <div class="vl-th-sortable" data-sort="id">Name <span class="mdi mdi-sort"></span></div>
@ -132,19 +126,42 @@
<div class="vl-th-sortable" data-sort="note">Note <span class="mdi mdi-sort"></span></div> <div class="vl-th-sortable" data-sort="note">Note <span class="mdi mdi-sort"></span></div>
<div class="vl-th-sortable" data-sort="enabled">Active <span class="mdi mdi-sort"></span></div> <div class="vl-th-sortable" data-sort="enabled">Active <span class="mdi mdi-sort"></span></div>
</div> </div>
<div class="vl-compact-header" id="vl-compact-header" role="group" aria-label="Sort voices">
<input type="checkbox" class="vl-select-all-cb" title="Select all / none of the visible voices" aria-label="Select all visible voices">
<button type="button" class="vl-th-sortable" data-sort="id">Name <span class="mdi mdi-sort" aria-hidden="true"></span></button>
<button type="button" class="vl-th-sortable" data-sort="flag">Lang <span class="mdi mdi-sort" aria-hidden="true"></span></button>
<button type="button" class="vl-th-sortable" data-sort="gender">Gender <span class="mdi mdi-sort" aria-hidden="true"></span></button>
<button type="button" class="vl-th-sortable" data-sort="benchmark">Speed <span class="mdi mdi-sort" aria-hidden="true"></span></button>
<button type="button" class="vl-th-sortable" data-sort="rating">Rating <span class="mdi mdi-sort" aria-hidden="true"></span></button>
</div>
<div id="voice-list" tabindex="0" role="region" aria-label="Voice library"></div> <div id="voice-list" tabindex="0" role="region" aria-label="Voice library"></div>
<div class="vl-toolbar"> <div class="vl-toolbar">
<button class="btn-secondary vl-tb-btn" id="refresh-voices-btn" title="Refresh voice list"><span class="mdi mdi-refresh"></span> Refresh</button> <button class="btn-secondary vl-tb-btn" id="refresh-voices-btn" title="Refresh voice list"><span class="mdi mdi-refresh" aria-hidden="true"></span><span class="vl-tb-label"> Refresh</span></button>
<button class="btn-secondary vl-tb-btn" id="sync-voice-folders-btn" title="Sync active/hidden folders">Sync</button> <button class="btn-secondary vl-tb-btn" id="sync-voice-folders-btn" title="Sync active/hidden folders"><span class="mdi mdi-sync" aria-hidden="true"></span><span class="vl-tb-label"> Sync</span></button>
<button class="btn-secondary vl-tb-btn vl-tb-bundle" id="export-voices-btn" title="Export the checked voices as .voice.zip bundles (backup / share)"><span class="mdi mdi-export" aria-hidden="true"></span> Export selected</button> <button class="btn-secondary vl-tb-btn" id="import-voices-btn" title="Import a .voice.zip bundle; an existing voice is never overwritten"><span class="mdi mdi-import" aria-hidden="true"></span><span class="vl-tb-label"> Import bundle</span></button>
<button class="btn-secondary vl-tb-btn vl-tb-bundle" id="import-voices-btn" title="Import a .voice.zip bundle; an existing voice is never overwritten"><span class="mdi mdi-import" aria-hidden="true"></span> Import bundle</button>
<input type="file" id="import-voices-input" accept=".zip,application/zip" multiple hidden> <input type="file" id="import-voices-input" accept=".zip,application/zip" multiple hidden>
<button class="btn-secondary vl-tb-btn" id="calculate-db-btn" title="Calculate dBFS">Calc dB</button> <div class="vl-actions">
<button class="btn-secondary vl-tb-btn" id="benchmark-voices-btn" title="Benchmark TTS speed">Benchmark</button> <button type="button" class="btn-primary vl-actions-btn" id="vl-actions-btn" aria-expanded="false" aria-controls="vl-actions-menu" disabled title="Check one or more voices to enable actions"><span class="mdi mdi-lightning-bolt-outline" aria-hidden="true"></span> Actions <span class="vl-actions-n" id="vl-actions-n"></span> <span class="mdi mdi-chevron-up" aria-hidden="true"></span></button>
<button class="btn-secondary vl-tb-btn" id="copy-active-voices-btn" title="Copy selected voices (or all active if none checked)">Copy selected</button> <div class="vl-actions-menu" id="vl-actions-menu" role="group" aria-label="Actions on the checked voices" hidden>
<button class="btn-secondary vl-tb-btn" id="precompute-embeddings-btn" title="Warm all active voices so the TTS engine pre-computes &amp; caches each speaker embedding (.pt) — makes first playback instant"><span class="mdi mdi-flash-outline"></span> Precompute</button> <div class="vl-menu-title">Edit</div>
<button class="btn-secondary vl-tb-btn" id="seed-batch-all-btn" title="Generate &amp; cache Seed Finder samples for every active voice (skips already-cached; resumable)"><span class="mdi mdi-dice-multiple-outline"></span> Batch seeds</button> <button class="vl-menu-item" id="vl-bulk-tag" title="Add a tag to the selected voices, keeping their existing tags"><span class="mdi mdi-tag-outline" aria-hidden="true"></span> Set tag</button>
<button class="btn-secondary vl-tb-btn" id="verify-voices-stt-btn" title="Synthesize a test sentence with each voice, transcribe it back with Whisper, and compare to the original text — catches repeated/garbled/nonsense output that a duration-only benchmark misses"><span class="mdi mdi-check-decagram-outline"></span> Verify (STT)</button> <button class="vl-menu-item" id="vl-bulk-source" title="Set source / origin on selected voices"><span class="mdi mdi-database-outline" aria-hidden="true"></span> Set source</button>
<button class="vl-menu-item" id="vl-bulk-rating" title="Set star rating on selected"><span class="mdi mdi-star-outline" aria-hidden="true"></span> Rate</button>
<button class="vl-menu-item" id="vl-bulk-hide" title="Disable (hide) selected voices"><span class="mdi mdi-eye-off-outline" aria-hidden="true"></span> Hide</button>
<button class="vl-menu-item" id="vl-bulk-unhide" title="Enable (unhide) selected voices"><span class="mdi mdi-eye-outline" aria-hidden="true"></span> Unhide</button>
<div class="vl-menu-title">Audio</div>
<button class="vl-menu-item" id="precompute-embeddings-btn" title="Warm all active voices so the TTS engine pre-computes &amp; caches each speaker embedding (.pt) — makes first playback instant"><span class="mdi mdi-flash-outline"></span> Precompute</button>
<button class="vl-menu-item" id="calculate-db-btn" title="Calculate dBFS"><span class="mdi mdi-sine-wave" aria-hidden="true"></span> Calc dB</button>
<button class="vl-menu-item" id="benchmark-voices-btn" title="Benchmark TTS speed"><span class="mdi mdi-speedometer" aria-hidden="true"></span> Benchmark</button>
<button class="vl-menu-item" id="seed-batch-all-btn" title="Generate &amp; cache Seed Finder samples for every active voice (skips already-cached; resumable)"><span class="mdi mdi-dice-multiple-outline"></span> Batch seeds</button>
<button class="vl-menu-item" id="verify-voices-stt-btn" title="Synthesize a test sentence with each voice, transcribe it back with Whisper, and compare to the original text — catches repeated/garbled/nonsense output that a duration-only benchmark misses"><span class="mdi mdi-check-decagram-outline"></span> Verify (STT)</button>
<div class="vl-menu-title">Share</div>
<button class="vl-menu-item" id="copy-active-voices-btn" title="Copy the names of the checked voices, comma separated (e.g. for SillyTavern)"><span class="mdi mdi-content-copy" aria-hidden="true"></span> Copy selected</button>
<button class="vl-menu-item" id="export-voices-btn" title="Export the checked voices as .voice.zip bundles (backup / share)"><span class="mdi mdi-export" aria-hidden="true"></span> Export selected</button>
<div class="vl-menu-title">Danger</div>
<button class="vl-menu-item vl-bulk-danger" id="vl-bulk-delete" title="Delete selected voices permanently"><span class="mdi mdi-delete-outline" aria-hidden="true"></span> Delete</button>
</div>
</div>
</div> </div>
<div class="vl-footer"> <div class="vl-footer">

View File

@ -2104,7 +2104,13 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
flex-shrink: 0; flex-shrink: 0;
} }
.vl-card-list { .vl-card-list {
max-height: 55vh; max-height: none;
}
/* The list scrolls inside a bounded box; the filter/synth panels and the toolbar
stay reachable by scrolling the page instead of squeezing the rows away. */
#voice-list {
flex: none;
max-height: max(50vh, 240px);
} }
.voices-inspector-pane { .voices-inspector-pane {
flex: none; flex: none;
@ -2374,7 +2380,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.vl-sort-dir-btn:hover { background: var(--accent); color: #fff; border-color: var(--accent); } .vl-sort-dir-btn:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
.vl-sort-dir-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); } .vl-sort-dir-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
#voice-list { flex: 1; overflow-y: auto; min-height: 0; } #voice-list { flex: 1; overflow-y: auto; min-height: 160px; }
/* Virtual tag folders in the voice list */ /* Virtual tag folders in the voice list */
.vl-tag-group { border-bottom: 1px solid var(--border); } .vl-tag-group { border-bottom: 1px solid var(--border); }
@ -2411,6 +2417,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.vl-bulk-btn:hover { background: var(--panel); border-color: var(--accent); } .vl-bulk-btn:hover { background: var(--panel); border-color: var(--accent); }
.vl-bulk-danger { color: var(--red, #ef4444); border-color: rgba(239,68,68,.3); } .vl-bulk-danger { color: var(--red, #ef4444); border-color: rgba(239,68,68,.3); }
.vl-bulk-danger:hover { background: rgba(239,68,68,.1); border-color: var(--red, #ef4444); } .vl-bulk-danger:hover { background: rgba(239,68,68,.1); border-color: var(--red, #ef4444); }
.vl-bulk-act:disabled { opacity: .45; cursor: not-allowed; }
.vl-bulk-sep { width: 1px; height: 18px; background: var(--border); margin: 0 2px; flex-shrink: 0; } .vl-bulk-sep { width: 1px; height: 18px; background: var(--border); margin: 0 2px; flex-shrink: 0; }
.vl-bulk-tag-inp, .vl-bulk-rating-sel { .vl-bulk-tag-inp, .vl-bulk-rating-sel {
height: 26px; padding: 0 8px; border: 1px solid var(--accent); border-radius: 6px; height: 26px; padding: 0 8px; border: 1px solid var(--accent); border-radius: 6px;
@ -2483,6 +2490,14 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
font-size: 15px; line-height: 1; padding: 0 2px; border-radius: 4px; font-size: 15px; line-height: 1; padding: 0 2px; border-radius: 4px;
} }
.vg-del:hover { color: var(--red, #ef4444); background: rgba(239,68,68,.12); } .vg-del:hover { color: var(--red, #ef4444); background: rgba(239,68,68,.12); }
.vg-acc { flex: 1; min-width: 0; }
.vg-acc > summary { cursor: pointer; list-style: none; display: flex; align-items: center; gap: 6px; }
.vg-acc > summary::-webkit-details-marker { display: none; }
.vg-acc > summary::after { content: "\F0140"; font: normal 16px/1 "Material Design Icons"; margin-left: auto; color: var(--subtext); }
.vg-acc[open] > summary::after { content: "\F0143"; }
.vg-acc > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.vg-active { font-weight: 600; color: #f59e0b; max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vg-chips { display: flex; flex-wrap: wrap; gap: 4px; padding-top: 6px; }
.vg-clear { border: none; background: none; cursor: pointer; color: var(--accent); font-size: 12px; font-weight: 600; padding: 2px 8px; } .vg-clear { border: none; background: none; cursor: pointer; color: var(--accent); font-size: 12px; font-weight: 600; padding: 2px 8px; }
.vl-footer { .vl-footer {
padding: 7px 10px; border-top: 1px solid var(--border); padding: 7px 10px; border-top: 1px solid var(--border);
@ -2612,6 +2627,20 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
position: sticky; top: 0; z-index: 5; position: sticky; top: 0; z-index: 5;
} }
.vl-filter-icon { color: var(--subtext); font-size: 16px; }
/* Compact sort header above the list in the narrow (list + inspector) layout; the
full table header takes over in table view. */
.vl-compact-header {
display: flex; align-items: center; gap: 4px 10px; flex-wrap: wrap;
padding: 6px 10px; border-bottom: 1px solid var(--border); background: var(--surface);
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--subtext);
}
.vl-compact-header .vl-th-sortable { background: none; border: 0; padding: 2px 4px; font: inherit; color: inherit; text-transform: inherit; letter-spacing: inherit; }
.vl-compact-header .vl-th-sortable:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 3px; }
.vl-compact-header .vl-th-sortable.sort-asc, .vl-compact-header .vl-th-sortable.sort-desc { color: var(--accent); }
.vl-compact-header .vl-th-sortable.sort-asc .mdi::before { content: "\F005D"; }
.vl-compact-header .vl-th-sortable.sort-desc .mdi::before { content: "\F0045"; }
.voices-workbench.table-view .vl-compact-header { display: none; }
.vl-th-sortable { cursor: pointer; display: flex; align-items: center; gap: 3px; transition: color .15s; } .vl-th-sortable { cursor: pointer; display: flex; align-items: center; gap: 3px; transition: color .15s; }
.vl-th-sortable:hover { color: var(--text); } .vl-th-sortable:hover { color: var(--text); }
.vl-th-sortable .mdi { font-size: 12px; opacity: .6; } .vl-th-sortable .mdi { font-size: 12px; opacity: .6; }
@ -3448,20 +3477,28 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
padding: 0; width: min(460px, 94vw); box-shadow: 0 8px 32px rgba(0,0,0,.28); padding: 0; width: min(460px, 94vw); box-shadow: 0 8px 32px rgba(0,0,0,.28);
color: var(--text); color: var(--text);
} }
.dc-editing > :not(.add-engine-form) { display: none !important; }
.dc-editing .add-engine-form { padding: 16px; min-width: 0; }
.dc-editing .add-engine-field input,
.dc-editing .add-engine-field select,
.dc-editing .add-engine-field textarea { min-width: 0; width: 100%; box-sizing: border-box; }
.dc-editing .add-engine-field input[type="checkbox"] { width: auto; }
.dc-editing .settings-mini-actions { flex-wrap: wrap; }
.add-engine-dlg::backdrop { background: rgba(0,0,0,.45); } .add-engine-dlg::backdrop { background: rgba(0,0,0,.45); }
.add-engine-form { padding: 22px 24px; display: flex; flex-direction: column; gap: 14px; } .add-engine-form { padding: 22px 24px; display: flex; flex-direction: column; gap: 14px; }
.add-engine-title { margin: 0 0 2px; font-size: 15px; font-weight: 700; } .add-engine-title { margin: 0 0 2px; font-size: 15px; font-weight: 700; }
.add-engine-field { display: flex; flex-direction: column; gap: 5px; } .add-engine-field { display: flex; flex-direction: column; gap: 5px; }
.add-engine-field label { font-size: 12px; font-weight: 600; color: var(--subtext); } .add-engine-field label { font-size: 12px; font-weight: 600; color: var(--subtext); }
.add-engine-opt { font-weight: 400; opacity: .65; } .add-engine-opt { font-weight: 400; opacity: .65; }
.add-engine-field input, .add-engine-field select { .add-engine-field input, .add-engine-field select, .add-engine-field textarea {
padding: 7px 10px; border: 1px solid var(--border); border-radius: 5px; padding: 7px 10px; border: 1px solid var(--border); border-radius: 5px;
background: var(--bg); color: var(--text); font-family: inherit; font-size: 13px; background: var(--bg); color: var(--text); font-family: inherit; font-size: 13px;
transition: border-color .15s; transition: border-color .15s;
} }
.add-engine-field input:focus, .add-engine-field select:focus { .add-engine-field input:focus, .add-engine-field select:focus, .add-engine-field textarea:focus {
outline: none; border-color: var(--accent); outline: none; border-color: var(--accent);
} }
.add-engine-field textarea { resize: vertical; min-height: 70px; }
.add-engine-actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 4px; } .add-engine-actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 4px; }
/* Delete button variant */ /* Delete button variant */
.dc-delete-btn { border-color: rgba(220,38,38,.35) !important; color: var(--red, #dc2626) !important; background: rgba(220,38,38,.05) !important; } .dc-delete-btn { border-color: rgba(220,38,38,.35) !important; color: var(--red, #dc2626) !important; background: rgba(220,38,38,.05) !important; }
@ -6064,3 +6101,64 @@ html[data-mag-night] #toast.success { color: var(--green); } html[data-mag-night
.conv-chart details summary { cursor:pointer; min-height:44px; display:flex; align-items:center; font-size:12px; color:var(--subtext); } .conv-chart details summary { cursor:pointer; min-height:44px; display:flex; align-items:center; font-size:12px; color:var(--subtext); }
.conv-chart table { width:100%; font-size:12px; border-collapse:collapse; font-variant-numeric:tabular-nums; } .conv-chart table { width:100%; font-size:12px; border-collapse:collapse; font-variant-numeric:tabular-nums; }
.conv-chart th, .conv-chart td { text-align:left; padding:2px 4px; border-bottom:1px solid var(--border); } .conv-chart th, .conv-chart td { text-align:left; padding:2px 4px; border-bottom:1px solid var(--border); }
/* ── My Voices: top bar + collapsible filter/sort panel ───────────────── */
.vl-topbar {
display: flex; align-items: center; gap: 6px; padding: 8px 10px;
border-bottom: 1px solid var(--border); background: var(--panel);
}
.vl-topbar .vl-search-main { flex: 1; min-width: 0; padding: 7px 10px; }
.vl-filter-toggle { position: relative; }
.vl-filter-badge {
position: absolute; top: -5px; right: -5px; min-width: 16px; height: 16px; padding: 0 4px;
border-radius: 8px; background: var(--accent); color: #fff; font-size: 10px; font-weight: 700;
line-height: 16px; text-align: center;
}
.vl-filter-panel { padding: 8px 10px 10px; border-bottom: 1px solid var(--border); background: var(--surface); }
.vl-filter-panel[hidden] { display: none; }
.vl-filter-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 8px; }
.vl-filter-grid label { display: flex; flex-direction: column; gap: 3px; font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: .05em; color: var(--subtext); }
.vl-filter-grid select { width: 100%; max-width: none; padding: 6px 8px; font-size: 13px; text-transform: none;
letter-spacing: 0; font-weight: 400; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--text); }
.vl-sort-dir-wide { width: 100%; height: 32px; }
.vl-filter-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.vl-filter-panel .voice-groups-bar { margin: 10px -10px -10px; }
/* Sort header: one line, columns share the width */
.vl-compact-header { flex-wrap: nowrap; gap: 2px; }
.vl-compact-header .vl-th-sortable { flex: 1 1 auto; justify-content: center; white-space: nowrap; }
.vl-compact-header .vl-th-sortable:first-of-type { justify-content: flex-start; }
.vl-filter-grid #voice-sort-field { width: 100%; padding: 6px 8px; font-size: 13px; }
.vl-synth-drawer { border-bottom: 1px solid var(--border); background: var(--surface); }
.vl-synth-drawer[hidden] { display: none; }
/* ── My Voices bottom bar: utilities left, one Actions menu right ───────── */
.vl-toolbar { align-items: center; }
.vl-actions { margin-left: auto; position: relative; }
.vl-actions-btn { display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px; font-size: 13px; font-weight: 600; }
.vl-actions-btn:disabled { opacity: .5; cursor: not-allowed; }
.vl-actions-n { font-variant-numeric: tabular-nums; }
.vl-actions-menu {
position: absolute; right: 0; bottom: calc(100% + 6px); z-index: 30; width: min(260px, 80vw);
max-height: min(80vh, 680px); overflow-y: auto; padding: 6px;
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 8px 28px rgba(0,0,0,.35);
}
.vl-actions-menu[hidden] { display: none; }
.vl-menu-title { padding: 6px 8px 2px; font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: .06em; color: var(--subtext); }
.vl-menu-item { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 44px; padding: 7px 8px; border: 0;
border-radius: 6px; background: none; color: var(--text); font: inherit; font-size: 13px; text-align: left; cursor: pointer; }
.vl-menu-item:hover, .vl-menu-item:focus-visible { background: var(--panel); outline: none; }
.vl-menu-item:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
.vl-menu-item .mdi { font-size: 16px; color: var(--subtext); }
.vl-menu-item.vl-bulk-danger, .vl-menu-item.vl-bulk-danger .mdi { color: var(--red, #ef4444); }
.vl-menu-item.vl-bulk-danger:hover { background: rgba(239,68,68,.1); }
/* One row: utilities collapse to icon buttons (label stays for screen readers); labels return when there is room. */
.vl-toolbar { flex-wrap: nowrap; }
.vl-toolbar .vl-tb-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; }
.vl-toolbar > .vl-tb-btn { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
.vl-toolbar > .vl-tb-btn .mdi { font-size: 18px; }
@container (min-width: 520px) { .vl-toolbar .vl-tb-label { position: static; width: auto; height: auto; clip: auto; } }
.vl-card-list { container-type: inline-size; }

View File

@ -0,0 +1,324 @@
const { test, expect } = require('./_guard');
const path = require('node:path');
const { readFileSync } = require('node:fs');
for (const [role, settingKey, modelKey] of [['llm', 'llm_url', 'llm_model'], ['ocr', 'vision_ocr_url', 'vision_ocr_model']]) {
test(`${role} active panel shows persisted disconnect and restores models on reconnect`, async ({ page }) => {
let discoveries = 0;
await page.route(url => url.pathname === '/', route => route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' }));
await page.route('**/api/**', route => {
discoveries++;
return route.fulfill({ json: { ok: true, models: ['saved-model'] } });
});
await page.goto('/');
await page.setContent(`<select id="${role}-active-url"><option value="http://engine/v1">Engine</option></select><select id="${role}-active-model"></select><span id="${role}-active-status"></span><button id="${role}-active-refresh">Refresh</button>`);
await page.evaluate(({ settingKey, modelKey }) => {
window.$ = id => document.getElementById(id);
window.escHtml = value => String(value);
window._appSettings = { [settingKey]: 'http://engine/v1', [modelKey]: 'saved-model', engine_active: { [settingKey]: 'saved-engine' }, engine_disabled: { 'saved-engine': true } };
}, { settingKey, modelKey });
await page.addScriptTag({ path: path.resolve('static/js/ai-backends.js') });
await expect(page.locator(`#${role}-active-status`)).toHaveText('Disconnected');
expect(discoveries).toBe(0);
await page.evaluate(role => {
delete window._appSettings.engine_disabled['saved-engine'];
window[role === 'llm' ? 'syncActiveLlmPanel' : 'syncActiveOcrPanel']('http://engine/v1');
}, role);
await expect(page.locator(`#${role}-active-model`)).toHaveValue('saved-model');
await expect(page.locator(`#${role}-active-status`)).toHaveText('1 model');
expect(discoveries).toBe(1);
let delayedModels;
await page.route('**/api/engine-models?**', route => { delayedModels = route; });
await page.evaluate(role => window[role === 'llm' ? 'syncActiveLlmPanel' : 'syncActiveOcrPanel']('http://engine/v1'), role);
await expect.poll(() => !!delayedModels).toBe(true);
await expect(page.locator(`#${role}-active-refresh`)).toBeDisabled();
await page.evaluate(role => {
window._appSettings.engine_disabled['saved-engine'] = true;
window[role === 'llm' ? 'syncActiveLlmPanel' : 'syncActiveOcrPanel']('http://engine/v1');
}, role);
await delayedModels.fulfill({ json: { ok: true, models: ['stale-model'] } });
await expect(page.locator(`#${role}-active-refresh`)).toBeEnabled();
await expect(page.locator(`#${role}-active-status`)).toHaveText('Disconnected');
await expect(page.locator(`#${role}-active-model option`)).toHaveText('Disconnected');
});
}
for (const [role, settingKey] of [['stt', 'faster_whisper_url'], ['tts', 'tts_url'], ['llm', 'llm_url'], ['ocr', 'vision_ocr_url']]) {
test(`${role} cards test repeatedly and activate the chosen model with a saved key`, async ({ page }) => {
await page.route(url => url.pathname === '/', route => route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' }));
const activations = [];
const connections = [];
let rejectDisconnect = false;
const settings = { engine_local_urls: { 'dc-custom-stt': 'http://engine/v1' }, engine_models: { 'custom-stt': 'whisper-b' }, engine_disabled: {}, engine_api_keys: { 'custom-stt': '__configured__' } };
let probes = 0;
await page.route('**/api/**', async route => {
const url = new URL(route.request().url());
if (url.pathname === '/api/probe-url') {
probes++;
expect(url.searchParams.get('name')).toBe('custom-stt');
return route.fulfill({ json: { ok: true } });
}
if (url.pathname.endsWith('/connection')) {
const { connected } = route.request().postDataJSON();
if (!connected && rejectDisconnect) return route.fulfill({ status: 500, json: { ok: false, error: 'Save failed' } });
connections.push(connected);
if (connected) delete settings.engine_disabled['custom-stt']; else settings.engine_disabled['custom-stt'] = true;
return route.fulfill({ json: { ok: true, connected } });
}
if (url.pathname === '/api/engine-models') {
expect(url.searchParams.get('type')).toBe(role);
expect(url.searchParams.get('name')).toBe('custom-stt');
return route.fulfill({ json: { ok: true, models: ['whisper-a', 'whisper-b'] } });
}
if (/\/use-as-(engine|llm)$/.test(url.pathname)) {
expect(route.request().headers()['x-provider-api-key']).toBe('test-only-key');
activations.push(route.request().postDataJSON());
return route.fulfill({ json: { ok: true, key_set: true } });
}
return route.fulfill({ json: settings });
});
await page.goto('/');
await page.setContent(`<div data-page="${role}"><div id="dc-grid-${role}"></div></div>`);
await page.evaluate(() => {
window.$ = id => document.getElementById(id);
window._appSettings = { engine_api_keys: { 'custom-stt': '__configured__' } };
window.escHtml = value => String(value).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
window.toast = () => {};
window.initLlmSnippets = () => {};
window.engineApiKeyValue = () => '';
window.applyAndSaveSettings = async patch => Object.assign(window._appSettings, patch);
window.probeUrl = (url, type, apiKey, name) => fetch('/api/probe-url?' + new URLSearchParams({ url, type, name })).then(r => r.json());
});
await page.addScriptTag({ path: path.resolve('static/js/engines.js') });
await page.evaluate(role => renderLocalContainers([{ id: 'custom-stt', name: 'Display name', label: 'Test engine', role, isCustom: true, url: 'http://engine/v1' }]), role);
await expect(page.locator('.llm-local-name')).toHaveText('Test engine');
await expect(page.locator('.dc-apikey-inp')).toBeVisible();
await page.locator('.dc-connect-btn').click();
await page.locator('.dc-connect-btn').click();
expect(probes).toBe(2);
await expect(page.locator('.dc-use-btn')).not.toHaveClass(/active/);
await page.locator('.dc-models-btn').click();
await expect(page.locator('datalist option[value="whisper-b"]')).toHaveCount(1);
await page.locator('.dc-model-inp').fill('whisper-b');
await page.locator('.dc-model-inp').blur();
await page.locator('.dc-apikey-inp').fill('test-only-key');
await page.locator('.dc-use-btn').click();
await expect.poll(() => activations.length).toBe(1);
expect(activations[0]).toEqual(role === 'llm'
? { url: 'http://engine/v1', model: 'whisper-b' }
: { url: 'http://engine/v1', role, setting_key: settingKey, model: 'whisper-b', activate: true });
await expect(page.locator('.dc-disconnect-btn')).toBeVisible();
rejectDisconnect = true;
await page.locator('.dc-disconnect-btn').click();
await expect(page.locator('.dc-disconnect-btn')).toBeVisible();
expect(settings.engine_disabled).toEqual({});
rejectDisconnect = false;
await page.locator('.dc-disconnect-btn').click();
await expect(page.locator('.dc-disconnect-btn')).toBeHidden();
await expect(page.locator('.dc-use-btn')).not.toHaveClass(/active/);
expect(settings.engine_disabled).toEqual({ 'custom-stt': true });
expect(settings.engine_models['custom-stt']).toBe('whisper-b');
expect(settings.engine_api_keys['custom-stt']).toBe('__configured__');
// A stale browser flag cannot reconnect an engine disabled in saved settings.
await page.evaluate(() => localStorage.setItem('dc-con-custom-stt', '1'));
await page.evaluate(role => renderLocalContainers([{ id: 'custom-stt', name: 'Renamed display label', role, isCustom: true, url: 'http://engine/v1' }]), role);
await expect(page.locator('.dc-disconnect-btn')).toBeHidden();
await expect(page.locator('.dc-model-inp')).toHaveValue('whisper-b');
await expect(page.locator('.dc-url-inp')).toHaveValue('http://engine/v1');
await page.locator('.dc-connect-btn').click();
await expect(page.locator('.dc-disconnect-btn')).toBeVisible();
expect(settings.engine_disabled).toEqual({});
expect(connections).toEqual([true, true, false, true]);
await page.route('**/api/probe-url?**', route => route.fulfill({ json: { ok: false, error: 'Denied' } }));
await page.locator('.dc-connect-btn').click();
await expect(page.locator('.dc-connect-btn')).not.toHaveClass(/\bok\b/);
await page.route('**/api/engine-models?**', route => route.fulfill({ json: { ok: false, error: 'Denied' } }));
await page.locator('.dc-models-btn').click();
await expect(page.locator('.dc-model-inp')).toHaveValue('whisper-b');
if (role === 'stt' || role === 'tts') {
const name = role === 'stt' ? 'nvidia-parakeet-asr' : 'fish-speech-api';
const setting = role === 'stt' ? 'nvidia_router_url' : 'fishspeech_url';
await page.evaluate(({ name, role }) => renderLocalContainers([{ name, role, port: 9000 }]), { name, role });
await page.locator('.dc-apikey-inp').fill('test-only-key');
await page.locator('.dc-model-inp').fill('actual-model-id');
await page.locator('.dc-use-btn').click();
await expect.poll(() => activations.length).toBe(2);
expect(activations[1]).toEqual({ url: 'http://host.docker.internal:9000', role, setting_key: setting, model: 'actual-model-id', activate: true });
}
if (role === 'tts') {
const targets = [
['tts-faster-qwen3-tts-voiceclone', 'tts_url'],
['tts-faster-qwen3-tts-voicedesign', 'voice_design_url'],
['tts-faster-qwen3-tts-customvoice', 'customvoice_url'],
['tts-faster-qwen3-tts-streaming', 'tts_stream_url'],
['fish-speech-api', 'fishspeech_url'], ['kokoro-tts', 'kokoro_url'],
['vibevoice-msft-tts', 'vibevoice_url'], ['nvidia-zeroshot-nim', 'nvidia_zeroshot_url'],
['nvidia-flow-nim', 'nvidia_flow_url'], ['xtts-dgx-spark', 'xtts_url'],
];
await page.evaluate(targets => renderLocalContainers(targets.map(([name]) => ({ name, role: 'tts', port: 9000 }))), targets);
expect((await page.locator('.dc-use-btn').allTextContents()).map(text => text.trim())).toEqual(targets.map(() => 'Use as TTS'));
expect(await page.locator('.dc-use-btn').evaluateAll(buttons => buttons.map(button => button.dataset.dcUseKey))).toEqual(targets.map(([, key]) => key));
}
});
}
test('custom editor probes, saves credentials separately, edits inline by stable ID and keeps failed saves open', async ({ page }) => {
await page.route(url => url.pathname === '/', route => route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' }));
let settings = { custom_engine_cards: [], engine_models: {}, engine_api_keys: {} };
let rejectSave = false;
let delaySave = false;
let pendingSave;
const writes = [];
const checks = [];
const activations = [];
await page.route('**/api/**', async route => {
const request = route.request();
const url = new URL(request.url());
if (/\/use-as-(llm|engine)$/.test(url.pathname)) {
activations.push({ path: url.pathname, body: request.postDataJSON() });
return route.fulfill({ json: { ok: true } });
}
if (url.pathname === '/api/settings' && request.method() === 'POST') {
const body = request.postDataJSON();
writes.push(body);
if (delaySave) { pendingSave = route; return; }
if (rejectSave) return route.fulfill({ status: 500, json: { error: 'Save failed' } });
settings = { ...settings, ...body, engine_api_keys: { ...settings.engine_api_keys } };
for (const key of Object.keys(body.engine_api_keys || {})) settings.engine_api_keys[key] = '__configured__';
return route.fulfill({ json: { ok: true } });
}
if (['/api/probe-url', '/api/engine-models'].includes(url.pathname)) {
checks.push({ type: url.searchParams.get('type'), name: url.searchParams.get('name'), typed: request.headers()['x-provider-api-key'] });
return route.fulfill({ json: { ok: true, models: ['discovered-model'] } });
}
return route.fulfill({ json: settings });
});
await page.goto('/');
const section = readFileSync(path.resolve('static/sections/s-llms.html'), 'utf8');
await page.setContent(['stt', 'tts', 'llm', 'ocr'].map(role => `<div data-page="${role}"><div id="dc-grid-${role}"></div></div>`).join('') + section.slice(section.indexOf('<dialog'), section.indexOf('</dialog>') + 9));
await page.addStyleTag({ path: path.resolve('static/style.css') });
await page.evaluate(() => {
window.$ = id => document.getElementById(id);
window._appSettings = { custom_engine_cards: [], engine_models: {}, engine_api_keys: {} };
window.escHtml = value => String(value).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
window.toast = () => {};
window.initLlmSnippets = () => {};
});
await page.addScriptTag({ path: path.resolve('static/js/engines.js') });
await page.evaluate(() => openAddEngineDialog('stt'));
await page.locator('#aed-name').fill('Example STT');
await page.locator('#aed-url').fill('http://engine/v1');
await page.locator('#aed-api-key').fill('test-only-key');
for (const invalid of ['[1]', '{', '{"nested":{"api_key":"test-only-key"}}', '{"headers":{"x-api-key":"test-only-key"}}', '{"llm_api_key":"test-only-key"}', '{"BearerToken":"test-only-key"}']) {
await page.locator('#aed-params').fill(invalid);
await page.locator('#aed-save').click();
await page.locator('#aed-test').click();
expect(writes).toHaveLength(0);
expect(checks).toHaveLength(0);
await expect(page.locator('#add-engine-dlg')).toBeVisible();
}
await page.locator('#aed-params').fill('{"temperature":0.7,"max_tokens":50}');
await page.locator('#aed-active').check();
await page.locator('#aed-test').click();
await page.locator('#aed-load-models').click();
await expect(page.locator('#aed-models option')).toHaveAttribute('value', 'discovered-model');
await page.locator('#aed-model').fill('manual-model');
await page.locator('#aed-save').click();
await expect(page.locator('#add-engine-dlg')).not.toBeVisible();
expect(writes).toHaveLength(1);
const id = writes[0].custom_engine_cards[0].id;
expect(writes[0].engine_api_keys).toEqual({ [id]: 'test-only-key' });
expect(writes[0].engine_models[id]).toBe('manual-model');
expect(writes[0].custom_engine_cards[0].params).toEqual({ temperature: 0.7, max_tokens: 50 });
await expect.poll(() => activations.length).toBe(1);
expect(activations[0]).toEqual({ path: `/api/local-containers/${id}/use-as-engine`, body: { url: 'http://engine/v1', model: 'manual-model', role: 'stt', activate: true, setting_key: 'faster_whisper_url' } });
expect(checks).toEqual([{ type: 'stt', name: id, typed: 'test-only-key' }, { type: 'stt', name: id, typed: 'test-only-key' }]);
expect(JSON.stringify(writes[0].custom_engine_cards)).not.toContain('test-only-key');
expect(await page.evaluate(() => localStorage.getItem('engines-custom-cards'))).not.toContain('test-only-key');
await expect(page.locator('#dc-grid-stt .dc-edit-btn')).toBeVisible();
await page.locator('#dc-grid-stt .dc-edit-btn').click();
await expect(page.locator('#add-engine-dlg')).not.toBeVisible();
await expect(page.locator('#dc-grid-stt .add-engine-form')).toBeVisible();
await page.locator('#aed-name').fill('Cancelled change');
await page.locator('#aed-api-key').fill('discarded-test-key');
await page.locator('#aed-cancel').click();
await expect(page.locator('#dc-grid-stt .llm-local-name')).toHaveText('Example STT');
await expect(page.locator('#dc-grid-stt .dc-edit-btn')).toBeFocused();
await page.locator('#dc-grid-stt .dc-url-inp').fill('http://visible-override/v1');
await page.locator('#dc-grid-stt .dc-container-name-inp').fill('visible-container');
await page.locator('#dc-grid-stt .dc-edit-btn').click();
await expect(page.locator('#aed-url')).toHaveValue('http://visible-override/v1');
await expect(page.locator('#aed-container')).toHaveValue('visible-container');
await expect(page.locator('#aed-name')).toHaveValue('Example STT');
await expect(page.locator('#aed-api-key')).toHaveValue('');
await expect(page.locator('#aed-api-key')).toHaveAttribute('placeholder', /Configured/);
await expect(page.locator('#aed-model')).toHaveValue('manual-model');
await expect(page.locator('#aed-params')).toHaveValue(/"temperature": 0.7/);
await page.locator('#aed-active').uncheck();
await page.locator('#aed-name').fill('Renamed STT');
await page.locator('#aed-url').fill('http://changed-engine/v1');
let delayedModels;
await page.route('**/api/engine-models?**', route => { delayedModels = route; });
await page.locator('#aed-load-models').click();
await expect.poll(() => !!delayedModels).toBe(true);
await page.locator('#aed-url').fill('http://new-engine/v1');
await delayedModels.fulfill({ json: { ok: true, models: ['stale-model'] } });
await expect(page.locator('#aed-load-models')).toBeEnabled();
await expect(page.locator('#aed-models option')).toHaveCount(0);
delaySave = true;
await page.locator('#aed-save').click();
await expect.poll(() => !!pendingSave).toBe(true);
await expect(page.locator('#aed-cancel')).toBeDisabled();
await page.evaluate(() => {
openAddEngineDialog('tts');
editCustomEngineCard(window._appSettings.custom_engine_cards[0]);
renderLocalContainers([]);
loadLocalContainers();
});
await expect(page.locator('#aed-name')).toHaveValue('Renamed STT');
await expect(page.locator('#dc-grid-stt .add-engine-form')).toBeVisible();
delaySave = false;
await pendingSave.fulfill({ status: 500, json: { error: 'Save failed' } });
await expect(page.locator('#aed-save')).toBeEnabled();
await expect(page.locator('#aed-cancel')).toBeEnabled();
await expect(page.locator('#dc-grid-stt .add-engine-form')).toBeVisible();
await expect(page.locator('#aed-name')).toHaveValue('Renamed STT');
await expect(page.locator('#aed-url')).toHaveValue('http://new-engine/v1');
rejectSave = false;
await page.route('**/api/settings', route => route.request().method() === 'GET' ? route.abort() : route.fallback());
await page.locator('#aed-save').click();
await expect(page.locator('#add-engine-dlg')).not.toBeVisible();
await expect(page.locator('#dc-grid-stt .llm-local-name')).toHaveText('Renamed STT');
expect(writes.at(-1).custom_engine_cards[0].id).toBe(id);
expect(writes.at(-1)).not.toHaveProperty('engine_api_keys');
expect(writes.at(-1).engine_local_urls['dc-' + id]).toBe('http://new-engine/v1');
expect(await page.evaluate(id => localStorage.getItem('dc-url-' + id), id)).toBe('http://new-engine/v1');
await page.route('**/api/local-containers/*/use-as-llm', route => {
activations.push({ path: new URL(route.request().url()).pathname, body: route.request().postDataJSON() });
return route.fulfill({ status: 400, json: { ok: false, error: 'Denied' } });
});
await page.evaluate(() => {
window.engineTestMessages = [];
window.toast = message => window.engineTestMessages.push(message);
editCustomEngineCard(window._appSettings.custom_engine_cards[0]);
});
await page.locator('#aed-role').selectOption('llm');
await page.locator('#aed-active').check();
const savesBeforeActivation = writes.length;
await page.locator('#aed-save').click();
await expect(page.locator('#add-engine-dlg')).not.toBeVisible();
await expect.poll(() => page.evaluate(() => window.engineTestMessages.some(message => message.includes('saved; activation failed')))).toBe(true);
await expect(page.locator('#dc-grid-llm .llm-local-name')).toHaveText('Renamed STT');
await expect(page.locator('#dc-grid-stt .llm-local-card')).toHaveCount(0);
for (const role of ['ocr', 'tts', 'stt']) {
await page.evaluate(() => editCustomEngineCard(window._appSettings.custom_engine_cards[0]));
await expect(page.locator('.dc-editing .add-engine-form')).toBeVisible();
expect(await page.locator('.add-engine-form').evaluate(form => form.getBoundingClientRect().right <= innerWidth)).toBe(true);
await page.locator('#aed-role').selectOption(role);
await page.locator('#aed-active').uncheck();
await page.locator('#aed-save').click();
await expect(page.locator(`#dc-grid-${role} .dc-edit-btn`)).toBeVisible();
}
expect(writes).toHaveLength(savesBeforeActivation + 4);
expect(activations.at(-1)).toEqual({ path: `/api/local-containers/${id}/use-as-llm`, body: { url: 'http://new-engine/v1', model: 'manual-model' } });
});

312
tests/engine-footer.spec.js Normal file
View File

@ -0,0 +1,312 @@
const { test, expect } = require('./_guard');
const path = require('node:path');
test('footer chooses dynamic custom TTS ID and settings preserve custom STT preference', async ({ page }) => {
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
let activated = false;
let rejectConnection = false;
let lastEngineOnly = false;
const connectionChanges = [];
const settings = {
custom_engine_cards: [
{ id: 'stable-stt', name: 'Renamed STT', role: 'stt' },
{ id: 'stable-tts', name: 'Renamed voice connection', role: 'tts', isCustom: true, url: 'http://engine/v1' },
],
stt_preferred_backend: 'custom:stable-stt',
engine_disabled: {},
engine_models: { 'stable-tts': 'keep-model' },
engine_api_keys: { 'stable-tts': '__configured__' },
engine_local_urls: { 'dc-stable-tts': 'http://engine/v1' },
};
const backends = [
{ id: 'openai', label: 'Voice Clone API', title: 'Qwen3 TTS VoiceClone', model: 'tts-1', available: true },
{ id: 'custom:stable-tts', label: 'Different API label', title: 'Renamed voice connection', model: 'actual-inference-model', available: true },
{ id: 'legacy', label: 'Legacy backend', model: 'legacy-model', available: true },
{ id: 'custom:offline', label: 'Offline', available: false },
];
await page.route(url => url.pathname === '/', route => route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' }));
await page.route('**/api/**', route => {
const url = new URL(route.request().url());
if (url.pathname.endsWith('/connection')) {
if (rejectConnection) return route.fulfill({ status: 400, json: { ok: false, error: 'Denied' } });
const connected = route.request().postDataJSON().connected;
connectionChanges.push(connected);
if (connected) delete settings.engine_disabled['stable-tts']; else settings.engine_disabled['stable-tts'] = true;
return route.fulfill({ json: { ok: true, connected } });
}
if (url.pathname === '/api/probe-url') return route.fulfill({ json: { ok: true } });
if (url.pathname.endsWith('/use-as-engine')) { activated = true; return route.fulfill({ json: { ok: true } }); }
if (url.pathname === '/api/tts-backends') {
const visible = activated ? backends.filter(backend => backend.id !== 'custom:stable-tts' || !settings.engine_disabled['stable-tts']) : [backends[0]];
return route.fulfill({ json: { backends: lastEngineOnly ? visible.filter(backend => backend.id === 'custom:stable-tts') : visible } });
}
return route.fulfill({ json: settings });
});
await page.goto('/');
await page.setContent('<button class="status-engine" data-status-kind="tts">TTS</button><select id="tts-backend-select"><option value="openai">Default</option></select><select id="s-stt-preferred-backend"><option value="">Configured</option></select><div data-page="tts"><div id="dc-grid-tts"></div></div>');
await page.evaluate(() => {
window._sttBackends = [];
window.logErr = () => {};
window.initLlmSnippets = () => {};
});
await page.addScriptTag({ path: path.resolve('static/js/utils.js') });
await page.addScriptTag({ path: path.resolve('static/js/settings.js') });
await page.addScriptTag({ path: path.resolve('static/js/engines.js') });
await page.addScriptTag({ path: path.resolve('static/js/ai-backends.js') });
await page.evaluate(settings => {
window._appSettings = settings;
window._ttsBackends = [{ id: 'openai', label: 'Default', available: true }];
renderLocalContainers([settings.custom_engine_cards[1]]);
}, settings);
await page.locator('.dc-use-btn').click();
await expect(page.locator('#tts-backend-select')).toHaveValue('custom:stable-tts');
await expect(page.locator('#tts-backend-select option[value="openai"]')).toHaveText('Qwen3 TTS VoiceClone');
await expect(page.locator('#tts-backend-select option[value="custom:stable-tts"]')).toHaveText('Renamed voice connection');
await expect(page.locator('#tts-backend-select option[value="legacy"]')).toHaveText('Legacy backend');
expect(await page.evaluate(() => {
const select = document.createElement('select');
select.innerHTML = styleBackendOptions('custom:stable-tts');
return [...select.options].map(option => ({ id: option.value, label: option.textContent, selected: option.selected }));
})).toEqual([
{ id: 'openai', label: 'Qwen3 TTS VoiceClone', selected: false },
{ id: 'custom:stable-tts', label: 'Renamed voice connection', selected: true },
{ id: 'legacy', label: 'Legacy backend', selected: false },
]);
await page.locator('.status-engine').click();
expect(errors).toEqual([]);
await expect(page.locator('.status-flyup-item[data-val="custom:stable-tts"]')).toHaveText('Renamed voice connection');
await expect(page.locator('.status-flyup-item[data-val="openai"]')).toHaveText('Qwen3 TTS VoiceClone');
await expect(page.locator('.status-flyup-item[data-val="legacy"]')).toHaveText('Legacy backend');
await expect(page.locator('.status-flyup-item[data-val="custom:offline"]')).toHaveCount(0);
await page.locator('.status-flyup-item[data-val="custom:stable-tts"]').click();
await expect(page.locator('#tts-backend-select')).toHaveValue('custom:stable-tts');
await page.locator('#tts-backend-select').selectOption('openai');
await page.locator('.status-engine').click();
await expect(page.locator('.status-flyup-item[data-val="openai"]')).toHaveClass(/is-active/);
await page.locator('.status-flyup-item[data-val="custom:stable-tts"]').click();
await expect(page.locator('#tts-backend-select')).toHaveValue('custom:stable-tts');
await page.evaluate(() => {
syncPreferredSttBackend(window._appSettings);
syncPreferredSttBackend(window._appSettings);
});
await expect(page.locator('#s-stt-preferred-backend')).toHaveValue('custom:stable-stt');
await expect(page.locator('#s-stt-preferred-backend option[value="custom:stable-stt"]')).toHaveCount(1);
await expect(page.locator('.dc-disconnect-btn')).toBeVisible();
rejectConnection = true;
await page.locator('.dc-disconnect-btn').click();
await expect(page.locator('.dc-disconnect-btn')).toBeVisible();
await expect(page.locator('#tts-backend-select')).toHaveValue('custom:stable-tts');
rejectConnection = false;
await page.locator('.status-engine').click();
await page.locator('.dc-disconnect-btn').click();
await expect(page.locator('.status-flyup')).toHaveCount(0);
await expect(page.locator('#tts-backend-select option[value="custom:stable-tts"]')).toHaveCount(0);
await expect(page.locator('.dc-disconnect-btn')).toBeHidden();
await expect(page.locator('.dc-url-inp')).toHaveValue('http://engine/v1');
await expect(page.locator('.dc-model-inp')).toHaveValue('keep-model');
expect(await page.evaluate(() => window._appSettings.engine_api_keys['stable-tts'] === '__configured__')).toBe(true);
await page.evaluate(() => {
localStorage.setItem('dc-con-stable-tts', '1');
renderLocalContainers([window._appSettings.custom_engine_cards[1]]);
});
await expect(page.locator('.dc-disconnect-btn')).toBeHidden();
await expect(page.locator('.dc-connect-btn')).not.toHaveClass(/\bok\b/);
await page.locator('.dc-connect-btn').click();
await expect(page.locator('.dc-disconnect-btn')).toBeVisible();
await expect(page.locator('#tts-backend-select option[value="custom:stable-tts"]')).toHaveCount(1);
await expect(page.locator('#tts-backend-select')).toHaveValue('openai');
expect(connectionChanges).toEqual([false, true]);
lastEngineOnly = true;
await page.evaluate(() => refreshTtsBackendAvailability('custom:stable-tts'));
await page.locator('.dc-disconnect-btn').click();
await expect(page.locator('#tts-backend-select')).toBeDisabled();
await expect(page.locator('#tts-backend-select')).toHaveValue('');
});
test('LLM footer selection updates active, rehearser and conversation model pickers', async ({ page }) => {
const writes = [];
await page.route(url => url.pathname === '/', route => route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' }));
await page.route('**/api/**', route => {
if (route.request().method() === 'POST') writes.push(route.request().postDataJSON());
return route.fulfill({ json: { ok: true, models: ['old-model', 'picked-model'] } });
});
await page.goto('/');
const options = '<option value="old-model">old-model</option><option value="picked-model">picked-model</option>';
await page.setContent('<button class="status-engine" data-status-kind="llm">LLM</button><select id="llm-active-url"><option value="http://engine/v1">Engine</option></select><span id="llm-active-status"></span>' +
['llm-active-model', 'reh-llm-model', 'conv-llm-model-select'].map(id => `<select id="${id}">${options}</select>`).join(''));
await page.evaluate(() => {
window._appSettings = { llm_url: 'http://engine/v1', llm_model: 'old-model' };
window.logErr = () => {};
window.applyAndSaveSettings = patch => fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) });
});
await page.addScriptTag({ path: path.resolve('static/js/utils.js') });
await page.addScriptTag({ path: path.resolve('static/js/benchmark-voice-picker.js') });
await page.addScriptTag({ path: path.resolve('static/js/ai-backends.js') });
await expect(page.locator('#llm-active-status')).toHaveText('2 models');
await page.locator('.status-engine').click();
await page.locator('.status-flyup-item[data-val="picked-model"]').click();
for (const id of ['llm-active-model', 'reh-llm-model', 'conv-llm-model-select']) {
await expect(page.locator('#' + id)).toHaveValue('picked-model');
}
await expect(page.locator('#llm-active-status')).toHaveText('Active model: picked-model');
await expect(page.locator('.bvp-root[data-bvp-for="llm-active-model"] .bvp-trigger-content')).toHaveText('picked-model');
expect(await page.evaluate(() => window._appSettings.llm_model)).toBe('picked-model');
await expect.poll(() => writes.some(patch => patch.llm_model === 'picked-model')).toBe(true);
await page.evaluate(() => {
window._appSettings.engine_active = { llm_url: 'chosen' };
window._appSettings.engine_disabled = { chosen: true };
document.body.insertAdjacentHTML('beforeend', '<div id="status-bar"></div>');
window.updateStatusBar();
});
await expect(page.locator('[data-status-kind="llm"] .status-engine-value')).toHaveText('Disconnected');
await page.locator('.status-engine[data-status-kind="llm"]').first().click();
await expect(page.locator('.status-flyup')).toContainText('LLM disconnected');
await expect(page.locator('.status-flyup-item')).toHaveCount(0);
expect(await page.evaluate(() => window._appSettings.llm_model)).toBe('picked-model');
});
test('disconnect refresh keeps sidebar navigation on Engines and Settings', async ({ page }) => {
const fs = require('node:fs');
const index = fs.readFileSync(path.resolve('static/index.html'), 'utf8');
const tabs = index.match(/<nav class="tabs"[\s\S]*?<\/nav>/)[0];
const sidebar = index.match(/<aside id="sidebar"[\s\S]*?<\/aside>/)[0];
const errors = [];
const changes = [];
let noBackends = false;
page.on('pageerror', error => errors.push(String(error)));
await page.route(url => url.pathname === '/', route => route.fulfill({ contentType: 'text/html', body: '<html><body></body></html>' }));
await page.route('**/api/**', route => {
const url = new URL(route.request().url());
if (url.pathname.endsWith('/connection')) {
changes.push(route.request().postDataJSON().connected);
return route.fulfill({ json: { ok: true, connected: false } });
}
if (url.pathname === '/api/tts-backends') return route.fulfill({ json: { backends: noBackends ? [] : [{ id: 'openai', available: true }] } });
return route.fulfill({ json: { engine_disabled: {} } });
});
await page.goto('/#s-llms');
await page.setContent(tabs + sidebar + '<main id="main-content"><section id="s-llms" class="page-section"></section><section id="s-settings" class="page-section"></section><section id="s-tryout" class="page-section"></section></main>');
await page.addScriptTag({ path: path.resolve('static/js/utils.js') });
await page.addScriptTag({ path: path.resolve('static/js/settings.js') });
await page.addScriptTag({ path: path.resolve('static/js/engines.js') });
await page.evaluate(() => {
window.loadLocalContainers = () => {};
window.initLlmSnippets = () => {};
document.body.insertAdjacentHTML('beforeend', ['tts', 'stt', 'ocr', 'llm'].map(role => '<div data-page="' + role + '"><div id="dc-grid-' + role + '"></div></div>').join(''));
});
await page.addScriptTag({ path: path.resolve('static/nav.js') });
for (const section of ['s-llms', 's-settings']) {
await page.evaluate(section => navTo(section), section);
for (const role of ['tts', 'stt', 'ocr', 'llm']) {
noBackends = role === 'llm';
await page.evaluate(role => {
localStorage.setItem('dc-con-mock-' + role, '1');
renderLocalContainers([{ id: 'mock-' + role, name: 'Mock ' + role, role, isCustom: true, url: 'http://engine/v1' }]);
}, role);
const disconnect = page.locator('#dc-grid-' + role + ' .dc-disconnect-btn');
await disconnect.click();
await expect(disconnect).toBeHidden();
await expect(page).toHaveURL(new RegExp('#' + section + '$'));
await expect(page.locator('#' + section)).toHaveClass(/is-active/);
await expect(page.locator('[data-nav-section="' + section + '"]').first()).toHaveClass(/active/);
}
}
await expect(page.locator('.tab[data-tab="library"]')).toHaveClass(/backend-unavailable/);
expect(changes).toEqual(Array(8).fill(false));
expect(errors).toEqual([]);
});
test('legacy tabs still leave an unavailable active backend', async ({ page }) => {
await page.route(url => url.pathname === '/', route => route.fulfill({ contentType: 'text/html', body: '<html><body></body></html>' }));
await page.route('**/api/tts-backends', route => route.fulfill({ json: { backends: [] } }));
await page.goto('/');
await page.setContent('<div class="tab active" data-tab="library" data-backend-required="voice_clone"></div><div class="tab" data-tab="settings"></div>');
await page.addScriptTag({ path: path.resolve('static/js/utils.js') });
await page.addScriptTag({ path: path.resolve('static/js/settings.js') });
await page.evaluate(() => refreshTtsBackendAvailability());
await expect(page.locator('.tab[data-tab="settings"]')).toHaveClass(/active/);
});
test('STT connection and activation refresh card titles and chosen footer backend', async ({ page }) => {
const errors = [];
const writes = [];
page.on('pageerror', error => errors.push(String(error)));
const settings = {
stt_preferred_backend: 'faster_whisper', engine_disabled: { 'stable-stt': true },
custom_engine_cards: [{ id: 'stable-stt', name: 'DGX Realtime STT', role: 'stt', isCustom: true, url: 'http://engine/v1' }],
};
const backends = [
{ id: 'configured', engine_id: 'stable-stt', label: 'Configured alias', title: 'DGX Realtime STT', available: true, connected: true },
{ id: 'faster_whisper', engine_id: 'stable-stt', label: 'Configured alias', title: 'DGX Realtime STT', available: true, connected: true, url: 'http://engine/v1' },
{ id: 'custom:stable-stt', engine_id: 'stable-stt', label: 'Inference model', title: 'DGX Realtime STT', available: true, connected: true, url: 'http://engine/v1' },
{ id: 'custom:other-stt', engine_id: 'other-stt', label: 'Inference model', title: 'DGX Realtime STT', available: true, connected: true, url: 'http://engine/v1' },
{ id: 'native', engine_id: 'native-stt', title: 'Disconnected native', available: true, connected: false },
{ id: 'legacy-stt', label: 'Legacy STT', available: true },
{ id: 'offline-stt', title: 'Offline STT', available: false },
];
await page.route(url => url.pathname === '/', route => route.fulfill({ contentType: 'text/html', body: '<html><body></body></html>' }));
await page.route('**/api/**', route => {
const url = new URL(route.request().url());
if (url.pathname === '/api/probe-url') return route.fulfill({ json: { ok: true } });
if (url.pathname.endsWith('/connection')) {
delete settings.engine_disabled['stable-stt'];
return route.fulfill({ json: { ok: true } });
}
if (url.pathname.endsWith('/use-as-engine')) {
settings.stt_preferred_backend = 'custom:stable-stt';
return route.fulfill({ json: { ok: true } });
}
if (url.pathname === '/api/stt-backends') return route.fulfill({ json: { backends: backends.filter(b => b.id !== 'custom:stable-stt' || !settings.engine_disabled['stable-stt']) } });
if (url.pathname === '/api/settings' && route.request().method() === 'POST') {
const patch = route.request().postDataJSON();
writes.push(patch);
Object.assign(settings, patch);
}
return route.fulfill({ json: settings });
});
await page.goto('/');
const ids = ['conv-stt-select', 'stt-tts-stt-backend', 'clone-stt-backend'];
await page.setContent('<button class="status-engine" data-status-kind="stt">STT</button><select id="s-stt-preferred-backend"><option value="faster_whisper">Configured</option><option value="legacy-stt">Legacy</option></select>' +
ids.map(id => `<select id="${id}"><option value="faster_whisper">Stale</option></select>`).join('') + '<div data-page="stt"><div id="dc-grid-stt"></div></div>');
await page.evaluate(settings => { window._appSettings = settings; window.logErr = () => {}; window.initLlmSnippets = () => {}; }, settings);
for (const file of ['utils', 'settings', 'stt', 'engines', 'ai-backends']) await page.addScriptTag({ path: path.resolve('static/js/' + file + '.js') });
await page.evaluate(settings => { window._appSettings = settings; }, settings);
await page.evaluate(() => refreshSttBackends());
await page.evaluate(() => renderLocalContainers(window._appSettings.custom_engine_cards));
await page.locator('.dc-connect-btn').click();
for (const id of ids) {
await expect(page.locator('#' + id)).toHaveValue('faster_whisper');
await expect(page.locator('#' + id + ' option[value="custom:stable-stt"]')).toHaveCount(0);
}
await page.locator('.dc-use-btn').click();
for (const id of ids) {
await expect(page.locator('#' + id)).toHaveValue('custom:stable-stt');
await expect(page.locator('#' + id + ' option[value="custom:stable-stt"]')).toHaveText('DGX Realtime STT');
}
await expect(page.locator('#conv-stt-select option[value="faster_whisper"]')).toHaveCount(0);
await expect(page.locator('#conv-stt-select option[value="configured"]')).toHaveCount(0);
await expect(page.locator('#conv-stt-select option[value="custom:other-stt"]')).toHaveCount(1);
await expect(page.locator('#conv-stt-select option[value="native"]')).toBeDisabled();
await page.locator('#conv-stt-select').selectOption('legacy-stt');
await page.locator('.status-engine').click();
await expect(page.locator('.status-flyup-item[data-val="custom:stable-stt"]')).toHaveClass(/is-active/);
await expect(page.locator('.status-flyup-item[data-val="faster_whisper"]')).toHaveCount(0);
await expect(page.locator('.status-flyup-item[data-val="configured"]')).toHaveCount(0);
await expect(page.locator('.status-flyup-item[data-val="custom:other-stt"]')).toHaveCount(1);
await expect(page.locator('.status-flyup-item[data-val="native"]')).toHaveCount(0);
await expect(page.locator('.status-flyup-item[data-val="legacy-stt"]')).toHaveText('Legacy STT');
await expect(page.locator('.status-flyup-item[data-val="offline-stt"]')).toHaveCount(0);
await page.locator('.status-flyup-item[data-val="legacy-stt"]').click();
for (const id of [...ids, 's-stt-preferred-backend']) await expect(page.locator('#' + id)).toHaveValue('legacy-stt');
await expect.poll(() => writes.some(patch => patch.stt_preferred_backend === 'legacy-stt')).toBe(true);
await page.evaluate(() => { document.body.insertAdjacentHTML('beforeend', '<div id="status-bar"></div>'); updateStatusBar(); });
await expect(page.locator('[data-status-kind="stt"] .status-engine-value')).toHaveText('Legacy STT');
expect(await page.evaluate(() => sttBackendChoices(_sttBackends, 'faster_whisper').filter(b => b.engine_id === 'stable-stt' && b.available).map(b => b.id))).toEqual(['faster_whisper']);
expect(await page.evaluate(() => sttBackendChoices([
{ id: 'nvidia_parakeet', engine_id: 'nvidia-parakeet-asr', available: true },
{ id: 'nvidia_router', engine_id: 'nvidia-parakeet-asr', available: true },
], 'nvidia_router').map(b => b.id))).toEqual(['nvidia_router']);
expect(errors).toEqual([]);
});

212
tests/test_custom_tts.py Normal file
View File

@ -0,0 +1,212 @@
"""Custom backend selection keeps endpoint, model, and credentials together."""
import asyncio
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
from fastapi import HTTPException
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="custom-tts-tests-"))
from core.config import _preview_backend_base_url
from core import tts_helpers
from routes import docker, stt, tts
class CustomTtsTests(unittest.TestCase):
def settings(self):
return {
"tts_url": "http://default/v1", "tts_api_key": "default-key", "tts_backend": "openai",
"llm_url": "http://gateway/v1", "llm_api_key": "gateway-key",
"custom_engine_cards": [
{"id": "first", "name": "Not a model", "role": "tts", "url": "http://gateway/v1", "params": {"speed": 0.8}},
{"id": "chosen", "name": "Also not a model", "role": "tts", "url": "http://gateway/v1", "params": {"speed": 1.2}},
],
"engine_api_keys": {"first": "first-key", "chosen": "chosen-key"},
"engine_models": {"first": "first-model", "chosen": "real-model"},
}
def test_custom_preview_posts_selected_model_key_and_params(self):
settings = self.settings()
response = Mock(content=b"audio", headers={"content-type": "audio/wav"}, status_code=200)
with patch("core.config._load_settings", return_value=settings), \
patch("core.config.request_with_retries", return_value=response) as post:
result = tts_helpers._preview_request_audio("hello", "alloy", settings, backend="custom:chosen")
self.assertEqual(result, (b"audio", "audio/wav"))
self.assertEqual(post.call_args.args, ("POST", "http://gateway/v1/audio/speech"))
payload = post.call_args.kwargs["json"]
self.assertEqual((payload["model"], payload["voice"], payload["input"], payload["speed"]),
("real-model", "alloy", "hello", 1.2))
self.assertEqual(post.call_args.kwargs["headers"], {"Authorization": "Bearer chosen-key"})
def test_url_priority_and_model_fallback_never_use_card_name(self):
settings = self.settings()
settings["engine_local_urls"] = {"dc-chosen": "http://override/v1", "chosen": "http://bare"}
self.assertEqual(_preview_backend_base_url(settings, "custom:chosen"), "http://override/v1")
settings["engine_models"].pop("chosen")
endpoint, payload, headers = tts_helpers._tts_request_config("hello", "voice", settings, backend_override="custom:chosen")
self.assertEqual(endpoint, "http://override/v1/audio/speech")
self.assertEqual(payload["model"], "tts-1")
self.assertEqual(headers, {"Authorization": "Bearer chosen-key"})
self.assertEqual(_preview_backend_base_url(settings, "custom:unknown"), "")
def test_keyless_gateway_probe_activation_and_inference_agree(self):
settings = self.settings()
settings["engine_api_keys"].pop("chosen")
for name in ("chosen",):
self.assertEqual(docker._engine_api_key(settings, name, "tts", url="http://gateway/"), "gateway-key")
self.assertEqual(docker._engine_api_key(settings, name, "tts", url="http://unrelated"), "sk-dummy-key")
self.assertEqual(docker._engine_api_key({"llm_url": "http://gateway/v1", "llm_api_key": "gateway-key"}, "", "tts", url="http://gateway"), "gateway-key")
with patch("routes.docker._load_settings", return_value=settings), patch("routes.docker._save_settings") as save:
request = Mock(json=AsyncMock(return_value={"url": "http://gateway/v1", "role": "tts", "setting_key": "tts_url", "model": "real-model"}))
asyncio.run(docker.use_as_engine("chosen", request, ""))
activated = save.call_args.args[0]
_, _, headers = tts_helpers._tts_request_config("hello", "voice", activated, backend_override="custom:chosen")
self.assertEqual(headers, {"Authorization": "Bearer gateway-key"})
self.assertEqual(docker._engine_api_key(settings, "chosen", "tts", "typed", "http://gateway"), "typed")
def test_unswitched_cards_list_with_card_url_and_skip_invalid_urls(self):
settings = self.settings()
settings["custom_engine_cards"][1]["label"] = "My Speech Engine"
settings["custom_engine_cards"].append({"id": "bad", "role": "tts", "url": ""})
with patch("routes.tts._load_settings", return_value=settings), \
patch("routes.tts._fetch_backend_voices", return_value=["alloy"]), \
patch("routes.tts._backend_health_detail", return_value={"ok": True}):
result = asyncio.run(tts.tts_backends())
custom = {item["id"]: item for item in result["backends"] if item["id"].startswith("custom:")}
self.assertEqual(set(custom), {"custom:first", "custom:chosen"})
self.assertEqual(custom["custom:chosen"]["url"], "http://gateway/v1")
self.assertEqual(custom["custom:chosen"]["label"], "real-model (Custom)")
self.assertEqual(custom["custom:chosen"]["title"], "My Speech Engine")
self.assertEqual(custom["custom:first"]["title"], "Not a model")
native = {item["id"]: item for item in result["backends"] if not item["id"].startswith("custom:")}
self.assertEqual(native["voice_clone"]["title"], "Qwen3 TTS · Voice Clone")
self.assertEqual(native["voice_design"]["title"], "Qwen3 TTS · Voice Design")
self.assertEqual(native["customvoice"]["title"], "Qwen3 TTS · Custom Voice")
self.assertEqual(native["streaming"]["title"], "Qwen3 TTS · Streaming")
self.assertEqual(native["fishspeech"]["title"], "Fish-Speech API")
self.assertEqual(settings["engine_models"]["chosen"], "real-model")
def test_model_discovery_does_not_request_voice_ids(self):
response = Mock(status_code=200, json=Mock(return_value={"data": [{"id": "real-model"}]}))
with patch("routes.docker._load_settings", return_value=self.settings()), \
patch("routes.docker.requests.get", return_value=response) as get:
result = asyncio.run(docker.engine_models("http://gateway/v1", "tts", "chosen", ""))
self.assertEqual(result["models"], ["real-model"])
self.assertEqual(get.call_args.args[0], "http://gateway/v1/models")
self.assertEqual(get.call_args.kwargs["headers"]["Authorization"], "Bearer chosen-key")
def test_speech_proxy_forwards_custom_backend(self):
async def bounded(fn, *args):
return fn(*args)
async def generate(text, _language, _settings, fn):
return await fn(text)
request = Mock(json=AsyncMock(return_value={"input": "hello", "voice": "alloy", "app": "test"}))
with patch("routes.tts._load_settings", return_value=self.settings()), \
patch("routes.tts._resolve_tts_route", return_value=("alloy", {"backend": "custom:chosen"})), \
patch("core.presets._resolve_virtual_voice", return_value=None), \
patch("routes.tts._resolve_virtual_voice", return_value=None), \
patch("routes.tts._bounded_tts_call", side_effect=bounded), \
patch("routes.tts._generate_with_tags", side_effect=generate), \
patch("routes.tts._preview_request_audio", return_value=(b"audio", "audio/wav")) as preview, \
patch("routes.tts._apply_route_sounds", return_value=(b"audio", "audio/wav", [])), \
patch("routes.tts._prepare_proxy_audio", return_value=(b"audio", "audio/wav", "wav", None, False)), \
patch("routes.tts._routing_log_request"):
response = asyncio.run(tts.openai_speech_proxy(request))
self.assertEqual(response.status_code, 200)
self.assertEqual(preview.call_args.args[-1], "custom:chosen")
def test_custom_stt_params_keep_selected_identity_on_shared_gateway(self):
settings = self.settings()
for card in settings["custom_engine_cards"]:
card["role"] = "stt"
card["params"] = {"language": "de" if card["id"] == "first" else "en"}
response = Mock(ok=True, status_code=200, text="transcribed")
with tempfile.NamedTemporaryFile(suffix=".wav") as audio, \
patch("routes.stt.requests.post", return_value=response) as post:
stt._transcribe_audio(Path(audio.name), settings, "custom:chosen")
self.assertEqual(post.call_args.kwargs["data"]["language"], "en")
self.assertEqual(post.call_args.kwargs["headers"]["Authorization"], "Bearer chosen-key")
def test_legacy_card_name_maps_remain_usable_before_migration(self):
settings = self.settings()
name = settings["custom_engine_cards"][1]["name"]
settings["engine_api_keys"] = {name: "legacy-key", "unrelated": "wrong-key"}
settings["engine_models"] = {name: "legacy-model"}
settings["engine_local_urls"] = {"dc-" + name: "http://legacy/v1"}
endpoint, payload, headers = tts_helpers._tts_request_config("hello", "voice", settings, backend_override="custom:chosen")
self.assertEqual(endpoint, "http://legacy/v1/audio/speech")
self.assertEqual(payload["model"], "legacy-model")
self.assertEqual(payload["speed"], 1.2)
self.assertEqual(headers, {"Authorization": "Bearer legacy-key"})
for role in ("tts", "stt", "ocr", "llm"):
self.assertEqual(docker._engine_api_key(settings, "chosen", role, url="http://legacy/v1"), "legacy-key")
self.assertEqual(docker._engine_api_key(settings, "unknown", role, url="http://unknown"), "sk-dummy-key")
settings["engine_api_keys"]["chosen"] = "id-key"
settings["engine_models"]["chosen"] = "id-model"
_, payload, headers = tts_helpers._tts_request_config("hello", "voice", settings, backend_override="custom:chosen")
self.assertEqual(payload["model"], "id-model")
self.assertEqual(headers, {"Authorization": "Bearer id-key"})
settings["engine_models"]["chosen"] = ""
_, payload, _ = tts_helpers._tts_request_config("hello", "voice", settings, backend_override="custom:chosen")
self.assertEqual(payload["model"], "tts-1")
def test_disconnect_persists_without_erasing_config_and_reconnect_restores(self):
settings = self.settings()
original_keys = dict(settings["engine_api_keys"])
original_models = dict(settings["engine_models"])
pairs = (("tts-faster-qwen3-tts-voiceclone", "voice_clone"), ("fish-speech-api", "fishspeech"), ("chosen", "custom:chosen"))
with patch("routes.docker._load_settings", side_effect=lambda: settings), \
patch("routes.docker._save_settings") as save:
for card, backend in pairs:
request = Mock(json=AsyncMock(return_value={"connected": False}))
self.assertEqual(asyncio.run(docker.engine_connection(card, request)), {"ok": True, "connected": False})
settings = save.call_args.args[0]
self.assertEqual(settings["engine_api_keys"], original_keys)
self.assertEqual(settings["engine_models"], original_models)
self.assertEqual(settings["tts_url"], "http://default/v1")
with patch("routes.tts._load_settings", side_effect=lambda: settings), \
patch("routes.tts._fetch_backend_voices", return_value=["alloy"]) as voices, \
patch("routes.tts._backend_health_detail", return_value={"ok": True}) as health:
listed = {item["id"]: item for item in asyncio.run(tts.tts_backends())["backends"]}
self.assertEqual(health.call_count, 10)
for card, backend in pairs:
self.assertTrue(listed[backend]["disabled"])
self.assertFalse(listed[backend]["available"])
self.assertNotIn(backend, [call.args[1] for call in voices.call_args_list])
with patch("routes.docker._load_settings", side_effect=lambda: settings), \
patch("routes.docker._save_settings") as save:
for card, backend in pairs:
asyncio.run(docker.engine_connection(card, Mock(json=AsyncMock(return_value={"connected": True}))))
settings = save.call_args.args[0]
listed = {item["id"]: item for item in asyncio.run(tts.tts_backends())["backends"]}
for card, backend in pairs:
self.assertFalse(listed[backend]["disabled"])
self.assertTrue(listed[backend]["available"])
def test_connection_rejects_unknown_identity_and_invalid_boolean(self):
with patch("routes.docker._load_settings", return_value=self.settings()), \
patch("routes.docker._save_settings") as save:
for body in ([], {}, {"connected": "false"}, {"connected": 1}):
with self.assertRaises(HTTPException) as caught:
asyncio.run(docker.engine_connection("chosen", Mock(json=AsyncMock(return_value=body))))
self.assertEqual(caught.exception.status_code, 400)
with self.assertRaises(HTTPException) as caught:
asyncio.run(docker.engine_connection("unknown", Mock(json=AsyncMock(return_value={"connected": False}))))
self.assertEqual(caught.exception.status_code, 404)
save.assert_not_called()
def test_use_as_reconnects_without_changing_other_disabled_cards(self):
settings = self.settings()
settings["engine_disabled"] = {"chosen": True, "first": True}
request = Mock(json=AsyncMock(return_value={"role": "tts", "setting_key": "tts_url", "url": "http://gateway/v1", "model": "real-model"}))
with patch("routes.docker._load_settings", return_value=settings), patch("routes.docker._save_settings") as save:
asyncio.run(docker.use_as_engine("chosen", request, ""))
self.assertEqual(save.call_args.args[0]["engine_disabled"], {"first": True})
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,159 @@
"""Provider credentials and model selections reach mocked inference calls."""
import asyncio
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
os.environ.setdefault('CONFIG_DIR', tempfile.mkdtemp(prefix='ttvc-engine-tests-'))
from fastapi import HTTPException
from routes import docker, stt, settings as settings_routes
from core import tts_helpers
from core.provider_health import provider_key_for_url, provider_request_params, validate_provider_params
class EngineConnectionTests(unittest.TestCase):
def test_extra_params_are_role_scoped_and_required_fields_protected(self):
settings = {'custom_engine_cards': [
{'id': 'chat', 'url': 'http://gateway/v1', 'role': 'llm', 'params': {'temperature': 0.7}},
{'id': 'speech', 'url': 'http://gateway/v1', 'role': 'tts', 'params': {'speed': 1.2}},
]}
payload = {'model': 'chosen', 'messages': [], 'temperature': 0.1}
merged = provider_request_params('http://gateway/v1/chat/completions', payload, settings)
self.assertEqual(merged['temperature'], 0.7)
self.assertNotIn('speed', merged)
self.assertEqual(merged['model'], 'chosen')
settings['custom_engine_cards'].append({'id': 'other-chat', 'url': 'http://gateway/v1', 'role': 'llm', 'params': {'top_p': 0.1}})
self.assertEqual(provider_request_params('http://gateway/v1/chat/completions', payload, settings), payload)
def test_params_follow_saved_endpoint_override(self):
settings = {'custom_engine_cards': [{'id': 'card', 'url': 'http://old', 'role': 'tts', 'params': {'speed': 1.2}}], 'engine_local_urls': {'dc-card': 'http://new/v1'}}
self.assertEqual(provider_request_params('http://new/v1/audio/speech', {'input': 'hello'}, settings)['speed'], 1.2)
self.assertEqual(provider_request_params('http://old/v1/audio/speech', {'input': 'hello'}, settings), {'input': 'hello'})
@patch('routes.settings._save_settings')
def test_invalid_or_secret_extra_params_are_rejected_before_save(self, save):
invalid = [[], {'model': 'replace'}, {'headers': {'x-api-key': 'dummy'}}, {'oauth': {'client_secret': 'dummy'}}, {'llm_api_key': 'dummy'}, {'nested': [{'BearerToken': 'dummy'}]}]
for params in invalid:
request = Mock(json=AsyncMock(return_value={'custom_engine_cards': [{'id': 'card', 'params': params}]}))
with self.assertRaises(HTTPException) as caught:
asyncio.run(settings_routes.post_settings(request))
self.assertEqual(caught.exception.status_code, 400)
save.assert_not_called()
validate_provider_params({'max_tokens': 100, 'temperature': 0.3})
@patch('routes.docker._save_settings')
@patch('routes.docker._load_settings')
def test_explicit_activation_selects_custom_stt_and_enables_ocr(self, load, save):
for role, setting_key in [('stt', 'faster_whisper_url'), ('ocr', 'vision_ocr_url'), ('tts', 'tts_url')]:
load.return_value = {'custom_engine_cards': [{'id': 'card', 'role': role}]}
request = Mock(json=AsyncMock(return_value={'url': 'http://engine', 'role': role, 'setting_key': setting_key, 'activate': True}))
asyncio.run(docker.use_as_engine('card', request, ''))
saved = save.call_args.args[0]
if role == 'stt':
self.assertEqual(saved['stt_preferred_backend'], 'custom:card')
elif role == 'ocr':
self.assertTrue(saved['vision_ocr_enabled'])
else:
self.assertEqual(saved['tts_backend'], 'openai')
@patch('routes.settings._save_settings')
@patch('routes.settings._load_settings')
def test_custom_rename_migrates_legacy_maps_without_exposing_key(self, load, save):
load.return_value = {'custom_engine_cards': [{'id': 'custom-1', 'name': 'Old'}], 'engine_api_keys': {'Old': 'dummy-secret'}, 'engine_models': {'Old': 'selected'}, 'engine_local_urls': {'dc-Old': 'http://engine'}}
request = Mock(json=AsyncMock(return_value={'custom_engine_cards': [{'id': 'custom-1', 'name': 'Renamed'}], 'engine_api_keys': {'custom-1': '__configured__'}}))
result = asyncio.run(settings_routes.post_settings(request))
saved = save.call_args.args[0]
self.assertEqual(saved['engine_api_keys']['custom-1'], 'dummy-secret')
self.assertEqual(saved['engine_models']['custom-1'], 'selected')
self.assertEqual(saved['engine_local_urls']['dc-custom-1'], 'http://engine')
self.assertNotIn('dummy-secret', str(result))
@patch('routes.settings._save_settings')
@patch('routes.settings._load_settings')
def test_custom_migration_preserves_id_values_and_normal_cards(self, load, save):
load.return_value = {'custom_engine_cards': [{'id': 'custom-1', 'name': 'Old'}], 'engine_api_keys': {'Old': 'legacy', 'custom-1': 'current', 'ollama': 'normal'}, 'engine_models': {'Old': 'legacy', 'custom-1': 'current'}}
request = Mock(json=AsyncMock(return_value={'custom_engine_cards': [{'id': 'custom-1', 'name': 'Renamed'}]}))
asyncio.run(settings_routes.post_settings(request))
saved = save.call_args.args[0]
self.assertEqual(saved['engine_api_keys']['custom-1'], 'current')
self.assertEqual(saved['engine_api_keys']['ollama'], 'normal')
self.assertEqual(saved['engine_models']['custom-1'], 'current')
def test_card_credentials_do_not_use_other_provider_key(self):
settings = {'llm_api_key': 'unrelated', 'engine_api_keys': {'card': 'saved'}}
self.assertEqual(docker._engine_api_key(settings, 'card', 'llm', 'typed'), 'typed')
self.assertEqual(docker._engine_api_key(settings, 'card', 'llm'), 'saved')
self.assertEqual(docker._engine_api_key(settings, 'empty', 'llm'), 'sk-dummy-key')
self.assertEqual(docker._engine_headers('typed', 'anthropic')['x-api-key'], 'typed')
@patch('routes.docker.requests.get')
@patch('routes.docker._load_settings')
def test_model_list_uses_saved_card_key_and_root_fallback(self, load, get):
load.return_value = {'engine_api_keys': {'card': 'saved'}, 'llm_api_key': 'other'}
get.side_effect = [Mock(status_code=404), Mock(status_code=200, json=Mock(return_value={'data': [{'id': 'selected'}]}))]
result = asyncio.run(docker.engine_models('http://engine/v1', 'stt', 'card', ''))
self.assertEqual(result['models'], ['selected'])
self.assertEqual(get.call_args.args[0], 'http://engine/models')
self.assertEqual(get.call_args.kwargs['headers']['Authorization'], 'Bearer saved')
@patch('routes.docker._save_settings')
@patch('routes.docker._load_settings')
def test_activation_scopes_key_and_clears_previous_model(self, load, save):
load.return_value = {'whisper_api_key': 'default', 'stt_models': {'faster_whisper': 'old'}}
request = Mock(json=AsyncMock(return_value={'url': 'http://engine', 'role': 'stt', 'setting_key': 'faster_whisper_url', 'model': ''}))
asyncio.run(docker.use_as_engine('card', request, 'typed'))
saved = save.call_args.args[0]
self.assertEqual(saved['whisper_api_key'], 'default')
self.assertEqual(saved['engine_api_keys']['card'], 'typed')
self.assertEqual(saved['stt_models']['faster_whisper'], '')
self.assertEqual(stt._stt_backend_api_key(saved, 'faster_whisper'), 'typed')
def test_activation_rejects_non_object_and_cross_role_setting(self):
for body in ([], {'role': 'ocr', 'setting_key': 'tts_url', 'url': 'http://engine'}):
request = Mock(json=AsyncMock(return_value=body))
with self.assertRaises(HTTPException) as caught:
asyncio.run(docker.use_as_engine('card', request, ''))
self.assertEqual(caught.exception.status_code, 400)
def test_endpoint_key_resolution_handles_ui_prefix(self):
settings = {'engine_local_urls': {'dc-card': 'http://engine/v1'}, 'engine_api_keys': {'card': 'saved'}}
self.assertEqual(provider_key_for_url(settings, 'http://engine', 'other'), 'saved')
settings['engine_api_keys'] = {}
self.assertEqual(provider_key_for_url(settings, 'http://engine', 'other'), '')
@patch('routes.stt.requests.post')
def test_stt_inference_uses_custom_card_key_and_model(self, post):
settings = {'custom_engine_cards': [{'id': 'card', 'url': 'http://engine/v1'}], 'engine_api_keys': {'card': 'saved'}, 'engine_models': {'card': 'selected'}, 'custom_engine_cards': [{'id': 'card', 'url': 'http://engine/v1', 'role': 'stt', 'params': {'language': 'en'}}]}
post.return_value = Mock(status_code=200, json=Mock(return_value={'text': 'heard'}))
with tempfile.TemporaryDirectory() as directory:
audio = Path(directory) / 'audio.wav'
audio.write_bytes(b'test')
stt._transcribe_audio(audio, settings, 'custom:card')
self.assertEqual(post.call_args.kwargs['headers']['Authorization'], 'Bearer saved')
self.assertEqual(post.call_args.kwargs['data']['model'], 'selected')
self.assertEqual(post.call_args.kwargs['data']['language'], 'en')
def test_tts_model_key_and_v1_base_reach_request_config(self):
settings = {'tts_url': 'http://engine/v1', 'tts_models': {'tts_url': 'selected'}, 'engine_local_urls': {'dc-card': 'http://engine/v1'}, 'engine_api_keys': {'card': 'saved'}, 'custom_engine_cards': [{'id': 'card', 'url': 'http://engine/v1', 'role': 'tts', 'params': {'speed': 1.2}}]}
endpoint, payload, headers = tts_helpers._tts_request_config('hello', 'voice', settings)
self.assertEqual(endpoint, 'http://engine/v1/audio/speech')
self.assertEqual(payload['model'], 'selected')
self.assertEqual(payload['speed'], 1.2)
self.assertEqual(headers['Authorization'], 'Bearer saved')
def test_explicit_tts_key_override_is_preserved(self):
settings = {'tts_url': 'http://primary', 'tts_api_key': 'default'}
_, _, headers = tts_helpers._tts_request_config('hello', 'voice', settings, url_override='http://other', api_key_override='default')
self.assertEqual(headers['Authorization'], 'Bearer default')
def test_sibling_backend_does_not_receive_default_credentials(self):
settings = {'tts_url': 'http://primary', 'tts_api_key': 'primary-key', 'whisper_url': 'http://primary', 'whisper_api_key': 'primary-key', 'faster_whisper_url': 'http://sibling'}
_, _, headers = tts_helpers._tts_request_config('hello', 'voice', settings, url_override='http://sibling')
self.assertEqual(headers, {})
self.assertEqual(stt._stt_backend_api_key(settings, 'faster_whisper'), '')
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,101 @@
"""Disconnect gates inference without destroying saved engine configuration."""
import asyncio
import os
import tempfile
import unittest
from unittest.mock import AsyncMock, Mock, patch
os.environ.setdefault('CONFIG_DIR', tempfile.mkdtemp(prefix='ttvc-disconnect-tests-'))
from fastapi import HTTPException
from core.provider_health import engine_disconnected, provider_request_params
from routes import docker, stt, tts
class EngineDisconnectTests(unittest.TestCase):
def test_legacy_native_and_cross_role_endpoint_matching(self):
self.assertTrue(engine_disconnected({'engine_disabled': {'ollama': True}}, 'http://localhost:11434/v1', 'llm'))
self.assertTrue(engine_disconnected({'engine_disabled': {'ollama': True}}, 'http://host.docker.internal:11434/v1', 'llm'))
settings = {'engine_disabled': {'litellm': True}, 'engine_local_urls': {'litellm': 'http://gateway'}}
self.assertFalse(engine_disconnected(settings, 'http://gateway', 'tts'))
self.assertFalse(engine_disconnected(settings, 'http://gateway', 'ocr'))
@patch('routes.tts._backend_health_detail', return_value={'ok': True})
@patch('routes.tts._fetch_backend_voices', return_value=[])
@patch('routes.tts._load_settings')
def test_native_alias_of_disabled_custom_tts_is_unavailable(self, load, voices, health):
load.return_value = {'tts_url': 'http://gateway', 'engine_active': {'tts_url': 'custom-card'}, 'engine_disabled': {'custom-card': True}, 'custom_engine_cards': [{'id': 'custom-card', 'role': 'tts', 'url': 'http://gateway'}]}
payload = asyncio.run(tts.tts_backends())
item = next(item for item in payload['backends'] if item['id'] == 'voice_clone')
self.assertFalse(item['available'])
self.assertTrue(item['disabled'])
self.assertNotIn('http://gateway', [call.args[0] for call in health.call_args_list])
def test_shared_endpoint_uses_selected_identity(self):
settings = {'llm_url': 'http://gateway/v1', 'engine_active': {'llm_url': 'enabled'}, 'engine_local_urls': {'disabled': 'http://gateway', 'enabled': 'http://gateway'}, 'engine_disabled': {'disabled': True}}
self.assertFalse(engine_disconnected(settings, 'http://gateway/v1', 'llm'))
provider_request_params('http://gateway/v1/chat/completions', {}, settings)
settings['engine_active']['llm_url'] = 'disabled'
with self.assertRaisesRegex(ValueError, 'disconnected'):
provider_request_params('http://gateway/v1/chat/completions', {}, settings)
@patch('routes.docker._save_settings')
@patch('routes.docker._load_settings')
def test_disconnect_retains_saved_configuration(self, load, save):
load.return_value = {'llm_url': 'http://gateway/v1', 'llm_model': 'chosen', 'engine_api_keys': {'card': 'dummy'}, 'custom_engine_cards': [{'id': 'card', 'role': 'llm'}]}
request = Mock(json=AsyncMock(return_value={'connected': False}))
asyncio.run(docker.engine_connection('card', request))
saved = save.call_args.args[0]
self.assertEqual(saved['llm_url'], 'http://gateway/v1')
self.assertEqual(saved['llm_model'], 'chosen')
self.assertEqual(saved['engine_api_keys']['card'], 'dummy')
self.assertTrue(saved['engine_disabled']['card'])
@patch('routes.docker._save_settings')
@patch('routes.docker._load_settings')
def test_current_dc_url_overrides_stale_bare_url(self, load, save):
load.return_value = {'llm_url': 'http://current/v1', 'engine_local_urls': {'dc-card': 'http://current', 'card': 'http://stale'}, 'custom_engine_cards': [{'id': 'card', 'role': 'llm'}], 'engine_disabled': {'card': True}}
self.assertTrue(engine_disconnected(load.return_value, 'http://current', 'llm'))
self.assertFalse(engine_disconnected(load.return_value, 'http://stale', 'llm'))
request = Mock(json=AsyncMock(return_value={'connected': False}))
asyncio.run(docker.engine_connection('card', request))
self.assertEqual(save.call_args.args[0]['engine_active']['llm_url'], 'card')
@patch('routes.docker._save_settings')
@patch('routes.docker._load_settings')
def test_disconnect_records_native_default_selection(self, load, save):
load.return_value = {'llm_url': 'http://host.docker.internal:11434/v1'}
request = Mock(json=AsyncMock(return_value={'connected': False}))
asyncio.run(docker.engine_connection('ollama', request))
self.assertEqual(save.call_args.args[0]['engine_active']['llm_url'], 'ollama')
@patch('routes.docker._save_settings')
@patch('routes.docker._load_settings')
def test_disconnect_records_unique_legacy_selection_for_both_roles(self, load, save):
for role, setting in [('llm', 'llm_url'), ('ocr', 'vision_ocr_url')]:
load.return_value = {setting: 'http://lan:14000/v1', 'engine_local_urls': {'dc-card': 'http://lan:14000'}, 'custom_engine_cards': [{'id': 'card', 'role': role}]}
request = Mock(json=AsyncMock(return_value={'connected': False}))
asyncio.run(docker.engine_connection('card', request))
self.assertEqual(save.call_args.args[0]['engine_active'][setting], 'card')
@patch('routes.docker._save_settings')
@patch('routes.docker._load_settings')
def test_disconnect_preserves_other_selection_on_shared_endpoint(self, load, save):
load.return_value = {'llm_url': 'http://gateway/v1', 'engine_active': {'llm_url': 'other'}, 'engine_local_urls': {'card': 'http://gateway', 'other': 'http://gateway'}, 'custom_engine_cards': [{'id': 'card', 'role': 'llm'}, {'id': 'other', 'role': 'llm'}]}
request = Mock(json=AsyncMock(return_value={'connected': False}))
asyncio.run(docker.engine_connection('card', request))
self.assertEqual(save.call_args.args[0]['engine_active']['llm_url'], 'other')
@patch('routes.stt._stt_backend_health')
@patch('routes.stt._load_settings')
def test_disabled_stt_skips_probe_and_rejects_inference(self, load, health):
load.return_value = {'engine_disabled': {'faster-whisper': True}, 'faster_whisper_url': 'http://whisper'}
health.return_value = {'ok': True, 'models': []}
payload = asyncio.run(stt.stt_backends())
item = next(item for item in payload['backends'] if item['id'] == 'faster_whisper')
self.assertFalse(item['available'])
self.assertFalse(item['connected'])
self.assertNotIn('http://whisper', [call.args[0] for call in health.call_args_list])
with self.assertRaises(HTTPException) as caught:
stt._transcribe_audio(None, load.return_value, 'faster_whisper')
self.assertEqual(caught.exception.status_code, 409)

View File

@ -0,0 +1,67 @@
"""Vision credentials stay attached to their configured endpoint."""
import os
import pathlib
import tempfile
import unittest
from unittest import mock
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="ttvc-magazine-auth-test-"))
from core.magazine import BLOCK_FIGURE, describe_visual_region, vision_ocr_page, vision_ocr_page_chain
class VisionAuthTests(unittest.TestCase):
def test_transcription_and_description_use_only_matching_vision_key(self):
settings = {
"vision_ocr_url": "http://primary/",
"vision_ocr_api_key": "primary-test-key",
"vision_ocr_fallback_url": "http://fallback/proxy/v1/",
"vision_ocr_fallback_api_key": "fallback-test-key",
"llm_api_key": "unrelated-test-key",
}
response = mock.Mock()
response.json.return_value = {"choices": [{"message": {"content":
"text [10,10,110,60]A real sentence transcribed from this page."}}]}
for url, expected in (
("http://primary/v1/", {"Authorization": "Bearer primary-test-key"}),
("http://fallback/proxy/v1", {"Authorization": "Bearer fallback-test-key"}),
("http://unconfigured/v1", {}),
("http://primary/other/v1", {}),
):
for describe in (False, True):
with self.subTest(url=url, describe=describe), \
mock.patch("core.config._load_settings", return_value=settings), \
mock.patch("core.magazine.request_with_retries", return_value=response) as request:
if describe:
result = describe_visual_region(pathlib.Path(__file__), BLOCK_FIGURE, url, "model")
else:
result = vision_ocr_page(pathlib.Path(__file__), url, "model")
self.assertIsNotNone(result)
self.assertEqual(request.call_args.kwargs["headers"], expected)
def test_empty_key_and_fallback_never_reuse_another_engine_key(self):
settings = {
"vision_ocr_url": "http://primary/v1",
"vision_ocr_api_key": "primary-test-key",
"vision_ocr_fallback_url": "http://fallback/v1",
"vision_ocr_fallback_api_key": "",
"llm_api_key": "unrelated-test-key",
}
failure = mock.Mock()
failure.json.return_value = {"choices": [{"message": {"content": ""}}]}
success = mock.Mock()
success.json.return_value = {"choices": [{"message": {"content":
"text [10,10,110,60]A real sentence transcribed from this page."}}]}
with mock.patch("core.config._load_settings", return_value=settings), \
mock.patch("core.magazine.request_with_retries", side_effect=[failure, success]) as request:
result = vision_ocr_page_chain(pathlib.Path(__file__), [
("http://primary/v1", "model"), ("http://fallback/v1", "model"),
])
self.assertIsNotNone(result)
self.assertEqual(request.call_args_list[0].kwargs["headers"],
{"Authorization": "Bearer primary-test-key"})
self.assertEqual(request.call_args_list[1].kwargs["headers"], {})
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,137 @@
"""STT footer metadata uses card names and effective endpoints."""
import asyncio
import os
import tempfile
import unittest
from unittest.mock import patch
from fastapi import HTTPException
os.environ.setdefault('CONFIG_DIR', tempfile.mkdtemp(prefix='ttvc-stt-footer-tests-'))
from routes import stt
class SttFooterMetadataTests(unittest.TestCase):
def test_custom_models_use_own_legacy_name_with_stable_id_priority(self):
settings = {
'custom_engine_cards': [
{'id': 'first', 'name': 'First speech', 'url': 'http://shared', 'role': 'stt'},
{'id': 'second', 'name': 'Second speech', 'url': 'http://shared', 'role': 'stt'},
],
'engine_models': {'First speech': 'first-legacy', 'Second speech': 'second-legacy'},
}
self.assertEqual(stt._stt_backend_model('custom:first', settings), 'first-legacy')
self.assertEqual(stt._stt_backend_model('custom:second', settings), 'second-legacy')
settings['engine_models']['first'] = 'first-current'
self.assertEqual(stt._stt_backend_model('custom:first', settings), 'first-current')
settings['engine_models']['first'] = ''
self.assertEqual(stt._stt_backend_model('custom:first', settings), 'large-v3')
def test_native_selected_credentials_preserve_only_matching_global_fallback(self):
settings = {
'engine_active': {'faster_whisper_url': 'faster-whisper'},
'faster_whisper_url': 'http://gateway/v1/',
'whisper_url': 'http://gateway',
'whisper_api_key': 'global-test-key',
'engine_api_keys': {'faster-whisper': 'own-test-key', 'other': 'other-test-key'},
'custom_engine_cards': [{'id': 'other', 'url': 'http://gateway', 'role': 'stt'}],
}
self.assertEqual(stt._stt_backend_api_key(settings, 'faster_whisper'), 'own-test-key')
del settings['engine_api_keys']['faster-whisper']
self.assertEqual(stt._stt_backend_api_key(settings, 'faster_whisper'), 'global-test-key')
settings['faster_whisper_url'] = 'http://unrelated'
self.assertEqual(stt._stt_backend_api_key(settings, 'faster_whisper'), '')
settings['engine_active']['faster_whisper_url'] = 'other'
settings['faster_whisper_url'] = 'http://gateway'
del settings['engine_api_keys']['other']
self.assertEqual(stt._stt_backend_api_key(settings, 'faster_whisper'), '')
@patch('routes.stt._stt_backend_health', return_value={'ok': True, 'models': ['discovered']})
@patch('routes.stt._load_settings')
def test_disabled_native_does_not_disable_selected_custom_on_shared_url(self, load, health):
load.return_value = {
'whisper_url': 'http://shared/v1',
'nvidia_router_url': 'http://shared/v1',
'engine_active': {'whisper_url': 'realtime', 'nvidia_router_url': 'realtime'},
'engine_disabled': {'nvidia-parakeet-asr': True},
'custom_engine_cards': [{'id': 'realtime', 'name': 'Realtime', 'role': 'stt', 'url': 'http://shared/v1'}],
}
items = {item['id']: item for item in asyncio.run(stt.stt_backends())['backends']}
self.assertFalse(items['nvidia_router']['available'])
self.assertFalse(items['nvidia_router']['connected'])
self.assertTrue(items['custom:realtime']['available'])
self.assertTrue(items['custom:realtime']['connected'])
self.assertEqual(items['configured']['engine_id'], 'realtime')
self.assertEqual(items['custom:realtime']['engine_id'], 'realtime')
self.assertEqual(sum(call.args[0] == 'http://shared/v1' for call in health.call_args_list), 2)
with self.assertRaises(HTTPException) as caught:
stt._transcribe_audio(None, load.return_value, 'nvidia_router')
self.assertEqual(caught.exception.status_code, 409)
@patch('routes.stt._stt_backend_health', return_value={'ok': True, 'models': ['discovered']})
@patch('routes.stt._load_settings')
def test_legacy_native_slot_uses_known_endpoint_identity(self, load, health):
load.return_value = {
'nvidia_asr_url': 'http://native:8090',
'engine_disabled': {'nvidia-parakeet-asr': True},
}
items = {item['id']: item for item in asyncio.run(stt.stt_backends())['backends']}
item = items['nvidia_parakeet']
self.assertEqual(item['title'], 'NVIDIA Parakeet Router ASR')
self.assertEqual(item['engine_id'], 'nvidia-parakeet-asr')
self.assertFalse(item['available'])
self.assertFalse(item['connected'])
self.assertNotIn('http://native:8090', [call.args[0] for call in health.call_args_list])
with self.assertRaises(HTTPException) as caught:
stt._transcribe_audio(None, load.return_value, 'nvidia_parakeet')
self.assertEqual(caught.exception.status_code, 409)
@patch('routes.stt._stt_backend_health', return_value={'ok': True, 'models': ['discovered']})
@patch('routes.stt._load_settings')
def test_selected_alternate_native_uses_its_own_disconnect_state(self, load, health):
load.return_value = {
'nvidia_asr_url': 'http://rnnt:8092',
'engine_active': {'nvidia_asr_url': 'nvidia-parakeet-rnnt-nim'},
'engine_disabled': {'parakeet-stt-multi-9011': True},
}
items = {item['id']: item for item in asyncio.run(stt.stt_backends())['backends']}
self.assertTrue(items['nvidia_parakeet']['available'])
self.assertTrue(items['nvidia_parakeet']['connected'])
self.assertEqual(items['nvidia_parakeet']['engine_id'], 'nvidia-parakeet-rnnt-nim')
self.assertFalse(stt._stt_disconnected(load.return_value, 'nvidia_parakeet', 'http://rnnt:8092'))
load.return_value['engine_disabled']['nvidia-parakeet-rnnt-nim'] = True
self.assertTrue(stt._stt_disconnected(load.return_value, 'nvidia_parakeet', 'http://rnnt:8092'))
@patch('routes.stt._stt_backend_health', return_value={'ok': True, 'models': ['discovered']})
@patch('routes.stt._load_settings')
def test_card_titles_stable_identity_and_effective_endpoint(self, load, health):
load.return_value = {
'faster_whisper_url': 'http://native:9000/v1',
'engine_active': {'faster_whisper_url': 'stt-whisperx-blackwell'},
'custom_engine_cards': [
{'id': 'first', 'name': 'Renamed speech', 'url': 'http://stale', 'role': 'stt'},
{'id': 'second', 'label': 'Other speech', 'url': 'http://shared/v1', 'role': 'stt'},
{'id': 'off', 'name': 'Disconnected', 'url': 'http://off', 'role': 'stt'},
],
'engine_local_urls': {'first': 'http://old', 'dc-first': 'http://shared/v1'},
'engine_api_keys': {'first': 'first-test-key', 'second': 'second-test-key'},
'engine_models': {'first': 'first-model', 'second': 'second-model'},
'engine_disabled': {'off': True},
}
items = {item['id']: item for item in asyncio.run(stt.stt_backends())['backends']}
self.assertEqual(items['faster_whisper']['title'], 'WhisperX · faster-whisper (Blackwell GPU)')
self.assertEqual(items['custom:first']['title'], 'Renamed speech')
self.assertEqual(items['custom:second']['title'], 'Other speech')
self.assertEqual(items['custom:first']['url'], 'http://shared/v1')
self.assertEqual(stt._stt_backend_url(load.return_value, 'custom:first'), 'http://shared/v1')
self.assertEqual(items['custom:first']['model'], 'first-model')
self.assertEqual(items['custom:second']['model'], 'second-model')
self.assertTrue(items['custom:first']['available'])
health.assert_any_call('http://shared/v1', 'first-test-key')
health.assert_any_call('http://shared/v1', 'second-test-key')
self.assertFalse(items['custom:off']['available'])
self.assertFalse(items['custom:off']['connected'])
self.assertNotIn('http://off', [call.args[0] for call in health.call_args_list])
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,99 @@
"""TTS reachability probing must work for OpenAI-style card URLs that end in /v1."""
import os
import tempfile
import unittest
from unittest.mock import patch
os.environ.setdefault("CONFIG_DIR", tempfile.mkdtemp(prefix="tts-health-tests-"))
from routes import tts
class TtsHealthProbeTests(unittest.TestCase):
def test_v1_card_url_is_probed_from_the_server_root(self):
with patch("routes.tts.probe_provider", return_value={"ok": True}) as probe:
tts._backend_health_detail("http://gateway:4000/v1/", "key")
base, checks = probe.call_args.args[:2]
self.assertEqual(base, "http://gateway:4000")
self.assertIn(("/health/liveliness", None), checks)
self.assertEqual(probe.call_args.kwargs["headers"], {"Authorization": "Bearer key"})
def test_plain_host_url_is_unchanged(self):
with patch("routes.tts.probe_provider", return_value={"ok": False}) as probe:
tts._backend_health_detail("http://host:8005")
self.assertEqual(probe.call_args.args[0], "http://host:8005")
class PublishUnhiddenVoiceTests(unittest.TestCase):
def setUp(self):
from pathlib import Path
self.dir = Path(tempfile.mkdtemp(prefix="publish-voice-"))
self.voices = self.dir / "voices.json"
self.settings = {"voices_scan_dir": str(self.dir), "tts_config_dir": str(self.dir)}
self.audio = self.dir / "DE_F_New.wav"
self.audio.write_bytes(b"RIFF")
def run_publish(self):
return tts.publish_voice_clone_if_missing(self.audio, "DE", self.settings)
def test_unknown_voice_with_transcript_is_published(self):
import json
self.voices.write_text(json.dumps({"other": {"ref_text": "x"}}))
self.audio.with_suffix(".reference.txt").write_text("Hallo Welt")
with patch("routes.tts.time.sleep"):
self.assertTrue(self.run_publish())
entry = json.loads(self.voices.read_text())["DE_F_New"]
self.assertEqual((entry["ref_text"], entry["language"]), ("Hallo Welt", "German"))
def test_known_voice_keeps_its_cached_embedding(self):
import json
self.voices.write_text(json.dumps({"DE_F_New": {"ref_text": "a", "speaker_embeddings": "/x.pt"}}))
self.audio.with_suffix(".reference.txt").write_text("Hallo")
self.assertFalse(self.run_publish())
self.assertIn("speaker_embeddings", json.loads(self.voices.read_text())["DE_F_New"])
def test_no_transcript_or_no_voices_file_is_a_no_op(self):
self.assertFalse(self.run_publish()) # no voices.json
self.voices.write_text("{}")
self.assertFalse(self.run_publish()) # no transcript
self.assertEqual(self.voices.read_text(), "{}")
class GatewayUpstreamProbeTests(unittest.TestCase):
URL = "http://gateway:4000/v1"
def test_running_upstream_is_available(self):
with patch("routes.tts._gateway_upstream", return_value="http://host:8023/v1"), \
patch("routes.tts.probe_provider", return_value={"ok": True}) as probe:
self.assertTrue(tts._backend_health_detail(self.URL, "k", "m")["ok"])
self.assertEqual(probe.call_count, 1)
self.assertEqual(probe.call_args.args[0], "http://host:8023")
def test_stopped_upstream_is_not_rescued_by_a_live_gateway(self):
refused = {"ok": False, "error": "Connection refused"}
with patch("routes.tts._gateway_upstream", return_value="http://host:8020/v1"), \
patch("routes.tts.probe_provider", return_value=refused) as probe:
self.assertFalse(tts._backend_health_detail(self.URL, "k", "m")["ok"])
self.assertEqual(probe.call_count, 1) # the gateway's /health/liveliness is not consulted
def test_unroutable_upstream_falls_back_to_the_gateway(self):
results = [{"ok": False, "error": "Timeout (2 s)"}, {"ok": True, "endpoint": "/health/liveliness"}]
with patch("routes.tts._gateway_upstream", return_value="http://internal:8020/v1"), \
patch("routes.tts.probe_provider", side_effect=results) as probe:
self.assertTrue(tts._backend_health_detail(self.URL, "k", "m")["ok"])
self.assertEqual(probe.call_args_list[1].args[0], "http://gateway:4000")
def test_upstream_lookup_needs_key_and_model_and_reads_model_info(self):
tts._GATEWAY_UPSTREAMS.clear()
self.assertEqual(tts._gateway_upstream("http://gw", "", "m"), "")
info = {"data": [{"model_name": "m", "litellm_params": {"api_base": "http://h:1/v1"}}, {"model_name": "x"}]}
resp = type("R", (), {"status_code": 200, "json": lambda self: info})()
with patch("routes.tts.requests.get", return_value=resp) as get:
self.assertEqual(tts._gateway_upstream("http://gw", "k", "m"), "http://h:1/v1")
self.assertEqual(tts._gateway_upstream("http://gw", "k", "other"), "")
self.assertEqual(get.call_count, 1) # second lookup served from the cache
if __name__ == "__main__":
unittest.main()