refactor(ui): Geräte-STT entfernt + Menü-Redesign (schlanke Kopfzeile + ⋮-Sheet)

Geräte-STT (Web Speech API) entfernt — auf Android nur Cloud, Qualität < Whisper,
verwirrend. STT läuft jetzt überall serverseitig (Whisper). Geräte-TTS bleibt.
- Backend: allow_cloud_stt (config/runtime_config/api me) + Tests entfernt.
- Frontend: SpeechRecognition-Code, STT-Dropdown, "Lokal"-Button raus.

Menü-Redesign (verständlicher + passt auf Handys):
- Kopfzeile schlank: Mund-Icon + Titel + Sprache + ⋮-Menübutton (kein Überlauf mehr).
- ⋮ öffnet ein Einstellungs-Sheet: Ton als 3 Presets (📱 Im Gerät / ☁ Server /
   Beste Qualität) statt zweier kryptischer "Gerät"-Dropdowns; Neues Gespräch;
  Tag-/Nachtmodus; Konto (Identität, Admin, Abmelden).
- #tts bleibt als verborgenes Quell-Select -> bestehende TTS-Logik unverändert.
- Doku §5.1.2 neu, §6.3 STT-Hinweis. 167 grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-06-20 22:26:20 +02:00
commit 3f6bf53a7f
7 changed files with 161 additions and 254 deletions

View file

@ -9,8 +9,6 @@ const formEl = $("#prompt-form");
const micBtn = $("#mic");
const ttsSel = $("#tts");
const langSel = $("#lang-sel");
const sttSel = $("#stt-sel");
const allLocalBtn = $("#all-local");
// "Flex" ist im Sprachmenü nur ein weiterer Wert: keine feste Sprache, sondern
// automatische Erkennung. Eine konkrete Sprache = Fix-Modus (Eingabe wird übersetzt).
@ -105,153 +103,18 @@ function unlockTTS() {
try { speechSynthesis.speak(new SpeechSynthesisUtterance("")); _ttsUnlocked = true; } catch (e) {}
}
// ---------- Geräte-STT (Web Speech API SpeechRecognition) ----------
// Gegenstück zum Geräte-TTS: das Gerät erkennt Sprache lokal und schickt nur Text.
// Linie C: standardmäßig nur bei nachweislich lokaler Erkennung (iOS, Chrome on-device);
// Cloud-Erkennung (z. B. Chrome-Desktop -> Audio zu Google) nur, wenn der Admin
// allow_cloud_stt aktiviert hat. Sonst greift weiter der Server-STT (Whisper).
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
const STT_SUPPORTED = !!SR;
const STT_KEY = "va-stt"; // geräte-lokal: "device" wenn Geräte-STT gewählt
let meAllowCloudStt = false; // aus /api/me (Admin-Flag)
function isIOS() {
const ua = navigator.userAgent || "";
return /iPhone|iPad|iPod/i.test(ua) ||
(navigator.maxTouchPoints > 1 && /Macintosh/.test(ua));
}
// Heuristik: iOS erkennt on-device. Chrome mit processLocally-Unterstützung gilt
// ebenfalls als lokal. Sonst (Cloud) nur erlaubt, wenn der Admin es freigibt.
function sttIsLocal() {
if (isIOS()) return true;
try { return SR && "processLocally" in SR.prototype; } catch (e) { return false; }
}
function sttDeviceAllowed() {
return STT_SUPPORTED && (sttIsLocal() || meAllowCloudStt);
}
function isSttDeviceMode() { return !!sttSel && sttSel.value === "device"; }
// Geräte-STT für DIESEN Turn nutzen? Nur Fix-Sprache (SpeechRecognition braucht einen
// Sprach-Hint); im Flex-Modus auf Server-STT (Whisper-Auto-Erkennung) zurückfallen.
function useDeviceStt() {
return isSttDeviceMode() && sttDeviceAllowed() && (langSel && langSel.value !== "flex");
}
// Stellt das STT-Dropdown beim Laden ein: Option ausblenden wenn nicht erlaubt;
// sonst lokale Wahl bzw. Mobil-Default.
function applySttChoice() {
if (!sttSel) return;
if (!sttDeviceAllowed()) {
sttSel.classList.add("hidden"); // kein Geräte-STT -> Server wie bisher
sttSel.value = "server";
return;
}
sttSel.classList.remove("hidden");
const local = localStorage.getItem(STT_KEY);
if (local === "device") sttSel.value = "device";
else if (local === "server") sttSel.value = "server";
else if (isMobile()) { sttSel.value = "device"; localStorage.setItem(STT_KEY, "device"); }
// Schnellwahl "Alles lokal" nur zeigen, wenn STT- UND TTS-on-device möglich sind.
if (allLocalBtn && TTS_SUPPORTED) allLocalBtn.classList.remove("hidden");
}
// Schnellwahl: STT + TTS auf "Gerät" (nur Text zum Server). Setzt beide Schalter.
function setAllLocal() {
unlockTTS();
if (sttSel) { sttSel.value = "device"; localStorage.setItem(STT_KEY, "device"); }
if (ttsSel) { ttsSel.value = "device"; localStorage.setItem(PLAYBACK_KEY, "device"); }
statusEl.textContent = "Alles lokal: Erkennung + Vorlesen im Gerät";
setTimeout(() => {
if (statusEl.textContent.startsWith("Alles lokal")) statusEl.textContent = "";
}, 2500);
}
let recognition = null;
// Bestimmt den besten STT-Modus für eine Sprache: "local" (on-device), "cloud" oder
// "server" (Whisper-Fallback). Nutzt die Chrome-API available()/install() für die
// On-Device-Sprachpakete — verhindert "language not supported", wenn das Paket fehlt.
async function resolveSttMode(bcp) {
// Chrome (neue API): prüft, ob das On-Device-Sprachpaket vorhanden ist.
if (SR && typeof SR.available === "function") {
try {
const a = await SR.available({ langs: [bcp], processLocally: true });
if (a === "available") return "local";
if (a === "downloadable" || a === "downloading") {
// On-Device-Paket anstoßen (für künftige Turns); jetzt Cloud/Server.
try { if (typeof SR.install === "function") SR.install({ langs: [bcp], processLocally: true }); } catch (e) {}
}
return meAllowCloudStt ? "cloud" : "server";
} catch (e) { /* API-Fehler -> unten */ }
}
// Keine available()-API: iOS-Safari erkennt on-device (mit Live-Vorschau) -> lokal.
if (isIOS()) return "local";
// Sonst (z. B. älteres Chrome): SpeechRecognition liefe über die Cloud.
return meAllowCloudStt ? "cloud" : "server";
}
const SERVER_STT_MSG = "Server-Erkennung. Live-Vorschau nur mit lokalem/Cloud-STT";
async function startDeviceStt() {
const bcp = LANG_BCP47[langSel.value] || "de-DE";
const mode = await resolveSttMode(bcp);
if (mode === "server") { // lokal (für diese Sprache) nicht da & Cloud verboten
startRecording(SERVER_STT_MSG);
return;
}
const rec = new SR();
rec.lang = bcp;
rec.interimResults = true;
rec.continuous = false;
if (mode === "local") { try { rec.processLocally = true; } catch (e) {} }
recognition = rec;
let finalText = "";
setMicRecording();
statusEl.textContent = "höre zu … (zum Stoppen erneut tippen)";
rec.onresult = (e) => {
let interim = "";
for (let i = e.resultIndex; i < e.results.length; i++) {
const t = e.results[i][0].transcript;
if (e.results[i].isFinal) finalText += t; else interim += t;
}
promptEl.value = (finalText + interim).trim(); // Live-Feedback im Eingabefeld
};
rec.onerror = (e) => {
recognition = null;
const err = e.error || "?";
if (err === "no-speech") { setMicIdle(); statusEl.textContent = "nichts gehört — bitte erneut"; return; }
// Sprache/Dienst/Netz nicht verfügbar -> sauber auf Server-STT zurückfallen statt Fehler.
if (err === "language-not-supported" || err === "service-not-allowed" || err === "network") {
startRecording(SERVER_STT_MSG);
return;
}
setMicIdle();
statusEl.textContent = "Spracherkennung fehlgeschlagen (" + err + ")";
};
rec.onend = () => {
recognition = null;
setMicIdle();
const text = (promptEl.value || "").trim();
promptEl.value = "";
if (text) sendText(text); // -> vorhandener Text-Turn (inkl. Geräte-/Server-TTS)
};
try { rec.start(); } catch (e) {
recognition = null;
startRecording(SERVER_STT_MSG); // Start scheiterte -> Server-Fallback
}
}
function stopDeviceStt() { if (recognition) { try { recognition.stop(); } catch (e) {} } }
const PLAYBACK_KEY = "va-playback"; // geräte-lokal: "device" wenn Geräte-TTS gewählt
// Setzt das Qualität-Dropdown beim Laden. Vorrang:
// 1. lokale Wahl (localStorage), 2. Server-Pref (tts_provider), 3. Mobil-Default = Gerät.
// Setzt das verborgene Quell-Select (#tts) beim Laden + spiegelt es in die Presets.
// Vorrang: 1. lokale Wahl (localStorage), 2. Server-Pref (tts_provider), 3. Mobil-Default = Gerät.
function applyPlaybackChoice(serverProvider) {
if (!ttsSel) return;
// "Gerät"-Option ausblenden, wenn der Browser keine Web Speech API hat.
// "Im Gerät"-Preset ausblenden, wenn der Browser keine Web Speech API hat.
if (!TTS_SUPPORTED) {
const opt = ttsSel.querySelector('option[value="device"]');
if (opt) opt.remove();
const btn = document.querySelector('.tts-preset[data-tts="device"]');
if (btn) btn.classList.add("hidden");
}
const local = localStorage.getItem(PLAYBACK_KEY);
if (local === "device" && TTS_SUPPORTED) {
@ -261,7 +124,34 @@ function applyPlaybackChoice(serverProvider) {
} else if (TTS_SUPPORTED && isMobile() && local === null) {
ttsSel.value = "device"; // Mobil-Default, solange nichts gewählt wurde
localStorage.setItem(PLAYBACK_KEY, "device");
} else if (!ttsSel.value) {
ttsSel.value = "piper"; // Fallback: Server
}
syncPresetUI();
}
// Markiert das aktive Ton-Preset (Häkchen + Rahmen) passend zu #tts.
function syncPresetUI() {
const val = ttsSel ? ttsSel.value : "";
document.querySelectorAll(".tts-preset").forEach((btn) => {
const active = btn.dataset.tts === val ||
(val === "openrouter" && btn.dataset.tts === "chatterbox"); // Cloud -> "Beste Qualität"
btn.classList.toggle("ring-2", active);
btn.classList.toggle("ring-blue-500", active);
btn.classList.toggle("border-blue-500", active);
const chk = btn.querySelector(".check");
if (chk) chk.style.opacity = active ? "1" : "0";
});
}
// ---------- Einstellungs-Menü (⋮) ----------
function openMenu() {
$("#settings-sheet").classList.remove("hidden");
$("#settings-backdrop").classList.remove("hidden");
}
function closeMenu() {
$("#settings-sheet").classList.add("hidden");
$("#settings-backdrop").classList.add("hidden");
}
let busy = false;
@ -306,8 +196,6 @@ function loadMe() {
: (prefs.language || "flex");
}
applyPlaybackChoice(prefs.tts_provider);
meAllowCloudStt = !!me.allow_cloud_stt;
applySttChoice();
return me;
} catch (e) {
$("#identity").textContent = "Verbindung fehlgeschlagen";
@ -666,18 +554,14 @@ micBtn.addEventListener("click", () => {
stopAudio();
statusEl.textContent = "Unterbrochen";
// busy + Button-Reset erfolgen sobald die WS schliesst (-> runTurn resolve -> sendVoice/sendText)
} else if (recognition) {
stopDeviceStt(); // Geräte-STT läuft -> stoppen (onend sendet den Text)
} else if (mediaRecorder && mediaRecorder.state === "recording") {
stopRecording();
} else if (useDeviceStt()) {
startDeviceStt(); // lokale Spracherkennung (nur Fix + erlaubt)
} else {
startRecording(); // Server-STT (Upload) — wie bisher, auch am Desktop
startRecording(); // Server-STT (Upload) — überall gleich
}
});
$("#admin-toggle").addEventListener("click", openAdminPanel);
$("#admin-toggle").addEventListener("click", () => { closeMenu(); openAdminPanel(); });
// ---------- Neues Gespräch ----------
// Startet eine frische Session (neuer session_id) -> der bisherige Verlauf wird nicht
@ -694,12 +578,29 @@ function newConversation() {
if (statusEl.textContent === "Neues Gespräch begonnen") statusEl.textContent = "";
}, 2500);
}
$("#new-chat").addEventListener("click", newConversation);
$("#new-chat").addEventListener("click", () => { closeMenu(); newConversation(); });
// ---------- Menü (⋮) verdrahten ----------
$("#menu-toggle").addEventListener("click", () =>
$("#settings-sheet").classList.contains("hidden") ? openMenu() : closeMenu());
$("#settings-backdrop").addEventListener("click", closeMenu);
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenu(); });
// Ton-Presets: setzen das verborgene #tts + lösen die bestehende Persistenz-Logik aus.
document.querySelectorAll(".tts-preset").forEach((btn) =>
btn.addEventListener("click", () => {
unlockTTS();
ttsSel.value = btn.dataset.tts;
ttsSel.dispatchEvent(new Event("change"));
syncPresetUI();
})
);
// Tag/Nacht: Umschalter (manuell) + automatisch dem System folgen, solange nichts gewählt.
const themeBtn = $("#theme-toggle");
const setThemeIcon = () =>
(themeBtn.textContent = document.documentElement.classList.contains("dark") ? "☀️" : "🌙");
const setThemeIcon = () => {
const icon = $("#theme-icon");
if (icon) icon.textContent = document.documentElement.classList.contains("dark") ? "☀️" : "🌙";
};
themeBtn.addEventListener("click", () => {
const dark = document.documentElement.classList.toggle("dark");
localStorage.setItem("theme", dark ? "dark" : "light");
@ -734,9 +635,6 @@ if (ttsSel) {
}
});
}
// STT-Wahl ist geräte-lokal (nicht server-seitig) -> nur in localStorage.
if (sttSel) sttSel.addEventListener("change", () => localStorage.setItem(STT_KEY, sttSel.value));
if (allLocalBtn) allLocalBtn.addEventListener("click", setAllLocal);
loadMe();
@ -1541,8 +1439,6 @@ const FIELD_META = {
labels: { "true":"Aktiviert","false":"Deaktiviert" } },
memory_extraction_every_n_turns: { ui: "number", min: 1, max: 20 },
daily_request_limit: { ui: "number", min: 0 },
allow_cloud_stt: { ui: "select", opts: ["true","false"],
labels: { "true":"Erlaubt (auch Cloud-Erkennung)","false":"Nur on-device (Datenschutz)" } },
};
const FIELD_GROUPS = [
@ -1553,7 +1449,6 @@ const FIELD_GROUPS = [
{ label: "TTS-Verarbeitung", keys: ["tts_normalize_level","audio_stream_default"] },
{ label: "Gedächtnis", keys: ["memory_extraction_enabled","memory_extraction_every_n_turns"] },
{ label: "Limits", keys: ["daily_request_limit"] },
{ label: "Geräte-STT (mobil)", keys: ["allow_cloud_stt"] },
];
let _settingsData = [];