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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user