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:
@@ -44,6 +44,7 @@ Sprachausgabe bereit (XTTS-v2, CPU-only, OpenAI-kompatibel).
|
|||||||
| `POST /v1/audio/transcriptions` | Deutsche Spracherkennung (whisper.cpp, 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/models` | Verfügbare Audio-Modelle (STT + TTS) |
|
||||||
| `GET /v1/audio/voices` | Verfügbare TTS-Stimmen |
|
| `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 |
|
| alles andere | Transparente Weiterleitung an llama.cpp |
|
||||||
|
|
||||||
### Verhalten
|
### Verhalten
|
||||||
@@ -174,6 +175,42 @@ VRAM-Check).
|
|||||||
Das Venv liegt unter `/opt/mike-ai/ai-profile-router/venv/` und wird von
|
Das Venv liegt unter `/opt/mike-ai/ai-profile-router/venv/` und wird von
|
||||||
`install.sh` automatisch angelegt/aktualisiert.
|
`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)
|
## Sprachausgabe (XTTS-v2, CPU-only)
|
||||||
|
|
||||||
Der Router stellt lokale Sprachausgabe bereit. Die Synthese läuft
|
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_WORKER_URL` | `http://127.0.0.1:8085` | TTS-Worker (Router-Seite) |
|
||||||
| `TTS_TIMEOUT` | `300` | Timeout pro Synthese (s) |
|
| `TTS_TIMEOUT` | `300` | Timeout pro Synthese (s) |
|
||||||
| `TTS_CONNECT_TIMEOUT` | `5` | Connect-Timeout TTS-Worker (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`):
|
TTS-Worker (`mike-ai-xtts.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_WORKER_LOG=/opt/mike-ai/ai-profile-router/worker.log
|
||||||
Environment=IMAGE_GEN_TIMEOUT=600
|
Environment=IMAGE_GEN_TIMEOUT=600
|
||||||
Environment=IMAGE_VRAM_FREE_TIMEOUT=120
|
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
|
NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
|
|
||||||
|
|||||||
+608
-4
@@ -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
|
gestellt und erst dann geantwortet (try/finally – Qwen wird auch bei
|
||||||
Fehlgeschlagener Generierung wiederhergestellt).
|
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).
|
Nur Python-Standardbibliothek. Logging nach stdout (journald).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -46,6 +55,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import email
|
import email
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -57,6 +67,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import http.client
|
import http.client
|
||||||
|
from collections import OrderedDict
|
||||||
from email.parser import BytesParser
|
from email.parser import BytesParser
|
||||||
from email.policy import compat32
|
from email.policy import compat32
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
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_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
|
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
|
# Erlaubte Auflösungen (Breite x Höhe). FLUX.2 klein ist für 1 MP
|
||||||
# ausgelegt; 1920x1088 (≈2 MP) wird zusätzlich unterstützt.
|
# ausgelegt; 1920x1088 (≈2 MP) wird zusätzlich unterstützt.
|
||||||
IMAGE_SIZES = {
|
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:
|
class _State:
|
||||||
"""Gemeinsamer, thread-sicherer Zustand.
|
"""Gemeinsamer, thread-sicherer Zustand.
|
||||||
|
|
||||||
lock : zentraler GPU-/Model-Lock. Wird von Profilwechsel UND
|
lock : zentraler GPU-/Model-Lock. Wird von Profilwechsel,
|
||||||
Image-Generation gehalten → gegenseitiger Ausschluss,
|
Image-Generation UND Vision-Swap gehalten →
|
||||||
kein Race zwischen beiden.
|
gegenseitiger Ausschluss, kein Race zwischen beiden.
|
||||||
avail_lock : schützt qwen_unavailable + active_chats (Chat-Waiting).
|
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
|
switching: str | None = None # Profil, das gerade gewechselt wird
|
||||||
started = time.time()
|
started = time.time()
|
||||||
image = _ImageState()
|
image = _ImageState()
|
||||||
|
vision = _VisionState()
|
||||||
# Qwen-Verfügbarkeit für das Chat-Waiting:
|
# Qwen-Verfügbarkeit für das Chat-Waiting:
|
||||||
qwen_unavailable = False # True, wenn Qwen down/neu geladen wird
|
qwen_unavailable = False # True, wenn Qwen down/neu geladen wird
|
||||||
active_chats = 0 # Anzahl laufender Chat-Requests
|
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))
|
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
|
# HTTP-Handler
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -778,6 +1272,8 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self._speech()
|
self._speech()
|
||||||
elif path == "/v1/audio/transcriptions" and self.command == "POST":
|
elif path == "/v1/audio/transcriptions" and self.command == "POST":
|
||||||
self._transcribe()
|
self._transcribe()
|
||||||
|
elif path == "/vision/test" and self.command == "POST":
|
||||||
|
self._vision_test()
|
||||||
elif path == "/images" and self.command == "GET":
|
elif path == "/images" and self.command == "GET":
|
||||||
self._images_list()
|
self._images_list()
|
||||||
elif path.startswith("/images/") and self.command == "GET":
|
elif path.startswith("/images/") and self.command == "GET":
|
||||||
@@ -952,6 +1448,13 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"last_seconds": img.last_seconds,
|
"last_seconds": img.last_seconds,
|
||||||
"last_error": img.last_error,
|
"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(),
|
"tts": tts_status(),
|
||||||
"stt": stt_status(),
|
"stt": stt_status(),
|
||||||
}
|
}
|
||||||
@@ -1379,9 +1882,67 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"model": up.get("model"),
|
"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 ----------
|
# ---------- Transparentes Forwarding ----------
|
||||||
|
|
||||||
def _forward(self) -> None:
|
def _forward(self) -> None:
|
||||||
|
path = self.path.split("?", 1)[0]
|
||||||
try:
|
try:
|
||||||
body = self._read_body() or None
|
body = self._read_body() or None
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -1389,6 +1950,21 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"invalid_request_error", "invalid_body")
|
"invalid_request_error", "invalid_body")
|
||||||
return
|
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.
|
# Virtuelles Modell? -> Profil sicherstellen, dann Modell ersetzen.
|
||||||
if body is not None and self.path.startswith("/v1/"):
|
if body is not None and self.path.startswith("/v1/"):
|
||||||
try:
|
try:
|
||||||
@@ -1417,6 +1993,34 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
"invalid_request_error", "unknown_model")
|
"invalid_request_error", "unknown_model")
|
||||||
return
|
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).
|
# An llama.cpp weiterleiten (mit Chat-Waiting, Streaming bleibt erhalten).
|
||||||
self._proxy_with_wait(body)
|
self._proxy_with_wait(body)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user