Observability 1a: Per-Turn-Latenz-Trace + Latenz-Karte
voice_metrics.py: neben den rollenden Stats jetzt ein echter Per-Turn-Trace (TurnTrace + Ringpuffer der letzten 60 Turns). Balken-Stufen zeitlich disjunkt (stt · vision · hirn · gen); hirn misst ab mark_brain_start() VOR dem Hermes- Request, damit die Vision-Zeit nicht doppelt gezaehlt wird. STT (davor) und Mem0-Retrieve (Rueckruf waehrend) werden per park()/_take_* best-effort dem Turn zugeordnet (Ein-Nutzer-Geraet, kein Turn-ID noetig). Mem0 = Unter-Detail INNERHALB hirn (nicht addieren) -> ehrlich, kein Doppelzaehlen. voice.py: TurnTrace in voice_chat's gen() (commit garantiert 1x via finally, auch bei Fehler/Abbruch). STT-Endpoint parkt seine Dauer. NEU: GET /api/voice/metrics (schliesst die Luecke - selbstkritik-feed.sh curlte das, existierte nie -> 404) + GET /api/voice/trace?limit=N. memory.py: GET /api/memory?q=... (= Hermes' Mem0-Prefetch) misst + parkt die Retrieve-Zeit. Frontend: LatencyCard (gestapelte Balken je Turn, "Taeter" = groesste Stufe, Fehler-Turns rot, Tooltip mit "davon Mem0 X s"), Query useVoiceTrace, in der Zentrale unter "Stack & Telemetrie". Lokal gegen Seed-Server verifiziert: 30,3-s- Haenger -> Hirn-Balken 94% + "davon Mem0 26,1 s". Grenzen (ehrlich, als Fussnote in der Karte): Tool-Runden im Hermes-LLM-Loop haben keinen Callback an MC2 -> stecken in "Antwort". Reine Telegram-Text-Turns laufen an MC2 vorbei und erscheinen hier nicht. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
+150
-150
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
-1
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-DRqLCVxW.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CWAZq_3Z.css">
|
||||
<script type="module" crossorigin src="/assets/index-Bcmk5MTW.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bwnys6u-.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
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 Mem0 ${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="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15">
|
||||
<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 Mem0 {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>
|
||||
)
|
||||
}
|
||||
@@ -413,6 +413,27 @@ export interface TokenStats {
|
||||
pricing?: Record<string, { in: number; out: number }>
|
||||
}
|
||||
|
||||
// Per-Turn-Latenz-Trace (GET /api/voice/trace). Balken-Stufen (zeitlich disjunkt):
|
||||
// stt · vision · hirn · gen. mem0 = Unter-Detail INNERHALB von hirn (nicht zum Balken addieren).
|
||||
export interface VoiceTurn {
|
||||
id: string
|
||||
ts: number // Epoch-Sekunden
|
||||
session: string
|
||||
kind: string
|
||||
had_images: boolean
|
||||
stt_ms: number | null
|
||||
vision_ms: number | null
|
||||
hirn_ms: number | null // Zeit bis erstes Inhalts-Token (Agent + Mem0 + LLM-TTFT)
|
||||
gen_ms: number | null // Generierung nach dem ersten Token bis Stream-Ende
|
||||
mem0_ms: number | null // Teil VON hirn (Mem0-Retrieve), best-effort
|
||||
total_ms: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface VoiceTraceResp {
|
||||
turns: VoiceTurn[]
|
||||
}
|
||||
|
||||
export interface ModelsResp {
|
||||
models: ModelInfo[]
|
||||
running?: string[]
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type SystemStatus,
|
||||
type TokenStats,
|
||||
type UpdatesResp,
|
||||
type VoiceTraceResp,
|
||||
} from "./api"
|
||||
|
||||
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
|
||||
@@ -45,8 +46,18 @@ export const qk = {
|
||||
connectHealth: ["connect-health"] as const,
|
||||
memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const,
|
||||
memoryGraph: ["memory-graph"] as const,
|
||||
voiceTrace: ["voice-trace"] as const,
|
||||
}
|
||||
|
||||
// Per-Turn-Latenz-Trace (letzte N Voice/Lucy-Turns mit Stufen-Breakdown).
|
||||
export const useVoiceTrace = (limit = 12, refetchInterval = 4_000) =>
|
||||
useQuery({
|
||||
queryKey: qk.voiceTrace,
|
||||
queryFn: () => api<VoiceTraceResp>(`/api/voice/trace?limit=${limit}`),
|
||||
refetchInterval,
|
||||
select: (d) => d.turns ?? [],
|
||||
})
|
||||
|
||||
export const useMemoryGraph = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: qk.memoryGraph,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AgentStatusCard } from "@/components/dashboard/AgentStatusCard"
|
||||
import { MemoryInputCard } from "@/components/dashboard/MemoryInputCard"
|
||||
import { TokenPerformanceCard } from "@/components/dashboard/TokenPerformanceCard"
|
||||
import { ServicesCard } from "@/components/dashboard/ServicesCard"
|
||||
import { LatencyCard } from "@/components/dashboard/LatencyCard"
|
||||
import { ModelsCard } from "@/components/dashboard/ModelsCard"
|
||||
import { LucyHealthCard } from "@/components/dashboard/LucyHealthCard"
|
||||
|
||||
@@ -34,7 +35,10 @@ export function DashboardView() {
|
||||
{/* Stack & Telemetrie */}
|
||||
<section>
|
||||
<ZoneLabel>Stack & Telemetrie</ZoneLabel>
|
||||
<ServicesCard />
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<ServicesCard />
|
||||
<LatencyCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Betrieb & Wissen */}
|
||||
|
||||
Reference in New Issue
Block a user