Vision: Q3-Augen-Orchestrierung + Folgefragen-Sanitize

- 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.
This commit is contained in:
Mike
2026-08-20 07:48:25 +02:00
parent fedcb0b6d3
commit a84220725a
3 changed files with 665 additions and 4 deletions
+47
View File
@@ -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`):
+10
View File
@@ -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
+608 -4
View File
@@ -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)