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:
parent
855fc71a1e
commit
76df695111
16 changed files with 703 additions and 29 deletions
210
app/web/app.js
Normal file
210
app/web/app.js
Normal 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
36
app/web/index.html
Normal 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
52
app/web/style.css
Normal 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; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue