Fix Connect button scope error and add custom STT cards to conversation dropdown

cardType() was defined inside initLlmsSection() IIFE but called from
renderLocalContainers() which is outside that scope, causing a silent
ReferenceError that reset every Connect click to failure. Moved cardType
to module scope.

Custom STT cards (e.g. whisperx-gpu) are now included in /api/stt-backends
and appear in the Conversation STT dropdown. Added _normalize_service_url()
so 0.0.0.0 URLs in stored cards are rewritten to host.docker.internal for
server-side health checks. _transcribe_audio() tries /transcribe as fallback
for custom backends that don't expose /v1/audio/transcriptions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-28 02:35:38 +02:00
parent 7bced9f126
commit d2fe6790ee
2 changed files with 71 additions and 18 deletions

View File

@ -186,8 +186,13 @@ def _safe_child_path(root: Path, candidate: Path) -> Path:
return candidate_resolved
def _normalize_service_url(url: str) -> str:
"""Replace 0.0.0.0 with host.docker.internal so server-side probes reach the host."""
return re.sub(r"(https?://)0\.0\.0\.0([\/:$])", r"\1host.docker.internal\2", str(url or ""))
def _validate_http_url(raw: str, *, allow_private: bool = True) -> str:
raw = str(raw or "").strip()
raw = _normalize_service_url(str(raw or "")).strip()
if not raw:
raise HTTPException(400, "URL is required")
parts = urlsplit(raw)
@ -1838,13 +1843,22 @@ _STT_BACKEND_METRICS: dict[str, dict] = {
_STT_VALID_BACKENDS = {"configured", "nvidia_parakeet", "nvidia_router", "faster_whisper", "whisper_cpp", "groq_whisper"}
def _clean_stt_backend(value: str) -> str:
key = re.sub(r"[^a-z0-9]+", "_", str(value or "configured").lower()).strip("_")
original = str(value or "").strip()
if original.startswith("custom:"):
return original # pass through custom card IDs unchanged
key = re.sub(r"[^a-z0-9]+", "_", original.lower()).strip("_")
key = _STT_BACKEND_ALIASES.get(key, key)
return key if key in _STT_VALID_BACKENDS else "configured"
def _stt_backend_url(settings: dict, backend: str) -> str:
backend = _clean_stt_backend(backend)
if backend.startswith("custom:"):
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", "")
return ""
if backend == "nvidia_parakeet":
return settings.get("nvidia_asr_url") or _NVIDIA_ASR_DEFAULT
if backend == "nvidia_router":
@ -1943,6 +1957,33 @@ async def stt_backends():
"models": models,
"metrics": _STT_BACKEND_METRICS.get(backend, {}),
})
# Custom STT cards from settings
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", "")))
if (card_id, url) in seen_urls:
continue
seen_urls.add((card_id, url))
ok, models = _stt_backend_health(url)
port = _backend_port_label(url)
label = card.get("label") or card.get("name") or "Custom STT"
if port:
label = f"{port} {label}"
items.append({
"id": card_id,
"label": label,
"url": url,
"port": port,
"available": ok,
"model": "whisper-1",
"models": models,
"metrics": {},
})
return {"backends": items}
@ -1966,18 +2007,26 @@ def _transcribe_audio(src: Path, settings: dict, backend: str = "configured") ->
stt_key = _stt_backend_api_key(settings, backend)
hdrs = {"Authorization": f"Bearer {stt_key}"} if stt_key else {}
model = _stt_backend_model(backend)
with src.open("rb") as f:
resp = requests.post(
f"{stt_url}/v1/audio/transcriptions",
files={"file": ("audio.wav", f, "audio/wav")},
data={"model": model, "response_format": "text"},
headers=hdrs,
timeout=_STT_REQUEST_TIMEOUT,
)
if resp.status_code in {400, 404, 422, 500} and model != "whisper-1":
# Ordered list of transcription paths to try; custom backends may use /transcribe
paths = ["/v1/audio/transcriptions", "/transcribe"] if backend.startswith("custom:") else ["/v1/audio/transcriptions"]
resp = None
for path in paths:
with src.open("rb") as f:
resp = requests.post(
f"{stt_url}/v1/audio/transcriptions",
f"{stt_url}{path}",
files={"file": ("audio.wav", f, "audio/wav")},
data={"model": model, "response_format": "text"},
headers=hdrs,
timeout=_STT_REQUEST_TIMEOUT,
)
if resp.status_code == 404 and len(paths) > 1:
continue # try next path
break
if resp.status_code in {400, 404, 422, 500} and model != "whisper-1":
path = paths[-1] # retry on whichever path last responded
with src.open("rb") as f:
resp = requests.post(
f"{stt_url}{path}",
files={"file": ("audio.wav", f, "audio/wav")},
data={"model": "whisper-1", "response_format": "text"},
headers=hdrs,
@ -3501,6 +3550,11 @@ async def restart_local_container(name: str):
async def probe_url(url: str, type: str = ""):
"""Server-side API probe — checks service-specific endpoints and validates JSON responses."""
base = url.rstrip("/")
# Strip common API prefixes so callers can paste full base URLs (e.g. .../v1)
for _suffix in ("/v1", "/api/v1"):
if base.endswith(_suffix):
base = base[: -len(_suffix)]
break
hdrs = {"User-Agent": "TTS-Voice-Creator/probe"}
# Ordered list of (path, json_key_that_must_exist_or_None)

View File

@ -6974,6 +6974,11 @@ function editCustomEngineCard(card) {
// ── Local Docker container management ─────────────────────────────────────
function cardType(card) {
const page = card?.closest('[data-page]');
return page ? page.dataset.page : '';
}
async function loadLocalContainers() {
const gridTts = $('dc-grid-tts');
const gridStt = $('dc-grid-stt');
@ -7321,12 +7326,6 @@ document.querySelectorAll('.dc-refresh-btn').forEach(b => b.addEventListener('cl
return r.json();
}
function cardType(card) {
const page = card.closest('[data-page]');
if (page) return page.dataset.page; // 'llm', 'stt', 'tts'
return '';
}
function applyCardState(card, key, connected, failed) {
card.classList.toggle('llm-local-card-online', connected);
card.classList.toggle('llm-local-card-offline', !connected && !!failed);