Feat: Frontend-Overhaul E — Lane-Editor, Routing-Policy (hot-reload) & Latenz-Karte
Teil 1: VoiceLatencyCard auf dem Dashboard (GET /api/voice/metrics, C2) — zeigt STT/Vision/Chat-TTFB/TTS mit p50/p95/last + count. Teil 2: UI-editierbare Routing-Policy. Neuer routing_policy.py (hot-reload JSON unter MODELS_DIR/mc2-routing.json, Env=Defaults, atomarer Write, Validierung). router_logic, gateway_proxy und gateway.routing_summary lesen jetzt live via load_policy(); routing_summary ist lane-bewusst (chat/coding statt altem auto). Neue Endpoints GET/PUT /api/routing/policy. Teil 3: LaneEditor.tsx als ZONE im Cockpit (chat/coding-Aliase + Schwellen + fast_no_think, Speichern/Default-je-Feld); Gateway-Node zeigt die Lanes. Verifiziert: npm run build (tsc strict) clean, FastAPI TestClient (GET/PUT, Validierung, Persistenz, Hot-reload durch die API), venv-Smoke (Routing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,8 @@ from fastapi.responses import JSONResponse, StreamingResponse
|
|||||||
|
|
||||||
from config import LLAMA_SWAP_URL
|
from config import LLAMA_SWAP_URL
|
||||||
from services.gateway_stream import record_stream_chunk, record_usage
|
from services.gateway_stream import record_stream_chunk, record_usage
|
||||||
from services.router_logic import FAST, FAST_NO_THINK, LANES, choose_for_lane
|
from services.router_logic import LANES, choose_for_lane
|
||||||
|
from services.routing_policy import load_policy
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1")
|
router = APIRouter(prefix="/v1")
|
||||||
|
|
||||||
@@ -37,7 +38,8 @@ async def _proxy(path: str, request: Request):
|
|||||||
alias = requested
|
alias = requested
|
||||||
routed = {"x-mc-routed-to": requested}
|
routed = {"x-mc-routed-to": requested}
|
||||||
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
|
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
|
||||||
if FAST_NO_THINK and alias == FAST and "chat_template_kwargs" not in body:
|
pol = load_policy()
|
||||||
|
if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body:
|
||||||
body["chat_template_kwargs"] = {"enable_thinking": False}
|
body["chat_template_kwargs"] = {"enable_thinking": False}
|
||||||
url = f"{LLAMA_SWAP_URL}{path}"
|
url = f"{LLAMA_SWAP_URL}{path}"
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"""Routing-Endpoint: zeigt den eingebauten Gateway (model:auto fast↔heavy)."""
|
"""Routing-Endpoints: Lane-Summary (chat/coding) + UI-editierbare Policy (hot-reload)."""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from services import gateway
|
from services import gateway
|
||||||
|
from services.routing_policy import policy_meta, save_policy
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
@@ -10,3 +12,32 @@ router = APIRouter(prefix="/api")
|
|||||||
@router.get("/routing")
|
@router.get("/routing")
|
||||||
def routing() -> dict:
|
def routing() -> dict:
|
||||||
return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()}
|
return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/routing/policy")
|
||||||
|
def get_policy() -> dict:
|
||||||
|
"""Aktuelle Policy + Defaults (für „Zurücksetzen“) + Feld-Spezifikation für den Editor."""
|
||||||
|
return policy_meta()
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyPatch(BaseModel):
|
||||||
|
fast: str | None = None
|
||||||
|
heavy: str | None = None
|
||||||
|
coder: str | None = None
|
||||||
|
coder_lite: str | None = None
|
||||||
|
heavy_chars: int | None = None
|
||||||
|
coding_escalate_chars: int | None = None
|
||||||
|
fast_no_think: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/routing/policy")
|
||||||
|
def put_policy(patch: PolicyPatch) -> dict:
|
||||||
|
"""Teil-Update der Routing-Policy. Validiert, persistiert atomar, sofort wirksam (hot-reload)."""
|
||||||
|
fields = {k: v for k, v in patch.model_dump().items() if v is not None}
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren.")
|
||||||
|
try:
|
||||||
|
new_policy = save_policy(fields)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Ungültige Policy: {e}")
|
||||||
|
return {"policy": new_policy}
|
||||||
|
|||||||
@@ -6,20 +6,36 @@ LiteLLM-Dienst nötig (baut auf Python 3.14 nicht); bleibt später austauschbar.
|
|||||||
|
|
||||||
from config import PORT
|
from config import PORT
|
||||||
from services.llamaswap import engine_reachable
|
from services.llamaswap import engine_reachable
|
||||||
from services.router_logic import FAST, HEAVY, HEAVY_CHARS
|
from services.routing_policy import load_policy
|
||||||
|
|
||||||
|
|
||||||
def routing_summary() -> dict:
|
def routing_summary() -> dict:
|
||||||
|
p = load_policy()
|
||||||
|
coding_default = p["coder_lite"] or p["coder"]
|
||||||
return {
|
return {
|
||||||
"mode": "builtin",
|
"mode": "builtin",
|
||||||
"endpoint": f":{PORT}/v1 (OpenAI-kompatibel)",
|
"endpoint": f":{PORT}/v1 (OpenAI-kompatibel)",
|
||||||
|
# Virtuelle Lanes, die Clients/IDEs als „Modell" wählen (Router pickt das echte Alias).
|
||||||
|
"lanes": [
|
||||||
|
{
|
||||||
|
"name": "chat",
|
||||||
|
"aka": "auto",
|
||||||
|
"target": f"{p['fast']} ↔ {p['heavy']} (nach Komplexität)",
|
||||||
|
"threshold_chars": p["heavy_chars"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "coding",
|
||||||
|
"target": f"{coding_default} ↔ {p['coder']} (Eskalation)",
|
||||||
|
"escalate_chars": p["coding_escalate_chars"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
# Rückwärtskompatible Flach-Liste (alte UI/Clients).
|
||||||
"routes": [
|
"routes": [
|
||||||
{"name": "auto", "target": f"{FAST} ↔ {HEAVY} (nach Komplexität)"},
|
{"name": "chat", "target": f"{p['fast']} ↔ {p['heavy']} (nach Komplexität)"},
|
||||||
{"name": FAST, "target": "llama-swap-Alias 'fast'"},
|
{"name": "coding", "target": f"{coding_default} ↔ {p['coder']} (Eskalation)"},
|
||||||
{"name": HEAVY, "target": "llama-swap-Alias 'heavy'"},
|
|
||||||
{"name": "<alias>", "target": "llama-swap-Passthrough (lädt bei Bedarf)"},
|
{"name": "<alias>", "target": "llama-swap-Passthrough (lädt bei Bedarf)"},
|
||||||
],
|
],
|
||||||
"heavy_threshold_chars": HEAVY_CHARS,
|
"heavy_threshold_chars": p["heavy_chars"],
|
||||||
"fallbacks": [],
|
"fallbacks": [],
|
||||||
"context_window_fallbacks": [],
|
"context_window_fallbacks": [],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,26 +6,18 @@ Zwei virtuelle Lanes, die Clients/IDEs auswählen — der Router pickt das echte
|
|||||||
- **coding**: Code-Arbeit → `coder` (Qwen3-Coder-Next); riesiger/architektonischer Kontext → `heavy`;
|
- **coding**: Code-Arbeit → `coder` (Qwen3-Coder-Next); riesiger/architektonischer Kontext → `heavy`;
|
||||||
triviale Kurzfrage ohne Code → `fast` (Tempo).
|
triviale Kurzfrage ohne Code → `fast` (Tempo).
|
||||||
|
|
||||||
Regelbasiert, sub-ms, ohne Cloud. Schwellen/Aliases via Env überschreibbar (Phase 2: UI-editierbare
|
Regelbasiert, sub-ms, ohne Cloud. Schwellen/Aliases liegen in einer UI-editierbaren Policy
|
||||||
Policy-JSON). Lucy läuft NICHT hierüber — die ist der Hermes-Agent (:8642), eigene Ebene.
|
(routing_policy.py, hot-reload; Env = Defaults). Lucy läuft NICHT hierüber — die ist der
|
||||||
|
Hermes-Agent (:8642), eigene Ebene.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
# Echte Modell-Aliases hinter den Lanes (Env-überschreibbar).
|
from services.routing_policy import load_policy
|
||||||
FAST = os.environ.get("MC_ROUTE_FAST", "fast")
|
|
||||||
HEAVY = os.environ.get("MC_ROUTE_HEAVY", "heavy")
|
|
||||||
CODER = os.environ.get("MC_ROUTE_CODER", "coder")
|
|
||||||
|
|
||||||
# Schwellen (Zeichen).
|
# Modell-Aliases & Zeichen-Schwellen liegen jetzt in der UI-editierbaren Policy
|
||||||
HEAVY_CHARS = int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")) # chat → heavy
|
# (routing_policy.py) und kommen pro Request via load_policy() (hot-reload). Die Env-Vars
|
||||||
# coding-Lane: leichter/schneller Coder als Default (wenn gesetzt = Phase 2b), starker Coder als Eskalation.
|
# sind dort die Defaults. Die Regex-Keyword-Listen unten bleiben bewusst im Code.
|
||||||
CODER_LITE = os.environ.get("MC_ROUTE_CODER_LITE", "").strip() # z.B. "coder-lite"
|
|
||||||
CODING_ESCALATE_CHARS = int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")) # darüber → starker Coder
|
|
||||||
|
|
||||||
# Thinking auf der fast-Spur aus → flotte Alltags-Antworten (Qwen3.6 ist ein Reasoning-Modell).
|
|
||||||
FAST_NO_THINK = os.environ.get("MC_FAST_NO_THINK", "1") not in ("0", "false", "")
|
|
||||||
|
|
||||||
# Virtuelle Lanes, die im Gateway als „Modelle" sichtbar sind.
|
# Virtuelle Lanes, die im Gateway als „Modelle" sichtbar sind.
|
||||||
LANES = ["coding", "chat"]
|
LANES = ["coding", "chat"]
|
||||||
@@ -64,21 +56,23 @@ def _text_of(body: dict) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _route_chat(text: str, n: int) -> tuple[str, str]:
|
def _route_chat(text: str, n: int) -> tuple[str, str]:
|
||||||
if n > HEAVY_CHARS:
|
p = load_policy()
|
||||||
return HEAVY, f"langer Kontext ({n} > {HEAVY_CHARS} Zeichen)"
|
if n > p["heavy_chars"]:
|
||||||
|
return p["heavy"], f"langer Kontext ({n} > {p['heavy_chars']} Zeichen)"
|
||||||
if _HEAVY_KW.search(text):
|
if _HEAVY_KW.search(text):
|
||||||
return HEAVY, "Komplexitäts-Schlüsselwort erkannt"
|
return p["heavy"], "Komplexitäts-Schlüsselwort erkannt"
|
||||||
return FAST, "Standard"
|
return p["fast"], "Standard"
|
||||||
|
|
||||||
|
|
||||||
def _route_coding(text: str, n: int) -> tuple[str, str]:
|
def _route_coding(text: str, n: int) -> tuple[str, str]:
|
||||||
# Agentisches Coden (OpenCode/RooCode/…) bleibt IMMER beim dedizierten Coder — NIE heavy/fast
|
# Agentisches Coden (OpenCode/RooCode/…) bleibt IMMER beim dedizierten Coder — NIE heavy/fast
|
||||||
# (das sind Allzweck-Modelle, schwächer bei Code). Die Qwen-Coder packen 256K–1M Kontext selbst,
|
# (das sind Allzweck-Modelle, schwächer bei Code). Die Qwen-Coder packen 256K–1M Kontext selbst,
|
||||||
# langer Repo-Kontext ist bei Agenten der Normalfall und darf NICHT zu heavy umrouten.
|
# langer Repo-Kontext ist bei Agenten der Normalfall und darf NICHT zu heavy umrouten.
|
||||||
# (Phase 2b: warme schnelle Coder-Stufe CODER_LITE als Default + CODER als Eskalation.)
|
# (Phase 2b: warme schnelle Coder-Stufe coder_lite als Default + coder als Eskalation.)
|
||||||
if CODER_LITE and not _CODING_HEAVY_KW.search(text) and n <= CODING_ESCALATE_CHARS:
|
p = load_policy()
|
||||||
return CODER_LITE, "Coding (schneller Coder)"
|
if p["coder_lite"] and not _CODING_HEAVY_KW.search(text) and n <= p["coding_escalate_chars"]:
|
||||||
return CODER, "Coding -> starker Coder"
|
return p["coder_lite"], "Coding (schneller Coder)"
|
||||||
|
return p["coder"], "Coding -> starker Coder"
|
||||||
|
|
||||||
|
|
||||||
def choose_for_lane(lane: str, body: dict) -> tuple[str, str]:
|
def choose_for_lane(lane: str, body: dict) -> tuple[str, str]:
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
UI-editierbare Routing-Policy für die Gateway-Lanes (coding/chat).
|
||||||
|
|
||||||
|
Persistiert als JSON unter MC_ROUTING_POLICY_PATH (Default MODELS_DIR/mc2-routing.json —
|
||||||
|
gleiche Konvention wie mc2-discover.json). **Hot-reload:** load_policy() liest die Datei nur
|
||||||
|
bei Änderung neu (mtime-Cache) → UI-Edits greifen ohne Dienst-Neustart. Die Env-Vars (bisher
|
||||||
|
einzige Stellschraube in router_logic.py) bleiben als Defaults/Fallback erhalten.
|
||||||
|
|
||||||
|
Bewusst NICHT editierbar (v1): die Regex-Keyword-Listen (heavy/coding-heavy/code-hint) — die
|
||||||
|
bleiben in router_logic.py im Code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import MODELS_DIR
|
||||||
|
|
||||||
|
POLICY_PATH = Path(os.environ.get("MC_ROUTING_POLICY_PATH", str(MODELS_DIR / "mc2-routing.json")))
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bool(name: str, default: str) -> bool:
|
||||||
|
return os.environ.get(name, default) not in ("0", "false", "")
|
||||||
|
|
||||||
|
|
||||||
|
# Defaults aus den Env-Vars — Quelle der Wahrheit, solange keine Policy-Datei existiert.
|
||||||
|
DEFAULTS: dict = {
|
||||||
|
"fast": os.environ.get("MC_ROUTE_FAST", "fast"),
|
||||||
|
"heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"),
|
||||||
|
"coder": os.environ.get("MC_ROUTE_CODER", "coder"),
|
||||||
|
"coder_lite": os.environ.get("MC_ROUTE_CODER_LITE", "").strip(),
|
||||||
|
"heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")),
|
||||||
|
"coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")),
|
||||||
|
"fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung.
|
||||||
|
FIELDS: list[dict] = [
|
||||||
|
{"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"},
|
||||||
|
{"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "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": "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": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"},
|
||||||
|
]
|
||||||
|
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
_CACHE: dict = {"mtime": None, "policy": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_file() -> dict:
|
||||||
|
try:
|
||||||
|
with open(POLICY_PATH, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce(patch: dict) -> dict:
|
||||||
|
"""Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten."""
|
||||||
|
spec = {f["key"]: f for f in FIELDS}
|
||||||
|
out: dict = {}
|
||||||
|
for k, v in (patch or {}).items():
|
||||||
|
f = spec.get(k)
|
||||||
|
if not f:
|
||||||
|
continue # unbekannte Keys still verwerfen
|
||||||
|
if f["type"] == "int":
|
||||||
|
iv = int(v)
|
||||||
|
lo, hi = f.get("min", 1), f.get("max", 10**9)
|
||||||
|
if not (lo <= iv <= hi):
|
||||||
|
raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]")
|
||||||
|
out[k] = iv
|
||||||
|
elif f["type"] == "bool":
|
||||||
|
out[k] = bool(v)
|
||||||
|
else: # str
|
||||||
|
sv = str(v).strip()
|
||||||
|
if k != "coder_lite" and not sv:
|
||||||
|
raise ValueError(f"{k} darf nicht leer sein")
|
||||||
|
out[k] = sv
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_safe(patch: dict) -> dict:
|
||||||
|
"""Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen."""
|
||||||
|
try:
|
||||||
|
return _coerce(patch)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def load_policy() -> dict:
|
||||||
|
"""Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig."""
|
||||||
|
try:
|
||||||
|
mtime = POLICY_PATH.stat().st_mtime
|
||||||
|
except OSError:
|
||||||
|
mtime = None
|
||||||
|
with _LOCK:
|
||||||
|
if _CACHE["policy"] is None or _CACHE["mtime"] != mtime:
|
||||||
|
merged = {**DEFAULTS}
|
||||||
|
if mtime is not None:
|
||||||
|
merged.update(_coerce_safe(_read_file()))
|
||||||
|
_CACHE["mtime"] = mtime
|
||||||
|
_CACHE["policy"] = merged
|
||||||
|
return dict(_CACHE["policy"])
|
||||||
|
|
||||||
|
|
||||||
|
def save_policy(patch: dict) -> dict:
|
||||||
|
"""Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück."""
|
||||||
|
clean = _coerce(patch) # wirft bei ungültigem Input
|
||||||
|
with _LOCK:
|
||||||
|
current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean}
|
||||||
|
POLICY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = POLICY_PATH.with_suffix(".json.tmp")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(current, f, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(tmp, POLICY_PATH)
|
||||||
|
_CACHE["mtime"] = None # nächster load_policy() lädt frisch
|
||||||
|
_CACHE["policy"] = None
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def policy_meta() -> dict:
|
||||||
|
"""Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation."""
|
||||||
|
return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS}
|
||||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+339
-324
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -7,8 +7,8 @@
|
|||||||
<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>Mission Control 2.0</title>
|
<title>Mission Control 2.0</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BK9pTA8z.js"></script>
|
<script type="module" crossorigin src="/assets/index-CyyjRWEt.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CDCyVscm.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CHGCSDst.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Gauge } from "lucide-react"
|
||||||
|
import { useVoiceMetrics } from "@/lib/queries"
|
||||||
|
import type { VoiceStageStat } from "@/lib/api"
|
||||||
|
|
||||||
|
// Stabile Reihenfolge + deutsche Labels, spiegelt STAGES in backend/services/voice_metrics.py.
|
||||||
|
const STAGES: { key: string; label: string; hint: string }[] = [
|
||||||
|
{ key: "stt", label: "STT", hint: "Sprache → Text" },
|
||||||
|
{ key: "vision", label: "Bildschirm-Sicht", hint: "Vision-Beschreibung" },
|
||||||
|
{ key: "chat_ttfb", label: "Chat-TTFB", hint: "Zeit bis 1. Token" },
|
||||||
|
{ key: "tts", label: "TTS", hint: "Text → Sprache" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function fmtMs(ms?: number): string {
|
||||||
|
if (ms == null) return "—"
|
||||||
|
return ms >= 1000 ? `${(ms / 1000).toFixed(2)} s` : `${Math.round(ms)} ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
// p95 relativ zum langsamsten p95 aller Stufen → grobe Balkenlänge.
|
||||||
|
function StageRow({ stat, label, hint, maxP95 }: { stat?: VoiceStageStat; label: string; hint: string; maxP95: number }) {
|
||||||
|
const has = !!stat && stat.count > 0
|
||||||
|
const pct = has && stat!.p95_ms && maxP95 > 0 ? Math.max(4, Math.min(100, (stat!.p95_ms! / maxP95) * 100)) : 0
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<div className="flex items-baseline gap-2 min-w-0">
|
||||||
|
<span className="text-xs font-semibold text-foreground">{label}</span>
|
||||||
|
<span className="truncate text-[10px] text-muted-foreground/60">{hint}</span>
|
||||||
|
</div>
|
||||||
|
{has ? (
|
||||||
|
<span className="shrink-0 font-mono text-sm font-bold tabular-nums text-foreground">{fmtMs(stat!.p50_ms)}</span>
|
||||||
|
) : (
|
||||||
|
<span className="shrink-0 text-[10px] italic text-muted-foreground/50">noch keine Messungen</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-background/50 border border-border/30">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-teal-500 to-indigo-500 transition-all duration-500"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{has && (
|
||||||
|
<div className="flex items-center gap-3 font-mono text-[10px] text-muted-foreground/65">
|
||||||
|
<span>p50 {fmtMs(stat!.p50_ms)}</span>
|
||||||
|
<span>p95 {fmtMs(stat!.p95_ms)}</span>
|
||||||
|
<span>zuletzt {fmtMs(stat!.last_ms)}</span>
|
||||||
|
<span className="text-muted-foreground/45">· n={stat!.count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoiceLatencyCard() {
|
||||||
|
const { data: metrics } = useVoiceMetrics(5_000)
|
||||||
|
const maxP95 = Math.max(1, ...STAGES.map((s) => metrics?.[s.key]?.p95_ms ?? 0))
|
||||||
|
const anyData = STAGES.some((s) => (metrics?.[s.key]?.count ?? 0) > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<Gauge className="h-4.5 w-4.5 text-primary" />
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Sprach-Latenz</h2>
|
||||||
|
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground/70">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 animate-pulse" /> live
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{STAGES.map((s) => (
|
||||||
|
<StageRow key={s.key} stat={metrics?.[s.key]} label={s.label} hint={s.hint} maxP95={maxP95} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 border-t border-border/30 pt-2 text-[10px] text-muted-foreground/70">
|
||||||
|
{anyData
|
||||||
|
? "Server-seitige Dauer je Pipeline-Stufe (p50 prominent). Rollender Schnitt über die letzten Turns; Reset bei Neustart."
|
||||||
|
: "Noch keine Voice-Turns gemessen — sprich einmal über den „Sprechen“-Tab, dann erscheinen hier STT/Vision/Chat/TTS."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Sliders, RotateCcw, Check, Loader2, MessageSquare, Code2 } from "lucide-react"
|
||||||
|
import { updateRoutingPolicy, type RoutingPolicy } from "@/lib/api"
|
||||||
|
import { useRoutingPolicy, useQueryClient, qk } from "@/lib/queries"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Welche Policy-Felder zu welcher Lane gehören (Rest ist global).
|
||||||
|
const CHAT_FIELDS: (keyof RoutingPolicy)[] = ["fast", "heavy", "heavy_chars"]
|
||||||
|
const CODING_FIELDS: (keyof RoutingPolicy)[] = ["coder_lite", "coder", "coding_escalate_chars"]
|
||||||
|
|
||||||
|
export function LaneEditor() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { data: meta, isLoading } = useRoutingPolicy()
|
||||||
|
const [draft, setDraft] = useState<RoutingPolicy | null>(null)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [err, setErr] = useState("")
|
||||||
|
const [savedAt, setSavedAt] = useState(0)
|
||||||
|
|
||||||
|
// Draft initialisieren, sobald die Policy geladen ist (und nicht überschreiben, wenn schon editiert).
|
||||||
|
useEffect(() => {
|
||||||
|
if (meta?.policy && !draft) setDraft({ ...meta.policy })
|
||||||
|
}, [meta, draft])
|
||||||
|
|
||||||
|
if (isLoading || !meta || !draft) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 text-xs text-muted-foreground">
|
||||||
|
Lade Routing-Policy…
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldSpec = (key: keyof RoutingPolicy) => meta.fields.find((f) => f.key === key)!
|
||||||
|
const dirty = (Object.keys(draft) as (keyof RoutingPolicy)[]).some((k) => draft[k] !== meta.policy[k])
|
||||||
|
|
||||||
|
const set = <K extends keyof RoutingPolicy>(key: K, value: RoutingPolicy[K]) => {
|
||||||
|
setDraft((d) => (d ? { ...d, [key]: value } : d))
|
||||||
|
setErr("")
|
||||||
|
}
|
||||||
|
const resetField = (key: keyof RoutingPolicy) => set(key, meta.defaults[key])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!draft) return
|
||||||
|
const patch: Partial<RoutingPolicy> = {}
|
||||||
|
for (const k of Object.keys(draft) as (keyof RoutingPolicy)[]) {
|
||||||
|
if (draft[k] !== meta!.policy[k]) (patch as any)[k] = draft[k]
|
||||||
|
}
|
||||||
|
if (Object.keys(patch).length === 0) return
|
||||||
|
setSaving(true)
|
||||||
|
setErr("")
|
||||||
|
try {
|
||||||
|
const { policy } = await updateRoutingPolicy(patch)
|
||||||
|
setDraft({ ...policy })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.routingPolicy })
|
||||||
|
qc.invalidateQueries({ queryKey: qk.routing })
|
||||||
|
setSavedAt(Date.now())
|
||||||
|
setTimeout(() => setSavedAt(0), 2000)
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(e.message || String(e))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ k }: { k: keyof RoutingPolicy }) {
|
||||||
|
const spec = fieldSpec(k)
|
||||||
|
const val = draft![k]
|
||||||
|
const isDefault = draft![k] === meta!.defaults[k]
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<label className="text-[10px] font-semibold text-muted-foreground">{spec.label}</label>
|
||||||
|
{!isDefault && (
|
||||||
|
<button
|
||||||
|
onClick={() => resetField(k)}
|
||||||
|
className="flex items-center gap-0.5 text-[9px] text-muted-foreground/60 hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
title={`Auf Default zurücksetzen (${String(meta!.defaults[k]) || "leer"})`}
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-2.5 w-2.5" /> Default
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{spec.type === "bool" ? (
|
||||||
|
<button
|
||||||
|
onClick={() => set(k, !val as any)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-8 w-full items-center justify-between rounded-lg border px-3 text-[11px] font-semibold transition-all cursor-pointer",
|
||||||
|
val ? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300" : "border-border/40 bg-background/40 text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{val ? "An" : "Aus"}</span>
|
||||||
|
<span className={cn("h-3.5 w-3.5 rounded-full transition-colors", val ? "bg-emerald-400" : "bg-muted-foreground/40")} />
|
||||||
|
</button>
|
||||||
|
) : spec.type === "int" ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={val as number}
|
||||||
|
min={spec.min}
|
||||||
|
max={spec.max}
|
||||||
|
onChange={(e) => set(k, Number(e.target.value) as any)}
|
||||||
|
className="h-8 w-full rounded-lg border border-border/40 bg-background/40 px-3 font-mono text-[11px] text-foreground outline-none focus:border-primary/50"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={val as string}
|
||||||
|
placeholder={k === "coder_lite" ? "(leer = aus)" : ""}
|
||||||
|
onChange={(e) => set(k, e.target.value as any)}
|
||||||
|
className="h-8 w-full rounded-lg border border-border/40 bg-background/40 px-3 font-mono text-[11px] text-foreground outline-none focus:border-primary/50"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sliders className="h-4.5 w-4.5 text-primary" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground">Lane-Routing & Policy</span>
|
||||||
|
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-300 border border-emerald-500/20">hot-reload</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{err && <span className="text-[10px] text-red-400 max-w-[280px] truncate" title={err}>{err}</span>}
|
||||||
|
{savedAt > 0 && (
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-emerald-400"><Check className="h-3.5 w-3.5" /> gespeichert</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
className={cn(
|
||||||
|
"h-8 px-4 rounded-lg text-[10px] font-bold uppercase tracking-wide transition-all flex items-center gap-1.5",
|
||||||
|
dirty && !saving
|
||||||
|
? "bg-primary text-primary-foreground hover:opacity-90 cursor-pointer shadow-md shadow-primary/10"
|
||||||
|
: "bg-background/40 text-muted-foreground/50 border border-border/40 cursor-not-allowed",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-muted-foreground/70 leading-relaxed -mt-1">
|
||||||
|
Welches echte Modell hinter den virtuellen Lanes <code className="text-cyan-300">chat</code> und{" "}
|
||||||
|
<code className="text-cyan-300">coding</code> steckt. Änderungen greifen sofort (kein Neustart). Die
|
||||||
|
Keyword-Heuristiken bleiben im Code.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
{/* chat-Lane */}
|
||||||
|
<div className="rounded-xl border border-border/40 bg-background/25 p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border/30 pb-2">
|
||||||
|
<MessageSquare className="h-4 w-4 text-teal-400" />
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider text-foreground">chat</span>
|
||||||
|
<span className="text-[9px] text-muted-foreground/60 font-mono">(= auto)</span>
|
||||||
|
</div>
|
||||||
|
{CHAT_FIELDS.map((k) => <Field key={k} k={k} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* coding-Lane */}
|
||||||
|
<div className="rounded-xl border border-border/40 bg-background/25 p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border/30 pb-2">
|
||||||
|
<Code2 className="h-4 w-4 text-indigo-400" />
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-wider text-foreground">coding</span>
|
||||||
|
<span className="text-[9px] text-muted-foreground/60 font-mono">(agentisch → immer Coder)</span>
|
||||||
|
</div>
|
||||||
|
{CODING_FIELDS.map((k) => <Field key={k} k={k} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Globale Schalter */}
|
||||||
|
<div className="rounded-xl border border-border/40 bg-background/25 p-4">
|
||||||
|
<div className="max-w-xs"><Field k="fast_no_think" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -191,11 +191,50 @@ export interface RoutingResp {
|
|||||||
endpoint?: string
|
endpoint?: string
|
||||||
heavy_threshold_chars?: number
|
heavy_threshold_chars?: number
|
||||||
routes: { name: string; target: string }[]
|
routes: { name: string; target: string }[]
|
||||||
|
lanes?: { name: string; target: string; threshold_chars?: number; escalate_chars?: number; aka?: string }[]
|
||||||
fallbacks: Record<string, string[]>[]
|
fallbacks: Record<string, string[]>[]
|
||||||
context_window_fallbacks: Record<string, string[]>[]
|
context_window_fallbacks: Record<string, string[]>[]
|
||||||
gateway_reachable: boolean
|
gateway_reachable: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UI-editierbare Routing-Policy (GET/PUT /api/routing/policy). Aliase + Zeichen-Schwellen
|
||||||
|
// hinter den Lanes; hot-reload im Backend (kein Restart).
|
||||||
|
export interface RoutingPolicy {
|
||||||
|
fast: string
|
||||||
|
heavy: string
|
||||||
|
coder: string
|
||||||
|
coder_lite: string
|
||||||
|
heavy_chars: number
|
||||||
|
coding_escalate_chars: number
|
||||||
|
fast_no_think: boolean
|
||||||
|
}
|
||||||
|
export interface RoutingPolicyField {
|
||||||
|
key: keyof RoutingPolicy
|
||||||
|
label: string
|
||||||
|
type: "str" | "int" | "bool"
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
}
|
||||||
|
export interface RoutingPolicyMeta {
|
||||||
|
policy: RoutingPolicy
|
||||||
|
defaults: RoutingPolicy
|
||||||
|
fields: RoutingPolicyField[]
|
||||||
|
}
|
||||||
|
export const getRoutingPolicy = () => api<RoutingPolicyMeta>("/api/routing/policy")
|
||||||
|
export const updateRoutingPolicy = (patch: Partial<RoutingPolicy>) =>
|
||||||
|
api<{ policy: RoutingPolicy }>("/api/routing/policy", { method: "PUT", body: JSON.stringify(patch) })
|
||||||
|
|
||||||
|
// Per-Stage-Latenz der Voice/Lucy-Pipeline (GET /api/voice/metrics, C2).
|
||||||
|
// Schlüssel = Stufe (stt|vision|chat_ttfb|tts), Wert = rollende Statistik.
|
||||||
|
export interface VoiceStageStat {
|
||||||
|
count: number
|
||||||
|
avg_ms?: number
|
||||||
|
p50_ms?: number
|
||||||
|
p95_ms?: number
|
||||||
|
last_ms?: number
|
||||||
|
}
|
||||||
|
export type VoiceMetrics = Record<string, VoiceStageStat>
|
||||||
|
|
||||||
export interface GitInfo {
|
export interface GitInfo {
|
||||||
hash: string
|
hash: string
|
||||||
date: string
|
date: string
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ import {
|
|||||||
type MemoryGraph,
|
type MemoryGraph,
|
||||||
type ModelsResp,
|
type ModelsResp,
|
||||||
type RoutingResp,
|
type RoutingResp,
|
||||||
|
type RoutingPolicyMeta,
|
||||||
type ServicesResp,
|
type ServicesResp,
|
||||||
type SystemStatus,
|
type SystemStatus,
|
||||||
type TokenStats,
|
type TokenStats,
|
||||||
type UpdatesResp,
|
type UpdatesResp,
|
||||||
|
type VoiceMetrics,
|
||||||
} from "./api"
|
} from "./api"
|
||||||
|
|
||||||
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
|
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
|
||||||
@@ -32,6 +34,8 @@ export const qk = {
|
|||||||
models: ["models"] as const,
|
models: ["models"] as const,
|
||||||
groups: ["groups"] as const,
|
groups: ["groups"] as const,
|
||||||
routing: ["routing"] as const,
|
routing: ["routing"] as const,
|
||||||
|
routingPolicy: ["routing-policy"] as const,
|
||||||
|
voiceMetrics: ["voice-metrics"] as const,
|
||||||
jobs: ["jobs"] as const,
|
jobs: ["jobs"] as const,
|
||||||
tokenStats: ["token-stats"] as const,
|
tokenStats: ["token-stats"] as const,
|
||||||
agentStatus: ["agent-status"] as const,
|
agentStatus: ["agent-status"] as const,
|
||||||
@@ -70,6 +74,12 @@ export const useGroups = (refetchInterval = 8_000) =>
|
|||||||
export const useRouting = (refetchInterval = 4_000) =>
|
export const useRouting = (refetchInterval = 4_000) =>
|
||||||
useQuery({ queryKey: qk.routing, queryFn: () => api<RoutingResp>("/api/routing"), refetchInterval })
|
useQuery({ queryKey: qk.routing, queryFn: () => api<RoutingResp>("/api/routing"), refetchInterval })
|
||||||
|
|
||||||
|
export const useVoiceMetrics = (refetchInterval = 5_000) =>
|
||||||
|
useQuery({ queryKey: qk.voiceMetrics, queryFn: () => api<VoiceMetrics>("/api/voice/metrics"), refetchInterval })
|
||||||
|
|
||||||
|
export const useRoutingPolicy = () =>
|
||||||
|
useQuery({ queryKey: qk.routingPolicy, queryFn: () => api<RoutingPolicyMeta>("/api/routing/policy") })
|
||||||
|
|
||||||
export const useJobs = (refetchInterval = 2_000) =>
|
export const useJobs = (refetchInterval = 2_000) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: qk.jobs,
|
queryKey: qk.jobs,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { RolesCard } from "@/components/dashboard/RolesCard"
|
|||||||
import { MemoryInputCard } from "@/components/dashboard/MemoryInputCard"
|
import { MemoryInputCard } from "@/components/dashboard/MemoryInputCard"
|
||||||
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
|
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
|
||||||
import { ServicesCard } from "@/components/dashboard/ServicesCard"
|
import { ServicesCard } from "@/components/dashboard/ServicesCard"
|
||||||
|
import { VoiceLatencyCard } from "@/components/dashboard/VoiceLatencyCard"
|
||||||
|
|
||||||
function ZoneLabel({ children }: { children: React.ReactNode }) {
|
function ZoneLabel({ children }: { children: React.ReactNode }) {
|
||||||
return <p className="mb-2 ml-0.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">{children}</p>
|
return <p className="mb-2 ml-0.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">{children}</p>
|
||||||
@@ -37,6 +38,12 @@ export function DashboardView() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Sprach-Latenz (Voice/Lucy-Pipeline, C2) */}
|
||||||
|
<section>
|
||||||
|
<ZoneLabel>Sprach-Latenz</ZoneLabel>
|
||||||
|
<VoiceLatencyCard />
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Betrieb & Wissen */}
|
{/* Betrieb & Wissen */}
|
||||||
<section>
|
<section>
|
||||||
<ZoneLabel>Betrieb & Wissen</ZoneLabel>
|
<ZoneLabel>Betrieb & Wissen</ZoneLabel>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils"
|
|||||||
import { fmtSize, fmtCtx } from "@/lib/format"
|
import { fmtSize, fmtCtx } from "@/lib/format"
|
||||||
import { getBrandInfo, ROLES, roleTone } from "@/components/models/ModelBadges"
|
import { getBrandInfo, ROLES, roleTone } from "@/components/models/ModelBadges"
|
||||||
import { SpecDraftModal } from "@/components/models/SpecDraftModal"
|
import { SpecDraftModal } from "@/components/models/SpecDraftModal"
|
||||||
|
import { LaneEditor } from "@/components/models/LaneEditor"
|
||||||
|
|
||||||
export function Cockpit() {
|
export function Cockpit() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
@@ -532,17 +533,22 @@ export function Cockpit() {
|
|||||||
<span>Continue</span>
|
<span>Continue</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* COLUMN 2: Central Gateway Node */}
|
{/* COLUMN 2: Central Gateway Node — virtuelle Lanes (Router pickt das echte Modell) */}
|
||||||
<div
|
<div
|
||||||
className="absolute select-none z-10 w-36 py-3 px-4 -translate-y-1/2 -translate-x-1/2 rounded-2xl border border-primary/50 bg-card/90 backdrop-blur-md flex flex-col items-center justify-center text-center shadow-lg shadow-primary/5"
|
className="absolute select-none z-10 w-40 py-3 px-4 -translate-y-1/2 -translate-x-1/2 rounded-2xl border border-primary/50 bg-card/90 backdrop-blur-md flex flex-col items-center justify-center text-center shadow-lg shadow-primary/5"
|
||||||
style={{ left: "50%", top: "50%" }}
|
style={{ left: "50%", top: "50%" }}
|
||||||
>
|
>
|
||||||
<div className="text-[10px] uppercase font-bold text-primary font-space tracking-wide">Gateway Auto</div>
|
<div className="text-[10px] uppercase font-bold text-primary font-space tracking-wide">Gateway · Lanes</div>
|
||||||
<div className="text-[10px] font-mono text-muted-foreground mt-0.5">
|
<div className="mt-1 flex flex-col gap-0.5 text-[9px] font-mono text-muted-foreground">
|
||||||
Schwelle: > {routing?.heavy_threshold_chars ? (routing.heavy_threshold_chars / 1000) : "4"}k Zeichen
|
{routing?.lanes?.length ? routing.lanes.map((l) => (
|
||||||
|
<span key={l.name}>
|
||||||
|
<span className="text-foreground font-semibold">{l.name}</span>
|
||||||
|
{l.threshold_chars ? ` ›${(l.threshold_chars / 1000).toFixed(0)}k` : l.escalate_chars ? ` ⇧${(l.escalate_chars / 1000).toFixed(0)}k` : ""}
|
||||||
|
</span>
|
||||||
|
)) : <span>chat · coding</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-[9px] font-semibold text-primary uppercase font-mono">
|
<div className="mt-1 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20 text-[9px] font-semibold text-primary uppercase font-mono">
|
||||||
Auto-Swap
|
Router
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -696,6 +702,9 @@ export function Cockpit() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ZONE B.4: Lane-Routing & Policy (UI-editierbar, hot-reload) */}
|
||||||
|
<LaneEditor />
|
||||||
|
|
||||||
{/* ZONE B.5: Slot-Belegung */}
|
{/* ZONE B.5: Slot-Belegung */}
|
||||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-3.5">
|
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/10 space-y-3.5">
|
||||||
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground px-1">
|
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground px-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user