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
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="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 &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
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
+10
View File
@@ -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,
+7
View File
@@ -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 &amp; Wissen</ZoneLabel>
+16 -7
View File
@@ -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: &gt; {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">