feat(stt): Geräte-STT auf Mobilgeräten (Web Speech API) — getrennt, Datenschutz-Linie C
- Geräte-STT erkennt Sprache lokal und sendet nur Text über den Text-Turn; spart Audio-Upload + Server-STT. Getrennter Schalter (STT ▾) unabhängig vom TTS. - Linie C: nur bei nachweislich lokaler Erkennung (iOS / Chrome on-device); Cloud (z. B. Chrome-Desktop -> Google) nur mit Admin-Flag ALLOW_CLOUD_STT. -> config.allow_cloud_stt, RUNTIME_SETTABLE, /api/me, Admin-Toggle. - Fix-only (SpeechRecognition braucht Sprach-Hint); Flex -> Server-STT-Fallback. Kein/instabiles SpeechRecognition (z. B. Firefox) -> Server-STT. Live-Interim im Eingabefeld. Terminal/Desktop/Laptop unverändert serverseitig (Option nur sichtbar, wenn das Gerät lokale Erkennung bietet). - Tests: /api/me-Flag + runtime-setzbar (169 grün). Doku §5.1.2 + §6.3.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
33d7189418
commit
ab9c4f4938
7 changed files with 150 additions and 4 deletions
|
|
@ -792,7 +792,8 @@ https://va.beispiel.de/ ← remote über Reverse-Proxy (alles, inkl. Mikr
|
||||||
| **Texteingabe + Senden** | Text tippen, dann Enter oder „Senden" |
|
| **Texteingabe + Senden** | Text tippen, dann Enter oder „Senden" |
|
||||||
| **🎤 Mikrofon-Button** | **Idle (grün 🎤):** Tippen → Aufnahme startet · **Aufnahme (rot pulsierend 🎤):** Tippen → Aufnahme stoppt und wird gesendet · **KI antwortet (amber ⏹):** Tippen → Antwort sofort unterbrechen (Barge-in) |
|
| **🎤 Mikrofon-Button** | **Idle (grün 🎤):** Tippen → Aufnahme startet · **Aufnahme (rot pulsierend 🎤):** Tippen → Aufnahme stoppt und wird gesendet · **KI antwortet (amber ⏹):** Tippen → Antwort sofort unterbrechen (Barge-in) |
|
||||||
| **Sprache ▾** | Antwortsprache wählen (→ § 6.6): `🔄 Flex` = folgt automatisch der gesprochenen Sprache · feste Sprache (🇩🇪/🇬🇧/…) = Eingabe wird in diese Sprache übersetzt. Die vorlesende Stimme folgt der Auswahl. |
|
| **Sprache ▾** | Antwortsprache wählen (→ § 6.6): `🔄 Flex` = folgt automatisch der gesprochenen Sprache · feste Sprache (🇩🇪/🇬🇧/…) = Eingabe wird in diese Sprache übersetzt. Die vorlesende Stimme folgt der Auswahl. |
|
||||||
| **Qualität ▾** | Vorlese-Quelle wählen: `📱 Gerät` = das Handy/der Browser liest selbst vor (Web Speech API, kein Server-Audio → spart Daten/Kosten) · `Schnell` = piper (lokal) · `Hoch` = chatterbox (neuronal) · `Cloud` = openrouter |
|
| **Qualität ▾** (Ausgabe/TTS) | Vorlese-Quelle: `📱 Gerät` = das Handy/der Browser liest selbst vor (Web Speech API, kein Server-Audio → spart Daten/Kosten) · `Schnell` = piper (lokal) · `Hoch` = chatterbox (neuronal) · `Cloud` = openrouter |
|
||||||
|
| **STT ▾** (Eingabe) | *Nur sichtbar auf Geräten mit lokaler Spracherkennung* (iOS/Android): `🎙 Gerät` = Spracherkennung im Gerät (nur Text wird gesendet) · `☁ Server` = Audio-Upload + Whisper (wie am PC/Laptop). Getrennt von der Ausgabe wählbar (→ § 6.3.1). |
|
||||||
| **☀️ / 🌙** | Tag-/Nacht-Modus; folgt sonst automatisch dem Betriebssystem |
|
| **☀️ / 🌙** | Tag-/Nacht-Modus; folgt sonst automatisch dem Betriebssystem |
|
||||||
| **⚙️** (Admin) | Öffnet das Admin-Panel — nur für Admin-Nutzer sichtbar (→ § 7.5) |
|
| **⚙️** (Admin) | Öffnet das Admin-Panel — nur für Admin-Nutzer sichtbar (→ § 7.5) |
|
||||||
| **Angemeldet als …** | SSO-Identität; „Gast" wenn AUTH deaktiviert oder kein SSO-Cookie |
|
| **Angemeldet als …** | SSO-Identität; „Gast" wenn AUTH deaktiviert oder kein SSO-Cookie |
|
||||||
|
|
@ -1012,6 +1013,27 @@ FASTER_WHISPER_DEVICE=cuda
|
||||||
FASTER_WHISPER_COMPUTE_TYPE=float16
|
FASTER_WHISPER_COMPUTE_TYPE=float16
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 6.3.1 Geräte-STT (Web Speech API) — „🎙 Gerät"
|
||||||
|
|
||||||
|
Auf **Mobilgeräten** (iOS/Android) kann die Spracherkennung **im Gerät** laufen: Das
|
||||||
|
Handy wandelt Sprache in Text um und sendet nur den **Text** an den Server — kein
|
||||||
|
Audio-Upload, keine Server-STT-Last. Gegenstück zum Geräte-TTS (§ 6.5.0), aber
|
||||||
|
**getrennt** wählbar (STT-Dropdown in der Kopfzeile).
|
||||||
|
|
||||||
|
- **Erscheint nur**, wenn das Gerät lokale Spracherkennung bietet. **Terminal, Desktop-
|
||||||
|
Browser und Laptop** zeigen die Option nicht — dort läuft STT wie bisher über den
|
||||||
|
Server (Whisper).
|
||||||
|
- **Datenschutz (Linie C):** Standardmäßig nur bei **nachweislich lokaler** Erkennung
|
||||||
|
(iOS-Safari; Chrome mit On-Device-Modus). **Chrome am Desktop** würde das Audio zur
|
||||||
|
Erkennung an Google schicken — das ist nur erlaubt, wenn ein Admin
|
||||||
|
`ALLOW_CLOUD_STT=true` setzt (Admin → Einstellungen → „Geräte-STT (mobil)").
|
||||||
|
- **Nur Fix-Sprache:** Geräte-STT braucht einen Sprach-Hint → es nutzt die im
|
||||||
|
Sprach-Dropdown gewählte feste Sprache. Im **Flex-Modus** fällt es automatisch auf
|
||||||
|
Server-STT (Whisper-Auto-Erkennung) zurück.
|
||||||
|
- **Fallback:** Ohne Unterstützung (z. B. Firefox) oder bei Erkennungsfehlern wird der
|
||||||
|
Server-STT-Pfad genutzt. Während der Erkennung erscheint der Text **live** im
|
||||||
|
Eingabefeld; erneutes Tippen aufs Mikrofon beendet und sendet.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 6.4 LLM-Einstellungen (Sprachmodell, lokal)
|
### 6.4 LLM-Einstellungen (Sprachmodell, lokal)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||||
from app.auth import require_user, is_admin_user
|
from app.auth import require_user, is_admin_user
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.dependencies import get_store
|
from app.dependencies import get_store
|
||||||
|
from app.runtime_config import runtime_settings
|
||||||
from app.schemas import UserPrefs, MemoryCreate, MemoryOut
|
from app.schemas import UserPrefs, MemoryCreate, MemoryOut
|
||||||
from app.store import User
|
from app.store import User
|
||||||
|
|
||||||
|
|
@ -18,6 +19,7 @@ async def get_me(user: User = Depends(require_user)):
|
||||||
"is_admin": user.is_admin or is_admin_user(user),
|
"is_admin": user.is_admin or is_admin_user(user),
|
||||||
"prefs": user.prefs,
|
"prefs": user.prefs,
|
||||||
"sso_logout_url": settings.sso_logout_url,
|
"sso_logout_url": settings.sso_logout_url,
|
||||||
|
"allow_cloud_stt": bool(runtime_settings.allow_cloud_stt),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,10 @@ class Settings(BaseSettings):
|
||||||
faster_whisper_model: str = "base" # tiny|base|small|medium|large-v3
|
faster_whisper_model: str = "base" # tiny|base|small|medium|large-v3
|
||||||
faster_whisper_device: str = "auto" # auto|cpu|cuda
|
faster_whisper_device: str = "auto" # auto|cpu|cuda
|
||||||
faster_whisper_compute_type: str = "default" # default|int8|float16|int8_float16
|
faster_whisper_compute_type: str = "default" # default|int8|float16|int8_float16
|
||||||
|
# Geräte-STT (Web Speech API) nur bei nachweislich lokaler Erkennung (iOS, Chrome
|
||||||
|
# on-device). True erlaubt es auch bei Cloud-Erkennung (z. B. Chrome-Desktop ->
|
||||||
|
# Audio geht zu Google). Default False = strenger Datenschutz (Linie C).
|
||||||
|
allow_cloud_stt: bool = False
|
||||||
# --- Lokales TTS (piper) -------------------------------------------------
|
# --- Lokales TTS (piper) -------------------------------------------------
|
||||||
piper_bin: str = "piper" # Pfad/Name des piper-Binaries
|
piper_bin: str = "piper" # Pfad/Name des piper-Binaries
|
||||||
piper_voices_dir: str = str(Path.home() / ".local" / "share" / "piper" / "voices")
|
piper_voices_dir: str = str(Path.home() / ".local" / "share" / "piper" / "voices")
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ RUNTIME_SETTABLE: dict[str, tuple[str, str, str]] = {
|
||||||
"memory_extraction_enabled": ("Erinnerungs-Extraktion", "bool", "true | false"),
|
"memory_extraction_enabled": ("Erinnerungs-Extraktion", "bool", "true | false"),
|
||||||
"memory_extraction_every_n_turns": ("Extraktion alle N Turns", "int", "z.B. 3"),
|
"memory_extraction_every_n_turns": ("Extraktion alle N Turns", "int", "z.B. 3"),
|
||||||
"daily_request_limit": ("Tageskontingent (global)", "int", "0 = unbegrenzt"),
|
"daily_request_limit": ("Tageskontingent (global)", "int", "0 = unbegrenzt"),
|
||||||
|
"allow_cloud_stt": ("Geräte-STT via Cloud erlauben", "bool", "true = auch Cloud-Erkennung (z.B. Chrome-Desktop -> Audio zu Google); false = nur on-device"),
|
||||||
}
|
}
|
||||||
|
|
||||||
_TTL = 30.0
|
_TTL = 30.0
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ const formEl = $("#prompt-form");
|
||||||
const micBtn = $("#mic");
|
const micBtn = $("#mic");
|
||||||
const ttsSel = $("#tts");
|
const ttsSel = $("#tts");
|
||||||
const langSel = $("#lang-sel");
|
const langSel = $("#lang-sel");
|
||||||
|
const sttSel = $("#stt-sel");
|
||||||
|
|
||||||
// "Flex" ist im Sprachmenü nur ein weiterer Wert: keine feste Sprache, sondern
|
// "Flex" ist im Sprachmenü nur ein weiterer Wert: keine feste Sprache, sondern
|
||||||
// automatische Erkennung. Eine konkrete Sprache = Fix-Modus (Eingabe wird übersetzt).
|
// automatische Erkennung. Eine konkrete Sprache = Fix-Modus (Eingabe wird übersetzt).
|
||||||
|
|
@ -103,6 +104,90 @@ function unlockTTS() {
|
||||||
try { speechSynthesis.speak(new SpeechSynthesisUtterance("")); _ttsUnlocked = true; } catch (e) {}
|
try { speechSynthesis.speak(new SpeechSynthesisUtterance("")); _ttsUnlocked = true; } catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Geräte-STT (Web Speech API SpeechRecognition) ----------
|
||||||
|
// Gegenstück zum Geräte-TTS: das Gerät erkennt Sprache lokal und schickt nur Text.
|
||||||
|
// Linie C: standardmäßig nur bei nachweislich lokaler Erkennung (iOS, Chrome on-device);
|
||||||
|
// Cloud-Erkennung (z. B. Chrome-Desktop -> Audio zu Google) nur, wenn der Admin
|
||||||
|
// allow_cloud_stt aktiviert hat. Sonst greift weiter der Server-STT (Whisper).
|
||||||
|
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
const STT_SUPPORTED = !!SR;
|
||||||
|
const STT_KEY = "va-stt"; // geräte-lokal: "device" wenn Geräte-STT gewählt
|
||||||
|
let meAllowCloudStt = false; // aus /api/me (Admin-Flag)
|
||||||
|
|
||||||
|
function isIOS() {
|
||||||
|
const ua = navigator.userAgent || "";
|
||||||
|
return /iPhone|iPad|iPod/i.test(ua) ||
|
||||||
|
(navigator.maxTouchPoints > 1 && /Macintosh/.test(ua));
|
||||||
|
}
|
||||||
|
// Heuristik: iOS erkennt on-device. Chrome mit processLocally-Unterstützung gilt
|
||||||
|
// ebenfalls als lokal. Sonst (Cloud) nur erlaubt, wenn der Admin es freigibt.
|
||||||
|
function sttIsLocal() {
|
||||||
|
if (isIOS()) return true;
|
||||||
|
try { return SR && "processLocally" in SR.prototype; } catch (e) { return false; }
|
||||||
|
}
|
||||||
|
function sttDeviceAllowed() {
|
||||||
|
return STT_SUPPORTED && (sttIsLocal() || meAllowCloudStt);
|
||||||
|
}
|
||||||
|
function isSttDeviceMode() { return !!sttSel && sttSel.value === "device"; }
|
||||||
|
|
||||||
|
// Geräte-STT für DIESEN Turn nutzen? Nur Fix-Sprache (SpeechRecognition braucht einen
|
||||||
|
// Sprach-Hint); im Flex-Modus auf Server-STT (Whisper-Auto-Erkennung) zurückfallen.
|
||||||
|
function useDeviceStt() {
|
||||||
|
return isSttDeviceMode() && sttDeviceAllowed() && (langSel && langSel.value !== "flex");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stellt das STT-Dropdown beim Laden ein: Option ausblenden wenn nicht erlaubt;
|
||||||
|
// sonst lokale Wahl bzw. Mobil-Default.
|
||||||
|
function applySttChoice() {
|
||||||
|
if (!sttSel) return;
|
||||||
|
if (!sttDeviceAllowed()) {
|
||||||
|
sttSel.classList.add("hidden"); // kein Geräte-STT -> Server wie bisher
|
||||||
|
sttSel.value = "server";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sttSel.classList.remove("hidden");
|
||||||
|
const local = localStorage.getItem(STT_KEY);
|
||||||
|
if (local === "device") sttSel.value = "device";
|
||||||
|
else if (local === "server") sttSel.value = "server";
|
||||||
|
else if (isMobile()) { sttSel.value = "device"; localStorage.setItem(STT_KEY, "device"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
let recognition = null;
|
||||||
|
function startDeviceStt() {
|
||||||
|
const rec = new SR();
|
||||||
|
rec.lang = LANG_BCP47[langSel.value] || "de-DE";
|
||||||
|
rec.interimResults = true;
|
||||||
|
rec.continuous = false;
|
||||||
|
try { if (sttIsLocal() && "processLocally" in rec) rec.processLocally = true; } catch (e) {}
|
||||||
|
recognition = rec;
|
||||||
|
let finalText = "";
|
||||||
|
setMicRecording();
|
||||||
|
statusEl.textContent = "höre zu … (zum Stoppen erneut tippen)";
|
||||||
|
rec.onresult = (e) => {
|
||||||
|
let interim = "";
|
||||||
|
for (let i = e.resultIndex; i < e.results.length; i++) {
|
||||||
|
const t = e.results[i][0].transcript;
|
||||||
|
if (e.results[i].isFinal) finalText += t; else interim += t;
|
||||||
|
}
|
||||||
|
promptEl.value = (finalText + interim).trim(); // Live-Feedback im Eingabefeld
|
||||||
|
};
|
||||||
|
rec.onerror = (e) => {
|
||||||
|
recognition = null;
|
||||||
|
setMicIdle();
|
||||||
|
statusEl.textContent = e.error === "no-speech" ? "nichts gehört — bitte erneut"
|
||||||
|
: "Spracherkennung fehlgeschlagen (" + (e.error || "?") + ")";
|
||||||
|
};
|
||||||
|
rec.onend = () => {
|
||||||
|
recognition = null;
|
||||||
|
setMicIdle();
|
||||||
|
const text = (promptEl.value || "").trim();
|
||||||
|
promptEl.value = "";
|
||||||
|
if (text) sendText(text); // -> vorhandener Text-Turn (inkl. Geräte-/Server-TTS)
|
||||||
|
};
|
||||||
|
try { rec.start(); } catch (e) { recognition = null; setMicIdle(); statusEl.textContent = "STT-Start fehlgeschlagen"; }
|
||||||
|
}
|
||||||
|
function stopDeviceStt() { if (recognition) { try { recognition.stop(); } catch (e) {} } }
|
||||||
|
|
||||||
const PLAYBACK_KEY = "va-playback"; // geräte-lokal: "device" wenn Geräte-TTS gewählt
|
const PLAYBACK_KEY = "va-playback"; // geräte-lokal: "device" wenn Geräte-TTS gewählt
|
||||||
|
|
||||||
// Setzt das Qualität-Dropdown beim Laden. Vorrang:
|
// Setzt das Qualität-Dropdown beim Laden. Vorrang:
|
||||||
|
|
@ -167,6 +252,8 @@ function loadMe() {
|
||||||
: (prefs.language || "flex");
|
: (prefs.language || "flex");
|
||||||
}
|
}
|
||||||
applyPlaybackChoice(prefs.tts_provider);
|
applyPlaybackChoice(prefs.tts_provider);
|
||||||
|
meAllowCloudStt = !!me.allow_cloud_stt;
|
||||||
|
applySttChoice();
|
||||||
return me;
|
return me;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$("#identity").textContent = "Verbindung fehlgeschlagen";
|
$("#identity").textContent = "Verbindung fehlgeschlagen";
|
||||||
|
|
@ -525,10 +612,14 @@ micBtn.addEventListener("click", () => {
|
||||||
stopAudio();
|
stopAudio();
|
||||||
statusEl.textContent = "Unterbrochen";
|
statusEl.textContent = "Unterbrochen";
|
||||||
// busy + Button-Reset erfolgen sobald die WS schliesst (-> runTurn resolve -> sendVoice/sendText)
|
// busy + Button-Reset erfolgen sobald die WS schliesst (-> runTurn resolve -> sendVoice/sendText)
|
||||||
|
} else if (recognition) {
|
||||||
|
stopDeviceStt(); // Geräte-STT läuft -> stoppen (onend sendet den Text)
|
||||||
} else if (mediaRecorder && mediaRecorder.state === "recording") {
|
} else if (mediaRecorder && mediaRecorder.state === "recording") {
|
||||||
stopRecording();
|
stopRecording();
|
||||||
|
} else if (useDeviceStt()) {
|
||||||
|
startDeviceStt(); // lokale Spracherkennung (nur Fix + erlaubt)
|
||||||
} else {
|
} else {
|
||||||
startRecording();
|
startRecording(); // Server-STT (Upload) — wie bisher, auch am Desktop
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -589,6 +680,8 @@ if (ttsSel) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// STT-Wahl ist geräte-lokal (nicht server-seitig) -> nur in localStorage.
|
||||||
|
if (sttSel) sttSel.addEventListener("change", () => localStorage.setItem(STT_KEY, sttSel.value));
|
||||||
|
|
||||||
loadMe();
|
loadMe();
|
||||||
|
|
||||||
|
|
@ -1393,6 +1486,8 @@ const FIELD_META = {
|
||||||
labels: { "true":"Aktiviert","false":"Deaktiviert" } },
|
labels: { "true":"Aktiviert","false":"Deaktiviert" } },
|
||||||
memory_extraction_every_n_turns: { ui: "number", min: 1, max: 20 },
|
memory_extraction_every_n_turns: { ui: "number", min: 1, max: 20 },
|
||||||
daily_request_limit: { ui: "number", min: 0 },
|
daily_request_limit: { ui: "number", min: 0 },
|
||||||
|
allow_cloud_stt: { ui: "select", opts: ["true","false"],
|
||||||
|
labels: { "true":"Erlaubt (auch Cloud-Erkennung)","false":"Nur on-device (Datenschutz)" } },
|
||||||
};
|
};
|
||||||
|
|
||||||
const FIELD_GROUPS = [
|
const FIELD_GROUPS = [
|
||||||
|
|
@ -1403,6 +1498,7 @@ const FIELD_GROUPS = [
|
||||||
{ label: "TTS-Verarbeitung", keys: ["tts_normalize_level","audio_stream_default"] },
|
{ label: "TTS-Verarbeitung", keys: ["tts_normalize_level","audio_stream_default"] },
|
||||||
{ label: "Gedächtnis", keys: ["memory_extraction_enabled","memory_extraction_every_n_turns"] },
|
{ label: "Gedächtnis", keys: ["memory_extraction_enabled","memory_extraction_every_n_turns"] },
|
||||||
{ label: "Limits", keys: ["daily_request_limit"] },
|
{ label: "Limits", keys: ["daily_request_limit"] },
|
||||||
|
{ label: "Geräte-STT (mobil)", keys: ["allow_cloud_stt"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
let _settingsData = [];
|
let _settingsData = [];
|
||||||
|
|
|
||||||
|
|
@ -214,13 +214,18 @@
|
||||||
<option value="ru">🇷🇺 RU</option>
|
<option value="ru">🇷🇺 RU</option>
|
||||||
<option value="zh">🇨🇳 ZH</option>
|
<option value="zh">🇨🇳 ZH</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="tts" title="Sprachqualität"
|
<select id="tts" title="Sprachausgabe (TTS)"
|
||||||
class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
|
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="device">📱 Gerät</option>
|
||||||
<option value="piper">Schnell</option>
|
<option value="piper">Schnell</option>
|
||||||
<option value="chatterbox">Hoch</option>
|
<option value="chatterbox">Hoch</option>
|
||||||
<option value="openrouter">Cloud</option>
|
<option value="openrouter">Cloud</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select id="stt-sel" title="Spracherkennung (STT)"
|
||||||
|
class="hidden 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="server">☁ Server</option>
|
||||||
|
</select>
|
||||||
<button id="new-chat" title="Neues Gespräch (Verlauf zurücksetzen)"
|
<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">
|
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>
|
<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>
|
||||||
|
|
@ -250,6 +255,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>
|
<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>
|
</footer>
|
||||||
|
|
||||||
<script src="/app.js?v=31"></script>
|
<script src="/app.js?v=32"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -42,3 +42,19 @@ def test_backend_switch_rejection_is_audited(caplog):
|
||||||
json={"backend": "boese"}, headers=ADM_HDR)
|
json={"backend": "boese"}, headers=ADM_HDR)
|
||||||
assert r.status_code == 422
|
assert r.status_code == 422
|
||||||
assert "action=llm_backend_switch_rejected" in "\n".join(caplog.messages)
|
assert "action=llm_backend_switch_rejected" in "\n".join(caplog.messages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_exposes_allow_cloud_stt():
|
||||||
|
# /api/me liefert das Datenschutz-Flag (Client-Gate für Geräte-STT).
|
||||||
|
d = client.get("/api/me").json()
|
||||||
|
assert "allow_cloud_stt" in d
|
||||||
|
assert isinstance(d["allow_cloud_stt"], bool)
|
||||||
|
|
||||||
|
|
||||||
|
def test_allow_cloud_stt_runtime_settable():
|
||||||
|
from app.runtime_config import RUNTIME_SETTABLE
|
||||||
|
assert "allow_cloud_stt" in RUNTIME_SETTABLE
|
||||||
|
r = client.put("/api/admin/config/allow_cloud_stt", json={"value": "true"}, headers=ADM_HDR)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert client.get("/api/me").json()["allow_cloud_stt"] is True
|
||||||
|
client.delete("/api/admin/config/allow_cloud_stt", headers=ADM_HDR)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue