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