137 lines
6.1 KiB
TypeScript
137 lines
6.1 KiB
TypeScript
import { Gauge } from "lucide-react"
|
||
import { useVoiceTrace } from "@/lib/queries"
|
||
import type { VoiceTurn } from "@/lib/api"
|
||
|
||
// Balken-Stufen in kanonischer Reihenfolge (zeitlich disjunkt). Mem0 ist ein Unter-Detail
|
||
// INNERHALB von „Hirn" und wird NICHT als eigenes Segment addiert (sonst Doppelzählung).
|
||
const SEGMENTS = [
|
||
{ key: "stt_ms", label: "STT", color: "#f59e0b" },
|
||
{ key: "vision_ms", label: "Sehen", color: "#a78bfa" },
|
||
{ key: "hirn_ms", label: "Hirn", color: "#2dd4bf" },
|
||
{ key: "gen_ms", label: "Antwort", color: "#60a5fa" },
|
||
] as const
|
||
|
||
const fmt = (ms: number | null | undefined): string =>
|
||
ms == null ? "–" : `${(ms / 1000).toLocaleString("de-DE", { minimumFractionDigits: 1, maximumFractionDigits: 1 })} s`
|
||
|
||
function ago(ts: number): string {
|
||
const s = Math.max(0, Date.now() / 1000 - ts)
|
||
if (s < 60) return `vor ${Math.round(s)} s`
|
||
if (s < 3600) return `vor ${Math.round(s / 60)} min`
|
||
return `vor ${Math.round(s / 3600)} h`
|
||
}
|
||
|
||
/** Summe der sichtbaren Balken-Stufen (STT gehört zur gefühlten Latenz dazu; total_ms des Backends
|
||
* misst nur den Chat-Request ohne STT). So stimmen Balkenbreite und angezeigte Zahl immer überein. */
|
||
function rowTotal(t: VoiceTurn): number {
|
||
return SEGMENTS.reduce((sum, s) => sum + ((t[s.key] as number | null) ?? 0), 0)
|
||
}
|
||
|
||
/** Dominante Stufe eines Turns = der „Täter". */
|
||
function culprit(t: VoiceTurn): { label: string; color: string; ms: number } | null {
|
||
let best: { label: string; color: string; ms: number } | null = null
|
||
for (const s of SEGMENTS) {
|
||
const ms = t[s.key] as number | null
|
||
if (ms != null && (best == null || ms > best.ms)) best = { label: s.label, color: s.color, ms }
|
||
}
|
||
return best
|
||
}
|
||
|
||
function TurnRow({ t, scale }: { t: VoiceTurn; scale: number }) {
|
||
const title = SEGMENTS.map((s) => `${s.label} ${fmt(t[s.key] as number | null)}`).join(" · ")
|
||
+ (t.mem0_ms != null ? ` (davon Gedächtnis ${fmt(t.mem0_ms)})` : "")
|
||
return (
|
||
<div className="flex items-center gap-2.5">
|
||
<span className="w-14 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">{ago(t.ts)}</span>
|
||
<div className="relative h-4 flex-1 overflow-hidden rounded-sm bg-muted/25" title={title}>
|
||
<div className="absolute inset-0 flex">
|
||
{SEGMENTS.map((s) => {
|
||
const ms = t[s.key] as number | null
|
||
if (!ms || ms <= 0) return null
|
||
return (
|
||
<div
|
||
key={s.key}
|
||
style={{ width: `${(ms / scale) * 100}%`, background: s.color }}
|
||
className="h-full first:rounded-l-sm"
|
||
/>
|
||
)
|
||
})}
|
||
</div>
|
||
{t.error && (
|
||
<div className="absolute inset-0 flex items-center justify-center bg-rose-500/15 text-[10px] font-semibold text-rose-300">
|
||
Fehler
|
||
</div>
|
||
)}
|
||
</div>
|
||
<span className="w-12 shrink-0 text-right font-mono text-[11px] font-semibold tabular-nums text-foreground">
|
||
{t.error ? "–" : fmt(rowTotal(t))}
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function LatencyCard() {
|
||
const { data: turns } = useVoiceTrace(12)
|
||
const list = turns ?? []
|
||
const scale = Math.max(1, ...list.map((t) => rowTotal(t)))
|
||
const latest = list[0]
|
||
const lead = latest ? culprit(latest) : null
|
||
|
||
return (
|
||
<div className="mc-card p-5">
|
||
<div className="mb-3 flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="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">Latenz je Turn</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>
|
||
{latest ? (
|
||
<div className="mt-2 flex items-baseline gap-2">
|
||
<span className={`font-space text-3xl font-bold tracking-tight tabular-nums ${latest.error ? "text-rose-400" : "text-foreground"}`}>
|
||
{latest.error ? "Fehler" : fmt(rowTotal(latest))}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
letzter Turn{!latest.error && lead && <> · Täter: <span style={{ color: lead.color }} className="font-semibold">{lead.label}</span> {fmt(lead.ms)}</>}
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<div className="mt-2 text-xs text-muted-foreground">Noch kein Voice-Turn aufgezeichnet.</div>
|
||
)}
|
||
{latest && !latest.error && (
|
||
<div className="mt-0.5 font-mono text-[11px] text-muted-foreground/70">
|
||
{SEGMENTS.filter((s) => (latest[s.key] as number | null) != null).map((s) => `${s.label} ${fmt(latest[s.key] as number)}`).join(" · ")}
|
||
{latest.mem0_ms != null && <span className="text-muted-foreground/50"> · davon Gedächtnis {fmt(latest.mem0_ms)}</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex shrink-0 flex-col items-end gap-1 pt-1">
|
||
{SEGMENTS.map((s) => (
|
||
<div key={s.key} className="flex items-center gap-1.5">
|
||
<span className="h-2 w-2 rounded-full" style={{ background: s.color }} />
|
||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{s.label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{list.length > 0 ? (
|
||
<div className="space-y-1.5">
|
||
{list.map((t) => <TurnRow key={t.id} t={t} scale={scale} />)}
|
||
</div>
|
||
) : (
|
||
<div className="flex h-[120px] items-center justify-center px-6 text-center text-xs text-muted-foreground">
|
||
Sprich einmal mit Lucy — dann erscheint hier der Zeit-Wasserfall pro Turn, damit man den einen Hänger sofort sieht.
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-3 border-t border-border/30 pt-2 text-[10px] leading-relaxed text-muted-foreground/70">
|
||
„Hirn" = Zeit bis zum ersten Wort (Agent + Mem0-Suche + Modell); Tool-Runden stecken in „Antwort". Reine
|
||
Telegram-Turns laufen an MC2 vorbei und erscheinen hier nicht.
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|