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:
parent
f74d2fadb2
commit
3f6bf53a7f
7 changed files with 161 additions and 254 deletions
|
|
@ -774,29 +774,43 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr
|
|||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Voice Assistant [🇩🇪 ▾] [Qualität ▾] [⚙️] [🌙] │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Nachrichtenverlauf │
|
||||
│ (eigene Nachrichten: blaue Blase rechts) │
|
||||
│ (Assistent: graue Blase links) │
|
||||
│ │
|
||||
├─────────────────────────────┬───────────────────────────┤
|
||||
│ Texteingabe … [Senden] │ [🎤] │
|
||||
└─────────────────────────────┴───────────────────────────┘
|
||||
Statuszeile: „denkt …" / „verarbeite Sprache …" / leer
|
||||
│ 👄 Voice Assistant ............... [🇩🇪 ▾] [ ⋮ ] │
|
||||
├─────────────────────────────────────────────────────────┤ ┌─ Menü (⋮) ──────────────┐
|
||||
│ │ │ VORLESEN │
|
||||
│ Nachrichtenverlauf │ │ ▣ 📱 Im Gerät │
|
||||
│ (eigene Nachrichten: blaue Blase rechts) │ │ ▢ ☁ Server │
|
||||
│ (Assistent: graue Blase links) │ │ ▢ ✨ Beste Qualität │
|
||||
│ │ │ ────────────────────── │
|
||||
├─────────────────────────────┬───────────────────────────┤ │ ✎ Neues Gespräch │
|
||||
│ Texteingabe … [Senden] │ [🎤] │ │ 🌙 Tag-/Nachtmodus │
|
||||
└─────────────────────────────┴───────────────────────────┘ │ ────────────────────── │
|
||||
Statuszeile: „denkt …" / „verarbeite Sprache …" / leer │ Angemeldet · Admin · Ab │
|
||||
└──────────────────────────┘
|
||||
```
|
||||
|
||||
**Kopfzeile (immer schlank):**
|
||||
|
||||
| Element | Funktion |
|
||||
|---------|----------|
|
||||
| **👄 / Titel** | Marke; Mund-Symbol als Wiedererkennung |
|
||||
| **Sprache ▾** | Antwortsprache (→ § 6.6): `🔄 Flex` = folgt der gesprochenen Sprache · feste Sprache (🇩🇪/🇬🇧/…) = Antwort + Stimme in dieser Sprache |
|
||||
| **⋮ Menü** | Öffnet die Einstellungen (unten) |
|
||||
|
||||
**Im ⋮-Menü:**
|
||||
|
||||
| Element | Funktion |
|
||||
|---------|----------|
|
||||
| **Vorlesen** | Wie wird die Antwort vorgelesen? `📱 Im Gerät` = das Handy liest selbst vor (kein Server-Audio → spart Daten) · `☁ Server` = piper (lokal, zuverlässig) · `✨ Beste Qualität` = chatterbox (natürliche Stimme). „Im Gerät" erscheint nur, wo der Browser es unterstützt. |
|
||||
| **✎ Neues Gespräch** | Frische Sitzung (Verlauf zurücksetzen) |
|
||||
| **🌙 Tag-/Nachtmodus** | Heller/dunkler Modus; folgt sonst dem Betriebssystem |
|
||||
| **Konto** | „Angemeldet als …", **⚙ Admin** (nur Admins → § 7.5), **Abmelden** |
|
||||
|
||||
**Unten:**
|
||||
|
||||
| Element | Funktion |
|
||||
|---------|----------|
|
||||
| **🎤 Mikrofon** | **grün:** tippen → Aufnahme · **rot:** tippen → stoppt & sendet · **amber ⏹:** tippen → KI unterbrechen (Barge-in). STT läuft serverseitig (Whisper). |
|
||||
| **Texteingabe + Senden** | Text tippen, dann Enter oder „Senden" |
|
||||
| **🎤 Mikrofon-Button** | **Idle (grün 🎤):** Tippen → Aufnahme startet · **Aufnahme (rot pulsierend 🎤):** Tippen → Aufnahme stoppt und wird gesendet · **KI antwortet (amber ⏹):** Tippen → Antwort sofort unterbrechen (Barge-in) |
|
||||
| **Sprache ▾** | Antwortsprache wählen (→ § 6.6): `🔄 Flex` = folgt automatisch der gesprochenen Sprache · feste Sprache (🇩🇪/🇬🇧/…) = Eingabe wird in diese Sprache übersetzt. Die vorlesende Stimme folgt der Auswahl. |
|
||||
| **Qualität ▾** (Ausgabe/TTS) | Vorlese-Quelle: `📱 Gerät` = das Handy/der Browser liest selbst vor (Web Speech API, kein Server-Audio → spart Daten/Kosten) · `Schnell` = piper (lokal) · `Hoch` = chatterbox (neuronal) · `Cloud` = openrouter |
|
||||
| **STT ▾** (Eingabe) | *Nur sichtbar auf Geräten mit lokaler Spracherkennung* (iOS/Android): `🎙 Gerät` = Spracherkennung im Gerät (nur Text wird gesendet) · `☁ Server` = Audio-Upload + Whisper (wie am PC/Laptop). Getrennt von der Ausgabe wählbar (→ § 6.3.1). |
|
||||
| **☀️ / 🌙** | Tag-/Nacht-Modus; folgt sonst automatisch dem Betriebssystem |
|
||||
| **⚙️** (Admin) | Öffnet das Admin-Panel — nur für Admin-Nutzer sichtbar (→ § 7.5) |
|
||||
| **Angemeldet als …** | SSO-Identität; „Gast" wenn AUTH deaktiviert oder kein SSO-Cookie |
|
||||
|
||||
#### 5.1.3 Typischer Ablauf — Textchat
|
||||
|
||||
|
|
@ -1013,34 +1027,10 @@ FASTER_WHISPER_DEVICE=cuda
|
|||
FASTER_WHISPER_COMPUTE_TYPE=float16
|
||||
```
|
||||
|
||||
#### 6.3.1 Geräte-STT (Web Speech API) — „🎙 Gerät"
|
||||
|
||||
Auf **Mobilgeräten** (iOS/Android) kann die Spracherkennung **im Gerät** laufen: Das
|
||||
Handy wandelt Sprache in Text um und sendet nur den **Text** an den Server — kein
|
||||
Audio-Upload, keine Server-STT-Last. Gegenstück zum Geräte-TTS (§ 6.5.0), aber
|
||||
**getrennt** wählbar (STT-Dropdown in der Kopfzeile).
|
||||
|
||||
- **Erscheint nur**, wenn das Gerät lokale Spracherkennung bietet. **Terminal, Desktop-
|
||||
Browser und Laptop** zeigen die Option nicht — dort läuft STT wie bisher über den
|
||||
Server (Whisper).
|
||||
- **Datenschutz (Linie C):** Standardmäßig nur bei **nachweislich lokaler** Erkennung
|
||||
(iOS-Safari; Chrome mit On-Device-Modus). **Chrome am Desktop** würde das Audio zur
|
||||
Erkennung an Google schicken — das ist nur erlaubt, wenn ein Admin
|
||||
`ALLOW_CLOUD_STT=true` setzt (Admin → Einstellungen → „Geräte-STT (mobil)").
|
||||
- **Nur Fix-Sprache:** Geräte-STT braucht einen Sprach-Hint → es nutzt die im
|
||||
Sprach-Dropdown gewählte feste Sprache. Im **Flex-Modus** fällt es automatisch auf
|
||||
Server-STT (Whisper-Auto-Erkennung) zurück.
|
||||
- **On-Device-Sprachpakete (Chrome):** Vor der lokalen Erkennung wird per
|
||||
`SpeechRecognition.available()` geprüft, ob das Sprachpaket vorhanden ist. Fehlt es,
|
||||
wird es im Hintergrund heruntergeladen (`install()`) und für **diesen** Turn auf
|
||||
Cloud (falls erlaubt) bzw. Server zurückgefallen — so erscheint kein
|
||||
„language not supported" mehr.
|
||||
- **Fallback:** Ohne Unterstützung (z. B. Firefox), bei fehlendem Sprachpaket oder
|
||||
Erkennungsfehlern (`language-not-supported`, `network` …) wird automatisch der
|
||||
Server-STT-Pfad genutzt. Während der Erkennung erscheint der Text **live** im
|
||||
Eingabefeld; erneutes Tippen aufs Mikrofon beendet und sendet.
|
||||
- **Schnellwahl „📴 Lokal":** Setzt mit einem Tipp **STT + TTS** auf „Gerät" — dann
|
||||
sendet/empfängt das Handy nur noch Text (maximale Daten-/Kostenersparnis).
|
||||
> **Hinweis:** Die Spracherkennung läuft auf **allen** Geräten serverseitig (Whisper) —
|
||||
> einheitlich und zuverlässig. (Ein früher erprobtes Geräte-STT über die Web Speech API
|
||||
> wurde wieder entfernt: Auf Android-Chrome lief es nur über die Google-Cloud, und die
|
||||
> Erkennungsqualität war Whisper unterlegen. Das **Vorlesen** im Gerät (§ 6.5.0) bleibt.)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||
from app.auth import require_user, is_admin_user
|
||||
from app.config import settings
|
||||
from app.dependencies import get_store
|
||||
from app.runtime_config import runtime_settings
|
||||
from app.schemas import UserPrefs, MemoryCreate, MemoryOut
|
||||
from app.store import User
|
||||
|
||||
|
|
@ -19,7 +18,6 @@ async def get_me(user: User = Depends(require_user)):
|
|||
"is_admin": user.is_admin or is_admin_user(user),
|
||||
"prefs": user.prefs,
|
||||
"sso_logout_url": settings.sso_logout_url,
|
||||
"allow_cloud_stt": bool(runtime_settings.allow_cloud_stt),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -135,10 +135,6 @@ class Settings(BaseSettings):
|
|||
faster_whisper_model: str = "base" # tiny|base|small|medium|large-v3
|
||||
faster_whisper_device: str = "auto" # auto|cpu|cuda
|
||||
faster_whisper_compute_type: str = "default" # default|int8|float16|int8_float16
|
||||
# Geräte-STT (Web Speech API) nur bei nachweislich lokaler Erkennung (iOS, Chrome
|
||||
# on-device). True erlaubt es auch bei Cloud-Erkennung (z. B. Chrome-Desktop ->
|
||||
# Audio geht zu Google). Default False = strenger Datenschutz (Linie C).
|
||||
allow_cloud_stt: bool = False
|
||||
# --- Lokales TTS (piper) -------------------------------------------------
|
||||
piper_bin: str = "piper" # Pfad/Name des piper-Binaries
|
||||
piper_voices_dir: str = str(Path.home() / ".local" / "share" / "piper" / "voices")
|
||||
|
|
|
|||
|
|
@ -34,7 +34,6 @@ RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = {
|
|||
"memory_extraction_enabled": ("Erinnerungs-Extraktion", "bool", "true | false"),
|
||||
"memory_extraction_every_n_turns": ("Extraktion alle N Turns", "int", "z.B. 3"),
|
||||
"daily_request_limit": ("Tageskontingent (global)", "int", "0 = unbegrenzt"),
|
||||
"allow_cloud_stt": ("Geräte-STT via Cloud erlauben", "bool", "true = auch Cloud-Erkennung (z.B. Chrome-Desktop -> Audio zu Google); false = nur on-device"),
|
||||
}
|
||||
|
||||
_TTL = 30.0
|
||||
|
|
|
|||
213
app/web/app.js
213
app/web/app.js
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -201,8 +201,9 @@
|
|||
|
||||
<!-- ═══════════════════ CHAT INTERFACE ═══════════════════ -->
|
||||
<header class="shrink-0 h-14 flex items-center gap-2 px-3 border-b border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 backdrop-blur">
|
||||
<img src="/favicon.svg?v=1" alt="" class="w-7 h-7 shrink-0" />
|
||||
<h1 class="flex-1 truncate font-semibold text-base">Voice Assistant</h1>
|
||||
<select id="lang-sel" title="Sprache — feste Sprache (Eingabe wird übersetzt) oder 🔄 Flex (folgt automatisch der gesprochenen Sprache)"
|
||||
<select id="lang-sel" title="Sprache — feste Sprache oder 🔄 Flex (folgt der gesprochenen Sprache)"
|
||||
class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
|
||||
<option value="flex">🔄 Flex</option>
|
||||
<option value="de">🇩🇪 DE</option>
|
||||
|
|
@ -214,33 +215,75 @@
|
|||
<option value="ru">🇷🇺 RU</option>
|
||||
<option value="zh">🇨🇳 ZH</option>
|
||||
</select>
|
||||
<select id="tts" title="Sprachausgabe (TTS)"
|
||||
class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
|
||||
<option value="device">📱 Gerät</option>
|
||||
<option value="piper">Schnell</option>
|
||||
<option value="chatterbox">Hoch</option>
|
||||
<option value="openrouter">Cloud</option>
|
||||
</select>
|
||||
<select id="stt-sel" title="Spracherkennung (STT)"
|
||||
class="hidden text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
|
||||
<option value="device">🎙 Gerät</option>
|
||||
<option value="server">☁ Server</option>
|
||||
</select>
|
||||
<button id="all-local" title="Alles lokal: Spracherkennung + Vorlesen im Gerät (nur Text wird gesendet)"
|
||||
class="hidden text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors whitespace-nowrap">📴 Lokal</button>
|
||||
<button id="new-chat" title="Neues Gespräch (Verlauf zurücksetzen)"
|
||||
class="h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
|
||||
</button>
|
||||
<button id="admin-toggle" title="Admin"
|
||||
class="hidden h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">⚙️</button>
|
||||
<button id="theme-toggle" title="Tag/Nacht"
|
||||
class="h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">🌙</button>
|
||||
<button id="menu-toggle" title="Menü" aria-label="Menü"
|
||||
class="h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 text-xl leading-none transition-colors">⋮</button>
|
||||
</header>
|
||||
|
||||
<div class="shrink-0 flex items-center justify-between px-4 py-1 text-xs text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700">
|
||||
<span id="identity">lade …</span>
|
||||
<a id="logout" class="hidden hover:underline" href="#">Abmelden</a>
|
||||
<!-- Verstecktes Quell-Select: speist die Ton-Presets (bewahrt die bestehende Logik) -->
|
||||
<select id="tts" class="hidden">
|
||||
<option value="device">Im Gerät</option>
|
||||
<option value="piper">Server</option>
|
||||
<option value="chatterbox">Beste Qualität</option>
|
||||
<option value="openrouter">Cloud</option>
|
||||
</select>
|
||||
|
||||
<!-- ═══════════════════ EINSTELLUNGS-SHEET ═══════════════════ -->
|
||||
<div id="settings-backdrop" class="hidden fixed inset-0 z-40 bg-black/30"></div>
|
||||
<div id="settings-sheet"
|
||||
class="hidden fixed top-14 right-2 z-50 w-[min(20rem,calc(100vw-1rem))] max-h-[80vh] overflow-y-auto
|
||||
rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-xl p-3 space-y-3">
|
||||
|
||||
<!-- Vorlesen (TTS-Presets) -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400 mb-1.5 px-1">Vorlesen</p>
|
||||
<div class="space-y-1.5">
|
||||
<button type="button" data-tts="device"
|
||||
class="tts-preset w-full flex items-center gap-3 rounded-lg border border-slate-200 dark:border-slate-700 px-3 py-2 text-left hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||
<span class="text-lg shrink-0">📱</span>
|
||||
<span class="flex-1 min-w-0"><span class="block text-sm font-medium">Im Gerät</span>
|
||||
<span class="block text-xs text-slate-500 dark:text-slate-400">Das Gerät liest vor · spart Daten</span></span>
|
||||
<span class="check text-blue-600 dark:text-blue-400 font-bold opacity-0">✓</span>
|
||||
</button>
|
||||
<button type="button" data-tts="piper"
|
||||
class="tts-preset w-full flex items-center gap-3 rounded-lg border border-slate-200 dark:border-slate-700 px-3 py-2 text-left hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||
<span class="text-lg shrink-0">☁</span>
|
||||
<span class="flex-1 min-w-0"><span class="block text-sm font-medium">Server</span>
|
||||
<span class="block text-xs text-slate-500 dark:text-slate-400">Standard · zuverlässig</span></span>
|
||||
<span class="check text-blue-600 dark:text-blue-400 font-bold opacity-0">✓</span>
|
||||
</button>
|
||||
<button type="button" data-tts="chatterbox"
|
||||
class="tts-preset w-full flex items-center gap-3 rounded-lg border border-slate-200 dark:border-slate-700 px-3 py-2 text-left hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors">
|
||||
<span class="text-lg shrink-0">✨</span>
|
||||
<span class="flex-1 min-w-0"><span class="block text-sm font-medium">Beste Qualität</span>
|
||||
<span class="block text-xs text-slate-500 dark:text-slate-400">Natürliche Stimme</span></span>
|
||||
<span class="check text-blue-600 dark:text-blue-400 font-bold opacity-0">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aktionen -->
|
||||
<div class="border-t border-slate-100 dark:border-slate-700 pt-2 space-y-1">
|
||||
<button id="new-chat" type="button"
|
||||
class="w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-700/50 text-left transition-colors">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" class="w-5 h-5 shrink-0"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
|
||||
Neues Gespräch
|
||||
</button>
|
||||
<button id="theme-toggle" type="button"
|
||||
class="w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-700/50 text-left transition-colors">
|
||||
<span id="theme-icon" class="w-5 text-center shrink-0">🌙</span>
|
||||
<span>Tag-/Nachtmodus</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Konto -->
|
||||
<div class="border-t border-slate-100 dark:border-slate-700 pt-2">
|
||||
<p id="identity" class="text-xs text-slate-500 dark:text-slate-400 px-1 mb-1.5">lade …</p>
|
||||
<div class="flex items-center gap-2 px-1">
|
||||
<button id="admin-toggle" type="button"
|
||||
class="hidden text-xs rounded-lg border border-slate-300 dark:border-slate-600 px-2.5 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">⚙ Admin</button>
|
||||
<a id="logout" href="#" class="hidden text-xs text-blue-600 dark:text-blue-400 hover:underline ml-auto">Abmelden</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main id="messages" class="flex-1 overflow-y-auto px-4 py-4 space-y-3 w-full max-w-3xl mx-auto"></main>
|
||||
|
|
@ -257,6 +300,6 @@
|
|||
<div id="status" class="w-full max-w-3xl mx-auto mt-1 min-h-[1rem] text-xs text-slate-500 dark:text-slate-400"></div>
|
||||
</footer>
|
||||
|
||||
<script src="/app.js?v=34"></script>
|
||||
<script src="/app.js?v=35"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -44,17 +44,3 @@ def test_backend_switch_rejection_is_audited(caplog):
|
|||
assert "action=llm_backend_switch_rejected" in "\n".join(caplog.messages)
|
||||
|
||||
|
||||
def test_me_exposes_allow_cloud_stt():
|
||||
# /api/me liefert das Datenschutz-Flag (Client-Gate für Geräte-STT).
|
||||
d = client.get("/api/me").json()
|
||||
assert "allow_cloud_stt" in d
|
||||
assert isinstance(d["allow_cloud_stt"], bool)
|
||||
|
||||
|
||||
def test_allow_cloud_stt_runtime_settable():
|
||||
from app.runtime_config import RUNTIME_SETTABLE
|
||||
assert "allow_cloud_stt" in RUNTIME_SETTABLE
|
||||
r = client.put("/api/admin/config/allow_cloud_stt", json={"value": "true"}, headers=ADM_HDR)
|
||||
assert r.status_code == 200
|
||||
assert client.get("/api/me").json()["allow_cloud_stt"] is True
|
||||
client.delete("/api/admin/config/allow_cloud_stt", headers=ADM_HDR)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue