- Layout auf echte Viewport-Hoehe (100dvh, vh-Fallback); nur der Nachrichten- bereich scrollt (min-height:0), Eingabe + Mic-Button bleiben immer sichtbar - Mobile: Menue als kompakte Kopfleiste, Chat fuellt den Rest - Auto-Scroll zentralisiert (scrollToBottom via rAF) und bei token/semantic/done/ neuer Nachricht ausgeloest; reagiert auf Tastatur (visualViewport) + Resize/Focus -> die neueste Antwort ist immer vollstaendig sichtbar Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
224 lines
7 KiB
JavaScript
224 lines
7 KiB
JavaScript
"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 ----------
|
|
// 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();
|