"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"); const langSel = $("#lang-sel"); const cartesiaHint = $("#cartesia-lang-hint"); let cartesiaUnsupportedLangs = []; // von /api/config: Sprachen, die Cartesia nicht spricht const CARTESIA_LANG_LABELS = { ar: "Arabisch", it: "Italienisch", ru: "Russisch", pl: "Polnisch", nl: "Niederländisch", sv: "Schwedisch", tr: "Türkisch" }; // Die gewählte Sprache ist immer fest (Flex-Modus wurde entfernt). function langOverrides() { return { language: (langSel && langSel.value) || "de" }; } // Nutzer-Präferenzen als Request-Overrides für jeden Turn. // Geschlechtspräferenz: 'm', 'f', oder 'any' (= weiblich bevorzugt) let _voiceGender = 'f'; function applyGenderChoice(gender) { // 'any' aus alten Prefs → weiblich als Standardwert _voiceGender = (gender === 'm') ? 'm' : 'f'; const btnM = document.getElementById('gender-m'); const btnF = document.getElementById('gender-f'); if (!btnM || !btnF) return; const mActive = (_voiceGender === 'm'); const fActive = (_voiceGender === 'f'); // Aktiv: blauer Hintergrund + weiße Schrift // Inaktiv: kein Hintergrund + dunkle Schrift (explizit, damit kein Vererbungsproblem) btnM.classList.toggle('bg-blue-600', mActive); btnM.classList.toggle('text-white', mActive); btnM.classList.toggle('border-blue-600', mActive); btnM.classList.toggle('text-slate-700', !mActive); btnF.classList.toggle('bg-blue-600', fActive); btnF.classList.toggle('text-white', fActive); btnF.classList.toggle('border-blue-600', fActive); btnF.classList.toggle('text-slate-700', !fActive); } function overrides() { const out = { ...langOverrides() }; if (isDeviceMode()) { out.text_only = true; // kein Server-Audio -> Gerät spricht selbst } else if (ttsSel && ttsSel.value) { out.tts_provider = ttsSel.value; } out.voice_gender = _voiceGender; return out; } async function savePrefs(obj) { try { await fetch("/api/me/prefs", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(obj), }); } catch (e) { console.error("savePrefs", e); } } // ---------- Geräte-TTS (Web Speech API) ---------- // "Gerät" liest die Antwort lokal vor -> der Server schickt kein Audio (spart Bytes/Kosten). const TTS_SUPPORTED = typeof window !== "undefined" && "speechSynthesis" in window; const LANG_BCP47 = { de: "de-DE", en: "en-US", fr: "fr-FR", es: "es-ES", it: "it-IT", pt: "pt-BR", pl: "pl-PL", ar: "ar-SA", ru: "ru-RU", zh: "zh-CN", }; function isMobile() { const ua = navigator.userAgent || ""; return /Android|iPhone|iPad|iPod/i.test(ua) || (navigator.maxTouchPoints > 1 && /Macintosh/.test(ua)); // iPad ab iOS 13 } function isDeviceMode() { return !!ttsSel && ttsSel.value === "device"; } let _voices = []; function loadVoices() { _voices = TTS_SUPPORTED ? speechSynthesis.getVoices() : []; } if (TTS_SUPPORTED) { loadVoices(); // Stimmen laden asynchron -> bei Eintreffen Preset-Zustand neu bewerten (entgrauen). speechSynthesis.addEventListener("voiceschanged", () => { loadVoices(); refreshDevicePreset(); }); setTimeout(() => refreshDevicePreset(), 1500); // Backstop, falls kein voiceschanged kommt } function pickVoice(bcp) { if (!_voices.length) loadVoices(); const lc = bcp.toLowerCase(); const two = lc.slice(0, 2); return _voices.find((v) => v.lang && v.lang.toLowerCase() === lc) || _voices.find((v) => v.lang && v.lang.toLowerCase().startsWith(two)) || null; } function _makeUtterance(text, lang) { const u = new SpeechSynthesisUtterance(text); u.lang = LANG_BCP47[lang] || lang || "de-DE"; const v = pickVoice(u.lang); if (v) u.voice = v; return u; } // Hat der Browser nutzbare Sprachstimmen? (Linux-Desktops haben oft KEINE -> stumm.) function deviceVoicesReady() { if (!TTS_SUPPORTED) return false; if (!_voices.length) loadVoices(); return _voices.length > 0; } // Server-Fallback: holt Audio via /api/speak (ohne tts_provider -> Server-Default, // niemals "device") und spielt es ab. Greift, wenn Geräte-TTS keine Stimme hat. async function serverSpeakFallback(text, lang, btn) { try { const body = { text }; if (lang) body.language = lang; const r = await fetch("/api/speak", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!r.ok) throw new Error("HTTP " + r.status); startReplay([await r.arrayBuffer()], btn); } catch (e) { setReplayPlaying(btn, false); statusEl.textContent = "Vorlesen fehlgeschlagen"; } } // Vorlesen ohne Button-Status (automatisches Vorlesen der Antwort). function deviceSpeak(text, lang) { if (!text) return; if (deviceVoicesReady()) { speechSynthesis.cancel(); speechSynthesis.speak(_makeUtterance(text, lang)); } else { serverSpeakFallback(text, lang, null); // keine Stimmen -> Server-Audio } } // Vorlesen mit Button-Status (Replay): Icon -> ⏹ während des Sprechens, danach zurück. function deviceSpeakBtn(text, lang, btn) { if (!text) { setReplayPlaying(btn, false); return; } if (!deviceVoicesReady()) { serverSpeakFallback(text, lang, btn); return; } speechSynthesis.cancel(); const u = _makeUtterance(text, lang); u.onend = u.onerror = () => setReplayPlaying(btn, false); setReplayPlaying(btn, true); speechSynthesis.speak(u); } // iOS: sowohl SpeechSynthesis (Geräte-TTS) als auch der Web-Audio-Kontext (Server-Chunks) // dürfen nur nach einer Nutzergeste starten -> beim ersten Tap beide freischalten. let _ttsUnlocked = false; let _audioUnlocked = false; function unlockTTS() { // Geräte-TTS per Leer-Utterance freischalten. if (TTS_SUPPORTED && !_ttsUnlocked) { try { speechSynthesis.speak(new SpeechSynthesisUtterance("")); _ttsUnlocked = true; } catch (e) {} } // Web-Audio-Kontext jetzt (in der Geste) anlegen + per Stille-Buffer aufwecken, damit // die satzweise Chunk-Wiedergabe in den Server-Modi auch auf iOS läuft (sonst bleibt ein // erst im WS-Callback erzeugter AudioContext dort "suspended"). if (!_audioUnlocked) { try { audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)(); if (audioCtx.state === "suspended") audioCtx.resume(); const src = audioCtx.createBufferSource(); src.buffer = audioCtx.createBuffer(1, 1, 22050); // 1 Sample Stille src.connect(audioCtx.destination); src.start(0); _audioUnlocked = true; } catch (e) {} } } const PLAYBACK_KEY = "va-playback"; // geräte-lokal: "device" wenn Geräte-TTS gewählt // Setzt das verborgene Quell-Select (#tts) beim Laden + spiegelt es in die Presets. // Vorrang: 1. lokale Wahl (localStorage), 2. Server-Pref (tts_provider), 3. Mobil-Default = Gerät. function applyPlaybackChoice(serverProvider) { if (!ttsSel) return; const local = localStorage.getItem(PLAYBACK_KEY); if (local === "device" && TTS_SUPPORTED) { ttsSel.value = "device"; } else if (serverProvider) { ttsSel.value = serverProvider; } else if (TTS_SUPPORTED && isMobile() && local === null) { ttsSel.value = "device"; // Mobil-Default, solange nichts gewählt wurde localStorage.setItem(PLAYBACK_KEY, "device"); } else if (!ttsSel.value) { ttsSel.value = "piper"; // Fallback: Server } refreshDevicePreset(); // graut „Im Gerät" aus, wenn keine Stimmen + syncPresetUI } // Graut „Im Gerät" aus, wenn keine Sprachstimmen vorhanden sind; entgraut automatisch, // sobald welche da sind. Kein Aufzwingen: device wird nur (wieder) gewählt, wenn es die // gespeicherte Präferenz ist. Ohne Stimmen NICHT auf der deaktivierten Option sitzen bleiben. function refreshDevicePreset() { const btn = document.querySelector('.tts-preset[data-tts="device"]'); if (!btn || !ttsSel) return; const ready = deviceVoicesReady(); btn.disabled = !ready; btn.classList.toggle("opacity-50", !ready); btn.classList.toggle("cursor-not-allowed", !ready); const sub = btn.querySelector(".tts-sub"); if (sub) sub.textContent = ready ? "Das Gerät liest vor · spart Daten" : "keine Sprachstimmen im Browser"; if (ready) { if (localStorage.getItem(PLAYBACK_KEY) === "device" && ttsSel.value !== "device") { ttsSel.value = "device"; // gespeicherte Präferenz wieder aktivieren } } else if (ttsSel.value === "device") { ttsSel.value = "piper"; // Server statt deaktivierter Option (Präferenz bleibt) } syncPresetUI(); } // Graut "Hohe Qualität" (Chatterbox) aus, wenn kein GPU-Backend erreichbar ist. async function refreshChatterboxPreset() { const btn = document.querySelector('.tts-preset[data-tts="chatterbox"]'); if (!btn || !ttsSel) return; try { const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json()); const available = cfg.available?.chatterbox_reachable === true; btn.disabled = !available; btn.classList.toggle("opacity-50", !available); btn.classList.toggle("cursor-not-allowed", !available); const sub = btn.querySelector(".tts-sub"); if (sub) sub.textContent = available ? "Chatterbox · natürliche Stimme" : "Chatterbox · kein GPU-Backend erreichbar"; if (!available && ttsSel.value === "chatterbox") { ttsSel.value = "piper"; syncPresetUI(); } } catch (e) { /* ignorieren */ } } // Graut "Cartesia" aus, wenn API-Key oder Voice-ID fehlt. async function refreshCartesiaPreset() { const btn = document.querySelector('.tts-preset[data-tts="cartesia"]'); if (!btn || !ttsSel) return; try { const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json()); const available = cfg.available?.cartesia_available === true; cartesiaUnsupportedLangs = cfg.available?.cartesia_unsupported_languages || []; btn.disabled = !available; btn.classList.toggle("opacity-50", !available); btn.classList.toggle("cursor-not-allowed", !available); const sub = btn.querySelector(".tts-sub"); if (sub) sub.textContent = available ? "Cartesia Sonic · natürlich & schnell" : "Cartesia · kein API-Key konfiguriert"; if (!available && ttsSel.value === "cartesia") { ttsSel.value = "piper"; syncPresetUI(); } updateCartesiaLangHint(); } catch (e) { /* ignorieren */ } } // Zeigt einen Hinweis, wenn Cartesia gewählt ist, die feste Sprache aber nicht // unterstützt wird — dann übernimmt automatisch die lokale Piper-Stimme (tts_fallback). function updateCartesiaLangHint() { if (!cartesiaHint) return; const lang = langSel && langSel.value; const show = ttsSel && ttsSel.value === "cartesia" && lang && lang !== "flex" && cartesiaUnsupportedLangs.includes(lang); if (show) { const label = CARTESIA_LANG_LABELS[lang] || lang.toUpperCase(); cartesiaHint.textContent = `Hinweis: Cartesia spricht ${label} nicht — es wird automatisch die lokale Stimme (Piper) verwendet.`; cartesiaHint.classList.remove("hidden"); } else { cartesiaHint.classList.add("hidden"); } } // Graut "Cloud" (OpenRouter TTS) aus, wenn das TTS-Modell nicht verfügbar ist. async function refreshOpenrouterTtsPreset() { const btn = document.querySelector('.tts-preset[data-tts="openrouter"]'); if (!btn || !ttsSel) return; try { const cfg = await fetch("/api/config", { cache: "no-store" }).then(r => r.json()); const available = cfg.available?.openrouter_tts_available === true; btn.disabled = !available; btn.classList.toggle("opacity-50", !available); btn.classList.toggle("cursor-not-allowed", !available); const sub = btn.querySelector(".tts-sub"); if (sub) sub.textContent = available ? "OpenRouter · benötigt Internet" : "OpenRouter · kein TTS-Modell verfügbar"; if (!available && ttsSel.value === "openrouter") { ttsSel.value = "piper"; syncPresetUI(); } } catch (e) { /* ignorieren */ } } // Markiert das aktive Ton-Preset (Häkchen + Rahmen) passend zu #tts. function syncPresetUI() { const val = ttsSel ? ttsSel.value : ""; document.querySelectorAll(".tts-preset").forEach((btn) => { const active = btn.dataset.tts === val; btn.classList.toggle("ring-2", active); btn.classList.toggle("ring-blue-500", active); btn.classList.toggle("border-blue-500", active); const chk = btn.querySelector(".check"); if (chk) chk.style.opacity = active ? "1" : "0"; }); } // ---------- Einstellungs-Menü (⋮) ---------- function openMenu() { $("#settings-sheet").classList.remove("hidden"); $("#settings-backdrop").classList.remove("hidden"); } function closeMenu() { $("#settings-sheet").classList.add("hidden"); $("#settings-backdrop").classList.add("hidden"); } let busy = false; let activeWs = null; // aktive WS-Verbindung (fuer Barge-in von aussen erreichbar) let activeSources = []; // laufende AudioBufferSourceNodes (stoppbar per Barge-in) let streamPlayTime = 0; // naechster Startzeitpunkt fuer gapless Streaming-Wiedergabe // 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; let myPrefs = {}; // Prefs des aktuellen Nutzers (u. a. location_consent für den Notruf-Standort) let currentUserId = null; const sessionKey = (uid) => "va-session-" + uid; // Sprachen (Code -> Kurzlabel) für Filter + Admin-Auswahl. function loginLink(token) { return location.origin + "/?k=" + token; } async function copyText(text, btn) { try { await navigator.clipboard.writeText(text); if (btn) { const t = btn.textContent; btn.textContent = "✓ kopiert"; setTimeout(() => (btn.textContent = t), 1500); } } catch (e) { /* Clipboard evtl. blockiert -> select-all bleibt als Fallback */ } } const ALL_LANGS = [["de","DE"],["en","EN"],["fr","FR"],["es","ES"],["it","IT"], ["pt","PT"],["pl","PL"],["ar","AR"],["ru","RU"],["zh","ZH"]]; // --- Format-Validierung der Notfall-Profil-Felder ------------------------ const _isEmail = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); const _isPhone = (v) => /^\+?[\d\s/().\-]{6,20}$/.test(v); function _isGermanDate(v) { const m = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(v); if (!m) return false; const d = +m[1], mo = +m[2], y = +m[3]; const dt = new Date(y, mo - 1, d); return dt.getDate() === d && dt.getMonth() === mo - 1 && dt.getFullYear() === y && y >= 1900 && y <= 2100; } const _csvAll = (v, fn) => v.split(",").map((s) => s.trim()).filter(Boolean).every(fn); // pro Feld: ok(wert) wird NUR bei nicht-leerem Wert geprüft + Meckertext. const PROF_VALIDATORS = { postal_code: { ok: (v) => /^\d{5}$/.test(v), msg: "PLZ: genau 5 Ziffern" }, phone_mobile: { ok: _isPhone, msg: "Mobil: gültige Telefonnummer (z. B. +49 151 …)" }, phone_landline: { ok: _isPhone, msg: "Festnetz: gültige Telefonnummer" }, birth_date: { ok: _isGermanDate, msg: "Geburtsdatum: TT.MM.JJJJ" }, emergency_contacts: { ok: (v) => _csvAll(v, _isEmail), msg: "Kontakt-E-Mail(s): gültige Adressen (Komma-getrennt)" }, emergency_phones: { ok: (v) => _csvAll(v, _isPhone), msg: "Kontakt-Telefon(e): gültige Nummern (Komma-getrennt)" }, }; // Wendet die vom Admin erlaubten Sprachen auf das Sprachmenü an. Leer = alle erlaubt. // Genau eine erlaubte Sprache -> Menü ausblenden (kein Stress für den Nutzer). function applyAllowedLanguages(prefs) { if (!langSel) return; const allowed = String((prefs || {}).allowed_languages || "") .split(",").map((s) => s.trim()).filter(Boolean); Array.from(langSel.options).forEach((opt) => { const blocked = allowed.length > 0 && !allowed.includes(opt.value); opt.hidden = blocked; // versteckt (moderne Browser) opt.disabled = blocked; // robust: auch wo