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:
@@ -1,5 +1,5 @@
|
||||
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 { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||
|
||||
@@ -41,6 +41,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
|
||||
const [showArch, setShowArch] = useState(true)
|
||||
const [activeStage, setActiveStage] = useState<number | 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 loadMeta = useCallback(async () => {
|
||||
@@ -359,6 +360,14 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
|
||||
<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 && (
|
||||
<a
|
||||
href={traceCfg.smith_url}
|
||||
@@ -386,6 +395,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<TraceViewer open={showTraces} onClose={() => setShowTraces(false)} smith={traceCfg?.tracing ? traceCfg.smith_url : null} />
|
||||
{showArch && (
|
||||
<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} />
|
||||
@@ -617,7 +627,7 @@ function FlowRow({ tag, stages, flowing, activeIndex }: { tag: string; stages: t
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<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) => {
|
||||
const state: 'idle' | 'active' | 'done' =
|
||||
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 }) {
|
||||
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
|
||||
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 }}
|
||||
@@ -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 }) {
|
||||
return (
|
||||
<span className={cn('rounded-full px-2 py-0.5 font-medium', ok ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger')}>
|
||||
|
||||
Reference in New Issue
Block a user