Knowledge Chat: spread pipeline nodes full-width, add self-hosted Trace Viewer + favicon

- RAG pipeline nodes now distribute across the row with flex-grow connectors (longer flowing lines)
- New Traces modal: list recent runs with stage timeline, retrieved chunks, prompt, answer (local LangSmith-style observability)
- Add topology-style SVG favicon
This commit is contained in:
mo
2026-06-28 12:48:52 +00:00
parent 03f300d46b
commit f080c242de
3 changed files with 250 additions and 3 deletions
+1
View File
@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>ATC Data & AI Command Center</title> <title>ATC Data & AI Command Center</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#38bdf8"/>
<stop offset="1" stop-color="#34d399"/>
</linearGradient>
</defs>
<rect x="3" y="3" width="58" height="58" rx="15" fill="#0b1220"/>
<rect x="3" y="3" width="58" height="58" rx="15" fill="none" stroke="url(#g)" stroke-width="2.5"/>
<g stroke="url(#g)" stroke-width="3" stroke-linecap="round" opacity="0.85">
<line x1="21" y1="23" x2="43" y2="23"/>
<line x1="21" y1="23" x2="32" y2="44"/>
<line x1="43" y1="23" x2="32" y2="44"/>
</g>
<circle cx="21" cy="23" r="6.5" fill="#38bdf8"/>
<circle cx="43" cy="23" r="6.5" fill="#22d3ee"/>
<circle cx="32" cy="44" r="6.5" fill="#34d399"/>
</svg>

After

Width:  |  Height:  |  Size: 796 B

@@ -1,5 +1,5 @@
import { Fragment, useCallback, useEffect, useRef, useState } from 'react' import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { Binary, Bot, BookOpen, ChevronDown, Cpu, Database, FileText, Layers, Loader2, MessageSquare, RefreshCw, RotateCcw, ScanText, Scissors, Search, Send, Sparkles, Upload, Workflow, Wrench } from 'lucide-react' import { Activity, Binary, Bot, BookOpen, ChevronDown, Cpu, Database, FileText, Layers, Loader2, MessageSquare, RefreshCw, RotateCcw, ScanText, Scissors, Search, Send, Sparkles, Trash2, Upload, Workflow, Wrench, X } from 'lucide-react'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive' import { subTabActive, subTabIdle } from '../../lib/tabActive'
@@ -41,6 +41,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
const [showArch, setShowArch] = useState(true) const [showArch, setShowArch] = useState(true)
const [activeStage, setActiveStage] = useState<number | null>(null) const [activeStage, setActiveStage] = useState<number | null>(null)
const [traceCfg, setTraceCfg] = useState<{ tracing: boolean; project: string; smith_url: string } | null>(null) const [traceCfg, setTraceCfg] = useState<{ tracing: boolean; project: string; smith_url: string } | null>(null)
const [showTraces, setShowTraces] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const loadMeta = useCallback(async () => { const loadMeta = useCallback(async () => {
@@ -359,6 +360,14 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
<StatusPill ok={health.llm} label="LLM" /> <StatusPill ok={health.llm} label="LLM" />
</> </>
)} )}
<button
type="button"
onClick={() => setShowTraces(true)}
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-[10px] font-medium text-foreground-muted transition-colors hover:bg-surface-overlay"
title="Self-hosted run traces (Command Center)"
>
<Activity className="h-3.5 w-3.5" /> Traces
</button>
{traceCfg?.tracing && ( {traceCfg?.tracing && (
<a <a
href={traceCfg.smith_url} href={traceCfg.smith_url}
@@ -386,6 +395,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
</div> </div>
</header> </header>
<TraceViewer open={showTraces} onClose={() => setShowTraces(false)} smith={traceCfg?.tracing ? traceCfg.smith_url : null} />
{showArch && ( {showArch && (
<div className="shrink-0 border-b border-border bg-surface-overlay/20 px-4 py-3"> <div className="shrink-0 border-b border-border bg-surface-overlay/20 px-4 py-3">
<RagFlow loading={loading} ingesting={ingesting} activeStage={activeStage} smithUrl={traceCfg?.tracing ? traceCfg.smith_url : null} project={traceCfg?.project} /> <RagFlow loading={loading} ingesting={ingesting} activeStage={activeStage} smithUrl={traceCfg?.tracing ? traceCfg.smith_url : null} project={traceCfg?.project} />
@@ -617,7 +627,7 @@ function FlowRow({ tag, stages, flowing, activeIndex }: { tag: string; stages: t
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-10 shrink-0 text-[8px] font-semibold uppercase text-foreground-faint">{tag}</span> <span className="w-10 shrink-0 text-[8px] font-semibold uppercase text-foreground-faint">{tag}</span>
<div className={cn('scrollbar-thin flex flex-1 items-center overflow-x-auto py-0.5', flowing && 'rag-flowing')}> <div className={cn('flex flex-1 items-center py-0.5', flowing && 'rag-flowing')}>
{stages.map((s, i) => { {stages.map((s, i) => {
const state: 'idle' | 'active' | 'done' = const state: 'idle' | 'active' | 'done' =
activeIndex == null ? 'idle' : i < activeIndex ? 'done' : i === activeIndex ? 'active' : 'idle' activeIndex == null ? 'idle' : i < activeIndex ? 'done' : i === activeIndex ? 'active' : 'idle'
@@ -653,7 +663,7 @@ function FlowNode({ icon: Icon, label, sub, color, lc, state }: { icon: typeof U
function FlowConnector({ color, delay }: { color: string; delay: number }) { function FlowConnector({ color, delay }: { color: string; delay: number }) {
return ( return (
<div className="relative mx-0.5 h-5 w-7 shrink-0"> <div className="relative mx-1 h-5 min-w-[24px] flex-1">
<div <div
className="rag-track absolute top-1/2 h-[2px] w-full -translate-y-1/2 rounded" className="rag-track absolute top-1/2 h-[2px] w-full -translate-y-1/2 rounded"
style={{ backgroundImage: `linear-gradient(90deg, ${color} 0 7px, transparent 7px 20px)`, backgroundSize: '20px 2px', opacity: 0.6 }} style={{ backgroundImage: `linear-gradient(90deg, ${color} 0 7px, transparent 7px 20px)`, backgroundSize: '20px 2px', opacity: 0.6 }}
@@ -666,6 +676,224 @@ function FlowConnector({ color, delay }: { color: string; delay: number }) {
) )
} }
type TraceSummary = {
id: string
ts: string
type: string
status: string
question: string
model: string
latency_ms: number
sources: number
answer_chars: number
langsmith: boolean
}
type TraceStage = { name: string; at_ms: number; dur_ms: number }
type TraceSource = { source: string; chunk: number; preview: string }
type TraceDetail = TraceSummary & {
collection: string
answer: string
system_prompt: string
context_chars: number
error: string | null
stages: TraceStage[]
sources: TraceSource[]
}
const STAGE_COLOR: Record<string, string> = {
embed: '#f472b6',
retrieve: '#34d399',
context: '#fbbf24',
llm: '#818cf8',
answer: '#22d3ee',
}
function TraceViewer({ open, onClose, smith }: { open: boolean; onClose: () => void; smith?: string | null }) {
const [items, setItems] = useState<TraceSummary[]>([])
const [selId, setSelId] = useState<string | null>(null)
const [sel, setSel] = useState<TraceDetail | null>(null)
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
setLoading(true)
try {
const r = await fetch('/rag/traces?limit=80')
const j = await r.json()
setItems((j.traces || []) as TraceSummary[])
} catch {
/* ignore */
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (!open) return
load()
const id = setInterval(load, 5000)
return () => clearInterval(id)
}, [open, load])
useEffect(() => {
if (!open) return
if (!selId && items.length) setSelId(items[0].id)
}, [open, items, selId])
useEffect(() => {
if (!selId) {
setSel(null)
return
}
fetch(`/rag/traces/${selId}`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => { if (d && !d.error) setSel(d as TraceDetail) })
.catch(() => {})
}, [selId])
const clearAll = async () => {
await fetch('/rag/traces', { method: 'DELETE' }).catch(() => {})
setSelId(null)
setSel(null)
load()
}
if (!open) return null
const total = sel ? Math.max(sel.latency_ms, 1) : 1
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
className="flex h-[82vh] w-[min(1120px,96vw)] flex-col overflow-hidden rounded-xl border border-border bg-surface-base shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<Activity className="h-4 w-4 text-docker" />
<div className="flex flex-col">
<span className="text-sm font-semibold text-foreground">Run Traces</span>
<span className="text-[10px] text-foreground-faint">Self-hosted RAG observability · {items.length} recent runs</span>
</div>
{smith && (
<a href={smith} target="_blank" rel="noreferrer" className="ml-3 inline-flex items-center gap-1 rounded border border-emerald-400/40 bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-medium text-emerald-300 hover:bg-emerald-500/20">
also in LangSmith
</a>
)}
<div className="ml-auto flex items-center gap-1.5">
<button type="button" onClick={load} className="rounded border border-border p-1.5 hover:bg-surface-overlay" title="Refresh">
<RefreshCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
</button>
<button type="button" onClick={clearAll} className="rounded border border-border p-1.5 text-danger hover:bg-surface-overlay" title="Clear all traces">
<Trash2 className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={onClose} className="rounded border border-border p-1.5 hover:bg-surface-overlay" title="Close">
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="flex min-h-0 flex-1">
<div className="w-72 shrink-0 overflow-y-auto border-r border-border">
{items.length === 0 && (
<div className="p-4 text-center text-xs text-foreground-faint">No runs yet. Ask a question in the chat.</div>
)}
{items.map((it) => (
<button
key={it.id}
type="button"
onClick={() => setSelId(it.id)}
className={cn('flex w-full flex-col gap-0.5 border-b border-border/60 px-3 py-2 text-left transition-colors',
selId === it.id ? 'bg-docker/10' : 'hover:bg-surface-overlay')}
>
<div className="flex items-center gap-1.5">
<span className={cn('h-1.5 w-1.5 rounded-full', it.status === 'ok' ? 'bg-success' : 'bg-danger')} />
<span className="truncate text-[11px] font-medium text-foreground">{it.question || '(empty)'}</span>
</div>
<div className="flex items-center gap-2 text-[9px] text-foreground-faint">
<span>{new Date(it.ts).toLocaleTimeString()}</span>
<span>· {it.latency_ms} ms</span>
<span>· {it.sources} src</span>
{it.langsmith && <span className="text-emerald-400">· LS</span>}
</div>
</button>
))}
</div>
<div className="min-w-0 flex-1 overflow-y-auto p-4">
{!sel && <div className="text-center text-xs text-foreground-faint">Select a run to inspect.</div>}
{sel && (
<div className="space-y-4">
<div>
<div className="mb-1 flex flex-wrap items-center gap-2">
<span className={cn('rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase', sel.status === 'ok' ? 'bg-success/15 text-success' : 'bg-danger/15 text-danger')}>{sel.status}</span>
<span className="rounded bg-surface-overlay px-1.5 py-0.5 font-mono text-[9px] text-foreground-muted">{sel.model}</span>
<span className="rounded bg-surface-overlay px-1.5 py-0.5 font-mono text-[9px] text-foreground-muted">{sel.latency_ms} ms</span>
<span className="rounded bg-surface-overlay px-1.5 py-0.5 font-mono text-[9px] text-foreground-muted">{sel.collection}</span>
{sel.langsmith && <span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-medium text-emerald-300"> LangSmith</span>}
<span className="ml-auto font-mono text-[9px] text-foreground-faint">{sel.id}</span>
</div>
<p className="text-sm font-medium text-foreground">{sel.question}</p>
{sel.error && <p className="mt-1 text-xs text-danger">{sel.error}</p>}
</div>
<div>
<div className="mb-1 text-[9px] font-semibold uppercase tracking-wider text-foreground-faint">Pipeline timeline</div>
<div className="relative h-7 w-full overflow-hidden rounded bg-surface-overlay/50">
{sel.stages.map((st) => (
<div
key={st.name}
className="absolute top-0 flex h-full items-center justify-center overflow-hidden text-[8px] font-medium text-black/80"
style={{
left: `${(st.at_ms / total) * 100}%`,
width: `${Math.max((st.dur_ms / total) * 100, 1)}%`,
background: STAGE_COLOR[st.name] || '#64748b',
opacity: 0.85,
}}
title={`${st.name}: ${st.dur_ms} ms`}
>
{st.dur_ms > total * 0.06 ? st.name : ''}
</div>
))}
</div>
<div className="mt-1 flex flex-wrap gap-2">
{sel.stages.map((st) => (
<span key={st.name} className="flex items-center gap-1 text-[9px] text-foreground-faint">
<span className="h-2 w-2 rounded-sm" style={{ background: STAGE_COLOR[st.name] || '#64748b' }} /> {st.name} {st.dur_ms}ms
</span>
))}
</div>
</div>
<div>
<div className="mb-1 text-[9px] font-semibold uppercase tracking-wider text-foreground-faint">Retrieved context · {sel.sources.length} chunks · {sel.context_chars} chars</div>
<div className="space-y-1.5">
{sel.sources.map((s, i) => (
<div key={i} className="rounded border border-border bg-surface-raised p-2">
<div className="mb-0.5 flex items-center gap-1.5 text-[10px] font-medium text-docker">
<FileText className="h-3 w-3" /> {s.source} · chunk {s.chunk}
</div>
<p className="line-clamp-3 text-[10px] leading-snug text-foreground-muted">{s.preview}</p>
</div>
))}
</div>
</div>
<details className="rounded border border-border bg-surface-raised p-2">
<summary className="cursor-pointer text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">System prompt</summary>
<pre className="mt-1 whitespace-pre-wrap text-[10px] text-foreground-muted">{sel.system_prompt}</pre>
</details>
<div>
<div className="mb-1 text-[9px] font-semibold uppercase tracking-wider text-foreground-faint">Answer · {sel.answer_chars} chars</div>
<div className="whitespace-pre-wrap rounded border border-border bg-surface-raised p-2 text-xs leading-relaxed text-foreground">{sel.answer || '(no answer)'}</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}
function StatusPill({ ok, label }: { ok: boolean; label: string }) { function StatusPill({ ok, label }: { ok: boolean; label: string }) {
return ( return (
<span className={cn('rounded-full px-2 py-0.5 font-medium', ok ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger')}> <span className={cn('rounded-full px-2 py-0.5 font-medium', ok ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger')}>