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 ) {
if ( ! str ) return "" ;
return str . replace ( /&/g , "&" ) . replace ( /</g , "<" ) . replace ( />/g , ">" ) . replace ( /"/g , """ ) ;
}
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 ( ) ;
}
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 >
` ;
} catch ( e ) {
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 ) ;
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 >
` ;
}