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)
This commit is contained in:
mo
2026-06-28 11:50:37 +00:00
parent f5c7227a97
commit 323acda6d4
2 changed files with 127 additions and 49 deletions
+117 -49
View File
@@ -39,6 +39,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
const [syncing, setSyncing] = useState(false) const [syncing, setSyncing] = useState(false)
const [catalogDocs, setCatalogDocs] = useState<number | null>(null) const [catalogDocs, setCatalogDocs] = useState<number | null>(null)
const [showArch, setShowArch] = useState(true) const [showArch, setShowArch] = useState(true)
const [activeStage, setActiveStage] = useState<number | null>(null)
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const loadMeta = useCallback(async () => { const loadMeta = useCallback(async () => {
@@ -186,6 +187,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
const onSendAgent = async (msg: string) => { const onSendAgent = async (msg: string) => {
setLoading(true) setLoading(true)
setActiveStage(0)
const steps: AgentStep[] = [] const steps: AgentStep[] = []
let answer = '' let answer = ''
let idx = -1 let idx = -1
@@ -222,9 +224,11 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
let data: Record<string, unknown> = {} let data: Record<string, unknown> = {}
try { data = JSON.parse(dMatch[1]) } catch { continue } try { data = JSON.parse(dMatch[1]) } catch { continue }
if (type === 'step') { if (type === 'step') {
setActiveStage(2)
steps.push({ tool: String(data.tool), input: data.input as Record<string, unknown> }) steps.push({ tool: String(data.tool), input: data.input as Record<string, unknown> })
patch() patch()
} else if (type === 'token') { } else if (type === 'token') {
setActiveStage(5)
answer += String(data.t || '') answer += String(data.t || '')
patch() patch()
} else if (type === 'error') { } else if (type === 'error') {
@@ -237,6 +241,70 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
} finally { } finally {
patch() patch()
setLoading(false) 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)
} }
} }
@@ -250,25 +318,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
await onSendAgent(msg) await onSendAgent(msg)
return return
} }
setLoading(true) await onSendChatStream(msg)
try {
const r = await fetch('/rag/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msg, collection, top_k: 5 }),
})
const j = await r.json()
if (!r.ok || !j.ok) {
setError(j.error || 'Chat failed')
setLoading(false)
return
}
setMessages((m) => [...m, { role: 'assistant', content: j.answer, sources: j.sources }])
} catch {
setError('Failed to reach RAG / LLM service')
} finally {
setLoading(false)
}
} }
const createCollection = async () => { const createCollection = async () => {
@@ -282,7 +332,6 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
} }
const doclingUiUrl = `${window.location.protocol}//${window.location.hostname}:5001/ui/` const doclingUiUrl = `${window.location.protocol}//${window.location.hostname}:5001/ui/`
const busy = loading || ingesting || summarizing || reindexing !== null
return ( return (
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col rounded-lg border border-border bg-surface-raised"> <div className="flex h-full min-h-[calc(100vh-140px)] flex-col rounded-lg border border-border bg-surface-raised">
@@ -320,7 +369,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
{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 busy={busy} /> <RagFlow loading={loading} ingesting={ingesting} activeStage={activeStage} />
</div> </div>
)} )}
@@ -498,62 +547,81 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
} }
const INDEX_STAGES = [ const INDEX_STAGES = [
{ icon: Upload, label: 'Upload', sub: 'PDF·DOCX·MD', color: '#38bdf8' }, { icon: Upload, label: 'Upload', sub: 'PDF·DOCX·MD', color: '#38bdf8', lc: false },
{ icon: ScanText, label: 'Docling', sub: 'OCR · tables', color: '#22d3ee' }, { icon: ScanText, label: 'Docling', sub: 'OCR · tables', color: '#22d3ee', lc: false },
{ icon: Scissors, label: 'Split', sub: '800 / 120', color: '#a78bfa' }, { icon: Scissors, label: 'Split', sub: 'TextSplitter', color: '#a78bfa', lc: true },
{ icon: Binary, label: 'Embed', sub: 'MiniLM 384d', color: '#f472b6' }, { icon: Binary, label: 'Embed', sub: 'MiniLM 384d', color: '#f472b6', lc: true },
{ icon: Database, label: 'ChromaDB', sub: 'vector store', color: '#34d399' }, { icon: Database, label: 'ChromaDB', sub: 'vector store', color: '#34d399', lc: true },
] ]
const QUERY_STAGES = [ const QUERY_STAGES = [
{ icon: MessageSquare, label: 'Question', sub: 'user', color: '#38bdf8' }, { icon: MessageSquare, label: 'Question', sub: 'user', color: '#38bdf8', lc: false },
{ icon: Binary, label: 'Embed', sub: 'MiniLM', color: '#f472b6' }, { icon: Binary, label: 'Embed', sub: 'MiniLM', color: '#f472b6', lc: true },
{ icon: Search, label: 'Retrieve', sub: 'ChromaDB', color: '#34d399' }, { icon: Search, label: 'Retrieve', sub: 'as_retriever', color: '#34d399', lc: true },
{ icon: Layers, label: 'Context', sub: 'top-5 chunks', color: '#fbbf24' }, { icon: Layers, label: 'Context', sub: 'top-5 chunks', color: '#fbbf24', lc: false },
{ icon: Cpu, label: 'LLM', sub: 'Llama70B / GPT-4o', color: '#818cf8' }, { icon: Cpu, label: 'LLM', sub: 'ChatOpenAI', color: '#818cf8', lc: true },
{ icon: Sparkles, label: 'Answer', sub: '+ sources', color: '#34d399' }, { icon: Sparkles, label: 'Answer', sub: '+ sources', color: '#34d399', lc: false },
] ]
function RagFlow({ busy }: { busy: boolean }) { const STAGE_LABEL = ['Question', 'Embed', 'Retrieve', 'Context', 'LLM', 'Answer']
function RagFlow({ loading, ingesting, activeStage }: { loading: boolean; ingesting: boolean; activeStage: number | null }) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-[9px] font-semibold uppercase tracking-wider text-foreground-faint"> <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 <Workflow className="h-3 w-3 text-docker" /> How it works RAG pipeline
<span className="rounded bg-docker/15 px-1.5 py-0.5 font-mono text-[8px] normal-case text-docker">LangChain · ChromaDB · Docling · sentence-transformers</span> <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">
{busy && ( <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="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" /> data flowing <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> </span>
)} )}
</div> </div>
<FlowRow tag="Index" stages={INDEX_STAGES} busy={busy} /> <FlowRow tag="Index" stages={INDEX_STAGES} flowing={ingesting} />
<FlowRow tag="Query" stages={QUERY_STAGES} busy={busy} /> <FlowRow tag="Query" stages={QUERY_STAGES} flowing={loading} activeIndex={activeStage} />
</div> </div>
) )
} }
function FlowRow({ tag, stages, busy }: { tag: string; stages: typeof INDEX_STAGES; busy: boolean }) { function FlowRow({ tag, stages, flowing, activeIndex }: { tag: string; stages: typeof INDEX_STAGES; flowing: boolean; activeIndex?: number | null }) {
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', busy && 'rag-flowing')}> <div className={cn('scrollbar-thin flex flex-1 items-center overflow-x-auto py-0.5', flowing && 'rag-flowing')}>
{stages.map((s, i) => ( {stages.map((s, i) => {
<Fragment key={`${tag}-${s.label}-${i}`}> const state: 'idle' | 'active' | 'done' =
<FlowNode icon={s.icon} label={s.label} sub={s.sub} color={s.color} /> activeIndex == null ? 'idle' : i < activeIndex ? 'done' : i === activeIndex ? 'active' : 'idle'
{i < stages.length - 1 && <FlowConnector color={s.color} delay={i * 0.18} />} return (
</Fragment> <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>
</div> </div>
) )
} }
function FlowNode({ icon: Icon, label, sub, color }: { icon: typeof Upload; label: string; sub: string; color: string }) { 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 ( return (
<div <div
className="rag-node 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" 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': `${color}66` } as React.CSSProperties} 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 }} /> <Icon className="h-4 w-4" style={{ color }} />
<span className="text-[9px] font-medium leading-tight text-foreground">{label}</span> <span className="text-[9px] font-medium leading-tight text-foreground">{label}</span>
<span className="text-[8px] leading-tight text-foreground-faint">{sub}</span> <span className="text-[8px] leading-tight text-foreground-faint">{sub}</span>
+10
View File
@@ -207,3 +207,13 @@
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.rag-track, .rag-dot, .rag-node { animation: none !important; } .rag-track, .rag-dot, .rag-node { animation: none !important; }
} }
/* RAG flow — active/done stage states (real-time per-stage pulsing) */
@keyframes rag-active-pulse {
0%, 100% { box-shadow: 0 0 0 0 var(--rag-glow); transform: scale(1); }
50% { box-shadow: 0 0 16px 3px var(--rag-glow); transform: scale(1.08); }
}
.rag-node-active { animation: rag-active-pulse 0.8s ease-in-out infinite; border-color: var(--rag-line) !important; z-index: 1; }
.rag-node-done { border-color: var(--rag-line) !important; }
@media (prefers-reduced-motion: reduce) { .rag-node-active { animation: none !important; } }