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 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")
|
||||
|
||||
@@ -37,7 +38,8 @@ async def _proxy(path: str, request: Request):
|
||||
alias = requested
|
||||
routed = {"x-mc-routed-to": requested}
|
||||
# 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}
|
||||
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.routing_policy import policy_meta, save_policy
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
|
||||
@@ -10,3 +12,32 @@ router = APIRouter(prefix="/api")
|
||||
@router.get("/routing")
|
||||
def routing() -> dict:
|
||||
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 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:
|
||||
p = load_policy()
|
||||
coding_default = p["coder_lite"] or p["coder"]
|
||||
return {
|
||||
"mode": "builtin",
|
||||
"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": [
|
||||
{"name": "auto", "target": f"{FAST} ↔ {HEAVY} (nach Komplexität)"},
|
||||
{"name": FAST, "target": "llama-swap-Alias 'fast'"},
|
||||
{"name": HEAVY, "target": "llama-swap-Alias 'heavy'"},
|
||||
{"name": "chat", "target": f"{p['fast']} ↔ {p['heavy']} (nach Komplexität)"},
|
||||
{"name": "coding", "target": f"{coding_default} ↔ {p['coder']} (Eskalation)"},
|
||||
{"name": "<alias>", "target": "llama-swap-Passthrough (lädt bei Bedarf)"},
|
||||
],
|
||||
"heavy_threshold_chars": HEAVY_CHARS,
|
||||
"heavy_threshold_chars": p["heavy_chars"],
|
||||
"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`;
|
||||
triviale Kurzfrage ohne Code → `fast` (Tempo).
|
||||
|
||||
Regelbasiert, sub-ms, ohne Cloud. Schwellen/Aliases via Env überschreibbar (Phase 2: UI-editierbare
|
||||
Policy-JSON). Lucy läuft NICHT hierüber — die ist der Hermes-Agent (:8642), eigene Ebene.
|
||||
Regelbasiert, sub-ms, ohne Cloud. Schwellen/Aliases liegen in einer UI-editierbaren Policy
|
||||
(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
|
||||
|
||||
# Echte Modell-Aliases hinter den Lanes (Env-überschreibbar).
|
||||
FAST = os.environ.get("MC_ROUTE_FAST", "fast")
|
||||
HEAVY = os.environ.get("MC_ROUTE_HEAVY", "heavy")
|
||||
CODER = os.environ.get("MC_ROUTE_CODER", "coder")
|
||||
from services.routing_policy import load_policy
|
||||
|
||||
# Schwellen (Zeichen).
|
||||
HEAVY_CHARS = int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")) # chat → heavy
|
||||
# coding-Lane: leichter/schneller Coder als Default (wenn gesetzt = Phase 2b), starker Coder als Eskalation.
|
||||
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", "")
|
||||
# Modell-Aliases & Zeichen-Schwellen liegen jetzt in der UI-editierbaren Policy
|
||||
# (routing_policy.py) und kommen pro Request via load_policy() (hot-reload). Die Env-Vars
|
||||
# sind dort die Defaults. Die Regex-Keyword-Listen unten bleiben bewusst im Code.
|
||||
|
||||
# Virtuelle Lanes, die im Gateway als „Modelle" sichtbar sind.
|
||||
LANES = ["coding", "chat"]
|
||||
@@ -64,21 +56,23 @@ def _text_of(body: dict) -> str:
|
||||
|
||||
|
||||
def _route_chat(text: str, n: int) -> tuple[str, str]:
|
||||
if n > HEAVY_CHARS:
|
||||
return HEAVY, f"langer Kontext ({n} > {HEAVY_CHARS} Zeichen)"
|
||||
p = load_policy()
|
||||
if n > p["heavy_chars"]:
|
||||
return p["heavy"], f"langer Kontext ({n} > {p['heavy_chars']} Zeichen)"
|
||||
if _HEAVY_KW.search(text):
|
||||
return HEAVY, "Komplexitäts-Schlüsselwort erkannt"
|
||||
return FAST, "Standard"
|
||||
return p["heavy"], "Komplexitäts-Schlüsselwort erkannt"
|
||||
return p["fast"], "Standard"
|
||||
|
||||
|
||||
def _route_coding(text: str, n: int) -> tuple[str, str]:
|
||||
# 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,
|
||||
# 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.)
|
||||
if CODER_LITE and not _CODING_HEAVY_KW.search(text) and n <= CODING_ESCALATE_CHARS:
|
||||
return CODER_LITE, "Coding (schneller Coder)"
|
||||
return CODER, "Coding -> starker Coder"
|
||||
# (Phase 2b: warme schnelle Coder-Stufe coder_lite als Default + coder als Eskalation.)
|
||||
p = load_policy()
|
||||
if p["coder_lite"] and not _CODING_HEAVY_KW.search(text) and n <= p["coding_escalate_chars"]:
|
||||
return p["coder_lite"], "Coding (schneller Coder)"
|
||||
return p["coder"], "Coding -> starker Coder"
|
||||
|
||||
|
||||
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="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Mission Control 2.0</title>
|
||||
<script type="module" crossorigin src="/assets/index-BK9pTA8z.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CDCyVscm.css">
|
||||
<script type="module" crossorigin src="/assets/index-CyyjRWEt.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CHGCSDst.css">
|
||||
</head>
|
||||
<body>
|
||||
<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
|
||||
heavy_threshold_chars?: number
|
||||
routes: { name: string; target: string }[]
|
||||
lanes?: { name: string; target: string; threshold_chars?: number; escalate_chars?: number; aka?: string }[]
|
||||
fallbacks: Record<string, string[]>[]
|
||||
context_window_fallbacks: Record<string, string[]>[]
|
||||
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 {
|
||||
hash: string
|
||||
date: string
|
||||
|
||||
@@ -18,10 +18,12 @@ import {
|
||||
type MemoryGraph,
|
||||
type ModelsResp,
|
||||
type RoutingResp,
|
||||
type RoutingPolicyMeta,
|
||||
type ServicesResp,
|
||||
type SystemStatus,
|
||||
type TokenStats,
|
||||
type UpdatesResp,
|
||||
type VoiceMetrics,
|
||||
} from "./api"
|
||||
|
||||
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
|
||||
@@ -32,6 +34,8 @@ export const qk = {
|
||||
models: ["models"] as const,
|
||||
groups: ["groups"] as const,
|
||||
routing: ["routing"] as const,
|
||||
routingPolicy: ["routing-policy"] as const,
|
||||
voiceMetrics: ["voice-metrics"] as const,
|
||||
jobs: ["jobs"] as const,
|
||||
tokenStats: ["token-stats"] as const,
|
||||
agentStatus: ["agent-status"] as const,
|
||||
@@ -70,6 +74,12 @@ export const useGroups = (refetchInterval = 8_000) =>
|
||||
export const useRouting = (refetchInterval = 4_000) =>
|
||||
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) =>
|
||||
useQuery({
|
||||
queryKey: qk.jobs,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { RolesCard } from "@/components/dashboard/RolesCard"
|
||||
import { MemoryInputCard } from "@/components/dashboard/MemoryInputCard"
|
||||
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
|
||||
import { ServicesCard } from "@/components/dashboard/ServicesCard"
|
||||
import { VoiceLatencyCard } from "@/components/dashboard/VoiceLatencyCard"
|
||||
|
||||
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>
|
||||
@@ -37,6 +38,12 @@ export function DashboardView() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sprach-Latenz (Voice/Lucy-Pipeline, C2) */}
|
||||
<section>
|
||||
<ZoneLabel>Sprach-Latenz</ZoneLabel>
|
||||
<VoiceLatencyCard />
|
||||
</section>
|
||||
|
||||
{/* Betrieb & Wissen */}
|
||||
<section>
|
||||
<ZoneLabel>Betrieb & Wissen</ZoneLabel>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils"
|
||||
import { fmtSize, fmtCtx } from "@/lib/format"
|
||||
import { getBrandInfo, ROLES, roleTone } from "@/components/models/ModelBadges"
|
||||
import { SpecDraftModal } from "@/components/models/SpecDraftModal"
|
||||
import { LaneEditor } from "@/components/models/LaneEditor"
|
||||
|
||||
export function Cockpit() {
|
||||
const qc = useQueryClient()
|
||||
@@ -532,17 +533,22 @@ export function Cockpit() {
|
||||
<span>Continue</span>
|
||||
</div>
|
||||
|
||||
{/* COLUMN 2: Central Gateway Node */}
|
||||
<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"
|
||||
{/* COLUMN 2: Central Gateway Node — virtuelle Lanes (Router pickt das echte Modell) */}
|
||||
<div
|
||||
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%" }}
|
||||
>
|
||||
<div className="text-[10px] uppercase font-bold text-primary font-space tracking-wide">Gateway Auto</div>
|
||||
<div className="text-[10px] font-mono text-muted-foreground mt-0.5">
|
||||
Schwelle: > {routing?.heavy_threshold_chars ? (routing.heavy_threshold_chars / 1000) : "4"}k Zeichen
|
||||
<div className="text-[10px] uppercase font-bold text-primary font-space tracking-wide">Gateway · Lanes</div>
|
||||
<div className="mt-1 flex flex-col gap-0.5 text-[9px] font-mono text-muted-foreground">
|
||||
{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 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>
|
||||
|
||||
@@ -696,6 +702,9 @@ export function Cockpit() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ZONE B.4: Lane-Routing & Policy (UI-editierbar, hot-reload) */}
|
||||
<LaneEditor />
|
||||
|
||||
{/* 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="text-xs font-bold uppercase tracking-wider text-muted-foreground px-1">
|
||||
|
||||
Reference in New Issue
Block a user