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:
Dieter Schlüter 2026-06-27 04:16:21 +02:00
commit 5e128a74ce
7 changed files with 333 additions and 114 deletions

View file

@ -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