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:
Hitonabi
2026-06-29 18:58:17 +02:00
parent 3611defe5d
commit 9e432dbd2d
15 changed files with 876 additions and 367 deletions
+4 -2
View File
@@ -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}"
+33 -2
View File
@@ -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}
+21 -5
View File
@@ -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": [],
} }
+17 -23
View File
@@ -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 256K1M Kontext selbst, # (das sind Allzweck-Modelle, schwächer bei Code). Die Qwen-Coder packen 256K1M 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]:
+127
View File
@@ -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}
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
+2 -2
View File
@@ -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 &amp; 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>
)
}
+39
View File
@@ -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
+10
View File
@@ -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,
+7
View File
@@ -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 &amp; Wissen</ZoneLabel> <ZoneLabel>Betrieb &amp; Wissen</ZoneLabel>
+16 -7
View File
@@ -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: &gt; {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">