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>
This commit is contained in:
Dieter Schlüter 2026-06-18 04:37:16 +02:00
commit 76df695111
16 changed files with 703 additions and 29 deletions

210
app/web/app.js Normal file
View file

@ -0,0 +1,210 @@
"use strict";
const SESSION_ID = "web-main";
const $ = (sel) => document.querySelector(sel);
const messagesEl = $("#messages");
const statusEl = $("#status");
const promptEl = $("#prompt");
const formEl = $("#prompt-form");
const micBtn = $("#mic");
let busy = false;
// ---------- Identitaet / Menue ----------
async function loadMe() {
try {
const res = await fetch("/api/me", { headers: { Accept: "application/json" } });
if (!res.ok) {
$("#identity").textContent = "Nicht angemeldet";
return;
}
const me = await res.json();
$("#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) {
$("#admin").classList.remove("hidden");
}
} catch (e) {
$("#identity").textContent = "Verbindung fehlgeschlagen";
}
}
async function loadUsers() {
const ul = $("#user-list");
ul.innerHTML = "<li class='muted'>lade …</li>";
try {
const res = await fetch("/api/admin/users");
if (!res.ok) { ul.innerHTML = "<li class='muted'>kein Zugriff</li>"; return; }
const users = await res.json();
ul.innerHTML = "";
for (const u of users) {
const li = document.createElement("li");
li.textContent = (u.external_id || u.display_name) + " (" + u.user_id.slice(0, 8) + ")";
ul.appendChild(li);
}
if (!users.length) ul.innerHTML = "<li class='muted'>keine Nutzer</li>";
} catch (e) {
ul.innerHTML = "<li class='muted'>Fehler</li>";
}
}
// ---------- Nachrichten-UI ----------
function addMessage(role, text) {
const div = document.createElement("div");
div.className = "msg " + role;
div.textContent = text;
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
return div;
}
// ---------- 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);
src.start();
}
// ---------- WS-Turn (Text und Sprache teilen die Event-Logik) ----------
function wsUrl(path) {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}${path}?session_id=${encodeURIComponent(SESSION_ID)}`;
}
// 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));
ws.binaryType = "arraybuffer";
const pcm = [];
let answerEl = null;
let sampleRate = 24000;
ws.onopen = () => onopen(ws);
ws.onerror = () => { statusEl.textContent = "Verbindungsfehler"; resolve(); };
ws.onclose = () => resolve();
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 || "";
messagesEl.scrollTop = messagesEl.scrollHeight;
break;
case "semantic":
if (!answerEl) answerEl = addMessage("assistant", "");
if (msg.text) answerEl.textContent = msg.text;
break;
case "emergency":
addMessage("emergency", "⚠ Notfall erkannt (" + (msg.category || "?") + ")");
break;
case "done":
sampleRate = msg.sample_rate || 24000;
playPcm(pcm, sampleRate);
statusEl.textContent = "";
ws.close();
break;
case "error":
addMessage("system", "Fehler: " + (msg.detail || msg.status || "unbekannt"));
ws.close();
break;
}
};
});
}
// ---------- Text senden ----------
async function sendText(text) {
if (busy || !text.trim()) return;
busy = true;
addMessage("user", text);
statusEl.textContent = "denkt …";
await runTurn("/ws/chat", (ws) => {
ws.send(JSON.stringify({ text, stream: true, audio_stream: true }));
});
busy = false;
}
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;
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (e) {
addMessage("system", "Mikrofon nicht verfuegbar (HTTPS noetig?). " + e.message);
return;
}
recChunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = (e) => { if (e.data.size) recChunks.push(e.data); };
mediaRecorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
const blob = new Blob(recChunks, { type: mediaRecorder.mimeType || "audio/webm" });
const bytes = await blob.arrayBuffer();
sendVoice(bytes);
};
mediaRecorder.start();
micBtn.classList.add("recording");
statusEl.textContent = "Aufnahme … (zum Stoppen erneut tippen)";
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== "inactive") mediaRecorder.stop();
micBtn.classList.remove("recording");
}
async function sendVoice(bytes) {
if (busy) return;
busy = true;
statusEl.textContent = "verarbeite Sprache …";
await runTurn("/ws/voice", (ws) => {
ws.send(JSON.stringify({ type: "start", format: "webm", stream: true, audio_stream: true }));
ws.send(bytes);
ws.send(JSON.stringify({ type: "end" }));
});
busy = false;
}
micBtn.addEventListener("click", () => {
if (mediaRecorder && mediaRecorder.state === "recording") stopRecording();
else startRecording();
});
$("#reload-users").addEventListener("click", loadUsers);
loadMe();

36
app/web/index.html Normal file
View file

@ -0,0 +1,36 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Voice Assistant</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div id="layout">
<aside id="menu">
<h2>Voice Assistant</h2>
<div id="identity" class="muted">lade …</div>
<div id="admin" class="hidden">
<h3>Admin</h3>
<button id="reload-users">Nutzer laden</button>
<ul id="user-list"></ul>
<a href="/api/metrics" target="_blank" rel="noopener">Metriken</a>
</div>
<a id="logout" class="hidden" href="#">Abmelden</a>
</aside>
<main id="chat">
<div id="messages"></div>
<form id="prompt-form">
<button type="button" id="mic" title="Mikrofon" aria-label="Mikrofon">🎤</button>
<input id="prompt" type="text" placeholder="Nachricht eingeben …" autocomplete="off" />
<button type="submit" id="send">Senden</button>
</form>
<div id="status" class="muted"></div>
</main>
</div>
<script src="/app.js"></script>
</body>
</html>

52
app/web/style.css Normal file
View file

@ -0,0 +1,52 @@
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: #1c1c1c;
background: #f6f7f9;
}
#layout { display: flex; min-height: 100vh; }
#menu {
width: 240px;
flex-shrink: 0;
background: #1f2933;
color: #e4e7eb;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
#menu h2 { font-size: 1.1rem; margin: 0; }
#menu h3 { font-size: 0.9rem; margin: 0 0 0.5rem; text-transform: uppercase; letter-spacing: 0.05em; }
#menu a { color: #9fd3ff; }
#identity { font-size: 0.9rem; }
.muted { color: #7b8794; font-size: 0.85rem; }
.hidden { display: none; }
#user-list { list-style: none; padding: 0; margin: 0 0 0.5rem; font-size: 0.85rem; }
#user-list li { padding: 0.15rem 0; border-bottom: 1px solid #323f4b; }
#chat { flex: 1; display: flex; flex-direction: column; padding: 1rem; max-width: 820px; margin: 0 auto; width: 100%; }
#messages { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 0.6rem; padding-bottom: 1rem; }
.msg { padding: 0.6rem 0.8rem; border-radius: 0.8rem; max-width: 80%; white-space: pre-wrap; line-height: 1.4; }
.msg.user { align-self: flex-end; background: #2b6cb0; color: #fff; }
.msg.assistant { align-self: flex-start; background: #fff; border: 1px solid #e1e4e8; }
.msg.system { align-self: center; background: #fdf3d7; border: 1px solid #f0d98c; font-size: 0.85rem; }
.msg.emergency { align-self: center; background: #fde0e0; border: 1px solid #f5a3a3; font-weight: 600; }
#prompt-form { display: flex; gap: 0.5rem; align-items: center; }
#prompt { flex: 1; padding: 0.7rem; border: 1px solid #cbd2d9; border-radius: 0.6rem; font-size: 1rem; }
button { padding: 0.7rem 1rem; border: none; border-radius: 0.6rem; background: #2b6cb0; color: #fff; font-size: 1rem; cursor: pointer; }
button:disabled { opacity: 0.5; cursor: default; }
#mic { background: #2f855a; }
#mic.recording { background: #c53030; animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } }
#status { min-height: 1.2rem; margin-top: 0.4rem; }
@media (max-width: 640px) {
#layout { flex-direction: column; }
#menu { width: 100%; flex-direction: row; flex-wrap: wrap; align-items: center; }
#menu h2 { flex: 1; }
}