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-25 00:49:28 +02:00
const cartesiaHint = $ ( "#cartesia-lang-hint" ) ;
let cartesiaUnsupportedLangs = [ ] ; // von /api/config: Sprachen, die Cartesia nicht spricht
const CARTESIA _LANG _LABELS = { ar : "Arabisch" , it : "Italienisch" , ru : "Russisch" , pl : "Polnisch" , nl : "Niederländisch" , sv : "Schwedisch" , tr : "Türkisch" } ;
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-25 02:51:17 +02:00
// Die gewählte Sprache ist immer fest (Flex-Modus wurde entfernt).
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 langOverrides ( ) {
2026-06-25 02:51:17 +02:00
return { language : ( langSel && langSel . value ) || "de" } ;
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 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-24 22:01:38 +02:00
// Geschlechtspräferenz: 'm', 'f', oder 'any' (= weiblich bevorzugt)
let _voiceGender = 'f' ;
function applyGenderChoice ( gender ) {
// 'any' aus alten Prefs → weiblich als Standardwert
_voiceGender = ( gender === 'm' ) ? 'm' : 'f' ;
const btnM = document . getElementById ( 'gender-m' ) ;
const btnF = document . getElementById ( 'gender-f' ) ;
if ( ! btnM || ! btnF ) return ;
const mActive = ( _voiceGender === 'm' ) ;
const fActive = ( _voiceGender === 'f' ) ;
// Aktiv: blauer Hintergrund + weiße Schrift
// Inaktiv: kein Hintergrund + dunkle Schrift (explizit, damit kein Vererbungsproblem)
btnM . classList . toggle ( 'bg-blue-600' , mActive ) ;
btnM . classList . toggle ( 'text-white' , mActive ) ;
btnM . classList . toggle ( 'border-blue-600' , mActive ) ;
btnM . classList . toggle ( 'text-slate-700' , ! mActive ) ;
btnF . classList . toggle ( 'bg-blue-600' , fActive ) ;
btnF . classList . toggle ( 'text-white' , fActive ) ;
btnF . classList . toggle ( 'border-blue-600' , fActive ) ;
btnF . classList . toggle ( 'text-slate-700' , ! fActive ) ;
}
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 ;
}
2026-06-24 22:01:38 +02:00
out . voice _gender = _voiceGender ;
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
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" ,
2026-06-28 15:13:29 +02:00
it : "it-IT" , nl : "nl-NL" , pt : "pt-BR" , pl : "pl-PL" , ar : "ar-SA" , ru : "ru-RU" , zh : "zh-CN" ,
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 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 ( ) ;
2026-06-21 01:51:37 +02:00
// Stimmen laden asynchron -> bei Eintreffen Preset-Zustand neu bewerten (entgrauen).
feat(menu): nicht nutzbare TTS-Provider ausblenden + GPU-Erkennung
Menue zeigt nur tatsaechlich nutzbare Vorlese-Optionen, um Verwirrung durch
folgenlose Eintraege zu vermeiden:
- Backend ist alleinige Quelle: /api/config liefert fertiges tts_menu
(Chatterbox nur wenn erreichbar, Cloud nur mit OpenRouter-TTS, Cartesia nur
mit Key). piper bleibt garantierter, immer sichtbarer Default.
- Frontend blendet aus statt auszugrauen; 'Im Geraet' nur auf Mobilgeraeten
(isMobile). Aktive/gespeicherte Auswahl auf einen sichtbaren Provider
geklemmt -> kein Server-422.
- 4x refresh*Preset + 3x /api/config-Fetch -> 1x refreshTtsMenu; /api/config
serverseitig 30s gecacht (spart die bisher kostenpflichtige OpenRouter-Probe
pro Seitenladen).
GPU-Erkennung zentralisiert (keine Dopplung):
- app/capabilities.py: detect_physical_gpus/resolve_gpus (nvidia-smi).
- Neues Setting GPUS (auto|none|0,1,2), beim Start an CUDA_VISIBLE_DEVICES
gekoppelt -> angezeigt = real nutzbar. admin_llm nutzt dieselbe Erkennung.
Tests: tests/test_capabilities.py (Maske, Kopplung, Menue-Sichtbarkeit, GPUs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 04:16:21 +02:00
speechSynthesis . addEventListener ( "voiceschanged" , ( ) => { loadVoices ( ) ; applyTtsVisibility ( ) ; } ) ;
setTimeout ( ( ) => applyTtsVisibility ( ) , 1500 ) ; // Backstop, falls kein voiceschanged kommt
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-27 02:43:29 +02:00
// Die Web Speech API kennt KEIN Standard-Geschlechtsfeld an einer Stimme -> Heuristik
// über Stimmname/voiceURI. Liefert 'm', 'f' oder null (unbekannt). Bewusst konservativ:
// im Zweifel null, damit wir lieber auf Server-TTS ausweichen als falsch zu raten.
const _VOICE _F = [ "female" , "weiblich" , "femme" , "mujer" , "femminile" , "feminino" ,
"anna" , "helena" , "petra" , "samantha" , "victoria" , "karen" , "moira" , "tessa" , "fiona" ,
"susan" , "zoe" , "amelie" , "amélie" , "audrey" , "aurelie" , "aurélie" , "marie" , "alice" ,
"federica" , "elsa" , "monica" , "mónica" , "paulina" , "luciana" , "joana" , "catarina" ,
"milena" , "alena" , "irina" , "tatyana" , "ting-ting" , "mei-jia" , "sin-ji" , "yaoyao" ,
"huihui" , "hedda" , "katja" , "hazel" , "zira" , "heami" ] ;
const _VOICE _M = [ "male" , "männlich" , "homme" , "hombre" , "maschile" , "masculino" ,
"markus" , "yannick" , "reed" , "aaron" , "fred" , "daniel" , "oliver" , "arthur" , "rishi" ,
"gordon" , "conrad" , "thomas" , "luca" , "cosimo" , "diego" , "jorge" , "carlos" , "felipe" ,
"bruno" , "yuri" , "pavel" , "maxim" , "liang" , "kangkang" , "stefan" , "david" , "mark" ,
"george" , "pablo" , "ravi" ] ;
function _voiceGenderOf ( v ) {
if ( ! v ) return null ;
if ( v . gender === "male" ) return "m" ; // nicht-Standard, aber manche Engines liefern es
if ( v . gender === "female" ) return "f" ;
const s = ( ( v . name || "" ) + " " + ( v . voiceURI || "" ) ) . toLowerCase ( ) ;
if ( _VOICE _F . some ( ( n ) => s . includes ( n ) ) ) return "f" ;
if ( _VOICE _M . some ( ( n ) => s . includes ( n ) ) ) return "m" ;
return null ;
}
// Sprach- UND (wenn erkennbar) geschlechtspassende Gerätestimme.
// Rückgabe: { voice, genderMatch }. genderMatch=false signalisiert dem Aufrufer, dass
// keine Stimme des Wunschgeschlechts gefunden wurde (-> Server-Fallback ist sinnvoll).
function pickVoice ( bcp , gender ) {
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 ( ! _voices . length ) loadVoices ( ) ;
const lc = bcp . toLowerCase ( ) ;
const two = lc . slice ( 0 , 2 ) ;
2026-06-27 02:43:29 +02:00
const cands = _voices . filter (
( v ) => v . lang && ( v . lang . toLowerCase ( ) === lc || v . lang . toLowerCase ( ) . startsWith ( two ) ) ,
) ;
if ( ! cands . length ) return { voice : null , genderMatch : false } ;
if ( gender === "m" || gender === "f" ) {
const hit = cands . find ( ( v ) => _voiceGenderOf ( v ) === gender ) ;
if ( hit ) return { voice : hit , genderMatch : true } ;
return { voice : cands [ 0 ] , genderMatch : false } ; // keine passende -> Caller weicht aus
}
return { voice : cands [ 0 ] , genderMatch : true } ;
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 _makeUtterance ( text , lang ) {
const u = new SpeechSynthesisUtterance ( text ) ;
u . lang = LANG _BCP47 [ lang ] || lang || "de-DE" ;
2026-06-27 02:43:29 +02:00
const { voice } = pickVoice ( u . lang , _voiceGender ) ;
if ( voice ) u . voice = voice ;
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
return u ;
}
2026-06-21 01:02:03 +02:00
// Hat der Browser nutzbare Sprachstimmen? (Linux-Desktops haben oft KEINE -> stumm.)
function deviceVoicesReady ( ) {
if ( ! TTS _SUPPORTED ) return false ;
if ( ! _voices . length ) loadVoices ( ) ;
return _voices . length > 0 ;
}
2026-06-28 16:15:04 +02:00
// Hat das Gerät eine Stimme für diese Sprache? Geschlecht ist Best-Effort: pickVoice wählt
// bei Treffer die geschlechtspassende, sonst die erste sprachrichtige Stimme (cands[0]).
// Früher wurde bei fehlendem Geschlechtsmatch hart auf Server-TTS ausgewichen — das
// verbannte Sprachen mit nur einer Geräte-Stimme (z.B. NL) auf eine schlechte Server-
// Aussprache. Jetzt gilt: eine sprachrichtige Stimme reicht; die m/f-Wahl wirkt am Gerät
// nur für Sprachen mit mehreren Stimmen (DE, EN …), sonst folgenlos.
function deviceVoiceReady ( lang ) {
2026-06-27 02:43:29 +02:00
const bcp = LANG _BCP47 [ lang ] || lang || "de-DE" ;
2026-06-28 16:15:04 +02:00
return pickVoice ( bcp , _voiceGender ) . voice != null ;
2026-06-27 02:43:29 +02:00
}
2026-06-21 01:02:03 +02:00
// Server-Fallback: holt Audio via /api/speak (ohne tts_provider -> Server-Default,
// niemals "device") und spielt es ab. Greift, wenn Geräte-TTS keine Stimme hat.
async function serverSpeakFallback ( text , lang , btn ) {
try {
const body = { text } ;
if ( lang ) body . language = lang ;
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" ;
}
}
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 ohne Button-Status (automatisches Vorlesen der Antwort).
function deviceSpeak ( text , lang ) {
2026-06-21 01:02:03 +02:00
if ( ! text ) return ;
2026-06-28 16:15:04 +02:00
if ( deviceVoicesReady ( ) && deviceVoiceReady ( lang ) ) {
2026-06-21 01:02:03 +02:00
speechSynthesis . cancel ( ) ;
speechSynthesis . speak ( _makeUtterance ( text , lang ) ) ;
} else {
2026-06-28 16:15:04 +02:00
serverSpeakFallback ( text , lang , null ) ; // keine Stimme für diese Sprache -> Server-Audio
2026-06-21 01:02:03 +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
}
// Vorlesen mit Button-Status (Replay): Icon -> ⏹ während des Sprechens, danach zurück.
function deviceSpeakBtn ( text , lang , btn ) {
2026-06-21 01:02:03 +02:00
if ( ! text ) { setReplayPlaying ( btn , false ) ; return ; }
2026-06-28 16:15:04 +02:00
if ( ! deviceVoicesReady ( ) || ! deviceVoiceReady ( lang ) ) { serverSpeakFallback ( text , lang , btn ) ; 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
speechSynthesis . cancel ( ) ;
const u = _makeUtterance ( text , lang ) ;
u . onend = u . onerror = ( ) => setReplayPlaying ( btn , false ) ;
setReplayPlaying ( btn , true ) ;
speechSynthesis . speak ( u ) ;
}
2026-06-26 10:55:15 +02:00
// iOS: sowohl SpeechSynthesis (Geräte-TTS) als auch der Web-Audio-Kontext (Server-Chunks)
// dürfen nur nach einer Nutzergeste starten -> beim ersten Tap beide freischalten.
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 _ttsUnlocked = false ;
2026-06-26 10:55:15 +02:00
let _audioUnlocked = false ;
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 unlockTTS ( ) {
2026-06-26 10:55:15 +02:00
// Geräte-TTS per Leer-Utterance freischalten.
if ( TTS _SUPPORTED && ! _ttsUnlocked ) {
try { speechSynthesis . speak ( new SpeechSynthesisUtterance ( "" ) ) ; _ttsUnlocked = true ; } catch ( e ) { }
}
// Web-Audio-Kontext jetzt (in der Geste) anlegen + per Stille-Buffer aufwecken, damit
// die satzweise Chunk-Wiedergabe in den Server-Modi auch auf iOS läuft (sonst bleibt ein
// erst im WS-Callback erzeugter AudioContext dort "suspended").
if ( ! _audioUnlocked ) {
try {
audioCtx = audioCtx || new ( window . AudioContext || window . webkitAudioContext ) ( ) ;
if ( audioCtx . state === "suspended" ) audioCtx . resume ( ) ;
const src = audioCtx . createBufferSource ( ) ;
src . buffer = audioCtx . createBuffer ( 1 , 1 , 22050 ) ; // 1 Sample Stille
src . connect ( audioCtx . destination ) ;
src . start ( 0 ) ;
_audioUnlocked = true ;
} 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
2026-06-20 22:26:20 +02:00
// Setzt das verborgene Quell-Select (#tts) beim Laden + spiegelt es in die Presets.
// Vorrang: 1. lokale Wahl (localStorage), 2. Server-Pref (tts_provider), 3. Mobil-Default = Gerät.
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 applyPlaybackChoice ( serverProvider ) {
if ( ! ttsSel ) return ;
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-20 22:26:20 +02:00
} else if ( ! ttsSel . value ) {
ttsSel . value = "piper" ; // Fallback: Server
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(menu): nicht nutzbare TTS-Provider ausblenden + GPU-Erkennung
Menue zeigt nur tatsaechlich nutzbare Vorlese-Optionen, um Verwirrung durch
folgenlose Eintraege zu vermeiden:
- Backend ist alleinige Quelle: /api/config liefert fertiges tts_menu
(Chatterbox nur wenn erreichbar, Cloud nur mit OpenRouter-TTS, Cartesia nur
mit Key). piper bleibt garantierter, immer sichtbarer Default.
- Frontend blendet aus statt auszugrauen; 'Im Geraet' nur auf Mobilgeraeten
(isMobile). Aktive/gespeicherte Auswahl auf einen sichtbaren Provider
geklemmt -> kein Server-422.
- 4x refresh*Preset + 3x /api/config-Fetch -> 1x refreshTtsMenu; /api/config
serverseitig 30s gecacht (spart die bisher kostenpflichtige OpenRouter-Probe
pro Seitenladen).
GPU-Erkennung zentralisiert (keine Dopplung):
- app/capabilities.py: detect_physical_gpus/resolve_gpus (nvidia-smi).
- Neues Setting GPUS (auto|none|0,1,2), beim Start an CUDA_VISIBLE_DEVICES
gekoppelt -> angezeigt = real nutzbar. admin_llm nutzt dieselbe Erkennung.
Tests: tests/test_capabilities.py (Maske, Kopplung, Menue-Sichtbarkeit, GPUs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 04:16:21 +02:00
applyTtsVisibility ( ) ; // blendet Provider passend ein/aus + syncPresetUI
}
// ── TTS-Menü: Sichtbarkeit der Provider (eine Quelle: Backend /api/config) ──
// Regeln liegen im Backend (tts_menu); hier nur rendern + die client-only-Regel
// „Im Gerät nur auf Mobilgeräten" (isMobile). Ausgeblendet, nicht ausgegraut.
let _ttsMenu = null ; // zuletzt geladenes tts_menu von /api/config
const TTS _DEFAULT = "piper" ; // garantiert sichtbarer Fallback -> Menü nie leer
function _ttsVisible ( provider ) {
if ( provider === "device" ) return TTS _SUPPORTED && isMobile ( ) ; // nur mobil
if ( provider === TTS _DEFAULT ) return true ; // garantierter Default
if ( ! _ttsMenu ) return true ; // vor dem Laden nichts verstecken
const e = _ttsMenu . find ( ( m ) => m . provider === provider ) ;
return e ? e . visible !== false : true ;
}
// Blendet Provider-Buttons (und die zugehörigen <option>) ein/aus und klemmt die
// aktive Auswahl auf einen sichtbaren Provider (sonst spielt nichts / Server-422).
function applyTtsVisibility ( ) {
if ( ! ttsSel ) return ;
document . querySelectorAll ( ".tts-preset" ) . forEach ( ( btn ) => {
const p = btn . dataset . tts ;
const vis = _ttsVisible ( p ) ;
btn . classList . toggle ( "hidden" , ! vis ) ;
const opt = ttsSel . querySelector ( ` option[value=" ${ p } "] ` ) ;
if ( opt ) { opt . hidden = ! vis ; opt . disabled = ! vis ; }
} ) ;
const dsub = document . querySelector ( '.tts-preset[data-tts="device"] .tts-sub' ) ;
if ( dsub ) dsub . textContent = deviceVoicesReady ( )
2026-06-21 01:51:37 +02:00
? "Das Gerät liest vor · spart Daten"
feat(menu): nicht nutzbare TTS-Provider ausblenden + GPU-Erkennung
Menue zeigt nur tatsaechlich nutzbare Vorlese-Optionen, um Verwirrung durch
folgenlose Eintraege zu vermeiden:
- Backend ist alleinige Quelle: /api/config liefert fertiges tts_menu
(Chatterbox nur wenn erreichbar, Cloud nur mit OpenRouter-TTS, Cartesia nur
mit Key). piper bleibt garantierter, immer sichtbarer Default.
- Frontend blendet aus statt auszugrauen; 'Im Geraet' nur auf Mobilgeraeten
(isMobile). Aktive/gespeicherte Auswahl auf einen sichtbaren Provider
geklemmt -> kein Server-422.
- 4x refresh*Preset + 3x /api/config-Fetch -> 1x refreshTtsMenu; /api/config
serverseitig 30s gecacht (spart die bisher kostenpflichtige OpenRouter-Probe
pro Seitenladen).
GPU-Erkennung zentralisiert (keine Dopplung):
- app/capabilities.py: detect_physical_gpus/resolve_gpus (nvidia-smi).
- Neues Setting GPUS (auto|none|0,1,2), beim Start an CUDA_VISIBLE_DEVICES
gekoppelt -> angezeigt = real nutzbar. admin_llm nutzt dieselbe Erkennung.
Tests: tests/test_capabilities.py (Maske, Kopplung, Menue-Sichtbarkeit, GPUs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 04:16:21 +02:00
: "Liest über den Server vor" ;
if ( ! _ttsVisible ( ttsSel . value ) ) {
const wasServer = ttsSel . value && ttsSel . value !== "device" ;
ttsSel . value = TTS _DEFAULT ;
if ( wasServer ) {
savePrefs ( { tts _provider : TTS _DEFAULT } ) ; // verhindert Server-422 beim nächsten Turn
if ( statusEl ) statusEl . textContent = "Stimme nicht verfügbar – auf „Schnell“ umgestellt" ;
2026-06-21 01:51:37 +02:00
}
feat(menu): nicht nutzbare TTS-Provider ausblenden + GPU-Erkennung
Menue zeigt nur tatsaechlich nutzbare Vorlese-Optionen, um Verwirrung durch
folgenlose Eintraege zu vermeiden:
- Backend ist alleinige Quelle: /api/config liefert fertiges tts_menu
(Chatterbox nur wenn erreichbar, Cloud nur mit OpenRouter-TTS, Cartesia nur
mit Key). piper bleibt garantierter, immer sichtbarer Default.
- Frontend blendet aus statt auszugrauen; 'Im Geraet' nur auf Mobilgeraeten
(isMobile). Aktive/gespeicherte Auswahl auf einen sichtbaren Provider
geklemmt -> kein Server-422.
- 4x refresh*Preset + 3x /api/config-Fetch -> 1x refreshTtsMenu; /api/config
serverseitig 30s gecacht (spart die bisher kostenpflichtige OpenRouter-Probe
pro Seitenladen).
GPU-Erkennung zentralisiert (keine Dopplung):
- app/capabilities.py: detect_physical_gpus/resolve_gpus (nvidia-smi).
- Neues Setting GPUS (auto|none|0,1,2), beim Start an CUDA_VISIBLE_DEVICES
gekoppelt -> angezeigt = real nutzbar. admin_llm nutzt dieselbe Erkennung.
Tests: tests/test_capabilities.py (Maske, Kopplung, Menue-Sichtbarkeit, GPUs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 04:16:21 +02:00
} else if ( localStorage . getItem ( PLAYBACK _KEY ) === "device"
&& _ttsVisible ( "device" ) && ttsSel . value !== "device" ) {
ttsSel . value = "device" ; // gespeicherte Geräte-Präferenz wieder aktivieren
2026-06-21 01:51:37 +02:00
}
2026-06-20 22:26:20 +02:00
syncPresetUI ( ) ;
feat(menu): nicht nutzbare TTS-Provider ausblenden + GPU-Erkennung
Menue zeigt nur tatsaechlich nutzbare Vorlese-Optionen, um Verwirrung durch
folgenlose Eintraege zu vermeiden:
- Backend ist alleinige Quelle: /api/config liefert fertiges tts_menu
(Chatterbox nur wenn erreichbar, Cloud nur mit OpenRouter-TTS, Cartesia nur
mit Key). piper bleibt garantierter, immer sichtbarer Default.
- Frontend blendet aus statt auszugrauen; 'Im Geraet' nur auf Mobilgeraeten
(isMobile). Aktive/gespeicherte Auswahl auf einen sichtbaren Provider
geklemmt -> kein Server-422.
- 4x refresh*Preset + 3x /api/config-Fetch -> 1x refreshTtsMenu; /api/config
serverseitig 30s gecacht (spart die bisher kostenpflichtige OpenRouter-Probe
pro Seitenladen).
GPU-Erkennung zentralisiert (keine Dopplung):
- app/capabilities.py: detect_physical_gpus/resolve_gpus (nvidia-smi).
- Neues Setting GPUS (auto|none|0,1,2), beim Start an CUDA_VISIBLE_DEVICES
gekoppelt -> angezeigt = real nutzbar. admin_llm nutzt dieselbe Erkennung.
Tests: tests/test_capabilities.py (Maske, Kopplung, Menue-Sichtbarkeit, GPUs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 04:16:21 +02:00
updateCartesiaLangHint ( ) ;
2026-06-20 22:26:20 +02:00
}
feat(menu): nicht nutzbare TTS-Provider ausblenden + GPU-Erkennung
Menue zeigt nur tatsaechlich nutzbare Vorlese-Optionen, um Verwirrung durch
folgenlose Eintraege zu vermeiden:
- Backend ist alleinige Quelle: /api/config liefert fertiges tts_menu
(Chatterbox nur wenn erreichbar, Cloud nur mit OpenRouter-TTS, Cartesia nur
mit Key). piper bleibt garantierter, immer sichtbarer Default.
- Frontend blendet aus statt auszugrauen; 'Im Geraet' nur auf Mobilgeraeten
(isMobile). Aktive/gespeicherte Auswahl auf einen sichtbaren Provider
geklemmt -> kein Server-422.
- 4x refresh*Preset + 3x /api/config-Fetch -> 1x refreshTtsMenu; /api/config
serverseitig 30s gecacht (spart die bisher kostenpflichtige OpenRouter-Probe
pro Seitenladen).
GPU-Erkennung zentralisiert (keine Dopplung):
- app/capabilities.py: detect_physical_gpus/resolve_gpus (nvidia-smi).
- Neues Setting GPUS (auto|none|0,1,2), beim Start an CUDA_VISIBLE_DEVICES
gekoppelt -> angezeigt = real nutzbar. admin_llm nutzt dieselbe Erkennung.
Tests: tests/test_capabilities.py (Maske, Kopplung, Menue-Sichtbarkeit, GPUs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 04:16:21 +02:00
// Holt die Sichtbarkeiten einmal vom (gecachten) Backend und wendet sie an.
async function refreshTtsMenu ( ) {
2026-06-24 22:01:38 +02:00
try {
feat(menu): nicht nutzbare TTS-Provider ausblenden + GPU-Erkennung
Menue zeigt nur tatsaechlich nutzbare Vorlese-Optionen, um Verwirrung durch
folgenlose Eintraege zu vermeiden:
- Backend ist alleinige Quelle: /api/config liefert fertiges tts_menu
(Chatterbox nur wenn erreichbar, Cloud nur mit OpenRouter-TTS, Cartesia nur
mit Key). piper bleibt garantierter, immer sichtbarer Default.
- Frontend blendet aus statt auszugrauen; 'Im Geraet' nur auf Mobilgeraeten
(isMobile). Aktive/gespeicherte Auswahl auf einen sichtbaren Provider
geklemmt -> kein Server-422.
- 4x refresh*Preset + 3x /api/config-Fetch -> 1x refreshTtsMenu; /api/config
serverseitig 30s gecacht (spart die bisher kostenpflichtige OpenRouter-Probe
pro Seitenladen).
GPU-Erkennung zentralisiert (keine Dopplung):
- app/capabilities.py: detect_physical_gpus/resolve_gpus (nvidia-smi).
- Neues Setting GPUS (auto|none|0,1,2), beim Start an CUDA_VISIBLE_DEVICES
gekoppelt -> angezeigt = real nutzbar. admin_llm nutzt dieselbe Erkennung.
Tests: tests/test_capabilities.py (Maske, Kopplung, Menue-Sichtbarkeit, GPUs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 04:16:21 +02:00
const cfg = await fetch ( "/api/config" , { cache : "no-store" } ) . then ( ( r ) => r . json ( ) ) ;
_ttsMenu = cfg . tts _menu || null ;
2026-06-25 00:49:28 +02:00
cartesiaUnsupportedLangs = cfg . available ? . cartesia _unsupported _languages || [ ] ;
2026-06-27 07:02:02 +02:00
const vEl = document . getElementById ( "app-version" ) ;
if ( vEl && cfg . version ) vEl . textContent = "v" + cfg . version ;
feat(menu): nicht nutzbare TTS-Provider ausblenden + GPU-Erkennung
Menue zeigt nur tatsaechlich nutzbare Vorlese-Optionen, um Verwirrung durch
folgenlose Eintraege zu vermeiden:
- Backend ist alleinige Quelle: /api/config liefert fertiges tts_menu
(Chatterbox nur wenn erreichbar, Cloud nur mit OpenRouter-TTS, Cartesia nur
mit Key). piper bleibt garantierter, immer sichtbarer Default.
- Frontend blendet aus statt auszugrauen; 'Im Geraet' nur auf Mobilgeraeten
(isMobile). Aktive/gespeicherte Auswahl auf einen sichtbaren Provider
geklemmt -> kein Server-422.
- 4x refresh*Preset + 3x /api/config-Fetch -> 1x refreshTtsMenu; /api/config
serverseitig 30s gecacht (spart die bisher kostenpflichtige OpenRouter-Probe
pro Seitenladen).
GPU-Erkennung zentralisiert (keine Dopplung):
- app/capabilities.py: detect_physical_gpus/resolve_gpus (nvidia-smi).
- Neues Setting GPUS (auto|none|0,1,2), beim Start an CUDA_VISIBLE_DEVICES
gekoppelt -> angezeigt = real nutzbar. admin_llm nutzt dieselbe Erkennung.
Tests: tests/test_capabilities.py (Maske, Kopplung, Menue-Sichtbarkeit, GPUs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 04:16:21 +02:00
} catch ( e ) { /* offline -> nichts verstecken */ }
applyTtsVisibility ( ) ;
2026-06-24 22:01:38 +02:00
}
2026-06-25 00:49:28 +02:00
// Zeigt einen Hinweis, wenn Cartesia gewählt ist, die feste Sprache aber nicht
// unterstützt wird — dann übernimmt automatisch die lokale Piper-Stimme (tts_fallback).
function updateCartesiaLangHint ( ) {
if ( ! cartesiaHint ) return ;
const lang = langSel && langSel . value ;
const show = ttsSel && ttsSel . value === "cartesia"
&& lang && lang !== "flex"
&& cartesiaUnsupportedLangs . includes ( lang ) ;
if ( show ) {
const label = CARTESIA _LANG _LABELS [ lang ] || lang . toUpperCase ( ) ;
cartesiaHint . textContent =
` Hinweis: Cartesia spricht ${ label } nicht — es wird automatisch die lokale Stimme (Piper) verwendet. ` ;
cartesiaHint . classList . remove ( "hidden" ) ;
} else {
cartesiaHint . classList . add ( "hidden" ) ;
}
}
2026-06-20 22:26:20 +02:00
// Markiert das aktive Ton-Preset (Häkchen + Rahmen) passend zu #tts.
function syncPresetUI ( ) {
const val = ttsSel ? ttsSel . value : "" ;
document . querySelectorAll ( ".tts-preset" ) . forEach ( ( btn ) => {
2026-06-21 01:10:51 +02:00
const active = btn . dataset . tts === val ;
2026-06-20 22:26:20 +02:00
btn . classList . toggle ( "ring-2" , active ) ;
btn . classList . toggle ( "ring-blue-500" , active ) ;
btn . classList . toggle ( "border-blue-500" , active ) ;
const chk = btn . querySelector ( ".check" ) ;
if ( chk ) chk . style . opacity = active ? "1" : "0" ;
} ) ;
}
// ---------- Einstellungs-Menü (⋮) ----------
function openMenu ( ) {
$ ( "#settings-sheet" ) . classList . remove ( "hidden" ) ;
$ ( "#settings-backdrop" ) . classList . remove ( "hidden" ) ;
feat(menu): nicht nutzbare TTS-Provider ausblenden + GPU-Erkennung
Menue zeigt nur tatsaechlich nutzbare Vorlese-Optionen, um Verwirrung durch
folgenlose Eintraege zu vermeiden:
- Backend ist alleinige Quelle: /api/config liefert fertiges tts_menu
(Chatterbox nur wenn erreichbar, Cloud nur mit OpenRouter-TTS, Cartesia nur
mit Key). piper bleibt garantierter, immer sichtbarer Default.
- Frontend blendet aus statt auszugrauen; 'Im Geraet' nur auf Mobilgeraeten
(isMobile). Aktive/gespeicherte Auswahl auf einen sichtbaren Provider
geklemmt -> kein Server-422.
- 4x refresh*Preset + 3x /api/config-Fetch -> 1x refreshTtsMenu; /api/config
serverseitig 30s gecacht (spart die bisher kostenpflichtige OpenRouter-Probe
pro Seitenladen).
GPU-Erkennung zentralisiert (keine Dopplung):
- app/capabilities.py: detect_physical_gpus/resolve_gpus (nvidia-smi).
- Neues Setting GPUS (auto|none|0,1,2), beim Start an CUDA_VISIBLE_DEVICES
gekoppelt -> angezeigt = real nutzbar. admin_llm nutzt dieselbe Erkennung.
Tests: tests/test_capabilities.py (Maske, Kopplung, Menue-Sichtbarkeit, GPUs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 04:16:21 +02:00
refreshTtsMenu ( ) ; // beim Öffnen neu bewerten (Backend kann zur Laufzeit gewechselt haben)
2026-06-20 22:26:20 +02:00
}
function closeMenu ( ) {
$ ( "#settings-sheet" ) . classList . add ( "hidden" ) ;
$ ( "#settings-backdrop" ) . classList . add ( "hidden" ) ;
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-26 10:18:31 +02:00
let streamPlayTime = 0 ; // naechster Startzeitpunkt fuer gapless Streaming-Wiedergabe
2026-06-18 18:38:33 +02:00
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: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
let myPrefs = { } ; // Prefs des aktuellen Nutzers (u. a. location_consent für den Notruf-Standort)
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
2026-06-25 04:06:09 +02:00
// Sprachen (Code -> Kurzlabel) für Filter + Admin-Auswahl.
2026-06-25 04:33:33 +02:00
function loginLink ( token ) { return location . origin + "/?k=" + token ; }
async function copyText ( text , btn ) {
try { await navigator . clipboard . writeText ( text ) ; if ( btn ) { const t = btn . textContent ; btn . textContent = "✓ kopiert" ; setTimeout ( ( ) => ( btn . textContent = t ) , 1500 ) ; } }
catch ( e ) { /* Clipboard evtl. blockiert -> select-all bleibt als Fallback */ }
}
2026-06-25 04:06:09 +02:00
const ALL _LANGS = [ [ "de" , "DE" ] , [ "en" , "EN" ] , [ "fr" , "FR" ] , [ "es" , "ES" ] , [ "it" , "IT" ] ,
2026-06-28 15:13:29 +02:00
[ "nl" , "NL" ] , [ "pt" , "PT" ] , [ "pl" , "PL" ] , [ "ar" , "AR" ] , [ "ru" , "RU" ] , [ "zh" , "ZH" ] ] ;
2026-06-25 04:06:09 +02:00
2026-06-25 18:03:13 +02:00
// --- Format-Validierung der Notfall-Profil-Felder ------------------------
const _isEmail = ( v ) => / ^ [ ^ \ s @ ] + @ [ ^ \ s @ ] + \ . [ ^ \ s @ ] + $ / . test ( v ) ;
const _isPhone = ( v ) => / ^ \ + ? [ \ d \ s / ( ) . \ - ] { 6 , 20 } $ / . test ( v ) ;
function _isGermanDate ( v ) {
const m = /^(\d{2})\.(\d{2})\.(\d{4})$/ . exec ( v ) ;
if ( ! m ) return false ;
const d = + m [ 1 ] , mo = + m [ 2 ] , y = + m [ 3 ] ;
const dt = new Date ( y , mo - 1 , d ) ;
return dt . getDate ( ) === d && dt . getMonth ( ) === mo - 1 && dt . getFullYear ( ) === y && y >= 1900 && y <= 2100 ;
}
const _csvAll = ( v , fn ) => v . split ( "," ) . map ( ( s ) => s . trim ( ) ) . filter ( Boolean ) . every ( fn ) ;
// pro Feld: ok(wert) wird NUR bei nicht-leerem Wert geprüft + Meckertext.
const PROF _VALIDATORS = {
postal _code : { ok : ( v ) => / ^ \ d { 5 } $ / . test ( v ) , msg : "PLZ: genau 5 Ziffern" } ,
phone _mobile : { ok : _isPhone , msg : "Mobil: gültige Telefonnummer (z. B. +49 151 …)" } ,
phone _landline : { ok : _isPhone , msg : "Festnetz: gültige Telefonnummer" } ,
birth _date : { ok : _isGermanDate , msg : "Geburtsdatum: TT.MM.JJJJ" } ,
emergency _contacts : { ok : ( v ) => _csvAll ( v , _isEmail ) , msg : "Kontakt-E-Mail(s): gültige Adressen (Komma-getrennt)" } ,
emergency _phones : { ok : ( v ) => _csvAll ( v , _isPhone ) , msg : "Kontakt-Telefon(e): gültige Nummern (Komma-getrennt)" } ,
2026-06-26 17:10:12 +02:00
emergency _cooldown _minutes : { ok : ( v ) => / ^ \ d + $ / . test ( v ) && + v <= 60 , msg : "Pause: 0– 60 ganze Minuten" } ,
2026-06-25 18:03:13 +02:00
} ;
2026-06-25 04:06:09 +02:00
// Wendet die vom Admin erlaubten Sprachen auf das Sprachmenü an. Leer = alle erlaubt.
// Genau eine erlaubte Sprache -> Menü ausblenden (kein Stress für den Nutzer).
function applyAllowedLanguages ( prefs ) {
if ( ! langSel ) return ;
const allowed = String ( ( prefs || { } ) . allowed _languages || "" )
. split ( "," ) . map ( ( s ) => s . trim ( ) ) . filter ( Boolean ) ;
Array . from ( langSel . options ) . forEach ( ( opt ) => {
2026-06-25 17:24:36 +02:00
const blocked = allowed . length > 0 && ! allowed . includes ( opt . value ) ;
opt . hidden = blocked ; // versteckt (moderne Browser)
opt . disabled = blocked ; // robust: auch wo <option hidden> ignoriert wird, nicht wählbar
2026-06-25 04:06:09 +02:00
} ) ;
let val = ( prefs || { } ) . language || "de" ;
if ( allowed . length && ! allowed . includes ( val ) ) val = allowed [ 0 ] ;
langSel . value = val ;
langSel . classList . toggle ( "hidden" , allowed . length === 1 ) ;
}
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 || { } ;
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
myPrefs = prefs ;
// Standort-Erlaubnis ggf. vorab anfragen, damit im Notfall kein Browser-Dialog kommt.
if ( prefs . location _consent && navigator . geolocation ) {
try {
navigator . geolocation . getCurrentPosition ( ( ) => { } , ( ) => { } ,
{ maximumAge : 600000 , timeout : 8000 } ) ;
} catch ( e ) { /* ignore */ }
}
2026-06-25 04:06:09 +02:00
applyAllowedLanguages ( prefs ) ;
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
applyPlaybackChoice ( prefs . tts _provider ) ;
2026-06-24 22:01:38 +02:00
applyGenderChoice ( prefs . voice _gender || 'f' ) ;
2026-06-26 15:58:17 +02:00
initSosCooldown ( ) ; // grünen Sperr-Zustand nach Reload wiederherstellen
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" ;
2026-06-24 22:01:38 +02:00
span . dir = "auto" ;
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
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 ;
}
2026-06-30 00:11:24 +02:00
// Quellen-Links der Web-Suche unter die Antwort-Bubble (max. 4, idempotent).
function renderCitations ( bubble , urls ) {
if ( ! bubble || ! Array . isArray ( urls ) || ! urls . length ) return ;
let box = bubble . _citationsEl ;
if ( ! box ) {
box = document . createElement ( "div" ) ;
box . className = "citations mt-1 text-xs text-slate-500 dark:text-slate-400 "
+ "flex flex-wrap gap-x-2 gap-y-0.5" ;
bubble . _citationsEl = box ;
bubble . appendChild ( box ) ;
}
box . innerHTML = "" ;
const label = document . createElement ( "span" ) ;
label . textContent = "Quellen:" ;
box . appendChild ( label ) ;
urls . slice ( 0 , 4 ) . forEach ( ( u ) => {
let host ;
try { host = new URL ( u ) . hostname . replace ( /^www\./ , "" ) ; } catch { host = u ; }
const a = document . createElement ( "a" ) ;
a . href = u ; a . target = "_blank" ; a . rel = "noopener noreferrer" ;
a . textContent = host ;
a . className = "underline hover:text-slate-700 dark:hover:text-white" ;
box . appendChild ( a ) ;
} ) ;
}
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 = [ ] ;
2026-06-26 10:18:31 +02:00
streamPlayTime = 0 ; // Streaming-Uhr verwerfen -> naechster Turn startet sofort
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 ( ) ;
}
2026-06-26 10:18:31 +02:00
// Inkrementelle Wiedergabe: einen einzelnen PCM-Chunk (chunked TTS, satzweise) lueckenlos
// an den bisher geplanten Stream anhaengen, statt bis zum done-Event zu puffern. So
// beginnt die Sprachausgabe, sobald der erste Satz fertig synthetisiert ist.
function scheduleChunk ( arrayBuffer , sampleRate ) {
const n = Math . floor ( arrayBuffer . byteLength / 2 ) ;
if ( ! n ) return ;
const view = new DataView ( arrayBuffer ) ;
audioCtx = audioCtx || new ( window . AudioContext || window . webkitAudioContext ) ( ) ;
if ( audioCtx . state === "suspended" ) audioCtx . resume ( ) ;
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 ) ;
activeSources . push ( src ) ; // stoppbar per Barge-in (stopAudio)
src . onended = ( ) => { activeSources = activeSources . filter ( ( s ) => s !== src ) ; } ;
// Ab jetzt oder ab dem Ende des zuletzt geplanten Chunks fortsetzen (gapless).
const startAt = Math . max ( audioCtx . currentTime , streamPlayTime ) ;
src . start ( startAt ) ;
streamPlayTime = startAt + buf . duration ;
}
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-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 ;
2026-06-26 10:18:31 +02:00
let expectChunk = false ; // naechster Binaer-Frame ist ein Streaming-Chunk (audio-Event ging voraus)
let streamed = false ; // mind. ein Chunk wurde schon inkrementell abgespielt
streamPlayTime = 0 ; // Streaming-Uhr fuer diesen Turn zuruecksetzen
2026-06-25 02:51:17 +02:00
// Sprache dieses Turns (für den Replay je Bubble) = feste Routensprache.
let routeLang = null ;
const turnLang = ( ) => 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 ) => {
2026-06-26 10:18:31 +02:00
if ( typeof event . data !== "string" ) {
2026-06-26 10:41:45 +02:00
if ( ws . _aborted ) return ; // nach Barge-in: in-flight-Chunks verwerfen, nicht mehr abspielen
2026-06-26 10:18:31 +02:00
pcm . push ( event . data ) ; // immer cachen (Replay je Bubble)
if ( expectChunk ) { // satzweiser Chunk -> sofort lueckenlos abspielen
expectChunk = false ;
if ( ! isDeviceMode ( ) ) { scheduleChunk ( event . data , sampleRate ) ; streamed = true ; }
}
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 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 ;
break ;
2026-06-29 23:08:48 +02:00
case "filler" :
// Beruhigungs-/Geduldssatz: transient in der Status-Zeile (nicht in der
// Antwort-Bubble) und im Geräte-Modus sofort sprechen — OHNE cancel, damit
// mehrere Filler in die SpeechSynthesis-Queue laufen; die Antwort (semantic)
// canceled später und übernimmt.
if ( msg . text ) statusEl . textContent = msg . text ;
if ( msg . text && isDeviceMode ( ) && deviceVoicesReady ( ) && deviceVoiceReady ( turnLang ( ) ) ) {
try { speechSynthesis . speak ( _makeUtterance ( msg . text , turnLang ( ) ) ) ; } catch ( e ) { }
}
break ;
2026-06-26 10:18:31 +02:00
case "audio" :
// Ankuendigung: der naechste Binaer-Frame ist ein satzweiser TTS-Chunk.
expectChunk = true ;
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
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 ;
2026-06-30 00:11:24 +02:00
if ( msg . citations ) renderCitations ( answerEl , msg . citations ) ;
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 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 ;
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
2026-06-26 10:18:31 +02:00
// Bei chunked TTS laeuft die Wiedergabe schon (scheduleChunk) -> nicht erneut starten.
if ( ! streamed ) playPcm ( pcm , sampleRate ) ;
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
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 ;
2026-06-30 09:25:10 +02:00
case "notice" :
// Kleine System-Notiz (z. B. Web-Suche aus / Kontingent aufgebraucht).
if ( msg . text ) addMessage ( "system" , msg . text , { lang : turnLang ( ) } ) ;
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 "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
const lo = langOverrides ( ) ;
2026-06-25 02:51:17 +02:00
addMessage ( "user" , text , { lang : 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
2026-06-26 10:41:45 +02:00
if ( activeWs ) { activeWs . _aborted = true ; activeWs . send ( JSON . stringify ( { type : "interrupt" } ) ) ; }
2026-06-18 18:38:33 +02:00
stopAudio ( ) ;
statusEl . textContent = "Unterbrochen" ;
// busy + Button-Reset erfolgen sobald die WS schliesst (-> runTurn resolve -> sendVoice/sendText)
} else if ( mediaRecorder && mediaRecorder . state === "recording" ) {
stopRecording ( ) ;
} else {
2026-06-20 22:26:20 +02:00
startRecording ( ) ; // Server-STT (Upload) — überall gleich
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-20 22:26:20 +02:00
$ ( "#admin-toggle" ) . addEventListener ( "click" , ( ) => { closeMenu ( ) ; 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 ) ;
}
2026-06-20 22:26:20 +02:00
$ ( "#new-chat" ) . addEventListener ( "click" , ( ) => { closeMenu ( ) ; newConversation ( ) ; } ) ;
2026-06-26 15:58:17 +02:00
// Gleiche Aktion als Symbol oben in der Kopfzeile (zwischen Sprachwahl und Menü).
const newChatTop = $ ( "#new-chat-top" ) ;
if ( newChatTop ) newChatTop . addEventListener ( "click" , newConversation ) ;
2026-06-20 22:26:20 +02:00
// ---------- Menü (⋮) verdrahten ----------
$ ( "#menu-toggle" ) . addEventListener ( "click" , ( ) =>
$ ( "#settings-sheet" ) . classList . contains ( "hidden" ) ? openMenu ( ) : closeMenu ( ) ) ;
$ ( "#settings-backdrop" ) . addEventListener ( "click" , closeMenu ) ;
document . addEventListener ( "keydown" , ( e ) => { if ( e . key === "Escape" ) closeMenu ( ) ; } ) ;
// Ton-Presets: setzen das verborgene #tts + lösen die bestehende Persistenz-Logik aus.
document . querySelectorAll ( ".tts-preset" ) . forEach ( ( btn ) =>
btn . addEventListener ( "click" , ( ) => {
unlockTTS ( ) ;
ttsSel . value = btn . dataset . tts ;
ttsSel . dispatchEvent ( new Event ( "change" ) ) ;
syncPresetUI ( ) ;
} )
) ;
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 10:20:24 +02:00
// Tag/Nacht: Umschalter (manuell) + automatisch dem System folgen, solange nichts gewählt.
const themeBtn = $ ( "#theme-toggle" ) ;
2026-06-20 22:26:20 +02:00
const setThemeIcon = ( ) => {
const icon = $ ( "#theme-icon" ) ;
if ( icon ) icon . textContent = document . documentElement . classList . contains ( "dark" ) ? "☀️" : "🌙" ;
} ;
2026-06-18 10:20:24 +02:00
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.
2026-06-25 00:49:28 +02:00
if ( langSel ) langSel . addEventListener ( "change" , ( ) => { savePrefs ( langOverrides ( ) ) ; updateCartesiaLangHint ( ) ; } ) ;
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 ( 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-25 00:49:28 +02:00
updateCartesiaLangHint ( ) ;
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-24 22:01:38 +02:00
// Gender-Buttons
document . addEventListener ( "DOMContentLoaded" , ( ) => {
const btnM = document . getElementById ( "gender-m" ) ;
const btnF = document . getElementById ( "gender-f" ) ;
if ( btnM ) btnM . addEventListener ( "click" , ( ) => {
applyGenderChoice ( "m" ) ;
savePrefs ( { voice _gender : "m" } ) ;
} ) ;
if ( btnF ) btnF . addEventListener ( "click" , ( ) => {
applyGenderChoice ( "f" ) ;
savePrefs ( { voice _gender : "f" } ) ;
} ) ;
} ) ;
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 ( ) ;
feat(menu): nicht nutzbare TTS-Provider ausblenden + GPU-Erkennung
Menue zeigt nur tatsaechlich nutzbare Vorlese-Optionen, um Verwirrung durch
folgenlose Eintraege zu vermeiden:
- Backend ist alleinige Quelle: /api/config liefert fertiges tts_menu
(Chatterbox nur wenn erreichbar, Cloud nur mit OpenRouter-TTS, Cartesia nur
mit Key). piper bleibt garantierter, immer sichtbarer Default.
- Frontend blendet aus statt auszugrauen; 'Im Geraet' nur auf Mobilgeraeten
(isMobile). Aktive/gespeicherte Auswahl auf einen sichtbaren Provider
geklemmt -> kein Server-422.
- 4x refresh*Preset + 3x /api/config-Fetch -> 1x refreshTtsMenu; /api/config
serverseitig 30s gecacht (spart die bisher kostenpflichtige OpenRouter-Probe
pro Seitenladen).
GPU-Erkennung zentralisiert (keine Dopplung):
- app/capabilities.py: detect_physical_gpus/resolve_gpus (nvidia-smi).
- Neues Setting GPUS (auto|none|0,1,2), beim Start an CUDA_VISIBLE_DEVICES
gekoppelt -> angezeigt = real nutzbar. admin_llm nutzt dieselbe Erkennung.
Tests: tests/test_capabilities.py (Maske, Kopplung, Menue-Sichtbarkeit, GPUs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 04:16:21 +02:00
refreshTtsMenu ( ) ;
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" ) ;
2026-06-20 23:11:57 +02:00
switchAdminTab ( "overview" ) ;
2026-06-19 01:04:20 +02:00
}
$ ( "#admin-close" ) . addEventListener ( "click" , ( ) => $ ( "#admin-panel" ) . classList . add ( "hidden" ) ) ;
2026-06-20 23:11:57 +02:00
// ---------- Tab-Struktur: 5 Bereiche, je mit 1+ Abschnitten ----------
// section = vorhandener Content-Block (#admin-tab-<section>); Bereiche mit mehreren
// Abschnitten bekommen eine Sub-Navigation.
const ADMIN _TABS = {
overview : [ [ "overview" , "Übersicht" ] ] ,
2026-06-25 04:52:09 +02:00
users : [ [ "users" , "Verwalten" ] , [ "chat" , "Gespräche" ] , [ "metrics" , "Nutzungs-Statistik" ] ] ,
system : [ [ "status" , "Status" ] , [ "log" , "Log" ] , [ "emergency" , "Notrufe" ] ] ,
config : [ [ "settings" , "Einstellungen" ] , [ "words" , "Wörterbuch" ] , [ "maintenance" , "Wartung" ] ] ,
2026-06-20 23:11:57 +02:00
} ;
const SECTION _LOADER = {
overview : ( ) => loadOverview ( ) ,
users : ( ) => loadAdminUsers ( ) ,
chat : ( ) => loadChatUsers ( ) ,
emergency : ( ) => loadEmergencyEvents ( ) ,
status : ( ) => loadStatus ( ) ,
metrics : ( ) => loadMetrics ( ) ,
words : ( ) => loadWoerterbuch ( ) ,
settings : ( ) => loadSettings ( ) ,
2026-06-25 04:52:09 +02:00
maintenance : ( ) => loadMaintenance ( ) ,
2026-06-20 23:11:57 +02:00
log : ( ) => { } , // Log: Nutzer verbindet manuell
} ;
function _setTabActive ( btn , active ) {
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 ) ;
}
function showAdminSection ( section , top ) {
2026-06-19 01:04:20 +02:00
document . querySelectorAll ( '[id^="admin-tab-"]' ) . forEach ( ( el ) => el . classList . add ( "hidden" ) ) ;
2026-06-20 23:11:57 +02:00
const panel = $ ( "#admin-tab-" + section ) ;
if ( panel ) panel . classList . remove ( "hidden" ) ;
// Sub-Tab markieren
document . querySelectorAll ( ".admin-subtab" ) . forEach ( ( b ) => {
const active = b . dataset . sec === section ;
b . classList . toggle ( "bg-blue-600" , active ) ;
b . classList . toggle ( "text-white" , active ) ;
b . classList . toggle ( "border-blue-600" , active ) ;
b . classList . toggle ( "text-slate-600" , ! active ) ;
b . classList . toggle ( "dark:text-slate-300" , ! active ) ;
b . classList . toggle ( "border-slate-300" , ! active ) ;
b . classList . toggle ( "dark:border-slate-600" , ! active ) ;
2026-06-19 01:04:20 +02:00
} ) ;
2026-06-20 23:11:57 +02:00
const loader = SECTION _LOADER [ section ] ;
if ( loader ) loader ( ) ;
}
function switchAdminTab ( top ) {
const sections = ADMIN _TABS [ top ] || [ [ top , top ] ] ;
document . querySelectorAll ( ".admin-tab" ) . forEach ( ( btn ) => _setTabActive ( btn , btn . dataset . tab === top ) ) ;
2026-06-19 01:04:20 +02:00
2026-06-20 23:11:57 +02:00
const subnav = $ ( "#admin-subnav" ) ;
if ( sections . length <= 1 ) {
subnav . classList . add ( "hidden" ) ;
subnav . innerHTML = "" ;
showAdminSection ( sections [ 0 ] [ 0 ] , top ) ;
} else {
subnav . classList . remove ( "hidden" ) ;
subnav . innerHTML = sections . map ( ( [ sec , label ] ) =>
` <button class="admin-subtab text-xs rounded-lg border px-3 py-1.5 whitespace-nowrap transition-colors border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700" data-sec=" ${ sec } "> ${ escHtml ( label ) } </button> `
) . join ( "" ) ;
subnav . querySelectorAll ( ".admin-subtab" ) . forEach ( ( b ) =>
b . addEventListener ( "click" , ( ) => showAdminSection ( b . dataset . sec , top ) )
) ;
showAdminSection ( sections [ 0 ] [ 0 ] , top ) ;
}
2026-06-19 01:04:20 +02:00
}
document . querySelectorAll ( ".admin-tab" ) . forEach ( ( btn ) =>
btn . addEventListener ( "click" , ( ) => switchAdminTab ( btn . dataset . tab ) )
) ;
2026-06-20 23:11:57 +02:00
$ ( "#load-overview" ) . addEventListener ( "click" , loadOverview ) ;
2026-06-19 01:04:20 +02:00
$ ( "#load-emergency" ) . addEventListener ( "click" , loadEmergencyEvents ) ;
$ ( "#load-status" ) . addEventListener ( "click" , loadStatus ) ;
$ ( "#load-metrics" ) . addEventListener ( "click" , loadMetrics ) ;
2026-06-20 23:11:57 +02:00
// ════════════════════════════════════════
// TAB: ÜBERSICHT (Dashboard)
// ════════════════════════════════════════
async function loadOverview ( ) {
const c = $ ( "#overview-content" ) ;
c . innerHTML = '<p class="text-sm text-slate-400">lade …</p>' ;
const [ users , usage , emergencies , llm ] = await Promise . all ( [
adminFetch ( "/api/admin/users" ) ,
adminFetch ( "/api/admin/usage" ) ,
adminFetch ( "/api/admin/emergency-events" ) ,
adminFetch ( "/api/admin/llm/status" ) ,
] ) ;
const userCount = users ? users . length : "—" ;
const reqTotal = usage ? usage . reduce ( ( s , u ) => s + ( u . total _requests || 0 ) , 0 ) : "—" ;
const emCount = emergencies ? emergencies . length : "—" ;
const backend = llm ? ( { ollama : "Ollama" , llamacpp : "llama.cpp" } [ llm . backend ] || llm . backend ) : "—" ;
const gpu = ( llm && llm . gpus && llm . gpus . length )
? llm . gpus . map ( ( g ) => ` GPU ${ g . index } : ${ g . percent } % ` ) . join ( " · " ) : "—" ;
2026-06-21 00:32:52 +02:00
const hasEmergencies = typeof emCount === "number" && emCount > 0 ;
// Kachel; mit data-jump wird sie anklickbar (springt in den Bereich).
const stat = ( label , value , sub , opts = { } ) => `
< $ { opts . jump ? ` button data-jump=" ${ opts . jump } " ` : "div" }
class = "ov-stat text-left bg-white dark:bg-slate-800 rounded-xl border ${opts.border || " border - slate - 200 dark : border - slate - 700 "} p-4 ${opts.jump ? " hover : bg - slate - 50 dark : hover : bg - slate - 700 / 50 transition - colors " : " "}" >
2026-06-20 23:11:57 +02:00
< p class = "text-xs text-slate-500 dark:text-slate-400" > $ { escHtml ( label ) } < / p >
2026-06-21 00:32:52 +02:00
< p class = "text-2xl font-semibold mt-1 ${opts.accent || " "}" > $ { escHtml ( value ) } < / p >
2026-06-20 23:11:57 +02:00
$ { sub ? ` <p class="text-xs text-slate-400 mt-0.5 truncate"> ${ escHtml ( sub ) } </p> ` : "" }
2026-06-21 00:32:52 +02:00
< / $ { o p t s . j u m p ? " b u t t o n " : " d i v " } > ` ;
2026-06-20 23:11:57 +02:00
const jump = ( to , label ) =>
` <button class="ov-jump text-sm rounded-lg border border-slate-300 dark:border-slate-600 px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors" data-jump=" ${ to } "> ${ escHtml ( label ) } </button> ` ;
c . innerHTML = `
< div class = "grid grid-cols-2 gap-3" >
2026-06-21 00:32:52 +02:00
$ { stat ( "Nutzer" , userCount , "" , { jump : "users" } ) }
$ { stat ( "Anfragen gesamt" , reqTotal , "" , { jump : "system" } ) }
$ { stat ( "Notfälle" , emCount , hasEmergencies ? "ansehen" : "keine" ,
{ jump : "emergency" ,
accent : hasEmergencies ? "text-red-600 dark:text-red-400" : "" ,
border : hasEmergencies ? "border-red-300 dark:border-red-800" : "" } ) }
$ { stat ( "LLM-Backend" , backend , llm ? llm . model : "" , { jump : "system" } ) }
2026-06-20 23:11:57 +02:00
< / d i v >
< div class = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" >
< h4 class = "font-semibold text-sm mb-2" > GPU - Auslastung < / h 4 >
< p class = "text-xs font-mono text-slate-600 dark:text-slate-300" > $ { escHtml ( gpu ) } < / p >
< / d i v >
< div class = "flex flex-wrap gap-2" >
$ { jump ( "users" , "→ Nutzer" ) } $ { jump ( "system" , "→ System" ) } $ { jump ( "emergency" , "→ Notfälle" ) }
< / d i v > ` ;
2026-06-21 00:32:52 +02:00
c . querySelectorAll ( ".ov-jump, .ov-stat[data-jump]" ) . forEach ( ( b ) =>
2026-06-20 23:11:57 +02:00
b . addEventListener ( "click" , ( ) => switchAdminTab ( b . dataset . jump ) ) ) ;
}
2026-06-19 01:04:20 +02:00
// ════════════════════════════════════════
// 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 ) {
2026-06-25 04:33:33 +02:00
$ ( "#new-token-value" ) . textContent = loginLink ( data . token ) ;
2026-06-19 01:04:20 +02:00
$ ( "#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 >
2026-06-25 04:33:33 +02:00
< button class = "btn-admin text-xs rounded-lg border px-2.5 py-1.5 transition-colors" > < / 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" > Link erneuern < / b u t t o n >
2026-06-30 09:08:43 +02:00
< button class = "btn-history text-xs rounded-lg border border-orange-300 dark:border-orange-700 text-orange-700 dark:text-orange-300 px-2.5 py-1.5 hover:bg-orange-50 dark:hover:bg-orange-900/20 transition-colors" > Verlauf löschen < / b u t t o n >
2026-06-19 01:04:20 +02:00
< 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" >
2026-06-25 04:33:33 +02:00
< p class = "text-xs text-amber-700 dark:text-amber-300 font-medium mb-1" > Neuer Login - Link ( einmalig — jetzt kopieren ! ) : < / p >
2026-06-19 01:04:20 +02:00
< 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 >
2026-06-25 04:06:09 +02:00
<!-- Erlaubte Sprachen -- >
< div class = "mt-3 pt-3 border-t border-slate-100 dark:border-slate-700" >
< p class = "text-xs text-slate-500 dark:text-slate-400 mb-1.5" > Erlaubte Sprachen < span class = "text-slate-400" > ( leer = alle ) < / s p a n > : < / p >
< div class = "lang-allow flex flex-wrap gap-1" > < / d i v >
< / d i v >
2026-06-30 09:12:55 +02:00
<!-- Web - Suche ( Weg 2 ) -- >
< div class = "mt-3 pt-3 border-t border-slate-100 dark:border-slate-700" >
< label class = "text-xs text-slate-600 dark:text-slate-300 flex items-center gap-2 cursor-pointer" >
< input type = "checkbox" class = "ws-enabled" / > Web - Suche für aktuelle Fragen erlauben
< span class = "ws-status text-slate-400" > < / s p a n >
< / l a b e l >
2026-06-30 09:22:06 +02:00
< div class = "mt-1.5 flex items-center gap-2" >
< span class = "text-xs text-slate-500 dark:text-slate-400" > max . Suchen pro Tag : < / s p a n >
< input type = "number" min = "0" class = "ws-limit w-20 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder = "∞" / >
< span class = "ws-limit-status text-xs text-slate-400" > < / s p a n >
< / d i v >
2026-06-30 13:17:54 +02:00
< div class = "mt-1.5 flex items-center gap-2" >
< span class = "text-xs text-slate-500 dark:text-slate-400" > max . Kosten pro Tag ( $ ) : < / s p a n >
< input type = "number" min = "0" step = "0.01" class = "cost-limit w-20 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder = "∞" / >
< span class = "cost-limit-status text-xs text-slate-400" > < / s p a n >
< / d i v >
2026-06-30 09:12:55 +02:00
< / d i v >
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
<!-- Notfall - Profil -- >
< div class = "mt-3 pt-3 border-t border-slate-100 dark:border-slate-700" >
< button class = "btn-prof text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 flex items-center gap-1 transition-colors" >
< span class = "prof-arrow" > ▶ < / s p a n > N o t f a l l - P r o f i l ( f l i e ß t i n N o t r u f - M e l d u n g e n )
< / b u t t o n >
< div class = "prof-section hidden mt-2 space-y-1.5" >
< input class = "prof-f w-full 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" data - k = "full_name" placeholder = "Voller Name" / >
< input class = "prof-f w-full 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" data - k = "street" placeholder = "Straße + Hausnummer" / >
< div class = "flex gap-1.5" >
< input class = "prof-f w-1/3 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" data - k = "postal_code" placeholder = "PLZ" / >
< input class = "prof-f w-2/3 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" data - k = "city" placeholder = "Ort" / >
< / d i v >
< div class = "flex gap-1.5" >
< input class = "prof-f w-1/2 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" data - k = "phone_mobile" placeholder = "Mobil" / >
< input class = "prof-f w-1/2 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" data - k = "phone_landline" placeholder = "Festnetz" / >
< / d i v >
2026-06-25 18:03:13 +02:00
< input class = "prof-f w-full 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" data - k = "birth_date" placeholder = "Geburtsdatum (TT.MM.JJJJ)" / >
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
< textarea class = "prof-f w-full 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" data - k = "medical_notes" rows = "2" placeholder = "Medizinische Hinweise (Allergien, Medikamente, Vorerkrankungen)" > < / t e x t a r e a >
< input class = "prof-f w-full 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" data - k = "emergency_contacts" placeholder = "Kontakt-E-Mail(s), Komma-getrennt" / >
< input class = "prof-f w-full 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" data - k = "emergency_phones" placeholder = "Kontakt-Telefon(e), Komma-getrennt" / >
< label class = "flex items-center gap-2 text-xs text-slate-600 dark:text-slate-300" >
< input type = "checkbox" class = "prof-consent" / > Standort im Notfall mitsenden
< / l a b e l >
2026-06-26 17:10:12 +02:00
< label class = "flex items-center gap-2 text-xs text-slate-600 dark:text-slate-300" >
Pause bis zum erneuten Alarm :
< input type = "number" min = "0" max = "60" inputmode = "numeric"
class = "prof-f w-16 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500"
data - k = "emergency_cooldown_minutes" placeholder = "Standard" / > Minuten
< / l a b e l >
< p class = "text-[11px] text-slate-400" > leer = globaler Standard · 0 = aus < / p >
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
< / d i v >
< / d i v >
2026-06-25 18:03:13 +02:00
<!-- Speichern ( Einstellungs - Ebene ) — prüft Datenformate , speichert ALLE Felder + Sprachen -- >
< div class = "mt-3 pt-3 border-t border-slate-100 dark:border-slate-700 flex items-center gap-2 flex-wrap" >
< button class = "prof-save rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-1.5 transition-colors" > Speichern < / b u t t o n >
< span class = "prof-status text-xs text-slate-400" > < / s p a n >
< / d i v >
2026-06-19 01:04:20 +02:00
<!-- 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 ;
2026-06-25 04:06:09 +02:00
// Erlaubte Sprachen (Toggle-Chips) — Admin schaltet Sprachen pro Nutzer frei.
const allowWrap = card . querySelector ( ".lang-allow" ) ;
let allowed = String ( u . allowed _languages || "" ) . split ( "," ) . map ( ( s ) => s . trim ( ) ) . filter ( Boolean ) ;
function renderLangChips ( ) {
allowWrap . innerHTML = ALL _LANGS . map ( ( [ code , label ] ) => {
const on = allowed . includes ( code ) ;
return ` <button type="button" data-lang=" ${ code } " class="lang-chip text-xs rounded-md border px-2 py-1 transition-colors ${ on ? "bg-blue-600 text-white border-blue-600" : "border-slate-300 dark:border-slate-600 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700" } "> ${ label } </button> ` ;
} ) . join ( "" ) ;
allowWrap . querySelectorAll ( ".lang-chip" ) . forEach ( ( b ) =>
b . addEventListener ( "click" , async ( ) => {
const code = b . dataset . lang ;
allowed = allowed . includes ( code ) ? allowed . filter ( ( c ) => c !== code ) : [ ... allowed , code ] ;
const res = await adminFetch ( ` /api/admin/users/ ${ uid } /prefs ` , "PUT" ,
{ allowed _languages : allowed . join ( "," ) } ) ;
if ( res ) renderLangChips ( ) ;
} )
) ;
}
renderLangChips ( ) ;
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
// Notfall-Profil — Admin pflegt Kontaktdaten + med. Hinweise + Standort-Freigabe.
const PROF _KEYS = [ "full_name" , "street" , "postal_code" , "city" , "phone_mobile" ,
"phone_landline" , "birth_date" , "medical_notes" ,
2026-06-26 17:10:12 +02:00
"emergency_contacts" , "emergency_phones" ,
"emergency_cooldown_minutes" ] ;
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
PROF _KEYS . forEach ( ( k ) => {
const el = card . querySelector ( ` .prof-f[data-k=" ${ k } "] ` ) ;
if ( el ) el . value = u [ k ] || "" ;
} ) ;
const consentEl = card . querySelector ( ".prof-consent" ) ;
if ( consentEl ) consentEl . checked = ! ! u . location _consent ;
2026-06-30 09:12:55 +02:00
// Web-Suche pro Nutzer: sofort speichern bei Änderung
const wsEl = card . querySelector ( ".ws-enabled" ) ;
const wsStatus = card . querySelector ( ".ws-status" ) ;
if ( wsEl ) {
wsEl . checked = ! ! u . web _search _enabled ;
wsEl . addEventListener ( "change" , async ( ) => {
if ( wsStatus ) wsStatus . textContent = "speichere …" ;
const res = await adminFetch ( ` /api/admin/users/ ${ uid } /prefs ` , "PUT" , { web _search _enabled : wsEl . checked } ) ;
if ( wsStatus ) {
wsStatus . textContent = res ? "✓" : "Fehler" ;
setTimeout ( ( ) => { wsStatus . textContent = "" ; } , 2000 ) ;
}
} ) ;
}
2026-06-30 09:22:06 +02:00
// Web-Such-Tagesbudget (leer/0 = unbegrenzt) — sofort speichern
const wsLimit = card . querySelector ( ".ws-limit" ) ;
const wsLimitStatus = card . querySelector ( ".ws-limit-status" ) ;
if ( wsLimit ) {
const lim = Number ( u . web _search _daily _limit ) ;
wsLimit . value = Number . isFinite ( lim ) && lim > 0 ? lim : "" ;
wsLimit . addEventListener ( "change" , async ( ) => {
const n = parseInt ( wsLimit . value , 10 ) ;
const val = Number . isFinite ( n ) && n > 0 ? n : 0 ; // leer/0 = unbegrenzt
if ( wsLimitStatus ) wsLimitStatus . textContent = "speichere …" ;
const res = await adminFetch ( ` /api/admin/users/ ${ uid } /prefs ` , "PUT" , { web _search _daily _limit : val } ) ;
if ( wsLimitStatus ) {
wsLimitStatus . textContent = res ? "✓" : "Fehler" ;
setTimeout ( ( ) => { wsLimitStatus . textContent = "" ; } , 2000 ) ;
2026-06-30 13:17:54 +02:00
}
} ) ;
}
// Kosten-Tagesbudget in $ (leer/0 = unbegrenzt) — sofort speichern
const costLimit = card . querySelector ( ".cost-limit" ) ;
const costLimitStatus = card . querySelector ( ".cost-limit-status" ) ;
if ( costLimit ) {
const lim = Number ( u . cost _daily _limit _usd ) ;
costLimit . value = Number . isFinite ( lim ) && lim > 0 ? lim : "" ;
costLimit . addEventListener ( "change" , async ( ) => {
const n = parseFloat ( costLimit . value ) ;
const val = Number . isFinite ( n ) && n > 0 ? n : 0 ; // leer/0 = unbegrenzt
if ( costLimitStatus ) costLimitStatus . textContent = "speichere …" ;
const res = await adminFetch ( ` /api/admin/users/ ${ uid } /prefs ` , "PUT" , { cost _daily _limit _usd : val } ) ;
if ( costLimitStatus ) {
costLimitStatus . textContent = res ? "✓" : "Fehler" ;
setTimeout ( ( ) => { costLimitStatus . textContent = "" ; } , 2000 ) ;
2026-06-30 09:22:06 +02:00
}
} ) ;
}
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
const profBtn = card . querySelector ( ".btn-prof" ) ;
const profSection = card . querySelector ( ".prof-section" ) ;
const profArrow = card . querySelector ( ".prof-arrow" ) ;
profBtn . addEventListener ( "click" , ( ) => {
const open = ! profSection . classList . contains ( "hidden" ) ;
profSection . classList . toggle ( "hidden" , open ) ;
profArrow . textContent = open ? "▶" : "▼" ;
} ) ;
2026-06-25 18:03:13 +02:00
const profStatus = card . querySelector ( ".prof-status" ) ;
// Prüft jedes Feld gegen sein Format (nur nicht-leere Werte), markiert Fehler rot.
// Liefert die Liste der Meckertexte (leer = alles konform).
function validateProfile ( ) {
const errs = [ ] ;
PROF _KEYS . forEach ( ( k ) => {
const el = card . querySelector ( ` .prof-f[data-k=" ${ k } "] ` ) ;
if ( ! el ) return ;
const v = el . value . trim ( ) ;
const rule = PROF _VALIDATORS [ k ] ;
const bad = ! ! v && ! ! rule && ! rule . ok ( v ) ;
el . classList . toggle ( "border-red-500" , bad ) ;
el . classList . toggle ( "ring-2" , bad ) ;
el . classList . toggle ( "ring-red-500" , bad ) ;
if ( bad ) errs . push ( rule . msg ) ;
} ) ;
return errs ;
}
// Rote Markierung verschwindet, sobald nachgebessert wird.
PROF _KEYS . forEach ( ( k ) => {
const el = card . querySelector ( ` .prof-f[data-k=" ${ k } "] ` ) ;
if ( el ) el . addEventListener ( "input" , validateProfile ) ;
} ) ;
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
card . querySelector ( ".prof-save" ) . addEventListener ( "click" , async ( ) => {
2026-06-25 18:03:13 +02:00
const errs = validateProfile ( ) ;
if ( errs . length ) {
profStatus . className = "prof-status text-xs text-red-600 dark:text-red-400" ;
profStatus . textContent = "Bitte korrigieren: " + errs . join ( " · " ) ;
return ;
}
const body = { location _consent : ! ! consentEl . checked , allowed _languages : allowed . join ( "," ) } ;
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
PROF _KEYS . forEach ( ( k ) => {
const el = card . querySelector ( ` .prof-f[data-k=" ${ k } "] ` ) ;
body [ k ] = el ? el . value . trim ( ) : "" ;
} ) ;
2026-06-25 18:03:13 +02:00
profStatus . className = "prof-status text-xs text-slate-400" ;
profStatus . textContent = "speichere …" ;
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
const res = await adminFetch ( ` /api/admin/users/ ${ uid } /prefs ` , "PUT" , body ) ;
2026-06-25 18:03:13 +02:00
profStatus . className = "prof-status text-xs " + ( res ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400" ) ;
profStatus . textContent = res ? "✓ alle Daten gespeichert" : "Fehler beim Speichern" ;
if ( res ) setTimeout ( ( ) => { profStatus . textContent = "" ; } , 3000 ) ;
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
} ) ;
2026-06-25 04:33:33 +02:00
// Admin-Rechte (persistentes Flag) ein-/ausschalten
let isAdmin = ! ! u . is _admin ;
const adminBtn = card . querySelector ( ".btn-admin" ) ;
function renderAdminBtn ( ) {
adminBtn . textContent = isAdmin ? "★ Admin" : "Admin?" ;
adminBtn . classList . toggle ( "bg-blue-600" , isAdmin ) ;
adminBtn . classList . toggle ( "text-white" , isAdmin ) ;
adminBtn . classList . toggle ( "border-blue-600" , isAdmin ) ;
adminBtn . classList . toggle ( "border-slate-300" , ! isAdmin ) ;
adminBtn . classList . toggle ( "dark:border-slate-600" , ! isAdmin ) ;
adminBtn . classList . toggle ( "text-slate-600" , ! isAdmin ) ;
adminBtn . classList . toggle ( "dark:text-slate-300" , ! isAdmin ) ;
}
renderAdminBtn ( ) ;
adminBtn . addEventListener ( "click" , async ( ) => {
const next = ! isAdmin ;
if ( next && ! confirm ( ` „ ${ u . display _name } " Admin-Rechte geben? ` ) ) return ;
const res = await adminFetch ( ` /api/admin/users/ ${ uid } /admin ` , "PUT" , { is _admin : next } ) ;
if ( res ) { isAdmin = ! ! res . is _admin ; renderAdminBtn ( ) ; }
} ) ;
2026-06-19 01:04:20 +02:00
// 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 ) {
2026-06-25 04:33:33 +02:00
card . querySelector ( ".token-val" ) . textContent = loginLink ( data . token ) ;
2026-06-19 01:04:20 +02:00
card . querySelector ( ".token-box" ) . classList . remove ( "hidden" ) ;
}
} ) ;
2026-06-30 09:08:43 +02:00
// Verlauf löschen (Konto + Erinnerungen bleiben)
card . querySelector ( ".btn-history" ) . addEventListener ( "click" , async ( ) => {
if ( ! confirm ( ` Gesprächsverlauf von „ ${ u . display _name } " löschen? \n Konto und Erinnerungen bleiben erhalten. ` ) ) return ;
const res = await adminFetch ( ` /api/admin/users/ ${ uid } /history ` , "DELETE" ) ;
if ( res ) alert ( ` Verlauf gelöscht ( ${ res . deleted _messages ? ? 0 } Nachrichten). ` ) ;
} ) ;
2026-06-19 01:04:20 +02:00
// 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 >
2026-06-21 00:32:52 +02:00
< td class = "px-4 py-2.5 text-xs text-slate-600 dark:text-slate-300 max-w-[16rem] break-words" > $ { escHtml ( ev . snippet ) } < / t d >
2026-06-19 01:04:20 +02:00
< / 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.
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." ;
}
2026-06-25 02:27:18 +02:00
function wireRestart ( ) {
2026-06-20 18:39:59 +02:00
const restartBtn = $ ( "#gw-restart-btn" ) ;
const msg = $ ( "#llm-ctrl-msg" ) ;
2026-06-25 02:27:18 +02:00
if ( ! restartBtn ) return ;
2026-06-20 18:39:59 +02:00
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 {
feat(admin): Dropdown „Zentrales KI-Modell" — erprobte Presets ruck-zuck umstellen
Neue Karte in Admin › System › Status: ein Dropdown kuratierter, erprobter
Modelle, das Provider + Modell in einem Klick als Laufzeit-Override umstellt
(wirkt sofort, kein Neustart).
- Presets in config/models.yaml (Label, optional note, set:{key:value} aus
RUNTIME_SETTABLE, default:true = Config-Standard). Loader app/model_presets.py
(nur erlaubte LLM-Keys: default_llm_provider, openrouter_llm_model,
local_llm_model).
- Endpunkte: GET /api/admin/models (Presets + aktuell aktives LLM + active_index),
POST /api/admin/models/select (Default -> Overrides löschen, sonst Override-Keys
setzen) — nutzt set_/delete_config_override + invalidate_cache, mit Audit-Log.
- Frontend: Karte zeigt Dropdown + „Aktiv: <provider> · <modell>"; Auswahl wirkt
sofort und lädt den Status neu. Bump app.js v=52 -> v=53.
- Startliste: Mistral Small 24B (Standard), DeepSeek V4 Flash/Pro, Ollama Gemma 3.
Tests: Laden/Default-aktiv/Select-setzt-Override/Default-löscht/404/Auth.
263 Offline-Tests grün. Live via Headless-Chrome verifiziert (Auswahl ->
Override gesetzt, Provider-Karte spiegelt das Modell).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:02:44 +02:00
const [ cfg , met , runtimeCfg , models ] = 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-25 02:27:18 +02:00
adminFetch ( "/api/admin/config" ) ,
feat(admin): Dropdown „Zentrales KI-Modell" — erprobte Presets ruck-zuck umstellen
Neue Karte in Admin › System › Status: ein Dropdown kuratierter, erprobter
Modelle, das Provider + Modell in einem Klick als Laufzeit-Override umstellt
(wirkt sofort, kein Neustart).
- Presets in config/models.yaml (Label, optional note, set:{key:value} aus
RUNTIME_SETTABLE, default:true = Config-Standard). Loader app/model_presets.py
(nur erlaubte LLM-Keys: default_llm_provider, openrouter_llm_model,
local_llm_model).
- Endpunkte: GET /api/admin/models (Presets + aktuell aktives LLM + active_index),
POST /api/admin/models/select (Default -> Overrides löschen, sonst Override-Keys
setzen) — nutzt set_/delete_config_override + invalidate_cache, mit Audit-Log.
- Frontend: Karte zeigt Dropdown + „Aktiv: <provider> · <modell>"; Auswahl wirkt
sofort und lädt den Status neu. Bump app.js v=52 -> v=53.
- Startliste: Mistral Small 24B (Standard), DeepSeek V4 Flash/Pro, Ollama Gemma 3.
Tests: Laden/Default-aktiv/Select-setzt-Override/Default-löscht/404/Auth.
263 Offline-Tests grün. Live via Headless-Chrome verifiziert (Auswahl ->
Override gesetzt, Provider-Karte spiegelt das Modell).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:02:44 +02:00
adminFetch ( "/api/admin/models" ) ,
2026-06-19 01:04:20 +02:00
] ) ;
2026-06-25 02:27:18 +02:00
const cfgByKey = Object . fromEntries ( ( runtimeCfg || [ ] ) . map ( ( d ) => [ d . key , d . effective _value ] ) ) ;
const llmModel = cfgByKey [ "openrouter_llm_model" ] || "—" ;
feat(admin): Dropdown „Zentrales KI-Modell" — erprobte Presets ruck-zuck umstellen
Neue Karte in Admin › System › Status: ein Dropdown kuratierter, erprobter
Modelle, das Provider + Modell in einem Klick als Laufzeit-Override umstellt
(wirkt sofort, kein Neustart).
- Presets in config/models.yaml (Label, optional note, set:{key:value} aus
RUNTIME_SETTABLE, default:true = Config-Standard). Loader app/model_presets.py
(nur erlaubte LLM-Keys: default_llm_provider, openrouter_llm_model,
local_llm_model).
- Endpunkte: GET /api/admin/models (Presets + aktuell aktives LLM + active_index),
POST /api/admin/models/select (Default -> Overrides löschen, sonst Override-Keys
setzen) — nutzt set_/delete_config_override + invalidate_cache, mit Audit-Log.
- Frontend: Karte zeigt Dropdown + „Aktiv: <provider> · <modell>"; Auswahl wirkt
sofort und lädt den Status neu. Bump app.js v=52 -> v=53.
- Startliste: Mistral Small 24B (Standard), DeepSeek V4 Flash/Pro, Ollama Gemma 3.
Tests: Laden/Default-aktiv/Select-setzt-Override/Default-löscht/404/Auth.
263 Offline-Tests grün. Live via Headless-Chrome verifiziert (Auswahl ->
Override gesetzt, Provider-Karte spiegelt das Modell).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:02:44 +02:00
// Dropdown „Zentrales KI-Modell" (erprobte Presets, Auswahl wirkt sofort).
const presets = ( models && models . presets ) || [ ] ;
const activeIdx = models ? models . active _index : null ;
const cur = ( models && models . current ) || { } ;
const curProv = cur . default _llm _provider || "—" ;
const curModel = curProv === "openrouter"
? ( cur . openrouter _llm _model || "—" ) : ( cur . local _llm _model || "—" ) ;
const modelOptions = presets . map ( ( p ) =>
` <option value=" ${ p . index } " ${ p . index === activeIdx ? " selected" : "" } > ${ escHtml ( p . label ) } </option> ` ) . join ( "" ) ;
const modelCard = presets . length ? `
< 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" > Zentrales KI - Modell < / h 3 >
< p class = "text-xs text-slate-400 mb-3" > Erprobte Modelle — Auswahl wirkt sofort ( kein Neustart ) . < / p >
< div class = "flex flex-wrap items-center gap-2" >
< select id = "model-preset" class = "text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5" >
$ { modelOptions }
< / s e l e c t >
< span id = "model-preset-msg" class = "text-xs text-slate-400" > < / s p a n >
< / d i v >
< p class = "text-xs text-slate-400 mt-2" > Aktiv : < span class = "font-mono text-slate-600 dark:text-slate-300" > $ { escHtml ( curProv ) } · $ { escHtml ( curModel ) } < / s p a n > < / p >
< / d i v > ` : " " ;
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 >
feat(admin): Dropdown „Zentrales KI-Modell" — erprobte Presets ruck-zuck umstellen
Neue Karte in Admin › System › Status: ein Dropdown kuratierter, erprobter
Modelle, das Provider + Modell in einem Klick als Laufzeit-Override umstellt
(wirkt sofort, kein Neustart).
- Presets in config/models.yaml (Label, optional note, set:{key:value} aus
RUNTIME_SETTABLE, default:true = Config-Standard). Loader app/model_presets.py
(nur erlaubte LLM-Keys: default_llm_provider, openrouter_llm_model,
local_llm_model).
- Endpunkte: GET /api/admin/models (Presets + aktuell aktives LLM + active_index),
POST /api/admin/models/select (Default -> Overrides löschen, sonst Override-Keys
setzen) — nutzt set_/delete_config_override + invalidate_cache, mit Audit-Log.
- Frontend: Karte zeigt Dropdown + „Aktiv: <provider> · <modell>"; Auswahl wirkt
sofort und lädt den Status neu. Bump app.js v=52 -> v=53.
- Startliste: Mistral Small 24B (Standard), DeepSeek V4 Flash/Pro, Ollama Gemma 3.
Tests: Laden/Default-aktiv/Select-setzt-Override/Default-löscht/404/Auth.
263 Offline-Tests grün. Live via Headless-Chrome verifiziert (Auswahl ->
Override gesetzt, Provider-Karte spiegelt das Modell).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:02:44 +02:00
$ { modelCard }
2026-06-19 01:04:20 +02:00
<!-- 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" >
2026-06-25 02:27:18 +02:00
$ { providerRow ( "STT" , route . stt _provider ) }
$ { providerRow ( "LLM" , route . llm _provider ) }
$ { providerRow ( "LLM-Modell" , llmModel ) }
$ { providerRow ( "TTS" , route . tts _provider ) }
$ { providerRow ( "Sprache" , route . language ) }
2026-06-19 01:04:20 +02:00
< / d l >
< / d i v >
<!-- 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 >
` ;
feat(admin): Dropdown „Zentrales KI-Modell" — erprobte Presets ruck-zuck umstellen
Neue Karte in Admin › System › Status: ein Dropdown kuratierter, erprobter
Modelle, das Provider + Modell in einem Klick als Laufzeit-Override umstellt
(wirkt sofort, kein Neustart).
- Presets in config/models.yaml (Label, optional note, set:{key:value} aus
RUNTIME_SETTABLE, default:true = Config-Standard). Loader app/model_presets.py
(nur erlaubte LLM-Keys: default_llm_provider, openrouter_llm_model,
local_llm_model).
- Endpunkte: GET /api/admin/models (Presets + aktuell aktives LLM + active_index),
POST /api/admin/models/select (Default -> Overrides löschen, sonst Override-Keys
setzen) — nutzt set_/delete_config_override + invalidate_cache, mit Audit-Log.
- Frontend: Karte zeigt Dropdown + „Aktiv: <provider> · <modell>"; Auswahl wirkt
sofort und lädt den Status neu. Bump app.js v=52 -> v=53.
- Startliste: Mistral Small 24B (Standard), DeepSeek V4 Flash/Pro, Ollama Gemma 3.
Tests: Laden/Default-aktiv/Select-setzt-Override/Default-löscht/404/Auth.
263 Offline-Tests grün. Live via Headless-Chrome verifiziert (Auswahl ->
Override gesetzt, Provider-Karte spiegelt das Modell).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:02:44 +02:00
wireModelPicker ( ) ;
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>' ;
}
}
feat(admin): Dropdown „Zentrales KI-Modell" — erprobte Presets ruck-zuck umstellen
Neue Karte in Admin › System › Status: ein Dropdown kuratierter, erprobter
Modelle, das Provider + Modell in einem Klick als Laufzeit-Override umstellt
(wirkt sofort, kein Neustart).
- Presets in config/models.yaml (Label, optional note, set:{key:value} aus
RUNTIME_SETTABLE, default:true = Config-Standard). Loader app/model_presets.py
(nur erlaubte LLM-Keys: default_llm_provider, openrouter_llm_model,
local_llm_model).
- Endpunkte: GET /api/admin/models (Presets + aktuell aktives LLM + active_index),
POST /api/admin/models/select (Default -> Overrides löschen, sonst Override-Keys
setzen) — nutzt set_/delete_config_override + invalidate_cache, mit Audit-Log.
- Frontend: Karte zeigt Dropdown + „Aktiv: <provider> · <modell>"; Auswahl wirkt
sofort und lädt den Status neu. Bump app.js v=52 -> v=53.
- Startliste: Mistral Small 24B (Standard), DeepSeek V4 Flash/Pro, Ollama Gemma 3.
Tests: Laden/Default-aktiv/Select-setzt-Override/Default-löscht/404/Auth.
263 Offline-Tests grün. Live via Headless-Chrome verifiziert (Auswahl ->
Override gesetzt, Provider-Karte spiegelt das Modell).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:02:44 +02:00
// Dropdown „Zentrales KI-Modell": Auswahl wendet ein Preset an (Laufzeit-Override).
function wireModelPicker ( ) {
const sel = $ ( "#model-preset" ) ;
const msg = $ ( "#model-preset-msg" ) ;
if ( ! sel ) return ;
sel . addEventListener ( "change" , async ( ) => {
msg . textContent = "stelle um …" ;
sel . disabled = true ;
const res = await adminFetch ( "/api/admin/models/select" , "POST" , { index : + sel . value } ) ;
sel . disabled = false ;
if ( res === null ) { msg . textContent = "Fehler beim Umstellen" ; return ; }
msg . textContent = "✓ " + ( res . selected || "umgestellt" ) ;
loadStatus ( ) ; // Karte + „Aktiv"-Anzeige aktualisieren
} ) ;
}
2026-06-25 04:52:09 +02:00
// ════════════════════════════════════════
// TAB: WARTUNG (Konfiguration)
// ════════════════════════════════════════
async function loadMaintenance ( ) {
const c = $ ( "#maintenance-content" ) ;
c . innerHTML = `
< 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" > Gateway neu starten < / h 3 >
< p class = "text-xs text-slate-400 mb-3" > Nötig nach Einstellungen , die einen Neustart erfordern ( Hinweis erscheint dort ) . < / p >
< div class = "flex flex-wrap items-center gap-2" >
< 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 >
< / d i v >
< 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 >
` ;
wireRestart ( ) ;
}
2026-06-19 01:04:20 +02:00
// ════════════════════════════════════════
// 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-30 09:51:29 +02:00
const totalWS = data . reduce ( ( s , u ) => s + ( u . total _web _searches || 0 ) , 0 ) ;
2026-06-30 11:08:45 +02:00
const totalCost = data . reduce ( ( s , u ) => s + ( u . month _cost _usd || 0 ) , 0 ) ;
const fmtUsd = ( n ) => "$" + ( Number ( n ) || 0 ) . toFixed ( 4 ) ;
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 >
2026-06-30 09:51:29 +02:00
< th class = "text-right px-4 py-2.5" > mit Websuche < / t h >
2026-06-30 11:08:45 +02:00
< th class = "text-right px-4 py-2.5" > Kosten ( Monat ) < / t h >
< th class = "text-right px-4 py-2.5" > Ø / Anfrage < / t h >
2026-06-19 01:04:20 +02:00
< 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 >
2026-06-30 09:51:29 +02:00
< td class = "px-4 py-2.5 text-right font-mono text-xs" > $ { u . total _web _searches ? ? 0 } < / t d >
2026-06-30 11:08:45 +02:00
< td class = "px-4 py-2.5 text-right font-mono text-xs" > $ { fmtUsd ( u . month _cost _usd ) } < / t d >
< td class = "px-4 py-2.5 text-right font-mono text-xs" > $ { u . month _requests ? fmtUsd ( ( u . month _cost _usd || 0 ) / u . month _requests ) : "—" } < / t d >
2026-06-19 01:04:20 +02:00
< 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 >
2026-06-30 09:51:29 +02:00
< td class = "px-4 py-2 text-right font-mono" > $ { totalWS } < / t d >
2026-06-30 11:08:45 +02:00
< td class = "px-4 py-2 text-right font-mono" > $ { fmtUsd ( totalCost ) } < / t d >
< td class = "px-4 py-2" > < / t d >
2026-06-19 01:04:20 +02:00
< 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" } ,
2026-06-28 15:13:29 +02:00
default _language : { ui : "combo" , opts : [ "de" , "en" , "fr" , "es" , "it" , "nl" , "pt" , "pl" , "ar" ] ,
labels : { de : "Deutsch" , en : "Englisch" , fr : "Französisch" , es : "Spanisch" , it : "Italienisch" , nl : "Niederländisch" , pt : "Portugiesisch" , pl : "Polnisch" , ar : "Arabisch" } } ,
2026-06-25 05:01:05 +02:00
openrouter _llm _model : { ui : "datalist" , test : "llm" } ,
openrouter _tts _model : { ui : "datalist" , test : "tts" , testProvider : "openrouter" } ,
2026-06-19 12:39:14 +02:00
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-28 15:13:29 +02:00
piper _voice : { ui : "combo" , opts : [ "de_DE-thorsten-high" , "de_DE-kerstin-low" , "en_US-lessac-high" , "en_US-amy-medium" , "en_US-ryan-high" , "en_GB-cori-high" , "fr_FR-siwis-medium" , "fr_FR-tom-medium" , "es_ES-sharvard-medium" , "it_IT-paola-medium" , "it_IT-riccardo-x_low" , "nl_NL-mls-medium" , "pt_BR-faber-medium" , "pl_PL-gosia-medium" , "pl_PL-darkman-medium" , "ar_JO-kareem-medium" , "ru_RU-irina-medium" , "ru_RU-ruslan-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 } ,
} ;
const FIELD _GROUPS = [
2026-06-25 04:54:56 +02:00
{ label : "Sprache & Stimme" , keys : [ "default_language" , "default_tts_provider" , "openrouter_tts_voice" , "piper_voice" ] } ,
{ label : "Antwortverhalten" , keys : [ "memory_extraction_enabled" , "audio_stream_default" , "daily_request_limit" ] } ,
{ label : "Erweitert" , advanced : true , keys : [
"default_stt_provider" , "default_llm_provider" , "openrouter_llm_model" , "openrouter_tts_model" ,
"tts_normalize_level" , "local_llm_system_prompt" , "local_llm_temperature" , "local_llm_top_p" ,
"local_llm_max_tokens" , "memory_extraction_every_n_turns" ] } ,
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" ;
2026-06-25 04:54:56 +02:00
const body = document . createElement ( "div" ) ;
2026-06-19 02:35:40 +02:00
items . forEach ( ( item , idx ) => {
const row = buildSettingRow ( item ) ;
if ( idx > 0 ) row . classList . add ( "border-t" , "border-slate-100" , "dark:border-slate-700/60" ) ;
2026-06-25 04:54:56 +02:00
body . appendChild ( row ) ;
2026-06-19 02:35:40 +02:00
} ) ;
2026-06-25 04:54:56 +02:00
const header = document . createElement ( group . advanced ? "button" : "div" ) ;
header . className = "w-full text-left px-4 py-2.5 border-b border-slate-100 dark:border-slate-700 bg-slate-50/80 dark:bg-slate-800/60 flex items-center gap-2" ;
if ( group . advanced ) {
body . classList . add ( "hidden" ) ;
header . innerHTML = ` <span class="adv-arrow text-slate-400">▶</span> ` +
` <h4 class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide flex-1"> ${ escHtml ( group . label ) } </h4> ` +
` <span class="text-xs text-slate-400">technische Optionen</span> ` ;
header . addEventListener ( "click" , ( ) => {
const open = ! body . classList . contains ( "hidden" ) ;
body . classList . toggle ( "hidden" , open ) ;
header . querySelector ( ".adv-arrow" ) . textContent = open ? "▶" : "▼" ;
} ) ;
} else {
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 ) ;
section . appendChild ( body ) ;
2026-06-19 02:35:40 +02:00
container . appendChild ( section ) ;
} ) ;
2026-06-25 05:01:05 +02:00
populateModelDatalists ( ) ;
}
// Füllt alle Modell-<datalist>s mit der Live-Liste von OpenRouter (einmal gecacht).
let _orModels = null ;
async function populateModelDatalists ( ) {
const lists = document . querySelectorAll ( "datalist[data-or-models]" ) ;
if ( ! lists . length ) return ;
if ( _orModels === null ) {
const res = await adminFetch ( "/api/admin/openrouter/models" ) ;
_orModels = ( res && res . models ) || [ ] ;
}
const opts = _orModels . map ( ( m ) => ` <option value=" ${ escHtml ( m ) } "></option> ` ) . join ( "" ) ;
lists . forEach ( ( dl ) => { dl . innerHTML = opts ; } ) ;
2026-06-19 02:35:40 +02:00
}
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 > ` ;
}
2026-06-25 05:01:05 +02:00
if ( meta . ui === "datalist" ) {
// Live-Modell-Liste (OpenRouter) per <datalist>: tippen/auswählen, immer gültige Namen.
return `
< input id = "${id}" list = "${id}-list" type = "text" value = "${escHtml(val)}" placeholder = "Modell eingeben oder wählen"
class = "${cls} flex-1" / >
< datalist id = "${id}-list" data - or - models > < / d a t a l i s t > ` ;
}
2026-06-19 02:35:40 +02:00
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 ) ;
2026-06-25 03:02:52 +02:00
// === NOTRUF-KNOPF =========================================================
// Bewusste Nutzeraktion mit Bestätigung. Eskalation (Benachrichtigung) folgt
// später; vorerst zeigt der Server einen Hinweis in der Nutzersprache.
const emergencyBtn = $ ( "#emergency-btn" ) ;
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
2026-06-25 17:24:36 +02:00
// Standort (One-Shot) für den Notruf erfassen — nur mit Freigabe (location_consent),
// mit Timeout (hängendes GPS blockiert den Alarm nie). Liefert {ok:true,…} oder
// {ok:false, error:"…"} mit erklärbarem Grund (für Diagnose in Meldung/Log).
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
function captureGeo ( timeoutMs = 8000 ) {
return new Promise ( ( resolve ) => {
2026-06-25 17:24:36 +02:00
if ( ! myPrefs . location _consent ) return resolve ( { ok : false , error : "Standort-Freigabe aus" } ) ;
if ( ! window . isSecureContext ) return resolve ( { ok : false , error : "kein HTTPS (insecure context)" } ) ;
if ( ! navigator . geolocation ) return resolve ( { ok : false , error : "Browser ohne Geolocation" } ) ;
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
let done = false ;
const finish = ( v ) => { if ( ! done ) { done = true ; resolve ( v ) ; } } ;
2026-06-25 17:24:36 +02:00
const timer = setTimeout ( ( ) => finish ( { ok : false , error : "Timeout" } ) , timeoutMs ) ;
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
navigator . geolocation . getCurrentPosition (
( pos ) => {
clearTimeout ( timer ) ;
finish ( {
2026-06-25 17:24:36 +02:00
ok : true ,
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
lat : pos . coords . latitude ,
lon : pos . coords . longitude ,
accuracy : pos . coords . accuracy ,
ts : new Date ( pos . timestamp ) . toISOString ( ) ,
} ) ;
} ,
2026-06-25 17:24:36 +02:00
( err ) => { clearTimeout ( timer ) ; finish ( { ok : false , error : "code " + err . code + ": " + err . message } ) ; } ,
feat: Notruf-Frontend — Geoposition + Admin-Notfallprofil (Phase 2)
- 🆘-Handler erfasst beim Auslösen die Geoposition (One-Shot, navigator.
geolocation, enableHighAccuracy) und hängt sie als geo:{lat,lon,accuracy,ts}
an /api/emergency. Timeout-Race (8 s), damit hängendes GPS den Alarm nie
blockiert; nur mit Freigabe (location_consent).
- loadMe: Prefs global gehalten; bei location_consent Standort-Erlaubnis vorab
angefragt (kein Browser-Dialog im Panikmoment).
- Admin-User-Karte: einklappbares „Notfall-Profil" zum Pflegen von Name,
Adresse, Telefonen, Geburtsdatum, med. Hinweisen, Kontakt-E-Mail/-Telefon
und der Standort-Freigabe; speichert via PUT /api/admin/users/{id}/prefs.
Nutzt das in Phase 1+3 gebaute Backend; keine Backend-Änderung. app.js per
node --check validiert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:48:51 +02:00
{ enableHighAccuracy : true , timeout : timeoutMs , maximumAge : 60000 }
) ;
} ) ;
}
2026-06-25 17:24:36 +02:00
// Geräteart/Plattform aus Browser-Daten (User-Agent Client Hints, Fallback UA-String).
function deviceInfo ( ) {
try {
const uad = navigator . userAgentData ;
if ( uad ) return { mobile : ! ! uad . mobile , platform : uad . platform || "" } ;
} catch ( e ) { /* ignore */ }
const ua = navigator . userAgent || "" ;
return { mobile : /Mobi|Android|iPhone|iPad|iPod/i . test ( ua ) , platform : ua . slice ( 0 , 120 ) } ;
}
2026-06-26 15:58:17 +02:00
// Sichtbarer Zustand am SOS-Button (Quadrat in Höhe der Sprachauswahl):
// idle (rot „SOS") | calling (rot, pulsierend) | done (grün ✓, pulsierend —
// während der Sperre) | error. Feedback über Farbe/Symbol, nicht über Wörter.
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
function setSosState ( state ) {
if ( ! emergencyBtn ) return ;
emergencyBtn . classList . remove (
2026-06-26 15:58:17 +02:00
"bg-red-600" , "hover:bg-red-700" , "bg-green-600" , "hover:bg-green-700" ,
"animate-pulse" , "text-sm" , "text-lg"
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
) ;
if ( state === "calling" ) {
2026-06-26 15:32:42 +02:00
emergencyBtn . textContent = "SOS" ; // pulsiert = „wird gerufen“
2026-06-25 03:02:52 +02:00
emergencyBtn . disabled = true ;
2026-06-26 15:32:42 +02:00
emergencyBtn . classList . add ( "bg-red-600" , "animate-pulse" , "text-sm" ) ;
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
} else if ( state === "done" ) {
2026-06-26 15:58:17 +02:00
emergencyBtn . textContent = "✓" ; // grün + Haken + pulsiert = Alarm raus
emergencyBtn . disabled = false ; // klickbar -> beruhigender Hinweis
emergencyBtn . classList . add ( "bg-green-600" , "hover:bg-green-700" , "animate-pulse" , "text-lg" ) ;
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
} else if ( state === "error" ) {
2026-06-26 15:32:42 +02:00
emergencyBtn . textContent = "!" ;
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
emergencyBtn . disabled = false ;
2026-06-26 15:32:42 +02:00
emergencyBtn . classList . add ( "bg-red-600" , "hover:bg-red-700" , "text-lg" ) ;
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
} else { // idle
emergencyBtn . textContent = "SOS" ;
emergencyBtn . disabled = false ;
2026-06-26 15:32:42 +02:00
emergencyBtn . classList . add ( "bg-red-600" , "hover:bg-red-700" , "text-sm" ) ;
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
}
}
2026-06-26 15:58:17 +02:00
// ----- Wiederauslöse-Sperre (Cooldown) -----
// Nach einem Alarm bleibt der Button für die (im Admin einstellbare) Dauer grün
// und pulsierend; erneutes Drücken zeigt nur einen beruhigenden Hinweis, statt
// einen zweiten Alarm auszulösen — Schutz vor Mehrfach-Alarmen.
let sosCooldownActive = false ;
let sosAlertedAtMs = 0 ;
let sosCooldownTimer = null ;
function enterCooldown ( remainingSec , minutesAgo ) {
sosCooldownActive = true ;
// Auslösezeitpunkt rekonstruieren, damit „vor X Minuten" stimmt (auch nach Reload).
sosAlertedAtMs = Date . now ( ) - Math . max ( 0 , minutesAgo || 0 ) * 60000 ;
setSosState ( "done" ) ;
if ( sosCooldownTimer ) clearTimeout ( sosCooldownTimer ) ;
if ( remainingSec > 0 ) sosCooldownTimer = setTimeout ( exitCooldown , remainingSec * 1000 ) ;
else exitCooldown ( ) ;
}
function exitCooldown ( ) {
sosCooldownActive = false ;
sosCooldownTimer = null ;
setSosState ( "idle" ) ;
}
function sosMinutesAgo ( ) {
return Math . max ( 0 , Math . floor ( ( Date . now ( ) - sosAlertedAtMs ) / 60000 ) ) ;
}
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
async function runEmergency ( ) {
const lang = ( langSel && langSel . value ) || "de" ;
setSosState ( "calling" ) ;
try {
const g = await captureGeo ( ) ;
const body = { language : lang , device : deviceInfo ( ) } ;
if ( g . ok ) body . geo = { lat : g . lat , lon : g . lon , accuracy : g . accuracy , ts : g . ts } ;
else body . geo _error = g . error ;
const res = await fetch ( "/api/emergency" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( body ) ,
} ) ;
const data = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
2026-06-27 16:50:03 +02:00
if ( data && data . notify _ready ) sosNotifyReady = ! ! data . notify _ready . any ;
2026-06-26 15:58:17 +02:00
if ( data && data . status === "cooldown" ) {
// Server hat den (Doppel-)Alarm unterdrückt -> nur beruhigen, kein neuer Alarm.
enterCooldown ( data . remaining _seconds || 0 , data . minutes _ago || 0 ) ;
openSosCooldown ( ) ;
return ;
}
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
const notice = ( data && data . notice ) ||
"ACHTUNG: Es ist noch keine Benachrichtigung eingebaut." ;
addMessage ( "emergency" , "🆘 " + notice ) ;
2026-06-27 16:50:03 +02:00
if ( data && data . status === "alerted" ) {
const cd = data . cooldown _seconds || 0 ;
if ( cd > 0 ) {
enterCooldown ( cd , 0 ) ; // grün/pulsierend für die Sperre
} else {
setSosState ( "done" ) ; // Sperre aus -> nur kurzes Erfolgs-Feedback
setTimeout ( ( ) => { if ( ! sosCooldownActive ) setSosState ( "idle" ) ; } , 6000 ) ;
}
2026-06-26 15:58:17 +02:00
} else {
2026-06-27 16:50:03 +02:00
// no_recipients / delivery_failed: es ging NICHTS raus -> KEIN grünes Erfolgssignal,
// sondern roter Fehlerzustand; der Hinweis nennt die Selbsthilfe (112).
setSosState ( "error" ) ;
setTimeout ( ( ) => { if ( ! sosCooldownActive ) setSosState ( "idle" ) ; } , 8000 ) ;
2026-06-26 15:58:17 +02:00
}
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
} catch ( e ) {
addMessage ( "emergency" , "🆘 Fehler beim Auslösen des Notrufs." ) ;
setSosState ( "error" ) ;
2026-06-26 15:58:17 +02:00
setTimeout ( ( ) => { if ( ! sosCooldownActive ) setSosState ( "idle" ) ; } , 4000 ) ;
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
}
2026-06-25 03:02:52 +02:00
}
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
2026-06-27 16:50:03 +02:00
// Ist (serverseitig) überhaupt ein Benachrichtigungskanal nutzbar? null = noch
// unbekannt; erst bei sicherem `false` zeigen wir die Vorab-Warnung im Dialog.
let sosNotifyReady = null ;
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
// Großer eigener Bestätigungsdialog statt native confirm() — seniorentauglich.
const sosConfirm = $ ( "#sos-confirm" ) ;
2026-06-27 16:50:03 +02:00
function openSosConfirm ( ) {
const warn = $ ( "#sos-confirm-warn" ) ;
if ( warn ) warn . classList . toggle ( "hidden" , sosNotifyReady !== false ) ;
if ( sosConfirm ) sosConfirm . classList . remove ( "hidden" ) ;
}
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
function closeSosConfirm ( ) { if ( sosConfirm ) sosConfirm . classList . add ( "hidden" ) ; }
2026-06-26 15:58:17 +02:00
// Beruhigender grüner Hinweis, wenn während der Sperre erneut gedrückt wird.
const sosCooldownDlg = $ ( "#sos-cooldown" ) ;
function openSosCooldown ( ) {
const m = sosMinutesAgo ( ) ;
const when = m < 1 ? "gerade eben" : ( "vor " + m + ( m === 1 ? " Minute" : " Minuten" ) ) ;
const txtEl = $ ( "#sos-cooldown-text" ) ;
if ( txtEl ) txtEl . textContent =
"Ich habe Deine Helfer " + when + " alarmiert. " +
"Wahrscheinlich ist Deine Hilfe schon unterwegs." ;
if ( sosCooldownDlg ) sosCooldownDlg . classList . remove ( "hidden" ) ;
}
function closeSosCooldown ( ) { if ( sosCooldownDlg ) sosCooldownDlg . classList . add ( "hidden" ) ; }
// Beim Laden den Sperr-Status vom Server holen, damit der grüne Zustand auch
// ein Seiten-Neuladen übersteht (verwirrte Senioren laden gern neu).
async function initSosCooldown ( ) {
try {
const res = await fetch ( "/api/emergency/status" , { headers : { Accept : "application/json" } } ) ;
if ( ! res . ok ) return ;
const s = await res . json ( ) ;
2026-06-27 16:50:03 +02:00
if ( s && s . notify _ready ) sosNotifyReady = ! ! s . notify _ready . any ;
2026-06-26 15:58:17 +02:00
if ( s && s . active ) enterCooldown ( s . remaining _seconds || 0 , s . minutes _ago || 0 ) ;
} catch ( e ) { /* ignore */ }
}
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
if ( emergencyBtn ) {
2026-06-26 15:58:17 +02:00
emergencyBtn . addEventListener ( "click" , ( ) => {
if ( sosCooldownActive ) openSosCooldown ( ) ; // schon alarmiert -> nur beruhigen
else openSosConfirm ( ) ; // sonst Notruf bestätigen
} ) ;
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
}
const sosYesBtn = $ ( "#sos-confirm-yes" ) ;
const sosNoBtn = $ ( "#sos-confirm-no" ) ;
if ( sosYesBtn ) sosYesBtn . addEventListener ( "click" , ( ) => { closeSosConfirm ( ) ; runEmergency ( ) ; } ) ;
if ( sosNoBtn ) sosNoBtn . addEventListener ( "click" , closeSosConfirm ) ;
2026-06-26 15:58:17 +02:00
const sosCooldownOkBtn = $ ( "#sos-cooldown-ok" ) ;
if ( sosCooldownOkBtn ) sosCooldownOkBtn . addEventListener ( "click" , closeSosCooldown ) ;
// Klick auf den Hintergrund oder Escape schließt die Dialoge (kein Alarm).
if ( sosConfirm ) sosConfirm . addEventListener ( "click" , ( e ) => { if ( e . target === sosConfirm ) closeSosConfirm ( ) ; } ) ;
if ( sosCooldownDlg ) sosCooldownDlg . addEventListener ( "click" , ( e ) => { if ( e . target === sosCooldownDlg ) closeSosCooldown ( ) ; } ) ;
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
document . addEventListener ( "keydown" , ( e ) => {
2026-06-26 15:58:17 +02:00
if ( e . key !== "Escape" ) return ;
if ( sosConfirm && ! sosConfirm . classList . contains ( "hidden" ) ) closeSosConfirm ( ) ;
if ( sosCooldownDlg && ! sosCooldownDlg . classList . contains ( "hidden" ) ) closeSosCooldown ( ) ;
feat(ui): SOS-Button prominent (rot gefüllt, großes weißes „SOS") + Bestätigung & Status
Der Notruf-Button war ein kleines 36px-Outline-Icon (🆘) — leicht zu übersehen.
Jetzt ein rot gefüllter Button (bg-red-600) mit großem, fettem weißem „SOS" in
der (immer sichtbaren) Kopfzeile — der Senior erkennt ihn sofort. Sprachwahl
und Menü bleiben unverändert an ihrem Platz.
- Bestätigung: statt native confirm() ein großer eigener Dialog (rote SOS-Kopf-
zeile, Warnzeile, große Knöpfe „JA, HILFE RUFEN" / „Abbrechen"). Ein Tipp auf
SOS, ein Tipp auf JA — schnell, aber gegen Fehlalarm geschützt.
- Feedback am Button selbst: setSosState idle|calling|done|error —
„Rufe …" (pulsierend) → „Gerufen ✓" (grün) → zurück zu „SOS". So sieht der
Senior unmittelbar am Knopf, dass der Alarm ausgelöst wurde.
Nur Frontend (app/web), live ohne Neustart (No-Cache-Static, Bump auf v=49).
Verifiziert via Headless-Chrome/CDP: Idle/Dialog/Calling/Done-Zustände.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:48:35 +02:00
} ) ;