From 323acda6d46c440c88b89738849f25622dc0f1ab Mon Sep 17 00:00:00 2001 From: mo Date: Sun, 28 Jun 2026 11:50:37 +0000 Subject: [PATCH] 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) --- .../components/features/KnowledgeChatView.tsx | 166 ++++++++++++------ ui/src/styles/globals.css | 10 ++ 2 files changed, 127 insertions(+), 49 deletions(-) diff --git a/ui/src/components/features/KnowledgeChatView.tsx b/ui/src/components/features/KnowledgeChatView.tsx index af88737..18a0d86 100644 --- a/ui/src/components/features/KnowledgeChatView.tsx +++ b/ui/src/components/features/KnowledgeChatView.tsx @@ -39,6 +39,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) { const [syncing, setSyncing] = useState(false) const [catalogDocs, setCatalogDocs] = useState(null) const [showArch, setShowArch] = useState(true) + const [activeStage, setActiveStage] = useState(null) const bottomRef = useRef(null) const loadMeta = useCallback(async () => { @@ -186,6 +187,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) { const onSendAgent = async (msg: string) => { setLoading(true) + setActiveStage(0) const steps: AgentStep[] = [] let answer = '' let idx = -1 @@ -222,9 +224,11 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) { let data: Record = {} try { data = JSON.parse(dMatch[1]) } catch { continue } if (type === 'step') { + setActiveStage(2) steps.push({ tool: String(data.tool), input: data.input as Record }) patch() } else if (type === 'token') { + setActiveStage(5) answer += String(data.t || '') patch() } else if (type === 'error') { @@ -237,6 +241,70 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) { } finally { patch() setLoading(false) + setActiveStage(null) + } + } + + const STAGE_INDEX: Record = { 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 = {} + 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) return } - setLoading(true) - 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) - } + await onSendChatStream(msg) } const createCollection = async () => { @@ -282,7 +332,6 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) { } const doclingUiUrl = `${window.location.protocol}//${window.location.hostname}:5001/ui/` - const busy = loading || ingesting || summarizing || reindexing !== null return (
@@ -320,7 +369,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) { {showArch && (
- +
)} @@ -498,62 +547,81 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) { } const INDEX_STAGES = [ - { icon: Upload, label: 'Upload', sub: 'PDF·DOCX·MD', color: '#38bdf8' }, - { icon: ScanText, label: 'Docling', sub: 'OCR · tables', color: '#22d3ee' }, - { icon: Scissors, label: 'Split', sub: '800 / 120', color: '#a78bfa' }, - { icon: Binary, label: 'Embed', sub: 'MiniLM 384d', color: '#f472b6' }, - { icon: Database, label: 'ChromaDB', sub: 'vector store', color: '#34d399' }, + { 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' }, - { icon: Binary, label: 'Embed', sub: 'MiniLM', color: '#f472b6' }, - { icon: Search, label: 'Retrieve', sub: 'ChromaDB', color: '#34d399' }, - { icon: Layers, label: 'Context', sub: 'top-5 chunks', color: '#fbbf24' }, - { icon: Cpu, label: 'LLM', sub: 'Llama70B / GPT-4o', color: '#818cf8' }, - { icon: Sparkles, label: 'Answer', sub: '+ sources', color: '#34d399' }, + { 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 }, ] -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 (
How it works — RAG pipeline - LangChain · ChromaDB · Docling · sentence-transformers - {busy && ( + + orchestrated by LangChain + + ChromaDB · Docling · sentence-transformers + {activeStage != null && ( - data flowing + {STAGE_LABEL[activeStage] || 'working'}… + + )} + {activeStage == null && ingesting && ( + + ingesting… )}
- - + +
) } -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 (
{tag} -
- {stages.map((s, i) => ( - - - {i < stages.length - 1 && } - - ))} +
+ {stages.map((s, i) => { + const state: 'idle' | 'active' | 'done' = + activeIndex == null ? 'idle' : i < activeIndex ? 'done' : i === activeIndex ? 'active' : 'idle' + return ( + + + {i < stages.length - 1 && } + + ) + })}
) } -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 (
+ {lc && ( + LC + )} {label} {sub} diff --git a/ui/src/styles/globals.css b/ui/src/styles/globals.css index 088ca94..ecb12b4 100644 --- a/ui/src/styles/globals.css +++ b/ui/src/styles/globals.css @@ -207,3 +207,13 @@ @media (prefers-reduced-motion: reduce) { .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; } }