diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index ccfef77..89cdf38 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -1755,10 +1755,19 @@ Kategorie, Textausschnitt). #### Status Zeigt aktives Profil, Provider-Konfiguration, Laufzeit-Metriken und verfügbare Provider. -Zusätzlich eine **LLM-Backend-Karte** (read-only): aktives Backend (Ollama/llama.cpp), -Modell, ob die Backends laufen, geladene Ollama-Modelle und die **GPU-Auslastung** je -Karte als Balken. Am Ende: **⬇ voice-assistant.db herunterladen** — lädt die -SQLite-Datenbank als Backup. +Zusätzlich eine **LLM-Backend-Karte**: aktives Backend (Ollama/llama.cpp), Modell, ob +die Backends laufen, geladene Ollama-Modelle und die **GPU-Auslastung** je Karte als +Balken. Darunter eine **Steuerung**: Backend wählen (+ Ollama-Modell), **„Backend +wechseln"** und **„Gateway neu starten"**. + +> Sicherheit: Backend ist auf `ollama|llamacpp` beschränkt, Modellnamen werden gegen +> `ollama list` und ein striktes Format geprüft (kein Shell-Zugriff, kein sudo). Der +> Wechsel läuft losgelöst; die Seite pollt, bis das Gateway wieder antwortet. +> **Wirkt vollständig nur, wenn das Gateway als systemd-Dienst läuft** (→ § 4.10) — +> im Vordergrund-Betrieb werden `.env`/Backend umgestellt, der Gateway muss aber manuell +> neu gestartet werden. + +Am Ende: **⬇ voice-assistant.db herunterladen** — lädt die SQLite-Datenbank als Backup. #### Metriken diff --git a/app/admin_llm.py b/app/admin_llm.py index 8a5acf1..8cb21cc 100644 --- a/app/admin_llm.py +++ b/app/admin_llm.py @@ -8,10 +8,18 @@ from __future__ import annotations import asyncio import os +import re import shutil +import subprocess +from pathlib import Path from app.config import settings +ALLOWED_BACKENDS = {"ollama", "llamacpp"} +_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "llm-server" / "switch-llm.sh" +# Erlaubtes Format fuer Ollama-Modellnamen (zusaetzlich zur Pruefung gegen 'ollama list'). +_MODEL_RE = re.compile(r"^[A-Za-z0-9._:/-]{1,100}$") + async def _run(cmd: list[str], timeout: float = 6.0) -> str | None: """Fuehrt ein Kommando aus (shell=False) und liefert stdout, oder None bei Fehler.""" @@ -113,3 +121,69 @@ async def llm_status() -> dict: "gpus": gpus, "gateway_service_active": gw, } + + +# ── Schreibende Steuerung (Backend-Wechsel / Gateway-Neustart) ─────────────── + +class LlmControlError(ValueError): + """Validierungs-/Steuerfehler -> wird vom Endpoint als 400/422 gemeldet.""" + + +async def available_ollama_models() -> list[str]: + """Modellnamen aus `ollama list` (erste Spalte), oder [].""" + out = await _run(["ollama", "list"]) + if out is None: + return [] + names: list[str] = [] + for ln in out.splitlines()[1:]: + ln = ln.strip() + if ln: + names.append(ln.split()[0]) + return names + + +async def switch_backend(backend: str, model: str | None = None) -> dict: + """Validiert streng und startet den Backend-Wechsel als losgelösten Prozess. + + Allowlist: backend ∈ {ollama, llamacpp}. Bei ollama muss `model` (falls gesetzt) + exakt in `ollama list` vorkommen. Nie freie Strings an die Shell — Aufruf mit + fester Argumentliste (shell=False); das Modell geht ausschließlich als Env-Var. + Der Wechsel läuft detached weiter (er startet ggf. das Gateway neu). + """ + if backend not in ALLOWED_BACKENDS: + raise LlmControlError(f"Unbekanntes Backend: {backend!r}") + if not _SCRIPT.exists(): + raise LlmControlError("switch-llm.sh nicht gefunden") + + env = {**os.environ} + if backend == "ollama" and model: + if not _MODEL_RE.match(model): + raise LlmControlError("Ungültiger Modellname") + available = await available_ollama_models() + if available and model not in available: + raise LlmControlError(f"Modell nicht in 'ollama list': {model!r}") + env["OLLAMA_MODEL"] = model + + # Detached starten: der Wechsel kann das Gateway neu starten -> wir würden uns + # sonst selbst killen, bevor die HTTP-Antwort raus ist. + subprocess.Popen( + ["bash", str(_SCRIPT), backend], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return {"status": "switching", "backend": backend, "model": model} + + +def restart_gateway_detached() -> dict: + """Startet das Gateway als systemd-User-Dienst neu (losgelöst, Self-Restart-sicher).""" + xdg = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}") + subprocess.Popen( + ["bash", "-c", + f"sleep 1; XDG_RUNTIME_DIR={xdg} systemctl --user restart voice-assistant.service"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return {"status": "restarting"} diff --git a/app/api/admin.py b/app/api/admin.py index a862d5a..5dfd3fc 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -6,7 +6,12 @@ from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSo from fastapi.responses import FileResponse from pydantic import BaseModel -from app.admin_llm import llm_status +from app.admin_llm import ( + LlmControlError, + llm_status, + restart_gateway_detached, + switch_backend, +) from app.auth import is_admin_user, require_admin, require_admin_or_user from app.config import settings from app.dependencies import get_store @@ -196,6 +201,27 @@ async def get_llm_status(): return await llm_status() +class BackendSwitch(BaseModel): + backend: str + model: str | None = None + + +@router.post("/admin/llm/backend", dependencies=[Depends(require_admin)]) +async def post_llm_backend(payload: BackendSwitch): + """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: + return await switch_backend(payload.backend, payload.model) + except LlmControlError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + +@router.post("/admin/gateway/restart", dependencies=[Depends(require_admin)]) +async def post_gateway_restart(): + """Startet das Gateway (systemd-User-Dienst) neu — losgelöst, Self-Restart-sicher.""" + return restart_gateway_detached() + + # ── Aussprache-Lexikon CRUD ───────────────────────────────────────────────── def _read_pronunciation(lang: str) -> dict: diff --git a/app/web/app.js b/app/web/app.js index f330bf6..3807454 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -995,9 +995,66 @@ function renderLlmCard(llm) {
Hinweis: Wirkt vollständig nur, wenn das Gateway als systemd-Dienst läuft (sonst .env/Backend umgestellt, aber Gateway manuell neu starten).
`; } +// Wartet, bis das Gateway nach einem Neustart wieder antwortet, dann Status neu laden. +async function pollGatewayBack(msgEl, label) { + for (let i = 0; i < 40; i++) { + await new Promise((r) => setTimeout(r, 1500)); + try { + const r = await fetch("/api/config", { cache: "no-store" }); + if (r.ok) { if (msgEl) msgEl.textContent = label + " ✓"; loadStatus(); return; } + } catch (e) { /* noch nicht oben */ } + if (msgEl) msgEl.textContent = label + " … (" + (i + 1) + ")"; + } + if (msgEl) msgEl.textContent = label + " — Zeitüberschreitung, bitte Status manuell prüfen."; +} + +function wireLlmControls() { + const sel = $("#llm-backend-sel"); + const switchBtn = $("#llm-switch-btn"); + const restartBtn = $("#gw-restart-btn"); + const msg = $("#llm-ctrl-msg"); + if (!switchBtn) return; + + switchBtn.addEventListener("click", async () => { + const backend = sel.value; + const model = ($("#llm-model-inp").value || "").trim(); + if (!confirm(`Backend auf „${backend}"${backend === "ollama" && model ? " (" + model + ")" : ""} umstellen?\nDie GPU des anderen Backends wird freigegeben.`)) return; + switchBtn.disabled = true; + msg.textContent = "stelle um …"; + const body = { backend }; + if (backend === "ollama" && model) body.model = model; + const res = await adminFetch("/api/admin/llm/backend", "POST", body); + if (res === null) { msg.textContent = "Fehler (Modell/Backend ungültig?)"; switchBtn.disabled = false; return; } + pollGatewayBack(msg, "Backend gewechselt"); + }); + + restartBtn.addEventListener("click", async () => { + if (!confirm("Gateway-Dienst jetzt neu starten?")) return; + restartBtn.disabled = true; + msg.textContent = "starte neu …"; + const res = await adminFetch("/api/admin/gateway/restart", "POST"); + if (res === null) { msg.textContent = "Fehler beim Neustart"; restartBtn.disabled = false; return; } + pollGatewayBack(msg, "Neu gestartet"); + }); +} + async function loadStatus() { const container = $("#status-content"); container.innerHTML = 'lade …
'; @@ -1074,6 +1131,7 @@ async function loadStatus() { `; + wireLlmControls(); // Buttons der LLM-Karte verdrahten (nach dem Rendern) } catch (e) { console.error("loadStatus error:", e); container.innerHTML = 'Fehler beim Laden.
'; diff --git a/app/web/index.html b/app/web/index.html index f715f87..5a86973 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -250,6 +250,6 @@ - +