feat(menu): nicht nutzbare TTS-Provider ausblenden + GPU-Erkennung
Menue zeigt nur tatsaechlich nutzbare Vorlese-Optionen, um Verwirrung durch folgenlose Eintraege zu vermeiden: - Backend ist alleinige Quelle: /api/config liefert fertiges tts_menu (Chatterbox nur wenn erreichbar, Cloud nur mit OpenRouter-TTS, Cartesia nur mit Key). piper bleibt garantierter, immer sichtbarer Default. - Frontend blendet aus statt auszugrauen; 'Im Geraet' nur auf Mobilgeraeten (isMobile). Aktive/gespeicherte Auswahl auf einen sichtbaren Provider geklemmt -> kein Server-422. - 4x refresh*Preset + 3x /api/config-Fetch -> 1x refreshTtsMenu; /api/config serverseitig 30s gecacht (spart die bisher kostenpflichtige OpenRouter-Probe pro Seitenladen). GPU-Erkennung zentralisiert (keine Dopplung): - app/capabilities.py: detect_physical_gpus/resolve_gpus (nvidia-smi). - Neues Setting GPUS (auto|none|0,1,2), beim Start an CUDA_VISIBLE_DEVICES gekoppelt -> angezeigt = real nutzbar. admin_llm nutzt dieselbe Erkennung. Tests: tests/test_capabilities.py (Maske, Kopplung, Menue-Sichtbarkeit, GPUs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6bf281fc30
commit
5e128a74ce
7 changed files with 333 additions and 114 deletions
|
|
@ -14,6 +14,7 @@ import subprocess
|
|||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
from app.capabilities import detect_physical_gpus
|
||||
|
||||
ALLOWED_BACKENDS = {"ollama", "llamacpp"}
|
||||
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "llm-server" / "switch-llm.sh"
|
||||
|
|
@ -74,28 +75,6 @@ async def _ollama_loaded() -> tuple[bool, list[dict]]:
|
|||
return True, models
|
||||
|
||||
|
||||
async def _gpus() -> list[dict]:
|
||||
out = await _run([
|
||||
"nvidia-smi",
|
||||
"--query-gpu=index,memory.used,memory.total",
|
||||
"--format=csv,noheader,nounits",
|
||||
])
|
||||
if out is None:
|
||||
return []
|
||||
gpus: list[dict] = []
|
||||
for ln in out.splitlines():
|
||||
parts = [p.strip() for p in ln.split(",")]
|
||||
if len(parts) == 3 and parts[0].isdigit():
|
||||
used, total = int(parts[1]), int(parts[2])
|
||||
gpus.append({
|
||||
"index": int(parts[0]),
|
||||
"used_mib": used,
|
||||
"total_mib": total,
|
||||
"percent": round(used / total * 100) if total else 0,
|
||||
})
|
||||
return gpus
|
||||
|
||||
|
||||
async def _gateway_service_active() -> bool:
|
||||
out = await _run(["systemctl", "is-active", "voice-assistant.service"], timeout=4.0)
|
||||
return bool(out and out.strip() == "active")
|
||||
|
|
@ -108,7 +87,7 @@ async def llm_status() -> dict:
|
|||
llamacpp, (ollama_reachable, ollama_models), gpus, gw = await asyncio.gather(
|
||||
_llamacpp_running(),
|
||||
_ollama_loaded(),
|
||||
_gpus(),
|
||||
detect_physical_gpus(),
|
||||
_gateway_service_active(),
|
||||
)
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import asyncio
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.config import settings, active_profile
|
||||
from app.capabilities import resolve_gpus
|
||||
from app.providers.tts.cartesia import _UNSUPPORTED_LANGS as _CARTESIA_UNSUPPORTED
|
||||
from app.dependencies import (
|
||||
resolve_route,
|
||||
|
|
@ -13,6 +17,13 @@ from app.dependencies import (
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
# /api/config probt externe Dienste (u. a. einen KOSTENPFLICHTIGEN OpenRouter-TTS-Aufruf).
|
||||
# Daher serverseitig kurz cachen: das Frontend ruft den Endpunkt mehrfach (Laden + Menü-Öffnen).
|
||||
_CACHE: dict | None = None
|
||||
_CACHE_TS: float = 0.0
|
||||
_CACHE_TTL = 30.0
|
||||
_CACHE_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
async def _chatterbox_reachable() -> bool:
|
||||
try:
|
||||
|
|
@ -62,16 +73,31 @@ async def _openrouter_tts_available() -> bool:
|
|||
return False
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_config():
|
||||
"""Zeigt aktives Profil, aufgeloeste Default-Route und verfuegbare Bausteine."""
|
||||
async def _build_config() -> dict:
|
||||
"""Baut die /api/config-Antwort (eine Quelle der Wahrheit für die Menü-Sichtbarkeit)."""
|
||||
route = resolve_route()
|
||||
audio_router = get_audio_router()
|
||||
chatterbox_ok, openrouter_tts_ok, cartesia_ok = await _chatterbox_reachable(), await _openrouter_tts_available(), await _cartesia_available()
|
||||
chatterbox_ok, openrouter_tts_ok, cartesia_ok, gpus = (
|
||||
await _chatterbox_reachable(),
|
||||
await _openrouter_tts_available(),
|
||||
await _cartesia_available(),
|
||||
await resolve_gpus(),
|
||||
)
|
||||
# Fertige Sichtbarkeits-Entscheidung je TTS-Provider -> Frontend dupliziert keine Regeln.
|
||||
# "device" ist client_only: nur das Frontend kann "Mobilgerät?" beantworten (isMobile()).
|
||||
# "piper" bleibt der garantierte, immer sichtbare Default (Menü nie leer).
|
||||
tts_menu = [
|
||||
{"provider": "piper", "visible": True, "client_only": False},
|
||||
{"provider": "chatterbox", "visible": chatterbox_ok, "client_only": False},
|
||||
{"provider": "cartesia", "visible": cartesia_ok, "client_only": False},
|
||||
{"provider": "openrouter", "visible": openrouter_tts_ok, "client_only": False},
|
||||
{"provider": "device", "visible": True, "client_only": True},
|
||||
]
|
||||
return {
|
||||
"profile": active_profile(),
|
||||
"app_env": settings.app_env,
|
||||
"default_route": route.as_dict(),
|
||||
"tts_menu": tts_menu,
|
||||
"available": {
|
||||
"stt_providers": sorted(STT_REGISTRY),
|
||||
"llm_providers": sorted(LLM_REGISTRY),
|
||||
|
|
@ -82,8 +108,23 @@ async def get_config():
|
|||
"openrouter_tts_available": openrouter_tts_ok,
|
||||
"cartesia_available": cartesia_ok,
|
||||
"cartesia_unsupported_languages": sorted(_CARTESIA_UNSUPPORTED),
|
||||
"gpus": gpus,
|
||||
},
|
||||
"secrets": {
|
||||
"openrouter_api_key_set": bool(settings.openrouter_api_key.strip()),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_config():
|
||||
"""Zeigt aktives Profil, aufgeloeste Default-Route und verfuegbare Bausteine (gecacht)."""
|
||||
global _CACHE, _CACHE_TS
|
||||
if _CACHE is not None and (time.monotonic() - _CACHE_TS) < _CACHE_TTL:
|
||||
return _CACHE
|
||||
async with _CACHE_LOCK:
|
||||
if _CACHE is not None and (time.monotonic() - _CACHE_TS) < _CACHE_TTL:
|
||||
return _CACHE
|
||||
_CACHE = await _build_config()
|
||||
_CACHE_TS = time.monotonic()
|
||||
return _CACHE
|
||||
|
|
|
|||
80
app/capabilities.py
Normal file
80
app/capabilities.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Laufzeit-Fähigkeiten der Hardware (GPU-Erkennung) — eine Quelle für Admin + /api/config.
|
||||
|
||||
Die GPU-Maske `settings.gpus` wird beim Start an `CUDA_VISIBLE_DEVICES` gekoppelt
|
||||
(`app/config.py::_apply_gpu_visibility`). Hier wird nur **erkannt** und nach Maske
|
||||
**aufgelöst**; CUDA_VISIBLE_DEVICES wird zusätzlich respektiert, damit „angezeigt" =
|
||||
„real nutzbar" gilt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from app.config import settings, _parse_gpu_indices
|
||||
|
||||
|
||||
async def detect_physical_gpus() -> list[dict]:
|
||||
"""Physisch vorhandene NVIDIA-GPUs via nvidia-smi. [] wenn kein Tool/keine GPU/Fehler."""
|
||||
if not shutil.which("nvidia-smi"):
|
||||
return []
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"nvidia-smi",
|
||||
"--query-gpu=index,name,memory.used,memory.total",
|
||||
"--format=csv,noheader,nounits",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=6.0)
|
||||
if proc.returncode != 0:
|
||||
return []
|
||||
except (asyncio.TimeoutError, OSError):
|
||||
return []
|
||||
gpus: list[dict] = []
|
||||
for ln in out.decode("utf-8", "replace").splitlines():
|
||||
parts = [p.strip() for p in ln.split(",")]
|
||||
if len(parts) == 4 and parts[0].isdigit():
|
||||
used, total = int(parts[2]), int(parts[3])
|
||||
gpus.append({
|
||||
"index": int(parts[0]),
|
||||
"name": parts[1],
|
||||
"used_mib": used,
|
||||
"total_mib": total,
|
||||
"percent": round(used / total * 100) if total else 0,
|
||||
})
|
||||
return gpus
|
||||
|
||||
|
||||
def _cuda_visible_mask() -> set[int] | None:
|
||||
"""Indizes aus CUDA_VISIBLE_DEVICES; None = keine Einschränkung, set() = explizit keine."""
|
||||
raw = os.environ.get("CUDA_VISIBLE_DEVICES")
|
||||
if raw is None:
|
||||
return None
|
||||
if raw.strip() == "":
|
||||
return set() # leer = keine GPU für CUDA-Anwendungen
|
||||
return set(_parse_gpu_indices(raw))
|
||||
|
||||
|
||||
async def resolve_gpus() -> list[dict]:
|
||||
"""Sichtbare/nutzbare GPUs = physisch ∩ settings.gpus-Maske ∩ CUDA_VISIBLE_DEVICES."""
|
||||
mode = (settings.gpus or "auto").strip().lower()
|
||||
if mode in ("none", "off", "false", "-1", ""):
|
||||
return []
|
||||
physical = await detect_physical_gpus()
|
||||
explicit = None if mode == "auto" else set(_parse_gpu_indices(settings.gpus))
|
||||
cuda = _cuda_visible_mask()
|
||||
visible: list[dict] = []
|
||||
for g in physical:
|
||||
idx = g["index"]
|
||||
if explicit is not None and idx not in explicit:
|
||||
continue
|
||||
if cuda is not None and idx not in cuda:
|
||||
continue
|
||||
visible.append(g)
|
||||
return visible
|
||||
|
||||
|
||||
async def gpu_available() -> bool:
|
||||
return len(await resolve_gpus()) > 0
|
||||
|
|
@ -135,6 +135,11 @@ class Settings(BaseSettings):
|
|||
faster_whisper_device: str = "auto" # auto|cpu|cuda
|
||||
faster_whisper_compute_type: str = "int8" # default|int8|float16|int8_float16
|
||||
stt_force_language: bool = True # STT mit fester Zielsprache (robust); False = Auto-Detect + Übersetzung
|
||||
# GPU-Sichtbarkeit: "auto" = erkennen (respektiert vorhandenes CUDA_VISIBLE_DEVICES),
|
||||
# "none"/leer = keine GPU erzwingen, "0,1,2" = nur diese Indizes. Wird beim Start an
|
||||
# CUDA_VISIBLE_DEVICES gekoppelt (siehe _apply_gpu_visibility), damit das, was das Menü
|
||||
# anzeigt, auch real von torch/faster-whisper genutzt wird.
|
||||
gpus: str = "auto"
|
||||
# --- Lokales TTS (piper) -------------------------------------------------
|
||||
piper_bin: str = "piper" # Pfad/Name des piper-Binaries
|
||||
piper_voices_dir: str = str(Path.home() / ".local" / "share" / "piper" / "voices")
|
||||
|
|
@ -234,3 +239,29 @@ class Settings(BaseSettings):
|
|||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def _parse_gpu_indices(value: str) -> list[int]:
|
||||
"""'0,2' -> [0, 2]; leer/ungültig -> []."""
|
||||
return sorted({int(t) for t in (value or "").split(",") if t.strip().isdigit()})
|
||||
|
||||
|
||||
def _apply_gpu_visibility() -> None:
|
||||
"""Koppelt settings.gpus an CUDA_VISIBLE_DEVICES, BEVOR torch/CUDA initialisiert.
|
||||
|
||||
- "auto": nichts erzwingen (ein bereits gesetztes CUDA_VISIBLE_DEVICES bleibt gültig).
|
||||
- "none"/leer/"-1": CUDA_VISIBLE_DEVICES="" -> faster-whisper "auto" fällt auf CPU.
|
||||
- "0,1,...": genau diese Indizes sichtbar machen (explizit gewinnt).
|
||||
"""
|
||||
mode = (settings.gpus or "auto").strip().lower()
|
||||
if mode == "auto":
|
||||
return
|
||||
if mode in ("none", "off", "false", "-1", ""):
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
||||
return
|
||||
indices = _parse_gpu_indices(settings.gpus)
|
||||
if indices:
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(str(i) for i in indices)
|
||||
|
||||
|
||||
_apply_gpu_visibility()
|
||||
|
|
|
|||
134
app/web/app.js
134
app/web/app.js
|
|
@ -84,8 +84,8 @@ function loadVoices() { _voices = TTS_SUPPORTED ? speechSynthesis.getVoices() :
|
|||
if (TTS_SUPPORTED) {
|
||||
loadVoices();
|
||||
// Stimmen laden asynchron -> bei Eintreffen Preset-Zustand neu bewerten (entgrauen).
|
||||
speechSynthesis.addEventListener("voiceschanged", () => { loadVoices(); refreshDevicePreset(); });
|
||||
setTimeout(() => refreshDevicePreset(), 1500); // Backstop, falls kein voiceschanged kommt
|
||||
speechSynthesis.addEventListener("voiceschanged", () => { loadVoices(); applyTtsVisibility(); });
|
||||
setTimeout(() => applyTtsVisibility(), 1500); // Backstop, falls kein voiceschanged kommt
|
||||
}
|
||||
|
||||
// Die Web Speech API kennt KEIN Standard-Geschlechtsfeld an einer Stimme -> Heuristik
|
||||
|
|
@ -237,75 +237,61 @@ function applyPlaybackChoice(serverProvider) {
|
|||
} else if (!ttsSel.value) {
|
||||
ttsSel.value = "piper"; // Fallback: Server
|
||||
}
|
||||
refreshDevicePreset(); // graut „Im Gerät" aus, wenn keine Stimmen + syncPresetUI
|
||||
applyTtsVisibility(); // blendet Provider passend ein/aus + syncPresetUI
|
||||
}
|
||||
|
||||
// Graut „Im Gerät" aus, wenn keine Sprachstimmen vorhanden sind; entgraut automatisch,
|
||||
// sobald welche da sind. Kein Aufzwingen: device wird nur (wieder) gewählt, wenn es die
|
||||
// gespeicherte Präferenz ist. Ohne Stimmen NICHT auf der deaktivierten Option sitzen bleiben.
|
||||
function refreshDevicePreset() {
|
||||
const btn = document.querySelector('.tts-preset[data-tts="device"]');
|
||||
if (!btn || !ttsSel) return;
|
||||
const ready = deviceVoicesReady();
|
||||
btn.disabled = !ready;
|
||||
btn.classList.toggle("opacity-50", !ready);
|
||||
btn.classList.toggle("cursor-not-allowed", !ready);
|
||||
const sub = btn.querySelector(".tts-sub");
|
||||
if (sub) sub.textContent = ready
|
||||
// ── TTS-Menü: Sichtbarkeit der Provider (eine Quelle: Backend /api/config) ──
|
||||
// Regeln liegen im Backend (tts_menu); hier nur rendern + die client-only-Regel
|
||||
// „Im Gerät nur auf Mobilgeräten" (isMobile). Ausgeblendet, nicht ausgegraut.
|
||||
let _ttsMenu = null; // zuletzt geladenes tts_menu von /api/config
|
||||
const TTS_DEFAULT = "piper"; // garantiert sichtbarer Fallback -> Menü nie leer
|
||||
|
||||
function _ttsVisible(provider) {
|
||||
if (provider === "device") return TTS_SUPPORTED && isMobile(); // nur mobil
|
||||
if (provider === TTS_DEFAULT) return true; // garantierter Default
|
||||
if (!_ttsMenu) return true; // vor dem Laden nichts verstecken
|
||||
const e = _ttsMenu.find((m) => m.provider === provider);
|
||||
return e ? e.visible !== false : true;
|
||||
}
|
||||
|
||||
// Blendet Provider-Buttons (und die zugehörigen <option>) ein/aus und klemmt die
|
||||
// aktive Auswahl auf einen sichtbaren Provider (sonst spielt nichts / Server-422).
|
||||
function applyTtsVisibility() {
|
||||
if (!ttsSel) return;
|
||||
document.querySelectorAll(".tts-preset").forEach((btn) => {
|
||||
const p = btn.dataset.tts;
|
||||
const vis = _ttsVisible(p);
|
||||
btn.classList.toggle("hidden", !vis);
|
||||
const opt = ttsSel.querySelector(`option[value="${p}"]`);
|
||||
if (opt) { opt.hidden = !vis; opt.disabled = !vis; }
|
||||
});
|
||||
const dsub = document.querySelector('.tts-preset[data-tts="device"] .tts-sub');
|
||||
if (dsub) dsub.textContent = deviceVoicesReady()
|
||||
? "Das Gerät liest vor · spart Daten"
|
||||
: "keine Sprachstimmen im Browser";
|
||||
if (ready) {
|
||||
if (localStorage.getItem(PLAYBACK_KEY) === "device" && ttsSel.value !== "device") {
|
||||
ttsSel.value = "device"; // gespeicherte Präferenz wieder aktivieren
|
||||
: "Liest über den Server vor";
|
||||
if (!_ttsVisible(ttsSel.value)) {
|
||||
const wasServer = ttsSel.value && ttsSel.value !== "device";
|
||||
ttsSel.value = TTS_DEFAULT;
|
||||
if (wasServer) {
|
||||
savePrefs({ tts_provider: TTS_DEFAULT }); // verhindert Server-422 beim nächsten Turn
|
||||
if (statusEl) statusEl.textContent = "Stimme nicht verfügbar – auf „Schnell“ umgestellt";
|
||||
}
|
||||
} else if (ttsSel.value === "device") {
|
||||
ttsSel.value = "piper"; // Server statt deaktivierter Option (Präferenz bleibt)
|
||||
} else if (localStorage.getItem(PLAYBACK_KEY) === "device"
|
||||
&& _ttsVisible("device") && ttsSel.value !== "device") {
|
||||
ttsSel.value = "device"; // gespeicherte Geräte-Präferenz wieder aktivieren
|
||||
}
|
||||
syncPresetUI();
|
||||
updateCartesiaLangHint();
|
||||
}
|
||||
|
||||
// Graut "Hohe Qualität" (Chatterbox) aus, wenn kein GPU-Backend erreichbar ist.
|
||||
async function refreshChatterboxPreset() {
|
||||
const btn = document.querySelector('.tts-preset[data-tts="chatterbox"]');
|
||||
if (!btn || !ttsSel) return;
|
||||
// Holt die Sichtbarkeiten einmal vom (gecachten) Backend und wendet sie an.
|
||||
async function refreshTtsMenu() {
|
||||
try {
|
||||
const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json());
|
||||
const available = cfg.available?.chatterbox_reachable === true;
|
||||
btn.disabled = !available;
|
||||
btn.classList.toggle("opacity-50", !available);
|
||||
btn.classList.toggle("cursor-not-allowed", !available);
|
||||
const sub = btn.querySelector(".tts-sub");
|
||||
if (sub) sub.textContent = available
|
||||
? "Chatterbox · natürliche Stimme"
|
||||
: "Chatterbox · kein GPU-Backend erreichbar";
|
||||
if (!available && ttsSel.value === "chatterbox") {
|
||||
ttsSel.value = "piper";
|
||||
syncPresetUI();
|
||||
}
|
||||
} catch (e) { /* ignorieren */ }
|
||||
}
|
||||
|
||||
// Graut "Cartesia" aus, wenn API-Key oder Voice-ID fehlt.
|
||||
async function refreshCartesiaPreset() {
|
||||
const btn = document.querySelector('.tts-preset[data-tts="cartesia"]');
|
||||
if (!btn || !ttsSel) return;
|
||||
try {
|
||||
const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json());
|
||||
const available = cfg.available?.cartesia_available === true;
|
||||
const cfg = await fetch("/api/config", { cache: "no-store" }).then((r) => r.json());
|
||||
_ttsMenu = cfg.tts_menu || null;
|
||||
cartesiaUnsupportedLangs = cfg.available?.cartesia_unsupported_languages || [];
|
||||
btn.disabled = !available;
|
||||
btn.classList.toggle("opacity-50", !available);
|
||||
btn.classList.toggle("cursor-not-allowed", !available);
|
||||
const sub = btn.querySelector(".tts-sub");
|
||||
if (sub) sub.textContent = available
|
||||
? "Cartesia Sonic · natürlich & schnell"
|
||||
: "Cartesia · kein API-Key konfiguriert";
|
||||
if (!available && ttsSel.value === "cartesia") {
|
||||
ttsSel.value = "piper";
|
||||
syncPresetUI();
|
||||
}
|
||||
updateCartesiaLangHint();
|
||||
} catch (e) { /* ignorieren */ }
|
||||
} catch (e) { /* offline -> nichts verstecken */ }
|
||||
applyTtsVisibility();
|
||||
}
|
||||
|
||||
// Zeigt einen Hinweis, wenn Cartesia gewählt ist, die feste Sprache aber nicht
|
||||
|
|
@ -326,27 +312,6 @@ function updateCartesiaLangHint() {
|
|||
}
|
||||
}
|
||||
|
||||
// Graut "Cloud" (OpenRouter TTS) aus, wenn das TTS-Modell nicht verfügbar ist.
|
||||
async function refreshOpenrouterTtsPreset() {
|
||||
const btn = document.querySelector('.tts-preset[data-tts="openrouter"]');
|
||||
if (!btn || !ttsSel) return;
|
||||
try {
|
||||
const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json());
|
||||
const available = cfg.available?.openrouter_tts_available === true;
|
||||
btn.disabled = !available;
|
||||
btn.classList.toggle("opacity-50", !available);
|
||||
btn.classList.toggle("cursor-not-allowed", !available);
|
||||
const sub = btn.querySelector(".tts-sub");
|
||||
if (sub) sub.textContent = available
|
||||
? "OpenRouter · benötigt Internet"
|
||||
: "OpenRouter · kein TTS-Modell verfügbar";
|
||||
if (!available && ttsSel.value === "openrouter") {
|
||||
ttsSel.value = "piper";
|
||||
syncPresetUI();
|
||||
}
|
||||
} catch (e) { /* ignorieren */ }
|
||||
}
|
||||
|
||||
// Markiert das aktive Ton-Preset (Häkchen + Rahmen) passend zu #tts.
|
||||
function syncPresetUI() {
|
||||
const val = ttsSel ? ttsSel.value : "";
|
||||
|
|
@ -364,6 +329,7 @@ function syncPresetUI() {
|
|||
function openMenu() {
|
||||
$("#settings-sheet").classList.remove("hidden");
|
||||
$("#settings-backdrop").classList.remove("hidden");
|
||||
refreshTtsMenu(); // beim Öffnen neu bewerten (Backend kann zur Laufzeit gewechselt haben)
|
||||
}
|
||||
function closeMenu() {
|
||||
$("#settings-sheet").classList.add("hidden");
|
||||
|
|
@ -963,9 +929,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
});
|
||||
|
||||
loadMe();
|
||||
refreshChatterboxPreset();
|
||||
refreshCartesiaPreset();
|
||||
refreshOpenrouterTtsPreset();
|
||||
refreshTtsMenu();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// ADMIN PANEL
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue