feat(flux): Bildgenerierung via FLUX.1-dev — flux-bridge auf Gamebox

Eigener Compose-Stack im /flux Verzeichnis (kann auf separater Maschine
laufen). aria-bridge routet flux_request via RVS, ARIA referenziert das
fertige PNG im Reply mit [FILE: ...]-Marker. Brain-Tool flux_generate
mit Caps fuer steps/dimension.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 22:33:48 +02:00
parent 33d5be781f
commit 7e53dcfed3
12 changed files with 984 additions and 2 deletions
+170 -1
View File
@@ -541,6 +541,12 @@ class ARIABridge:
# Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger,
# weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann.
self._remote_stt_ready: bool = False
# FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (Gamebox) warten.
# requestId → Future mit dem flux_response-Payload (oder None bei Fehler).
self._pending_flux: dict[str, asyncio.Future] = {}
# flux-bridge service_status: True wenn ready. Render-Timeouts werden
# bei 'loading' deutlich grosszuegiger gesetzt (Modell-Download ~24 GB).
self._remote_flux_ready: bool = False
# User-Message-Counter fuer Auto-Compact. Bei zu langer Konversation
# sprengt die argv-Liste beim Claude-Subprocess-Spawn (E2BIG). Bei
# COMPACT_AFTER erreicht → Sessions reset + Container restart.
@@ -2309,8 +2315,36 @@ class ARIABridge:
future.set_result(text)
return
elif msg_type == "flux_response":
# Antwort der flux-bridge auf unseren flux_request. Erste Nachricht
# mit state='rendering' ist nur Progress-Ping — die echte Antwort
# kommt mit state='done' (oder error).
request_id = payload.get("requestId", "")
future = self._pending_flux.get(request_id)
if future is None or future.done():
return
error = payload.get("error", "")
if error:
logger.warning("[rvs] flux_response Fehler: %s", error)
future.set_result({"error": error})
return
state = payload.get("state", "")
if state == "rendering":
# Nur Progress-Info, future bleibt offen
logger.info("[rvs] flux: rendering %dx%d steps=%d ...",
payload.get("width", 0), payload.get("height", 0),
payload.get("steps", 0))
return
# state == "done" oder fehlt → final
logger.info("[rvs] flux fertig: %dx%d, %.1fs, %d KB",
payload.get("width", 0), payload.get("height", 0),
payload.get("renderSeconds", 0),
(payload.get("sizeBytes", 0)) // 1024)
future.set_result(payload)
return
elif msg_type == "service_status":
# Gamebox-Bridges (whisper / f5tts) melden ihren Lade-Status.
# Gamebox-Bridges (whisper / f5tts / flux) melden ihren Lade-Status.
# Wir nutzen das fuer den dynamischen STT-Timeout: solange whisper
# im 'loading' steckt, geben wir der Bridge mehr Zeit (Modell-Download
# kann 1-2 Min dauern), statt nach 45s lokal zu fallbacken.
@@ -2321,6 +2355,11 @@ class ARIABridge:
self._remote_stt_ready = (state == "ready")
if self._remote_stt_ready != was_ready:
logger.info("[rvs] whisper-bridge -> %s", state)
elif svc == "flux":
was_ready = self._remote_flux_ready
self._remote_flux_ready = (state == "ready")
if self._remote_flux_ready != was_ready:
logger.info("[rvs] flux-bridge -> %s", state)
return
elif msg_type == "config_request":
@@ -2505,6 +2544,101 @@ class ARIABridge:
except OSError:
pass
# ── Flux-Roundtrip: Brain → Bridge → RVS → flux-bridge → zurueck ──
# FLUX-Render auf der 3060 dauert je nach Aufloesung/Steps 20-90 s.
# Beim 1. Render frisch nach Container-Start muss zudem das ~24 GB
# Modell von HF geladen werden — daher der grosse Loading-Timeout.
_FLUX_TIMEOUT_READY_S = 240.0 # 4 min nach erstem Render
_FLUX_TIMEOUT_LOADING_S = 900.0 # 15 min beim allerersten Mal (Modell-Download)
async def _flux_generate(self, prompt: str, width: int, height: int,
steps: Optional[int], guidance: Optional[float],
seed: Optional[int]) -> dict:
"""Schickt einen flux_request an die flux-bridge, wartet auf das fertige
PNG, speichert es nach /shared/uploads/aria_generated_<ts>.png.
Rueckgabe:
{ok: True, path, sizeBytes, width, height, steps, guidance, seed, model, renderSeconds}
{ok: False, error}
"""
if self.ws_rvs is None:
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
request_id = str(uuid.uuid4())
loop = asyncio.get_event_loop()
future: asyncio.Future = loop.create_future()
self._pending_flux[request_id] = future
try:
req_payload: dict = {"requestId": request_id, "prompt": prompt,
"width": width, "height": height}
if steps is not None:
req_payload["steps"] = steps
if guidance is not None:
req_payload["guidance_scale"] = guidance
if seed is not None:
req_payload["seed"] = seed
logger.info("[rvs] flux_request → flux-bridge (id=%s, %dx%d, steps=%s, prompt=%r)",
request_id[:8], width, height, steps, prompt[:60])
ok = await self._send_to_rvs({
"type": "flux_request",
"payload": req_payload,
"timestamp": int(time.time() * 1000),
})
if not ok:
return {"ok": False, "error": "flux_request konnte nicht gesendet werden"}
timeout_s = (self._FLUX_TIMEOUT_READY_S
if self._remote_flux_ready
else self._FLUX_TIMEOUT_LOADING_S)
result = await asyncio.wait_for(future, timeout=timeout_s)
if not isinstance(result, dict) or result.get("error"):
err = (result or {}).get("error") if isinstance(result, dict) else "leeres Resultat"
return {"ok": False, "error": err or "flux-bridge Fehler"}
b64 = result.get("base64") or ""
if not b64:
return {"ok": False, "error": "flux_response ohne Bilddaten"}
try:
png_bytes = base64.b64decode(b64)
except Exception as e:
return {"ok": False, "error": f"PNG-Decode fehlgeschlagen: {e}"}
SHARED_DIR = "/shared/uploads"
os.makedirs(SHARED_DIR, exist_ok=True)
ts_ms = int(time.time() * 1000)
file_name = f"aria_generated_{ts_ms}.png"
path = os.path.join(SHARED_DIR, file_name)
try:
with open(path, "wb") as f:
f.write(png_bytes)
except Exception as e:
return {"ok": False, "error": f"Speichern fehlgeschlagen: {e}"}
logger.info("[rvs] flux PNG gespeichert: %s (%d KB)", path, len(png_bytes) // 1024)
return {
"ok": True,
"path": path,
"sizeBytes": len(png_bytes),
"width": result.get("width", width),
"height": result.get("height", height),
"steps": result.get("steps"),
"guidance": result.get("guidance"),
"seed": result.get("seed"),
"model": result.get("model", ""),
"renderSeconds": result.get("renderSeconds", 0),
}
except asyncio.TimeoutError:
return {"ok": False, "error": f"Render-Timeout ({int(timeout_s)}s) — flux-bridge offline?"}
except Exception as e:
logger.exception("[rvs] _flux_generate Fehler")
return {"ok": False, "error": str(e)[:200]}
finally:
self._pending_flux.pop(request_id, None)
async def _send_to_rvs(self, message: dict) -> bool:
"""Sendet eine Nachricht an die App (via RVS) mit Verbindungs-Check.
@@ -2735,6 +2869,41 @@ class ARIABridge:
# selbst wenn derselbe Name zweimal in Folge kommt.
asyncio.create_task(self._emit_activity("tool", tool, force=True))
await _send_response(writer, 200, {"ok": True})
elif method == "POST" and path == "/internal/flux-generate":
# Vom Brain (flux_generate-Tool) gefeuert. Wir routen den
# Render-Request via RVS an die flux-bridge (Gamebox),
# warten synchron auf die PNG-Antwort, speichern sie nach
# /shared/uploads/ und melden Pfad + Render-Stats zurueck.
# Brain referenziert das Bild dann mit [FILE:]-Marker in
# seiner Antwort, die Bridge broadcastet daraufhin
# automatisch ein file_from_aria-Event an App+Diagnostic.
try:
data = json.loads(body.decode("utf-8", "ignore"))
except Exception as exc:
await _send_response(writer, 400, {"error": f"bad json: {exc}"})
return
prompt = (data.get("prompt") or "").strip()
if not prompt:
await _send_response(writer, 400, {"error": "prompt erforderlich"})
return
try:
width = int(data.get("width") or 1024)
height = int(data.get("height") or 1024)
except (TypeError, ValueError):
width, height = 1024, 1024
steps_raw = data.get("steps")
guidance_raw = data.get("guidance_scale")
seed_raw = data.get("seed")
steps = int(steps_raw) if isinstance(steps_raw, (int, float)) else None
guidance = float(guidance_raw) if isinstance(guidance_raw, (int, float)) else None
seed = int(seed_raw) if isinstance(seed_raw, (int, float)) else None
result = await self._flux_generate(
prompt=prompt, width=width, height=height,
steps=steps, guidance=guidance, seed=seed,
)
status = 200 if result.get("ok") else 502
await _send_response(writer, status, result)
elif method == "POST" and path == "/internal/delete-chat-message":
try:
data = json.loads(body.decode("utf-8", "ignore"))