From a84220725a482d59c88795c36bec43a37fba8876 Mon Sep 17 00:00:00 2001 From: Mike Date: Thu, 20 Aug 2026 07:48:25 +0200 Subject: [PATCH] Vision: Q3-Augen-Orchestrierung + Folgefragen-Sanitize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Automatische Vision-Orchestrierung: temporärer Q3-Vision-Server (llama.cpp + mmproj) analysiert Bilder, Hauptmodell (Fast/Medium/Long) erzeugt die finale Antwort. Zentrale GPU-Lock (Ausschluss mit FLUX), Drain, Timeouts, Restore in jedem Fehlerfall. - Vision-Analyse wird als interne User-Message injiziert (nie als Assistant-Turn in Open WebUI). - Analyse-Cache (LRU, keyed by Bild-Hash): Folgefragen triggern keinen neuen Hotswap. - Sanitize: alle Bild-Parts (image_url/Base64) werden bei jedem Request durch die gecachte Analyse ersetzt, Bilddaten entfernt - das Nicht-Vision-Hauptmodell bekommt keine Bilder mehr (kein image input is not supported). - /v1/streams/lookup fail-fast während Vision-Swap; /props-Regression behoben; POST /vision/test für direkten Test. - systemd-Unit: VISION_*-Umgebungsvariablen; README dokumentiert Vision. --- README.md | 47 ++ deploy/mike-ai-profile-router.service | 10 + router/ai_profile_router.py | 612 +++++++++++++++++++++++++- 3 files changed, 665 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5aecc46..cd089cb 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ Sprachausgabe bereit (XTTS-v2, CPU-only, OpenAI-kompatibel). | `POST /v1/audio/transcriptions` | Deutsche Spracherkennung (whisper.cpp, OpenAI-kompatibel) | | `GET /v1/audio/models` | Verfügbare Audio-Modelle (STT + TTS) | | `GET /v1/audio/voices` | Verfügbare TTS-Stimmen | +| `POST /vision/test` | Direkter Vision-Test (Bild + Frage → Q3-Analyse) | | alles andere | Transparente Weiterleitung an llama.cpp | ### Verhalten @@ -174,6 +175,42 @@ VRAM-Check). Das Venv liegt unter `/opt/mike-ai/ai-profile-router/venv/` und wird von `install.sh` automatisch angelegt/aktualisiert. +## Vision (Q3 "Augen") + +Bilder in `POST /v1/chat/completions` werden automatisch analysiert, ohne +dass das Hauptmodell (Fast/Medium/Long) ein Vision-Modell braucht: + +1. **Neues Bild** (in der letzten User-Message, noch nicht analysiert): + Der Router entlädt das Hauptprofil, startet kurzzeitig einen + Q3-Vision-Server (llama.cpp + mmproj, Port `VISION_PORT`), analysiert + das Bild und stellt das Hauptprofil wieder her. Die Analyse wird im + internen Cache (keyed by Bild-Hash) gespeichert. +2. **Finale Antwort**: Das (wiederhergestellte) Hauptmodell erzeugt die + sichtbare Antwort. Die Vision-Analyse wird als interne User-Message + injiziert – sie erscheint **nie** als Assistant-Turn in Open WebUI. +3. **Folgefragen**: Open WebUI schickt den multimodalen Verlauf erneut. + Der Router ersetzt **alle** Bild-Parts durch die gecachte Analyse + (klar gekennzeichnet) und entfernt Base64/URLs komplett – das + Nicht-Vision-Hauptmodell bekommt also keine Bilddaten mehr + (kein `image input is not supported`). Bereits analysierte Bilder + triggern **keinen** neuen Hotswap (Cache-Treffer). + +- **Zentrale GPU-Lock**: Vision und FLUX schließen sich gegenseitig aus + (kein gleichzeitiges Modell-Laden). +- **Fehlerbehandlung**: Bei jedem Fehler wird das Hauptprofil + wiederhergestellt; `finally` dient nur als Cleanup-Sicherung. +- **Test**: `POST /vision/test` mit `{"image_url": "...", "question": "..."}` + liefert die Analyse direkt (ohne finale Hauptmodell-Antwort). + +### Verhalten während eines Vision-Jobs + +- `GET /status` → `vision.phase` (`idle`, `stopping-main`, + `loading-vision`, `analyzing`, `unloading-vision`, `restoring-main`) + und `vision.analysis_cache_size`. +- `/v1/streams/lookup` antwortet fail-fast (`[]`), statt zu blockieren. +- Timing-Log: `Vision-Timing: main_unload=… vision_load=… vision_infer=… + vision_unload=… main_restore=… | Gesamt … s`. + ## Sprachausgabe (XTTS-v2, CPU-only) Der Router stellt lokale Sprachausgabe bereit. Die Synthese läuft @@ -433,6 +470,16 @@ Die Installation ist idempotent (Update = erneut ausführen). | `TTS_WORKER_URL` | `http://127.0.0.1:8085` | TTS-Worker (Router-Seite) | | `TTS_TIMEOUT` | `300` | Timeout pro Synthese (s) | | `TTS_CONNECT_TIMEOUT` | `5` | Connect-Timeout TTS-Worker (s) | +| `VISION_MODEL` | `/opt/mike-ai/models/qwen3.8-27b/Qwen3.8-27B-Q3_K_M.gguf` | Q3-Vision-Modell | +| `VISION_MMPROJ` | `/opt/mike-ai/models/qwen3.8-27b-nvfp4/mmproj-BF16.gguf` | Vision-Projektor | +| `VISION_CTX` | `32768` | Kontext des Vision-Servers | +| `VISION_PORT` | `8086` | Port des Vision-Servers (nur lokal) | +| `VISION_LOAD_TIMEOUT` | `300` | Warten auf Vision-Ready (s) | +| `VISION_INFER_TIMEOUT` | `300` | Timeout pro Vision-Inferenz (s) | +| `VISION_UNLOAD_TIMEOUT` | `120` | Warten auf VRAM-Freiheit (s) | +| `VISION_MAX_TOKENS` | `4096` | Max. Tokens der Vision-Analyse | +| `VISION_CACHE_MAX` | `64` | Größe des Analyse-Caches (LRU) | +| `VISION_LOG` | `/opt/mike-ai/ai-profile-router/vision_server.log` | Vision-Server-Log | TTS-Worker (`mike-ai-xtts.service`): diff --git a/deploy/mike-ai-profile-router.service b/deploy/mike-ai-profile-router.service index 1a7e021..defdd94 100644 --- a/deploy/mike-ai-profile-router.service +++ b/deploy/mike-ai-profile-router.service @@ -22,6 +22,16 @@ Environment=IMAGE_DIR=/opt/mike-ai/ai-profile-router/images Environment=IMAGE_WORKER_LOG=/opt/mike-ai/ai-profile-router/worker.log Environment=IMAGE_GEN_TIMEOUT=600 Environment=IMAGE_VRAM_FREE_TIMEOUT=120 +Environment=LLAMA_SERVER_BIN=/opt/mike-ai/llama.cpp-nvfp4/build/bin/llama-server +Environment=VISION_MODEL=/opt/mike-ai/models/qwen3.8-27b/Qwen3.8-27B-Q3_K_M.gguf +Environment=VISION_MMPROJ=/opt/mike-ai/models/qwen3.8-27b-nvfp4/mmproj-BF16.gguf +Environment=VISION_CTX=32768 +Environment=VISION_PORT=8086 +Environment=VISION_LOAD_TIMEOUT=300 +Environment=VISION_INFER_TIMEOUT=300 +Environment=VISION_UNLOAD_TIMEOUT=120 +Environment=VISION_MAX_TOKENS=4096 +Environment=VISION_LOG=/opt/mike-ai/ai-profile-router/vision_server.log NoNewPrivileges=true PrivateTmp=true diff --git a/router/ai_profile_router.py b/router/ai_profile_router.py index 16b14ba..381f2cf 100755 --- a/router/ai_profile_router.py +++ b/router/ai_profile_router.py @@ -39,6 +39,15 @@ das Modell wieder; danach wird das vorherige Qwen-Profil wiederher- gestellt und erst dann geantwortet (try/finally – Qwen wird auch bei Fehlgeschlagener Generierung wiederhergestellt). +Vision-Orchestrierung (Q3 "Augen", temporär): + POST /v1/chat/completions mit Bild im letzten + User-Message → ein temporäres Q3-Vision-Modell + (llama-server + mmproj) wird geladen, analysiert + das Bild und wird wieder entladen; das Hauptprofil + wird immer wiederhergestellt (try/finally) und + erzeugt die Endantwort. Die Vision-Analyse bleibt + intern (kein sichtbarer Assistant-Turn). + Nur Python-Standardbibliothek. Logging nach stdout (journald). """ @@ -46,6 +55,7 @@ from __future__ import annotations import base64 import email +import hashlib import json import logging import os @@ -57,6 +67,7 @@ import threading import time import uuid import http.client +from collections import OrderedDict from email.parser import BytesParser from email.policy import compat32 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -92,6 +103,27 @@ IMAGE_START_TIMEOUT = float(os.environ.get("IMAGE_START_TIMEOUT", "120")) # s, IMAGE_GEN_TIMEOUT = float(os.environ.get("IMAGE_GEN_TIMEOUT", "1800")) # s, pro Bild IMAGE_VRAM_FREE_TIMEOUT = float(os.environ.get("IMAGE_VRAM_FREE_TIMEOUT", "90")) # s, VRAM-Abgabe +# --- Vision-Orchestrierung (Q3 "Augen", temporär) --- +# Chat-Requests mit Bild im letzten User-Message lösen einen +# temporären Hotswap aus: Hauptprofil raus → Q3+mmproj rein → +# Bild analysieren → Q3 raus → Hauptprofil rein (try/finally). +LLAMA_SERVER_BIN = os.environ.get( + "LLAMA_SERVER_BIN", "/opt/mike-ai/llama.cpp-nvfp4/build/bin/llama-server") +VISION_MODEL = os.environ.get( + "VISION_MODEL", "/opt/mike-ai/models/qwen3.8-27b/Qwen3.8-27B-Q3_K_M.gguf") +VISION_MMPROJ = os.environ.get( + "VISION_MMPROJ", "/opt/mike-ai/models/qwen3.8-27b-nvfp4/mmproj-BF16.gguf") +VISION_CTX = int(os.environ.get("VISION_CTX", "32768")) +VISION_PORT = int(os.environ.get("VISION_PORT", "8086")) +VISION_ALIAS = "qwen38-27b-q3-vision" +VISION_LOAD_TIMEOUT = float(os.environ.get("VISION_LOAD_TIMEOUT", "300")) # s +VISION_INFER_TIMEOUT = float(os.environ.get("VISION_INFER_TIMEOUT", "300")) # s +VISION_UNLOAD_TIMEOUT = float(os.environ.get("VISION_UNLOAD_TIMEOUT", "120")) # s +VISION_MAX_TOKENS = int(os.environ.get("VISION_MAX_TOKENS", "4096")) +VISION_CACHE_MAX = int(os.environ.get("VISION_CACHE_MAX", "64")) +VISION_LOG = os.environ.get( + "VISION_LOG", "/opt/mike-ai/ai-profile-router/vision_server.log") + # Erlaubte Auflösungen (Breite x Höhe). FLUX.2 klein ist für 1 MP # ausgelegt; 1920x1088 (≈2 MP) wird zusätzlich unterstützt. IMAGE_SIZES = { @@ -176,18 +208,40 @@ IMAGE_PHASES = ( ) +class _VisionState: + """Zustand der Vision-Orchestrierung (Status-Reporting + Analyse-Cache).""" + def __init__(self) -> None: + self.phase = "idle" # siehe VISION_PHASES unten + self.last_error: str | None = None + self.last_turnaround: float | None = None # s, letzter kompletter Swap + self.last_profile: str | None = None # Profil, das gesichert wurde + # Cache: stabiler Bild-Hash → Vision-Analyse-Text. Folgefragen im + # selben Chat (Open WebUI schickt den multimodalen Verlauf erneut) + # senden das Bild NICHT erneut durch Q3, sondern verwenden die + # gecachte Analyse. LRU-begrenzt auf VISION_CACHE_MAX Einträge. + self.analysis_cache: "OrderedDict[str, str]" = OrderedDict() + self.cache_lock = threading.Lock() + + +VISION_PHASES = ( + "idle", "stopping-main", "loading-vision", "analyzing", + "unloading-vision", "restoring-main", +) + + class _State: """Gemeinsamer, thread-sicherer Zustand. - lock : zentraler GPU-/Model-Lock. Wird von Profilwechsel UND - Image-Generation gehalten → gegenseitiger Ausschluss, - kein Race zwischen beiden. + lock : zentraler GPU-/Model-Lock. Wird von Profilwechsel, + Image-Generation UND Vision-Swap gehalten → + gegenseitiger Ausschluss, kein Race zwischen beiden. avail_lock : schützt qwen_unavailable + active_chats (Chat-Waiting). """ - lock = threading.Lock() # GPU-/Model-Lock (Profilwechsel + Image) + lock = threading.Lock() # GPU-/Model-Lock (Profilwechsel + Image + Vision) switching: str | None = None # Profil, das gerade gewechselt wird started = time.time() image = _ImageState() + vision = _VisionState() # Qwen-Verfügbarkeit für das Chat-Waiting: qwen_unavailable = False # True, wenn Qwen down/neu geladen wird active_chats = 0 # Anzahl laufender Chat-Requests @@ -744,6 +798,446 @@ def _image_filename_ok(name: str) -> bool: return bool(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*\.png", name)) +# --------------------------------------------------------------------------- +# Vision-Orchestrierung (Q3 "Augen", temporär) +# --------------------------------------------------------------------------- + +VISION_ANALYST_PROMPT = ( + "Du bist ein reiner Bild- und Screenshot-Analyst. Du beantwortest die " + "Benutzerfrage NICHT selbst. Du extrahierst aus dem Bild alle " + "Informationen, die für die Beantwortung relevant sein könnten.\n\n" + "Antworte NUR mit einer strukturierten Analyse in dieser Form:\n" + "1. SIEHTBARER TEXT: alle Texte wörtlich und vollständig, mit Anordnung\n" + "2. UI-ELEMENTE: Felder, Buttons, Menüs, Tabs, Dropdowns, Checkboxen – " + "mit Namen, Werten und Zustand (aktiv/inaktiv, gefüllt/leer, ausgewählt)\n" + "3. FEHLER- UND WARNMELDUNGEN: wörtlich, mit Farbe und Position\n" + "4. POSITIONEN UND BEZIEHUNGEN: räumliche Anordnung (oben/unten, " + "links/rechts, Reihenfolge)\n" + "5. ZUSTÄNDE: Statusanzeigen, Farben (rot/grün/gelb), Ladezustände\n" + "6. OBJEKTE: relevante Objekte und Beziehungen zwischen Elementen\n" + "7. WEITERES: alles Weitere, was für die Benutzerfrage relevant sein " + "könnte\n\n" + "Regeln: Bei Screenshots hat Text- und UI-Genauigkeit Vorrang vor " + "schöner Beschreibung. Keine Interpretation, keine Vermutungen – nur " + "was sichtbar ist. Unleserliches als [unleserlich] markieren." +) + +IMAGE_PLACEHOLDER = "[Bild angehänggt – siehe Vision-Analyse]" + + +class _VisionServer: + """Temporärer llama-server (Q3 + mmproj) für die Bildanalyse.""" + + def __init__(self) -> None: + self.proc: subprocess.Popen | None = None + self._logf = None + + def alive(self) -> bool: + return self.proc is not None and self.proc.poll() is None + + def start(self) -> None: + if self.alive(): + return + cmd = [ + LLAMA_SERVER_BIN, + "--model", VISION_MODEL, + "--mmproj", VISION_MMPROJ, + "--alias", VISION_ALIAS, + "--ctx-size", str(VISION_CTX), + "--flash-attn", "on", + "--cache-type-k", "q4_0", + "--cache-type-v", "q4_0", + "--threads", "6", + "--threads-batch", "6", + "--batch-size", "64", + "--ubatch-size", "32", + "--parallel", "1", + "--jinja", + "--host", "127.0.0.1", + "--port", str(VISION_PORT), + "--metrics", + "--fit", "off", + "--n-gpu-layers", "all", + "--mmproj-offload", + "--no-mmap", + "--temperature", "0.2", + "--top-p", "0.8", + "--top-k", "20", + "--device", "CUDA0", + "--split-mode", "none", + ] + self._logf = open(VISION_LOG, "ab") + self.proc = subprocess.Popen( + cmd, stdin=subprocess.DEVNULL, + stdout=self._logf, stderr=subprocess.STDOUT) + log.info("Vision-Server gestartet (PID %d, Port %d, ctx %d)", + self.proc.pid, VISION_PORT, VISION_CTX) + + def wait_ready(self, deadline: float) -> None: + """Wartet, bis der Vision-Server das Modell mit erwartetem ctx meldet.""" + while True: + try: + conn = http.client.HTTPConnection("127.0.0.1", VISION_PORT, + timeout=3) + conn.request("GET", "/v1/models") + resp = conn.getresponse() + data = json.loads(resp.read()) + conn.close() + models = data.get("data") or [] + if models and (models[0].get("meta") or {}).get("n_ctx") == VISION_CTX: + return + except (OSError, ValueError): + pass + if not self.alive(): + raise RuntimeError( + "Vision-Server-Prozess beendet sich während des Ladens " + f"(Details: {VISION_LOG})") + if time.monotonic() > deadline: + raise RuntimeError( + f"Vision-Server nach {VISION_LOAD_TIMEOUT:.0f} s nicht " + f"bereit (Details: {VISION_LOG})") + time.sleep(2) + + def stop(self) -> None: + if self.proc is not None and self.proc.poll() is None: + self.proc.terminate() + try: + self.proc.wait(timeout=30) + except subprocess.TimeoutExpired: + log.warning("Vision-Server reagiert nicht auf SIGTERM – SIGKILL") + self.proc.kill() + try: + self.proc.wait(timeout=10) + except subprocess.TimeoutExpired: + pass + if self._logf is not None: + try: + self._logf.close() + except OSError: + pass + self._logf = None + self.proc = None + + +def _extract_last_user_image(data: dict) -> tuple[str | None, str]: + """Liefert (image_url, question) aus der letzten User-Message. + + Nur Bilder in der LETZTEN User-Message lösen eine Vision-Analyse aus. + Bilder in früheren Nachrichten sind bereits durch die vorherige + Antwort abgedeckt (Chat-Historie) und werden nur durch einen + Platzhalter ersetzt. + """ + messages = data.get("messages") + if not isinstance(messages, list): + return None, "" + for msg in reversed(messages): + if not isinstance(msg, dict) or msg.get("role") != "user": + continue + content = msg.get("content") + if isinstance(content, str): + return None, content + if isinstance(content, list): + image_url: str | None = None + texts: list[str] = [] + for part in content: + if not isinstance(part, dict): + continue + if part.get("type") == "image_url": + iu = part.get("image_url") + url = iu.get("url") if isinstance(iu, dict) else iu + if isinstance(url, str) and url: + image_url = url + elif (part.get("type") == "text" + and isinstance(part.get("text"), str)): + texts.append(part["text"]) + question = " ".join(t.strip() for t in texts if t.strip()) + return image_url, question + return None, "" + + +def _image_hash(image_url: str) -> str: + """Stabiler Hash für ein Bild (Base64-Payload oder URL). + + Dasselbe Bild → derselbe Hash (unabhängig von Chat/Turn). Dient als + Schlüssel für den Vision-Analyse-Cache. + """ + if image_url.startswith("data:"): + payload = image_url.split(",", 1)[1] if "," in image_url else "" + try: + raw = base64.b64decode(payload) + return "img:" + hashlib.sha256(raw).hexdigest()[:32] + except (ValueError, TypeError): + return "b64:" + hashlib.sha256(payload.encode()).hexdigest()[:32] + return "url:" + hashlib.sha256(image_url.encode()).hexdigest()[:32] + + +def _vision_cached_analysis(img_hash: str) -> str | None: + """Liefert die gecachte Vision-Analyse für einen Bild-Hash (oder None).""" + with STATE.vision.cache_lock: + return STATE.vision.analysis_cache.get(img_hash) + + +def _vision_store_analysis(img_hash: str, analysis: str) -> None: + """Speichert eine Vision-Analyse im Cache (LRU-begrenzt).""" + with STATE.vision.cache_lock: + STATE.vision.analysis_cache[img_hash] = analysis + STATE.vision.analysis_cache.move_to_end(img_hash) + while len(STATE.vision.analysis_cache) > VISION_CACHE_MAX: + STATE.vision.analysis_cache.popitem(last=False) + + +def _request_has_image(data: dict) -> bool: + """True, wenn irgendwo im Request ein image_url-Part vorkommt.""" + messages = data.get("messages") + if not isinstance(messages, list): + return False + for msg in messages: + if not isinstance(msg, dict): + continue + content = msg.get("content") + if isinstance(content, list): + for part in content: + if isinstance(part, dict) and part.get("type") == "image_url": + return True + return False + + +def _sanitize_for_main_model(data: dict) -> dict: + """Erzeugt eine sanisierte Kopie des Requests für das Hauptmodell. + + Das Hauptmodell (Fast/Medium/Long) hat KEIN Vision-Modell. Deshalb + werden alle Bild-Parts (image_url) durch den zu diesem Bild erzeugten + Vision-Analyse-Text (aus dem Cache) ersetzt: + + - Bilddaten (Base64/URL) werden vollständig entfernt. + - Der ursprüngliche Text des Users bleibt erhalten. + - Die Analyse wird klar gekennzeichnet in denselben Turn eingesetzt. + + Beispiel: + user: [image_url, "Was ist hier falsch?"] + → user: "Was ist hier falsch?\n\n[Vision-Analyse des hochgeladenen + Bildes: ...]" + + Wird bei JEDER Folgefrage erneut angewendet, weil Open WebUI den + ursprünglichen multimodalen Verlauf wieder mitsendet. + """ + out = json.loads(json.dumps(data)) + messages = out.get("messages") + if not isinstance(messages, list): + return out + for msg in messages: + if not isinstance(msg, dict): + continue + content = msg.get("content") + if not isinstance(content, list): + continue + texts: list[str] = [] + had_image = False + for part in content: + if isinstance(part, dict) and part.get("type") == "image_url": + had_image = True + iu = part.get("image_url") + url = iu.get("url") if isinstance(iu, dict) else iu + analysis = None + if isinstance(url, str) and url: + analysis = _vision_cached_analysis(_image_hash(url)) + if analysis: + texts.append("[Vision-Analyse des hochgeladenen Bildes: " + + analysis + "]") + else: + texts.append(IMAGE_PLACEHOLDER) + elif (isinstance(part, dict) and part.get("type") == "text" + and isinstance(part.get("text"), str)): + texts.append(part["text"]) + # andere Part-Typen (z. B. audio) werden verworfen + if had_image: + msg["content"] = "\n\n".join(t for t in texts if t.strip()) + return out + + +def _vision_analyze(image_url: str, question: str) -> str: + """Sendet Bild + Frage an den Vision-Server, liefert den Analysen-Text.""" + payload = { + "model": VISION_ALIAS, + "messages": [ + {"role": "system", "content": VISION_ANALYST_PROMPT}, + {"role": "user", "content": [ + {"type": "image_url", "image_url": {"url": image_url}}, + {"type": "text", + "text": ("Benutzerfrage (nur zur Orientierung, NICHT " + "beantworten: " + (question or "(keine Frage)") + + "\n\nErstelle jetzt die strukturierte Vision-Analyse.")}, + ]}, + ], + "max_tokens": VISION_MAX_TOKENS, + "temperature": 0.1, + "stream": False, + } + body = json.dumps(payload).encode() + # timeout=VISION_INFER_TIMEOUT gilt für Connect UND Read. (Nicht + # conn.sock.settimeout() – vor connect() ist conn.sock noch None.) + conn = http.client.HTTPConnection("127.0.0.1", VISION_PORT, + timeout=VISION_INFER_TIMEOUT) + conn.request("POST", "/v1/chat/completions", body=body, + headers={"Content-Type": "application/json"}) + resp = conn.getresponse() + raw = resp.read() + conn.close() + try: + data = json.loads(raw) + except ValueError: + raise RuntimeError( + f"Vision-Inferenz: ungültige Antwort (HTTP {resp.status})") + if resp.status != 200: + msg = (data.get("error") or {}).get("message", str(data)) \ + if isinstance(data, dict) else str(data) + raise RuntimeError(f"Vision-Inferenz fehlgeschlagen ({resp.status}): {msg}") + choices = data.get("choices") or [] + if not choices: + raise RuntimeError("Vision-Inferenz: leere Antwort") + content = (choices[0].get("message") or {}).get("content") + if not isinstance(content, str) or not content.strip(): + raise RuntimeError("Vision-Inferenz: leere Analyse") + return content.strip() + + +def _vision_swap(data: dict) -> str: + """Kompletter Vision-Hotswap (Q3 "Augen"). + + Hält den zentralen GPU-Lock (gegenseitiger Ausschluss mit + Profilwechsel und FLUX). Normaler Ablauf (explizit, Schritt für + Schritt – die Wiederherstellung des Hauptprofils erfolgt erst NACH + abgeschlossener Vision-Inferenz): + + 1. Hauptprofil entladen (VRAM freigeben) + 2. Q3-Vision-Server starten und auf Ready warten + 3. Bild direkt an 127.0.0.1:VISION_PORT analysieren (Inferenz) + 4. Q3-Vision-Server stoppen + 5. Hauptprofil wiederherstellen + + finally dient NUR als Fehler-/Cleanup-Sicherung (Q3 stoppen, + Hauptprofil retten, Phase zurücksetzen, Timing loggen) – nicht als + normaler Ablauf. + + Liefert den Analysen-Text. Die Analyse wird zusätzlich im + Vision-Cache (keyed by Bild-Hash) gespeichert, damit Folgefragen das + Bild nicht erneut durch Q3 schicken (siehe _sanitize_for_main_model). + """ + vis = STATE.vision + profile = current_profile() + if profile is None: + raise RuntimeError("kein aktives Qwen-Profil (override.conf?)") + image_url, question = _extract_last_user_image(data) + if not image_url: + raise RuntimeError("kein Bild im Request") + img_hash = _image_hash(image_url) + vis.last_profile = profile + vis.last_error = None + t_total = time.monotonic() + timings: dict[str, float] = {} + with STATE.lock: + if vis.phase != "idle": + raise RuntimeError(f"Vision-Analyse läuft ({vis.phase})") + # Qwen wird gestoppt → für Chats nicht verfügbar (die warten). + _set_qwen_unavailable(True) + vision = _VisionServer() + analysis: str | None = None + main_restored = False + try: + _wait_chats_drained() + + # 1) Hauptprofil entladen (VRAM freigeben). + vis.phase = "stopping-main" + t0 = time.monotonic() + subprocess.run([SYSTEMCTL_BIN, "stop", LLAMA_SERVICE], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=120) + _wait_upstream_down(time.monotonic() + 60) + timings["main_unload"] = time.monotonic() - t0 + log.info("Vision: Hauptprofil %s entladen (%.1f s)", + profile, timings["main_unload"]) + + # 2) Q3-Vision-Server starten und auf Ready warten. + vis.phase = "loading-vision" + t0 = time.monotonic() + vision.start() + vision.wait_ready(time.monotonic() + VISION_LOAD_TIMEOUT) + timings["vision_load"] = time.monotonic() - t0 + log.info("Vision: Q3-Vision-Modell geladen (%.1f s)", + timings["vision_load"]) + + # 3) Bild direkt an den Vision-Server analysieren. + vis.phase = "analyzing" + t0 = time.monotonic() + log.info("Vision: Inferenz gestartet (127.0.0.1:%d)", VISION_PORT) + analysis = _vision_analyze(image_url, question) + timings["vision_infer"] = time.monotonic() - t0 + log.info("Vision: Inferenz abgeschlossen (%.1f s, %d Zeichen)", + timings["vision_infer"], len(analysis)) + _vision_store_analysis(img_hash, analysis) + log.info("Vision: Analyse im Cache gespeichert (Hash %s…)", + img_hash[:16]) + + # 4) Q3-Vision-Server stoppen. + vis.phase = "unloading-vision" + t0 = time.monotonic() + vision.stop() + try: + _wait_vram_free(timeout=VISION_UNLOAD_TIMEOUT) + except RuntimeError as e: + log.warning("Vision VRAM-Check: %s (fahre mit Restore fort)", e) + timings["vision_unload"] = time.monotonic() - t0 + log.info("Vision: Q3 gestoppt (%.1f s)", timings["vision_unload"]) + + # 5) Hauptprofil wiederherstellen (erst NACH der Inferenz). + vis.phase = "restoring-main" + t0 = time.monotonic() + log.info("Vision: Hauptprofil %s wird wiederhergestellt", profile) + _restore_qwen(profile) + timings["main_restore"] = time.monotonic() - t0 + log.info("Vision: Hauptprofil %s wiederhergestellt (%.1f s)", + profile, timings["main_restore"]) + main_restored = True + _set_qwen_unavailable(False) + + except Exception as e: + # Fehler-/Cleanup-Pfad: Q3 stoppen, Hauptprofil retten. + log.error("Vision-Fehler in Phase %s: %s", vis.phase, e) + vis.last_error = str(e) + if vision.alive(): + try: + vision.stop() + log.info("Vision: Q3 gestoppt (Cleanup nach Fehler)") + except Exception: + log.exception("Vision: Q3-Cleanup fehlgeschlagen") + if not main_restored: + try: + _restore_qwen(profile) + _set_qwen_unavailable(False) + log.info("Vision: Hauptprofil %s wiederhergestellt " + "(Cleanup nach Fehler)", profile) + except Exception as e2: + vis.last_error = (f"Qwen-Wiederherstellung " + f"fehlgeschlagen: {e2}") + log.error("Vision: %s", vis.last_error) + # qwen_unavailable bleibt True (Qwen ist down). + raise + finally: + # NUR Cleanup: Phase zurücksetzen, Timing loggen. + vis.phase = "idle" + vis.last_turnaround = round(time.monotonic() - t_total, 1) + log.info("Vision-Timing: %s | Gesamt %.1f s", + " ".join(f"{k}={v:.1f}s" for k, v in timings.items()), + vis.last_turnaround) + if analysis is None: + # Defensive Absicherung (der except-Pfad wirft immer weiter). + raise RuntimeError( + f"Vision-Analyse fehlgeschlagen: " + f"{vis.last_error or 'unbekannter Fehler'}") + return analysis + + # --------------------------------------------------------------------------- # HTTP-Handler # --------------------------------------------------------------------------- @@ -778,6 +1272,8 @@ class Handler(BaseHTTPRequestHandler): self._speech() elif path == "/v1/audio/transcriptions" and self.command == "POST": self._transcribe() + elif path == "/vision/test" and self.command == "POST": + self._vision_test() elif path == "/images" and self.command == "GET": self._images_list() elif path.startswith("/images/") and self.command == "GET": @@ -952,6 +1448,13 @@ class Handler(BaseHTTPRequestHandler): "last_seconds": img.last_seconds, "last_error": img.last_error, }, + "vision": { + "phase": STATE.vision.phase, + "last_error": STATE.vision.last_error, + "last_profile": STATE.vision.last_profile, + "last_turnaround_seconds": STATE.vision.last_turnaround, + "analysis_cache_size": len(STATE.vision.analysis_cache), + }, "tts": tts_status(), "stt": stt_status(), } @@ -1379,9 +1882,67 @@ class Handler(BaseHTTPRequestHandler): "model": up.get("model"), }) + # ---------- Interner Vision-Test ---------- + + def _vision_test(self) -> None: + """Interner Vision-Test: führt den kompletten Q3-Hotswap durch und + liefert die Analyse + Timing (ohne finale Hauptmodell-Inferenz). + + Body: {"image_url": "data:image/png;base64,..." | "http://...", + "question": "optional"} + """ + try: + body = self._read_body() + except ValueError as e: + self._send_error(400, str(e), + "invalid_request_error", "invalid_body") + return + try: + req = json.loads(body) if body else {} + except ValueError: + self._send_error(400, "ungültiges JSON", + "invalid_request_error", "invalid_body") + return + if not isinstance(req, dict): + self._send_error(400, "Body muss ein JSON-Objekt sein", + "invalid_request_error", "invalid_body") + return + image_url = req.get("image_url") + if not isinstance(image_url, str) or not image_url: + self._send_error(400, "image_url fehlt (data-URL oder http-URL)", + "invalid_request_error", "missing_image") + return + question = req.get("question") or "" + # Request bauen, der den Vision-Pfad triggert. + data = { + "model": "qwen-medium", + "messages": [ + {"role": "user", "content": [ + {"type": "image_url", "image_url": {"url": image_url}}, + {"type": "text", + "text": question or "Analysiere das Bild."}, + ]}, + ], + } + self.timeout = None # Vision-Swap kann Minuten dauern + try: + analysis = _vision_swap(data) + except (ValueError, RuntimeError) as e: + self._send_error(502, str(e), "server_error", "vision_failed") + return + vis = STATE.vision + self._send_json(200, { + "status": "ok", + "profile": vis.last_profile, + "turnaround_seconds": vis.last_turnaround, + "analysis_chars": len(analysis), + "analysis": analysis, + }) + # ---------- Transparentes Forwarding ---------- def _forward(self) -> None: + path = self.path.split("?", 1)[0] try: body = self._read_body() or None except ValueError as e: @@ -1389,6 +1950,21 @@ class Handler(BaseHTTPRequestHandler): "invalid_request_error", "invalid_body") return + # /v1/streams/lookup (Open-WebUI-Stream-Recovery): darf NIEMALS auf + # die Qwen-Wiederherstellung warten (während Vision-Hotswap, + # Profilwechsel oder Image-Job ist Qwen down). Wenn Qwen down ist, + # gibt es per Definition keine aktiven Streams → sofortige lokale + # Antwort []. Ansonsten normal an llama.cpp weiterleiten. + if path == "/v1/streams/lookup": + with STATE.avail_lock: + qwen_unavailable = STATE.qwen_unavailable + if qwen_unavailable: + self._send_json(200, []) + return + self._proxy(body) + return + + data = None # Virtuelles Modell? -> Profil sicherstellen, dann Modell ersetzen. if body is not None and self.path.startswith("/v1/"): try: @@ -1417,6 +1993,34 @@ class Handler(BaseHTTPRequestHandler): "invalid_request_error", "unknown_model") return + # Vision: Bilder im Request → Q3-Vision-Analyse (nur für NEUE, + # noch nicht analysierte Bilder), danach erzeugt das + # (wiederhergestellte) Hauptmodell die Endantwort. Die gesamte + # History wird sanisiert: alle Bild-Parts werden durch ihre + # (gecachten) Vision-Analysen ersetzt, damit das Nicht-Vision- + # Hauptmodell (Fast/Medium/Long) keine Bilddaten bekommt. Das ist + # wichtig, weil Open WebUI bei Folgefragen den ursprünglichen + # multimodalen Verlauf erneut mitsendet. + if isinstance(data, dict) and path == "/v1/chat/completions": + image_url, _ = _extract_last_user_image(data) + if image_url: + if _vision_cached_analysis(_image_hash(image_url)) is None: + # Neues Bild → Vision-Hotswap (Q3 analysiert + cacht). + self.timeout = None # Vision-Swap kann Minuten dauern + try: + _vision_swap(data) + except (ValueError, RuntimeError) as e: + self._send_error(502, str(e), "server_error", + "vision_failed") + return + else: + log.info("Vision: Bild bereits analysiert " + "(Cache-Treffer) – kein Hotswap") + if _request_has_image(data): + data = _sanitize_for_main_model(data) + body = json.dumps(data).encode() + log.info("Vision: finale Hauptmodell-Inferenz gestartet") + # An llama.cpp weiterleiten (mit Chat-Waiting, Streaming bleibt erhalten). self._proxy_with_wait(body)