Files
atc-agents/ui/src/components/features/KnowledgeChatView.tsx
T
mo 323acda6d4 Real-time RAG pipeline pulsing + prominent LangChain branding
- rag-api: new POST /chat/stream SSE endpoint emits live pipeline stages (embed/retrieve/context/llm/answer) and streams LLM tokens
- Knowledge Chat: document chat now streams answers token-by-token and pulses each pipeline stage in real time as it executes
- How-it-works panel: active stage glows/scales, completed stages settle, agent mode also drives the pulse
- LangChain made visible: orchestrated-by-LangChain badge + LC markers on LangChain-native nodes (TextSplitter, Embeddings, as_retriever, ChatOpenAI, Chroma)
2026-06-28 11:50:37 +00:00

654 lines
28 KiB
TypeScript

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 { cn } from '../../lib/utils'
import { subTabActive, subTabIdle } from '../../lib/tabActive'
type Collection = { name: string; documents: number; files?: number; filenames?: string[] }
type StoredDoc = {
id: string
filename: string
collection: string
chunks: number
characters?: number
ingested_at: string
bytes?: number
}
type Source = { source?: string; chunk?: number; preview?: string }
type AgentStep = { tool: string; input?: Record<string, unknown> }
type ChatMsg = { role: 'user' | 'assistant'; content: string; sources?: Source[]; steps?: AgentStep[] }
type Health = { ok: boolean; chroma: boolean; docling: boolean; llm: boolean; embed_model?: string }
type Props = { onGpuActivity?: (active: boolean) => void }
export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
const [health, setHealth] = useState<Health | null>(null)
const [collections, setCollections] = useState<Collection[]>([])
const [collection, setCollection] = useState('default')
const [newCol, setNewCol] = useState('')
const [messages, setMessages] = useState<ChatMsg[]>([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [ingesting, setIngesting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [storedDocs, setStoredDocs] = useState<StoredDoc[]>([])
const [selectedDocId, setSelectedDocId] = useState<string | null>(null)
const [summarizing, setSummarizing] = useState(false)
const [reindexing, setReindexing] = useState<string | null>(null)
const [agentMode, setAgentMode] = useState(false)
const [syncing, setSyncing] = useState(false)
const [catalogDocs, setCatalogDocs] = useState<number | null>(null)
const [showArch, setShowArch] = useState(true)
const [activeStage, setActiveStage] = useState<number | null>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const loadMeta = useCallback(async () => {
try {
const [h, c, d] = await Promise.all([
fetch('/rag/health'),
fetch('/rag/collections'),
fetch('/rag/documents'),
])
if (h.ok) setHealth(await h.json())
if (c.ok) {
const j = await c.json()
setCollections(j.collections || [])
}
if (d.ok) {
const j = await d.json()
setStoredDocs(j.documents || [])
}
} catch {
setHealth(null)
}
}, [])
const loadCatalog = useCallback(async () => {
try {
const r = await fetch('/rag/catalog/status')
if (r.ok) setCatalogDocs((await r.json()).documents ?? 0)
} catch {
setCatalogDocs(null)
}
}, [])
useEffect(() => {
loadMeta()
loadCatalog()
}, [loadMeta, loadCatalog])
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, loading])
useEffect(() => {
onGpuActivity?.(loading || ingesting || summarizing || reindexing !== null)
}, [loading, ingesting, summarizing, reindexing, onGpuActivity])
const onIngest = async (file: File) => {
setIngesting(true)
setError(null)
const fd = new FormData()
fd.append('file', file)
fd.append('collection', collection)
try {
const r = await fetch('/rag/ingest', { method: 'POST', body: fd })
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'Ingest failed')
return
}
setMessages((m) => [...m, {
role: 'assistant',
content: j.duplicate
? `Already indexed: ${j.filename} (${j.chunks} chunks). You can chat immediately — no re-upload needed.`
: `Indexed ${j.filename} → collection "${j.collection}" — ${j.chunks} chunks (${j.characters?.toLocaleString()} chars). Stored permanently.`,
}])
loadMeta()
} catch {
setError('RAG API unavailable')
} finally {
setIngesting(false)
}
}
const onSummarize = async (doc: StoredDoc) => {
setSummarizing(true)
setError(null)
setSelectedDocId(doc.id)
setCollection(doc.collection)
setMessages((m) => [...m, { role: 'user', content: `Summarize: ${doc.filename}` }])
try {
const r = await fetch('/rag/summarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ collection: doc.collection, doc_id: doc.id }),
})
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'Summarize failed')
return
}
setMessages((m) => [...m, {
role: 'assistant',
content: `Summary of ${j.filename} (${j.characters?.toLocaleString()} chars):\n\n${j.summary}`,
}])
} catch {
setError('Summarize request failed')
} finally {
setSummarizing(false)
}
}
const onReindex = async (doc: StoredDoc) => {
setReindexing(doc.id)
setError(null)
try {
const r = await fetch(`/rag/documents/${doc.id}/reindex`, { method: 'POST' })
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'Re-index failed')
return
}
setMessages((m) => [...m, {
role: 'assistant',
content: `Re-indexed ${j.filename}: ${j.chunks} clean text chunks (${j.characters?.toLocaleString()} chars). You can now chat and summarize.`,
}])
loadMeta()
} catch {
setError('Re-index request failed')
} finally {
setReindexing(null)
}
}
const onSyncCatalog = async () => {
setSyncing(true)
setError(null)
try {
const r = await fetch('/rag/catalog/sync', { method: 'POST' })
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'Catalog sync failed')
return
}
const c = j.counts || {}
setMessages((m) => [...m, {
role: 'assistant',
content: `Catalog synced — ${j.documents} entries (${c.tables ?? 0} tables, ${c.pii_datasets ?? 0} PII datasets, ${c.flows ?? 0} lineage edges, ${c.movements ?? 0} movements). Agent mode can now answer catalog/PII/lineage questions.`,
}])
loadCatalog()
} catch {
setError('Catalog sync request failed')
} finally {
setSyncing(false)
}
}
const onSendAgent = async (msg: string) => {
setLoading(true)
setActiveStage(0)
const steps: AgentStep[] = []
let answer = ''
let idx = -1
setMessages((m) => {
idx = m.length
return [...m, { role: 'assistant', content: '', steps: [] }]
})
const patch = () =>
setMessages((m) => m.map((x, i) => (i === idx ? { ...x, content: answer, steps: [...steps] } : x)))
try {
const r = await fetch('/rag/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msg, max_steps: 5 }),
})
if (!r.ok || !r.body) {
setError('Agent unavailable')
return
}
const reader = r.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const events = buf.split('\n\n')
buf = events.pop() || ''
for (const ev of events) {
const eMatch = ev.match(/^event: (.+)$/m)
const dMatch = ev.match(/^data: (.+)$/m)
if (!eMatch || !dMatch) continue
const type = eMatch[1].trim()
let data: Record<string, unknown> = {}
try { data = JSON.parse(dMatch[1]) } catch { continue }
if (type === 'step') {
setActiveStage(2)
steps.push({ tool: String(data.tool), input: data.input as Record<string, unknown> })
patch()
} else if (type === 'token') {
setActiveStage(5)
answer += String(data.t || '')
patch()
} else if (type === 'error') {
setError(String(data.error || 'Agent error'))
}
}
}
} catch {
setError('Failed to reach agent')
} finally {
patch()
setLoading(false)
setActiveStage(null)
}
}
const STAGE_INDEX: Record<string, number> = { question: 0, embed: 1, retrieve: 2, context: 3, llm: 4, answer: 5 }
const onSendChatStream = async (msg: string) => {
setLoading(true)
setActiveStage(0)
let answer = ''
let srcs: Source[] | undefined
let idx = -1
setMessages((m) => {
idx = m.length
return [...m, { role: 'assistant', content: '' }]
})
const patch = () => setMessages((m) => m.map((x, i) => (i === idx ? { ...x, content: answer, sources: srcs } : x)))
try {
const r = await fetch('/rag/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msg, collection, top_k: 5 }),
})
if (!r.ok || !r.body) {
setError('Chat service unavailable')
return
}
const reader = r.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const events = buf.split('\n\n')
buf = events.pop() || ''
for (const ev of events) {
const eMatch = ev.match(/^event: (.+)$/m)
const dMatch = ev.match(/^data: (.+)$/m)
if (!eMatch || !dMatch) continue
const type = eMatch[1].trim()
let data: Record<string, unknown> = {}
try { data = JSON.parse(dMatch[1]) } catch { continue }
if (type === 'stage') {
const si = STAGE_INDEX[String(data.stage)]
if (si != null) setActiveStage(si)
} else if (type === 'sources') {
srcs = data.sources as Source[]
patch()
} else if (type === 'token') {
setActiveStage(5)
answer += String(data.t || '')
patch()
} else if (type === 'error') {
setError(String(data.error || 'Chat error'))
}
}
}
} catch {
setError('Failed to reach RAG / LLM service')
} finally {
patch()
setLoading(false)
setActiveStage(null)
}
}
const onSend = async () => {
const msg = input.trim()
if (!msg || loading) return
setInput('')
setError(null)
setMessages((m) => [...m, { role: 'user', content: msg }])
if (agentMode) {
await onSendAgent(msg)
return
}
await onSendChatStream(msg)
}
const createCollection = async () => {
if (!newCol.trim()) return
const fd = new FormData()
fd.append('name', newCol.trim())
await fetch('/rag/collections', { method: 'POST', body: fd })
setCollection(newCol.trim())
setNewCol('')
loadMeta()
}
const doclingUiUrl = `${window.location.protocol}//${window.location.hostname}:5001/ui/`
return (
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col rounded-lg border border-border bg-surface-raised">
<header className="shrink-0 border-b border-border bg-surface-overlay/30 px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-foreground">Knowledge Chat (RAG)</h2>
<p className="text-[11px] text-foreground-muted">
LangChain + ChromaDB chat with your ingested documents via Llama 70B
</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-[10px]">
{health && (
<>
<StatusPill ok={health.chroma} label="ChromaDB" />
<StatusPill ok={health.docling} label="Docling" />
<StatusPill ok={health.llm} label="LLM" />
</>
)}
<button
type="button"
onClick={() => setShowArch((v) => !v)}
className={cn('inline-flex items-center gap-1 rounded border px-2 py-1 text-[10px] font-medium transition-colors',
showArch ? 'border-docker/50 bg-docker/15 text-docker' : 'border-border text-foreground-muted hover:bg-surface-overlay')}
>
<Workflow className="h-3.5 w-3.5" /> How it works
<ChevronDown className={cn('h-3 w-3 transition-transform', showArch && 'rotate-180')} />
</button>
<button type="button" onClick={loadMeta} className="rounded border border-border p-1.5 hover:bg-surface-overlay">
<RefreshCw className="h-4 w-4" />
</button>
</div>
</div>
</header>
{showArch && (
<div className="shrink-0 border-b border-border bg-surface-overlay/20 px-4 py-3">
<RagFlow loading={loading} ingesting={ingesting} activeStage={activeStage} />
</div>
)}
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
<aside className="shrink-0 border-b border-border p-4 lg:w-72 lg:border-b-0 lg:border-r">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Collection</h3>
<select
value={collection}
onChange={(e) => setCollection(e.target.value)}
className="mb-2 w-full rounded border border-border bg-surface-overlay px-2 py-1.5 text-[11px]"
>
{collections.length === 0 && <option value="default">default (empty)</option>}
{collections.map((c) => (
<option key={c.name} value={c.name}>{c.name} ({c.documents} docs)</option>
))}
</select>
<div className="mb-4 flex gap-1">
<input
value={newCol}
onChange={(e) => setNewCol(e.target.value)}
placeholder="New collection name"
className="min-w-0 flex-1 rounded border border-border bg-surface-overlay px-2 py-1 text-[10px]"
/>
<button type="button" onClick={createCollection} className={cn('shrink-0 rounded px-2 py-1 text-[10px]', subTabIdle)}>Add</button>
</div>
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Platform agent</h3>
<button
type="button"
onClick={() => setAgentMode((v) => !v)}
className={cn(
'mb-1 flex w-full items-center justify-between rounded border px-2 py-1.5 text-[11px] transition-colors',
agentMode ? 'border-docker/50 bg-docker/15 text-docker' : 'border-border text-foreground-muted',
)}
>
<span className="flex items-center gap-1"><Bot className="h-3.5 w-3.5" />Agent mode {agentMode ? 'on' : 'off'}</span>
<span className={cn('h-2 w-2 rounded-full', agentMode ? 'bg-docker' : 'bg-foreground-faint/40')} />
</button>
<p className="mb-2 text-[9px] text-foreground-faint">
Live tools: catalog, PII, CDC, movements + approval-gated triggers.
</p>
<button
type="button"
onClick={onSyncCatalog}
disabled={syncing}
className={cn('mb-4 flex w-full items-center justify-center gap-1 rounded px-2 py-1 text-[10px]', subTabIdle)}
>
{syncing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Database className="h-3 w-3" />}
Sync catalog{catalogDocs != null ? ` (${catalogDocs})` : ''}
</button>
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Ingest documents</h3>
<label className={cn('flex cursor-pointer flex-col items-center rounded-lg border-2 border-dashed border-border px-3 py-4 text-center hover:border-docker/40', ingesting && 'opacity-50')}>
<Upload className="mb-1 h-6 w-6 text-docker opacity-60" />
<span className="text-[10px] font-medium">PDF, PPTX, DOCX, CSV, TXT, MD</span>
<span className="text-[9px] text-foreground-faint">Stored in ChromaDB + disk upload once</span>
<input type="file" className="hidden" disabled={ingesting} accept=".pdf,.pptx,.ppt,.docx,.csv,.txt,.md,.json" onChange={(e) => e.target.files?.[0] && onIngest(e.target.files[0])} />
</label>
{ingesting && (
<p className="mt-2 flex items-center gap-1 text-[10px] text-foreground-muted">
<Loader2 className="h-3 w-3 animate-spin" /> Ingesting & embedding
</p>
)}
<h3 className="mb-2 mt-4 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">
Document library ({storedDocs.length})
</h3>
<div className="scrollbar-thin max-h-40 space-y-1 overflow-y-auto">
{storedDocs.length === 0 ? (
<p className="text-[9px] text-foreground-faint">No documents yet upload above.</p>
) : (
storedDocs.map((doc) => (
<div
key={doc.id}
className={cn(
'rounded border px-2 py-1.5 text-[9px] transition-colors',
selectedDocId === doc.id ? 'border-docker/40 bg-docker/10' : 'border-border',
)}
>
<button type="button" onClick={() => { setSelectedDocId(doc.id); setCollection(doc.collection) }} className="w-full text-left">
<p className="truncate font-medium text-foreground">{doc.filename}</p>
<p className="text-foreground-faint">{doc.collection} · {doc.chunks} chunks · {new Date(doc.ingested_at).toLocaleDateString()}</p>
</button>
<div className="mt-1 flex gap-1">
<button
type="button"
disabled={summarizing}
onClick={() => onSummarize(doc)}
className={cn('flex flex-1 items-center justify-center gap-0.5 rounded px-1 py-0.5', subTabIdle)}
>
{summarizing && selectedDocId === doc.id ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <FileText className="h-2.5 w-2.5" />}
Summarize
</button>
<button
type="button"
disabled={reindexing === doc.id}
onClick={() => onReindex(doc)}
className={cn('flex flex-1 items-center justify-center gap-0.5 rounded px-1 py-0.5', subTabIdle)}
title="Re-parse with clean text (fixes corrupted PDF index)"
>
{reindexing === doc.id ? <Loader2 className="h-2.5 w-2.5 animate-spin" /> : <RotateCcw className="h-2.5 w-2.5" />}
Re-index
</button>
</div>
</div>
))
)}
</div>
<div className="mt-4 space-y-1 text-[9px] text-foreground-faint">
<p><BookOpen className="mr-1 inline h-3 w-3" />Embed: {health?.embed_model || 'all-MiniLM-L6-v2'}</p>
<a href={doclingUiUrl} target="_blank" rel="noreferrer" className="text-docker hover:underline">Docling UI (port 5001) </a>
</div>
</aside>
<div className="flex min-h-0 flex-1 flex-col">
<div className="scrollbar-thin flex-1 overflow-y-auto p-4">
{messages.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-2 text-center text-sm text-foreground-muted">
<MessageSquare className="h-10 w-10 opacity-30" />
<p>Upload once documents stay in ChromaDB. Ask anytime without re-uploading.</p>
<p className="text-[11px]">Example: &quot;What maturity gaps exist in the customer dataset?&quot;</p>
</div>
)}
{messages.map((m, i) => (
<div key={i} className={cn('mb-3 max-w-[90%] rounded-lg px-3 py-2 text-[12px]', m.role === 'user' ? 'ml-auto bg-docker/20 text-foreground' : 'bg-surface-overlay text-foreground-muted')}>
{m.steps && m.steps.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1">
{m.steps.map((s, j) => (
<span key={j} className="inline-flex items-center gap-1 rounded-full bg-docker/15 px-2 py-0.5 text-[9px] text-docker">
<Wrench className="h-2.5 w-2.5" />{s.tool}
</span>
))}
</div>
)}
<p className="whitespace-pre-wrap leading-relaxed">{m.content || (loading && i === messages.length - 1 ? '…' : '')}</p>
{m.sources && m.sources.length > 0 && (
<div className="mt-2 border-t border-border pt-2">
<p className="mb-1 text-[9px] font-semibold uppercase text-foreground-faint">Sources</p>
{m.sources.map((s, j) => (
<p key={j} className="text-[9px] text-foreground-faint">
{s.source} · chunk {s.chunk}: {s.preview?.slice(0, 120)}
</p>
))}
</div>
)}
</div>
))}
{loading && (
<div className="flex items-center gap-2 text-[11px] text-foreground-muted">
<Loader2 className="h-4 w-4 animate-spin text-docker" /> Retrieving context & generating answer
</div>
)}
{error && <p className="text-[11px] text-danger">{error}</p>}
<div ref={bottomRef} />
</div>
<div className="flex shrink-0 gap-2 border-t border-border p-3">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && (e.preventDefault(), onSend())}
placeholder={agentMode ? 'Ask the platform agent (catalog, PII, CDC, run a movement…)' : 'Ask a question about your ingested data…'}
className="min-w-0 flex-1 rounded-lg border border-border bg-surface-overlay px-3 py-2 text-[12px]"
disabled={loading}
/>
<button type="button" onClick={onSend} disabled={loading || !input.trim()} className={cn('rounded-lg px-3 py-2', subTabActive, 'disabled:opacity-40')}>
<Send className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
)
}
const INDEX_STAGES = [
{ icon: Upload, label: 'Upload', sub: 'PDF·DOCX·MD', color: '#38bdf8', lc: false },
{ icon: ScanText, label: 'Docling', sub: 'OCR · tables', color: '#22d3ee', lc: false },
{ icon: Scissors, label: 'Split', sub: 'TextSplitter', color: '#a78bfa', lc: true },
{ icon: Binary, label: 'Embed', sub: 'MiniLM 384d', color: '#f472b6', lc: true },
{ icon: Database, label: 'ChromaDB', sub: 'vector store', color: '#34d399', lc: true },
]
const QUERY_STAGES = [
{ icon: MessageSquare, label: 'Question', sub: 'user', color: '#38bdf8', lc: false },
{ icon: Binary, label: 'Embed', sub: 'MiniLM', color: '#f472b6', lc: true },
{ icon: Search, label: 'Retrieve', sub: 'as_retriever', color: '#34d399', lc: true },
{ icon: Layers, label: 'Context', sub: 'top-5 chunks', color: '#fbbf24', lc: false },
{ icon: Cpu, label: 'LLM', sub: 'ChatOpenAI', color: '#818cf8', lc: true },
{ icon: Sparkles, label: 'Answer', sub: '+ sources', color: '#34d399', lc: false },
]
const STAGE_LABEL = ['Question', 'Embed', 'Retrieve', 'Context', 'LLM', 'Answer']
function RagFlow({ loading, ingesting, activeStage }: { loading: boolean; ingesting: boolean; activeStage: number | null }) {
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-[9px] font-semibold uppercase tracking-wider text-foreground-faint">
<Workflow className="h-3 w-3 text-docker" /> How it works RAG pipeline
<span className="inline-flex items-center gap-1 rounded-md border border-emerald-400/40 bg-emerald-500/15 px-1.5 py-0.5 font-mono text-[8px] normal-case text-emerald-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" /> orchestrated by LangChain
</span>
<span className="rounded bg-docker/15 px-1.5 py-0.5 font-mono text-[8px] normal-case text-docker">ChromaDB · Docling · sentence-transformers</span>
{activeStage != null && (
<span className="ml-auto flex items-center gap-1 normal-case text-docker">
<span className="h-1.5 w-1.5 animate-ping rounded-full bg-docker" /> {STAGE_LABEL[activeStage] || 'working'}
</span>
)}
{activeStage == null && ingesting && (
<span className="ml-auto flex items-center gap-1 normal-case text-docker">
<span className="h-1.5 w-1.5 animate-ping rounded-full bg-docker" /> ingesting
</span>
)}
</div>
<FlowRow tag="Index" stages={INDEX_STAGES} flowing={ingesting} />
<FlowRow tag="Query" stages={QUERY_STAGES} flowing={loading} activeIndex={activeStage} />
</div>
)
}
function FlowRow({ tag, stages, flowing, activeIndex }: { tag: string; stages: typeof INDEX_STAGES; flowing: boolean; activeIndex?: number | null }) {
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')}>
{stages.map((s, i) => {
const state: 'idle' | 'active' | 'done' =
activeIndex == null ? 'idle' : i < activeIndex ? 'done' : i === activeIndex ? 'active' : 'idle'
return (
<Fragment key={`${tag}-${s.label}-${i}`}>
<FlowNode icon={s.icon} label={s.label} sub={s.sub} color={s.color} lc={s.lc} state={state} />
{i < stages.length - 1 && <FlowConnector color={s.color} delay={i * 0.18} />}
</Fragment>
)
})}
</div>
</div>
)
}
function FlowNode({ icon: Icon, label, sub, color, lc, state }: { icon: typeof Upload; label: string; sub: string; color: string; lc: boolean; state: 'idle' | 'active' | 'done' }) {
const cls = state === 'active' ? 'rag-node-active' : state === 'done' ? 'rag-node-done' : 'rag-node'
const glow = state === 'active' ? `${color}cc` : `${color}55`
return (
<div
className={cn('relative flex min-w-[64px] shrink-0 flex-col items-center gap-0.5 rounded-lg border border-border bg-surface-raised px-2 py-1.5 text-center transition-colors', cls)}
style={{ '--rag-glow': glow, '--rag-line': color } as React.CSSProperties}
>
{lc && (
<span className="absolute -right-1.5 -top-1.5 rounded bg-emerald-500/90 px-1 text-[7px] font-bold leading-none text-white shadow" title="LangChain component">LC</span>
)}
<Icon className="h-4 w-4" style={{ color }} />
<span className="text-[9px] font-medium leading-tight text-foreground">{label}</span>
<span className="text-[8px] leading-tight text-foreground-faint">{sub}</span>
</div>
)
}
function FlowConnector({ color, delay }: { color: string; delay: number }) {
return (
<div className="relative mx-0.5 h-5 w-7 shrink-0">
<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 }}
/>
<span
className="rag-dot absolute top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full"
style={{ background: color, boxShadow: `0 0 6px ${color}`, animationDelay: `${delay}s` }}
/>
</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')}>
{label} {ok ? '●' : '○'}
</span>
)
}