bilder: Hirn und Coder sehen selbst - ueber Bild-Zwillinge, Text bleibt mit Draft schnell
Ampel / ampel (push) Failing after 21s

llama.cpp kann Draft-Beschleunigung und Bilder nicht zusammen (HTTP 500 "failed to process
speculative batch", b11057 und b11157 geprueft; speculative.n_max=0 je Anfrage hilft nicht).
Darum bekommen Hirn und Coder je einen Bild-Zwilling: gleiche Gewichte plus Projektor, ohne
Draft (vision, coder-bild), in einer eigenen llama-swap-Gruppe, die den Coder nicht verdraengt.
Probe 24.09.: beide 8/8 Bildmerkmale; Hirn-Zwilling 68 t/s, Coder-Zwilling 12,5 t/s.

Bild-Weiche v3 im Gateway: Bild im aktuellen Schritt geht an den Zwilling der Rolle, aeltere
Bilder werden einmal beschrieben (gemerkt) und als Text mitgeschickt, damit der Rest einer
Agenten-Aufgabe wieder beim schnellen Modell laeuft. Qwen3-VL gibt "vision" ab, der Coder
verliert den Projektor, der mit Draft nur HTTP 500 lieferte.

Radar misst die Bildfaehigkeit des heutigen Modells ueber dessen Zwilling (sonst gewaenne
jeder bildfaehige Kandidat mit "versteht Bilder"). Pruefstand: Coder darf vor dem Aendern
lesen (Version 3). Modelle-Seite zeigt "Bilder: ja" ueber den Zwilling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 13:02:06 +02:00
co-authored by Claude Opus 5.5
parent 2cc1e1cc47
commit 91aa16eee1
17 changed files with 497 additions and 230 deletions
+89 -71
View File
@@ -1,12 +1,15 @@
import hashlib
import json
import logging import logging
import os import os
from collections import OrderedDict
import httpx import httpx
from config import LLAMA_SWAP_URL from config import LLAMA_SWAP_URL
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image from services.router_logic import VISION_CAPABLE, bild_ziel, bilder_aufteilen, choose_for_lane, has_image
from services.routing_policy import load_policy from services.routing_policy import load_policy
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -22,15 +25,15 @@ _LANG_DIRECTIVE = os.environ.get(
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.") "Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
# Deckel für die Bild-Weiche. Ohne ihn wächst die Wartezeit linear mit der Bildzahl: # Deckel für die Bild-Beschreibungen. Ohne ihn wächst die Wartezeit linear mit der Bildzahl:
# je Bild bis zu 2 Versuche à _BILD_TIMEOUT_S, und der Client hängt so lange am offenen # je Bild bis zu 2 Versuche à _BILD_TIMEOUT_S, und der Client hängt so lange am offenen
# Request. Über _BILD_MAX Bilder wird gar nicht erst beschrieben — der Aufrufer fällt # Request. Sind mehr als _BILD_MAX Bilder NEU zu beschreiben, bleiben alle Bilder drin und die
# dann auf die normale Vision-Umleitung zurück (ehrlich langsam statt scheinbar hängend). # Anfrage geht an den Bild-Zwilling (ehrlich langsam statt scheinbar hängend).
_BILD_TIMEOUT_S = float(os.environ.get("MC_CODER_IMAGE_TIMEOUT_S", "240")) _BILD_TIMEOUT_S = float(os.environ.get("MC_CODER_IMAGE_TIMEOUT_S", "240"))
_BILD_MAX = int(os.environ.get("MC_CODER_IMAGE_MAX", "4")) _BILD_MAX = int(os.environ.get("MC_CODER_IMAGE_MAX", "4"))
# Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von # Beschreibung älterer Bilder: Prompt bewusst auf wörtliche Wiedergabe von Code/Fehlermeldungen
# Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter. # getrimmt — das schnelle Modell arbeitet in den Folgeschritten nur noch mit diesem Text.
_BILD_BESCHREIB_PROMPT = os.environ.get( _BILD_BESCHREIB_PROMPT = os.environ.get(
"MC_CODER_IMAGE_PROMPT", "MC_CODER_IMAGE_PROMPT",
"Beschreibe dieses Bild vollstaendig und praezise auf Deutsch: sichtbarer Text, " "Beschreibe dieses Bild vollstaendig und praezise auf Deutsch: sichtbarer Text, "
@@ -70,61 +73,74 @@ def _apply_no_think_marker(body: dict) -> None:
body["chat_template_kwargs"] = {"enable_thinking": False} body["chat_template_kwargs"] = {"enable_thinking": False}
def _ist_coder_alias(alias: str) -> bool: # Bild-Weiche v3 (24.09.2026). llama.cpp kann Draft-Beschleunigung und Bilder nicht zusammen (HTTP 500
"""Coder-Ziele (coder, rohe Qwen-Coder-IDs) bekommen Bild-BESCHREIBUNGEN statt der # „failed to process speculative batch“, b11057 und b11157 geprüft). Darum haben Hirn und Coder je einen
stumpfen Vision-Umleitung — die Code-Frage bleibt beim Spezialisten.""" # Bild-Zwilling: dieselben Gewichte mit Bild-Projektor, ohne Draft (vision, coder-bild).
return "coder" in (alias or "").lower() # • Bild im aktuellen Schritt → der Zwilling der Rolle sieht es selbst, auch mitten in einer Agenten-Aufgabe.
# • Bild aus früheren Schritten → einmal von vision beschrieben (je Bild gemerkt) und als Text mitgeschickt,
# damit der Rest der Aufgabe wieder beim schnellen Modell mit Draft läuft.
_BESCHREIBUNGEN: OrderedDict[str, str] = OrderedDict()
_BESCHREIBUNGEN_MAX = 64
async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str) -> bool: def _bild_schluessel(part: dict) -> str:
"""Ersetzt jeden Bild-Part durch eine Text-Beschreibung vom Vision-Modell. return hashlib.sha1(json.dumps(part, sort_keys=True).encode("utf-8")).hexdigest()
Idee aus einem verwaisten, nie verdrahteten Patch in der Hermes-Quelle
(gateway/platforms/api_server.py, 07/2026) — bei der Projekt-Review 15.07. async def _beschreibe(part: dict, vision_alias: str) -> str:
regelkonform hierher umgezogen (Archiv: docs/archiv/). True = alle Bilder """Beschreibung eines Bild-Parts; Hermes und OpenCode schicken den ganzen Verlauf mit jedem Schritt
ersetzt; False = eine Analyse scheiterte, Aufrufer nutzt die normale neu, darum wird jedes Bild nur einmal beschrieben."""
Vision-Umleitung als Fallback. schluessel = _bild_schluessel(part)
""" if schluessel in _BESCHREIBUNGEN:
fundstellen: list[tuple[dict, int, dict]] = [] _BESCHREIBUNGEN.move_to_end(schluessel)
for m in body.get("messages") or []: return _BESCHREIBUNGEN[schluessel]
if not isinstance(m, dict) or not isinstance(m.get("content"), list): frage = {
continue "model": vision_alias,
for i, part in enumerate(m["content"]): "stream": False,
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES: "max_tokens": 700,
fundstellen.append((m, i, part)) # Denken aus: sonst frisst die Denkphase das Token-Budget und die Beschreibung bleibt leer.
if len(fundstellen) > _BILD_MAX: "chat_template_kwargs": {"enable_thinking": False},
log.warning("Bild-Weiche v2: %s Bilder (Deckel %s) — Request geht an das Vision-Modell " "messages": [{"role": "user", "content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
"statt einzeln beschrieben zu werden", len(fundstellen), _BILD_MAX) }
text = ""
# Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach Sekunden trotz
# timeout=240 — llama-swap kappt beim Modell-Swap gern alte Keep-Alive-Sockets) + 1 Retry.
for versuch in (1, 2):
try:
async with httpx.AsyncClient(timeout=_BILD_TIMEOUT_S) as c:
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
if r.status_code == 200:
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
if text.strip():
break
log.warning("Bild-Weiche (Versuch %s): Beschreibung leer/HTTP %s%.200s",
versuch, r.status_code, r.text)
except Exception:
log.warning("Bild-Weiche (Versuch %s): Beschreibung scheiterte", versuch, exc_info=True)
text = text.strip()
if text:
_BESCHREIBUNGEN[schluessel] = text
while len(_BESCHREIBUNGEN) > _BESCHREIBUNGEN_MAX:
_BESCHREIBUNGEN.popitem(last=False)
return text
async def _alte_bilder_beschreiben(alt: list[tuple[dict, int]], vision_alias: str) -> bool:
"""Bilder aus früheren Schritten durch ihre Beschreibung ersetzen. False = ging nicht (zu viele neue
oder eine Beschreibung scheiterte) — dann bleiben alle Bilder drin."""
neu = {_bild_schluessel(msg["content"][idx]) for msg, idx in alt} - set(_BESCHREIBUNGEN)
if len(neu) > _BILD_MAX:
log.warning("Bild-Weiche: %s ältere Bilder neu zu beschreiben (Deckel %s) — Anfrage geht mit allen "
"Bildern an den Bild-Zwilling", len(neu), _BILD_MAX)
return False return False
for nr, (msg, idx, part) in enumerate(fundstellen, start=1): texte = []
frage = { for msg, idx in alt:
"model": vision_alias, text = await _beschreibe(msg["content"][idx], vision_alias)
"stream": False, if not text:
"max_tokens": 700,
"messages": [{"role": "user",
"content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
}
# Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach
# Sekunden trotz timeout=240 — llama-swap kappt beim Modell-Swap gern alte
# Keep-Alive-Sockets, der Pool reicht sie trotzdem wieder aus) + 1 Retry.
text = ""
for versuch in (1, 2):
try:
async with httpx.AsyncClient(timeout=_BILD_TIMEOUT_S) as c:
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
if r.status_code == 200:
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
if text.strip():
break
log.warning("Bild-Weiche v2 (Versuch %s): Beschreibung leer/HTTP %s%.200s",
versuch, r.status_code, r.text)
except Exception:
log.warning("Bild-Weiche v2 (Versuch %s): Vision-Aufruf scheiterte", versuch, exc_info=True)
if not text.strip():
return False return False
msg["content"][idx] = {"type": "text", "text": ( texte.append((msg, idx, text))
f"[Bild {nr}: dem Request lag ein Bild bei — {vision_alias} beschreibt es so:\n" for msg, idx, text in texte:
f"{text.strip()}]")} msg["content"][idx] = {"type": "text", "text": f"[Bild aus einem früheren Schritt — so sah es aus:\n{text}]"}
return True return True
@@ -233,25 +249,27 @@ async def _proxy(path: str, request: Request):
pol = load_policy() pol = load_policy()
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang automatisch ans Vision-Modell umleiten, angefragt = alias
# sofern das Ziel nicht ohnehin bildfähig ist. Das MTP-Hirn (fast/hermes) kann keine Bilder # Bild-Weiche v3 (siehe oben): Bild im aktuellen Schritt → Bild-Zwilling der Rolle; ältere Bilder
# so bleibt Lucy schnell, ohne dass jemand manuell das Modell wechselt (Entscheid Weg A). # Beschreibung als Text, die Anfrage bleibt beim schnellen Modell.
# Coder-Sonderweg (15.07.): Coder-Ziele behalten den Request — die Bilder werden vorab vom
# Vision-Modell BESCHRIEBEN und als Text injiziert (Screenshot-Debugging bleibt beim Coder).
vision_alias = pol.get("vision") vision_alias = pol.get("vision")
if vision_alias and alias not in VISION_CAPABLE and has_image(body): if vision_alias and alias not in VISION_CAPABLE and has_image(body):
if _ist_coder_alias(alias) and await _bilder_fuer_coder_beschreiben(body, client, vision_alias): frisch, alt = bilder_aufteilen(body)
routed = {"x-mc-routed-to": alias, beschrieben = bool(alt) and await _alte_bilder_beschreiben(alt, vision_alias)
"x-mc-route-reason": "Bild beschrieben (Vision) -> bleibt beim Coder", lane = routed.get("x-mc-lane", "-")
"x-mc-lane": routed.get("x-mc-lane", "-")} if frisch or not beschrieben:
else: alias = bild_ziel(alias, vision_alias, pol.get("coder_vision") or None)
alias = vision_alias
body["model"] = alias body["model"] = alias
# HTTP-Header-Werte muessen latin-1 sein — kein '→' o.ae. (sonst 500, 13.07.). # HTTP-Header-Werte muessen latin-1 sein — kein '→' o.ae. (sonst 500, 13.07.).
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild erkannt -> Vision-Modell", routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild im aktuellen Schritt -> Bild-Zwilling",
"x-mc-lane": routed.get("x-mc-lane", "-")} "x-mc-lane": lane}
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt). else:
if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body: routed = {"x-mc-routed-to": alias,
"x-mc-route-reason": "aeltere Bilder beschrieben -> bleibt beim schnellen Modell",
"x-mc-lane": lane}
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt) — auch wenn die
# Anfrage wegen eines Bildes beim Hirn-Zwilling landet (gleiches Hirn, gleiche Spur).
if pol["fast_no_think"] and angefragt == pol["fast"] and "chat_template_kwargs" not in body:
body["chat_template_kwargs"] = {"enable_thinking": False} body["chat_template_kwargs"] = {"enable_thinking": False}
_inject_language(body, alias) _inject_language(body, alias)
url = f"{LLAMA_SWAP_URL}{path}" url = f"{LLAMA_SWAP_URL}{path}"
+24 -3
View File
@@ -101,6 +101,7 @@ HF_BASIS = "https://huggingface.co"
ROLLEN = ("hirn", "coder") ROLLEN = ("hirn", "coder")
ALIAS = {"hirn": "hermes", "coder": "coder"} # llama-swap-Alias der Rolle heute ALIAS = {"hirn": "hermes", "coder": "coder"} # llama-swap-Alias der Rolle heute
ZWILLING = {"hirn": "vision", "coder": "coder-bild"} # Bild-Zwilling der Rolle (seit 24.09.)
DISCOVER_ROLLEN = {"coder": "coder", "fast": "hirn", "hermes": "hirn"} DISCOVER_ROLLEN = {"coder": "coder", "fast": "hirn", "hermes": "hirn"}
PARALLEL = {"hirn": 2, "coder": 1} # Slots im Betrieb, wie heute PARALLEL = {"hirn": 2, "coder": 1} # Slots im Betrieb, wie heute
KANDIDAT_FELDER = ("id", "name", "rolle", "repo", "datei", "groesse_gb", "passt", "eng", "quelle", "status", KANDIDAT_FELDER = ("id", "name", "rolle", "repo", "datei", "groesse_gb", "passt", "eng", "quelle", "status",
@@ -1068,14 +1069,34 @@ class _Nachtwaechter(threading.Thread):
self._halt.set() self._halt.set()
def _bild_zwilling(rolle: str, heute: dict) -> dict | None:
"""Der Bild-Zwilling der Rolle: trägt ZWILLING[rolle], hat einen Projektor und dieselben Gewichte."""
pfad = heute.get("gguf_path")
try:
modelle = llamaswap.list_models()
except Exception:
return None
for m in modelle:
if (ZWILLING[rolle] in {str(a).lower() for a in m.get("aliases") or []}
and "--mmproj" in str(m.get("cmd") or "") and pfad and m.get("gguf_path") == pfad):
return m
return None
def _baseline(rolle: str, frist: float) -> dict | None: def _baseline(rolle: str, frist: float) -> dict | None:
"""Werte des heutigen Modells der Rolle (Cache, höchstens monatlich neu gemessen).""" """Werte des heutigen Modells der Rolle (Cache, höchstens monatlich neu gemessen). Die Bild-Probe
macht der Bild-Zwilling, wenn es einen gibt — so zählt „versteht Bilder“ nicht als Vorteil eines
Kandidaten, obwohl das heutige Modell über seinen Zwilling längst sieht."""
heute = _heutige_modelle().get(rolle) heute = _heutige_modelle().get(rolle)
if not heute: if not heute:
return None return None
befehl = str(heute.get("cmd") or "") befehl = str(heute.get("cmd") or "")
kennung = f"{heute['name']}|{hashlib.sha1(befehl.encode()).hexdigest()[:10]}" zwilling = _bild_zwilling(rolle, heute)
return _pruefstand().baseline(rolle, str(BASELINE_PATH), kennung=kennung, bild="--mmproj" in befehl, zwilling_befehl = str((zwilling or {}).get("cmd") or "")
kennung = f"{heute['name']}|{hashlib.sha1((befehl + zwilling_befehl).encode()).hexdigest()[:10]}"
return _pruefstand().baseline(rolle, str(BASELINE_PATH), kennung=kennung,
bild="--mmproj" in befehl or zwilling is not None,
bild_modell=ZWILLING[rolle] if zwilling else None,
frist=frist, max_alter_tage=BASELINE_TAGE, modell=ALIAS[rolle], frist=frist, max_alter_tage=BASELINE_TAGE, modell=ALIAS[rolle],
protokoll=_protokoll) protokoll=_protokoll)
+34 -1
View File
@@ -51,10 +51,43 @@ _CODE_HINT = re.compile(
# Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet. # Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet.
VISION_CAPABLE = {"vision", "scout"} # Seit 24.09.2026 sind vision und coder-bild die Bild-Zwillinge von Hirn und Coder (gleiche Gewichte
# plus Bild-Projektor, ohne Draft — llama.cpp kann Draft und Bild nicht zusammen, HTTP 500).
VISION_CAPABLE = {"vision", "coder-bild", "scout"}
IMAGE_PART_TYPES = {"image_url", "input_image", "image"} IMAGE_PART_TYPES = {"image_url", "input_image", "image"}
def ist_coder_alias(alias: str) -> bool:
"""Coder-Ziele: coder, heavy (derselbe Coder) und rohe Coder-IDs."""
a = (alias or "").lower()
return "coder" in a or a == "heavy"
def bilder_aufteilen(body: dict) -> tuple[list[tuple[dict, int]], list[tuple[dict, int]]]:
"""Bild-Parts in (frisch, alt) teilen. Frisch = nach der letzten Antwort des Modells (der Schritt,
um den es gerade geht: neue Nutzer-Nachricht oder Werkzeug-Ergebnis). Alt = davor — das Modell hat
sie schon gesehen und seine Schlüsse im Verlauf. Jeder Eintrag ist (Nachricht, Index im content)."""
msgs = [m for m in body.get("messages") or [] if isinstance(m, dict)]
letzte_antwort = max((i for i, m in enumerate(msgs) if m.get("role") == "assistant"), default=-1)
frisch: list[tuple[dict, int]] = []
alt: list[tuple[dict, int]] = []
for i, m in enumerate(msgs):
if not isinstance(m.get("content"), list):
continue
for j, part in enumerate(m["content"]):
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
(frisch if i > letzte_antwort else alt).append((m, j))
return frisch, alt
def bild_ziel(alias: str, vision_alias: str, coder_vision_alias: str | None) -> str:
"""Bild-Zwilling für eine Anfrage mit frischem Bild: Coder-Ziele an den Coder-Zwilling (falls es ihn
gibt), alles andere an den Hirn-Zwilling (vision)."""
if coder_vision_alias and ist_coder_alias(alias):
return coder_vision_alias
return vision_alias
def has_image(body: dict) -> bool: def has_image(body: dict) -> bool:
"""True, wenn irgendeine Nachricht einen Bild-Part enthaelt (OpenAI-multimodaler """True, wenn irgendeine Nachricht einen Bild-Part enthaelt (OpenAI-multimodaler
content: eine Liste mit einem {"type": "image_url"|"input_image"|"image", ...}-Teil).""" content: eine Liste mit einem {"type": "image_url"|"input_image"|"image", ...}-Teil)."""
+5 -1
View File
@@ -33,6 +33,9 @@ DEFAULTS: dict = {
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang werden automatisch hierhin geroutet # Bild-Weiche (Faden 11): Requests mit Bild-Anhang werden automatisch hierhin geroutet
# (die MTP-Hirn-Config kann keine Bilder). "" schaltet die Weiche ab (Passthrough). # (die MTP-Hirn-Config kann keine Bilder). "" schaltet die Weiche ab (Passthrough).
"vision": os.environ.get("MC_ROUTE_VISION", "vision"), "vision": os.environ.get("MC_ROUTE_VISION", "vision"),
# Seit 24.09.2026: Bild-Zwilling des Coders — Coder-Anfragen mit neuem Bild gehen hierhin statt an vision.
# "" = auch Coder-Bilder gehen an vision.
"coder_vision": os.environ.get("MC_ROUTE_CODER_VISION", "coder-bild"),
"heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")), "heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")),
"coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")), "coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")),
"fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"), "fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"),
@@ -45,6 +48,7 @@ FIELDS: list[dict] = [
{"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"}, {"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"},
{"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"}, {"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"},
{"key": "vision", "label": "vision-Alias (Bild-Weiche: Requests mit Bild; leer = aus)", "type": "str"}, {"key": "vision", "label": "vision-Alias (Bild-Weiche: Requests mit Bild; leer = aus)", "type": "str"},
{"key": "coder_vision", "label": "Bild-Zwilling des Coders (leer = Coder-Bilder an vision)", "type": "str"},
{"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000}, {"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000},
{"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000}, {"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000},
{"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"}, {"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"},
@@ -81,7 +85,7 @@ def _coerce(patch: dict) -> dict:
out[k] = bool(v) out[k] = bool(v)
else: # str else: # str
sv = str(v).strip() sv = str(v).strip()
if k not in ("coder_lite", "vision") and not sv: if k not in ("coder_lite", "vision", "coder_vision") and not sv:
raise ValueError(f"{k} darf nicht leer sein") raise ValueError(f"{k} darf nicht leer sein")
out[k] = sv out[k] = sv
return out return out
+100
View File
@@ -0,0 +1,100 @@
"""Bild-Weiche v3 (24.09.2026): frische Bilder an den Bild-Zwilling, ältere als Beschreibung."""
import asyncio
import copy
from typing import ClassVar
from routers import gateway_proxy
from services.router_logic import bild_ziel, bilder_aufteilen, ist_coder_alias
BILD_A = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
BILD_B = {"type": "image_url", "image_url": {"url": "data:image/png;base64,BBBB"}}
def verlauf() -> dict:
"""Agenten-Ablauf: Bild A im ersten Schritt, danach Werkzeug-Aufruf, jetzt Bild B als Ergebnis."""
return {"model": "coder", "messages": [
{"role": "system", "content": "Du bist ein Coding-Agent."},
{"role": "user", "content": [BILD_A, {"type": "text", "text": "Was zeigt der Fehler?"}]},
{"role": "assistant", "content": "", "tool_calls": [
{"id": "c1", "type": "function", "function": {"name": "screenshot", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "Bildschirmfoto folgt."},
{"role": "user", "content": [BILD_B, {"type": "text", "text": "Bildschirmfoto aus screenshot."}]},
]}
def test_frisch_ist_was_nach_der_letzten_antwort_kommt():
frisch, alt = bilder_aufteilen(verlauf())
assert [m["content"][i] for m, i in frisch] == [BILD_B]
assert [m["content"][i] for m, i in alt] == [BILD_A]
def test_ohne_antwort_sind_alle_bilder_frisch():
frisch, alt = bilder_aufteilen({"messages": [{"role": "user", "content": [BILD_A, BILD_B]}]})
assert len(frisch) == 2 and alt == []
def test_coder_ziele_gehen_an_den_coder_zwilling_alles_andere_an_vision():
assert ist_coder_alias("coder") and ist_coder_alias("heavy") and not ist_coder_alias("fast")
assert bild_ziel("coder", "vision", "coder-bild") == "coder-bild"
assert bild_ziel("heavy", "vision", "coder-bild") == "coder-bild"
assert bild_ziel("fast", "vision", "coder-bild") == "vision"
assert bild_ziel("coder", "vision", None) == "vision" # ohne Coder-Zwilling: Hirn-Zwilling
class _FalscherClient:
"""Ersetzt httpx.AsyncClient: zählt Beschreibungs-Anfragen und antwortet mit fester Beschreibung."""
anfragen: ClassVar[list] = []
def __init__(self, *a, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url, json):
_FalscherClient.anfragen.append(json)
class Antwort:
status_code = 200
text = ""
@staticmethod
def json():
return {"choices": [{"message": {"content": "Fehlermeldung: PORT 4711 belegt"}}]}
return Antwort()
def test_aeltere_bilder_werden_einmal_beschrieben_und_gemerkt(monkeypatch):
monkeypatch.setattr(gateway_proxy.httpx, "AsyncClient", _FalscherClient)
monkeypatch.setattr(gateway_proxy, "_BESCHREIBUNGEN", gateway_proxy.OrderedDict())
_FalscherClient.anfragen = []
body = verlauf()
_, alt = bilder_aufteilen(body)
assert asyncio.run(gateway_proxy._alte_bilder_beschreiben(alt, "vision")) is True
ersetzt = body["messages"][1]["content"][0]
assert ersetzt["type"] == "text" and "PORT 4711" in ersetzt["text"]
assert body["messages"][4]["content"][0] == BILD_B # das frische Bild bleibt ein Bild
anfrage = _FalscherClient.anfragen[0]
assert anfrage["model"] == "vision" and anfrage["chat_template_kwargs"] == {"enable_thinking": False}
# Nächster Schritt schickt denselben Verlauf erneut: aus dem Gedächtnis, keine zweite Anfrage.
nochmal = verlauf()
_, alt = bilder_aufteilen(nochmal)
assert asyncio.run(gateway_proxy._alte_bilder_beschreiben(alt, "vision")) is True
assert len(_FalscherClient.anfragen) == 1
def test_zu_viele_neue_bilder_bleiben_drin(monkeypatch):
monkeypatch.setattr(gateway_proxy, "_BESCHREIBUNGEN", gateway_proxy.OrderedDict())
monkeypatch.setattr(gateway_proxy, "_BILD_MAX", 1)
body = {"messages": [{"role": "user", "content": [BILD_A, BILD_B]}, {"role": "assistant", "content": "ok"},
{"role": "user", "content": "und jetzt?"}]}
vorher = copy.deepcopy(body)
_, alt = bilder_aufteilen(body)
assert asyncio.run(gateway_proxy._alte_bilder_beschreiben(alt, "vision")) is False
assert body == vorher
+20
View File
@@ -634,3 +634,23 @@ def test_baseline_misst_neu_wenn_sich_die_proben_geaendert_haben(ps, tmp_path, m
def test_kein_ablenker_klingt_nach_plattenplatz(ps): def test_kein_ablenker_klingt_nach_plattenplatz(ps):
namen = {w["function"]["name"] for w in ps.WERKZEUGE_HIRN} namen = {w["function"]["name"] for w in ps.WERKZEUGE_HIRN}
assert "system_speicher" not in namen and "system_arbeitsspeicher" in namen assert "system_speicher" not in namen and "system_arbeitsspeicher" in namen
def test_baseline_misst_bilder_ueber_den_zwilling(monkeypatch, ps):
"""Seit 24.09. sieht das heutige Hirn über seinen Bild-Zwilling (vision) — die Baseline muss das wissen."""
hirn = {"name": "Qwen3.6-35B-A3B", "aliases": ["hermes", "fast"], "gguf_path": "/m/hirn.gguf",
"cmd": "llama-server -m /m/hirn.gguf --spec-type draft-dflash"}
zwilling = {"name": "Qwen3.6-35B-A3B-Bild", "aliases": ["vision"], "gguf_path": "/m/hirn.gguf",
"cmd": "llama-server -m /m/hirn.gguf --mmproj /m/mmproj.gguf"}
fremd = {"name": "Qwen3-VL", "aliases": [], "gguf_path": "/m/vl.gguf", "cmd": "llama-server -m /m/vl.gguf --mmproj x"}
monkeypatch.setattr(radar.llamaswap, "list_models", lambda: [hirn, zwilling, fremd])
aufrufe = []
monkeypatch.setattr(ps, "baseline", lambda rolle, pfad, **kw: aufrufe.append(kw) or {"werte": {}})
radar._baseline("hirn", time.time() + 60)
assert aufrufe[0]["bild"] is True and aufrufe[0]["bild_modell"] == "vision"
# Ohne Zwilling (Projektor auf anderen Gewichten zählt nicht): keine Bild-Probe.
monkeypatch.setattr(radar.llamaswap, "list_models", lambda: [hirn, fremd])
radar._baseline("hirn", time.time() + 60)
assert aufrufe[1]["bild"] is False and aufrufe[1]["bild_modell"] is None
assert aufrufe[0]["kennung"] != aufrufe[1]["kennung"] # neuer Zwilling → neue Baseline
+18 -6
View File
@@ -62,7 +62,7 @@ HERMES = os.path.expanduser("~/.local/bin/hermes")
CTX_STANDARD = 65536 CTX_STANDARD = 65536
# Stand der Proben. Ändern sich Aufgaben oder Werkzeuge, ist eine gecachte Baseline nicht mehr # Stand der Proben. Ändern sich Aufgaben oder Werkzeuge, ist eine gecachte Baseline nicht mehr
# vergleichbar — baseline() misst dann neu. Bei jeder Änderung an den Proben hochzählen. # vergleichbar — baseline() misst dann neu. Bei jeder Änderung an den Proben hochzählen.
PRUEFSTAND_VERSION = 2 PRUEFSTAND_VERSION = 3 # 24.09.: Coder darf vor dem Ändern lesen (Bild im Ablauf)
# Flags wie am 17.09. (der Kontext kommt je Kandidat dazu): voller GPU-Offload, Flash-Attention, # Flags wie am 17.09. (der Kontext kommt je Kandidat dazu): voller GPU-Offload, Flash-Attention,
# kein mmap (--load-mode none, das alte --no-mmap gibt es seit b10936 nicht mehr), ein Slot, # kein mmap (--load-mode none, das alte --no-mmap gibt es seit b10936 nicht mehr), ein Slot,
# KV-Cache q8_0 wie im Betrieb. # KV-Cache q8_0 wie im Betrieb.
@@ -709,6 +709,15 @@ def _bild_im_ablauf(basis: str, modell: str, rolle: str, url: str, frist: float
] ]
j = chat(basis, modell, verlauf, WERKZEUG_TOKENS, werkzeuge, frist=frist) j = chat(basis, modell, verlauf, WERKZEUG_TOKENS, werkzeuge, frist=frist)
aufrufe = _nachricht(j).get("tool_calls") or [] aufrufe = _nachricht(j).get("tool_calls") or []
lesen = next((a for a in aufrufe if (a.get("function") or {}).get("name") == "read"), None)
if rolle != "hirn" and lesen and not trifft(aufrufe, erwartet):
# Ein guter Coding-Agent liest die Datei, bevor er sie ändert (24.09.: der Coder tat genau das und
# galt als gescheitert). Dann den Inhalt liefern und nach dem nächsten Schritt fragen.
verlauf += [{"role": "assistant", "content": "", "tool_calls": [lesen]},
{"role": "tool", "tool_call_id": lesen.get("id") or "call_lesen",
"content": 'PORT = 8080\nHOST = "0.0.0.0"\n'}]
j = chat(basis, modell, verlauf, WERKZEUG_TOKENS, werkzeuge, frist=frist)
aufrufe = _nachricht(j).get("tool_calls") or []
return {"ok": trifft(aufrufe, erwartet), "aufrufe": _aufrufe_kurz(aufrufe), return {"ok": trifft(aufrufe, erwartet), "aufrufe": _aufrufe_kurz(aufrufe),
"antwort": "" if aufrufe else _text(j)[:160]} "antwort": "" if aufrufe else _text(j)[:160]}
@@ -734,8 +743,10 @@ def messe_bild(basis: str, modell: str, rolle: str, frist: float | None = None)
# --- Gesamtprüfung --------------------------------------------------------------------- # --- Gesamtprüfung ---------------------------------------------------------------------
def pruefe(rolle: str, basis: str, modell: str, *, bild: bool = False, frist: float | None = None, def pruefe(rolle: str, basis: str, modell: str, *, bild: bool = False, frist: float | None = None,
protokoll=log) -> dict: protokoll=log, bild_modell: str | None = None) -> dict:
"""Alle Proben der Rolle gegen ein laufendes Modell (Kandidat auf :5899 oder Baseline über llama-swap).""" """Alle Proben der Rolle gegen ein laufendes Modell (Kandidat auf :5899 oder Baseline über llama-swap).
bild_modell: wer die Bild-Probe macht, wenn es nicht das Modell selbst ist — seit 24.09. sehen Hirn und
Coder über ihren Bild-Zwilling (vision, coder-bild), weil Draft und Bild in llama.cpp nicht zusammengehen."""
if rolle not in ALIAS: if rolle not in ALIAS:
raise ValueError(f"Unbekannte Rolle {rolle!r}") raise ValueError(f"Unbekannte Rolle {rolle!r}")
t0 = time.time() t0 = time.time()
@@ -755,7 +766,7 @@ def pruefe(rolle: str, basis: str, modell: str, *, bild: bool = False, frist: fl
werte["bild"] = None werte["bild"] = None
if bild: if bild:
try: try:
werte["bild"] = messe_bild(basis, modell, rolle, frist) werte["bild"] = messe_bild(basis, bild_modell or modell, rolle, frist)
except Zeitende: except Zeitende:
raise raise
except Exception as e: except Exception as e:
@@ -948,7 +959,7 @@ def _schreibe_json(pfad: str, daten: dict) -> None:
def baseline(rolle: str, cache_pfad: str, *, kennung: str, bild: bool = False, frist: float | None = None, def baseline(rolle: str, cache_pfad: str, *, kennung: str, bild: bool = False, frist: float | None = None,
max_alter_tage: int = 30, basis_url: str = SWAP_URL, modell: str | None = None, max_alter_tage: int = 30, basis_url: str = SWAP_URL, modell: str | None = None,
protokoll=log) -> dict | None: protokoll=log, bild_modell: str | None = None) -> dict | None:
"""Werte des heutigen Modells der Rolle, höchstens alle max_alter_tage neu gemessen. """Werte des heutigen Modells der Rolle, höchstens alle max_alter_tage neu gemessen.
kennung = welches Modell mit welchen Flags (ändert sie sich, wird neu gemessen). kennung = welches Modell mit welchen Flags (ändert sie sich, wird neu gemessen).
Rückgabe {"kennung", "zeit", "werte"} oder None, wenn es weder Cache noch Messung gibt.""" Rückgabe {"kennung", "zeit", "werte"} oder None, wenn es weder Cache noch Messung gibt."""
@@ -960,7 +971,8 @@ def baseline(rolle: str, cache_pfad: str, *, kennung: str, bild: bool = False, f
return passend return passend
protokoll(f" Baseline {rolle} wird neu gemessen ({modell or ALIAS[rolle]} über llama-swap)") protokoll(f" Baseline {rolle} wird neu gemessen ({modell or ALIAS[rolle]} über llama-swap)")
try: try:
werte = pruefe(rolle, basis_url, modell or ALIAS[rolle], bild=bild, frist=frist, protokoll=protokoll) werte = pruefe(rolle, basis_url, modell or ALIAS[rolle], bild=bild, frist=frist, protokoll=protokoll,
bild_modell=bild_modell)
except Zeitende: except Zeitende:
raise raise
except Exception as e: except Exception as e:
+48 -4
View File
@@ -27,6 +27,24 @@ models:
out: [text] out: [text]
tools: true tools: true
context: 65536 context: 65536
Qwen3.6-35B-A3B-Bild:
# 24.09.2026: Bild-Zwilling des Hirns — dieselben Gewichte plus Bild-Projektor, aber OHNE Draft. llama.cpp
# scheitert mit Draft UND Bild an „failed to process speculative batch“ (HTTP 500; b11057 und b11157 geprüft,
# auch speculative.n_max=0 je Anfrage hilft nicht). Das MC2-Gateway schickt nur Anfragen mit einem Bild im
# aktuellen Schritt hierher; ältere Bilder ersetzt es durch eine Beschreibung von hier, damit der Rest der
# Aufgabe beim schnellen Hirn mit Draft bleibt. Probe 24.09.: 8/8 Bildmerkmale, Bildschirm-Aufgabe im
# Agenten-Ablauf richtig, 67,9 t/s @13k (Hirn mit Draft 105,8). Projektor aus dem Original-Repo
# (Qwen3.6-35B-A3B-MTP-GGUF) — er passt zur abliterierten Variante. Löst Qwen3-VL als `vision` ab.
cmd: |
llama-server -m /srv/models/Qwen3.6-35B-A3B-Uncensored-GGUF/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 65536 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Qwen3.6-35B-A3B-MTP-GGUF/mmproj-BF16.gguf --jinja --parallel 1 -cram 8192 -ctk q8_0 -ctv q8_0 --reasoning-budget 2048
ttl: 900
aliases:
- vision
capabilities:
in: [text, image]
out: [text]
tools: true
context: 65536
Qwen3.8-27B: Qwen3.8-27B:
# Qwen 3.8 27B: Dichtes 27B-Modell mit hybrider Linear-Attention (48/64 Schichten linear), # Qwen 3.8 27B: Dichtes 27B-Modell mit hybrider Linear-Attention (48/64 Schichten linear),
# nativem Multimodal-Support (mmproj-BF16) und Tool-Calling via --jinja. # nativem Multimodal-Support (mmproj-BF16) und Tool-Calling via --jinja.
@@ -36,19 +54,35 @@ models:
# 17.09.2026: Uncensored-Variante (JonathanColetti, Heretic-Abliteration: KL 0,12, Refusals 98->12/100, Benchmarks -0,5) # 17.09.2026: Uncensored-Variante (JonathanColetti, Heretic-Abliteration: KL 0,12, Refusals 98->12/100, Benchmarks -0,5)
# mit demselben DFlash2-Draft (Akzeptanz 0,82 @13k). Prüfstand: 30,4 t/s @13k (Original 26,2), Tools 6/6, Coding 3/3, # mit demselben DFlash2-Draft (Akzeptanz 0,82 @13k). Prüfstand: 30,4 t/s @13k (Original 26,2), Tools 6/6, Coding 3/3,
# Hermes-Smoke grün. Original bleibt in Qwen3.8-27B-GGUF liegen — Rückweg = beide Pfade zurück. # Hermes-Smoke grün. Original bleibt in Qwen3.8-27B-GGUF liegen — Rückweg = beide Pfade zurück.
# 24.09.2026: ohne --mmproj. Bild UND DFlash2 ergaben immer HTTP 500; Bilder gehen jetzt an den Zwilling
# Qwen3.8-27B-Bild (Alias coder-bild), der Text bleibt hier mit Draft schnell.
cmd: | cmd: |
llama-server -m /srv/models/Qwen3.8-27B-Uncensored-GGUF/Qwen3.8-27B-Uncensored-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Qwen3.8-27B-Uncensored-GGUF/mmproj-Qwen3.8-27B-Uncensored-F16.gguf --jinja --parallel 1 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf llama-server -m /srv/models/Qwen3.8-27B-Uncensored-GGUF/Qwen3.8-27B-Uncensored-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --load-mode none --jinja --parallel 1 -cram 16384 -ctk q8_0 -ctv q8_0 --spec-type draft-dflash --spec-draft-model /srv/models/Qwen3.8-27B-DFlash2-GGUF/Qwen3.8-27B-DFlash2-Q4_K_M.gguf
ttl: 5400 ttl: 5400
aliases: aliases:
- coder - coder
- heavy - heavy
capabilities: capabilities:
in: [text, image] in: [text]
out: [text] out: [text]
tools: true tools: true
# --parallel 1 = der Coder bekommt den ganzen Slot (34a9862, am laufenden Prozess # --parallel 1 = der Coder bekommt den ganzen Slot (34a9862, am laufenden Prozess
# gegengeprueft). Er hat nur einen Verbraucher; Lucy/explore nutzen hermes, review heavy. # gegengeprueft). Er hat nur einen Verbraucher; Lucy/explore nutzen hermes, review heavy.
context: 131072 context: 131072
Qwen3.8-27B-Bild:
# 24.09.2026: Bild-Zwilling des Coders (dieselben Gewichte plus Projektor, ohne DFlash2 — Grund siehe
# Qwen3.6-35B-A3B-Bild). Nur für Anfragen mit einem Bild im aktuellen Schritt (Screenshot der App o. Ä.).
# Probe 24.09.: 8/8 Bildmerkmale, 12,5 t/s @13k (Coder mit Draft 30,4).
cmd: |
llama-server -m /srv/models/Qwen3.8-27B-Uncensored-GGUF/Qwen3.8-27B-Uncensored-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 131072 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Qwen3.8-27B-Uncensored-GGUF/mmproj-Qwen3.8-27B-Uncensored-F16.gguf --jinja --parallel 1 -cram 16384 -ctk q8_0 -ctv q8_0
ttl: 900
aliases:
- coder-bild
capabilities:
in: [text, image]
out: [text]
tools: true
context: 131072
Muse-Glimmer-30B: Muse-Glimmer-30B:
# Meta Muse Glimmer 30B: Dichtes 30B-Agenten-Modell mit DFlash-Speculative-Drafting # Meta Muse Glimmer 30B: Dichtes 30B-Agenten-Modell mit DFlash-Speculative-Drafting
# und 1.8B Perception Multimodal Projector. Ideal als Runtime-Debugger & Fehler-Diagnostiker. # und 1.8B Perception Multimodal Projector. Ideal als Runtime-Debugger & Fehler-Diagnostiker.
@@ -85,8 +119,7 @@ models:
cmd: | cmd: |
llama-server -m /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/mmproj-F16.gguf --jinja llama-server -m /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --host 127.0.0.1 --port ${PORT} -c 32768 -ngl 999 -fa on --load-mode none --mmproj /srv/models/Qwen3-VL-30B-A3B-Instruct-GGUF/mmproj-F16.gguf --jinja
ttl: 900 ttl: 900
aliases: # 24.09.2026: `vision` an den Hirn-Zwilling abgegeben (Qwen3.6-35B-A3B-Bild); ohne Rolle, wird gelöscht.
- vision
capabilities: capabilities:
in: [text, image] in: [text, image]
out: [text] out: [text]
@@ -111,3 +144,14 @@ groups:
- Qwen3-Embedding-0.6B - Qwen3-Embedding-0.6B
- Qwen3-Reranker-0.6B - Qwen3-Reranker-0.6B
- Qwen3.6-35B-A3B - Qwen3.6-35B-A3B
bild:
# 24.09.2026: die Bild-Zwillinge von Hirn und Coder. swap: true = höchstens einer von beiden geladen;
# exclusive: false = das Laden verdrängt den Coder NICHT (sonst kostete jedes Bild in einer OpenChamber-
# Sitzung den Coder samt Prompt-Cache). Speicher im schlimmsten Fall: Warm-Set 27 + Coder 29 +
# Zwilling ~25 GB = ~81 GB. Nachts entlädt das Modell-Radar alles außerhalb des Warm-Sets.
swap: true
exclusive: false
persistent: false
members:
- Qwen3.6-35B-A3B-Bild
- Qwen3.8-27B-Bild
@@ -1 +1 @@
import{c as e,f as t,i as n,t as r,u as i}from"./button-Dd8hfusv.js";import{A as a,L as o,f as s,j as c,y as l}from"./index-B-ORi4lf.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(i(),1),g=e();function _({offen:e,onSchliessen:t}){let i=o(),_=s(),[v,y]=(0,h.useState)(null),[b,x]=(0,h.useState)(null),S=l(v);async function C(e){x(null);try{let t=await a(`/api/maintenance/restart`,{service:e});t.ok?c(`erfolg`,`${e} startet neu.`):c(`fehler`,t.err||`${e} ließ sich nicht neu starten.`),i.invalidateQueries({queryKey:[`dienste`]})}catch(e){c(`fehler`,e.message)}}return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-2xl`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),(0,g.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-2`,children:(_.data?.services??[]).map(e=>(0,g.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,g.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,g.jsx)(`span`,{className:n(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:`bg-rot`),"aria-hidden":!0}),(0,g.jsxs)(`span`,{className:`min-w-0`,children:[(0,g.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,g.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:`antwortet nicht`]})]})]}),(0,g.jsxs)(`span`,{className:`flex gap-2`,children:[(0,g.jsx)(r,{variant:v===e.unit?`info`:`ghost`,size:`sm`,onClick:()=>y(e.unit),children:`Protokoll`}),b===e.unit?(0,g.jsx)(r,{variant:`gefahr`,size:`sm`,onClick:()=>C(e.unit),children:`Wirklich neu starten?`}):(0,g.jsx)(r,{variant:`outline`,size:`sm`,onClick:()=>x(e.unit),children:`Neu starten`})]})]},e.unit))}),v&&(0,g.jsx)(`pre`,{className:`ziffern mx-4 mb-4 max-h-[50vh] overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:S.isFetching?`Wird gelesen …`:S.data?.text||S.data?.err||`Kein Protokoll.`})]})})}export{_ as Dienste}; import{c as e,f as t,i as n,t as r,u as i}from"./button-Dd8hfusv.js";import{A as a,L as o,f as s,j as c,y as l}from"./index-BRMEvo2A.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(i(),1),g=e();function _({offen:e,onSchliessen:t}){let i=o(),_=s(),[v,y]=(0,h.useState)(null),[b,x]=(0,h.useState)(null),S=l(v);async function C(e){x(null);try{let t=await a(`/api/maintenance/restart`,{service:e});t.ok?c(`erfolg`,`${e} startet neu.`):c(`fehler`,t.err||`${e} ließ sich nicht neu starten.`),i.invalidateQueries({queryKey:[`dienste`]})}catch(e){c(`fehler`,e.message)}}return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-2xl`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),(0,g.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-2`,children:(_.data?.services??[]).map(e=>(0,g.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,g.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,g.jsx)(`span`,{className:n(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:`bg-rot`),"aria-hidden":!0}),(0,g.jsxs)(`span`,{className:`min-w-0`,children:[(0,g.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,g.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:`antwortet nicht`]})]})]}),(0,g.jsxs)(`span`,{className:`flex gap-2`,children:[(0,g.jsx)(r,{variant:v===e.unit?`info`:`ghost`,size:`sm`,onClick:()=>y(e.unit),children:`Protokoll`}),b===e.unit?(0,g.jsx)(r,{variant:`gefahr`,size:`sm`,onClick:()=>C(e.unit),children:`Wirklich neu starten?`}):(0,g.jsx)(r,{variant:`outline`,size:`sm`,onClick:()=>x(e.unit),children:`Neu starten`})]})]},e.unit))}),v&&(0,g.jsx)(`pre`,{className:`ziffern mx-4 mb-4 max-h-[50vh] overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:S.isFetching?`Wird gelesen …`:S.data?.text||S.data?.err||`Kein Protokoll.`})]})})}export{_ as Dienste};
@@ -1 +1 @@
import{c as e,f as t,t as n,u as r}from"./button-Dd8hfusv.js";import{A as i,F as a,I as o,L as s,j as c,k as l}from"./index-B-ORi4lf.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(r(),1),g=e();function _({offen:e,onSchliessen:t}){let r=s(),_=o({queryKey:[`geheimnisse`],queryFn:()=>l(`/api/maintenance/geheimnisse`),enabled:e}),[v,y]=(0,h.useState)(``);async function b(e){try{await i(`/api/maintenance/geheimnisse`,{schluessel:`hf_token`,wert:e}),c(`erfolg`,e?`Hugging-Face-Zugang gespeichert.`:`Hugging-Face-Zugang gelöscht.`),y(``),r.invalidateQueries({queryKey:[`geheimnisse`]})}catch(e){c(`fehler`,e.message)}}let x=_.data;return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-lg`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Einstellungen`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Was die Box sich merkt.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-3 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hugging-Face-Zugang`}),(0,g.jsxs)(`p`,{className:`text-sm text-text-2`,children:[x?.hf_token_gesetzt?`Ist hinterlegt.`:`Fehlt.`,` Nötig für gesperrte Modelle und schnellere Downloads.`,x?.hf_token_aus_env?` Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen.`:``]}),(0,g.jsxs)(`form`,{className:`flex gap-2`,onSubmit:e=>{e.preventDefault(),v.trim()&&b(v.trim())},children:[(0,g.jsx)(`label`,{htmlFor:`hf-token`,className:`sr-only`,children:`Hugging-Face-Zugang`}),(0,g.jsx)(`input`,{id:`hf-token`,type:`password`,autoComplete:`off`,value:v,onChange:e=>y(e.target.value),placeholder:`hf_…`,className:`h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan`}),(0,g.jsx)(n,{type:`submit`,disabled:!v.trim()||x?.schreibbar===!1,children:`Speichern`})]}),x?.hf_token_gesetzt&&!x.hf_token_aus_env&&(0,g.jsx)(n,{variant:`ghost`,size:`sm`,className:`self-start`,onClick:()=>b(null),children:`Zugang löschen`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hinweise`}),(0,g.jsx)(`p`,{className:`text-sm text-text-2`,children:`Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hermes`}),(0,g.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3`,children:[(0,g.jsx)(a,{className:`size-4`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),(0,g.jsx)(`p`,{className:`text-xs text-text-3`,children:`Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.`})]})]})})}export{_ as Einstellungen}; import{c as e,f as t,t as n,u as r}from"./button-Dd8hfusv.js";import{A as i,F as a,I as o,L as s,j as c,k as l}from"./index-BRMEvo2A.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-DWG2Q04V.js";var h=t(r(),1),g=e();function _({offen:e,onSchliessen:t}){let r=s(),_=o({queryKey:[`geheimnisse`],queryFn:()=>l(`/api/maintenance/geheimnisse`),enabled:e}),[v,y]=(0,h.useState)(``);async function b(e){try{await i(`/api/maintenance/geheimnisse`,{schluessel:`hf_token`,wert:e}),c(`erfolg`,e?`Hugging-Face-Zugang gespeichert.`:`Hugging-Face-Zugang gelöscht.`),y(``),r.invalidateQueries({queryKey:[`geheimnisse`]})}catch(e){c(`fehler`,e.message)}}let x=_.data;return(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-lg`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Einstellungen`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Was die Box sich merkt.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-3 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hugging-Face-Zugang`}),(0,g.jsxs)(`p`,{className:`text-sm text-text-2`,children:[x?.hf_token_gesetzt?`Ist hinterlegt.`:`Fehlt.`,` Nötig für gesperrte Modelle und schnellere Downloads.`,x?.hf_token_aus_env?` Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen.`:``]}),(0,g.jsxs)(`form`,{className:`flex gap-2`,onSubmit:e=>{e.preventDefault(),v.trim()&&b(v.trim())},children:[(0,g.jsx)(`label`,{htmlFor:`hf-token`,className:`sr-only`,children:`Hugging-Face-Zugang`}),(0,g.jsx)(`input`,{id:`hf-token`,type:`password`,autoComplete:`off`,value:v,onChange:e=>y(e.target.value),placeholder:`hf_…`,className:`h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan`}),(0,g.jsx)(n,{type:`submit`,disabled:!v.trim()||x?.schreibbar===!1,children:`Speichern`})]}),x?.hf_token_gesetzt&&!x.hf_token_aus_env&&(0,g.jsx)(n,{variant:`ghost`,size:`sm`,className:`self-start`,onClick:()=>b(null),children:`Zugang löschen`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hinweise`}),(0,g.jsx)(`p`,{className:`text-sm text-text-2`,children:`Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hermes`}),(0,g.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3`,children:[(0,g.jsx)(a,{className:`size-4`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),(0,g.jsx)(`p`,{className:`text-xs text-text-3`,children:`Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.`})]})]})})}export{_ as Einstellungen};
@@ -1 +1 @@
import{c as e,s as t}from"./button-Dd8hfusv.js";import{F as n,N as r,P as i}from"./index-B-ORi4lf.js";import{a,i as o,n as s,r as c,t as l}from"./sheet-DWG2Q04V.js";var u=t(),d=e();function f(e){let t=(0,u.c)(17),{verbindung:f,onDienste:p,onEinstellungen:m,onSchliessen:h}=e,g;t[0]===h?g=t[1]:(g=e=>!e&&h(),t[0]=h,t[1]=g);let _;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,d.jsxs)(o,{children:[(0,d.jsx)(a,{className:`schild text-lg`,children:`Mehr`}),(0,d.jsx)(c,{className:`sr-only`,children:`Weitere Bereiche`})]}),t[2]=_):_=t[2];let v;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,d.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base`,children:[(0,d.jsx)(n,{className:`size-5`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),t[3]=v):v=t[3];let y;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,d.jsx)(i,{className:`size-5`,"aria-hidden":!0}),t[4]=y):y=t[4];let b;t[5]===p?b=t[6]:(b=(0,d.jsxs)(`button`,{type:`button`,onClick:p,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[y,` Dienste und Protokolle`]}),t[5]=p,t[6]=b);let x;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,d.jsx)(r,{className:`size-5`,"aria-hidden":!0}),t[7]=x):x=t[7];let S;t[8]===m?S=t[9]:(S=(0,d.jsxs)(`button`,{type:`button`,onClick:m,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[x,` Einstellungen`]}),t[8]=m,t[9]=S);let C;t[10]!==b||t[11]!==S||t[12]!==f?(C=(0,d.jsxs)(s,{side:`bottom`,className:`border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]`,children:[_,(0,d.jsxs)(`div`,{className:`flex flex-col gap-2 px-4`,children:[f,v,b,S]})]}),t[10]=b,t[11]=S,t[12]=f,t[13]=C):C=t[13];let w;return t[14]!==g||t[15]!==C?(w=(0,d.jsx)(l,{open:!0,onOpenChange:g,children:C}),t[14]=g,t[15]=C,t[16]=w):w=t[16],w}export{f as MehrMenue}; import{c as e,s as t}from"./button-Dd8hfusv.js";import{F as n,N as r,P as i}from"./index-BRMEvo2A.js";import{a,i as o,n as s,r as c,t as l}from"./sheet-DWG2Q04V.js";var u=t(),d=e();function f(e){let t=(0,u.c)(17),{verbindung:f,onDienste:p,onEinstellungen:m,onSchliessen:h}=e,g;t[0]===h?g=t[1]:(g=e=>!e&&h(),t[0]=h,t[1]=g);let _;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,d.jsxs)(o,{children:[(0,d.jsx)(a,{className:`schild text-lg`,children:`Mehr`}),(0,d.jsx)(c,{className:`sr-only`,children:`Weitere Bereiche`})]}),t[2]=_):_=t[2];let v;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,d.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base`,children:[(0,d.jsx)(n,{className:`size-5`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),t[3]=v):v=t[3];let y;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(y=(0,d.jsx)(i,{className:`size-5`,"aria-hidden":!0}),t[4]=y):y=t[4];let b;t[5]===p?b=t[6]:(b=(0,d.jsxs)(`button`,{type:`button`,onClick:p,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[y,` Dienste und Protokolle`]}),t[5]=p,t[6]=b);let x;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(x=(0,d.jsx)(r,{className:`size-5`,"aria-hidden":!0}),t[7]=x):x=t[7];let S;t[8]===m?S=t[9]:(S=(0,d.jsxs)(`button`,{type:`button`,onClick:m,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[x,` Einstellungen`]}),t[8]=m,t[9]=S);let C;t[10]!==b||t[11]!==S||t[12]!==f?(C=(0,d.jsxs)(s,{side:`bottom`,className:`border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]`,children:[_,(0,d.jsxs)(`div`,{className:`flex flex-col gap-2 px-4`,children:[f,v,b,S]})]}),t[10]=b,t[11]=S,t[12]=f,t[13]=C):C=t[13];let w;return t[14]!==g||t[15]!==C?(w=(0,d.jsx)(l,{open:!0,onOpenChange:g,children:C}),t[14]=g,t[15]=C,t[16]=w):w=t[16],w}export{f as MehrMenue};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>MC2 · Box-Wart</title> <title>MC2 · Box-Wart</title>
<script type="module" crossorigin src="/assets/index-B-ORi4lf.js"></script> <script type="module" crossorigin src="/assets/index-BRMEvo2A.js"></script>
<link rel="modulepreload" crossorigin href="/assets/button-Dd8hfusv.js"> <link rel="modulepreload" crossorigin href="/assets/button-Dd8hfusv.js">
<link rel="stylesheet" crossorigin href="/assets/index-C3amg33Y.css"> <link rel="stylesheet" crossorigin href="/assets/index-C3amg33Y.css">
</head> </head>
+21 -6
View File
@@ -52,7 +52,13 @@ function Wert({ name, wert }: { name: string; wert: string }) {
) )
} }
function Rolle({ titel, modell, laeuft, nutzer }: { titel: string; modell?: Modell; laeuft: boolean; nutzer: string }) { function Rolle({ titel, modell, zwilling, laeuft, nutzer }: {
titel: string
modell?: Modell
zwilling?: Modell
laeuft: boolean
nutzer: string
}) {
const laden = useModellLaden() const laden = useModellLaden()
if (!modell) { if (!modell) {
return ( return (
@@ -64,7 +70,8 @@ function Rolle({ titel, modell, laeuft, nutzer }: { titel: string; modell?: Mode
) )
} }
const kontext = modell.ctx ? Math.round(modell.ctx / Math.max(1, modell.parallel_slots) / 1024) : null const kontext = modell.ctx ? Math.round(modell.ctx / Math.max(1, modell.parallel_slots) / 1024) : null
const bilder = Boolean(modell.capabilities?.vision) // Seit 24.09.: Hirn und Coder sehen über ihren Bild-Zwilling (gleiche Gewichte, ohne Draft).
const bilder = Boolean(modell.capabilities?.vision) || Boolean(zwilling)
return ( return (
<article className="flex flex-col gap-3.5 rounded-2xl border border-linie bg-panel p-5"> <article className="flex flex-col gap-3.5 rounded-2xl border border-linie bg-panel p-5">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -77,7 +84,10 @@ function Rolle({ titel, modell, laeuft, nutzer }: { titel: string; modell?: Mode
<Wert name="Kontext" wert={kontext ? `${kontext}k` : ""} /> <Wert name="Kontext" wert={kontext ? `${kontext}k` : ""} />
<Wert name="Bilder" wert={bilder ? "ja" : "nein"} /> <Wert name="Bilder" wert={bilder ? "ja" : "nein"} />
</dl> </dl>
<p className="text-sm text-text-2">Genutzt von {nutzer}.{modell.spec_active ? " Mit Draft-Beschleunigung." : ""}</p> <p className="text-sm text-text-2">
Genutzt von {nutzer}.{modell.spec_active ? " Mit Draft-Beschleunigung." : ""}
{zwilling ? " Bilder sieht es über seinen Bild-Zwilling, der nur bei Bedarf lädt." : ""}
</p>
{!laeuft && ( {!laeuft && (
<Button variant="outline" size="sm" className="mt-auto self-start" onClick={() => laden.mutate(modell.name)} disabled={laden.isPending}> <Button variant="outline" size="sm" className="mt-auto self-start" onClick={() => laden.mutate(modell.name)} disabled={laden.isPending}>
Jetzt laden Jetzt laden
@@ -275,15 +285,20 @@ export function ModelleSeite() {
const laufend = modelle.data.running const laufend = modelle.data.running
const hirn = liste.find((m) => rolleVon(m) === "hirn") const hirn = liste.find((m) => rolleVon(m) === "hirn")
const coder = liste.find((m) => rolleVon(m) === "coder") const coder = liste.find((m) => rolleVon(m) === "coder")
const weitere = liste.filter((m) => m !== hirn && m !== coder) // Bild-Zwilling: gleiche Gewichte, eigener Alias (vision bzw. coder-bild).
const zwilling = (rolle: Modell | undefined, alias: string) =>
rolle ? liste.find((m) => m !== rolle && m.aliases.includes(alias) && m.filename === rolle.filename) : undefined
const hirnZwilling = zwilling(hirn, "vision")
const coderZwilling = zwilling(coder, "coder-bild")
const weitere = liste.filter((m) => ![hirn, coder, hirnZwilling, coderZwilling].includes(m))
const zuletzt = nutzung.data?.zuletzt_geladen ?? {} const zuletzt = nutzung.data?.zuletzt_geladen ?? {}
return ( return (
<> <>
<Speicher modelle={liste} laufend={laufend} /> <Speicher modelle={liste} laufend={laufend} />
<section aria-label="Rollen" className="grid gap-5 md:grid-cols-3 md:gap-6"> <section aria-label="Rollen" className="grid gap-5 md:grid-cols-3 md:gap-6">
<Rolle titel="Hirn" modell={hirn} laeuft={!!hirn && laufend.includes(hirn.name)} nutzer="Lucy, NerdQuiz und OpenChamber für Kleinkram" /> <Rolle titel="Hirn" modell={hirn} zwilling={hirnZwilling} laeuft={!!hirn && laufend.includes(hirn.name)} nutzer="Lucy, NerdQuiz und OpenChamber für Kleinkram" />
<Rolle titel="Coder" modell={coder} laeuft={!!coder && laufend.includes(coder.name)} nutzer="OpenChamber zum Planen und Bauen und Lucys Delegation" /> <Rolle titel="Coder" modell={coder} zwilling={coderZwilling} laeuft={!!coder && laufend.includes(coder.name)} nutzer="OpenChamber zum Planen und Bauen und Lucys Delegation" />
<Rolle titel="Dritte Rolle" laeuft={false} nutzer="" /> <Rolle titel="Dritte Rolle" laeuft={false} nutzer="" />
</section> </section>
<div className="grid gap-5 md:gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]"> <div className="grid gap-5 md:gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">