feat: Geräte-TTS, native Stimmen, Favicon, Auth-Gate, UI-Fixes
Web-UI / TTS:
- Geräte-TTS ("📱 Gerät"): Antwort wird on-device vorgelesen (Web Speech
API), Server sendet nur Text (text_only) -> spart Bandbreite/Kosten.
Mobil-Default, geräte-lokale Speicherung, iOS-Autoplay-Freischaltung.
- Vorlese-Symbol (🔊) je Bubble: Hybrid-Replay (Assistent-PCM gecacht,
Eingabe via /api/speak); SVG-Icon mit kontrastreicher Farbe.
- Kombiniertes Sprachmenü (Flex + feste Sprachen) statt separatem Modus-Menü.
- "Neues Gespräch"-Button (frische Session gegen Sprach-Trägheit).
- Dark-Mode: lesbare <option>-Popups (Kontrast-Fix).
- Favicon (SVG + PNG-Fallbacks) aus mund.png.
TTS-Backend:
- Sprache wird an alle TTS-Provider durchgereicht; Piper-Stimme folgt der
Sprache; Chatterbox mehrsprachig + cross-lingual.
- Native Referenz-Stimmen je Sprache (config/voices/<lang>.wav, FLEURS CC-BY),
loudness-normalisiert.
LLM-Sprache:
- Antwort folgt zuverlässig der gewählten Sprache (verstärkte Anweisung +
Erinnerung an der letzten Nutzer-Nachricht gegen History-Trägheit).
Admin / Auth:
- Wörterbuch: alle 8 Sprachen, Zeilen editierbar, alphabetische Sortierung.
- Web-UI hinter Auth-Gate (Redirect auf SSO_LOGIN_URL / 401); Favicons offen.
- Log-Tab: Hinweis, wenn der systemd-Dienst nicht aktiv ist.
- Einstellungen: Hinweis "pro Nutzer überschreibbar" bei Sprache/Modus/Qualität.
Doku (BEDIENUNGSANLEITUNG.md): Geräte-TTS §6.5.0, Fix/Flex §6.6, native
Stimmen §6.5.3, llama.cpp<->Ollama-Wechsel §4.7, Auth/SSO §7.4.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
dc8ac80505
commit
878bf785dd
51 changed files with 985 additions and 202 deletions
333
app/web/app.js
333
app/web/app.js
|
|
@ -8,11 +8,121 @@ const promptEl = $("#prompt");
|
|||
const formEl = $("#prompt-form");
|
||||
const micBtn = $("#mic");
|
||||
const ttsSel = $("#tts");
|
||||
const langSel = $("#lang-sel");
|
||||
|
||||
// Gewählter TTS-Provider (leer = Default/piper) als Request-Override.
|
||||
// "Flex" ist im Sprachmenü nur ein weiterer Wert: keine feste Sprache, sondern
|
||||
// automatische Erkennung. Eine konkrete Sprache = Fix-Modus (Eingabe wird übersetzt).
|
||||
// Beide internen Felder (language, language_mode) werden aus dieser einen Auswahl abgeleitet.
|
||||
function langOverrides() {
|
||||
const sel = langSel && langSel.value;
|
||||
if (!sel || sel === "flex") return { language_mode: "flex" };
|
||||
return { language: sel, language_mode: "fix" };
|
||||
}
|
||||
|
||||
// Nutzer-Präferenzen als Request-Overrides für jeden Turn.
|
||||
function overrides() {
|
||||
const t = ttsSel && ttsSel.value;
|
||||
return t ? { tts_provider: t } : {};
|
||||
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;
|
||||
}
|
||||
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", nl: "nl-NL", 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();
|
||||
speechSynthesis.addEventListener("voiceschanged", loadVoices);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Vorlesen ohne Button-Status (automatisches Vorlesen der Antwort).
|
||||
function deviceSpeak(text, lang) {
|
||||
if (!TTS_SUPPORTED || !text) return;
|
||||
speechSynthesis.cancel();
|
||||
speechSynthesis.speak(_makeUtterance(text, lang));
|
||||
}
|
||||
|
||||
// Vorlesen mit Button-Status (Replay): Icon -> ⏹ während des Sprechens, danach zurück.
|
||||
function deviceSpeakBtn(text, lang, btn) {
|
||||
if (!TTS_SUPPORTED || !text) { setReplayPlaying(btn, false); return; }
|
||||
speechSynthesis.cancel();
|
||||
const u = _makeUtterance(text, lang);
|
||||
u.onend = u.onerror = () => setReplayPlaying(btn, false);
|
||||
setReplayPlaying(btn, true);
|
||||
speechSynthesis.speak(u);
|
||||
}
|
||||
|
||||
// iOS: speechSynthesis darf nur nach einer Nutzergeste starten -> beim ersten Tap freischalten.
|
||||
let _ttsUnlocked = false;
|
||||
function unlockTTS() {
|
||||
if (_ttsUnlocked || !TTS_SUPPORTED) return;
|
||||
try { speechSynthesis.speak(new SpeechSynthesisUtterance("")); _ttsUnlocked = true; } catch (e) {}
|
||||
}
|
||||
|
||||
const PLAYBACK_KEY = "va-playback"; // geräte-lokal: "device" wenn Geräte-TTS gewählt
|
||||
|
||||
// Setzt das Qualität-Dropdown beim Laden. Vorrang:
|
||||
// 1. lokale Wahl (localStorage), 2. Server-Pref (tts_provider), 3. Mobil-Default = Gerät.
|
||||
function applyPlaybackChoice(serverProvider) {
|
||||
if (!ttsSel) return;
|
||||
// "Gerät"-Option ausblenden, wenn der Browser keine Web Speech API hat.
|
||||
if (!TTS_SUPPORTED) {
|
||||
const opt = ttsSel.querySelector('option[value="device"]');
|
||||
if (opt) opt.remove();
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
let busy = false;
|
||||
|
|
@ -23,6 +133,8 @@ let activeSources = []; // laufende AudioBufferSourceNodes (stoppbar per Barge-
|
|||
// /api/me abgeleitet; bis dahin null -> ensureSession() wartet auf loadMe().
|
||||
let sessionId = null;
|
||||
let mePromise = null;
|
||||
let currentUserId = null;
|
||||
const sessionKey = (uid) => "va-session-" + uid;
|
||||
|
||||
// ---------- Identitaet / Menue ----------
|
||||
function loadMe() {
|
||||
|
|
@ -34,7 +146,9 @@ function loadMe() {
|
|||
return null;
|
||||
}
|
||||
const me = await res.json();
|
||||
sessionId = "web-" + me.user_id;
|
||||
currentUserId = me.user_id;
|
||||
// Zuletzt aktive (ggf. frische) Session bevorzugen, sonst die Basis-Session.
|
||||
sessionId = localStorage.getItem(sessionKey(me.user_id)) || ("web-" + me.user_id);
|
||||
$("#identity").textContent = "Angemeldet als " + (me.display_name || me.external_id || "Gast");
|
||||
if (me.sso_logout_url) {
|
||||
const logout = $("#logout");
|
||||
|
|
@ -44,6 +158,15 @@ function loadMe() {
|
|||
if (me.is_admin) {
|
||||
$("#admin-toggle").classList.remove("hidden");
|
||||
}
|
||||
// Gespeicherte Nutzer-Präferenzen in die Dropdowns laden.
|
||||
const prefs = me.prefs || {};
|
||||
if (langSel) {
|
||||
// Flex-Modus -> "flex", sonst die feste Sprache (Fallback: Flex).
|
||||
langSel.value = prefs.language_mode === "flex"
|
||||
? "flex"
|
||||
: (prefs.language || "flex");
|
||||
}
|
||||
applyPlaybackChoice(prefs.tts_provider);
|
||||
return me;
|
||||
} catch (e) {
|
||||
$("#identity").textContent = "Verbindung fehlgeschlagen";
|
||||
|
|
@ -76,19 +199,98 @@ const BUBBLE = {
|
|||
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) {
|
||||
// Lautsprecher-/Stop-Icons als SVG (currentColor → per CSS einfärbbar, anders als Emoji).
|
||||
const ICON_SPEAKER =
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" ' +
|
||||
'class="w-4 h-4"><path stroke-linecap="round" stroke-linejoin="round" ' +
|
||||
'd="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" /></svg>';
|
||||
const ICON_STOP =
|
||||
'<svg viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">' +
|
||||
'<rect x="6" y="6" width="12" height="12" rx="2" /></svg>';
|
||||
|
||||
// meta.lang: Sprache, in der diese Bubble vorgelesen werden soll (für Replay).
|
||||
function addMessage(role, text, meta = {}) {
|
||||
const div = document.createElement("div");
|
||||
div.className = BUBBLE[role] || BUBBLE.assistant;
|
||||
div.textContent = text;
|
||||
const span = document.createElement("span");
|
||||
span.className = "bubble-text";
|
||||
span.textContent = text;
|
||||
div.appendChild(span);
|
||||
div._textEl = span;
|
||||
// Vorlese-Symbol nur an Nutzer-/Assistenten-Bubbles (nicht System/Notfall).
|
||||
if (role === "user" || role === "assistant") {
|
||||
div.classList.add("relative", "pr-7");
|
||||
div._replay = { lang: meta.lang || null, chunks: null }; // chunks: gecachtes PCM (Assistent)
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.innerHTML = ICON_SPEAKER;
|
||||
btn.title = "Vorlesen";
|
||||
btn.setAttribute("aria-label", "Vorlesen");
|
||||
// Kontrastreiche Farbe je Bubble: weiß auf Blau; im Dark-Mode hell auf Dunkelgrau.
|
||||
const replayColor = role === "user"
|
||||
? "text-white/80 hover:text-white"
|
||||
: "text-slate-500 hover:text-slate-700 dark:text-slate-300 dark:hover:text-white";
|
||||
btn.className = "replay-btn absolute top-1.5 right-1.5 transition-colors " + replayColor;
|
||||
btn.addEventListener("click", () => replayBubble(div));
|
||||
div._replayBtn = btn;
|
||||
div.appendChild(btn);
|
||||
}
|
||||
messagesEl.appendChild(div);
|
||||
scrollToBottom();
|
||||
return div;
|
||||
}
|
||||
|
||||
// ---------- Vorlesen / Replay ----------
|
||||
function setReplayPlaying(btn, on) {
|
||||
if (!btn) return;
|
||||
btn.innerHTML = on ? ICON_STOP : ICON_SPEAKER;
|
||||
btn.dataset.playing = on ? "1" : "";
|
||||
}
|
||||
|
||||
function startReplay(chunks, btn) {
|
||||
stopAudio(); // Barge-in: laufendes Audio (auch andere Replays) stoppen
|
||||
setReplayPlaying(btn, true);
|
||||
playPcm(chunks, 24000, () => setReplayPlaying(btn, false));
|
||||
}
|
||||
|
||||
async function speakBubble(text, lang, btn) {
|
||||
try {
|
||||
const body = { text };
|
||||
if (lang) body.language = lang;
|
||||
if (ttsSel && ttsSel.value) body.tts_provider = ttsSel.value;
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
function replayBubble(div) {
|
||||
const btn = div._replayBtn;
|
||||
if (btn && btn.dataset.playing) { stopAudio(); setReplayPlaying(btn, false); return; } // Toggle
|
||||
const info = div._replay || {};
|
||||
const text = (div._textEl && div._textEl.textContent) || "";
|
||||
if (info.chunks && info.chunks.length) {
|
||||
startReplay(info.chunks, btn); // Assistent: gecachtes PCM, sofort & gratis
|
||||
} else if (isDeviceMode()) {
|
||||
stopAudio();
|
||||
deviceSpeakBtn(text, info.lang, btn); // Geräte-TTS: lokal vorlesen, gratis
|
||||
} else {
|
||||
speakBubble(text, info.lang, btn); // Server-TTS via /api/speak
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Barge-in-Hilfsfunktionen ----------
|
||||
function stopAudio() {
|
||||
activeSources.forEach((s) => { try { s.stop(); } catch (e) {} });
|
||||
activeSources = [];
|
||||
if (TTS_SUPPORTED) { try { speechSynthesis.cancel(); } catch (e) {} } // Geräte-TTS stoppen
|
||||
}
|
||||
|
||||
function setMicIdle() {
|
||||
|
|
@ -113,8 +315,8 @@ function setMicBusy() {
|
|||
|
||||
// ---------- Audio-Wiedergabe (PCM s16le) ----------
|
||||
let audioCtx = null;
|
||||
function playPcm(chunks, sampleRate) {
|
||||
if (!chunks.length) return;
|
||||
function playPcm(chunks, sampleRate, onended) {
|
||||
if (!chunks.length) { if (onended) onended(); return; }
|
||||
const total = chunks.reduce((n, c) => n + c.byteLength, 0);
|
||||
const merged = new Uint8Array(total);
|
||||
let off = 0;
|
||||
|
|
@ -122,6 +324,7 @@ function playPcm(chunks, sampleRate) {
|
|||
const view = new DataView(merged.buffer);
|
||||
const n = Math.floor(merged.byteLength / 2);
|
||||
audioCtx = audioCtx || new (window.AudioContext || window.webkitAudioContext)();
|
||||
if (audioCtx.state === "suspended") audioCtx.resume();
|
||||
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;
|
||||
|
|
@ -129,7 +332,10 @@ function playPcm(chunks, sampleRate) {
|
|||
src.buffer = buf;
|
||||
src.connect(audioCtx.destination);
|
||||
activeSources.push(src);
|
||||
src.onended = () => { activeSources = activeSources.filter((s) => s !== src); };
|
||||
src.onended = () => {
|
||||
activeSources = activeSources.filter((s) => s !== src);
|
||||
if (onended) onended();
|
||||
};
|
||||
src.start();
|
||||
}
|
||||
|
||||
|
|
@ -148,6 +354,10 @@ function runTurn(path, onopen) {
|
|||
const pcm = [];
|
||||
let answerEl = null;
|
||||
let sampleRate = 24000;
|
||||
// Sprache dieses Turns (für den Replay je Bubble): Flex folgt erkannter Sprache.
|
||||
let routeLang = null, routeMode = null, detectedLang = null;
|
||||
const turnLang = () =>
|
||||
(routeMode === "flex" ? (detectedLang || routeLang) : routeLang) || null;
|
||||
|
||||
ws.onopen = () => onopen(ws);
|
||||
ws.onerror = () => { statusEl.textContent = "Verbindungsfehler"; resolve(); };
|
||||
|
|
@ -158,17 +368,25 @@ function runTurn(path, onopen) {
|
|||
let msg;
|
||||
try { msg = JSON.parse(event.data); } catch { return; }
|
||||
switch (msg.type) {
|
||||
case "ack":
|
||||
routeLang = (msg.route && msg.route.language) || null;
|
||||
routeMode = (msg.route && msg.route.language_mode) || null;
|
||||
break;
|
||||
case "transcript":
|
||||
if (msg.text) addMessage("user", msg.text);
|
||||
detectedLang = msg.detected_language || null;
|
||||
if (msg.text) addMessage("user", msg.text, { lang: turnLang() });
|
||||
break;
|
||||
case "token":
|
||||
if (!answerEl) answerEl = addMessage("assistant", "");
|
||||
answerEl.textContent += msg.text || "";
|
||||
if (!answerEl) answerEl = addMessage("assistant", "", { lang: turnLang() });
|
||||
answerEl._textEl.textContent += msg.text || "";
|
||||
scrollToBottom();
|
||||
break;
|
||||
case "semantic":
|
||||
if (!answerEl) answerEl = addMessage("assistant", "");
|
||||
if (msg.text) answerEl.textContent = msg.text;
|
||||
if (!answerEl) answerEl = addMessage("assistant", "", { lang: turnLang() });
|
||||
if (msg.text) answerEl._textEl.textContent = msg.text;
|
||||
// Sprache final festhalten (für Replay) und im Geräte-Modus lokal vorlesen.
|
||||
if (answerEl._replay) answerEl._replay.lang = turnLang();
|
||||
if (isDeviceMode() && msg.text) deviceSpeak(msg.text, turnLang());
|
||||
scrollToBottom();
|
||||
break;
|
||||
case "emergency":
|
||||
|
|
@ -176,7 +394,11 @@ function runTurn(path, onopen) {
|
|||
break;
|
||||
case "done":
|
||||
sampleRate = msg.sample_rate || 24000;
|
||||
playPcm(pcm, sampleRate);
|
||||
// Geräte-Modus: kein Server-Audio -> nichts abspielen/cachen (Gerät hat schon gesprochen).
|
||||
if (!isDeviceMode()) {
|
||||
if (answerEl && answerEl._replay) answerEl._replay.chunks = pcm.slice(); // Replay-Cache
|
||||
playPcm(pcm, sampleRate);
|
||||
}
|
||||
statusEl.textContent = "";
|
||||
scrollToBottom();
|
||||
ws.close();
|
||||
|
|
@ -200,10 +422,13 @@ async function sendText(text) {
|
|||
busy = true;
|
||||
setMicBusy();
|
||||
await ensureSession();
|
||||
addMessage("user", text);
|
||||
// Bei fester Sprache wird der eigene Text in dieser Sprache vorgelesen; bei Flex offen.
|
||||
const lo = langOverrides();
|
||||
addMessage("user", text, { lang: lo.language_mode === "fix" ? lo.language : null });
|
||||
statusEl.textContent = "denkt …";
|
||||
const audioStream = !isDeviceMode(); // Geräte-Modus: kein Server-Audio anfordern
|
||||
await runTurn("/ws/chat", (ws) => {
|
||||
ws.send(JSON.stringify({ text, stream: true, audio_stream: true, ...overrides() }));
|
||||
ws.send(JSON.stringify({ text, stream: true, audio_stream: audioStream, ...overrides() }));
|
||||
});
|
||||
busy = false;
|
||||
setMicIdle();
|
||||
|
|
@ -211,6 +436,7 @@ async function sendText(text) {
|
|||
|
||||
formEl.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
unlockTTS(); // iOS: Geräte-TTS bei der Nutzergeste freischalten
|
||||
const text = promptEl.value;
|
||||
promptEl.value = "";
|
||||
sendText(text);
|
||||
|
|
@ -272,8 +498,9 @@ async function sendVoice(bytes, fmt = "webm") {
|
|||
setMicBusy();
|
||||
await ensureSession();
|
||||
statusEl.textContent = "verarbeite Sprache …";
|
||||
const audioStream = !isDeviceMode(); // Geräte-Modus: kein Server-Audio anfordern
|
||||
await runTurn("/ws/voice", (ws) => {
|
||||
ws.send(JSON.stringify({ type: "start", format: fmt, stream: true, audio_stream: true, ...overrides() }));
|
||||
ws.send(JSON.stringify({ type: "start", format: fmt, stream: true, audio_stream: audioStream, ...overrides() }));
|
||||
ws.send(bytes);
|
||||
ws.send(JSON.stringify({ type: "end" }));
|
||||
});
|
||||
|
|
@ -282,6 +509,7 @@ async function sendVoice(bytes, fmt = "webm") {
|
|||
}
|
||||
|
||||
micBtn.addEventListener("click", () => {
|
||||
unlockTTS(); // iOS: Geräte-TTS bei der Nutzergeste freischalten
|
||||
if (busy) {
|
||||
// Barge-in: laufende KI-Antwort sofort unterbrechen
|
||||
if (activeWs) activeWs.send(JSON.stringify({ type: "interrupt" }));
|
||||
|
|
@ -297,6 +525,23 @@ micBtn.addEventListener("click", () => {
|
|||
|
||||
$("#admin-toggle").addEventListener("click", openAdminPanel);
|
||||
|
||||
// ---------- Neues Gespräch ----------
|
||||
// Startet eine frische Session (neuer session_id) -> der bisherige Verlauf wird nicht
|
||||
// mehr als LLM-Kontext genutzt. Behebt "Sprache springt nicht um", wenn die History
|
||||
// in einer anderen Sprache dominiert. Nicht-destruktiv: alte Session bleibt in der DB.
|
||||
function newConversation() {
|
||||
const base = currentUserId || "anon";
|
||||
sessionId = "web-" + base + "-" + Date.now();
|
||||
if (currentUserId) localStorage.setItem(sessionKey(currentUserId), sessionId);
|
||||
stopAudio();
|
||||
messagesEl.innerHTML = "";
|
||||
statusEl.textContent = "Neues Gespräch begonnen";
|
||||
setTimeout(() => {
|
||||
if (statusEl.textContent === "Neues Gespräch begonnen") statusEl.textContent = "";
|
||||
}, 2500);
|
||||
}
|
||||
$("#new-chat").addEventListener("click", newConversation);
|
||||
|
||||
// Tag/Nacht: Umschalter (manuell) + automatisch dem System folgen, solange nichts gewählt.
|
||||
const themeBtn = $("#theme-toggle");
|
||||
const setThemeIcon = () =>
|
||||
|
|
@ -321,6 +566,21 @@ if (window.visualViewport) {
|
|||
window.addEventListener("resize", scrollToBottom);
|
||||
promptEl.addEventListener("focus", () => setTimeout(scrollToBottom, 300));
|
||||
|
||||
// Dropdown-Änderungen dauerhaft als Nutzer-Präferenz speichern.
|
||||
if (langSel) langSel.addEventListener("change", () => savePrefs(langOverrides()));
|
||||
if (ttsSel) {
|
||||
ttsSel.addEventListener("change", () => {
|
||||
unlockTTS(); // Auswahl ist eine Nutzergeste -> Geräte-TTS gleich freischalten
|
||||
if (ttsSel.value === "device") {
|
||||
// Geräte-TTS ist geräte-lokal (nicht server-seitig) -> nur lokal merken.
|
||||
localStorage.setItem(PLAYBACK_KEY, "device");
|
||||
} else {
|
||||
localStorage.removeItem(PLAYBACK_KEY);
|
||||
savePrefs({ tts_provider: ttsSel.value || null }); // echter Provider -> Server-Pref
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadMe();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
|
@ -854,13 +1114,14 @@ function buildWortSection(title, section, entries, lang) {
|
|||
wrap.className = "bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4";
|
||||
|
||||
const rows = Object.entries(entries).map(([k, v]) => `
|
||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors group">
|
||||
<tr class="row-edit cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors group"
|
||||
data-key="${escHtml(k)}" data-val="${escHtml(v)}" title="Zum Bearbeiten anklicken">
|
||||
<td class="px-3 py-2 text-xs font-mono text-slate-700 dark:text-slate-300">${escHtml(k)}</td>
|
||||
<td class="px-3 py-2 text-xs text-slate-400">→</td>
|
||||
<td class="px-3 py-2 text-xs text-slate-700 dark:text-slate-300 font-medium">${escHtml(v)}</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<button class="del-btn text-slate-300 dark:text-slate-600 hover:text-red-500 dark:hover:text-red-400 transition-colors text-xs opacity-0 group-hover:opacity-100"
|
||||
data-key="${escHtml(k)}">✕</button>
|
||||
data-key="${escHtml(k)}" title="Löschen">✕</button>
|
||||
</td>
|
||||
</tr>`).join("");
|
||||
|
||||
|
|
@ -885,8 +1146,24 @@ function buildWortSection(title, section, entries, lang) {
|
|||
</form>
|
||||
`;
|
||||
|
||||
const form = wrap.querySelector(".add-form");
|
||||
const [keyInp, valInp] = form.querySelectorAll("input");
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
|
||||
// Zeile anklicken -> Begriffspaar in die Editier-Felder laden, Button wird zu „Speichern".
|
||||
wrap.querySelectorAll("tr.row-edit").forEach((tr) =>
|
||||
tr.addEventListener("click", () => {
|
||||
keyInp.value = tr.dataset.key;
|
||||
valInp.value = tr.dataset.val;
|
||||
form.dataset.editKey = tr.dataset.key; // Originalschlüssel merken (für Umbenennen)
|
||||
submitBtn.textContent = "Speichern";
|
||||
valInp.focus();
|
||||
})
|
||||
);
|
||||
|
||||
wrap.querySelectorAll(".del-btn").forEach((btn) =>
|
||||
btn.addEventListener("click", async () => {
|
||||
btn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation(); // nicht zugleich die Zeile zum Bearbeiten laden
|
||||
const key = btn.dataset.key;
|
||||
if (!confirm(`Eintrag „${key}" löschen?`)) return;
|
||||
const res = await adminFetch(
|
||||
|
|
@ -896,14 +1173,22 @@ function buildWortSection(title, section, entries, lang) {
|
|||
})
|
||||
);
|
||||
|
||||
wrap.querySelector(".add-form").addEventListener("submit", async (e) => {
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const [keyInp, valInp] = e.target.querySelectorAll("input");
|
||||
const key = keyInp.value.trim();
|
||||
const value = valInp.value.trim();
|
||||
if (!key || !value) return;
|
||||
const editKey = form.dataset.editKey || "";
|
||||
// Hinzufügen ODER Aktualisieren (gleicher Schlüssel wird überschrieben; Backend sortiert).
|
||||
const res = await adminFetch(`/api/admin/pronunciation/${lang}`, "POST", { section, key, value });
|
||||
if (res !== null) { keyInp.value = ""; valInp.value = ""; loadWoerterbuch(); }
|
||||
if (res === null) return;
|
||||
// Umbenannt -> alten Schlüssel entfernen (neuer wurde oben bereits angelegt).
|
||||
if (editKey && editKey !== key) {
|
||||
await adminFetch(
|
||||
`/api/admin/pronunciation/${lang}/${section}/${encodeURIComponent(editKey)}`, "DELETE"
|
||||
);
|
||||
}
|
||||
loadWoerterbuch(); // neu laden -> sortierte Liste, Formular zurück auf „+ Hinzufügen"
|
||||
});
|
||||
|
||||
return wrap;
|
||||
|
|
|
|||
BIN
app/web/apple-touch-icon.png
Normal file
BIN
app/web/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
app/web/favicon-32.png
Normal file
BIN
app/web/favicon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
3
app/web/favicon.svg
Normal file
3
app/web/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 44 KiB |
|
|
@ -4,8 +4,17 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>Voice Assistant</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=1" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=1" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=1" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>tailwind.config = { darkMode: "class" };</script>
|
||||
<style>
|
||||
/* Dropdown-Popup (<option>) hat sonst Browser-Default-Weiß -> im Dark-Mode
|
||||
helle Schrift auf hellem Grund = unlesbar. Farben explizit setzen. */
|
||||
select option { background-color: #ffffff; color: #1e293b; } /* slate-800 */
|
||||
.dark select option { background-color: #1e293b; color: #f1f5f9; } /* slate-100 */
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
|
|
@ -137,6 +146,12 @@
|
|||
class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
|
||||
<option value="de">Deutsch (de)</option>
|
||||
<option value="en">Englisch (en)</option>
|
||||
<option value="fr">Französisch (fr)</option>
|
||||
<option value="es">Spanisch (es)</option>
|
||||
<option value="it">Italienisch (it)</option>
|
||||
<option value="nl">Niederländisch (nl)</option>
|
||||
<option value="ru">Russisch (ru)</option>
|
||||
<option value="zh">Chinesisch (zh)</option>
|
||||
</select>
|
||||
<button id="load-words"
|
||||
class="rounded-lg border border-slate-300 dark:border-slate-600 text-sm px-3 py-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">Aktualisieren</button>
|
||||
|
|
@ -185,14 +200,31 @@
|
|||
|
||||
|
||||
<!-- ═══════════════════ CHAT INTERFACE ═══════════════════ -->
|
||||
<header class="shrink-0 h-14 flex items-center gap-3 px-4 border-b border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 backdrop-blur">
|
||||
<header class="shrink-0 h-14 flex items-center gap-2 px-3 border-b border-slate-200 dark:border-slate-700 bg-white/80 dark:bg-slate-800/80 backdrop-blur">
|
||||
<h1 class="flex-1 truncate font-semibold text-base">Voice Assistant</h1>
|
||||
<select id="tts" title="Stimme"
|
||||
<select id="lang-sel" title="Sprache — feste Sprache (Eingabe wird übersetzt) oder 🔄 Flex (folgt automatisch der gesprochenen Sprache)"
|
||||
class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
|
||||
<option value="">Schnell</option>
|
||||
<option value="chatterbox">Hohe Qualität</option>
|
||||
<option value="flex">🔄 Flex</option>
|
||||
<option value="de">🇩🇪 DE</option>
|
||||
<option value="en">🇬🇧 EN</option>
|
||||
<option value="fr">🇫🇷 FR</option>
|
||||
<option value="es">🇪🇸 ES</option>
|
||||
<option value="it">🇮🇹 IT</option>
|
||||
<option value="nl">🇳🇱 NL</option>
|
||||
<option value="ru">🇷🇺 RU</option>
|
||||
<option value="zh">🇨🇳 ZH</option>
|
||||
</select>
|
||||
<select id="tts" title="Sprachqualität"
|
||||
class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
|
||||
<option value="device">📱 Gerät</option>
|
||||
<option value="piper">Schnell</option>
|
||||
<option value="chatterbox">Hoch</option>
|
||||
<option value="openrouter">Cloud</option>
|
||||
</select>
|
||||
<button id="new-chat" title="Neues Gespräch (Verlauf zurücksetzen)"
|
||||
class="h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>
|
||||
</button>
|
||||
<button id="admin-toggle" title="Admin"
|
||||
class="hidden h-9 w-9 grid place-items-center rounded-lg border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors">⚙️</button>
|
||||
<button id="theme-toggle" title="Tag/Nacht"
|
||||
|
|
@ -218,6 +250,6 @@
|
|||
<div id="status" class="w-full max-w-3xl mx-auto mt-1 min-h-[1rem] text-xs text-slate-500 dark:text-slate-400"></div>
|
||||
</footer>
|
||||
|
||||
<script src="/app.js?v=19"></script>
|
||||
<script src="/app.js?v=27"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue