2026-06-19 01:15:28 +02:00
|
|
|
import asyncio
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import yaml
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
|
|
|
|
|
from fastapi.responses import FileResponse
|
|
|
|
|
from pydantic import BaseModel
|
2026-06-17 02:14:25 +02:00
|
|
|
|
2026-06-20 18:39:59 +02:00
|
|
|
from app.admin_llm import (
|
|
|
|
|
LlmControlError,
|
|
|
|
|
llm_status,
|
|
|
|
|
restart_gateway_detached,
|
|
|
|
|
switch_backend,
|
|
|
|
|
)
|
2026-06-20 18:50:55 +02:00
|
|
|
from app.audit import log_admin_action
|
2026-06-18 05:19:41 +02:00
|
|
|
from app.auth import is_admin_user, require_admin, require_admin_or_user
|
|
|
|
|
from app.config import settings
|
2026-06-17 02:14:25 +02:00
|
|
|
from app.dependencies import get_store
|
2026-06-19 01:39:36 +02:00
|
|
|
from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache, runtime_settings
|
2026-06-18 17:55:05 +02:00
|
|
|
from app.schemas import MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUpdate
|
2026-06-17 02:14:25 +02:00
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
2026-06-19 01:15:28 +02:00
|
|
|
_CONFIG_DIR = Path(__file__).resolve().parents[2] / "config"
|
|
|
|
|
_ALLOWED_SECTIONS = {"abbreviations", "units", "terms"}
|
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>
2026-06-20 13:12:04 +02:00
|
|
|
_ALLOWED_LANGS = {"de", "en", "fr", "es", "it", "nl", "ru", "zh"}
|
2026-06-19 01:15:28 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class PronunciationEntry(BaseModel):
|
|
|
|
|
section: str
|
|
|
|
|
key: str
|
|
|
|
|
value: str
|
|
|
|
|
|
2026-06-17 02:14:25 +02:00
|
|
|
|
2026-06-18 05:19:41 +02:00
|
|
|
@router.get("/admin/request-headers")
|
|
|
|
|
async def request_headers(request: Request, key: str | None = None):
|
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
|
|
|
"""Discovery: zeigt die eingehenden HTTP-Header + Quell-IP.
|
|
|
|
|
|
|
|
|
|
Hilft, hinter dem Reverse-Proxy/SSO den richtigen Identitaets-Header
|
2026-06-18 05:19:41 +02:00
|
|
|
(TRUSTED_AUTH_HEADER) festzustellen.
|
|
|
|
|
|
|
|
|
|
Henne-Ei: Beim Einrichten kennt das Gateway den SSO-Admin noch nicht. Daher
|
|
|
|
|
ist der Endpoint auch per Admin-Key aufrufbar - als Query (`?key=...`, im Browser
|
|
|
|
|
durchs SSO bequem) oder X-Admin-Key-Header. Nach dem Setup wieder meiden bzw.
|
|
|
|
|
den Key rotieren (er landet sonst in Proxy-Logs).
|
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
|
|
|
"""
|
2026-06-18 05:19:41 +02:00
|
|
|
expected = settings.admin_api_key.strip()
|
|
|
|
|
provided = key or request.headers.get("x-admin-key")
|
|
|
|
|
ok = bool(expected) and provided is not None and provided.strip() == expected
|
|
|
|
|
if not ok and not is_admin_user(_current_user_or_none(request)):
|
|
|
|
|
raise HTTPException(status_code=403, detail="Admin privileges required")
|
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
|
|
|
return {
|
|
|
|
|
"client": request.client.host if request.client else None,
|
|
|
|
|
"headers": dict(request.headers),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 05:19:41 +02:00
|
|
|
def _current_user_or_none(request: Request):
|
|
|
|
|
from app.auth import authenticate, _bearer_token
|
|
|
|
|
|
|
|
|
|
client_host = request.client.host if request.client else ""
|
|
|
|
|
token = _bearer_token(request.headers.get("authorization"))
|
|
|
|
|
return authenticate(request.headers, client_host, token)
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 02:14:25 +02:00
|
|
|
@router.post("/admin/users", response_model=UserCreated, dependencies=[Depends(require_admin)])
|
|
|
|
|
async def create_user(payload: UserCreate):
|
|
|
|
|
"""Legt einen Nutzer an und gibt das Bearer-Token EINMALIG zurueck."""
|
|
|
|
|
user, token = get_store().create_user(payload.display_name)
|
|
|
|
|
return UserCreated(user_id=user.id, display_name=user.display_name, token=token)
|
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
|
|
|
|
|
|
|
|
|
2026-06-18 17:23:25 +02:00
|
|
|
@router.delete("/admin/users/{user_id}", dependencies=[Depends(require_admin)])
|
|
|
|
|
async def delete_user(user_id: str):
|
|
|
|
|
"""Loescht einen Nutzer und alle seine Daten (Sessions, Nachrichten, Erinnerungen,
|
|
|
|
|
Nutzungsdaten). Der anonyme Nutzer kann nicht geloescht werden."""
|
|
|
|
|
try:
|
|
|
|
|
deleted = get_store().delete_user(user_id)
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
|
|
|
if not deleted:
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
|
|
|
|
|
return {"deleted": user_id}
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 17:36:41 +02:00
|
|
|
@router.post("/admin/users/{user_id}/token", response_model=UserCreated, dependencies=[Depends(require_admin)])
|
|
|
|
|
async def reset_token(user_id: str):
|
|
|
|
|
"""Stellt einen neuen Bearer-Token aus; der alte wird sofort ungueltig.
|
|
|
|
|
Der neue Token wird EINMALIG zurueckgegeben und danach nicht mehr angezeigt."""
|
|
|
|
|
result = get_store().reset_token(user_id)
|
|
|
|
|
if result is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
|
|
|
|
|
user, token = result
|
|
|
|
|
return UserCreated(user_id=user.id, display_name=user.display_name, token=token)
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 17:55:05 +02:00
|
|
|
@router.put("/admin/users/{user_id}", dependencies=[Depends(require_admin)])
|
|
|
|
|
async def update_user(user_id: str, payload: UserUpdate):
|
|
|
|
|
"""Aktualisiert den Anzeigenamen eines Nutzers (z. B. nach erstem SSO-Login)."""
|
|
|
|
|
user = get_store().update_display_name(user_id, payload.display_name)
|
|
|
|
|
if user is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
|
|
|
|
|
return {"user_id": user.id, "display_name": user.display_name}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/admin/users/{user_id}/memories", response_model=MemoryOut, dependencies=[Depends(require_admin)])
|
|
|
|
|
async def add_user_memory(user_id: str, payload: MemoryCreate):
|
|
|
|
|
"""Legt eine Erinnerung fuer einen Nutzer an (Admin kann Kontext vorbelegen)."""
|
|
|
|
|
store = get_store()
|
|
|
|
|
if store.get_user(user_id) is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
|
|
|
|
|
memory = store.add_memory(user_id, payload.content)
|
|
|
|
|
return MemoryOut(id=memory.id, content=memory.content, created_at=memory.created_at)
|
|
|
|
|
|
|
|
|
|
|
feat(web): Remote-Web-UI mit Mikrofon + Forward-Auth (YunoHost-SSO)
- Minimale Web-UI (app/web/, vanilla, same-origin -> kein CORS): Text-Prompt +
Mikrofon-Button (Aufnahme im Browser -> /ws/voice -> Antwort wird vorgelesen),
Token-Streaming, PCM-Wiedergabe, Identitaet/Logout/Admin im Menue
- Forward-/Trusted-Header-Auth (app/auth.py): Identitaet aus SSO-Header, nur von
TRUSTED_PROXY_IPS akzeptiert; sonst Token/Anonymous-Fallback. Auto-Provisioning
via store.get_or_create_user_by_external_id (+ external_id-Spalte/Migration)
- /api/me um is_admin + sso_logout_url erweitert; GET /api/admin/users (Liste) und
GET /api/admin/request-headers (SSO-Header-Discovery), Admin-gated
- StaticFiles-Mount; Config: TRUSTED_AUTH_HEADER/_PROXY_IPS, ADMIN_USERS, SSO_LOGOUT_URL
- WS-Auth liest Identitaet aus dem Handshake-Header
- Deploy: nginx-Vorlage (WS-Upgrade!) + deploy/README.md (HTTPS/SSO/Firewall/Discovery)
- Tests: Forward-Auth (Provisioning, Admin-Flag, Proxy-IP-Trust, 401/403, Static)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:37:16 +02:00
|
|
|
@router.get("/admin/users", dependencies=[Depends(require_admin_or_user)])
|
|
|
|
|
async def list_users():
|
|
|
|
|
"""Listet die Nutzer (ohne Secrets). Fuer Admins (SSO/ADMIN_USERS) oder ADMIN_API_KEY."""
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"user_id": u.id,
|
|
|
|
|
"display_name": u.display_name,
|
|
|
|
|
"external_id": u.external_id,
|
|
|
|
|
"created_at": u.created_at,
|
|
|
|
|
}
|
|
|
|
|
for u in get_store().list_users()
|
|
|
|
|
]
|
2026-06-19 01:04:20 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/admin/users/{user_id}/memories", dependencies=[Depends(require_admin)])
|
|
|
|
|
async def get_user_memories(user_id: str):
|
|
|
|
|
"""Gibt alle Erinnerungen eines Nutzers zurueck."""
|
|
|
|
|
store = get_store()
|
|
|
|
|
if store.get_user(user_id) is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.")
|
|
|
|
|
return [
|
|
|
|
|
{"id": m.id, "content": m.content, "created_at": m.created_at}
|
|
|
|
|
for m in store.get_memories(user_id)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/admin/users/{user_id}/memories/{memory_id}", dependencies=[Depends(require_admin)])
|
|
|
|
|
async def delete_user_memory(user_id: str, memory_id: int):
|
|
|
|
|
"""Loescht eine einzelne Erinnerung eines Nutzers."""
|
|
|
|
|
deleted = get_store().delete_memory(user_id, memory_id)
|
|
|
|
|
if not deleted:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Erinnerung nicht gefunden.")
|
|
|
|
|
return {"deleted": memory_id}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/admin/users/{user_id}/sessions", dependencies=[Depends(require_admin)])
|
|
|
|
|
async def list_user_sessions(user_id: str):
|
|
|
|
|
"""Listet alle Sessions eines Nutzers (neueste zuerst)."""
|
|
|
|
|
return get_store().list_sessions_for_user(user_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/admin/sessions/{session_id}/messages", dependencies=[Depends(require_admin)])
|
|
|
|
|
async def get_session_messages(session_id: str, limit: int = 200):
|
|
|
|
|
"""Gibt alle Nachrichten einer Session zurueck (Gespraechs-Browser)."""
|
|
|
|
|
return get_store().get_messages_for_session(session_id, limit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/admin/emergency-events", dependencies=[Depends(require_admin)])
|
|
|
|
|
async def list_emergency_events(limit: int = 50):
|
|
|
|
|
"""Listet alle protokollierten Notfall-Ereignisse (neueste zuerst)."""
|
|
|
|
|
return get_store().list_emergency_events(limit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/admin/users/{user_id}/usage", dependencies=[Depends(require_admin)])
|
|
|
|
|
async def get_user_usage(user_id: str):
|
|
|
|
|
"""Nutzungsstatistik eines Nutzers (letzte 30 Tage)."""
|
|
|
|
|
return get_store().get_usage_for_user(user_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/admin/usage", dependencies=[Depends(require_admin)])
|
|
|
|
|
async def get_all_usage():
|
|
|
|
|
"""Aggregierte Nutzungsstatistik aller Nutzer."""
|
|
|
|
|
return get_store().get_all_usage()
|
2026-06-19 01:15:28 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Datenbank-Export ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@router.get("/admin/db-export", dependencies=[Depends(require_admin)])
|
|
|
|
|
async def export_db():
|
|
|
|
|
"""Laed die SQLite-Datenbank als Datei herunter (Backup)."""
|
|
|
|
|
path = Path(settings.db_path)
|
|
|
|
|
if not path.exists():
|
|
|
|
|
raise HTTPException(status_code=404, detail="Datenbank nicht gefunden.")
|
|
|
|
|
return FileResponse(
|
|
|
|
|
path,
|
|
|
|
|
media_type="application/octet-stream",
|
|
|
|
|
filename="voice-assistant.db",
|
|
|
|
|
headers={"Content-Disposition": 'attachment; filename="voice-assistant.db"'},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-20 18:33:06 +02:00
|
|
|
# ── LLM-/System-Status (read-only) ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@router.get("/admin/llm/status", dependencies=[Depends(require_admin)])
|
|
|
|
|
async def get_llm_status():
|
|
|
|
|
"""Read-only Status: aktives Backend, Modell, GPU-Auslastung, Dienste."""
|
|
|
|
|
return await llm_status()
|
|
|
|
|
|
|
|
|
|
|
2026-06-20 18:39:59 +02:00
|
|
|
class BackendSwitch(BaseModel):
|
|
|
|
|
backend: str
|
|
|
|
|
model: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/admin/llm/backend", dependencies=[Depends(require_admin)])
|
2026-06-20 18:50:55 +02:00
|
|
|
async def post_llm_backend(payload: BackendSwitch, request: Request):
|
2026-06-20 18:39:59 +02:00
|
|
|
"""Wechselt das LLM-Backend (Allowlist-validiert, detached). Greift voll erst,
|
|
|
|
|
wenn das Gateway als systemd-Dienst läuft; sonst muss es manuell neu starten."""
|
|
|
|
|
try:
|
2026-06-20 18:50:55 +02:00
|
|
|
result = await switch_backend(payload.backend, payload.model)
|
2026-06-20 18:39:59 +02:00
|
|
|
except LlmControlError as exc:
|
2026-06-20 18:50:55 +02:00
|
|
|
log_admin_action(request, "llm_backend_switch_rejected",
|
|
|
|
|
backend=payload.backend, model=payload.model, error=str(exc))
|
2026-06-20 18:39:59 +02:00
|
|
|
raise HTTPException(status_code=422, detail=str(exc))
|
2026-06-20 18:50:55 +02:00
|
|
|
log_admin_action(request, "llm_backend_switch",
|
|
|
|
|
backend=payload.backend, model=payload.model)
|
|
|
|
|
return result
|
2026-06-20 18:39:59 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/admin/gateway/restart", dependencies=[Depends(require_admin)])
|
2026-06-20 18:50:55 +02:00
|
|
|
async def post_gateway_restart(request: Request):
|
2026-06-20 18:39:59 +02:00
|
|
|
"""Startet das Gateway (systemd-User-Dienst) neu — losgelöst, Self-Restart-sicher."""
|
2026-06-20 18:50:55 +02:00
|
|
|
log_admin_action(request, "gateway_restart")
|
2026-06-20 18:39:59 +02:00
|
|
|
return restart_gateway_detached()
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 01:15:28 +02:00
|
|
|
# ── Aussprache-Lexikon CRUD ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
def _read_pronunciation(lang: str) -> dict:
|
|
|
|
|
path = _CONFIG_DIR / f"pronunciation.{lang}.yaml"
|
|
|
|
|
if not path.exists():
|
|
|
|
|
return {"abbreviations": {}, "units": {}, "terms": {}}
|
|
|
|
|
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
|
|
|
return {
|
|
|
|
|
"abbreviations": dict(data.get("abbreviations") or {}),
|
|
|
|
|
"units": dict(data.get("units") or {}),
|
|
|
|
|
"terms": dict(data.get("terms") or {}),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _write_pronunciation(lang: str, data: dict) -> None:
|
|
|
|
|
path = _CONFIG_DIR / f"pronunciation.{lang}.yaml"
|
|
|
|
|
path.write_text(
|
|
|
|
|
yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
# LRU-Cache des Normalizers invalidieren, damit die Aenderung sofort greift.
|
|
|
|
|
from app.pipeline.tts_normalizer import _load_lexicon
|
|
|
|
|
_load_lexicon.cache_clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/admin/pronunciation/{lang}", dependencies=[Depends(require_admin)])
|
|
|
|
|
async def get_pronunciation(lang: str):
|
|
|
|
|
"""Gibt alle Eintraege des Aussprache-Lexikons zurueck."""
|
|
|
|
|
if lang not in _ALLOWED_LANGS:
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Sprache muss eine von {_ALLOWED_LANGS} sein.")
|
|
|
|
|
return _read_pronunciation(lang)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/admin/pronunciation/{lang}", dependencies=[Depends(require_admin)])
|
|
|
|
|
async def add_pronunciation(lang: str, entry: PronunciationEntry):
|
|
|
|
|
"""Fuegt einen Eintrag zum Aussprache-Lexikon hinzu oder ueberschreibt ihn."""
|
|
|
|
|
if lang not in _ALLOWED_LANGS:
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Sprache muss eine von {_ALLOWED_LANGS} sein.")
|
|
|
|
|
if entry.section not in _ALLOWED_SECTIONS:
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Section muss eine von {_ALLOWED_SECTIONS} sein.")
|
|
|
|
|
if not entry.key.strip() or not entry.value.strip():
|
|
|
|
|
raise HTTPException(status_code=422, detail="key und value duerfen nicht leer sein.")
|
|
|
|
|
data = _read_pronunciation(lang)
|
|
|
|
|
data[entry.section][entry.key.strip()] = entry.value.strip()
|
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>
2026-06-20 13:12:04 +02:00
|
|
|
# Nach jedem Einfügen: Sektion alphabetisch aufsteigend nach Schlüssel sortieren.
|
|
|
|
|
data[entry.section] = dict(
|
|
|
|
|
sorted(data[entry.section].items(), key=lambda kv: kv[0].lower())
|
|
|
|
|
)
|
2026-06-19 01:15:28 +02:00
|
|
|
_write_pronunciation(lang, data)
|
|
|
|
|
return {"section": entry.section, "key": entry.key.strip(), "value": entry.value.strip()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/admin/pronunciation/{lang}/{section}/{key:path}", dependencies=[Depends(require_admin)])
|
|
|
|
|
async def delete_pronunciation(lang: str, section: str, key: str):
|
|
|
|
|
"""Loescht einen Eintrag aus dem Aussprache-Lexikon."""
|
|
|
|
|
if lang not in _ALLOWED_LANGS:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Unbekannte Sprache.")
|
|
|
|
|
if section not in _ALLOWED_SECTIONS:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Unbekannte Section.")
|
|
|
|
|
data = _read_pronunciation(lang)
|
|
|
|
|
if key not in data[section]:
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Eintrag '{key}' nicht gefunden.")
|
|
|
|
|
del data[section][key]
|
|
|
|
|
_write_pronunciation(lang, data)
|
|
|
|
|
return {"deleted": key}
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 01:39:36 +02:00
|
|
|
# ── Laufzeit-Konfiguration ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class ConfigValue(BaseModel):
|
|
|
|
|
value: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/admin/config", dependencies=[Depends(require_admin)])
|
|
|
|
|
async def get_runtime_config():
|
|
|
|
|
"""Gibt alle überschreibbaren Einstellungen mit aktuellem Wert zurück."""
|
|
|
|
|
overrides = get_store().get_config_overrides()
|
|
|
|
|
result = []
|
|
|
|
|
for key, (label, type_str, hint) in RUNTIME_SETTABLE.items():
|
|
|
|
|
base_val = getattr(settings, key, None)
|
|
|
|
|
effective_val = getattr(runtime_settings, key, base_val)
|
|
|
|
|
result.append({
|
|
|
|
|
"key": key,
|
|
|
|
|
"label": label,
|
|
|
|
|
"type": type_str,
|
|
|
|
|
"hint": hint,
|
|
|
|
|
"base_value": str(base_val) if base_val is not None else "",
|
|
|
|
|
"override_value": overrides.get(key),
|
|
|
|
|
"effective_value": str(effective_val) if effective_val is not None else "",
|
|
|
|
|
"is_overridden": key in overrides,
|
|
|
|
|
})
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/admin/config/{key}", dependencies=[Depends(require_admin)])
|
2026-06-20 18:50:55 +02:00
|
|
|
async def set_runtime_config(key: str, body: ConfigValue, request: Request):
|
2026-06-19 01:39:36 +02:00
|
|
|
"""Setzt eine Laufzeit-Einstellung (wirkt sofort, kein Neustart nötig)."""
|
|
|
|
|
if key not in RUNTIME_SETTABLE:
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}")
|
2026-06-20 18:50:55 +02:00
|
|
|
value = body.value.strip()
|
|
|
|
|
get_store().set_config_override(key, value)
|
2026-06-19 01:39:36 +02:00
|
|
|
invalidate_cache()
|
2026-06-20 18:50:55 +02:00
|
|
|
log_admin_action(request, "config_set", key=key, value=value)
|
|
|
|
|
return {"key": key, "value": value}
|
2026-06-19 01:39:36 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/admin/config/{key}", dependencies=[Depends(require_admin)])
|
2026-06-20 18:50:55 +02:00
|
|
|
async def delete_runtime_config(key: str, request: Request):
|
2026-06-19 01:39:36 +02:00
|
|
|
"""Entfernt eine Laufzeit-Einstellung (fällt auf .env-Wert zurück)."""
|
|
|
|
|
if key not in RUNTIME_SETTABLE:
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Nicht überschreibbar: {key!r}")
|
|
|
|
|
deleted = get_store().delete_config_override(key)
|
|
|
|
|
invalidate_cache()
|
|
|
|
|
if not deleted:
|
|
|
|
|
raise HTTPException(status_code=404, detail=f"Kein Override für {key!r} gesetzt.")
|
2026-06-20 18:50:55 +02:00
|
|
|
log_admin_action(request, "config_reset", key=key)
|
2026-06-19 01:39:36 +02:00
|
|
|
return {"deleted": key}
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 01:15:28 +02:00
|
|
|
# ── Live-Log (journalctl → WebSocket) ──────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@router.websocket("/admin/log")
|
|
|
|
|
async def admin_log_ws(websocket: WebSocket, key: str | None = None):
|
|
|
|
|
"""Streamt den systemd-Journal-Log des Voice-Assistant-Service live."""
|
|
|
|
|
# Auth: Admin-Key als Query-Param ODER SSO-Identitaet via Cookie/Header.
|
|
|
|
|
from app.auth import authenticate, _bearer_token
|
|
|
|
|
client_host = websocket.client.host if websocket.client else ""
|
|
|
|
|
token = _bearer_token(websocket.headers.get("authorization")) or key
|
|
|
|
|
user = authenticate(websocket.headers, client_host, token)
|
|
|
|
|
if not is_admin_user(user):
|
|
|
|
|
await websocket.close(code=1008)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
await websocket.accept()
|
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>
2026-06-20 13:12:04 +02:00
|
|
|
|
|
|
|
|
# Hinweis, falls die laufende Instanz NICHT der systemd-Dienst ist (z. B. manueller
|
|
|
|
|
# `uvicorn --reload`-Start). Dann hat das Journal dieser Unit keine aktuellen Zeilen,
|
|
|
|
|
# und der Log-Tab bliebe sonst kommentarlos leer.
|
|
|
|
|
try:
|
|
|
|
|
check = await asyncio.create_subprocess_exec(
|
2026-06-25 02:27:18 +02:00
|
|
|
"systemctl", "is-active", "voice-assistant.service",
|
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>
2026-06-20 13:12:04 +02:00
|
|
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
|
|
|
|
)
|
|
|
|
|
out, _ = await check.communicate()
|
|
|
|
|
if out.decode().strip() != "active":
|
|
|
|
|
await websocket.send_text(
|
|
|
|
|
"⚠ Der systemd-Dienst 'voice-assistant.service' ist nicht aktiv — "
|
|
|
|
|
"die laufende Instanz wurde vermutlich manuell gestartet (uvicorn --reload). "
|
|
|
|
|
"Live-Logs erscheinen hier nur, wenn das Gateway als Dienst läuft "
|
2026-06-25 02:27:18 +02:00
|
|
|
"(sudo systemctl start voice-assistant.service). Manuelle Starts loggen ins Terminal."
|
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>
2026-06-20 13:12:04 +02:00
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
|
2026-06-19 01:15:28 +02:00
|
|
|
proc = await asyncio.create_subprocess_exec(
|
2026-06-25 02:27:18 +02:00
|
|
|
"journalctl", "-f", "-u", "voice-assistant.service",
|
2026-06-19 01:15:28 +02:00
|
|
|
"-n", "100", "--no-pager", "-o", "short",
|
|
|
|
|
stdout=asyncio.subprocess.PIPE,
|
|
|
|
|
stderr=asyncio.subprocess.STDOUT,
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
while True:
|
|
|
|
|
line = await proc.stdout.readline()
|
|
|
|
|
if not line:
|
|
|
|
|
break
|
|
|
|
|
await websocket.send_text(line.decode("utf-8", "replace").rstrip())
|
|
|
|
|
except (WebSocketDisconnect, Exception):
|
|
|
|
|
pass
|
|
|
|
|
finally:
|
|
|
|
|
try:
|
|
|
|
|
proc.terminate()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|