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" ) ;
// Gewählter TTS-Provider (leer = Default/piper) als Request-Override.
function overrides ( ) {
const t = ttsSel && ttsSel . value ;
return t ? { tts _provider : t } : { } ;
}
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
let busy = false ;
2026-06-18 18:38:33 +02:00
let activeWs = null ; // aktive WS-Verbindung (fuer Barge-in von aussen erreichbar)
let activeSources = [ ] ; // laufende AudioBufferSourceNodes (stoppbar per Barge-in)
2026-06-18 08:50:30 +02:00
// Session-ID pro Nutzer (sonst "gehoert einem anderen Nutzer"-Konflikt). Wird aus
// /api/me abgeleitet; bis dahin null -> ensureSession() wartet auf loadMe().
let sessionId = null ;
let mePromise = null ;
feat(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 ( ) ;
sessionId = "web-" + me . user _id ;
$ ( "#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
}
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(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 addMessage ( role , text ) {
const div = document . createElement ( "div" ) ;
2026-06-18 10:20:24 +02:00
div . className = BUBBLE [ role ] || BUBBLE . assistant ;
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
div . textContent = text ;
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-18 18:38:33 +02:00
// ---------- Barge-in-Hilfsfunktionen ----------
function stopAudio ( ) {
activeSources . forEach ( ( s ) => { try { s . stop ( ) ; } catch ( e ) { } } ) ;
activeSources = [ ] ;
}
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 ;
function playPcm ( chunks , sampleRate ) {
if ( ! chunks . length ) return ;
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 ) ( ) ;
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 ) ;
src . onended = ( ) => { activeSources = activeSources . filter ( ( s ) => s !== src ) ; } ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
src . start ( ) ;
}
// ---------- WS-Turn (Text und Sprache teilen die Event-Logik) ----------
function wsUrl ( path ) {
const proto = location . protocol === "https:" ? "wss" : "ws" ;
2026-06-18 08:50:30 +02:00
return ` ${ proto } :// ${ location . host } ${ path } ?session_id= ${ encodeURIComponent ( sessionId ) } ` ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
}
// onopen: (ws) => sendet die Eingabe. Liefert ein Promise, das beim done-Event endet.
function runTurn ( path , onopen ) {
return new Promise ( ( resolve ) => {
const ws = new WebSocket ( wsUrl ( path ) ) ;
2026-06-18 18:38:33 +02:00
activeWs = ws ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
ws . binaryType = "arraybuffer" ;
const pcm = [ ] ;
let answerEl = null ;
let sampleRate = 24000 ;
ws . onopen = ( ) => onopen ( ws ) ;
ws . onerror = ( ) => { statusEl . textContent = "Verbindungsfehler" ; resolve ( ) ; } ;
2026-06-18 18:38:33 +02:00
ws . onclose = ( ) => { activeWs = null ; resolve ( ) ; } ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
ws . onmessage = ( event ) => {
if ( typeof event . data !== "string" ) { pcm . push ( event . data ) ; return ; }
let msg ;
try { msg = JSON . parse ( event . data ) ; } catch { return ; }
switch ( msg . type ) {
case "transcript" :
if ( msg . text ) addMessage ( "user" , msg . text ) ;
break ;
case "token" :
if ( ! answerEl ) answerEl = addMessage ( "assistant" , "" ) ;
answerEl . 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" :
if ( ! answerEl ) answerEl = addMessage ( "assistant" , "" ) ;
if ( msg . text ) answerEl . 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 "emergency" :
addMessage ( "emergency" , "⚠ Notfall erkannt (" + ( msg . category || "?" ) + ")" ) ;
break ;
case "done" :
sampleRate = msg . sample _rate || 24000 ;
playPcm ( pcm , sampleRate ) ;
statusEl . textContent = "" ;
2026-06-18 07:39:40 +02:00
scrollToBottom ( ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
ws . close ( ) ;
break ;
case "error" :
addMessage ( "system" , "Fehler: " + ( msg . detail || msg . status || "unbekannt" ) ) ;
ws . close ( ) ;
break ;
2026-06-18 19:03:03 +02:00
case "interrupted" :
statusEl . textContent = "" ;
ws . close ( ) ; // → ws.onclose → resolve() → busy=false → setMicIdle()
break ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
}
} ;
} ) ;
}
// ---------- Text senden ----------
async function sendText ( text ) {
if ( busy || ! text . trim ( ) ) return ;
busy = true ;
2026-06-18 18:38:33 +02:00
setMicBusy ( ) ;
2026-06-18 08:50:30 +02:00
await ensureSession ( ) ;
feat(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
addMessage ( "user" , text ) ;
statusEl . textContent = "denkt …" ;
await runTurn ( "/ws/chat" , ( ws ) => {
2026-06-18 09:49:25 +02:00
ws . send ( JSON . stringify ( { text , stream : true , audio _stream : true , ... 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 ( ) ;
const text = promptEl . value ;
promptEl . value = "" ;
sendText ( text ) ;
} ) ;
// ---------- Mikrofon (Push-to-Talk) ----------
let mediaRecorder = null ;
let recChunks = [ ] ;
async function startRecording ( ) {
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 ( ) ;
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 = "Aufnahme … (zum Stoppen erneut tippen)" ;
}
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 …" ;
await runTurn ( "/ws/voice" , ( ws ) => {
2026-06-18 10:39:02 +02:00
ws . send ( JSON . stringify ( { type : "start" , format : fmt , stream : true , audio _stream : true , ... 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" , ( ) => {
2026-06-18 18:38:33 +02:00
if ( busy ) {
// Barge-in: laufende KI-Antwort sofort unterbrechen
if ( activeWs ) activeWs . send ( JSON . stringify ( { type : "interrupt" } ) ) ;
stopAudio ( ) ;
statusEl . textContent = "Unterbrochen" ;
// busy + Button-Reset erfolgen sobald die WS schliesst (-> runTurn resolve -> sendVoice/sendText)
} else if ( mediaRecorder && mediaRecorder . state === "recording" ) {
stopRecording ( ) ;
} else {
startRecording ( ) ;
}
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
} ) ;
2026-06-19 01:04:20 +02:00
$ ( "#admin-toggle" ) . addEventListener ( "click" , openAdminPanel ) ;
2026-06-18 10:20:24 +02:00
// Tag/Nacht: Umschalter (manuell) + automatisch dem System folgen, solange nichts gewählt.
const themeBtn = $ ( "#theme-toggle" ) ;
const setThemeIcon = ( ) =>
( themeBtn . textContent = document . documentElement . classList . contains ( "dark" ) ? "☀️" : "🌙" ) ;
themeBtn . addEventListener ( "click" , ( ) => {
const dark = document . documentElement . classList . toggle ( "dark" ) ;
localStorage . setItem ( "theme" , dark ? "dark" : "light" ) ;
setThemeIcon ( ) ;
} ) ;
matchMedia ( "(prefers-color-scheme: dark)" ) . addEventListener ( "change" , ( e ) => {
if ( ! localStorage . getItem ( "theme" ) ) {
document . documentElement . classList . toggle ( "dark" , e . matches ) ;
setThemeIcon ( ) ;
}
} ) ;
setThemeIcon ( ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
2026-06-18 07:39:40 +02:00
// Wenn die Bildschirmtastatur die sichtbare Hoehe aendert (iOS), unten bleiben.
if ( window . visualViewport ) {
window . visualViewport . addEventListener ( "resize" , scrollToBottom ) ;
}
window . addEventListener ( "resize" , scrollToBottom ) ;
promptEl . addEventListener ( "focus" , ( ) => setTimeout ( scrollToBottom , 300 ) ) ;
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
loadMe ( ) ;
2026-06-19 01:04:20 +02:00
// ═══════════════════════════════════════════════════════════════
// ADMIN PANEL
// ═══════════════════════════════════════════════════════════════
// ---------- Hilfsfunktionen ----------
function escHtml ( str ) {
2026-06-19 01:33:49 +02:00
if ( str === null || str === undefined || str === "" ) return "" ;
return String ( str ) . replace ( /&/g , "&" ) . replace ( /</g , "<" ) . replace ( />/g , ">" ) . replace ( /"/g , """ ) ;
2026-06-19 01:04:20 +02:00
}
function fmtDate ( iso ) {
if ( ! iso ) return "—" ;
try {
return new Date ( iso ) . toLocaleString ( "de-DE" , {
day : "2-digit" , month : "2-digit" , year : "2-digit" ,
hour : "2-digit" , minute : "2-digit" ,
} ) ;
} catch { return iso ; }
}
async function adminFetch ( url , method = "GET" , body = null ) {
try {
const opts = { method , headers : { "Content-Type" : "application/json" } } ;
if ( body ) opts . body = JSON . stringify ( body ) ;
const res = await fetch ( url , opts ) ;
if ( method === "DELETE" && res . status === 204 ) return { } ;
const data = await res . json ( ) . catch ( ( ) => null ) ;
if ( ! res . ok ) { console . error ( "Admin API" , res . status , data ) ; return null ; }
return data ;
} catch ( e ) { console . error ( "adminFetch" , e ) ; return null ; }
}
// ---------- Panel öffnen / schließen ----------
function openAdminPanel ( ) {
$ ( "#admin-panel" ) . classList . remove ( "hidden" ) ;
switchAdminTab ( "users" ) ;
}
$ ( "#admin-close" ) . addEventListener ( "click" , ( ) => $ ( "#admin-panel" ) . classList . add ( "hidden" ) ) ;
// ---------- Tab-Umschalter ----------
function switchAdminTab ( name ) {
document . querySelectorAll ( '[id^="admin-tab-"]' ) . forEach ( ( el ) => el . classList . add ( "hidden" ) ) ;
$ ( "#admin-tab-" + name ) . classList . remove ( "hidden" ) ;
document . querySelectorAll ( ".admin-tab" ) . forEach ( ( btn ) => {
const active = btn . dataset . tab === name ;
btn . classList . toggle ( "border-blue-600" , active ) ;
btn . classList . toggle ( "text-blue-600" , active ) ;
btn . classList . toggle ( "dark:border-blue-400" , active ) ;
btn . classList . toggle ( "dark:text-blue-400" , active ) ;
btn . classList . toggle ( "border-transparent" , ! active ) ;
btn . classList . toggle ( "text-slate-500" , ! active ) ;
} ) ;
if ( name === "users" ) loadAdminUsers ( ) ;
if ( name === "chat" ) loadChatUsers ( ) ;
if ( name === "emergency" ) loadEmergencyEvents ( ) ;
if ( name === "status" ) loadStatus ( ) ;
if ( name === "metrics" ) loadMetrics ( ) ;
2026-06-19 01:15:28 +02:00
if ( name === "words" ) loadWoerterbuch ( ) ;
if ( name === "log" ) { /* User muss manuell verbinden */ }
2026-06-19 01:39:36 +02:00
if ( name === "settings" ) loadSettings ( ) ;
2026-06-19 01:04:20 +02:00
}
document . querySelectorAll ( ".admin-tab" ) . forEach ( ( btn ) =>
btn . addEventListener ( "click" , ( ) => switchAdminTab ( btn . dataset . tab ) )
) ;
$ ( "#load-emergency" ) . addEventListener ( "click" , loadEmergencyEvents ) ;
$ ( "#load-status" ) . addEventListener ( "click" , loadStatus ) ;
$ ( "#load-metrics" ) . addEventListener ( "click" , loadMetrics ) ;
// ════════════════════════════════════════
// TAB: NUTZER
// ════════════════════════════════════════
async function loadAdminUsers ( ) {
const container = $ ( "#admin-user-cards" ) ;
container . innerHTML = '<p class="text-sm text-slate-400 text-center py-4">lade …</p>' ;
const users = await adminFetch ( "/api/admin/users" ) ;
if ( ! users ) { container . innerHTML = '<p class="text-sm text-red-500 text-center py-4">Fehler beim Laden.</p>' ; return ; }
if ( ! users . length ) { container . innerHTML = '<p class="text-sm text-slate-400 text-center py-4">Noch keine Nutzer.</p>' ; return ; }
container . innerHTML = "" ;
users . forEach ( ( u ) => container . appendChild ( buildUserCard ( u ) ) ) ;
}
$ ( "#create-user-form" ) . addEventListener ( "submit" , async ( e ) => {
e . preventDefault ( ) ;
const nameEl = $ ( "#new-user-name" ) ;
const name = nameEl . value . trim ( ) ;
if ( ! name ) return ;
const data = await adminFetch ( "/api/admin/users" , "POST" , { display _name : name } ) ;
if ( data ? . token ) {
$ ( "#new-token-value" ) . textContent = data . token ;
$ ( "#new-token-box" ) . classList . remove ( "hidden" ) ;
nameEl . value = "" ;
loadAdminUsers ( ) ;
}
} ) ;
function buildUserCard ( u ) {
const card = document . createElement ( "div" ) ;
card . className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" ;
const badgeSSO = u . external _id
? ` <span class="rounded-full bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 text-xs px-2 py-0.5">SSO</span> ` : "" ;
card . innerHTML = `
< div class = "flex items-start gap-3 flex-wrap" >
< div class = "flex-1 min-w-0" >
< div class = "flex items-center gap-2 flex-wrap mb-0.5" >
< span class = "font-semibold text-slate-900 dark:text-slate-100 user-dn" > $ { escHtml ( u . display _name ) } < / s p a n >
$ { badgeSSO }
< / d i v >
< p class = "text-xs text-slate-400 font-mono" > $ { u . user _id . slice ( 0 , 8 ) } … $ { u . external _id ? " · " + escHtml ( u . external _id ) : "" } < / p >
< p class = "text-xs text-slate-400" > seit $ { fmtDate ( u . created _at ) } < / p >
< / d i v >
< div class = "flex flex-wrap gap-1.5" >
< button class = "btn-rename text-xs rounded-lg border border-slate-300 dark:border-slate-600 px-2.5 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors" > Umbenennen < / b u t t o n >
< button class = "btn-token text-xs rounded-lg border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 px-2.5 py-1.5 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors" > Token - Reset < / b u t t o n >
< button class = "btn-delete text-xs rounded-lg border border-red-300 dark:border-red-800 text-red-600 dark:text-red-400 px-2.5 py-1.5 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors" > Löschen < / b u t t o n >
< / d i v >
< / d i v >
<!-- Token - Anzeige nach Reset -- >
< div class = "token-box hidden mt-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 p-3" >
< p class = "text-xs text-amber-700 dark:text-amber-300 font-medium mb-1" > Neuer Token ( einmalig — jetzt kopieren ! ) : < / p >
< code class = "token-val text-xs font-mono break-all text-amber-900 dark:text-amber-100 select-all" > < / c o d e >
< / d i v >
<!-- Umbenenn - Formular -- >
< div class = "rename-box hidden mt-3" >
< form class = "flex gap-2" >
< input type = "text" class = "rename-inp flex-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
value = "${escHtml(u.display_name)}" / >
< button type = "submit" class = "rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm px-3 py-1.5 transition-colors" > Speichern < / b u t t o n >
< button type = "button" class = "rename-cancel rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors" > Abbrechen < / b u t t o n >
< / f o r m >
< / d i v >
<!-- Erinnerungen -- >
< div class = "mt-3 pt-3 border-t border-slate-100 dark:border-slate-700" >
< button class = "btn-mem text-xs text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 flex items-center gap-1 transition-colors" >
< span class = "mem-arrow" > ▶ < / s p a n > E r i n n e r u n g e n
< / b u t t o n >
< div class = "mem-section hidden mt-2 space-y-1.5" >
< div class = "mem-list" > < / d i v >
< form class = "mem-add-form flex gap-2 mt-2" >
< input type = "text" placeholder = "Neue Erinnerung …"
class = "flex-1 rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-blue-500" / >
< button type = "submit" class = "rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-1.5 transition-colors" > Hinzufügen < / b u t t o n >
< / f o r m >
< / d i v >
< / d i v >
` ;
const uid = u . user _id ;
// Umbenennen
card . querySelector ( ".btn-rename" ) . addEventListener ( "click" , ( ) =>
card . querySelector ( ".rename-box" ) . classList . toggle ( "hidden" )
) ;
card . querySelector ( ".rename-cancel" ) . addEventListener ( "click" , ( ) =>
card . querySelector ( ".rename-box" ) . classList . add ( "hidden" )
) ;
card . querySelector ( ".rename-box form" ) . addEventListener ( "submit" , async ( e ) => {
e . preventDefault ( ) ;
const name = card . querySelector ( ".rename-inp" ) . value . trim ( ) ;
if ( ! name ) return ;
const res = await adminFetch ( ` /api/admin/users/ ${ uid } ` , "PUT" , { display _name : name } ) ;
if ( res ) {
card . querySelector ( ".user-dn" ) . textContent = name ;
card . querySelector ( ".rename-box" ) . classList . add ( "hidden" ) ;
}
} ) ;
// Token-Reset
card . querySelector ( ".btn-token" ) . addEventListener ( "click" , async ( ) => {
if ( ! confirm ( ` Token für „ ${ u . display _name } " zurücksetzen? \n Der alte Token wird sofort ungültig. ` ) ) return ;
const data = await adminFetch ( ` /api/admin/users/ ${ uid } /token ` , "POST" ) ;
if ( data ? . token ) {
card . querySelector ( ".token-val" ) . textContent = data . token ;
card . querySelector ( ".token-box" ) . classList . remove ( "hidden" ) ;
}
} ) ;
// Löschen
card . querySelector ( ".btn-delete" ) . addEventListener ( "click" , async ( ) => {
if ( ! confirm ( ` Nutzer „ ${ u . display _name } " und alle Daten unwiderruflich löschen? ` ) ) return ;
const res = await adminFetch ( ` /api/admin/users/ ${ uid } ` , "DELETE" ) ;
if ( res !== null ) card . remove ( ) ;
} ) ;
// Erinnerungen auf-/zuklappen
const memBtn = card . querySelector ( ".btn-mem" ) ;
const memSection = card . querySelector ( ".mem-section" ) ;
const memArrow = card . querySelector ( ".mem-arrow" ) ;
memBtn . addEventListener ( "click" , async ( ) => {
const open = ! memSection . classList . contains ( "hidden" ) ;
memSection . classList . toggle ( "hidden" , open ) ;
memArrow . textContent = open ? "▶" : "▼" ;
if ( ! open ) await renderMemories ( uid , card . querySelector ( ".mem-list" ) ) ;
} ) ;
// Erinnerung hinzufügen
card . querySelector ( ".mem-add-form" ) . addEventListener ( "submit" , async ( e ) => {
e . preventDefault ( ) ;
const inp = e . target . querySelector ( "input" ) ;
const content = inp . value . trim ( ) ;
if ( ! content ) return ;
const res = await adminFetch ( ` /api/admin/users/ ${ uid } /memories ` , "POST" , { content } ) ;
if ( res ) { inp . value = "" ; await renderMemories ( uid , card . querySelector ( ".mem-list" ) ) ; }
} ) ;
return card ;
}
async function renderMemories ( uid , container ) {
container . innerHTML = '<p class="text-xs text-slate-400">lade …</p>' ;
const mems = await adminFetch ( ` /api/admin/users/ ${ uid } /memories ` ) ;
if ( ! mems ? . length ) {
container . innerHTML = '<p class="text-xs text-slate-400 italic">Keine Erinnerungen vorhanden.</p>' ;
return ;
}
container . innerHTML = "" ;
mems . forEach ( ( m ) => {
const row = document . createElement ( "div" ) ;
row . className = "flex items-start gap-2 rounded-lg bg-slate-50 dark:bg-slate-700/50 px-3 py-2 text-xs" ;
row . innerHTML = `
< span class = "flex-1 text-slate-700 dark:text-slate-300 leading-relaxed" > $ { escHtml ( m . content ) } < / s p a n >
< button class = "shrink-0 text-slate-400 hover:text-red-500 dark:hover:text-red-400 transition-colors font-bold" title = "Löschen" > ✕ < / b u t t o n >
` ;
row . querySelector ( "button" ) . addEventListener ( "click" , async ( ) => {
await adminFetch ( ` /api/admin/users/ ${ uid } /memories/ ${ m . id } ` , "DELETE" ) ;
row . remove ( ) ;
} ) ;
container . appendChild ( row ) ;
} ) ;
}
// ════════════════════════════════════════
// TAB: GESPRÄCHE
// ════════════════════════════════════════
let _chatUser = null ;
async function loadChatUsers ( ) {
const list = $ ( "#chat-user-list" ) ;
list . innerHTML = '<li class="px-3 py-3 text-sm text-slate-400">lade …</li>' ;
const users = await adminFetch ( "/api/admin/users" ) ;
if ( ! users ) return ;
list . innerHTML = "" ;
users . forEach ( ( u ) => {
const li = document . createElement ( "li" ) ;
li . className = "px-3 py-2.5 text-sm cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex flex-col" ;
li . innerHTML = ` <span class="font-medium truncate"> ${ escHtml ( u . display _name ) } </span>
< span class = "text-xs text-slate-400 font-mono truncate" > $ { u . user _id . slice ( 0 , 8 ) } … < / s p a n > ` ;
li . addEventListener ( "click" , ( ) => {
document . querySelectorAll ( "#chat-user-list li" ) . forEach ( ( el ) =>
el . classList . remove ( "bg-blue-50" , "dark:bg-blue-900/20" , "text-blue-700" , "dark:text-blue-300" )
) ;
li . classList . add ( "bg-blue-50" , "dark:bg-blue-900/20" ) ;
_chatUser = u ;
loadChatSessions ( u ) ;
} ) ;
list . appendChild ( li ) ;
} ) ;
}
async function loadChatSessions ( u ) {
const panel = $ ( "#chat-right-panel" ) ;
panel . innerHTML = ` <p class="p-4 text-sm text-slate-400">lade Sessions für ${ escHtml ( u . display _name ) } …</p> ` ;
const sessions = await adminFetch ( ` /api/admin/users/ ${ u . user _id } /sessions ` ) ;
if ( ! sessions ? . length ) {
panel . innerHTML = '<p class="p-4 text-sm text-slate-400">Keine Sessions gefunden.</p>' ;
return ;
}
panel . innerHTML = `
< div class = "px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-slate-50/80 dark:bg-slate-800/50" >
< p class = "text-xs font-semibold text-slate-500 uppercase tracking-wide" > Sessions · $ { escHtml ( u . display _name ) } < / p >
< / d i v >
< div id = "session-list" class = "divide-y divide-slate-100 dark:divide-slate-800/60" > < / d i v >
` ;
const list = panel . querySelector ( "#session-list" ) ;
sessions . forEach ( ( s ) => {
const row = document . createElement ( "div" ) ;
row . className = "px-4 py-3 cursor-pointer hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors" ;
row . innerHTML = `
< p class = "text-sm font-mono text-slate-700 dark:text-slate-300 truncate" > $ { escHtml ( s . id ) } < / p >
< p class = "text-xs text-slate-400 mt-0.5" > $ { fmtDate ( s . updated _at ) } · $ { s . msg _count } Nachrichten < / p >
` ;
row . addEventListener ( "click" , ( ) => loadTranscript ( s . id ) ) ;
list . appendChild ( row ) ;
} ) ;
}
async function loadTranscript ( sessionId ) {
const panel = $ ( "#chat-right-panel" ) ;
panel . innerHTML = ` <p class="p-4 text-sm text-slate-400">lade Transkript …</p> ` ;
const msgs = await adminFetch ( ` /api/admin/sessions/ ${ sessionId } /messages ` ) ;
if ( ! msgs ? . length ) {
panel . innerHTML = ` <div class="p-4">
< button class = "back-btn text-xs text-blue-600 hover:underline mb-3 block" > ← zurück zu Sessions < / b u t t o n >
< p class = "text-sm text-slate-400" > Keine Nachrichten . < / p > < / d i v > ` ;
panel . querySelector ( ".back-btn" ) . addEventListener ( "click" , ( ) => _chatUser && loadChatSessions ( _chatUser ) ) ;
return ;
}
const bubbles = msgs . map ( ( m ) => {
const isUser = m . role === "user" ;
return ` <div class="flex ${ isUser ? "justify-end" : "justify-start" } ">
< div class = " max - w - [ 80 % ] rounded - 2 xl px - 4 py - 2 text - sm whitespace - pre - wrap leading - relaxed
$ { isUser
? "rounded-br-sm bg-blue-600 text-white"
: "rounded-bl-sm border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-800 dark:text-slate-100" } " >
$ { escHtml ( m . content ) } < / d i v > < / d i v > ` ;
} ) . join ( "" ) ;
panel . innerHTML = `
< div class = "sticky top-0 px-4 py-2.5 border-b border-slate-200 dark:border-slate-700 bg-white/90 dark:bg-slate-800/90 backdrop-blur flex items-center gap-3" >
< button class = "back-btn text-xs text-blue-600 hover:underline" > ← Sessions < / b u t t o n >
< span class = "text-xs text-slate-400 font-mono truncate" > $ { escHtml ( sessionId ) } < / s p a n >
< / d i v >
< div class = "p-4 space-y-3" > $ { bubbles } < / d i v >
` ;
panel . querySelector ( ".back-btn" ) . addEventListener ( "click" , ( ) => _chatUser && loadChatSessions ( _chatUser ) ) ;
}
// ════════════════════════════════════════
// TAB: NOTFÄLLE
// ════════════════════════════════════════
async function loadEmergencyEvents ( ) {
const container = $ ( "#emergency-content" ) ;
container . innerHTML = '<p class="text-sm text-slate-400">lade …</p>' ;
const events = await adminFetch ( "/api/admin/emergency-events" ) ;
if ( ! events ) { container . innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>' ; return ; }
if ( ! events . length ) { container . innerHTML = '<p class="text-sm text-slate-400 py-4 text-center">Keine Notfall-Ereignisse protokolliert.</p>' ; return ; }
container . innerHTML = `
< div class = "overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700" >
< table class = "w-full text-sm" >
< thead class = "bg-slate-50 dark:bg-slate-800/60" >
< tr class = "text-xs text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700" >
< th class = "text-left px-4 py-2.5" > Zeitpunkt < / t h >
< th class = "text-left px-4 py-2.5" > Nutzer < / t h >
< th class = "text-left px-4 py-2.5" > Kategorie < / t h >
< th class = "text-left px-4 py-2.5" > Textausschnitt < / t h >
< / t r >
< / t h e a d >
< tbody class = "divide-y divide-slate-100 dark:divide-slate-800 bg-white dark:bg-slate-800" >
$ { events . map ( ( ev ) => `
< tr class = "hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors" >
< td class = "px-4 py-2.5 text-xs text-slate-400 whitespace-nowrap" > $ { fmtDate ( ev . created _at ) } < / t d >
< td class = "px-4 py-2.5 text-xs font-medium" > $ { escHtml ( ev . display _name ) } < / t d >
< td class = "px-4 py-2.5" >
< span class = "rounded-full bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 text-xs px-2.5 py-0.5 font-medium" > $ { escHtml ( ev . category ) } < / s p a n >
< / t d >
< td class = "px-4 py-2.5 text-xs text-slate-600 dark:text-slate-300 max-w-xs truncate" > $ { escHtml ( ev . snippet ) } < / t d >
< / t r > ` ) . j o i n ( " " ) }
< / t b o d y >
< / t a b l e >
< / d i v > ` ;
}
// ════════════════════════════════════════
// TAB: STATUS
// ════════════════════════════════════════
async function loadStatus ( ) {
const container = $ ( "#status-content" ) ;
container . innerHTML = '<p class="text-sm text-slate-400">lade …</p>' ;
try {
const [ cfg , met ] = await Promise . all ( [
fetch ( "/api/config" ) . then ( ( r ) => r . json ( ) ) ,
fetch ( "/api/metrics" ) . then ( ( r ) => r . json ( ) ) ,
] ) ;
const route = cfg . default _route || { } ;
const counters = met . counters || { } ;
const turnsTotal = counters [ "turns_total" ] || 0 ;
const httpTotal = Object . entries ( counters )
. filter ( ( [ k ] ) => k . startsWith ( "http_requests_total" ) )
. reduce ( ( s , [ , v ] ) => s + v , 0 ) ;
const providerRow = ( label , val ) =>
` <dt class="text-slate-500 dark:text-slate-400"> ${ label } </dt>
< dd class = "font-mono text-xs text-slate-700 dark:text-slate-300" > $ { escHtml ( val || "—" ) } < / d d > ` ;
container . innerHTML = `
<!-- Gateway - Status -- >
< div class = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" >
< div class = "flex items-center gap-2 mb-3" >
< span class = "h-2.5 w-2.5 rounded-full bg-emerald-500 shadow shadow-emerald-300/60" > < / s p a n >
< h3 class = "font-semibold text-sm" > Gateway < / h 3 >
< span class = "text-xs text-emerald-600 dark:text-emerald-400 font-medium" > läuft < / s p a n >
< / d i v >
< dl class = "grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm" >
$ { providerRow ( "Profil" , cfg . profile ) }
$ { providerRow ( "Umgebung" , cfg . app _env ) }
< / d l >
< / d i v >
<!-- Provider -- >
< div class = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" >
< h3 class = "font-semibold text-sm mb-3" > Provider ( Standard - Route ) < / h 3 >
< dl class = "grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm" >
$ { providerRow ( "STT" , route . stt _provider ) }
$ { providerRow ( "LLM" , route . llm _provider ) }
$ { providerRow ( "TTS" , route . tts _provider ) }
$ { providerRow ( "Sprache" , route . language ) }
< / d l >
< / d i v >
<!-- Laufzeit - Metriken -- >
< div class = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" >
< h3 class = "font-semibold text-sm mb-3" > Laufzeit - Metriken < / h 3 >
< dl class = "grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm" >
$ { providerRow ( "Turns gesamt" , turnsTotal ) }
$ { providerRow ( "HTTP-Anfragen" , httpTotal ) }
< / d l >
< / d i v >
<!-- Verfügbare Provider -- >
< div class = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" >
< h3 class = "font-semibold text-sm mb-3" > Verfügbare Provider < / h 3 >
< dl class = "grid grid-cols-2 gap-x-8 gap-y-1.5 text-sm" >
$ { providerRow ( "STT" , ( cfg . available ? . stt _providers || [ ] ) . join ( ", " ) ) }
$ { providerRow ( "LLM" , ( cfg . available ? . llm _providers || [ ] ) . join ( ", " ) ) }
$ { providerRow ( "TTS" , ( cfg . available ? . tts _providers || [ ] ) . join ( ", " ) ) }
< / d l >
< / d i v >
2026-06-19 01:15:28 +02:00
<!-- Datenbank - Export -- >
< div class = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" >
< h3 class = "font-semibold text-sm mb-1" > Datenbank - Backup < / h 3 >
< p class = "text-xs text-slate-400 mb-3" > SQLite - Datenbank als Datei herunterladen ( Backup / Migration ) . < / p >
< a href = "/api/admin/db-export" download = "voice-assistant.db"
class = "inline-flex items-center gap-2 rounded-lg bg-slate-700 hover:bg-slate-600 text-white text-xs px-4 py-2 font-medium transition-colors" >
⬇ voice - assistant . db herunterladen
< / a >
< / d i v >
2026-06-19 01:04:20 +02:00
` ;
} catch ( e ) {
2026-06-19 01:33:49 +02:00
console . error ( "loadStatus error:" , e ) ;
2026-06-19 01:04:20 +02:00
container . innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>' ;
}
}
// ════════════════════════════════════════
// TAB: METRIKEN
// ════════════════════════════════════════
async function loadMetrics ( ) {
const container = $ ( "#metrics-content" ) ;
container . innerHTML = '<p class="text-sm text-slate-400">lade …</p>' ;
const data = await adminFetch ( "/api/admin/usage" ) ;
if ( ! data ) { container . innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>' ; return ; }
if ( ! data . length ) { container . innerHTML = '<p class="text-sm text-slate-400 py-4 text-center">Noch keine Nutzungsdaten vorhanden.</p>' ; return ; }
const totalReq = data . reduce ( ( s , u ) => s + ( u . total _requests || 0 ) , 0 ) ;
2026-06-19 01:15:28 +02:00
const maxReq = Math . max ( ... data . map ( ( u ) => u . total _requests || 0 ) , 1 ) ;
const bars = data . map ( ( u ) => {
const pct = Math . round ( ( ( u . total _requests || 0 ) / maxReq ) * 100 ) ;
return `
< div class = "flex items-center gap-3" >
< span class = "w-28 shrink-0 text-xs text-slate-600 dark:text-slate-300 truncate" > $ { escHtml ( u . display _name ) } < / s p a n >
< div class = "flex-1 h-4 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden" >
< div class = "h-full bg-blue-500 dark:bg-blue-600 rounded-full" style = "width:${pct}%" > < / d i v >
< / d i v >
< span class = "w-10 text-right text-xs font-mono text-slate-500 dark:text-slate-400" > $ { u . total _requests ? ? 0 } < / s p a n >
< / d i v > ` ;
} ) . join ( "" ) ;
2026-06-19 01:04:20 +02:00
container . innerHTML = `
< div class = "overflow-x-auto rounded-xl border border-slate-200 dark:border-slate-700 mb-4" >
< table class = "w-full text-sm" >
< thead class = "bg-slate-50 dark:bg-slate-800/60" >
< tr class = "text-xs text-slate-500 dark:text-slate-400 border-b border-slate-200 dark:border-slate-700" >
< th class = "text-left px-4 py-2.5" > Nutzer < / t h >
< th class = "text-right px-4 py-2.5" > Anfragen gesamt < / t h >
< th class = "text-right px-4 py-2.5" > Einheiten < / t h >
< th class = "text-right px-4 py-2.5" > Letzte Aktivität < / t h >
< / t r >
< / t h e a d >
< tbody class = "divide-y divide-slate-100 dark:divide-slate-800 bg-white dark:bg-slate-800" >
$ { data . map ( ( u ) => `
< tr class = "hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors" >
< td class = "px-4 py-2.5 font-medium" > $ { escHtml ( u . display _name ) } < / t d >
< td class = "px-4 py-2.5 text-right font-mono text-xs" > $ { u . total _requests ? ? 0 } < / t d >
< td class = "px-4 py-2.5 text-right font-mono text-xs" > $ { u . total _units ? ? 0 } < / t d >
< td class = "px-4 py-2.5 text-right text-xs text-slate-400" > $ { u . last _active || "—" } < / t d >
< / t r > ` ) . j o i n ( " " ) }
< / t b o d y >
< tfoot class = "border-t-2 border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-800/60" >
< tr class = "text-xs font-semibold text-slate-600 dark:text-slate-300" >
< td class = "px-4 py-2" > Gesamt < / t d >
< td class = "px-4 py-2 text-right font-mono" > $ { totalReq } < / t d >
< td class = "px-4 py-2" > < / t d >
< td class = "px-4 py-2" > < / t d >
< / t r >
< / t f o o t >
< / t a b l e >
< / d i v >
2026-06-19 01:15:28 +02:00
< div class = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" >
< h3 class = "font-semibold text-sm mb-4" > Anfragen je Nutzer < / h 3 >
< div class = "space-y-2.5" > $ { bars } < / d i v >
< / d i v >
` ;
}
// ════════════════════════════════════════
// TAB: WÖRTERBUCH
// ════════════════════════════════════════
async function loadWoerterbuch ( ) {
const lang = ( $ ( "#words-lang" ) || { } ) . value || "de" ;
const container = $ ( "#words-content" ) ;
container . innerHTML = '<p class="text-sm text-slate-400">lade …</p>' ;
const data = await adminFetch ( ` /api/admin/pronunciation/ ${ lang } ` ) ;
if ( ! data ) { container . innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>' ; return ; }
container . innerHTML = "" ;
const sections = [
[ "abbreviations" , "Abkürzungen" ] ,
[ "units" , "Einheiten" ] ,
[ "terms" , "Begriffe / Aussprache" ] ,
] ;
for ( const [ section , label ] of sections ) {
container . appendChild ( buildWortSection ( label , section , data [ section ] || { } , lang ) ) ;
}
}
function buildWortSection ( title , section , entries , lang ) {
const wrap = document . createElement ( "div" ) ;
wrap . className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4" ;
const rows = Object . entries ( entries ) . map ( ( [ k , v ] ) => `
< tr class = "hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors group" >
< 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"
data - key = "${escHtml(k)}" > ✕ < / b u t t o n >
< / 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
wrap . querySelectorAll ( ".del-btn" ) . forEach ( ( btn ) =>
btn . addEventListener ( "click" , async ( ) => {
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 ( ) ;
} )
) ;
wrap . querySelector ( ".add-form" ) . addEventListener ( "submit" , async ( e ) => {
e . preventDefault ( ) ;
const [ keyInp , valInp ] = e . target . querySelectorAll ( "input" ) ;
const key = keyInp . value . trim ( ) ;
const value = valInp . value . trim ( ) ;
if ( ! key || ! value ) return ;
const res = await adminFetch ( ` /api/admin/pronunciation/ ${ lang } ` , "POST" , { section , key , value } ) ;
if ( res !== null ) { keyInp . value = "" ; valInp . value = "" ; loadWoerterbuch ( ) ; }
} ) ;
return wrap ;
}
$ ( "#load-words" ) . addEventListener ( "click" , loadWoerterbuch ) ;
$ ( "#words-lang" ) . addEventListener ( "change" , loadWoerterbuch ) ;
// ════════════════════════════════════════
// TAB: LOG
// ════════════════════════════════════════
let _logWs = null ;
function connectLog ( ) {
if ( _logWs ) return ;
const proto = location . protocol === "https:" ? "wss" : "ws" ;
_logWs = new WebSocket ( ` ${ proto } :// ${ location . host } /api/admin/log ` ) ;
const output = $ ( "#log-output" ) ;
const logStat = $ ( "#log-status" ) ;
const btnConn = $ ( "#log-connect" ) ;
const btnDisc = $ ( "#log-disconnect" ) ;
btnConn . disabled = true ;
btnDisc . disabled = false ;
btnDisc . classList . remove ( "opacity-40" ) ;
logStat . textContent = "verbinde …" ;
logStat . className = "text-xs text-amber-400" ;
_logWs . onopen = ( ) => {
logStat . textContent = "verbunden" ;
logStat . className = "text-xs text-emerald-400" ;
output . innerHTML = "" ;
} ;
_logWs . onmessage = ( e ) => {
const line = document . createElement ( "div" ) ;
line . textContent = e . data ;
output . appendChild ( line ) ;
output . scrollTop = output . scrollHeight ;
} ;
_logWs . onerror = ( ) => {
logStat . textContent = "Verbindungsfehler" ;
logStat . className = "text-xs text-red-400" ;
} ;
_logWs . onclose = ( ) => {
_logWs = null ;
btnConn . disabled = false ;
btnDisc . disabled = true ;
btnDisc . classList . add ( "opacity-40" ) ;
if ( ! logStat . className . includes ( "red" ) ) {
logStat . textContent = "getrennt" ;
logStat . className = "text-xs text-slate-400" ;
}
} ;
2026-06-19 01:04:20 +02:00
}
2026-06-19 01:15:28 +02:00
function disconnectLog ( ) {
if ( _logWs ) { _logWs . close ( ) ; }
}
$ ( "#log-connect" ) . addEventListener ( "click" , connectLog ) ;
$ ( "#log-disconnect" ) . addEventListener ( "click" , disconnectLog ) ;
$ ( "#log-clear" ) . addEventListener ( "click" , ( ) => { $ ( "#log-output" ) . innerHTML = "" ; } ) ;
2026-06-19 01:39:36 +02:00
// ════════════════════════════════════════
// TAB: EINSTELLUNGEN
// ════════════════════════════════════════
2026-06-19 02:35:40 +02:00
// Metadaten für jedes Feld: UI-Typ, Optionen, Test-Typ
const FIELD _META = {
2026-06-19 12:30:52 +02:00
default _stt _provider : { ui : "combo" , opts : [ "openrouter" , "faster-whisper" ] , test : "stt" } ,
default _llm _provider : { ui : "combo" , opts : [ "openrouter" , "local-openai-compatible" ] , test : "llm" } ,
default _tts _provider : { ui : "combo" , opts : [ "openrouter" , "piper" , "chatterbox" ] , test : "tts" } ,
default _language : { ui : "combo" , opts : [ "de" , "en" , "fr" , "es" , "it" , "nl" ] ,
2026-06-19 02:35:40 +02:00
labels : { de : "Deutsch" , en : "Englisch" , fr : "Französisch" , es : "Spanisch" , it : "Italienisch" , nl : "Niederländisch" } } ,
openrouter _llm _model : { ui : "combo" , opts : [ "google/gemini-3.1-flash-lite" , "openai/gpt-4.1-mini" , "google/gemini-2.5-flash" , "anthropic/claude-haiku-4-5-20251001" , "meta-llama/llama-3.3-70b-instruct" ] , test : "llm" } ,
2026-06-19 12:39:14 +02:00
openrouter _tts _model : { ui : "combo" , opts : [ "google/gemini-3.1-flash-tts-preview" , "openai/gpt-4o-mini-tts" ] , test : "tts" , testProvider : "openrouter" } ,
openrouter _tts _voice : { ui : "combo" , opts : [ "Zephyr" , "Puck" , "Charon" , "Kore" , "Fenrir" , "Leda" , "Orus" , "Aoede" , "Callirrhoe" , "Enceladus" , "Algieba" , "Despina" , "Achernar" ] , test : "tts" , testProvider : "openrouter" } ,
2026-06-19 13:04:49 +02:00
piper _voice : { ui : "combo" , opts : [ "de_DE-thorsten-high" , "de_DE-kerstin-low" , "en_US-ryan-high" , "es_ES-davefx-medium" , "fr_FR-gilles-low" ] , 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" } ,
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 = [
{ label : "Provider & Sprache" , keys : [ "default_stt_provider" , "default_llm_provider" , "default_tts_provider" , "default_language" ] } ,
{ label : "OpenRouter-Modelle & Stimmen" , keys : [ "openrouter_llm_model" , "openrouter_tts_model" , "openrouter_tts_voice" ] } ,
{ label : "Piper TTS" , keys : [ "piper_voice" ] } ,
{ label : "Lokales LLM" , keys : [ "local_llm_system_prompt" , "local_llm_temperature" , "local_llm_max_tokens" ] } ,
{ label : "TTS-Verarbeitung" , keys : [ "tts_normalize_level" , "audio_stream_default" ] } ,
{ label : "Gedächtnis" , keys : [ "memory_extraction_enabled" , "memory_extraction_every_n_turns" ] } ,
{ label : "Limits" , keys : [ "daily_request_limit" ] } ,
] ;
let _settingsData = [ ] ;
2026-06-19 01:39:36 +02:00
async function loadSettings ( ) {
const container = $ ( "#settings-content" ) ;
container . innerHTML = '<p class="text-sm text-slate-400">lade …</p>' ;
const data = await adminFetch ( "/api/admin/config" ) ;
if ( ! data ) { container . innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>' ; return ; }
2026-06-19 02:35:40 +02:00
_settingsData = data ;
2026-06-19 01:39:36 +02:00
container . innerHTML = "" ;
2026-06-19 02:35:40 +02:00
const byKey = Object . fromEntries ( data . map ( ( d ) => [ d . key , d ] ) ) ;
FIELD _GROUPS . forEach ( ( group ) => {
const items = group . keys . map ( ( k ) => byKey [ k ] ) . filter ( Boolean ) ;
if ( ! items . length ) return ;
const section = document . createElement ( "div" ) ;
section . className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden" ;
const header = document . createElement ( "div" ) ;
header . className = "px-4 py-2.5 border-b border-slate-100 dark:border-slate-700 bg-slate-50/80 dark:bg-slate-800/60" ;
header . innerHTML = ` <h4 class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide"> ${ escHtml ( group . label ) } </h4> ` ;
section . appendChild ( header ) ;
items . forEach ( ( item , idx ) => {
const row = buildSettingRow ( item ) ;
if ( idx > 0 ) row . classList . add ( "border-t" , "border-slate-100" , "dark:border-slate-700/60" ) ;
section . appendChild ( row ) ;
} ) ;
container . appendChild ( section ) ;
} ) ;
}
function _buildControl ( item , meta ) {
const val = item . effective _value ;
const id = "cfg-" + item . key ;
const cls = "rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-3 py-2 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-blue-500" ;
if ( meta . ui === "select" ) {
const labels = meta . labels || { } ;
const options = meta . opts . map ( ( o ) =>
` <option value=" ${ escHtml ( o ) } " ${ o === val ? "selected" : "" } > ${ escHtml ( labels [ o ] || o ) } </option> `
) . join ( "" ) ;
return ` <select id=" ${ id } " class=" ${ cls } flex-1"> ${ options } </select> ` ;
}
if ( meta . ui === "combo" ) {
const inList = meta . opts . includes ( val ) ;
2026-06-19 12:30:52 +02:00
const labels = meta . labels || { } ;
2026-06-19 02:35:40 +02:00
const options = meta . opts . map ( ( o ) =>
2026-06-19 12:30:52 +02:00
` <option value=" ${ escHtml ( o ) } " ${ o === val ? "selected" : "" } > ${ escHtml ( labels [ o ] || o ) } </option> `
2026-06-19 02:35:40 +02:00
) . join ( "" ) ;
const customSel = ! inList ? 'selected' : '' ;
return `
< div class = "flex-1 space-y-1.5" >
< select class = "combo-sel w-full ${cls}" >
$ { options }
< option value = "__custom__" $ { customSel } > ✏ Eigener Wert … < / o p t i o n >
< / s e l e c t >
< input id = "${id}" type = "text" value = "${escHtml(val)}" placeholder = "Eigenen Wert eingeben"
class = "combo-inp w-full ${cls} ${inList ? " hidden " : " "}" / >
< / d i v > ` ;
}
if ( meta . ui === "range" ) {
return `
< div class = "flex-1 flex items-center gap-3" >
< input id = "${id}" type = "range" min = "${meta.min}" max = "${meta.max}" step = "${meta.step}" value = "${escHtml(val)}"
class = "flex-1 accent-blue-600" / >
< span class = "range-val text-xs font-mono w-8 text-right" > $ { escHtml ( val ) } < / s p a n >
< / d i v > ` ;
}
if ( meta . ui === "number" ) {
const extra = meta . max !== undefined ? ` max=" ${ meta . max } " ` : "" ;
return ` <input id=" ${ id } " type="number" min=" ${ meta . min ? ? 0 } " ${ extra } value=" ${ escHtml ( val ) } " class=" ${ cls } flex-1" /> ` ;
}
if ( meta . ui === "textarea" ) {
return ` <textarea id=" ${ id } " rows="3" class=" ${ cls } flex-1 resize-y"> ${ escHtml ( val ) } </textarea> ` ;
}
return ` <input id=" ${ id } " type="text" value=" ${ escHtml ( val ) } " class=" ${ cls } flex-1" /> ` ;
2026-06-19 01:39:36 +02:00
}
function buildSettingRow ( item ) {
2026-06-19 02:35:40 +02:00
const meta = FIELD _META [ item . key ] || { ui : "text" } ;
2026-06-19 01:39:36 +02:00
const overridden = item . is _overridden ;
2026-06-19 02:35:40 +02:00
const envName = item . key . toUpperCase ( ) ;
const testType = meta . test ;
const wrap = document . createElement ( "div" ) ;
wrap . className = "px-4 py-3" + ( overridden ? " bg-amber-50/40 dark:bg-amber-900/10" : "" ) ;
2026-06-19 01:39:36 +02:00
2026-06-19 02:35:40 +02:00
const testBtn = testType
? ` <button class="test-btn text-xs rounded-lg border border-slate-300 dark:border-slate-600 px-2.5 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors whitespace-nowrap"> ${ testType === "tts" ? "🔊 Test" : testType === "llm" ? "💬 Test" : "🎤 Test" } </button> `
: "" ;
const resetBtn = overridden
? ` <button class="reset-btn text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors whitespace-nowrap">↩ Reset</button> `
: "" ;
2026-06-19 01:39:36 +02:00
wrap . innerHTML = `
2026-06-19 02:35:40 +02:00
< div class = "flex items-center gap-2 mb-2 flex-wrap" >
< span class = "font-medium text-sm flex-1" > $ { escHtml ( item . label ) } < / s p a n >
$ { overridden ? '<span class="text-xs rounded-full bg-amber-200 dark:bg-amber-800/60 text-amber-800 dark:text-amber-200 px-2 py-0.5 font-medium">überschrieben</span>' : "" }
$ { resetBtn }
< / d i v >
< div class = "flex gap-2 items-start flex-wrap" >
$ { _buildControl ( item , meta ) }
< div class = "flex flex-col gap-1.5 shrink-0" >
< button class = "save-btn rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-2 font-medium transition-colors" > Speichern < / b u t t o n >
$ { testBtn }
2026-06-19 01:39:36 +02:00
< / d i v >
< / d i v >
2026-06-19 02:35:40 +02:00
< div class = "flex items-baseline gap-2 mt-1.5 text-xs text-slate-400 flex-wrap" >
< span class = "font-mono font-medium text-slate-500 dark:text-slate-400" > $ { envName } < / s p a n >
< span class = "text-slate-300 dark:text-slate-600" > · < / s p a n >
< span > . env : < span class = "font-mono" > $ { escHtml ( item . base _value ) || "—" } < / s p a n > < / s p a n >
< span class = "text-slate-300 dark:text-slate-600" > · < / s p a n >
< span > $ { escHtml ( item . hint ) } < / s p a n >
2026-06-19 01:39:36 +02:00
< / d i v >
2026-06-19 02:35:40 +02:00
< div class = "feedback-msg text-xs mt-1 hidden" > < / d i v >
< div class = "test-result text-xs mt-2 hidden rounded-lg bg-slate-100 dark:bg-slate-700/60 px-3 py-2 font-mono whitespace-pre-wrap max-h-28 overflow-y-auto" > < / d i v >
2026-06-19 01:39:36 +02:00
` ;
2026-06-19 02:35:40 +02:00
// Combo-Synchronisation: Dropdown ↔ Textfeld
const comboSel = wrap . querySelector ( ".combo-sel" ) ;
const comboInp = wrap . querySelector ( ".combo-inp" ) ;
if ( comboSel && comboInp ) {
comboSel . addEventListener ( "change" , ( ) => {
if ( comboSel . value === "__custom__" ) {
comboInp . classList . remove ( "hidden" ) ;
comboInp . focus ( ) ;
} else {
comboInp . value = comboSel . value ;
comboInp . classList . add ( "hidden" ) ;
}
} ) ;
comboInp . addEventListener ( "input" , ( ) => {
const match = Array . from ( comboSel . options ) . find ( ( o ) => o . value === comboInp . value ) ;
comboSel . value = match ? match . value : "__custom__" ;
} ) ;
}
2026-06-19 01:39:36 +02:00
2026-06-19 02:35:40 +02:00
// Range-Slider: Wert live anzeigen
const rangeInp = wrap . querySelector ( 'input[type="range"]' ) ;
if ( rangeInp ) {
rangeInp . addEventListener ( "input" , ( ) => {
wrap . querySelector ( ".range-val" ) . textContent = rangeInp . value ;
} ) ;
}
// Aktuellen Wert holen (aus Combo-Textfeld oder normalem Input)
function getCurrentValue ( ) {
if ( comboInp ) return comboInp . value . trim ( ) ;
const inp = wrap . querySelector ( ` #cfg- ${ item . key } ` ) ;
return inp ? ( inp . value ? ? inp . textContent ? ? "" ) . trim ( ) : "" ;
}
// Feedback-Anzeige
function showFeedback ( ok , msg ) {
const fb = wrap . querySelector ( ".feedback-msg" ) ;
fb . textContent = msg ;
fb . className = "feedback-msg text-xs mt-1 " + ( ok ? "text-emerald-600 dark:text-emerald-400" : "text-red-500" ) ;
setTimeout ( ( ) => { fb . className = "feedback-msg text-xs mt-1 hidden" ; } , 3000 ) ;
}
// Speichern
2026-06-19 01:39:36 +02:00
wrap . querySelector ( ".save-btn" ) . addEventListener ( "click" , async ( ) => {
2026-06-19 02:35:40 +02:00
const val = getCurrentValue ( ) ;
2026-06-19 01:39:36 +02:00
const res = await adminFetch ( ` /api/admin/config/ ${ item . key } ` , "PUT" , { value : val } ) ;
2026-06-19 02:35:40 +02:00
if ( res !== null ) {
showFeedback ( true , "✓ Gespeichert" ) ;
item . is _overridden = true ;
item . effective _value = val ;
// Overrides-Badge nachrüsten falls noch nicht da
if ( ! wrap . querySelector ( ".reset-btn" ) ) {
wrap . querySelector ( ".flex.items-center.gap-2" ) . insertAdjacentHTML ( "beforeend" ,
'<button class="reset-btn text-xs text-red-500 hover:text-red-700 dark:hover:text-red-400 transition-colors whitespace-nowrap">↩ Reset</button>' ) ;
wrap . querySelector ( ".reset-btn" ) . addEventListener ( "click" , doReset ) ;
}
} else {
showFeedback ( false , "✗ Fehler beim Speichern" ) ;
}
2026-06-19 01:39:36 +02:00
} ) ;
2026-06-19 02:35:40 +02:00
// Reset
async function doReset ( ) {
if ( ! confirm ( ` Override für „ ${ item . label } " entfernen? \n Der .env-Wert gilt danach wieder. ` ) ) return ;
const res = await adminFetch ( ` /api/admin/config/ ${ item . key } ` , "DELETE" ) ;
if ( res !== null ) loadSettings ( ) ;
}
wrap . querySelector ( ".reset-btn" ) ? . addEventListener ( "click" , doReset ) ;
// Test
const testBtnEl = wrap . querySelector ( ".test-btn" ) ;
if ( testBtnEl ) {
testBtnEl . addEventListener ( "click" , async ( ) => {
const resultEl = wrap . querySelector ( ".test-result" ) ;
resultEl . textContent = "…" ;
resultEl . classList . remove ( "hidden" ) ;
testBtnEl . disabled = true ;
try {
if ( testType === "tts" ) {
2026-06-19 12:39:14 +02:00
const body = { text : "Hallo, ich bin der Sprachassistent." } ;
if ( meta . testProvider ) body . tts _provider = meta . testProvider ;
2026-06-19 02:35:40 +02:00
const r = await fetch ( "/api/speak" , {
method : "POST" , headers : { "Content-Type" : "application/json" } ,
2026-06-19 12:39:14 +02:00
body : JSON . stringify ( body ) ,
2026-06-19 02:35:40 +02:00
} ) ;
if ( ! r . ok ) throw new Error ( "HTTP " + r . status ) ;
const buf = await r . arrayBuffer ( ) ;
const view = new DataView ( buf ) ;
const n = Math . floor ( buf . byteLength / 2 ) ;
audioCtx = audioCtx || new ( window . AudioContext || window . webkitAudioContext ) ( ) ;
const abuf = audioCtx . createBuffer ( 1 , n , 24000 ) ;
const ch = abuf . getChannelData ( 0 ) ;
for ( let i = 0 ; i < n ; i ++ ) ch [ i ] = view . getInt16 ( i * 2 , true ) / 32768 ;
const src = audioCtx . createBufferSource ( ) ;
src . buffer = abuf ; src . connect ( audioCtx . destination ) ; src . start ( ) ;
resultEl . textContent = "▶ Audio spielt …" ;
src . onended = ( ) => { resultEl . classList . add ( "hidden" ) ; } ;
} else if ( testType === "llm" ) {
const r = await fetch ( "/api/chat?debug=true" , {
method : "POST" , headers : { "Content-Type" : "application/json" } ,
body : JSON . stringify ( { text : "Sag kurz Hallo." , tts _provider : "piper" } ) ,
} ) ;
const d = await r . json ( ) ;
resultEl . textContent = d . response || d . semantic || d . trace ? . response || JSON . stringify ( d ) . slice ( 0 , 200 ) ;
} else if ( testType === "stt" ) {
const cfg = await fetch ( "/api/config" ) . then ( ( r ) => r . json ( ) ) ;
const available = cfg . available ? . stt _providers || [ ] ;
const current = item . effective _value ;
resultEl . textContent = available . includes ( current )
? ` ✓ Provider „ ${ current } " ist verfügbar. `
: ` ✗ Provider „ ${ current } " nicht in verfügbaren: ${ available . join ( ", " ) } ` ;
}
} catch ( e ) {
resultEl . textContent = "Fehler: " + e . message ;
}
testBtnEl . disabled = false ;
2026-06-19 01:39:36 +02:00
} ) ;
}
return wrap ;
}
$ ( "#load-settings" ) . addEventListener ( "click" , loadSettings ) ;