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:
@@ -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>
|
||||||
|
|||||||
@@ -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; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user