my_voice_assistant_v3_jamulix/app/web/app.js

338 lines
11 KiB
JavaScript
Raw Normal View History

"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 = "<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 ----------
// 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;
}
};
});
}
// ---------- 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();