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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue