"use strict"; const $ = (sel) => document.querySelector(sel); const messagesEl = $("#messages"); const statusEl = $("#status"); const promptEl = $("#prompt"); const formEl = $("#prompt-form"); const micBtn = $("#mic"); 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 } : {}; } let busy = false; let activeWs = null; // aktive WS-Verbindung (fuer Barge-in von aussen erreichbar) let activeSources = []; // laufende AudioBufferSourceNodes (stoppbar per Barge-in) // 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; // ---------- Identitaet / Menue ---------- 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) { $("#admin-toggle").classList.remove("hidden"); } return me; } catch (e) { $("#identity").textContent = "Verbindung fehlgeschlagen"; return null; } })(); 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 */ } } if (!sessionId) sessionId = "web-anon"; } 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; }); } 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", }; function addMessage(role, text) { const div = document.createElement("div"); div.className = BUBBLE[role] || BUBBLE.assistant; div.textContent = text; messagesEl.appendChild(div); scrollToBottom(); return div; } // ---------- 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"); } // ---------- 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); activeSources.push(src); src.onended = () => { activeSources = activeSources.filter((s) => s !== src); }; 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(sessionId)}`; } // 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)); activeWs = ws; ws.binaryType = "arraybuffer"; const pcm = []; let answerEl = null; let sampleRate = 24000; ws.onopen = () => onopen(ws); ws.onerror = () => { statusEl.textContent = "Verbindungsfehler"; resolve(); }; ws.onclose = () => { activeWs = null; 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; case "interrupted": statusEl.textContent = ""; ws.close(); // → ws.onclose → resolve() → busy=false → setMicIdle() break; } }; }); } // ---------- Text senden ---------- async function sendText(text) { if (busy || !text.trim()) return; busy = true; setMicBusy(); await ensureSession(); addMessage("user", text); statusEl.textContent = "denkt …"; await runTurn("/ws/chat", (ws) => { ws.send(JSON.stringify({ text, stream: true, audio_stream: true, ...overrides() })); }); busy = false; setMicIdle(); } 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; 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; } let stream; try { stream = await md.getUserMedia({ audio: true }); } catch (e) { addMessage("system", "Mikrofon-Zugriff fehlgeschlagen: " + (e.name || "") + ". Tipp: iPhone → Einstellungen → Safari → Mikrofon = Erlauben/Fragen."); return; } recChunks = []; try { mediaRecorder = new MediaRecorder(stream); } catch (e) { stream.getTracks().forEach((t) => t.stop()); addMessage("system", "Aufnahme nicht möglich: " + (e.name || e.message)); return; } mediaRecorder.ondataavailable = (e) => { if (e.data.size) recChunks.push(e.data); }; mediaRecorder.onstop = async () => { stream.getTracks().forEach((t) => t.stop()); const mime = mediaRecorder.mimeType || "audio/mp4"; const blob = new Blob(recChunks, { type: mime }); const bytes = await blob.arrayBuffer(); // Safari -> audio/mp4, andere -> webm; der Server erkennt das Format am Inhalt. sendVoice(bytes, mime.includes("mp4") || mime.includes("mpeg") ? "mp4" : "webm"); }; mediaRecorder.start(); setMicRecording(); statusEl.textContent = "Aufnahme … (zum Stoppen erneut tippen)"; } function stopRecording() { if (mediaRecorder && mediaRecorder.state !== "inactive") mediaRecorder.stop(); setMicIdle(); } async function sendVoice(bytes, fmt = "webm") { if (busy) return; busy = true; setMicBusy(); await ensureSession(); statusEl.textContent = "verarbeite Sprache …"; await runTurn("/ws/voice", (ws) => { ws.send(JSON.stringify({ type: "start", format: fmt, stream: true, audio_stream: true, ...overrides() })); ws.send(bytes); ws.send(JSON.stringify({ type: "end" })); }); busy = false; setMicIdle(); } micBtn.addEventListener("click", () => { 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(); } }); $("#reload-users").addEventListener("click", loadUsers); $("#admin-toggle").addEventListener("click", () => $("#admin").classList.toggle("hidden")); // 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(); // 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();