"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 = "
  • lade …
  • "; try { const res = await fetch("/api/admin/users"); if (!res.ok) { ul.innerHTML = "
  • kein Zugriff
  • "; 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 = "
  • keine Nutzer
  • "; } catch (e) { ul.innerHTML = "
  • Fehler
  • "; } } // ---------- Nachrichten-UI ---------- // Nach dem naechsten Layout ans Ende scrollen -> die neueste Antwort ist sichtbar. function scrollToBottom() { requestAnimationFrame(() => { messagesEl.scrollTop = messagesEl.scrollHeight; }); } function addMessage(role, text) { const div = document.createElement("div"); div.className = "msg " + role; div.textContent = text; messagesEl.appendChild(div); scrollToBottom(); 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 || ""; scrollToBottom(); break; case "semantic": if (!answerEl) answerEl = addMessage("assistant", ""); if (msg.text) answerEl.textContent = msg.text; scrollToBottom(); break; case "emergency": addMessage("emergency", "⚠ Notfall erkannt (" + (msg.category || "?") + ")"); break; case "done": sampleRate = msg.sample_rate || 24000; playPcm(pcm, sampleRate); statusEl.textContent = ""; scrollToBottom(); 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); // 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)); loadMe();