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:
parent
b365894e44
commit
0551b6651f
@ -7,15 +7,24 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
## [Unreleased]
|
||||
### 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.
|
||||
- **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.
|
||||
### 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.
|
||||
- Conversation Playground: the round microphone button is red (pressed, "Stop microphone") while the mic is hot (live or recording) and blue ("Start microphone") when off; it returns to blue on stop, auto-stop and mic errors. The pulse ring only animates when reduced motion is not requested.
|
||||
- Conversation Playground: the metrics (Token Usage and STT / LLM first token / TTS first audio TTFB charts) now sit in the right-hand Latency column below Turn history instead of a second "Metrics" tab; the Conversation | Metrics tablist is removed. Charts gain axis titles ("Time (ms)", "Time"), a legend and three y-ticks.
|
||||
|
||||
### Fixed
|
||||
- **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).
|
||||
- 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.
|
||||
|
||||
@ -44,12 +44,12 @@ _SETTINGS_KEYS = {
|
||||
# Magazine layout is a vision task. Kept separate from the general text
|
||||
# LLM so page images never accidentally go to a text-only 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",
|
||||
"vision_ocr_fallback_url", "vision_ocr_fallback_model",
|
||||
# OPDS catalogues to search for magazine issues: [{"name": ..., "url": ...}], no credentials.
|
||||
"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)
|
||||
"image_gen_provider", "image_gen_model",
|
||||
"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:
|
||||
if str(value or "").startswith("custom:"):
|
||||
return str(value)
|
||||
key = re.sub(r"[^a-z0-9]+", "_", str(value or "voice_clone").lower()).strip("_")
|
||||
aliases = {
|
||||
"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:
|
||||
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)
|
||||
fallback = _strip_tts_extra_params(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
|
||||
|
||||
|
||||
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:
|
||||
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)
|
||||
if backend == "streaming":
|
||||
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"
|
||||
if not isinstance(s.get("engine_local_urls"), dict):
|
||||
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):
|
||||
s["engine_container_names"] = {}
|
||||
if not isinstance(s.get("engine_api_keys"), dict):
|
||||
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):
|
||||
s["custom_engine_cards"] = []
|
||||
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_model": _VISION_OCR_FALLBACK_MODEL_DEFAULT,
|
||||
"engine_local_urls": {},
|
||||
"engine_disabled": {},
|
||||
"engine_active": {},
|
||||
"engine_container_names": {},
|
||||
"engine_api_keys": {},
|
||||
"engine_models": {},
|
||||
"stt_models": {},
|
||||
"tts_models": {},
|
||||
"vision_ocr_api_key": "",
|
||||
"vision_ocr_fallback_api_key": "",
|
||||
"custom_engine_cards": [],
|
||||
"magazine_opds_catalogs": [],
|
||||
"image_gen_provider": "",
|
||||
|
||||
@ -1269,6 +1269,24 @@ def glued_repetition_share(text: str) -> float:
|
||||
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,
|
||||
timeout: float = 150.0) -> list[dict[str, Any]] | None:
|
||||
"""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:
|
||||
resp = request_with_retries(
|
||||
"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()
|
||||
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:
|
||||
resp = request_with_retries(
|
||||
"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()
|
||||
return resp.json()["choices"][0]["message"]["content"]
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Iterable
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import requests
|
||||
|
||||
@ -45,3 +46,140 @@ def probe_provider(
|
||||
except Exception as exc:
|
||||
last_error = str(exc)
|
||||
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}}
|
||||
|
||||
@ -39,6 +39,8 @@ def _clean_route_sound(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("_")
|
||||
aliases = {
|
||||
"clone": "voice_clone",
|
||||
|
||||
@ -7,11 +7,14 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from core.provider_health import provider_key_for_url, provider_request_params
|
||||
from pydub import AudioSegment
|
||||
|
||||
from core.config import (
|
||||
_clean_preview_backend,
|
||||
_preview_backend_base_url,
|
||||
_tts_api_key,
|
||||
_tts_extra_params,
|
||||
_apply_tts_extra_params,
|
||||
_post_tts_with_fallback,
|
||||
@ -119,6 +122,26 @@ def _audio_duration_from_bytes(audio: bytes, media_type: str) -> float | None:
|
||||
|
||||
# ── 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(
|
||||
text: str,
|
||||
voice: str,
|
||||
@ -133,17 +156,35 @@ def _tts_request_config(
|
||||
from core.constants import _TTS_DEFAULT
|
||||
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")
|
||||
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 {}
|
||||
|
||||
if backend == "localai":
|
||||
endpoint, payload = f"{tts_url}/tts", {"input": text, "model": voice, "response_format": response_format}
|
||||
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:
|
||||
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():
|
||||
payload["instruct"] = instruct.strip()
|
||||
# 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)
|
||||
if _eff_backend in ("voice_clone", "streaming", "customvoice"):
|
||||
_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(
|
||||
@ -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
|
||||
tts_url = _validate_http_url(stream_url, allow_private=True).rstrip("/")
|
||||
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 {}
|
||||
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():
|
||||
payload["instruct"] = instruct.strip()
|
||||
_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:
|
||||
@ -265,9 +310,10 @@ def _voice_design_request_audio(
|
||||
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_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 {}
|
||||
payload = {
|
||||
"model": _VOICE_DESIGN_MODEL,
|
||||
"model": _tts_selected_model(settings, "voice_design_url", _VOICE_DESIGN_MODEL),
|
||||
"input": text,
|
||||
"instruct": _apply_voice_design_gender(instruct, gender),
|
||||
"language": language,
|
||||
@ -293,9 +339,10 @@ def _voice_design_voice_request_audio(
|
||||
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_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 {}
|
||||
payload = {
|
||||
"model": _VOICE_DESIGN_MODEL,
|
||||
"model": _tts_selected_model(settings, "voice_design_url", _VOICE_DESIGN_MODEL),
|
||||
"input": text,
|
||||
"voice": voice,
|
||||
"response_format": "wav",
|
||||
@ -341,7 +388,7 @@ def _nvidia_clone_request_audio(
|
||||
mode = "flow" if str(mode).lower().endswith("flow") else "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("/")
|
||||
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 {}
|
||||
scan_dir = Path(settings.get("voices_scan_dir", _VOICES_DIR_DEFAULT))
|
||||
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
|
||||
# was the one exception.
|
||||
_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()
|
||||
audio = resp.content
|
||||
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]:
|
||||
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()
|
||||
audio = resp.content
|
||||
if not audio:
|
||||
@ -525,6 +576,8 @@ def _preview_request_audio(
|
||||
backend: str = "voice_clone",
|
||||
) -> tuple[bytes, str]:
|
||||
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":
|
||||
resp = _open_tts_stream_response(text, voice, settings, instruct)
|
||||
audio, media_type = _read_tts_stream_response(resp)
|
||||
@ -535,7 +588,7 @@ def _preview_request_audio(
|
||||
return _tts_request_audio(
|
||||
text, voice, settings, instruct,
|
||||
_preview_backend_base_url(settings, "customvoice"),
|
||||
settings.get("tts_api_key", ""),
|
||||
None,
|
||||
"openai",
|
||||
"customvoice",
|
||||
)
|
||||
@ -554,7 +607,7 @@ def _preview_request_audio(
|
||||
return _tts_request_audio(
|
||||
text, voice, settings, instruct,
|
||||
_preview_backend_base_url(settings, "nvidia_magpie"),
|
||||
settings.get("tts_api_key", ""),
|
||||
None,
|
||||
"nvidia_magpie",
|
||||
"nvidia_magpie",
|
||||
)
|
||||
@ -566,7 +619,7 @@ def _preview_request_audio(
|
||||
return _tts_request_audio(
|
||||
text, voice, settings, instruct,
|
||||
url_override=_preview_backend_base_url(settings, "kokoro"),
|
||||
api_key_override=settings.get("tts_api_key", ""),
|
||||
api_key_override=None,
|
||||
backend_override="openai",
|
||||
extra_backend="kokoro",
|
||||
)
|
||||
@ -578,7 +631,7 @@ def _preview_request_audio(
|
||||
return _tts_request_audio(
|
||||
text, voice, settings, instruct,
|
||||
url_override=_preview_backend_base_url(settings, "xtts"),
|
||||
api_key_override=settings.get("tts_api_key", ""),
|
||||
api_key_override=None,
|
||||
backend_override="openai",
|
||||
extra_backend="xtts",
|
||||
)
|
||||
|
||||
@ -398,9 +398,13 @@ def _usage_from_chunk(obj: dict) -> dict | None:
|
||||
return out or None
|
||||
|
||||
|
||||
from core.provider_health import provider_request_params
|
||||
|
||||
|
||||
def _post_llm_stream(url: str, payload: dict, headers: dict):
|
||||
"""POST a streaming chat request; on 400/422 retry once without the optional keys
|
||||
(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)
|
||||
optional = [k for k in ("chat_template_kwargs", "think", "stream_options") if k in payload]
|
||||
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:
|
||||
payload["model"] = model
|
||||
payload.update(_thinking_off_params(settings, llm_url))
|
||||
payload = provider_request_params(llm_url, payload, settings, "llm")
|
||||
url = f"{llm_url.rstrip('/')}/chat/completions"
|
||||
hdrs = {"Authorization": f"Bearer {settings.get('llm_api_key') or 'sk-dummy-key'}"}
|
||||
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):
|
||||
payload = provider_request_params(llm_url, payload, role="llm")
|
||||
last_resp = None
|
||||
last_exc = None
|
||||
for url in _llm_chat_completion_urls(llm_url):
|
||||
@ -745,7 +751,7 @@ async def refine_text(request: Request):
|
||||
resp = await asyncio.to_thread(
|
||||
requests.post,
|
||||
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'}"},
|
||||
timeout=60,
|
||||
)
|
||||
@ -807,7 +813,7 @@ async def rewrite_with_persona(request: Request):
|
||||
resp = await asyncio.to_thread(
|
||||
requests.post,
|
||||
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'}"},
|
||||
timeout=600,
|
||||
)
|
||||
@ -897,7 +903,7 @@ async def analyze_characters(request: Request):
|
||||
payload["model"] = model
|
||||
resp = requests.post(
|
||||
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'}"},
|
||||
timeout=600,
|
||||
)
|
||||
@ -1045,7 +1051,7 @@ async def match_characters_voices(request: Request):
|
||||
try:
|
||||
resp = await asyncio.to_thread(
|
||||
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,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@ -1571,7 +1577,7 @@ async def character_sheets_stream(request: Request):
|
||||
for u in urls:
|
||||
try:
|
||||
upstream = requests.post(
|
||||
u, json=payload, headers=headers,
|
||||
u, json=provider_request_params(u, payload), headers=headers,
|
||||
timeout=(15, prep["timeout_seconds"]), stream=True,
|
||||
)
|
||||
if upstream.status_code in (404, 405) and u != urls[-1]:
|
||||
@ -1696,7 +1702,7 @@ async def character_deep_analysis(request: Request):
|
||||
try:
|
||||
resp = await asyncio.to_thread(
|
||||
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,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@ -3607,7 +3613,7 @@ async def character_generate_prompts(request: Request):
|
||||
payload["model"] = model
|
||||
try:
|
||||
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,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@ -3806,7 +3812,7 @@ async def audiobook_consistency_check(request: Request):
|
||||
|
||||
def _call() -> dict:
|
||||
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,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@ -5384,7 +5390,7 @@ async def attribute_dialogue_stream(request: Request):
|
||||
for u in urls:
|
||||
try:
|
||||
upstream = requests.post(
|
||||
u, json=payload, headers=headers,
|
||||
u, json=provider_request_params(u, payload), headers=headers,
|
||||
timeout=(15, prep["timeout_seconds"]), stream=True,
|
||||
)
|
||||
if upstream.status_code in (404, 405) and u != urls[-1]:
|
||||
@ -5598,6 +5604,7 @@ async def audio_effects(request: Request):
|
||||
_EXPORT_SKIP_KEYS = {
|
||||
"groq_api_key", "whisper_api_key", "tts_api_key", "voice_design_api_key",
|
||||
"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"])
|
||||
|
||||
@ -6058,26 +6065,7 @@ async def mcp_sse(request: Request):
|
||||
|
||||
# ── Conversation playground ───────────────────────────────────────────────────
|
||||
|
||||
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 []
|
||||
from core.provider_health import _litellm_model_aliases
|
||||
|
||||
|
||||
@router.get("/api/conversation/llm-models")
|
||||
@ -6089,6 +6077,9 @@ async def conversation_llm_models(
|
||||
from core.validation import _validate_http_url
|
||||
settings = _load_settings()
|
||||
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"
|
||||
try:
|
||||
base = _validate_http_url(base, allow_private=True)
|
||||
|
||||
174
routes/docker.py
174
routes/docker.py
@ -4,15 +4,15 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, urlsplit
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
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.provider_health import probe_provider
|
||||
from core.provider_health import probe_provider, _litellm_model_aliases
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@ -123,6 +123,71 @@ async def restart_local_container(name: str):
|
||||
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")
|
||||
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."""
|
||||
@ -132,30 +197,8 @@ async def probe_url(url: str, type: str = "", name: str = "", provider_api_key:
|
||||
base = base[: -len(_suffix)]
|
||||
break
|
||||
settings = _load_settings()
|
||||
# Key precedence:
|
||||
# 1. A key supplied explicitly by the browser for this probe (e.g. the
|
||||
# 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}"}
|
||||
key_to_use = _engine_api_key(settings, name, type, provider_api_key, base)
|
||||
hdrs = {"User-Agent": "TTS-Voice-Creator/probe", **_engine_headers(key_to_use, type)}
|
||||
|
||||
if type == "anthropic":
|
||||
checks = [("/v1/models", "data")]
|
||||
@ -164,7 +207,7 @@ async def probe_url(url: str, type: str = "", name: str = "", provider_api_key:
|
||||
elif type == "stt":
|
||||
checks = [("/health", None), ("/v1/models", "data"), ("/v1/audio/transcriptions", None)]
|
||||
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":
|
||||
checks = [("/health", None), ("/v1/models", "data"), ("/v1/chat/completions", None), ("/parse", None), ("/ocr", None)]
|
||||
elif type == "comfyui":
|
||||
@ -172,7 +215,7 @@ async def probe_url(url: str, type: str = "", name: str = "", provider_api_key:
|
||||
else:
|
||||
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'}
|
||||
|
||||
|
||||
@ -193,12 +236,15 @@ async def use_as_llm(name: str, request: Request, provider_api_key: str = Header
|
||||
raise HTTPException(400, "URL is required")
|
||||
|
||||
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 ""
|
||||
|
||||
s["llm_url"] = url
|
||||
if key_to_use:
|
||||
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 provider_api_key:
|
||||
# Keep this card's own saved key in sync with what was just typed.
|
||||
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)}
|
||||
|
||||
|
||||
@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")
|
||||
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."""
|
||||
base = _normalize_service_url(url).rstrip("/")
|
||||
if not base:
|
||||
raise HTTPException(400, "URL is required")
|
||||
candidates: list[str]
|
||||
candidates: list[str] = []
|
||||
if base.endswith("/v1"):
|
||||
candidates = [base + "/models", base[:-3].rstrip("/") + "/v1/models"]
|
||||
candidates = [base + "/models", base[:-3].rstrip("/") + "/models"]
|
||||
else:
|
||||
candidates = [base.rstrip("/") + "/v1/models", base.rstrip("/") + "/models"]
|
||||
if type == "ollama":
|
||||
@ -226,10 +325,10 @@ async def engine_models(url: str, type: str = "", provider_api_key: str = Header
|
||||
seen: set[str] = set()
|
||||
models: list[str] = []
|
||||
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:
|
||||
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:
|
||||
last_err = f"HTTP {r.status_code} from {endpoint}"
|
||||
continue
|
||||
@ -253,6 +352,9 @@ async def engine_models(url: str, type: str = "", provider_api_key: str = Header
|
||||
seen.add(mid)
|
||||
models.append(mid)
|
||||
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}
|
||||
except requests.exceptions.Timeout:
|
||||
last_err = "Timeout fetching models"
|
||||
|
||||
@ -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))
|
||||
meta = _load_meta(wav)
|
||||
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",
|
||||
"name", "tag", "source", "avatar", "voice_design_prompt"):
|
||||
|
||||
@ -14,6 +14,7 @@ router = APIRouter()
|
||||
_SECRET_SETTING_KEYS = {
|
||||
"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",
|
||||
"vision_ocr_api_key", "vision_ocr_fallback_api_key",
|
||||
}
|
||||
_SECRET_MASK = "__configured__"
|
||||
|
||||
@ -55,7 +56,21 @@ async def regenerate_api_key(response: Response):
|
||||
@router.post("/api/settings")
|
||||
async def post_settings(request: Request):
|
||||
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()
|
||||
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()
|
||||
for key, value in data.items():
|
||||
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:
|
||||
continue
|
||||
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)
|
||||
_save_settings(s)
|
||||
return {"ok": True}
|
||||
|
||||
121
routes/stt.py
121
routes/stt.py
@ -21,7 +21,7 @@ from core.constants import (
|
||||
)
|
||||
from core.registry import _registry_get, TEMP_DIR
|
||||
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.database import background_job_create, background_job_get, background_job_update
|
||||
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:"):]
|
||||
for card in settings.get("custom_engine_cards", []):
|
||||
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 ""
|
||||
if backend == "nvidia_parakeet":
|
||||
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)
|
||||
|
||||
|
||||
def _stt_backend_model(backend: str) -> str:
|
||||
def _stt_backend_model(backend: str, settings: dict | None = None) -> str:
|
||||
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"}:
|
||||
return "whisper-1"
|
||||
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:
|
||||
if backend == "groq_whisper":
|
||||
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:
|
||||
@ -244,7 +273,7 @@ def _make_minimal_wav(duration_ms: int = 500, sample_rate: int = 16000) -> bytes
|
||||
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("/")
|
||||
# Users commonly paste an OpenAI-style `/v1` base URL. WhisperX exposes
|
||||
# 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.
|
||||
api_base = base[:-3].rstrip("/") if base.endswith("/v1") else base
|
||||
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(
|
||||
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'])
|
||||
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]:
|
||||
backend = _clean_stt_backend(backend)
|
||||
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)
|
||||
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`.
|
||||
# WhisperX's native fallback remains rooted at `/transcribe`.
|
||||
if stt_url.endswith("/v1"):
|
||||
@ -315,7 +347,7 @@ def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") ->
|
||||
resp = requests.post(
|
||||
endpoint,
|
||||
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,
|
||||
timeout=_STT_REQUEST_TIMEOUT,
|
||||
)
|
||||
@ -328,7 +360,7 @@ def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") ->
|
||||
resp = requests.post(
|
||||
f"{stt_url}{path}",
|
||||
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,
|
||||
timeout=60,
|
||||
)
|
||||
@ -487,6 +519,43 @@ async def _default_stt_benchmark_engines() -> list[dict]:
|
||||
|
||||
# ── 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")
|
||||
async def stt_backends():
|
||||
settings = _load_settings()
|
||||
@ -501,36 +570,47 @@ async def stt_backends():
|
||||
continue
|
||||
seen_urls.add(key)
|
||||
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)
|
||||
health_detail = {"ok": ok}
|
||||
models: list[str] = ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"]
|
||||
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", [])
|
||||
items.append({
|
||||
"id": backend,
|
||||
"engine_id": _stt_backend_engine_id(settings, backend, url),
|
||||
"label": _stt_backend_label(backend, url),
|
||||
"title": _stt_backend_title(settings, backend, url),
|
||||
"url": url,
|
||||
"port": _backend_port_label(url),
|
||||
"available": ok,
|
||||
"connected": not disconnected,
|
||||
"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},
|
||||
"model": _stt_backend_model(backend),
|
||||
"model": _stt_backend_model(backend, settings),
|
||||
"models": models,
|
||||
"metrics": _STT_BACKEND_METRICS.get(backend, {}),
|
||||
})
|
||||
for card in settings.get("custom_engine_cards", []):
|
||||
if card.get("role") not in ("stt", "stt+tts"):
|
||||
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", "")))
|
||||
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:
|
||||
continue
|
||||
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", [])
|
||||
port = _backend_port_label(url)
|
||||
label = card.get("label") or card.get("name") or "Custom STT"
|
||||
@ -538,13 +618,16 @@ async def stt_backends():
|
||||
label = f"{port} {label}"
|
||||
items.append({
|
||||
"id": card_id,
|
||||
"engine_id": card_id[7:],
|
||||
"label": label,
|
||||
"title": card.get("label") or card.get("name") or "Custom STT",
|
||||
"url": url,
|
||||
"port": port,
|
||||
"available": ok,
|
||||
"connected": not disconnected,
|
||||
"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},
|
||||
"model": "whisper-1",
|
||||
"model": _stt_backend_model(card_id, settings),
|
||||
"models": models,
|
||||
"metrics": {},
|
||||
})
|
||||
|
||||
192
routes/tts.py
192
routes/tts.py
@ -21,7 +21,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
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 (
|
||||
_VOICES_DIR_DEFAULT, _TTS_CONTAINER, _TTS_CONTAINERS_RAW,
|
||||
_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.validation import _validate_http_url
|
||||
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.tts_helpers import (
|
||||
_tts_request_audio, _open_tts_stream_response, _read_tts_stream_response,
|
||||
@ -214,7 +214,7 @@ def _backend_port_label(url: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _backend_display_name(backend: str, url: str) -> str:
|
||||
def _backend_display_name(backend: str, url: str, settings: dict = None) -> str:
|
||||
names = {
|
||||
"voice_clone": "Voice Clone/Base (WAV File)",
|
||||
"voice_design": "Voice Design",
|
||||
@ -228,11 +228,42 @@ def _backend_display_name(backend: str, url: str) -> str:
|
||||
"xtts": "XTTS v2",
|
||||
"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:
|
||||
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 = {
|
||||
"voice_clone": {
|
||||
"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), {})
|
||||
|
||||
|
||||
def _backend_health_detail(url: str) -> dict:
|
||||
return probe_provider(url, [("/health", None), ("/v1/health", None), ("/v1/audio/list_voices", None)], timeout=2)
|
||||
_TTS_BACKEND_CARDS = {
|
||||
"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:
|
||||
@ -416,6 +514,28 @@ def sync_voice_clone_reference(audio: Path, transcript: str, language: str, sett
|
||||
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:
|
||||
cfg = dict(voices.get(voice) or {})
|
||||
if cfg.get("ref_audio"):
|
||||
@ -573,6 +693,10 @@ def _fetch_backend_voices(settings: dict, backend: str) -> list:
|
||||
return _active_library_voice_options(settings)
|
||||
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 ""
|
||||
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 {}
|
||||
for ep in _TTS_VOICE_ENDPOINTS:
|
||||
try:
|
||||
@ -643,16 +767,22 @@ async def tts_backends():
|
||||
items = []
|
||||
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("/")
|
||||
voices = _fetch_backend_voices(settings, backend)
|
||||
health_detail = _backend_health_detail(url)
|
||||
disabled = (settings.get("engine_disabled") or {}).get(_TTS_BACKEND_CARDS[backend]) is True
|
||||
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'))
|
||||
available = _backend_available(backend, voices, health)
|
||||
items.append({
|
||||
"id": backend,
|
||||
"label": _backend_display_name(backend, url),
|
||||
"label": _backend_display_name(backend, url, settings),
|
||||
"title": _backend_card_title(backend),
|
||||
"url": url,
|
||||
"port": _backend_port_label(url),
|
||||
"available": available,
|
||||
"disabled": disabled,
|
||||
"configured": _backend_configured(settings, backend, voices),
|
||||
"health": {
|
||||
"ok": health,
|
||||
@ -663,6 +793,42 @@ async def tts_backends():
|
||||
"voice_count": len(voices) if isinstance(voices, list) else 0,
|
||||
**_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}
|
||||
|
||||
|
||||
@ -1214,7 +1380,7 @@ async def openai_speech_proxy(request: Request):
|
||||
audio, media_type = await _bounded_tts_call(
|
||||
_tts_request_audio, text, voice, settings, style_instruction,
|
||||
_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"}:
|
||||
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("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":
|
||||
audio, media_type = await _bounded_tts_call(
|
||||
_fishspeech_request_audio, text, voice, settings,
|
||||
|
||||
@ -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), {});
|
||||
|
||||
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 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.
|
||||
export function groupIssues(items, size = MAX_LOCATIONS) {
|
||||
export function groupIssues(items, size = MAX_LOCATIONS, severityFirst = false) {
|
||||
const byRule = new Map();
|
||||
for (const i of items) (byRule.get(i.rule) || byRule.set(i.rule, []).get(i.rule)).push(i);
|
||||
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 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))];
|
||||
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;
|
||||
}
|
||||
|
||||
@ -149,10 +160,11 @@ export function postWebhook(url, payload) {
|
||||
|
||||
function ghIssues(groups, apply, cap, log, lane = 'fix', noHook = false) {
|
||||
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']);
|
||||
// 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']);
|
||||
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 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}).`);
|
||||
@ -161,7 +173,7 @@ function ghIssues(groups, apply, cap, log, lane = 'fix', noHook = false) {
|
||||
for (const g of todo.slice(0, cap)) {
|
||||
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));
|
||||
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();
|
||||
log(` created ${url}`);
|
||||
// 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 hook = deps.webhookUrl ? deps.webhookUrl() : webhookUrl(process.env, undefined, lane);
|
||||
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 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 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) {
|
||||
const base = (view(it.number).comments || []).length;
|
||||
view(it.number); // Fail before delivery if GitHub cannot be read.
|
||||
let sent = 0, answered = false;
|
||||
while (!answered && sent <= resends) {
|
||||
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) {
|
||||
await sleep(pollSec * 1000);
|
||||
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`);
|
||||
}
|
||||
@ -247,6 +259,7 @@ export async function main(argv, deps = {}) {
|
||||
else if (f === '--no-webhook') a.noHook = true;
|
||||
else if (f === '--timeout') a.timeout = +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 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 {}; }
|
||||
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();
|
||||
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 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)');
|
||||
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);
|
||||
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}`);
|
||||
for (const c of ['small', 'medium', 'large', 'skipped']) {
|
||||
log(`\n${c.toUpperCase()} (${res[c].length})`);
|
||||
@ -270,13 +286,13 @@ export async function main(argv, deps = {}) {
|
||||
if (deps.noActions) return res;
|
||||
const lane = a.lane || 'fix';
|
||||
// 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'
|
||||
? 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;
|
||||
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).'); }
|
||||
return res;
|
||||
}
|
||||
|
||||
13
static/dist/main.min.js
vendored
13
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -70,20 +70,12 @@
|
||||
card?.classList.add('llm-local-card-online');
|
||||
}
|
||||
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 apiKey = apiKeyInp?.value.trim() || '';
|
||||
if (!apiKey) { toast('Enter an API key first', 'error'); return; }
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Connecting…';
|
||||
try {
|
||||
const d = await probeUrl(endpoint, probeType, apiKey);
|
||||
const d = await probeUrl(endpoint, probeType, apiKey, key);
|
||||
if (d.ok) {
|
||||
btn.innerHTML = '<span class="mdi mdi-check-network"></span> Connected';
|
||||
btn.className = 'llm-local-ping llm-cloud-connect-btn ok';
|
||||
@ -114,7 +106,6 @@
|
||||
const card = btn.closest('.llm-card');
|
||||
const apiKeyInp = card?.querySelector(`.llm-input[data-llm-key="${CSS.escape(key)}"]`);
|
||||
const apiKey = apiKeyInp?.value.trim() || '';
|
||||
if (!apiKey) { toast('Enter an API key first', 'error'); return; }
|
||||
if (btn.dataset.llmIncompatible && typeof confirmDialog === 'function') {
|
||||
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?",
|
||||
@ -122,13 +113,20 @@
|
||||
);
|
||||
if (!proceed) return;
|
||||
}
|
||||
const patch = { llm_url: url, llm_api_key: apiKey };
|
||||
if (model) patch.llm_model = model;
|
||||
await applyAndSaveSettings(patch);
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (apiKey) headers['X-Provider-API-Key'] = apiKey;
|
||||
const response = await fetch(`/api/local-containers/${encodeURIComponent(key)}/use-as-llm`, {
|
||||
method: 'POST', headers, body: JSON.stringify({ url, model: card?.querySelector('.cloud-model-select')?.value || model }),
|
||||
});
|
||||
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 apiKey = typeof window.engineApiKeyValue === 'function'
|
||||
? 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);
|
||||
if (d.ok) {
|
||||
toast(`✓ ${type.toUpperCase() || 'Service'} reachable — ${d.endpoint}`, 'success');
|
||||
@ -230,6 +228,109 @@
|
||||
window.applyAndSaveSettings = applyAndSaveSettings;
|
||||
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', () => {
|
||||
const url = document.querySelector('[data-llm-local-key="faster-whisper"]')?.value.trim()
|
||||
|| '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) ───────────────────────────────
|
||||
(function initActiveLlmPanel() {
|
||||
upgradeModelSelect('llm-active-model');
|
||||
@ -329,12 +438,13 @@
|
||||
async function fetchModels(url, apiKey) {
|
||||
url = (url || urlInp.value.trim());
|
||||
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) || '';
|
||||
setStatus('Loading models…');
|
||||
if (refreshBtn) refreshBtn.disabled = true;
|
||||
try {
|
||||
const fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
|
||||
const d = await fetch(fetchUrl).then(r => r.json());
|
||||
const d = await fetchEngineModels(url, 'llm', apiKey);
|
||||
if (activeEngineDisconnected('llm_url', url, modelSel, setStatus)) return;
|
||||
if (d.error) {
|
||||
let msg = 'Failed';
|
||||
try {
|
||||
@ -468,11 +578,13 @@
|
||||
async function fetchModels(url) {
|
||||
url = (url || urlInp.value.trim());
|
||||
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) || '';
|
||||
setStatus('Loading models…');
|
||||
if (refreshBtn) refreshBtn.disabled = true;
|
||||
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) {
|
||||
let msg = d.error;
|
||||
try { const j = JSON.parse(d.error); msg = j.error?.message || j.detail || d.error; } catch (_) {}
|
||||
|
||||
@ -335,17 +335,17 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
|
||||
// ── Populate STT backends ────────────────────────────────────────────────
|
||||
async function loadConvSttBackends() {
|
||||
if (!sttSel) return;
|
||||
const prev = sttSel.value;
|
||||
const prev = _appSettings?.stt_preferred_backend || sttSel.value;
|
||||
try {
|
||||
const d = await fetch('/api/stt-backends').then(r => r.json());
|
||||
const all = d.backends || [];
|
||||
const all = sttBackendChoices(d.backends || [], prev);
|
||||
if (!all.length) {
|
||||
sttSel.innerHTML = '<option value="configured">Default (configured)</option>';
|
||||
return;
|
||||
}
|
||||
sttSel.innerHTML = all.map(b => {
|
||||
const icon = b.available ? '✓' : '✗';
|
||||
const label = `${icon} ${escHtml(b.label)}`;
|
||||
const label = `${icon} ${escHtml(b.title || b.label || b.id)}`;
|
||||
const disabled = !b.available;
|
||||
return `<option value="${escHtml(b.id)}"${disabled ? ' disabled' : ''}>${label}</option>`;
|
||||
}).join('');
|
||||
|
||||
@ -253,6 +253,7 @@ function loadCustomEngineCards() {
|
||||
return merged;
|
||||
}
|
||||
function saveCustomEngineCards(cards) {
|
||||
cards = cards.map(({ apiKey, api_key, ...card }) => card);
|
||||
localStorage.setItem('engines-custom-cards', JSON.stringify(cards));
|
||||
if (_appSettings) _appSettings.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' },
|
||||
};
|
||||
|
||||
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) {
|
||||
if ($('aed-save')?.disabled) return;
|
||||
const dlg = document.getElementById('add-engine-dlg');
|
||||
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');
|
||||
if (titleEl) titleEl.textContent = ' Add Custom Engine';
|
||||
const saveBtn = document.getElementById('aed-save');
|
||||
if (saveBtn) saveBtn.textContent = 'Add Card';
|
||||
const roleEl = document.getElementById('aed-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 = '';
|
||||
});
|
||||
$('aed-api-key').placeholder = 'API key (optional)';
|
||||
$('aed-models').innerHTML = '';
|
||||
$('aed-status').textContent = '';
|
||||
$('aed-params').value = '{}';
|
||||
$('aed-active').checked = false;
|
||||
dlg.showModal();
|
||||
};
|
||||
|
||||
function editCustomEngineCard(card) {
|
||||
if ($('aed-save')?.disabled) return;
|
||||
const dlg = document.getElementById('add-engine-dlg');
|
||||
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.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');
|
||||
if (titleEl) titleEl.textContent = ' Edit Engine';
|
||||
const saveBtn = document.getElementById('aed-save');
|
||||
@ -316,18 +398,76 @@ function editCustomEngineCard(card) {
|
||||
if (roleEl) roleEl.value = card.role || 'tts';
|
||||
const setVal = (id, val) => { const el = document.getElementById(id); if (el) el.value = val || ''; };
|
||||
setVal('aed-name', card.label || card.name);
|
||||
setVal('aed-url', card.url);
|
||||
setVal('aed-container', card.containerName);
|
||||
setVal('aed-url', cardElement.querySelector('.dc-url-inp')?.value || card.url);
|
||||
setVal('aed-container', cardElement.querySelector('.dc-container-name-inp')?.value ?? card.containerName);
|
||||
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() {
|
||||
const dlg = document.getElementById('add-engine-dlg');
|
||||
if (!dlg) return;
|
||||
document.getElementById('aed-cancel')?.addEventListener('click', () => { delete dlg.dataset.editId; dlg.close(); });
|
||||
dlg.addEventListener('click', e => { if (e.target === dlg) { delete dlg.dataset.editId; dlg.close(); } });
|
||||
document.getElementById('aed-save')?.addEventListener('click', () => {
|
||||
function readParams() {
|
||||
let params;
|
||||
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 role = document.getElementById('aed-role')?.value;
|
||||
const url = document.getElementById('aed-url')?.value.trim();
|
||||
@ -335,27 +475,47 @@ function editCustomEngineCard(card) {
|
||||
const desc = document.getElementById('aed-desc')?.value.trim() || '';
|
||||
if (!name) { toast('Name 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;
|
||||
if (editId) {
|
||||
const cards = loadCustomEngineCards().map(c =>
|
||||
const id = dlg.dataset.cardId || editId || 'custom-' + Date.now();
|
||||
const cards = editId ? loadCustomEngineCards().map(c =>
|
||||
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
|
||||
);
|
||||
saveCustomEngineCards(cards);
|
||||
delete dlg.dataset.editId;
|
||||
dlg.close();
|
||||
) : [...loadCustomEngineCards(), { id, name, label: name, role, url, containerName, description: desc, params, isCustom: true }];
|
||||
const apiKey = $('aed-api-key').value.trim();
|
||||
const model = $('aed-model').value.trim();
|
||||
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();
|
||||
toast(`"${name}" updated`, 'success');
|
||||
} else {
|
||||
const card = { id: 'custom-' + Date.now(), name, label: name, role, url, containerName, description: desc, isCustom: true };
|
||||
const cards = loadCustomEngineCards();
|
||||
cards.push(card);
|
||||
saveCustomEngineCards(cards);
|
||||
dlg.close();
|
||||
loadLocalContainers();
|
||||
toast(`"${name}" added`, 'success');
|
||||
toast(`"${name}" ${editId ? 'updated' : 'added'}`, 'success');
|
||||
if (useActive) {
|
||||
try {
|
||||
await activateEngine(id, role, DC_ROLE_USE[role].settingKey, url, model, apiKey);
|
||||
toast(`"${name}" is active`, 'success');
|
||||
} catch (_) { toast(`"${name}" saved; activation failed. Use the card's activation button to retry.`, 'error'); }
|
||||
}
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
finally { $('aed-save').disabled = false; $('aed-cancel').disabled = false; }
|
||||
});
|
||||
})();
|
||||
|
||||
@ -400,6 +560,7 @@ async function fetchLocalContainersWithRetry() {
|
||||
}
|
||||
|
||||
async function loadLocalContainers() {
|
||||
if (_inlineEngineCard) return;
|
||||
const seq = ++_localContainersLoadSeq;
|
||||
const gridTts = $('dc-grid-tts');
|
||||
const gridStt = $('dc-grid-stt');
|
||||
@ -438,6 +599,8 @@ async function loadLocalContainers() {
|
||||
window.loadLocalContainers = loadLocalContainers;
|
||||
|
||||
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 gridStt = $('dc-grid-stt');
|
||||
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"
|
||||
placeholder="${escHtml(defaultUrl)}"
|
||||
data-dc-url-key="${n}"
|
||||
data-dc-legacy-name="${escHtml(c.legacyName || c.name)}"
|
||||
data-dc-url-default="${escHtml(defaultUrl)}">
|
||||
</div>`;
|
||||
|
||||
@ -541,27 +705,36 @@ function renderLocalContainers(containers) {
|
||||
data-cn-key="${n}">
|
||||
</div>`;
|
||||
|
||||
// API key row — only for LLMs
|
||||
// Authentication is optional for every supported engine role.
|
||||
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) : '') || '');
|
||||
const apiKeyRowHtml = (c.role === 'llm' || c.name === 'litellm') ? `
|
||||
const apiKeyRowHtml = `
|
||||
<div class="llm-local-url">
|
||||
<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"
|
||||
placeholder="API key (optional)" spellcheck="false"
|
||||
value="${escHtml(savedApiKey)}"
|
||||
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
|
||||
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
|
||||
? `<button class="llm-use-btn dc-use-btn"
|
||||
data-dc-use-key="${escHtml(useEntry.settingKey)}"
|
||||
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.">
|
||||
<span class="mdi mdi-arrow-right-circle-outline"></span> ${escHtml(useEntry.label)}
|
||||
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(useLabel)}
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
@ -586,6 +759,7 @@ function renderLocalContainers(containers) {
|
||||
const controlsRowHtml = `
|
||||
<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-use-btn dc-disconnect-btn" data-dc-name="${n}" type="button" hidden>Disconnect</button>
|
||||
<div class="dc-ctrl-group">${dockerBtnsSpan}${githubBtn}${editBtn}${deleteBtn}</div>
|
||||
${useBtn}
|
||||
</div>`;
|
||||
@ -622,7 +796,7 @@ function renderLocalContainers(containers) {
|
||||
<span class="llm-local-compat ${stCls}">${escHtml(stLabel)}</span>
|
||||
</div>
|
||||
${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>`;
|
||||
}
|
||||
|
||||
@ -673,32 +847,33 @@ function renderLocalContainers(containers) {
|
||||
|
||||
// Helpers — set connected / disconnected visual state on a card
|
||||
function setConnected(btn, card, useBtn, connected) {
|
||||
const disconnectBtn = card?.querySelector('.dc-disconnect-btn');
|
||||
if (disconnectBtn) disconnectBtn.hidden = !connected;
|
||||
if (connected) {
|
||||
btn.innerHTML = '<span class="mdi mdi-check-network"></span> Connected';
|
||||
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 (useBtn) useBtn.classList.add('active');
|
||||
} else {
|
||||
btn.innerHTML = '<span class="mdi mdi-lan-connect"></span> Connect';
|
||||
btn.className = 'llm-local-ping dc-connect-btn';
|
||||
btn.title = 'Test connection to this service';
|
||||
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
|
||||
grid.querySelectorAll('.dc-url-inp[data-dc-url-key]').forEach(inp => {
|
||||
const key = inp.dataset.dcUrlKey;
|
||||
const fromSrv = _appSettings?.engine_local_urls?.['dc-' + key];
|
||||
const fromLS = localStorage.getItem('dc-url-' + key);
|
||||
const legacy = inp.dataset.dcLegacyName;
|
||||
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;
|
||||
inp.addEventListener('input', () => {
|
||||
localStorage.setItem('dc-url-' + key, inp.value);
|
||||
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 btn = card?.querySelector(`.dc-connect-btn[data-dc-url-key="${CSS.escape(key)}"]`);
|
||||
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
|
||||
grid.querySelectorAll('.dc-apikey-inp[data-dc-apikey-key]').forEach(inp => {
|
||||
const key = inp.dataset.dcApikeyKey;
|
||||
@ -725,15 +944,6 @@ function renderLocalContainers(containers) {
|
||||
// Connect / Disconnect — probe URL, auto-apply setting, show state
|
||||
grid.querySelectorAll('.dc-connect-btn[data-dc-url-key]').forEach(btn => {
|
||||
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 card = btn.closest('.llm-local-card');
|
||||
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 d = await window.probeUrl(rawUrl, type, apiKey, key);
|
||||
if (d.ok) {
|
||||
await setEngineConnection(key, true, type);
|
||||
setConnected(btn, card, useBtn, true);
|
||||
localStorage.setItem('dc-con-' + key, '1');
|
||||
// Auto-apply URL to settings so it appears in dropdowns immediately.
|
||||
// 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 {
|
||||
setConnected(btn, card, useBtn, false);
|
||||
localStorage.removeItem('dc-con-' + key);
|
||||
@ -789,32 +991,17 @@ function renderLocalContainers(containers) {
|
||||
const url = inp?.value.trim() || inp?.placeholder || '';
|
||||
const apiKey = apikeyInp?.value.trim() || '';
|
||||
if (!url) { toast('Enter a URL first', 'error'); return; }
|
||||
if (settingKey === 'llm_url') {
|
||||
// Goes through a dedicated endpoint (not applyAndSaveSettings) so the
|
||||
// backend can fall back to this card's own saved key — the browser
|
||||
// only has a real key in hand here if the user just typed one.
|
||||
|
||||
const role = cardType(card);
|
||||
const model = card?.querySelector('.dc-model-inp')?.value.trim() || '';
|
||||
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
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);
|
||||
await activateEngine(urlKey, role, settingKey, url, model, apiKey);
|
||||
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');
|
||||
} 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');
|
||||
toast(`✓ ${btn.textContent.trim()} — now available in the backend dropdown`, 'success');
|
||||
}
|
||||
} catch (e) { toast('Failed to set active engine: ' + e.message, 'error'); }
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -41,7 +41,7 @@ function ttsBackendOptions(selected = '') {
|
||||
const backends = availableTtsBackends();
|
||||
if (!backends.length) return '<option value="">No TTS backend available</option>';
|
||||
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) {
|
||||
@ -51,7 +51,7 @@ function styleBackendOptions(selected = 'customvoice', preferStyleAware = false)
|
||||
: preferStyleAware
|
||||
? (backends.find(b => b.style_aware)?.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) {
|
||||
@ -178,7 +178,8 @@ function updateBackendDependentTabs() {
|
||||
if (tooltip) tooltip.textContent = ok ? originalTooltip : `${originalTooltip}\n\n${disabledBackendTabMessage(tab)}`;
|
||||
});
|
||||
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)');
|
||||
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() {
|
||||
const s = await fetch('/api/settings').then(r => r.json());
|
||||
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' : ''; } };
|
||||
sv('s-whisper-url', s.whisper_url || '');
|
||||
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-faster-whisper-url', s.faster_whisper_url || '');
|
||||
sv('s-whisper-cpp-url', s.whisper_cpp_url || '');
|
||||
@ -748,7 +774,7 @@ async function loadSettings() {
|
||||
if (window.populateLangSelect) window.populateLangSelect();
|
||||
// Captures settings
|
||||
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 refModel = $('s-refine-model'); if (refModel) refModel.value = s.refine_model || '';
|
||||
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'},
|
||||
body: JSON.stringify({
|
||||
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,
|
||||
whisper_cpp_url: $('s-whisper-cpp-url').value,
|
||||
groq_api_key: $('s-groq-api-key').value,
|
||||
|
||||
@ -16,12 +16,13 @@ function sttTtsSelectedSttBackend() {
|
||||
}
|
||||
|
||||
function sttBackendOptionHtml(selected = 'configured') {
|
||||
if (!_sttBackends.length) return '<option value="configured">Configured Whisper/STT</option>';
|
||||
const preferred = _sttBackends.some(b => b.id === selected && b.available) ? selected : (_sttBackends.find(b => b.available)?.id || selected);
|
||||
return _sttBackends.map(b => {
|
||||
const choices = sttBackendChoices(_sttBackends, selected);
|
||||
if (!choices.length) return '<option value="configured">Configured Whisper/STT</option>';
|
||||
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 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('');
|
||||
}
|
||||
|
||||
@ -37,14 +38,14 @@ function updateSttBackendHelp() {
|
||||
async function refreshSttBackends(selected = '') {
|
||||
try {
|
||||
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 (_) {
|
||||
_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);
|
||||
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.disabled = !_sttBackends.some(b => b.available);
|
||||
});
|
||||
|
||||
@ -115,7 +115,7 @@ function statusActiveTts() {
|
||||
try {
|
||||
all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : [];
|
||||
} catch (_) {}
|
||||
const labels = all.map(b => b.label || b.id);
|
||||
const labels = all.map(b => b.title || b.label || b.id);
|
||||
return {
|
||||
kind: '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() {
|
||||
let all = [];
|
||||
try {
|
||||
all = Array.isArray(_sttBackends) ? _sttBackends : [];
|
||||
} catch (_) {}
|
||||
const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value
|
||||
|| (typeof _appSettings !== 'undefined' && _appSettings?.stt_preferred_backend) || '';
|
||||
const preferred = (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 chosen = (preferred && all.find(b => b.id === preferred)) || available[0] || all[0] || null;
|
||||
return {
|
||||
kind: 'stt',
|
||||
label: 'STT',
|
||||
ok: !!(chosen && chosen.available),
|
||||
value: chosen ? (chosen.label || chosen.model || chosen.id) : 'No STT backend',
|
||||
title: chosen ? `${chosen.label || chosen.id} · ${chosen.url || ''}` : 'No STT backend configured',
|
||||
value: chosen ? (chosen.title || chosen.label || chosen.id) : 'No STT backend',
|
||||
title: chosen ? `${chosen.title || chosen.label || chosen.id} · ${chosen.url || ''}` : 'No STT backend configured',
|
||||
};
|
||||
}
|
||||
|
||||
@ -149,6 +161,9 @@ function statusActiveStt() {
|
||||
function statusLlmTarget() {
|
||||
let settings = {};
|
||||
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 {
|
||||
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 || '',
|
||||
@ -157,14 +172,14 @@ function statusLlmTarget() {
|
||||
}
|
||||
|
||||
function statusActiveLlm() {
|
||||
const { url, model } = statusLlmTarget();
|
||||
const { url, model, disconnected } = statusLlmTarget();
|
||||
const same = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model;
|
||||
const ok = !!url && same && STATUS_LLM_CACHE.ok;
|
||||
return {
|
||||
kind: 'llm',
|
||||
label: 'LLM',
|
||||
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',
|
||||
};
|
||||
}
|
||||
@ -247,8 +262,8 @@ function _statusApplyToSelects(ids, value) {
|
||||
}
|
||||
|
||||
async function _statusOpenLlmFlyup(anchor) {
|
||||
const { url, model } = statusLlmTarget();
|
||||
if (!url) { _statusFlyupList(anchor, [], null, 'No LLM endpoint configured', 'llm'); return; }
|
||||
const { url, model, disconnected } = statusLlmTarget();
|
||||
if (!url) { _statusFlyupList(anchor, [], null, disconnected ? 'LLM disconnected — use Connect to restore' : 'No LLM endpoint configured', 'llm'); return; }
|
||||
_statusFlyupList(anchor, [], null, 'Loading models…', 'llm');
|
||||
let models = [];
|
||||
try {
|
||||
@ -257,10 +272,13 @@ async function _statusOpenLlmFlyup(anchor) {
|
||||
models = Array.isArray(data.models) ? data.models : [];
|
||||
} catch (_) { logErr('status-llm-models', _); }
|
||||
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) => {
|
||||
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(() => {});
|
||||
_statusApplyToSelects(['reh-llm-model', 'conv-llm-model-select'], picked);
|
||||
_statusApplyToSelects(['llm-active-model', 'reh-llm-model', 'conv-llm-model-select'], picked);
|
||||
refreshStatusBarEngines({ force: true });
|
||||
toast('LLM model set to ' + picked, 'success');
|
||||
}, 'No models found at this endpoint', 'llm');
|
||||
@ -268,10 +286,10 @@ async function _statusOpenLlmFlyup(anchor) {
|
||||
|
||||
function _statusOpenSttFlyup(anchor) {
|
||||
const all = Array.isArray(_sttBackends) ? _sttBackends : [];
|
||||
const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value
|
||||
|| (typeof _appSettings !== 'undefined' && _appSettings?.stt_preferred_backend) || '';
|
||||
_statusFlyupList(anchor, all.filter(b => b.available).map(b => ({
|
||||
id: b.id, label: b.label || b.id, active: b.id === preferred,
|
||||
const preferred = (typeof _appSettings !== 'undefined' && _appSettings?.stt_preferred_backend)
|
||||
|| $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value || '';
|
||||
_statusFlyupList(anchor, sttBackendChoices(all, preferred).filter(b => b.available).map(b => ({
|
||||
id: b.id, label: b.title || b.label || b.id, active: b.id === preferred,
|
||||
})), (picked) => {
|
||||
// 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
|
||||
@ -282,6 +300,7 @@ function _statusOpenSttFlyup(anchor) {
|
||||
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(() => {});
|
||||
_statusApplyToSelects(['conv-stt-select', 'stt-tts-stt-backend', 'clone-stt-backend'], picked);
|
||||
window.syncPreferredSttBackend?.(_appSettings);
|
||||
updateStatusBar();
|
||||
toast('STT backend set to ' + picked, 'success');
|
||||
}, 'No reachable STT backend', 'stt');
|
||||
@ -289,13 +308,14 @@ function _statusOpenSttFlyup(anchor) {
|
||||
|
||||
function _statusOpenTtsFlyup(anchor) {
|
||||
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 => ({
|
||||
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) => {
|
||||
_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();
|
||||
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');
|
||||
}
|
||||
|
||||
@ -372,18 +392,49 @@ async function runPool(items, worker, concurrency, onProgress) {
|
||||
await Promise.all(Array.from({ length: Math.min(effectiveConcurrency, arr.length) }, run));
|
||||
}
|
||||
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) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch(e) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
let ok = false;
|
||||
try { document.execCommand('copy'); } catch (_) {}
|
||||
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) {
|
||||
if (sec == null || Number.isNaN(Number(sec))) return '-';
|
||||
const total = Math.max(0, Math.round(Number(sec)));
|
||||
@ -1432,8 +1483,8 @@ else initLangSelects(document);
|
||||
})();
|
||||
|
||||
Object.assign(root, {
|
||||
$, avatarColor, toast, confirmDialog, status, ensureStatusBar, updateStatusBar,
|
||||
escHtml, debounce, logErr, ttvcIsLowMemoryDevice, fmtDuration, loadingMarkup,
|
||||
$, avatarColor, toast, confirmDialog, status, ensureStatusBar, updateStatusBar, sttBackendChoices,
|
||||
escHtml, copyText, copyTextDialog, debounce, logErr, ttvcIsLowMemoryDevice, fmtDuration, loadingMarkup,
|
||||
setBusyButton, enhanceAccessibility, applyTheme, cc2flag, ccDisplay,
|
||||
setPreviewLang, openPreviewLangPicker, createSearchablePicker, getStoredTags,
|
||||
addStoredTag, scheduleVoiceLibraryLoad, disabledBackendTabMessage, switchTab,
|
||||
|
||||
@ -164,10 +164,13 @@ function toggleSortDir() {
|
||||
}
|
||||
|
||||
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');
|
||||
if (el.dataset.sort === _sortField)
|
||||
el.removeAttribute('aria-sort');
|
||||
if (el.dataset.sort === _sortField) {
|
||||
el.classList.add(_sortDir === 1 ? 'sort-asc' : 'sort-desc');
|
||||
el.setAttribute('aria-sort', _sortDir === 1 ? 'ascending' : 'descending');
|
||||
}
|
||||
});
|
||||
const sel = document.getElementById('voice-sort-field');
|
||||
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 () => {
|
||||
const useSelected = _bulkSelected && _bulkSelected.size > 0;
|
||||
const ids = useSelected ? [..._bulkSelected] : activeVoiceIds();
|
||||
const label = useSelected ? `${ids.length} selected` : `${ids.length} active`;
|
||||
if (!ids.length) { toast('No voices to copy', 'error'); return; }
|
||||
await copyText(ids.join(', '));
|
||||
toast('Copied ' + label + ' voices', 'success');
|
||||
status('Copied ' + label + ' voices to clipboard');
|
||||
const text = ids.join(', ');
|
||||
if (await copyText(text)) {
|
||||
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
|
||||
@ -3456,15 +3464,22 @@ function renderVoiceGroupsBar() {
|
||||
if (!names.length) { bar.hidden = true; bar.innerHTML = ''; return; }
|
||||
bar.hidden = false;
|
||||
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 =
|
||||
`<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 =>
|
||||
`<span class="vg-chip${g===active?' active':''}" data-group="${escHtml(g)}">
|
||||
${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">×</button>
|
||||
</span>`
|
||||
).join('') +
|
||||
(active ? `<button class="vg-clear" title="Clear group filter">Show all</button>` : '');
|
||||
).join('') + `</div></details>`;
|
||||
bar.querySelector('details').addEventListener('toggle', e => { window._vgOpen = e.target.open; });
|
||||
|
||||
bar.querySelectorAll('.vg-chip').forEach(chip => {
|
||||
chip.addEventListener('click', e => {
|
||||
@ -3478,7 +3493,8 @@ function renderVoiceGroupsBar() {
|
||||
bar.querySelectorAll('.vg-del').forEach(btn => {
|
||||
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 = '';
|
||||
const groupSel = $('library-filter-group'); if (groupSel) groupSel.value = '';
|
||||
renderVoiceList();
|
||||
@ -3528,18 +3544,25 @@ function _bulkUpdate() {
|
||||
const count = $('vl-bulk-count');
|
||||
const n = _bulkSelected.size;
|
||||
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`;
|
||||
// Sync checkboxes in the DOM to match the set
|
||||
document.querySelectorAll('.vl-bulk-cb').forEach(cb => {
|
||||
cb.checked = _bulkSelected.has(cb.dataset.id);
|
||||
});
|
||||
|
||||
const headerCb = document.getElementById('vl-select-all-header');
|
||||
if (headerCb) {
|
||||
const rows = [...document.querySelectorAll('#voice-list .vl-row')].filter(r => r.dataset.id);
|
||||
const allSelected = rows.length > 0 && rows.every(r => _bulkSelected.has(r.dataset.id));
|
||||
headerCb.checked = allSelected;
|
||||
}
|
||||
const nSel = rows.filter(r => _bulkSelected.has(r.dataset.id)).length;
|
||||
document.querySelectorAll('.vl-select-all-cb').forEach(cb => {
|
||||
cb.checked = rows.length > 0 && nSel === rows.length;
|
||||
cb.indeterminate = nSel > 0 && nSel < rows.length;
|
||||
});
|
||||
}
|
||||
|
||||
function _bulkToggle(id, checked) {
|
||||
@ -3552,8 +3575,69 @@ const _origRenderVoiceList = renderVoiceList;
|
||||
renderVoiceList = function() {
|
||||
_origRenderVoiceList.apply(this, arguments);
|
||||
_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
|
||||
// (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
|
||||
@ -3598,17 +3682,11 @@ function _bulkInjectCheckboxes() {
|
||||
|
||||
// ── 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
|
||||
// something is selected, so this is the entry point). Selects every visible row, or
|
||||
// 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);
|
||||
if (e.target.checked) {
|
||||
rows.forEach(r => _bulkSelected.add(r.dataset.id));
|
||||
@ -3618,11 +3696,6 @@ $('vl-select-all-header')?.addEventListener('change', (e) => {
|
||||
_bulkUpdate();
|
||||
});
|
||||
|
||||
$('vl-bulk-deselect')?.addEventListener('click', () => {
|
||||
_bulkSelected.clear();
|
||||
_bulkUpdate();
|
||||
});
|
||||
|
||||
$('vl-bulk-hide')?.addEventListener('click', async () => {
|
||||
if (!_bulkSelected.size) return;
|
||||
const ids = [..._bulkSelected];
|
||||
|
||||
@ -431,6 +431,15 @@
|
||||
<label>Active STT URL</label>
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
@ -844,7 +853,7 @@
|
||||
</div><!-- /ocr -->
|
||||
|
||||
<!-- ── 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">
|
||||
<h3 class="add-engine-title"><span class="mdi mdi-plus-circle-outline"></span> Add Custom Engine</h3>
|
||||
<div class="add-engine-field">
|
||||
@ -864,6 +873,28 @@
|
||||
<label for="aed-url">URL</label>
|
||||
<input id="aed-url" type="text" placeholder="http://192.168.1.100:8080" spellcheck="false">
|
||||
</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">
|
||||
<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">
|
||||
|
||||
@ -17,8 +17,18 @@
|
||||
<!-- LEFT: compact voice list — two cards -->
|
||||
<div class="voices-list-pane">
|
||||
|
||||
<!-- Card 1: TTS synth mode + preview sentence -->
|
||||
<div class="vl-card vl-card-synth">
|
||||
<!-- Card 2: filters + sort + voice list + toolbar -->
|
||||
<div class="vl-card vl-card-list">
|
||||
<!-- 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…" 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 & 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">
|
||||
<span class="vl-synth-icon"><span class="mdi mdi-play"></span><span class="mdi mdi-play"></span></span>
|
||||
@ -45,16 +55,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /vl-card-synth -->
|
||||
|
||||
<!-- Card 2: filters + sort + voice list + toolbar -->
|
||||
<div class="vl-card vl-card-list">
|
||||
<div class="vl-filters">
|
||||
<select id="library-filter-lang" title="Language"><option value="">All</option></select>
|
||||
<select id="library-filter-sex" title="Gender"><option value="">All</option></select>
|
||||
<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">
|
||||
</div>
|
||||
<div class="vl-filter-panel" id="vl-filter-panel" role="group" aria-label="Filter and sort voices" hidden>
|
||||
<div class="vl-filter-grid">
|
||||
<label>Language <select id="library-filter-lang"><option value="">All</option></select></label>
|
||||
<label>Gender <select id="library-filter-sex"><option value="">All</option></select></label>
|
||||
<label>Type <select id="library-filter-type"><option value="">All types</option></select></label>
|
||||
<label>Tag <select id="library-filter-tag"><option value="">All tags</option></select></label>
|
||||
<label>Rating <select id="library-filter-rating" >
|
||||
<option value="">All ratings</option>
|
||||
<option value="5">★★★★★</option>
|
||||
<option value="4">★★★★+</option>
|
||||
@ -62,15 +70,9 @@
|
||||
<option value="2">★★+</option>
|
||||
<option value="1">★+</option>
|
||||
<option value="0">Unrated</option>
|
||||
</select>
|
||||
<select id="library-filter-group" title="Group"><option value="">All groups</option></select>
|
||||
<button id="library-clear-filters" class="vl-sort-dir-btn" title="Clear all filters"><span class="mdi mdi-filter-off-outline"></span></button>
|
||||
<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="🔍 Search voices…" autocomplete="off" class="vl-search vl-search-main">
|
||||
<span class="vl-sort-label">Sort</span>
|
||||
<select id="voice-sort-field" title="Sort field">
|
||||
</select></label>
|
||||
<label>Group <select id="library-filter-group"><option value="">All groups</option></select></label>
|
||||
<label>Sort by <select id="voice-sort-field" title="Sort field">
|
||||
<option value="id">Name</option>
|
||||
<option value="flag">Language</option>
|
||||
<option value="gender">Gender</option>
|
||||
@ -83,11 +85,14 @@
|
||||
<option value="note">Note</option>
|
||||
<option value="tag">Tags</option>
|
||||
<option value="enabled">Active</option>
|
||||
</select>
|
||||
<button id="voice-sort-dir" class="vl-sort-dir-btn" title="Toggle sort direction"><span class="mdi mdi-arrow-up"></span></button>
|
||||
<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>
|
||||
<button id="voice-table-view-btn" class="vl-sort-dir-btn" title="Toggle full-width table view"><span class="mdi mdi-table"></span></button>
|
||||
<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>
|
||||
</select></label>
|
||||
<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>
|
||||
</div>
|
||||
<div class="vl-filter-actions">
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
<div id="voice-groups-bar" class="voice-groups-bar" hidden></div>
|
||||
|
||||
<!-- Bulk-edit toolbar — appears when voices are selected -->
|
||||
<div class="vl-bulk-bar" id="vl-bulk-bar" hidden>
|
||||
<span class="vl-bulk-count" id="vl-bulk-count">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>
|
||||
<span class="vl-bulk-count" id="vl-bulk-count" role="status">0 selected</span>
|
||||
</div>
|
||||
|
||||
<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>Play</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="enabled">Active <span class="mdi mdi-sort"></span></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 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="sync-voice-folders-btn" title="Sync active/hidden folders">Sync</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 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>
|
||||
<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"><span class="mdi mdi-sync" aria-hidden="true"></span><span class="vl-tb-label"> Sync</span></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>
|
||||
<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>
|
||||
<button class="btn-secondary vl-tb-btn" id="benchmark-voices-btn" title="Benchmark TTS speed">Benchmark</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>
|
||||
<button class="btn-secondary vl-tb-btn" id="precompute-embeddings-btn" title="Warm all active voices so the TTS engine pre-computes & caches each speaker embedding (.pt) — makes first playback instant"><span class="mdi mdi-flash-outline"></span> Precompute</button>
|
||||
<button class="btn-secondary vl-tb-btn" id="seed-batch-all-btn" title="Generate & 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="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>
|
||||
<div class="vl-actions">
|
||||
<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>
|
||||
<div class="vl-actions-menu" id="vl-actions-menu" role="group" aria-label="Actions on the checked voices" hidden>
|
||||
<div class="vl-menu-title">Edit</div>
|
||||
<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="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 & 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 & 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 class="vl-footer">
|
||||
|
||||
106
static/style.css
106
static/style.css
@ -2104,7 +2104,13 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.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 {
|
||||
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.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 */
|
||||
.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-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-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-tag-inp, .vl-bulk-rating-sel {
|
||||
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;
|
||||
}
|
||||
.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; }
|
||||
.vl-footer {
|
||||
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;
|
||||
}
|
||||
|
||||
.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:hover { color: var(--text); }
|
||||
.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);
|
||||
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-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-field { display: flex; flex-direction: column; gap: 5px; }
|
||||
.add-engine-field label { font-size: 12px; font-weight: 600; color: var(--subtext); }
|
||||
.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;
|
||||
background: var(--bg); color: var(--text); font-family: inherit; font-size: 13px;
|
||||
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);
|
||||
}
|
||||
.add-engine-field textarea { resize: vertical; min-height: 70px; }
|
||||
.add-engine-actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 4px; }
|
||||
/* 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; }
|
||||
@ -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 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); }
|
||||
|
||||
/* ── 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; }
|
||||
|
||||
324
tests/engine-connections.spec.js
Normal file
324
tests/engine-connections.spec.js
Normal 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
312
tests/engine-footer.spec.js
Normal 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
212
tests/test_custom_tts.py
Normal 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()
|
||||
159
tests/test_engine_connections.py
Normal file
159
tests/test_engine_connections.py
Normal 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()
|
||||
101
tests/test_engine_disconnect.py
Normal file
101
tests/test_engine_disconnect.py
Normal 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)
|
||||
67
tests/test_magazine_auth.py
Normal file
67
tests/test_magazine_auth.py
Normal 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()
|
||||
137
tests/test_stt_footer_metadata.py
Normal file
137
tests/test_stt_footer_metadata.py
Normal 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()
|
||||
99
tests/test_tts_health_probe.py
Normal file
99
tests/test_tts_health_probe.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user