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