feat(trace): waterfall visualization + persona YAML sync

- trace.Logger: Trace+Span model with 14+ hop tracking
- trace.SpanTimer: convenience API for instrumentation
- trace_adapter.go: serialize Trace+Span for frontend
- Frontend TracePage: colored waterfall bars, expand/collapse, detail popup
- persona.go: full v1 YAML parser (identity/traits/forbidden/sections)
- cyrene_persona.yaml: v1 persona config copied to v2
- Orchestrator: TraceHook + MemoryExtractor injection points
This commit is contained in:
Aska committed 2026-08-12 18:52:39 +08:00
1 parent 47047c903c
commit 4e2e81f684
8 files changed
+1178 -198

No files matched your search

+157 -46
View File
@@ -1,74 +1,185 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, useRef } from 'react'
import { getToken } from '../../api/client'
// Hop color map — waterfall bar colors
const hopColors: Record<string, string> = {
msg_received: '#06b6d4', dispatch: '#8b5cf6', intent_analysis: '#f59e0b',
intent_fast: '#fbbf24', persona_load: '#ec4899', subsession: '#10b981',
memory_search: '#3b82f6', iot_query: '#06b6d4', context_build: '#6b7280',
llm_request: '#6366f1', llm_first_token: '#818cf8', llm_stream: '#a5b4fc',
review_parse: '#8b5cf6', ws_push: '#f97316', db_persist: '#22c55e',
memory_extract: '#ec4899', background_think: '#14b8a6',
}
const hopIcons: Record<string, string> = {
msg_received: '📥', msg_sent: '📤',
intent: '🎯', llm_call: '🧠', tool_call: '🔧',
response: '💬', session: '👤', subsession: '⚡',
msg_received: '📥', dispatch: '🚀', intent_analysis: '🎯', intent_fast: '⚡',
persona_load: '👤', subsession: '⚙', memory_search: '🧠', iot_query: '⌂',
context_build: '📦', llm_request: '🤖', llm_first_token: '💬', llm_stream: '▸',
review_parse: '📝', ws_push: '📤', db_persist: '💾', memory_extract: '♻',
background_think: '🔄',
}
export default function TracePage() {
const [events, setEvents] = useState<any[]>([])
const [traces, setTraces] = useState<any[]>([])
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [selected, setSelected] = useState<any>(null)
const intervalRef = useRef<number>()
useEffect(() => {
fetchTrace()
const interval = setInterval(fetchTrace, 5000)
return () => clearInterval(interval)
fetchTraces()
intervalRef.current = window.setInterval(fetchTraces, 3000)
return () => clearInterval(intervalRef.current)
}, [])
const fetchTrace = async () => {
const fetchTraces = async () => {
try {
const token = getToken()
const res = await fetch('/api/v1/admin/trace', { headers: { Authorization: `Bearer ${token}` } })
const data = await res.json()
setEvents(data.events || [])
setTraces(data.traces || [])
} catch {}
}
const toggleExpand = (id: string) => {
const next = new Set(expanded)
if (next.has(id)) next.delete(id) else next.add(id)
setExpanded(next)
}
// Calculate global time scale
const maxDuration = Math.max(100, ...traces.map((t: any) => {
if (t.end_time && t.start_time) {
return new Date(t.end_time).getTime() - new Date(t.start_time).getTime()
}
const maxSpan = Math.max(0, ...(t.spans || []).map((s: any) => (s.start_ms + s.duration)))
return maxSpan
}))
return (
<div className="p-6 space-y-4">
<h2 className="text-lg font-semibold">全链路追踪</h2>
<p className="text-xs text-[var(--mx-text-dim)]">最近 {events.length} 条追踪事件</p>
<div className="p-6 space-y-4 h-full flex flex-col" style={{ color: 'var(--mx-text)' }}>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">全链路追踪</h2>
<button onClick={fetchTraces}
className="px-3 py-1.5 rounded-full text-xs border"
style={{ borderColor: 'var(--mx-outline)', color: 'var(--mx-text-dim)' }}
>刷新</button>
</div>
{events.length === 0 ? (
<div className="p-8 text-center text-[var(--mx-text-dim)] text-sm">暂无追踪数据 — 发送消息后刷新</div>
) : (
<div className="space-y-1.5">
{events.map((ev, i) => (
<div key={ev.id || i}
className={`bg-[var(--mx-surface)] border rounded-[14px] px-4 py-3 flex items-center gap-3 ${
ev.status === 'error' ? 'border-[var(--mx-error)]/30' : 'border-[var(--mx-outline)]'
}`}
>
{/* Hop icon */}
<span className="text-lg shrink-0">{hopIcons[ev.hop] || '●'}</span>
{/* Waterfall charts */}
<div className="flex-1 overflow-auto space-y-3">
{traces.length === 0 ? (
<div className="text-center py-12 text-sm" style={{ color: 'var(--mx-text-dim)' }}>
暂无追踪数据 — 发送消息后自动记录
</div>
) : (
traces.map((trace: any) => {
const totalMs = trace.end_time
? new Date(trace.end_time).getTime() - new Date(trace.start_time).getTime()
: Date.now() - new Date(trace.start_time).getTime()
const isRunning = trace.status === 'running'
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--mx-text)]">{ev.label || ev.hop}</span>
{ev.duration_ms > 0 && (
<span className="text-xs text-[var(--mx-text-dim)]">{ev.duration_ms}ms</span>
)}
return (
<div key={trace.id}
className="rounded-[18px] border overflow-hidden"
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 cursor-pointer"
onClick={() => toggleExpand(trace.id)}
>
<div className="flex items-center gap-3 min-w-0">
{isRunning ? (
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse shrink-0" />
) : trace.status === 'error' ? (
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{background:'var(--mx-error)'}} />
) : (
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{background:'var(--mx-tertiary-text)'}} />
)}
<span className="text-sm truncate">{trace.user_msg || '(无消息)'}</span>
</div>
<div className="flex items-center gap-3 text-xs shrink-0" style={{ color: 'var(--mx-text-dim)' }}>
<span>{totalMs}ms</span>
<span>{trace.spans?.length || 0} 环节</span>
<span>{expanded.has(trace.id) ? '▼' : '▶'}</span>
</div>
</div>
{ev.detail && (
<p className="text-xs text-[var(--mx-text-dim)] truncate mt-0.5">{ev.detail}</p>
{/* Expanded: waterfall view */}
{expanded.has(trace.id) && (
<div className="px-4 pb-4 space-y-1">
{/* Time scale ruler */}
<div className="flex justify-between text-xs px-1" style={{ color: 'var(--mx-text-dim)' }}>
<span>0ms</span>
<span>{Math.round(maxDuration/2)}ms</span>
<span>{Math.round(maxDuration)}ms</span>
</div>
{/* Span bars */}
{(trace.spans || []).map((span: any, i: number) => {
const pct = maxDuration > 0 ? (span.duration / maxDuration) * 100 : 0
const leftPct = maxDuration > 0 ? (span.start_ms / maxDuration) * 100 : 0
const color = hopColors[span.name] || '#6b7280'
return (
<div key={i}
className="flex items-center gap-2 group cursor-pointer hover:opacity-90"
onClick={() => setSelected({...span, trace_id: trace.id, trace_msg: trace.user_msg})}
>
{/* Label */}
<div className="w-24 shrink-0 text-right">
<span className="text-xs">{hopIcons[span.name] || '●'} {span.label}</span>
</div>
{/* Bar */}
<div className="flex-1 relative h-5">
<div className="absolute top-0 h-4 rounded-full opacity-20"
style={{ left: `${leftPct}%`, width: `${Math.max(pct, 1)}%`, background: color }} />
<div className="absolute top-0 h-4 rounded-full"
style={{ left: `${leftPct}%`, width: `${Math.max(pct, 1)}%`, background: color }} />
</div>
{/* Duration */}
<div className="w-14 shrink-0 text-right text-xs" style={{ color: 'var(--mx-text-dim)' }}>
{span.duration}ms
</div>
{/* Status dot */}
<div className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ background: span.status === 'error' ? 'var(--mx-error)' : span.status === 'running' ? '#fbbf24' : color }} />
</div>
)
})}
</div>
)}
</div>
)
})
)}
</div>
{/* Status */}
<div className="flex items-center gap-3 shrink-0">
<span className={`text-xs font-mono text-[var(--mx-text-dim)]`}>
{ev.timestamp ? ev.timestamp.slice(11, 19) : ''}
</span>
<span className={`w-2 h-2 rounded-full ${
ev.status === 'success' ? 'bg-[#4788FF]' :
ev.status === 'error' ? 'bg-[#F12522]' :
ev.status === 'pending' ? 'bg-yellow-500' : 'bg-[#404040]'
}`} />
</div>
{/* Detail popup */}
{selected && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setSelected(null)}>
<div className="rounded-[18px] border p-6 max-w-md w-full mx-4 space-y-3"
style={{ background: 'var(--mx-surface)', borderColor: 'var(--mx-outline)' }}
onClick={e => e.stopPropagation()}
>
<h3 className="text-sm font-semibold">{hopIcons[selected.name]} {selected.label}</h3>
<div className="grid grid-cols-2 gap-2 text-xs">
<div><span style={{color:'var(--mx-text-dim)'}}>耗时</span><p>{selected.duration}ms</p></div>
<div><span style={{color:'var(--mx-text-dim)'}}>偏移</span><p>{selected.start_ms}ms</p></div>
<div><span style={{color:'var(--mx-text-dim)'}}>状态</span><p>{selected.status}</p></div>
<div><span style={{color:'var(--mx-text-dim)'}}>Hop</span><p>{selected.name}</p></div>
</div>
))}
{selected.detail && (
<div className="rounded-[14px] border p-3 text-xs font-mono whitespace-pre-wrap max-h-40 overflow-auto"
style={{ background: 'var(--mx-bg)', borderColor: 'var(--mx-outline)', color: 'var(--mx-text-dim)' }}>
{selected.detail}
</div>
)}
<button onClick={() => setSelected(null)}
className="w-full py-2 rounded-full text-sm text-white" style={{background:'var(--mx-primary)'}}>关闭</button>
</div>
</div>
)}
</div>