feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
"use strict" ;
const $ = ( sel ) => document . querySelector ( sel ) ;
const messagesEl = $ ( "#messages" ) ;
const statusEl = $ ( "#status" ) ;
const promptEl = $ ( "#prompt" ) ;
const formEl = $ ( "#prompt-form" ) ;
const micBtn = $ ( "#mic" ) ;
2026-06-18 09:49:25 +02:00
const ttsSel = $ ( "#tts" ) ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
const langSel = $ ( "#lang-sel" ) ;
2026-06-20 20:08:10 +02:00
const sttSel = $ ( "#stt-sel" ) ;
2026-06-20 20:32:17 +02:00
const allLocalBtn = $ ( "#all-local" ) ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
// "Flex" ist im Sprachmenü nur ein weiterer Wert: keine feste Sprache, sondern
// automatische Erkennung. Eine konkrete Sprache = Fix-Modus (Eingabe wird übersetzt).
// Beide internen Felder (language, language_mode) werden aus dieser einen Auswahl abgeleitet.
function langOverrides ( ) {
const sel = langSel && langSel . value ;
if ( ! sel || sel === "flex" ) return { language _mode : "flex" } ;
return { language : sel , language _mode : "fix" } ;
}
2026-06-18 09:49:25 +02:00
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
// Nutzer-Präferenzen als Request-Overrides für jeden Turn.
2026-06-18 09:49:25 +02:00
function overrides ( ) {
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
const out = { ... langOverrides ( ) } ;
if ( isDeviceMode ( ) ) {
out . text _only = true ; // kein Server-Audio -> Gerät spricht selbst
} else if ( ttsSel && ttsSel . value ) {
out . tts _provider = ttsSel . value ;
}
return out ;
}
async function savePrefs ( obj ) {
try {
await fetch ( "/api/me/prefs" , {
method : "PUT" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( obj ) ,
} ) ;
} catch ( e ) { console . error ( "savePrefs" , e ) ; }
}
// ---------- Geräte-TTS (Web Speech API) ----------
// "Gerät" liest die Antwort lokal vor -> der Server schickt kein Audio (spart Bytes/Kosten).
const TTS _SUPPORTED = typeof window !== "undefined" && "speechSynthesis" in window ;
const LANG _BCP47 = {
de : "de-DE" , en : "en-US" , fr : "fr-FR" , es : "es-ES" ,
it : "it-IT" , nl : "nl-NL" , ru : "ru-RU" , zh : "zh-CN" ,
} ;
function isMobile ( ) {
const ua = navigator . userAgent || "" ;
return /Android|iPhone|iPad|iPod/i . test ( ua ) ||
( navigator . maxTouchPoints > 1 && /Macintosh/ . test ( ua ) ) ; // iPad ab iOS 13
}
function isDeviceMode ( ) { return ! ! ttsSel && ttsSel . value === "device" ; }
let _voices = [ ] ;
function loadVoices ( ) { _voices = TTS _SUPPORTED ? speechSynthesis . getVoices ( ) : [ ] ; }
if ( TTS _SUPPORTED ) {
loadVoices ( ) ;
speechSynthesis . addEventListener ( "voiceschanged" , loadVoices ) ;
}
function pickVoice ( bcp ) {
if ( ! _voices . length ) loadVoices ( ) ;
const lc = bcp . toLowerCase ( ) ;
const two = lc . slice ( 0 , 2 ) ;
return _voices . find ( ( v ) => v . lang && v . lang . toLowerCase ( ) === lc ) ||
_voices . find ( ( v ) => v . lang && v . lang . toLowerCase ( ) . startsWith ( two ) ) || null ;
}
function _makeUtterance ( text , lang ) {
const u = new SpeechSynthesisUtterance ( text ) ;
u . lang = LANG _BCP47 [ lang ] || lang || "de-DE" ;
const v = pickVoice ( u . lang ) ;
if ( v ) u . voice = v ;
return u ;
}
// Vorlesen ohne Button-Status (automatisches Vorlesen der Antwort).
function deviceSpeak ( text , lang ) {
if ( ! TTS _SUPPORTED || ! text ) return ;
speechSynthesis . cancel ( ) ;
speechSynthesis . speak ( _makeUtterance ( text , lang ) ) ;
}
// Vorlesen mit Button-Status (Replay): Icon -> ⏹ während des Sprechens, danach zurück.
function deviceSpeakBtn ( text , lang , btn ) {
if ( ! TTS _SUPPORTED || ! text ) { setReplayPlaying ( btn , false ) ; return ; }
speechSynthesis . cancel ( ) ;
const u = _makeUtterance ( text , lang ) ;
u . onend = u . onerror = ( ) => setReplayPlaying ( btn , false ) ;
setReplayPlaying ( btn , true ) ;
speechSynthesis . speak ( u ) ;
}
// iOS: speechSynthesis darf nur nach einer Nutzergeste starten -> beim ersten Tap freischalten.
let _ttsUnlocked = false ;
function unlockTTS ( ) {
if ( _ttsUnlocked || ! TTS _SUPPORTED ) return ;
try { speechSynthesis . speak ( new SpeechSynthesisUtterance ( "" ) ) ; _ttsUnlocked = true ; } catch ( e ) { }
}
2026-06-20 20:08:10 +02:00
// ---------- 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" ) ; }
2026-06-20 20:32:17 +02:00
// 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 ) ;
2026-06-20 20:08:10 +02:00
}
let recognition = null ;
2026-06-20 20:32:17 +02:00
// 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 ) {
2026-06-20 21:28:39 +02:00
// Chrome (neue API): prüft, ob das On-Device-Sprachpaket vorhanden ist.
2026-06-20 20:32:17 +02:00
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 ) { }
}
2026-06-20 21:28:39 +02:00
return meAllowCloudStt ? "cloud" : "server" ;
2026-06-20 20:32:17 +02:00
} catch ( e ) { /* API-Fehler -> unten */ }
}
2026-06-20 21:28:39 +02:00
// 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.
2026-06-20 20:32:17 +02:00
return meAllowCloudStt ? "cloud" : "server" ;
}
2026-06-20 21:28:39 +02:00
const SERVER _STT _MSG = "Server-Erkennung. Live-Vorschau nur mit lokalem/Cloud-STT" ;
2026-06-20 20:32:17 +02:00
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
2026-06-20 21:28:39 +02:00
startRecording ( SERVER _STT _MSG ) ;
2026-06-20 20:32:17 +02:00
return ;
}
2026-06-20 20:08:10 +02:00
const rec = new SR ( ) ;
2026-06-20 20:32:17 +02:00
rec . lang = bcp ;
2026-06-20 20:08:10 +02:00
rec . interimResults = true ;
rec . continuous = false ;
2026-06-20 20:32:17 +02:00
if ( mode === "local" ) { try { rec . processLocally = true ; } catch ( e ) { } }
2026-06-20 20:08:10 +02:00
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 ;
2026-06-20 20:32:17 +02:00
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" ) {
2026-06-20 21:28:39 +02:00
startRecording ( SERVER _STT _MSG ) ;
2026-06-20 20:32:17 +02:00
return ;
}
2026-06-20 20:08:10 +02:00
setMicIdle ( ) ;
2026-06-20 20:32:17 +02:00
statusEl . textContent = "Spracherkennung fehlgeschlagen (" + err + ")" ;
2026-06-20 20:08:10 +02:00
} ;
rec . onend = ( ) => {
recognition = null ;
setMicIdle ( ) ;
const text = ( promptEl . value || "" ) . trim ( ) ;
promptEl . value = "" ;
if ( text ) sendText ( text ) ; // -> vorhandener Text-Turn (inkl. Geräte-/Server-TTS)
} ;
2026-06-20 20:32:17 +02:00
try { rec . start ( ) ; } catch ( e ) {
recognition = null ;
2026-06-20 21:28:39 +02:00
startRecording ( SERVER _STT _MSG ) ; // Start scheiterte -> Server-Fallback
2026-06-20 20:32:17 +02:00
}
2026-06-20 20:08:10 +02:00
}
function stopDeviceStt ( ) { if ( recognition ) { try { recognition . stop ( ) ; } catch ( e ) { } } }
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
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.
function applyPlaybackChoice ( serverProvider ) {
if ( ! ttsSel ) return ;
// "Gerät"-Option ausblenden, wenn der Browser keine Web Speech API hat.
if ( ! TTS _SUPPORTED ) {
const opt = ttsSel . querySelector ( 'option[value="device"]' ) ;
if ( opt ) opt . remove ( ) ;
}
const local = localStorage . getItem ( PLAYBACK _KEY ) ;
if ( local === "device" && TTS _SUPPORTED ) {
ttsSel . value = "device" ;
} else if ( serverProvider ) {
ttsSel . value = serverProvider ;
} else if ( TTS _SUPPORTED && isMobile ( ) && local === null ) {
ttsSel . value = "device" ; // Mobil-Default, solange nichts gewählt wurde
localStorage . setItem ( PLAYBACK _KEY , "device" ) ;
}
2026-06-18 09:49:25 +02:00
}
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
let busy = false ;
2026-06-18 18:38:33 +02:00
let activeWs = null ; // aktive WS-Verbindung (fuer Barge-in von aussen erreichbar)
let activeSources = [ ] ; // laufende AudioBufferSourceNodes (stoppbar per Barge-in)
2026-06-18 08:50:30 +02:00
// Session-ID pro Nutzer (sonst "gehoert einem anderen Nutzer"-Konflikt). Wird aus
// /api/me abgeleitet; bis dahin null -> ensureSession() wartet auf loadMe().
let sessionId = null ;
let mePromise = null ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
let currentUserId = null ;
const sessionKey = ( uid ) => "va-session-" + uid ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
// ---------- Identitaet / Menue ----------
2026-06-18 08:50:30 +02:00
function loadMe ( ) {
mePromise = ( async ( ) => {
try {
const res = await fetch ( "/api/me" , { headers : { Accept : "application/json" } } ) ;
if ( ! res . ok ) {
$ ( "#identity" ) . textContent = "Nicht angemeldet" ;
return null ;
}
const me = await res . json ( ) ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
currentUserId = me . user _id ;
// Zuletzt aktive (ggf. frische) Session bevorzugen, sonst die Basis-Session.
sessionId = localStorage . getItem ( sessionKey ( me . user _id ) ) || ( "web-" + me . user _id ) ;
2026-06-18 08:50:30 +02:00
$ ( "#identity" ) . textContent = "Angemeldet als " + ( me . display _name || me . external _id || "Gast" ) ;
if ( me . sso _logout _url ) {
const logout = $ ( "#logout" ) ;
logout . href = me . sso _logout _url ;
logout . classList . remove ( "hidden" ) ;
}
if ( me . is _admin ) {
2026-06-18 10:20:24 +02:00
$ ( "#admin-toggle" ) . classList . remove ( "hidden" ) ;
2026-06-18 08:50:30 +02:00
}
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
// Gespeicherte Nutzer-Präferenzen in die Dropdowns laden.
const prefs = me . prefs || { } ;
if ( langSel ) {
// Flex-Modus -> "flex", sonst die feste Sprache (Fallback: Flex).
langSel . value = prefs . language _mode === "flex"
? "flex"
: ( prefs . language || "flex" ) ;
}
applyPlaybackChoice ( prefs . tts _provider ) ;
2026-06-20 20:08:10 +02:00
meAllowCloudStt = ! ! me . allow _cloud _stt ;
applySttChoice ( ) ;
2026-06-18 08:50:30 +02:00
return me ;
} catch ( e ) {
$ ( "#identity" ) . textContent = "Verbindung fehlgeschlagen" ;
return null ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
}
2026-06-18 08:50:30 +02:00
} ) ( ) ;
return mePromise ;
}
// Stellt sicher, dass eine nutzerspezifische Session-ID feststeht, bevor ein Turn startet.
async function ensureSession ( ) {
if ( ! sessionId && mePromise ) {
try { await mePromise ; } catch ( e ) { /* ignore */ }
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
}
2026-06-18 08:50:30 +02:00
if ( ! sessionId ) sessionId = "web-anon" ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
}
2026-06-19 01:04:20 +02:00
// loadUsers() ersetzt durch Admin-Panel (siehe unten)
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
// ---------- Nachrichten-UI ----------
2026-06-18 07:39:40 +02:00
// Nach dem naechsten Layout ans Ende scrollen -> die neueste Antwort ist sichtbar.
function scrollToBottom ( ) {
requestAnimationFrame ( ( ) => { messagesEl . scrollTop = messagesEl . scrollHeight ; } ) ;
}
2026-06-18 10:20:24 +02:00
const BUBBLE = {
user : "ml-auto max-w-[80%] rounded-2xl rounded-br-sm bg-blue-600 px-4 py-2 text-white whitespace-pre-wrap" ,
assistant : "mr-auto max-w-[80%] rounded-2xl rounded-bl-sm border border-slate-200 bg-white px-4 py-2 whitespace-pre-wrap dark:border-slate-700 dark:bg-slate-800" ,
system : "mx-auto max-w-[90%] rounded-lg bg-amber-100 px-3 py-1.5 text-xs text-amber-800 dark:bg-amber-900/40 dark:text-amber-200" ,
emergency : "mx-auto max-w-[90%] rounded-lg bg-red-100 px-3 py-1.5 text-sm font-semibold text-red-800 dark:bg-red-900/40 dark:text-red-200" ,
} ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
// Lautsprecher-/Stop-Icons als SVG (currentColor → per CSS einfärbbar, anders als Emoji).
const ICON _SPEAKER =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" ' +
'class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" ' +
'd="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" /></svg>' ;
const ICON _STOP =
'<svg viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">' +
'<rect x="6" y="6" width="12" height="12" rx="2" /></svg>' ;
// meta.lang: Sprache, in der diese Bubble vorgelesen werden soll (für Replay).
function addMessage ( role , text , meta = { } ) {
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
const div = document . createElement ( "div" ) ;
2026-06-18 10:20:24 +02:00
div . className = BUBBLE [ role ] || BUBBLE . assistant ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
const span = document . createElement ( "span" ) ;
span . className = "bubble-text" ;
span . textContent = text ;
div . appendChild ( span ) ;
div . _textEl = span ;
// Vorlese-Symbol nur an Nutzer-/Assistenten-Bubbles (nicht System/Notfall).
if ( role === "user" || role === "assistant" ) {
div . classList . add ( "relative" , "pr-7" ) ;
div . _replay = { lang : meta . lang || null , chunks : null } ; // chunks: gecachtes PCM (Assistent)
const btn = document . createElement ( "button" ) ;
btn . type = "button" ;
btn . innerHTML = ICON _SPEAKER ;
btn . title = "Vorlesen" ;
btn . setAttribute ( "aria-label" , "Vorlesen" ) ;
// Kontrastreiche Farbe je Bubble: weiß auf Blau; im Dark-Mode hell auf Dunkelgrau.
const replayColor = role === "user"
? "text-white/80 hover:text-white"
: "text-slate-500 hover:text-slate-700 dark:text-slate-300 dark:hover:text-white" ;
btn . className = "replay-btn absolute top-1.5 right-1.5 transition-colors " + replayColor ;
btn . addEventListener ( "click" , ( ) => replayBubble ( div ) ) ;
div . _replayBtn = btn ;
div . appendChild ( btn ) ;
}
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
messagesEl . appendChild ( div ) ;
2026-06-18 07:39:40 +02:00
scrollToBottom ( ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
return div ;
}
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
// ---------- Vorlesen / Replay ----------
function setReplayPlaying ( btn , on ) {
if ( ! btn ) return ;
btn . innerHTML = on ? ICON _STOP : ICON _SPEAKER ;
btn . dataset . playing = on ? "1" : "" ;
}
function startReplay ( chunks , btn ) {
stopAudio ( ) ; // Barge-in: laufendes Audio (auch andere Replays) stoppen
setReplayPlaying ( btn , true ) ;
playPcm ( chunks , 24000 , ( ) => setReplayPlaying ( btn , false ) ) ;
}
async function speakBubble ( text , lang , btn ) {
try {
const body = { text } ;
if ( lang ) body . language = lang ;
if ( ttsSel && ttsSel . value ) body . tts _provider = ttsSel . value ;
const r = await fetch ( "/api/speak" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( body ) ,
} ) ;
if ( ! r . ok ) throw new Error ( "HTTP " + r . status ) ;
startReplay ( [ await r . arrayBuffer ( ) ] , btn ) ;
} catch ( e ) {
setReplayPlaying ( btn , false ) ;
statusEl . textContent = "Vorlesen fehlgeschlagen" ;
}
}
function replayBubble ( div ) {
const btn = div . _replayBtn ;
if ( btn && btn . dataset . playing ) { stopAudio ( ) ; setReplayPlaying ( btn , false ) ; return ; } // Toggle
const info = div . _replay || { } ;
const text = ( div . _textEl && div . _textEl . textContent ) || "" ;
if ( info . chunks && info . chunks . length ) {
startReplay ( info . chunks , btn ) ; // Assistent: gecachtes PCM, sofort & gratis
} else if ( isDeviceMode ( ) ) {
stopAudio ( ) ;
2026-06-20 18:25:45 +02:00
// Geräte-TTS: bereinigtes "spoken" bevorzugen (ohne Markdown/Emojis), sonst Rohtext.
deviceSpeakBtn ( info . spoken || text , info . lang , btn ) ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
} else {
2026-06-20 18:25:45 +02:00
speakBubble ( text , info . lang , btn ) ; // Server-TTS via /api/speak (bereinigt selbst)
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
}
}
2026-06-18 18:38:33 +02:00
// ---------- Barge-in-Hilfsfunktionen ----------
function stopAudio ( ) {
activeSources . forEach ( ( s ) => { try { s . stop ( ) ; } catch ( e ) { } } ) ;
activeSources = [ ] ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
if ( TTS _SUPPORTED ) { try { speechSynthesis . cancel ( ) ; } catch ( e ) { } } // Geräte-TTS stoppen
2026-06-18 18:38:33 +02:00
}
function setMicIdle ( ) {
micBtn . textContent = "🎤" ;
micBtn . title = "Mikrofon" ;
micBtn . classList . remove ( "bg-red-600" , "animate-pulse" , "bg-amber-500" ) ;
micBtn . classList . add ( "bg-emerald-600" ) ;
}
function setMicRecording ( ) {
micBtn . classList . remove ( "bg-emerald-600" , "bg-amber-500" ) ;
micBtn . classList . add ( "bg-red-600" , "animate-pulse" ) ;
micBtn . title = "Aufnahme stoppen" ;
}
function setMicBusy ( ) {
micBtn . textContent = "⏹" ;
micBtn . title = "KI unterbrechen (Barge-in)" ;
micBtn . classList . remove ( "bg-emerald-600" , "bg-red-600" , "animate-pulse" ) ;
micBtn . classList . add ( "bg-amber-500" ) ;
}
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
// ---------- Audio-Wiedergabe (PCM s16le) ----------
let audioCtx = null ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
function playPcm ( chunks , sampleRate , onended ) {
if ( ! chunks . length ) { if ( onended ) onended ( ) ; return ; }
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
const total = chunks . reduce ( ( n , c ) => n + c . byteLength , 0 ) ;
const merged = new Uint8Array ( total ) ;
let off = 0 ;
for ( const c of chunks ) { merged . set ( new Uint8Array ( c ) , off ) ; off += c . byteLength ; }
const view = new DataView ( merged . buffer ) ;
const n = Math . floor ( merged . byteLength / 2 ) ;
audioCtx = audioCtx || new ( window . AudioContext || window . webkitAudioContext ) ( ) ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
if ( audioCtx . state === "suspended" ) audioCtx . resume ( ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
const buf = audioCtx . createBuffer ( 1 , n , sampleRate || 24000 ) ;
const ch = buf . getChannelData ( 0 ) ;
for ( let i = 0 ; i < n ; i ++ ) ch [ i ] = view . getInt16 ( i * 2 , true ) / 32768 ;
const src = audioCtx . createBufferSource ( ) ;
src . buffer = buf ;
src . connect ( audioCtx . destination ) ;
2026-06-18 18:38:33 +02:00
activeSources . push ( src ) ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
src . onended = ( ) => {
activeSources = activeSources . filter ( ( s ) => s !== src ) ;
if ( onended ) onended ( ) ;
} ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
src . start ( ) ;
}
// ---------- WS-Turn (Text und Sprache teilen die Event-Logik) ----------
function wsUrl ( path ) {
const proto = location . protocol === "https:" ? "wss" : "ws" ;
2026-06-18 08:50:30 +02:00
return ` ${ proto } :// ${ location . host } ${ path } ?session_id= ${ encodeURIComponent ( sessionId ) } ` ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
}
// onopen: (ws) => sendet die Eingabe. Liefert ein Promise, das beim done-Event endet.
function runTurn ( path , onopen ) {
return new Promise ( ( resolve ) => {
const ws = new WebSocket ( wsUrl ( path ) ) ;
2026-06-18 18:38:33 +02:00
activeWs = ws ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
ws . binaryType = "arraybuffer" ;
const pcm = [ ] ;
let answerEl = null ;
let sampleRate = 24000 ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
// Sprache dieses Turns (für den Replay je Bubble): Flex folgt erkannter Sprache.
let routeLang = null , routeMode = null , detectedLang = null ;
const turnLang = ( ) =>
( routeMode === "flex" ? ( detectedLang || routeLang ) : routeLang ) || null ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
ws . onopen = ( ) => onopen ( ws ) ;
ws . onerror = ( ) => { statusEl . textContent = "Verbindungsfehler" ; resolve ( ) ; } ;
2026-06-18 18:38:33 +02:00
ws . onclose = ( ) => { activeWs = null ; resolve ( ) ; } ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
ws . onmessage = ( event ) => {
if ( typeof event . data !== "string" ) { pcm . push ( event . data ) ; return ; }
let msg ;
try { msg = JSON . parse ( event . data ) ; } catch { return ; }
switch ( msg . type ) {
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
case "ack" :
routeLang = ( msg . route && msg . route . language ) || null ;
routeMode = ( msg . route && msg . route . language _mode ) || null ;
break ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
case "transcript" :
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
detectedLang = msg . detected _language || null ;
if ( msg . text ) addMessage ( "user" , msg . text , { lang : turnLang ( ) } ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
break ;
case "token" :
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
if ( ! answerEl ) answerEl = addMessage ( "assistant" , "" , { lang : turnLang ( ) } ) ;
answerEl . _textEl . textContent += msg . text || "" ;
2026-06-18 07:39:40 +02:00
scrollToBottom ( ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
break ;
case "semantic" :
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
if ( ! answerEl ) answerEl = addMessage ( "assistant" , "" , { lang : turnLang ( ) } ) ;
if ( msg . text ) answerEl . _textEl . textContent = msg . text ;
// Sprache final festhalten (für Replay) und im Geräte-Modus lokal vorlesen.
2026-06-20 18:25:45 +02:00
// Für TTS das bereinigte "spoken" (ohne Markdown/Emojis) bevorzugen, anzeigen
// bleibt der Originaltext.
if ( answerEl . _replay ) {
answerEl . _replay . lang = turnLang ( ) ;
answerEl . _replay . spoken = msg . spoken || msg . text || "" ;
}
if ( isDeviceMode ( ) ) {
const spoken = msg . spoken || msg . text ;
if ( spoken ) deviceSpeak ( spoken , turnLang ( ) ) ;
}
2026-06-18 07:39:40 +02:00
scrollToBottom ( ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
break ;
case "emergency" :
addMessage ( "emergency" , "⚠ Notfall erkannt (" + ( msg . category || "?" ) + ")" ) ;
break ;
case "done" :
sampleRate = msg . sample _rate || 24000 ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
// Geräte-Modus: kein Server-Audio -> nichts abspielen/cachen (Gerät hat schon gesprochen).
if ( ! isDeviceMode ( ) ) {
if ( answerEl && answerEl . _replay ) answerEl . _replay . chunks = pcm . slice ( ) ; // Replay-Cache
playPcm ( pcm , sampleRate ) ;
}
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
statusEl . textContent = "" ;
2026-06-18 07:39:40 +02:00
scrollToBottom ( ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
ws . close ( ) ;
break ;
case "error" :
addMessage ( "system" , "Fehler: " + ( msg . detail || msg . status || "unbekannt" ) ) ;
ws . close ( ) ;
break ;
2026-06-18 19:03:03 +02:00
case "interrupted" :
statusEl . textContent = "" ;
ws . close ( ) ; // → ws.onclose → resolve() → busy=false → setMicIdle()
break ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
}
} ;
} ) ;
}
// ---------- Text senden ----------
async function sendText ( text ) {
if ( busy || ! text . trim ( ) ) return ;
busy = true ;
2026-06-18 18:38:33 +02:00
setMicBusy ( ) ;
2026-06-18 08:50:30 +02:00
await ensureSession ( ) ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
// Bei fester Sprache wird der eigene Text in dieser Sprache vorgelesen; bei Flex offen.
const lo = langOverrides ( ) ;
addMessage ( "user" , text , { lang : lo . language _mode === "fix" ? lo . language : null } ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
statusEl . textContent = "denkt …" ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
const audioStream = ! isDeviceMode ( ) ; // Geräte-Modus: kein Server-Audio anfordern
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
await runTurn ( "/ws/chat" , ( ws ) => {
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
ws . send ( JSON . stringify ( { text , stream : true , audio _stream : audioStream , ... overrides ( ) } ) ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
} ) ;
busy = false ;
2026-06-18 18:38:33 +02:00
setMicIdle ( ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
}
formEl . addEventListener ( "submit" , ( e ) => {
e . preventDefault ( ) ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
unlockTTS ( ) ; // iOS: Geräte-TTS bei der Nutzergeste freischalten
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
const text = promptEl . value ;
promptEl . value = "" ;
sendText ( text ) ;
} ) ;
// ---------- Mikrofon (Push-to-Talk) ----------
let mediaRecorder = null ;
let recChunks = [ ] ;
2026-06-20 21:28:39 +02:00
async function startRecording ( statusText ) {
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
if ( busy ) return ;
2026-06-18 10:39:02 +02:00
const md = navigator . mediaDevices ;
if ( ! md || ! md . getUserMedia ) {
addMessage ( "system" , "Mikrofon nicht freigegeben: Bitte die Seite direkt in Safari öffnen (nicht im In-App-Browser) und keinen Privat-Modus verwenden." ) ;
return ;
}
if ( typeof MediaRecorder === "undefined" ) {
addMessage ( "system" , "Aufnahme nicht unterstützt (iOS < 14.3?). Bitte iOS aktualisieren." ) ;
return ;
}
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
let stream ;
try {
2026-06-18 10:39:02 +02:00
stream = await md . getUserMedia ( { audio : true } ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
} catch ( e ) {
2026-06-18 10:39:02 +02:00
addMessage ( "system" , "Mikrofon-Zugriff fehlgeschlagen: " + ( e . name || "" ) +
". Tipp: iPhone → Einstellungen → Safari → Mikrofon = Erlauben/Fragen." ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
return ;
}
recChunks = [ ] ;
2026-06-18 10:39:02 +02:00
try {
mediaRecorder = new MediaRecorder ( stream ) ;
} catch ( e ) {
stream . getTracks ( ) . forEach ( ( t ) => t . stop ( ) ) ;
addMessage ( "system" , "Aufnahme nicht möglich: " + ( e . name || e . message ) ) ;
return ;
}
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
mediaRecorder . ondataavailable = ( e ) => { if ( e . data . size ) recChunks . push ( e . data ) ; } ;
mediaRecorder . onstop = async ( ) => {
stream . getTracks ( ) . forEach ( ( t ) => t . stop ( ) ) ;
2026-06-18 10:39:02 +02:00
const mime = mediaRecorder . mimeType || "audio/mp4" ;
const blob = new Blob ( recChunks , { type : mime } ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
const bytes = await blob . arrayBuffer ( ) ;
2026-06-18 10:39:02 +02:00
// Safari -> audio/mp4, andere -> webm; der Server erkennt das Format am Inhalt.
sendVoice ( bytes , mime . includes ( "mp4" ) || mime . includes ( "mpeg" ) ? "mp4" : "webm" ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
} ;
mediaRecorder . start ( ) ;
2026-06-18 18:38:33 +02:00
setMicRecording ( ) ;
2026-06-20 21:28:39 +02:00
statusEl . textContent = statusText || "Aufnahme … (zum Stoppen erneut tippen)" ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
}
function stopRecording ( ) {
if ( mediaRecorder && mediaRecorder . state !== "inactive" ) mediaRecorder . stop ( ) ;
2026-06-18 18:38:33 +02:00
setMicIdle ( ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
}
2026-06-18 10:39:02 +02:00
async function sendVoice ( bytes , fmt = "webm" ) {
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
if ( busy ) return ;
busy = true ;
2026-06-18 18:38:33 +02:00
setMicBusy ( ) ;
2026-06-18 08:50:30 +02:00
await ensureSession ( ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
statusEl . textContent = "verarbeite Sprache …" ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
const audioStream = ! isDeviceMode ( ) ; // Geräte-Modus: kein Server-Audio anfordern
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
await runTurn ( "/ws/voice" , ( ws ) => {
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
ws . send ( JSON . stringify ( { type : "start" , format : fmt , stream : true , audio _stream : audioStream , ... overrides ( ) } ) ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
ws . send ( bytes ) ;
ws . send ( JSON . stringify ( { type : "end" } ) ) ;
} ) ;
busy = false ;
2026-06-18 18:38:33 +02:00
setMicIdle ( ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
}
micBtn . addEventListener ( "click" , ( ) => {
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
unlockTTS ( ) ; // iOS: Geräte-TTS bei der Nutzergeste freischalten
2026-06-18 18:38:33 +02:00
if ( busy ) {
// Barge-in: laufende KI-Antwort sofort unterbrechen
if ( activeWs ) activeWs . send ( JSON . stringify ( { type : "interrupt" } ) ) ;
stopAudio ( ) ;
statusEl . textContent = "Unterbrochen" ;
// busy + Button-Reset erfolgen sobald die WS schliesst (-> runTurn resolve -> sendVoice/sendText)
2026-06-20 20:08:10 +02:00
} else if ( recognition ) {
stopDeviceStt ( ) ; // Geräte-STT läuft -> stoppen (onend sendet den Text)
2026-06-18 18:38:33 +02:00
} else if ( mediaRecorder && mediaRecorder . state === "recording" ) {
stopRecording ( ) ;
2026-06-20 20:08:10 +02:00
} else if ( useDeviceStt ( ) ) {
startDeviceStt ( ) ; // lokale Spracherkennung (nur Fix + erlaubt)
2026-06-18 18:38:33 +02:00
} else {
2026-06-20 20:08:10 +02:00
startRecording ( ) ; // Server-STT (Upload) — wie bisher, auch am Desktop
2026-06-18 18:38:33 +02:00
}
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
} ) ;
2026-06-19 01:04:20 +02:00
$ ( "#admin-toggle" ) . addEventListener ( "click" , openAdminPanel ) ;
2026-06-18 10:20:24 +02:00
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
// ---------- Neues Gespräch ----------
// Startet eine frische Session (neuer session_id) -> der bisherige Verlauf wird nicht
// mehr als LLM-Kontext genutzt. Behebt "Sprache springt nicht um", wenn die History
// in einer anderen Sprache dominiert. Nicht-destruktiv: alte Session bleibt in der DB.
function newConversation ( ) {
const base = currentUserId || "anon" ;
sessionId = "web-" + base + "-" + Date . now ( ) ;
if ( currentUserId ) localStorage . setItem ( sessionKey ( currentUserId ) , sessionId ) ;
stopAudio ( ) ;
messagesEl . innerHTML = "" ;
statusEl . textContent = "Neues Gespräch begonnen" ;
setTimeout ( ( ) => {
if ( statusEl . textContent === "Neues Gespräch begonnen" ) statusEl . textContent = "" ;
} , 2500 ) ;
}
$ ( "#new-chat" ) . addEventListener ( "click" , newConversation ) ;
2026-06-18 10:20:24 +02:00
// 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" ) ? "☀️" : "🌙" ) ;
themeBtn . addEventListener ( "click" , ( ) => {
const dark = document . documentElement . classList . toggle ( "dark" ) ;
localStorage . setItem ( "theme" , dark ? "dark" : "light" ) ;
setThemeIcon ( ) ;
} ) ;
matchMedia ( "(prefers-color-scheme: dark)" ) . addEventListener ( "change" , ( e ) => {
if ( ! localStorage . getItem ( "theme" ) ) {
document . documentElement . classList . toggle ( "dark" , e . matches ) ;
setThemeIcon ( ) ;
}
} ) ;
setThemeIcon ( ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
2026-06-18 07:39:40 +02:00
// Wenn die Bildschirmtastatur die sichtbare Hoehe aendert (iOS), unten bleiben.
if ( window . visualViewport ) {
window . visualViewport . addEventListener ( "resize" , scrollToBottom ) ;
}
window . addEventListener ( "resize" , scrollToBottom ) ;
promptEl . addEventListener ( "focus" , ( ) => setTimeout ( scrollToBottom , 300 ) ) ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
// Dropdown-Änderungen dauerhaft als Nutzer-Präferenz speichern.
if ( langSel ) langSel . addEventListener ( "change" , ( ) => savePrefs ( langOverrides ( ) ) ) ;
if ( ttsSel ) {
ttsSel . addEventListener ( "change" , ( ) => {
unlockTTS ( ) ; // Auswahl ist eine Nutzergeste -> Geräte-TTS gleich freischalten
if ( ttsSel . value === "device" ) {
// Geräte-TTS ist geräte-lokal (nicht server-seitig) -> nur lokal merken.
localStorage . setItem ( PLAYBACK _KEY , "device" ) ;
} else {
localStorage . removeItem ( PLAYBACK _KEY ) ;
savePrefs ( { tts _provider : ttsSel . value || null } ) ; // echter Provider -> Server-Pref
}
} ) ;
}
2026-06-20 20:08:10 +02:00
// STT-Wahl ist geräte-lokal (nicht server-seitig) -> nur in localStorage.
if ( sttSel ) sttSel . addEventListener ( "change" , ( ) => localStorage . setItem ( STT _KEY , sttSel . value ) ) ;
2026-06-20 20:32:17 +02:00
if ( allLocalBtn ) allLocalBtn . addEventListener ( "click" , setAllLocal ) ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
loadMe ( ) ;
2026-06-19 01:04:20 +02:00
// ═══════════════════════════════════════════════════════════════
// ADMIN PANEL
// ═══════════════════════════════════════════════════════════════
// ---------- Hilfsfunktionen ----------
function escHtml ( str ) {
2026-06-19 01:33:49 +02:00
if ( str === null || str === undefined || str === "" ) return "" ;
return String ( str ) . replace ( /&/g , "&" ) . replace ( /</g , "<" ) . replace ( />/g , ">" ) . replace ( /"/g , """ ) ;
2026-06-19 01:04:20 +02:00
}
function fmtDate ( iso ) {
if ( ! iso ) return "—" ;
try {
return new Date ( iso ) . toLocaleString ( "de-DE" , {
day : "2-digit" , month : "2-digit" , year : "2-digit" ,
hour : "2-digit" , minute : "2-digit" ,
} ) ;
} catch { return iso ; }
}
async function adminFetch ( url , method = "GET" , body = null ) {
try {
const opts = { method , headers : { "Content-Type" : "application/json" } } ;
if ( body ) opts . body = JSON . stringify ( body ) ;
const res = await fetch ( url , opts ) ;
if ( method === "DELETE" && res . status === 204 ) return { } ;
const data = await res . json ( ) . catch ( ( ) => null ) ;
if ( ! res . ok ) { console . error ( "Admin API" , res . status , data ) ; return null ; }
return data ;
} catch ( e ) { console . error ( "adminFetch" , e ) ; return null ; }
}
// ---------- Panel öffnen / schließen ----------
function openAdminPanel ( ) {
$ ( "#admin-panel" ) . classList . remove ( "hidden" ) ;
switchAdminTab ( "users" ) ;
}
$ ( "#admin-close" ) . addEventListener ( "click" , ( ) => $ ( "#admin-panel" ) . classList . add ( "hidden" ) ) ;
// ---------- Tab-Umschalter ----------
function switchAdminTab ( name ) {
document . querySelectorAll ( '[id^="admin-tab-"]' ) . forEach ( ( el ) => el . classList . add ( "hidden" ) ) ;
$ ( "#admin-tab-" + name ) . classList . remove ( "hidden" ) ;
document . querySelectorAll ( ".admin-tab" ) . forEach ( ( btn ) => {
const active = btn . dataset . tab === name ;
btn . classList . toggle ( "border-blue-600" , active ) ;
btn . classList . toggle ( "text-blue-600" , active ) ;
btn . classList . toggle ( "dark:border-blue-400" , active ) ;
btn . classList . toggle ( "dark:text-blue-400" , active ) ;
btn . classList . toggle ( "border-transparent" , ! active ) ;
btn . classList . toggle ( "text-slate-500" , ! active ) ;
} ) ;
if ( name === "users" ) loadAdminUsers ( ) ;
if ( name === "chat" ) loadChatUsers ( ) ;
if ( name === "emergency" ) loadEmergencyEvents ( ) ;
if ( name === "status" ) loadStatus ( ) ;
if ( name === "metrics" ) loadMetrics ( ) ;
2026-06-19 01:15:28 +02:00
if ( name === "words" ) loadWoerterbuch ( ) ;
if ( name === "log" ) { /* User muss manuell verbinden */ }
2026-06-19 01:39:36 +02:00
if ( name === "settings" ) loadSettings ( ) ;
2026-06-19 01:04:20 +02:00
}
document . querySelectorAll ( ".admin-tab" ) . forEach ( ( btn ) =>
btn . addEventListener ( "click" , ( ) => switchAdminTab ( btn . dataset . tab ) )
) ;
$ ( "#load-emergency" ) . addEventListener ( "click" , loadEmergencyEvents ) ;
$ ( "#load-status" ) . addEventListener ( "click" , loadStatus ) ;
$ ( "#load-metrics" ) . addEventListener ( "click" , loadMetrics ) ;
// ════════════════════════════════════════
// TAB: NUTZER
// ════════════════════════════════════════
async function loadAdminUsers ( ) {
const container = $ ( "#admin-user-cards" ) ;
container . innerHTML = '<p class="text-sm text-slate-400 text-center py-4">lade …</p>' ;
const users = await adminFetch ( "/api/admin/users" ) ;
if ( ! users ) { container . innerHTML = '<p class="text-sm text-red-500 text-center py-4">Fehler beim Laden.</p>' ; return ; }
if ( ! users . length ) { container . innerHTML = '<p class="text-sm text-slate-400 text-center py-4">Noch keine Nutzer.</p>' ; return ; }
container . innerHTML = "" ;
users . forEach ( ( u ) => container . appendChild ( buildUserCard ( u ) ) ) ;
}
$ ( "#create-user-form" ) . addEventListener ( "submit" , async ( e ) => {
e . preventDefault ( ) ;
const nameEl = $ ( "#new-user-name" ) ;
const name = nameEl . value . trim ( ) ;
if ( ! name ) return ;
const data = await adminFetch ( "/api/admin/users" , "POST" , { display _name : name } ) ;
if ( data ? . token ) {
$ ( "#new-token-value" ) . textContent = data . token ;
$ ( "#new-token-box" ) . classList . remove ( "hidden" ) ;
nameEl . value = "" ;
loadAdminUsers ( ) ;
}
} ) ;
function buildUserCard ( u ) {
const card = document . createElement ( "div" ) ;
card . className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" ;
const badgeSSO = u . external _id
? ` <span class="rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 text-xs px-2 py-0.5">SSO</span> ` : "" ;
card . innerHTML = `
< div class = "flex items-start gap-3 flex-wrap" >
< div class = "flex-1 min-w-0" >
< div class = "flex items-center gap-2 flex-wrap mb-0.5" >
< span class = "font-semibold text-slate-900 dark:text-slate-100 user-dn" > $ { escHtml ( u . display _name ) } < / s p a n >
$ { badgeSSO }
< / d i v >
< p class = "text-xs text-slate-400 font-mono" > $ { u . user _id . slice ( 0 , 8 ) } … $ { u . external _id ? " · " + escHtml ( u . external _id ) : "" } < / p >
< p class = "text-xs text-slate-400" > seit $ { fmtDate ( u . created _at ) } < / p >
< / d i v >
< div class = "flex flex-wrap gap-1.5" >
< button class = "btn-rename 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" > Umbenennen < / b u t t o n >
< button class = "btn-token text-xs rounded-lg border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 px-2.5 py-1.5 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors" > Token - Reset < / b u t t o n >
< button class = "btn-delete text-xs rounded-lg border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 px-2.5 py-1.5 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors" > Löschen < / b u t t o n >
< / d i v >
< / d i v >
<!-- Token - Anzeige nach Reset -- >
< div class = "token-box hidden mt-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 p-3" >
< p class = "text-xs text-amber-700 dark:text-amber-300 font-medium mb-1" > Neuer Token ( einmalig — jetzt kopieren ! ) : < / p >
< code class = "token-val text-xs font-mono break-all text-amber-900 dark:text-amber-100 select-all" > < / c o d e >
< / d i v >
<!-- Umbenenn - Formular -- >
< div class = "rename-box hidden mt-3" >
< form class = "flex gap-2" >
< input type = "text" class = "rename-inp flex-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value = "${escHtml(u.display_name)}" / >
< button type = "submit" class = "rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm px-3 py-1.5 transition-colors" > Speichern < / b u t t o n >
< button type = "button" class = "rename-cancel rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors" > Abbrechen < / b u t t o n >
< / f o r m >
< / d i v >
<!-- Erinnerungen -- >
< div class = "mt-3 pt-3 border-t border-slate-100 dark:border-slate-700" >
< button class = "btn-mem text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 flex items-center gap-1 transition-colors" >
< span class = "mem-arrow" > ▶ < / s p a n > E r i n n e r u n g e n
< / b u t t o n >
< div class = "mem-section hidden mt-2 space-y-1.5" >
< div class = "mem-list" > < / d i v >
< form class = "mem-add-form flex gap-2 mt-2" >
< input type = "text" placeholder = "Neue Erinnerung …"
class = "flex-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" / >
< button type = "submit" class = "rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-1.5 transition-colors" > Hinzufügen < / b u t t o n >
< / f o r m >
< / d i v >
< / d i v >
` ;
const uid = u . user _id ;
// Umbenennen
card . querySelector ( ".btn-rename" ) . addEventListener ( "click" , ( ) =>
card . querySelector ( ".rename-box" ) . classList . toggle ( "hidden" )
) ;
card . querySelector ( ".rename-cancel" ) . addEventListener ( "click" , ( ) =>
card . querySelector ( ".rename-box" ) . classList . add ( "hidden" )
) ;
card . querySelector ( ".rename-box form" ) . addEventListener ( "submit" , async ( e ) => {
e . preventDefault ( ) ;
const name = card . querySelector ( ".rename-inp" ) . value . trim ( ) ;
if ( ! name ) return ;
const res = await adminFetch ( ` /api/admin/users/ ${ uid } ` , "PUT" , { display _name : name } ) ;
if ( res ) {
card . querySelector ( ".user-dn" ) . textContent = name ;
card . querySelector ( ".rename-box" ) . classList . add ( "hidden" ) ;
}
} ) ;
// Token-Reset
card . querySelector ( ".btn-token" ) . addEventListener ( "click" , async ( ) => {
if ( ! confirm ( ` Token für „ ${ u . display _name } " zurücksetzen? \n Der alte Token wird sofort ungültig. ` ) ) return ;
const data = await adminFetch ( ` /api/admin/users/ ${ uid } /token ` , "POST" ) ;
if ( data ? . token ) {
card . querySelector ( ".token-val" ) . textContent = data . token ;
card . querySelector ( ".token-box" ) . classList . remove ( "hidden" ) ;
}
} ) ;
// Löschen
card . querySelector ( ".btn-delete" ) . addEventListener ( "click" , async ( ) => {
if ( ! confirm ( ` Nutzer „ ${ u . display _name } " und alle Daten unwiderruflich löschen? ` ) ) return ;
const res = await adminFetch ( ` /api/admin/users/ ${ uid } ` , "DELETE" ) ;
if ( res !== null ) card . remove ( ) ;
} ) ;
// Erinnerungen auf-/zuklappen
const memBtn = card . querySelector ( ".btn-mem" ) ;
const memSection = card . querySelector ( ".mem-section" ) ;
const memArrow = card . querySelector ( ".mem-arrow" ) ;
memBtn . addEventListener ( "click" , async ( ) => {
const open = ! memSection . classList . contains ( "hidden" ) ;
memSection . classList . toggle ( "hidden" , open ) ;
memArrow . textContent = open ? "▶" : "▼" ;
if ( ! open ) await renderMemories ( uid , card . querySelector ( ".mem-list" ) ) ;
} ) ;
// Erinnerung hinzufügen
card . querySelector ( ".mem-add-form" ) . addEventListener ( "submit" , async ( e ) => {
e . preventDefault ( ) ;
const inp = e . target . querySelector ( "input" ) ;
const content = inp . value . trim ( ) ;
if ( ! content ) return ;
const res = await adminFetch ( ` /api/admin/users/ ${ uid } /memories ` , "POST" , { content } ) ;
if ( res ) { inp . value = "" ; await renderMemories ( uid , card . querySelector ( ".mem-list" ) ) ; }
} ) ;
return card ;
}
async function renderMemories ( uid , container ) {
container . innerHTML = '<p class="text-xs text-slate-400">lade …</p>' ;
const mems = await adminFetch ( ` /api/admin/users/ ${ uid } /memories ` ) ;
if ( ! mems ? . length ) {
container . innerHTML = '<p class="text-xs text-slate-400 italic">Keine Erinnerungen vorhanden.</p>' ;
return ;
}
container . innerHTML = "" ;
mems . forEach ( ( m ) => {
const row = document . createElement ( "div" ) ;
row . className = "flex items-start gap-2 rounded-lg bg-slate-50 dark:bg-slate-700/50 px-3 py-2 text-xs" ;
row . innerHTML = `
< span class = "flex-1 text-slate-700 dark:text-slate-300 leading-relaxed" > $ { escHtml ( m . content ) } < / s p a n >
< button class = "shrink-0 text-slate-400 hover:text-red-500 dark:hover:text-red-400 transition-colors font-bold" title = "Löschen" > ✕ < / b u t t o n >
` ;
row . querySelector ( "button" ) . addEventListener ( "click" , async ( ) => {
await adminFetch ( ` /api/admin/users/ ${ uid } /memories/ ${ m . id } ` , "DELETE" ) ;
row . remove ( ) ;
} ) ;
container . appendChild ( row ) ;
} ) ;
}
// ════════════════════════════════════════
// TAB: GESPRÄCHE
// ════════════════════════════════════════
let _chatUser = null ;
async function loadChatUsers ( ) {
const list = $ ( "#chat-user-list" ) ;
list . innerHTML = '<li class="px-3 py-3 text-sm text-slate-400">lade …</li>' ;
const users = await adminFetch ( "/api/admin/users" ) ;
if ( ! users ) return ;
list . innerHTML = "" ;
users . forEach ( ( u ) => {
const li = document . createElement ( "li" ) ;
li . className = "px-3 py-2.5 text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex flex-col" ;
li . innerHTML = ` <span class="font-medium truncate"> ${ escHtml ( u . display _name ) } </span>
< span class = "text-xs text-slate-400 font-mono truncate" > $ { u . user _id . slice ( 0 , 8 ) } … < / s p a n > ` ;
li . addEventListener ( "click" , ( ) => {
document . querySelectorAll ( "#chat-user-list li" ) . forEach ( ( el ) =>
el . classList . remove ( "bg-blue-50" , "dark:bg-blue-900/20" , "text-blue-700" , "dark:text-blue-300" )
) ;
li . classList . add ( "bg-blue-50" , "dark:bg-blue-900/20" ) ;
_chatUser = u ;
loadChatSessions ( u ) ;
} ) ;
list . appendChild ( li ) ;
} ) ;
}
async function loadChatSessions ( u ) {
const panel = $ ( "#chat-right-panel" ) ;
panel . innerHTML = ` <p class="p-4 text-sm text-slate-400">lade Sessions für ${ escHtml ( u . display _name ) } …</p> ` ;
const sessions = await adminFetch ( ` /api/admin/users/ ${ u . user _id } /sessions ` ) ;
if ( ! sessions ? . length ) {
panel . innerHTML = '<p class="p-4 text-sm text-slate-400">Keine Sessions gefunden.</p>' ;
return ;
}
panel . innerHTML = `
< div class = "px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-slate-50/80 dark:bg-slate-800/50" >
< p class = "text-xs font-semibold text-slate-500 uppercase tracking-wide" > Sessions · $ { escHtml ( u . display _name ) } < / p >
< / d i v >
< div id = "session-list" class = "divide-y divide-slate-100 dark:divide-slate-800/60" > < / d i v >
` ;
const list = panel . querySelector ( "#session-list" ) ;
sessions . forEach ( ( s ) => {
const row = document . createElement ( "div" ) ;
row . className = "px-4 py-3 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" ;
row . innerHTML = `
< p class = "text-sm font-mono text-slate-700 dark:text-slate-300 truncate" > $ { escHtml ( s . id ) } < / p >
< p class = "text-xs text-slate-400 mt-0.5" > $ { fmtDate ( s . updated _at ) } · $ { s . msg _count } Nachrichten < / p >
` ;
row . addEventListener ( "click" , ( ) => loadTranscript ( s . id ) ) ;
list . appendChild ( row ) ;
} ) ;
}
async function loadTranscript ( sessionId ) {
const panel = $ ( "#chat-right-panel" ) ;
panel . innerHTML = ` <p class="p-4 text-sm text-slate-400">lade Transkript …</p> ` ;
const msgs = await adminFetch ( ` /api/admin/sessions/ ${ sessionId } /messages ` ) ;
if ( ! msgs ? . length ) {
panel . innerHTML = ` <div class="p-4">
< button class = "back-btn text-xs text-blue-600 hover:underline mb-3 block" > ← zurück zu Sessions < / b u t t o n >
< p class = "text-sm text-slate-400" > Keine Nachrichten . < / p > < / d i v > ` ;
panel . querySelector ( ".back-btn" ) . addEventListener ( "click" , ( ) => _chatUser && loadChatSessions ( _chatUser ) ) ;
return ;
}
const bubbles = msgs . map ( ( m ) => {
const isUser = m . role === "user" ;
return ` <div class="flex ${ isUser ? "justify-end" : "justify-start" } ">
< div class = " max - w - [ 80 % ] rounded - 2 xl px - 4 py - 2 text - sm whitespace - pre - wrap leading - relaxed
$ { isUser
? "rounded-br-sm bg-blue-600 text-white"
: "rounded-bl-sm border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100" } " >
$ { escHtml ( m . content ) } < / d i v > < / d i v > ` ;
} ) . join ( "" ) ;
panel . innerHTML = `
< div class = "sticky top-0 px-4 py-2.5 border-b border-slate-200 dark:border-slate-700 bg-white/90 dark:bg-slate-800/90 backdrop-blur flex items-center gap-3" >
< button class = "back-btn text-xs text-blue-600 hover:underline" > ← Sessions < / b u t t o n >
< span class = "text-xs text-slate-400 font-mono truncate" > $ { escHtml ( sessionId ) } < / s p a n >
< / d i v >
< div class = "p-4 space-y-3" > $ { bubbles } < / d i v >
` ;
panel . querySelector ( ".back-btn" ) . addEventListener ( "click" , ( ) => _chatUser && loadChatSessions ( _chatUser ) ) ;
}
// ════════════════════════════════════════
// TAB: NOTFÄLLE
// ════════════════════════════════════════
async function loadEmergencyEvents ( ) {
const container = $ ( "#emergency-content" ) ;
container . innerHTML = '<p class="text-sm text-slate-400">lade …</p>' ;
const events = await adminFetch ( "/api/admin/emergency-events" ) ;
if ( ! events ) { container . innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>' ; return ; }
if ( ! events . length ) { container . innerHTML = '<p class="text-sm text-slate-400 py-4 text-center">Keine Notfall-Ereignisse protokolliert.</p>' ; return ; }
container . innerHTML = `
< div class = "overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700" >
< table class = "w-full text-sm" >
< thead class = "bg-slate-50 dark:bg-slate-800/60" >
< tr class = "text-xs text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700" >
< th class = "text-left px-4 py-2.5" > Zeitpunkt < / t h >
< th class = "text-left px-4 py-2.5" > Nutzer < / t h >
< th class = "text-left px-4 py-2.5" > Kategorie < / t h >
< th class = "text-left px-4 py-2.5" > Textausschnitt < / t h >
< / t r >
< / t h e a d >
< tbody class = "divide-y divide-slate-100 dark:divide-slate-800 bg-white dark:bg-slate-800" >
$ { events . map ( ( ev ) => `
< tr class = "hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors" >
< td class = "px-4 py-2.5 text-xs text-slate-400 whitespace-nowrap" > $ { fmtDate ( ev . created _at ) } < / t d >
< td class = "px-4 py-2.5 text-xs font-medium" > $ { escHtml ( ev . display _name ) } < / t d >
< td class = "px-4 py-2.5" >
< span class = "rounded-full bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 text-xs px-2.5 py-0.5 font-medium" > $ { escHtml ( ev . category ) } < / s p a n >
< / t d >
< td class = "px-4 py-2.5 text-xs text-slate-600 dark:text-slate-300 max-w-xs truncate" > $ { escHtml ( ev . snippet ) } < / t d >
< / t r > ` ) . j o i n ( " " ) }
< / t b o d y >
< / t a b l e >
< / d i v > ` ;
}
// ════════════════════════════════════════
// TAB: STATUS
// ════════════════════════════════════════
2026-06-20 18:33:06 +02:00
// LLM-/GPU-Status-Karte (read-only) für den Status-Tab.
function renderLlmCard ( llm ) {
if ( ! llm ) return "" ;
const backendLabel = { ollama : "Ollama" , llamacpp : "llama.cpp" , unknown : "—" } [ llm . backend ] || llm . backend ;
const dot = ( on ) => ` <span class="h-2.5 w-2.5 rounded-full ${ on ? "bg-emerald-500" : "bg-slate-400" } "></span> ` ;
const row = ( label , val ) =>
` <dt class="text-slate-500 dark:text-slate-400"> ${ label } </dt>
< dd class = "font-mono text-xs text-slate-700 dark:text-slate-300" > $ { escHtml ( val ? ? "—" ) } < / d d > ` ;
const loaded = ( llm . ollama _loaded || [ ] ) . map ( ( m ) =>
` ${ escHtml ( m . name ) } <span class="text-slate-400">( ${ escHtml ( m . processor || "" ) } )</span> `
) . join ( ", " ) || "—" ;
const gpuBars = ( llm . gpus || [ ] ) . map ( ( g ) => `
< div class = "flex items-center gap-3" >
< span class = "w-10 shrink-0 text-xs text-slate-500 dark:text-slate-400" > GPU $ { g . index } < / s p a n >
< div class = "flex-1 h-3 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden" >
< div class = "h-full ${g.percent > 85 ? " bg - red - 500 " : " bg - blue - 500 dark : bg - blue - 600 "} rounded-full" style = "width:${g.percent}%" > < / d i v >
< / d i v >
< span class = "w-28 text-right text-xs font-mono text-slate-500 dark:text-slate-400" > $ { g . used _mib } / $ { g . total _mib } MiB < / s p a n >
< / d i v > ` ) . j o i n ( " " ) | | ' < p c l a s s = " t e x t - x s t e x t - s l a t e - 4 0 0 " > k e i n e G P U - D a t e n ( n v i d i a - s m i ) < / p > ' ;
return `
< div class = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" >
< div class = "flex items-center gap-2 mb-3" >
$ { dot ( true ) } < h3 class = "font-semibold text-sm" > LLM - Backend < / h 3 >
< span class = "text-xs rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 px-2 py-0.5 font-medium" > $ { escHtml ( backendLabel ) } < / s p a n >
< / d i v >
< dl class = "grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm mb-3" >
$ { row ( "Modell" , llm . model ) }
$ { row ( "URL" , llm . base _url ) }
$ { row ( "Ollama erreichbar" , llm . ollama _reachable ? "ja" : "nein" ) }
$ { row ( "llama.cpp läuft" , llm . llamacpp _running ? "ja" : "nein" ) }
$ { row ( "Geladene Modelle" , loaded ) }
$ { row ( "Gateway-Dienst" , llm . gateway _service _active ? "aktiv (systemd)" : "Vordergrund/aus" ) }
< / d l >
< div class = "space-y-2 pt-2 border-t border-slate-100 dark:border-slate-700" >
$ { gpuBars }
< / d i v >
2026-06-20 18:39:59 +02:00
<!-- Steuerung ( schreibend ) -- >
< div class = "mt-4 pt-3 border-t border-slate-100 dark:border-slate-700 flex flex-wrap items-center gap-2" >
< select id = "llm-backend-sel" class = "text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5" >
< option value = "ollama" $ { llm . backend === "ollama" ? "selected" : "" } > Ollama < / o p t i o n >
< option value = "llamacpp" $ { llm . backend === "llamacpp" ? "selected" : "" } > llama . cpp < / o p t i o n >
< / s e l e c t >
< input id = "llm-model-inp" type = "text" placeholder = "Modell (Ollama)" value = "${escHtml(llm.backend === " ollama " ? llm.model : " ")}"
class = "text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5 flex-1 min-w-32 font-mono" / >
< button id = "llm-switch-btn" class = "rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm px-3 py-1.5 font-medium transition-colors" > Backend wechseln < / b u t t o n >
< button id = "gw-restart-btn" class = "rounded-lg border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 text-sm px-3 py-1.5 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors" > Gateway neu starten < / b u t t o n >
< span id = "llm-ctrl-msg" class = "text-xs text-slate-400" > < / s p a n >
< / d i v >
< p class = "mt-2 text-xs text-slate-400" > Hinweis : Wirkt vollständig nur , wenn das Gateway als systemd - Dienst läuft ( sonst . env / Backend umgestellt , aber Gateway manuell neu starten ) . < / p >
2026-06-20 18:33:06 +02:00
< / d i v > ` ;
}
2026-06-20 18:39:59 +02:00
// Wartet, bis das Gateway nach einem Neustart wieder antwortet, dann Status neu laden.
async function pollGatewayBack ( msgEl , label ) {
for ( let i = 0 ; i < 40 ; i ++ ) {
await new Promise ( ( r ) => setTimeout ( r , 1500 ) ) ;
try {
const r = await fetch ( "/api/config" , { cache : "no-store" } ) ;
if ( r . ok ) { if ( msgEl ) msgEl . textContent = label + " ✓" ; loadStatus ( ) ; return ; }
} catch ( e ) { /* noch nicht oben */ }
if ( msgEl ) msgEl . textContent = label + " … (" + ( i + 1 ) + ")" ;
}
if ( msgEl ) msgEl . textContent = label + " — Zeitüberschreitung, bitte Status manuell prüfen." ;
}
function wireLlmControls ( ) {
const sel = $ ( "#llm-backend-sel" ) ;
const switchBtn = $ ( "#llm-switch-btn" ) ;
const restartBtn = $ ( "#gw-restart-btn" ) ;
const msg = $ ( "#llm-ctrl-msg" ) ;
if ( ! switchBtn ) return ;
switchBtn . addEventListener ( "click" , async ( ) => {
const backend = sel . value ;
const model = ( $ ( "#llm-model-inp" ) . value || "" ) . trim ( ) ;
if ( ! confirm ( ` Backend auf „ ${ backend } " ${ backend === "ollama" && model ? " (" + model + ")" : "" } umstellen? \n Die GPU des anderen Backends wird freigegeben. ` ) ) return ;
switchBtn . disabled = true ;
msg . textContent = "stelle um …" ;
const body = { backend } ;
if ( backend === "ollama" && model ) body . model = model ;
const res = await adminFetch ( "/api/admin/llm/backend" , "POST" , body ) ;
if ( res === null ) { msg . textContent = "Fehler (Modell/Backend ungültig?)" ; switchBtn . disabled = false ; return ; }
pollGatewayBack ( msg , "Backend gewechselt" ) ;
} ) ;
restartBtn . addEventListener ( "click" , async ( ) => {
if ( ! confirm ( "Gateway-Dienst jetzt neu starten?" ) ) return ;
restartBtn . disabled = true ;
msg . textContent = "starte neu …" ;
const res = await adminFetch ( "/api/admin/gateway/restart" , "POST" ) ;
if ( res === null ) { msg . textContent = "Fehler beim Neustart" ; restartBtn . disabled = false ; return ; }
pollGatewayBack ( msg , "Neu gestartet" ) ;
} ) ;
}
2026-06-19 01:04:20 +02:00
async function loadStatus ( ) {
const container = $ ( "#status-content" ) ;
container . innerHTML = '<p class="text-sm text-slate-400">lade …</p>' ;
try {
2026-06-20 18:33:06 +02:00
const [ cfg , met , llm ] = await Promise . all ( [
2026-06-19 01:04:20 +02:00
fetch ( "/api/config" ) . then ( ( r ) => r . json ( ) ) ,
fetch ( "/api/metrics" ) . then ( ( r ) => r . json ( ) ) ,
2026-06-20 18:33:06 +02:00
adminFetch ( "/api/admin/llm/status" ) ,
2026-06-19 01:04:20 +02:00
] ) ;
const route = cfg . default _route || { } ;
const counters = met . counters || { } ;
const turnsTotal = counters [ "turns_total" ] || 0 ;
const httpTotal = Object . entries ( counters )
. filter ( ( [ k ] ) => k . startsWith ( "http_requests_total" ) )
. reduce ( ( s , [ , v ] ) => s + v , 0 ) ;
const providerRow = ( label , val ) =>
` <dt class="text-slate-500 dark:text-slate-400"> ${ label } </dt>
< dd class = "font-mono text-xs text-slate-700 dark:text-slate-300" > $ { escHtml ( val || "—" ) } < / d d > ` ;
container . innerHTML = `
<!-- Gateway - Status -- >
< div class = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" >
< div class = "flex items-center gap-2 mb-3" >
< span class = "h-2.5 w-2.5 rounded-full bg-emerald-500 shadow shadow-emerald-300/60" > < / s p a n >
< h3 class = "font-semibold text-sm" > Gateway < / h 3 >
< span class = "text-xs text-emerald-600 dark:text-emerald-400 font-medium" > läuft < / s p a n >
< / d i v >
< dl class = "grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm" >
$ { providerRow ( "Profil" , cfg . profile ) }
$ { providerRow ( "Umgebung" , cfg . app _env ) }
< / d l >
< / d i v >
<!-- Provider -- >
< div class = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" >
< h3 class = "font-semibold text-sm mb-3" > Provider ( Standard - Route ) < / h 3 >
< dl class = "grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm" >
$ { providerRow ( "STT" , route . stt _provider ) }
$ { providerRow ( "LLM" , route . llm _provider ) }
$ { providerRow ( "TTS" , route . tts _provider ) }
$ { providerRow ( "Sprache" , route . language ) }
< / d l >
< / d i v >
2026-06-20 18:33:06 +02:00
$ { renderLlmCard ( llm ) }
2026-06-19 01:04:20 +02:00
<!-- Laufzeit - Metriken -- >
< div class = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" >
< h3 class = "font-semibold text-sm mb-3" > Laufzeit - Metriken < / h 3 >
< dl class = "grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm" >
$ { providerRow ( "Turns gesamt" , turnsTotal ) }
$ { providerRow ( "HTTP-Anfragen" , httpTotal ) }
< / d l >
< / d i v >
<!-- Verfügbare Provider -- >
< div class = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" >
< h3 class = "font-semibold text-sm mb-3" > Verfügbare Provider < / h 3 >
< dl class = "grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm" >
$ { providerRow ( "STT" , ( cfg . available ? . stt _providers || [ ] ) . join ( ", " ) ) }
$ { providerRow ( "LLM" , ( cfg . available ? . llm _providers || [ ] ) . join ( ", " ) ) }
$ { providerRow ( "TTS" , ( cfg . available ? . tts _providers || [ ] ) . join ( ", " ) ) }
< / d l >
< / d i v >
2026-06-19 01:15:28 +02:00
<!-- Datenbank - Export -- >
< div class = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" >
< h3 class = "font-semibold text-sm mb-1" > Datenbank - Backup < / h 3 >
< p class = "text-xs text-slate-400 mb-3" > SQLite - Datenbank als Datei herunterladen ( Backup / Migration ) . < / p >
< a href = "/api/admin/db-export" download = "voice-assistant.db"
class = "inline-flex items-center gap-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white text-xs px-4 py-2 font-medium transition-colors" >
⬇ voice - assistant . db herunterladen
< / a >
< / d i v >
2026-06-19 01:04:20 +02:00
` ;
2026-06-20 18:39:59 +02:00
wireLlmControls ( ) ; // Buttons der LLM-Karte verdrahten (nach dem Rendern)
2026-06-19 01:04:20 +02:00
} catch ( e ) {
2026-06-19 01:33:49 +02:00
console . error ( "loadStatus error:" , e ) ;
2026-06-19 01:04:20 +02:00
container . innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>' ;
}
}
// ════════════════════════════════════════
// TAB: METRIKEN
// ════════════════════════════════════════
async function loadMetrics ( ) {
const container = $ ( "#metrics-content" ) ;
container . innerHTML = '<p class="text-sm text-slate-400">lade …</p>' ;
const data = await adminFetch ( "/api/admin/usage" ) ;
if ( ! data ) { container . innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>' ; return ; }
if ( ! data . length ) { container . innerHTML = '<p class="text-sm text-slate-400 py-4 text-center">Noch keine Nutzungsdaten vorhanden.</p>' ; return ; }
const totalReq = data . reduce ( ( s , u ) => s + ( u . total _requests || 0 ) , 0 ) ;
2026-06-19 01:15:28 +02:00
const maxReq = Math . max ( ... data . map ( ( u ) => u . total _requests || 0 ) , 1 ) ;
const bars = data . map ( ( u ) => {
const pct = Math . round ( ( ( u . total _requests || 0 ) / maxReq ) * 100 ) ;
return `
< div class = "flex items-center gap-3" >
< span class = "w-28 shrink-0 text-xs text-slate-600 dark:text-slate-300 truncate" > $ { escHtml ( u . display _name ) } < / s p a n >
< div class = "flex-1 h-4 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden" >
< div class = "h-full bg-blue-500 dark:bg-blue-600 rounded-full" style = "width:${pct}%" > < / d i v >
< / d i v >
< span class = "w-10 text-right text-xs font-mono text-slate-500 dark:text-slate-400" > $ { u . total _requests ? ? 0 } < / s p a n >
< / d i v > ` ;
} ) . join ( "" ) ;
2026-06-19 01:04:20 +02:00
container . innerHTML = `
< div class = "overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700 mb-4" >
< table class = "w-full text-sm" >
< thead class = "bg-slate-50 dark:bg-slate-800/60" >
< tr class = "text-xs text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700" >
< th class = "text-left px-4 py-2.5" > Nutzer < / t h >
< th class = "text-right px-4 py-2.5" > Anfragen gesamt < / t h >
< th class = "text-right px-4 py-2.5" > Einheiten < / t h >
< th class = "text-right px-4 py-2.5" > Letzte Aktivität < / t h >
< / t r >
< / t h e a d >
< tbody class = "divide-y divide-slate-100 dark:divide-slate-800 bg-white dark:bg-slate-800" >
$ { data . map ( ( u ) => `
< tr class = "hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors" >
< td class = "px-4 py-2.5 font-medium" > $ { escHtml ( u . display _name ) } < / t d >
< td class = "px-4 py-2.5 text-right font-mono text-xs" > $ { u . total _requests ? ? 0 } < / t d >
< td class = "px-4 py-2.5 text-right font-mono text-xs" > $ { u . total _units ? ? 0 } < / t d >
< td class = "px-4 py-2.5 text-right text-xs text-slate-400" > $ { u . last _active || "—" } < / t d >
< / t r > ` ) . j o i n ( " " ) }
< / t b o d y >
< tfoot class = "border-t-2 border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-800/60" >
< tr class = "text-xs font-semibold text-slate-600 dark:text-slate-300" >
< td class = "px-4 py-2" > Gesamt < / t d >
< td class = "px-4 py-2 text-right font-mono" > $ { totalReq } < / t d >
< td class = "px-4 py-2" > < / t d >
< td class = "px-4 py-2" > < / t d >
< / t r >
< / t f o o t >
< / t a b l e >
< / d i v >
2026-06-19 01:15:28 +02:00
< div class = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" >
< h3 class = "font-semibold text-sm mb-4" > Anfragen je Nutzer < / h 3 >
< div class = "space-y-2.5" > $ { bars } < / d i v >
< / d i v >
` ;
}
// ════════════════════════════════════════
// TAB: WÖRTERBUCH
// ════════════════════════════════════════
async function loadWoerterbuch ( ) {
const lang = ( $ ( "#words-lang" ) || { } ) . value || "de" ;
const container = $ ( "#words-content" ) ;
container . innerHTML = '<p class="text-sm text-slate-400">lade …</p>' ;
const data = await adminFetch ( ` /api/admin/pronunciation/ ${ lang } ` ) ;
if ( ! data ) { container . innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>' ; return ; }
container . innerHTML = "" ;
const sections = [
[ "abbreviations" , "Abkürzungen" ] ,
[ "units" , "Einheiten" ] ,
[ "terms" , "Begriffe / Aussprache" ] ,
] ;
for ( const [ section , label ] of sections ) {
container . appendChild ( buildWortSection ( label , section , data [ section ] || { } , lang ) ) ;
}
}
function buildWortSection ( title , section , entries , lang ) {
const wrap = document . createElement ( "div" ) ;
wrap . className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" ;
const rows = Object . entries ( entries ) . map ( ( [ k , v ] ) => `
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
< tr class = "row-edit cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors group"
data - key = "${escHtml(k)}" data - val = "${escHtml(v)}" title = "Zum Bearbeiten anklicken" >
2026-06-19 01:15:28 +02:00
< td class = "px-3 py-2 text-xs font-mono text-slate-700 dark:text-slate-300" > $ { escHtml ( k ) } < / t d >
< td class = "px-3 py-2 text-xs text-slate-400" > → < / t d >
< td class = "px-3 py-2 text-xs text-slate-700 dark:text-slate-300 font-medium" > $ { escHtml ( v ) } < / t d >
< td class = "px-3 py-2 text-right" >
< button class = "del-btn text-slate-300 dark:text-slate-600 hover:text-red-500 dark:hover:text-red-400 transition-colors text-xs opacity-0 group-hover:opacity-100"
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
data - key = "${escHtml(k)}" title = "Löschen" > ✕ < / b u t t o n >
2026-06-19 01:15:28 +02:00
< / t d >
< / t r > ` ) . j o i n ( " " ) ;
wrap . innerHTML = `
< h4 class = "font-semibold text-sm mb-3 flex items-center justify-between" >
$ { escHtml ( title ) }
< span class = "text-xs font-normal text-slate-400" > $ { Object . keys ( entries ) . length } Einträge < / s p a n >
< / h 4 >
$ { rows ? `
< div class = "overflow-x-auto mb-4" >
< table class = "w-full" >
< tbody class = "divide-y divide-slate-100 dark:divide-slate-700/60" > $ { rows } < / t b o d y >
< / t a b l e >
< / d i v > ` : ' < p c l a s s = " t e x t - x s t e x t - s l a t e - 4 0 0 i t a l i c m b - 4 " > K e i n e E i n t r ä g e . < / p > ' }
< form class = "add-form flex gap-2 flex-wrap" >
< input type = "text" placeholder = "Schlüssel" required
class = "flex-1 min-w-24 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" / >
< input type = "text" placeholder = "Ersetzung" required
class = "flex-1 min-w-24 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" / >
< button type = "submit"
class = "rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-1.5 font-medium transition-colors whitespace-nowrap" > + Hinzufügen < / b u t t o n >
< / f o r m >
2026-06-19 01:04:20 +02:00
` ;
2026-06-19 01:15:28 +02:00
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
const form = wrap . querySelector ( ".add-form" ) ;
const [ keyInp , valInp ] = form . querySelectorAll ( "input" ) ;
const submitBtn = form . querySelector ( 'button[type="submit"]' ) ;
// Zeile anklicken -> Begriffspaar in die Editier-Felder laden, Button wird zu „Speichern".
wrap . querySelectorAll ( "tr.row-edit" ) . forEach ( ( tr ) =>
tr . addEventListener ( "click" , ( ) => {
keyInp . value = tr . dataset . key ;
valInp . value = tr . dataset . val ;
form . dataset . editKey = tr . dataset . key ; // Originalschlüssel merken (für Umbenennen)
submitBtn . textContent = "Speichern" ;
valInp . focus ( ) ;
} )
) ;
2026-06-19 01:15:28 +02:00
wrap . querySelectorAll ( ".del-btn" ) . forEach ( ( btn ) =>
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
btn . addEventListener ( "click" , async ( e ) => {
e . stopPropagation ( ) ; // nicht zugleich die Zeile zum Bearbeiten laden
2026-06-19 01:15:28 +02:00
const key = btn . dataset . key ;
if ( ! confirm ( ` Eintrag „ ${ key } " löschen? ` ) ) return ;
const res = await adminFetch (
` /api/admin/pronunciation/ ${ lang } / ${ section } / ${ encodeURIComponent ( key ) } ` , "DELETE"
) ;
if ( res !== null ) btn . closest ( "tr" ) . remove ( ) ;
} )
) ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
form . addEventListener ( "submit" , async ( e ) => {
2026-06-19 01:15:28 +02:00
e . preventDefault ( ) ;
const key = keyInp . value . trim ( ) ;
const value = valInp . value . trim ( ) ;
if ( ! key || ! value ) return ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
const editKey = form . dataset . editKey || "" ;
// Hinzufügen ODER Aktualisieren (gleicher Schlüssel wird überschrieben; Backend sortiert).
2026-06-19 01:15:28 +02:00
const res = await adminFetch ( ` /api/admin/pronunciation/ ${ lang } ` , "POST" , { section , key , value } ) ;
feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 13:12:04 +02:00
if ( res === null ) return ;
// Umbenannt -> alten Schlüssel entfernen (neuer wurde oben bereits angelegt).
if ( editKey && editKey !== key ) {
await adminFetch (
` /api/admin/pronunciation/ ${ lang } / ${ section } / ${ encodeURIComponent ( editKey ) } ` , "DELETE"
) ;
}
loadWoerterbuch ( ) ; // neu laden -> sortierte Liste, Formular zurück auf „+ Hinzufügen"
2026-06-19 01:15:28 +02:00
} ) ;
return wrap ;
}
$ ( "#load-words" ) . addEventListener ( "click" , loadWoerterbuch ) ;
$ ( "#words-lang" ) . addEventListener ( "change" , loadWoerterbuch ) ;
// ════════════════════════════════════════
// TAB: LOG
// ════════════════════════════════════════
let _logWs = null ;
function connectLog ( ) {
if ( _logWs ) return ;
const proto = location . protocol === "https:" ? "wss" : "ws" ;
_logWs = new WebSocket ( ` ${ proto } :// ${ location . host } /api/admin/log ` ) ;
const output = $ ( "#log-output" ) ;
const logStat = $ ( "#log-status" ) ;
const btnConn = $ ( "#log-connect" ) ;
const btnDisc = $ ( "#log-disconnect" ) ;
btnConn . disabled = true ;
btnDisc . disabled = false ;
btnDisc . classList . remove ( "opacity-40" ) ;
logStat . textContent = "verbinde …" ;
logStat . className = "text-xs text-amber-400" ;
_logWs . onopen = ( ) => {
logStat . textContent = "verbunden" ;
logStat . className = "text-xs text-emerald-400" ;
output . innerHTML = "" ;
} ;
_logWs . onmessage = ( e ) => {
const line = document . createElement ( "div" ) ;
line . textContent = e . data ;
output . appendChild ( line ) ;
output . scrollTop = output . scrollHeight ;
} ;
_logWs . onerror = ( ) => {
logStat . textContent = "Verbindungsfehler" ;
logStat . className = "text-xs text-red-400" ;
} ;
_logWs . onclose = ( ) => {
_logWs = null ;
btnConn . disabled = false ;
btnDisc . disabled = true ;
btnDisc . classList . add ( "opacity-40" ) ;
if ( ! logStat . className . includes ( "red" ) ) {
logStat . textContent = "getrennt" ;
logStat . className = "text-xs text-slate-400" ;
}
} ;
2026-06-19 01:04:20 +02:00
}
2026-06-19 01:15:28 +02:00
function disconnectLog ( ) {
if ( _logWs ) { _logWs . close ( ) ; }
}
$ ( "#log-connect" ) . addEventListener ( "click" , connectLog ) ;
$ ( "#log-disconnect" ) . addEventListener ( "click" , disconnectLog ) ;
$ ( "#log-clear" ) . addEventListener ( "click" , ( ) => { $ ( "#log-output" ) . innerHTML = "" ; } ) ;
2026-06-19 01:39:36 +02:00
// ════════════════════════════════════════
// TAB: EINSTELLUNGEN
// ════════════════════════════════════════
2026-06-19 02:35:40 +02:00
// Metadaten für jedes Feld: UI-Typ, Optionen, Test-Typ
const FIELD _META = {
2026-06-19 12:30:52 +02:00
default _stt _provider : { ui : "combo" , opts : [ "openrouter" , "faster-whisper" ] , test : "stt" } ,
default _llm _provider : { ui : "combo" , opts : [ "openrouter" , "local-openai-compatible" ] , test : "llm" } ,
default _tts _provider : { ui : "combo" , opts : [ "openrouter" , "piper" , "chatterbox" ] , test : "tts" } ,
default _language : { ui : "combo" , opts : [ "de" , "en" , "fr" , "es" , "it" , "nl" ] ,
2026-06-19 02:35:40 +02:00
labels : { de : "Deutsch" , en : "Englisch" , fr : "Französisch" , es : "Spanisch" , it : "Italienisch" , nl : "Niederländisch" } } ,
openrouter _llm _model : { ui : "combo" , opts : [ "google/gemini-3.1-flash-lite" , "openai/gpt-4.1-mini" , "google/gemini-2.5-flash" , "anthropic/claude-haiku-4-5-20251001" , "meta-llama/llama-3.3-70b-instruct" ] , test : "llm" } ,
2026-06-19 12:39:14 +02:00
openrouter _tts _model : { ui : "combo" , opts : [ "google/gemini-3.1-flash-tts-preview" , "openai/gpt-4o-mini-tts" ] , test : "tts" , testProvider : "openrouter" } ,
openrouter _tts _voice : { ui : "combo" , opts : [ "Zephyr" , "Puck" , "Charon" , "Kore" , "Fenrir" , "Leda" , "Orus" , "Aoede" , "Callirrhoe" , "Enceladus" , "Algieba" , "Despina" , "Achernar" ] , test : "tts" , testProvider : "openrouter" } ,
2026-06-19 13:41:03 +02:00
piper _voice : { ui : "combo" , opts : [ "de_DE-thorsten-high" , "en_US-ryan-high" , "en_US-lessac-high" , "en_GB-cori-high" , "es_ES-sharvard-medium" , "fr_FR-siwis-medium" , "it_IT-paola-medium" , "nl_NL-mls-medium" , "ru_RU-irina-medium" , "zh_CN-huayan-medium" ] , test : "tts" , testProvider : "piper" } ,
2026-06-19 02:35:40 +02:00
local _llm _system _prompt : { ui : "textarea" , test : "llm" } ,
local _llm _temperature : { ui : "range" , min : 0 , max : 2 , step : 0.1 , test : "llm" } ,
2026-06-20 18:46:33 +02:00
local _llm _top _p : { ui : "range" , min : 0 , max : 1 , step : 0.05 , test : "llm" } ,
2026-06-19 02:35:40 +02:00
local _llm _max _tokens : { ui : "number" , min : 0 , test : "llm" } ,
tts _normalize _level : { ui : "select" , opts : [ "auto" , "full" , "light" , "off" ] , test : "tts" } ,
audio _stream _default : { ui : "select" , opts : [ "true" , "false" ] ,
labels : { "true" : "Aktiviert (satzweises Streaming)" , "false" : "Deaktiviert (alles auf einmal)" } , test : "tts" } ,
memory _extraction _enabled : { ui : "select" , opts : [ "true" , "false" ] ,
labels : { "true" : "Aktiviert" , "false" : "Deaktiviert" } } ,
memory _extraction _every _n _turns : { ui : "number" , min : 1 , max : 20 } ,
daily _request _limit : { ui : "number" , min : 0 } ,
2026-06-20 20:08:10 +02:00
allow _cloud _stt : { ui : "select" , opts : [ "true" , "false" ] ,
labels : { "true" : "Erlaubt (auch Cloud-Erkennung)" , "false" : "Nur on-device (Datenschutz)" } } ,
2026-06-19 02:35:40 +02:00
} ;
const FIELD _GROUPS = [
{ label : "Provider & Sprache" , keys : [ "default_stt_provider" , "default_llm_provider" , "default_tts_provider" , "default_language" ] } ,
{ label : "OpenRouter-Modelle & Stimmen" , keys : [ "openrouter_llm_model" , "openrouter_tts_model" , "openrouter_tts_voice" ] } ,
{ label : "Piper TTS" , keys : [ "piper_voice" ] } ,
2026-06-20 18:46:33 +02:00
{ label : "Lokales LLM" , keys : [ "local_llm_system_prompt" , "local_llm_temperature" , "local_llm_top_p" , "local_llm_max_tokens" ] } ,
2026-06-19 02:35:40 +02:00
{ 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" ] } ,
2026-06-20 20:08:10 +02:00
{ label : "Geräte-STT (mobil)" , keys : [ "allow_cloud_stt" ] } ,
2026-06-19 02:35:40 +02:00
] ;
let _settingsData = [ ] ;
2026-06-19 01:39:36 +02:00
async function loadSettings ( ) {
const container = $ ( "#settings-content" ) ;
container . innerHTML = '<p class="text-sm text-slate-400">lade …</p>' ;
const data = await adminFetch ( "/api/admin/config" ) ;
if ( ! data ) { container . innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>' ; return ; }
2026-06-19 02:35:40 +02:00
_settingsData = data ;
2026-06-19 01:39:36 +02:00
container . innerHTML = "" ;
2026-06-19 02:35:40 +02:00
const byKey = Object . fromEntries ( data . map ( ( d ) => [ d . key , d ] ) ) ;
FIELD _GROUPS . forEach ( ( group ) => {
const items = group . keys . map ( ( k ) => byKey [ k ] ) . filter ( Boolean ) ;
if ( ! items . length ) return ;
const section = document . createElement ( "div" ) ;
section . className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden" ;
const header = document . createElement ( "div" ) ;
header . className = "px-4 py-2.5 border-b border-slate-100 dark:border-slate-700 bg-slate-50/80 dark:bg-slate-800/60" ;
header . innerHTML = ` <h4 class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide"> ${ escHtml ( group . label ) } </h4> ` ;
section . appendChild ( header ) ;
items . forEach ( ( item , idx ) => {
const row = buildSettingRow ( item ) ;
if ( idx > 0 ) row . classList . add ( "border-t" , "border-slate-100" , "dark:border-slate-700/60" ) ;
section . appendChild ( row ) ;
} ) ;
container . appendChild ( section ) ;
} ) ;
}
function _buildControl ( item , meta ) {
const val = item . effective _value ;
const id = "cfg-" + item . key ;
const cls = "rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-2 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-blue-500" ;
if ( meta . ui === "select" ) {
const labels = meta . labels || { } ;
const options = meta . opts . map ( ( o ) =>
` <option value=" ${ escHtml ( o ) } " ${ o === val ? "selected" : "" } > ${ escHtml ( labels [ o ] || o ) } </option> `
) . join ( "" ) ;
return ` <select id=" ${ id } " class=" ${ cls } flex-1"> ${ options } </select> ` ;
}
if ( meta . ui === "combo" ) {
const inList = meta . opts . includes ( val ) ;
2026-06-19 12:30:52 +02:00
const labels = meta . labels || { } ;
2026-06-19 02:35:40 +02:00
const options = meta . opts . map ( ( o ) =>
2026-06-19 12:30:52 +02:00
` <option value=" ${ escHtml ( o ) } " ${ o === val ? "selected" : "" } > ${ escHtml ( labels [ o ] || o ) } </option> `
2026-06-19 02:35:40 +02:00
) . join ( "" ) ;
const customSel = ! inList ? 'selected' : '' ;
return `
< div class = "flex-1 space-y-1.5" >
< select class = "combo-sel w-full ${cls}" >
$ { options }
< option value = "__custom__" $ { customSel } > ✏ Eigener Wert … < / o p t i o n >
< / s e l e c t >
< input id = "${id}" type = "text" value = "${escHtml(val)}" placeholder = "Eigenen Wert eingeben"
class = "combo-inp w-full ${cls} ${inList ? " hidden " : " "}" / >
< / d i v > ` ;
}
if ( meta . ui === "range" ) {
return `
< div class = "flex-1 flex items-center gap-3" >
< input id = "${id}" type = "range" min = "${meta.min}" max = "${meta.max}" step = "${meta.step}" value = "${escHtml(val)}"
class = "flex-1 accent-blue-600" / >
< span class = "range-val text-xs font-mono w-8 text-right" > $ { escHtml ( val ) } < / s p a n >
< / d i v > ` ;
}
if ( meta . ui === "number" ) {
const extra = meta . max !== undefined ? ` max=" ${ meta . max } " ` : "" ;
return ` <input id=" ${ id } " type="number" min=" ${ meta . min ? ? 0 } " ${ extra } value=" ${ escHtml ( val ) } " class=" ${ cls } flex-1" /> ` ;
}
if ( meta . ui === "textarea" ) {
return ` <textarea id=" ${ id } " rows="3" class=" ${ cls } flex-1 resize-y"> ${ escHtml ( val ) } </textarea> ` ;
}
return ` <input id=" ${ id } " type="text" value=" ${ escHtml ( val ) } " class=" ${ cls } flex-1" /> ` ;
2026-06-19 01:39:36 +02:00
}
function buildSettingRow ( item ) {
2026-06-19 02:35:40 +02:00
const meta = FIELD _META [ item . key ] || { ui : "text" } ;
2026-06-19 01:39:36 +02:00
const overridden = item . is _overridden ;
2026-06-19 02:35:40 +02:00
const envName = item . key . toUpperCase ( ) ;
const testType = meta . test ;
const wrap = document . createElement ( "div" ) ;
wrap . className = "px-4 py-3" + ( overridden ? " bg-amber-50/40 dark:bg-amber-900/10" : "" ) ;
2026-06-19 01:39:36 +02:00
2026-06-19 02:35:40 +02:00
const testBtn = testType
? ` <button class="test-btn 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 whitespace-nowrap"> ${ testType === "tts" ? "🔊 Test" : testType === "llm" ? "💬 Test" : "🎤 Test" } </button> `
: "" ;
const resetBtn = overridden
? ` <button class="reset-btn text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors whitespace-nowrap">↩ Reset</button> `
: "" ;
2026-06-19 01:39:36 +02:00
wrap . innerHTML = `
2026-06-19 02:35:40 +02:00
< div class = "flex items-center gap-2 mb-2 flex-wrap" >
< span class = "font-medium text-sm flex-1" > $ { escHtml ( item . label ) } < / s p a n >
$ { overridden ? '<span class="text-xs rounded-full bg-amber-200 dark:bg-amber-800/60 text-amber-800 dark:text-amber-200 px-2 py-0.5 font-medium">überschrieben</span>' : "" }
$ { resetBtn }
< / d i v >
< div class = "flex gap-2 items-start flex-wrap" >
$ { _buildControl ( item , meta ) }
< div class = "flex flex-col gap-1.5 shrink-0" >
< button class = "save-btn rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-2 font-medium transition-colors" > Speichern < / b u t t o n >
$ { testBtn }
2026-06-19 01:39:36 +02:00
< / d i v >
< / d i v >
2026-06-19 02:35:40 +02:00
< div class = "flex items-baseline gap-2 mt-1.5 text-xs text-slate-400 flex-wrap" >
< span class = "font-mono font-medium text-slate-500 dark:text-slate-400" > $ { envName } < / s p a n >
< span class = "text-slate-300 dark:text-slate-600" > · < / s p a n >
< span > . env : < span class = "font-mono" > $ { escHtml ( item . base _value ) || "—" } < / s p a n > < / s p a n >
< span class = "text-slate-300 dark:text-slate-600" > · < / s p a n >
< span > $ { escHtml ( item . hint ) } < / s p a n >
2026-06-19 01:39:36 +02:00
< / d i v >
2026-06-19 02:35:40 +02:00
< div class = "feedback-msg text-xs mt-1 hidden" > < / d i v >
< div class = "test-result text-xs mt-2 hidden rounded-lg bg-slate-100 dark:bg-slate-700/60 px-3 py-2 font-mono whitespace-pre-wrap max-h-28 overflow-y-auto" > < / d i v >
2026-06-19 01:39:36 +02:00
` ;
2026-06-19 02:35:40 +02:00
// Combo-Synchronisation: Dropdown ↔ Textfeld
const comboSel = wrap . querySelector ( ".combo-sel" ) ;
const comboInp = wrap . querySelector ( ".combo-inp" ) ;
if ( comboSel && comboInp ) {
comboSel . addEventListener ( "change" , ( ) => {
if ( comboSel . value === "__custom__" ) {
comboInp . classList . remove ( "hidden" ) ;
comboInp . focus ( ) ;
} else {
comboInp . value = comboSel . value ;
comboInp . classList . add ( "hidden" ) ;
}
} ) ;
comboInp . addEventListener ( "input" , ( ) => {
const match = Array . from ( comboSel . options ) . find ( ( o ) => o . value === comboInp . value ) ;
comboSel . value = match ? match . value : "__custom__" ;
} ) ;
}
2026-06-19 01:39:36 +02:00
2026-06-19 02:35:40 +02:00
// Range-Slider: Wert live anzeigen
const rangeInp = wrap . querySelector ( 'input[type="range"]' ) ;
if ( rangeInp ) {
rangeInp . addEventListener ( "input" , ( ) => {
wrap . querySelector ( ".range-val" ) . textContent = rangeInp . value ;
} ) ;
}
// Aktuellen Wert holen (aus Combo-Textfeld oder normalem Input)
function getCurrentValue ( ) {
if ( comboInp ) return comboInp . value . trim ( ) ;
const inp = wrap . querySelector ( ` #cfg- ${ item . key } ` ) ;
return inp ? ( inp . value ? ? inp . textContent ? ? "" ) . trim ( ) : "" ;
}
// Feedback-Anzeige
function showFeedback ( ok , msg ) {
const fb = wrap . querySelector ( ".feedback-msg" ) ;
fb . textContent = msg ;
fb . className = "feedback-msg text-xs mt-1 " + ( ok ? "text-emerald-600 dark:text-emerald-400" : "text-red-500" ) ;
setTimeout ( ( ) => { fb . className = "feedback-msg text-xs mt-1 hidden" ; } , 3000 ) ;
}
// Speichern
2026-06-19 01:39:36 +02:00
wrap . querySelector ( ".save-btn" ) . addEventListener ( "click" , async ( ) => {
2026-06-19 02:35:40 +02:00
const val = getCurrentValue ( ) ;
2026-06-19 01:39:36 +02:00
const res = await adminFetch ( ` /api/admin/config/ ${ item . key } ` , "PUT" , { value : val } ) ;
2026-06-19 02:35:40 +02:00
if ( res !== null ) {
showFeedback ( true , "✓ Gespeichert" ) ;
item . is _overridden = true ;
item . effective _value = val ;
// Overrides-Badge nachrüsten falls noch nicht da
if ( ! wrap . querySelector ( ".reset-btn" ) ) {
wrap . querySelector ( ".flex.items-center.gap-2" ) . insertAdjacentHTML ( "beforeend" ,
'<button class="reset-btn text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors whitespace-nowrap">↩ Reset</button>' ) ;
wrap . querySelector ( ".reset-btn" ) . addEventListener ( "click" , doReset ) ;
}
} else {
showFeedback ( false , "✗ Fehler beim Speichern" ) ;
}
2026-06-19 01:39:36 +02:00
} ) ;
2026-06-19 02:35:40 +02:00
// Reset
async function doReset ( ) {
if ( ! confirm ( ` Override für „ ${ item . label } " entfernen? \n Der .env-Wert gilt danach wieder. ` ) ) return ;
const res = await adminFetch ( ` /api/admin/config/ ${ item . key } ` , "DELETE" ) ;
if ( res !== null ) loadSettings ( ) ;
}
wrap . querySelector ( ".reset-btn" ) ? . addEventListener ( "click" , doReset ) ;
// Test
const testBtnEl = wrap . querySelector ( ".test-btn" ) ;
if ( testBtnEl ) {
testBtnEl . addEventListener ( "click" , async ( ) => {
const resultEl = wrap . querySelector ( ".test-result" ) ;
resultEl . textContent = "…" ;
resultEl . classList . remove ( "hidden" ) ;
testBtnEl . disabled = true ;
try {
if ( testType === "tts" ) {
2026-06-19 12:39:14 +02:00
const body = { text : "Hallo, ich bin der Sprachassistent." } ;
if ( meta . testProvider ) body . tts _provider = meta . testProvider ;
2026-06-19 02:35:40 +02:00
const r = await fetch ( "/api/speak" , {
method : "POST" , headers : { "Content-Type" : "application/json" } ,
2026-06-19 12:39:14 +02:00
body : JSON . stringify ( body ) ,
2026-06-19 02:35:40 +02:00
} ) ;
if ( ! r . ok ) throw new Error ( "HTTP " + r . status ) ;
const buf = await r . arrayBuffer ( ) ;
const view = new DataView ( buf ) ;
const n = Math . floor ( buf . byteLength / 2 ) ;
audioCtx = audioCtx || new ( window . AudioContext || window . webkitAudioContext ) ( ) ;
const abuf = audioCtx . createBuffer ( 1 , n , 24000 ) ;
const ch = abuf . getChannelData ( 0 ) ;
for ( let i = 0 ; i < n ; i ++ ) ch [ i ] = view . getInt16 ( i * 2 , true ) / 32768 ;
const src = audioCtx . createBufferSource ( ) ;
src . buffer = abuf ; src . connect ( audioCtx . destination ) ; src . start ( ) ;
resultEl . textContent = "▶ Audio spielt …" ;
src . onended = ( ) => { resultEl . classList . add ( "hidden" ) ; } ;
} else if ( testType === "llm" ) {
const r = await fetch ( "/api/chat?debug=true" , {
method : "POST" , headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( { text : "Sag kurz Hallo." , tts _provider : "piper" } ) ,
} ) ;
const d = await r . json ( ) ;
resultEl . textContent = d . response || d . semantic || d . trace ? . response || JSON . stringify ( d ) . slice ( 0 , 200 ) ;
} else if ( testType === "stt" ) {
const cfg = await fetch ( "/api/config" ) . then ( ( r ) => r . json ( ) ) ;
const available = cfg . available ? . stt _providers || [ ] ;
const current = item . effective _value ;
resultEl . textContent = available . includes ( current )
? ` ✓ Provider „ ${ current } " ist verfügbar. `
: ` ✗ Provider „ ${ current } " nicht in verfügbaren: ${ available . join ( ", " ) } ` ;
}
} catch ( e ) {
resultEl . textContent = "Fehler: " + e . message ;
}
testBtnEl . disabled = false ;
2026-06-19 01:39:36 +02:00
} ) ;
}
return wrap ;
}
$ ( "#load-settings" ) . addEventListener ( "click" , loadSettings ) ;