feat(knowledge-chat): animated "How it works" RAG architecture panel

Add a collapsible, pulsing architecture diagram to the Knowledge Chat that
visualizes the real RAG pipeline in two rows: Index (Upload -> Docling ->
Split -> Embed/MiniLM -> ChromaDB) and Query (Question -> Embed -> Retrieve
-> Context -> LLM -> Answer), badged with the actual stack (LangChain,
ChromaDB, Docling, sentence-transformers).

Connectors have a flowing dashed track plus a traveling glow dot and nodes
pulse; the whole flow speeds up while the chat/ingest is busy. Honors
prefers-reduced-motion.
This commit is contained in:
mo
2026-06-28 11:36:21 +00:00
parent d05fe403a2
commit f5c7227a97
2 changed files with 116 additions and 2 deletions
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Bot, BookOpen, Database, FileText, Loader2, MessageSquare, RefreshCw, RotateCcw, Send, Upload, Wrench } from 'lucide-react'
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'
@@ -38,6 +38,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
const [agentMode, setAgentMode] = useState(false)
const [syncing, setSyncing] = useState(false)
const [catalogDocs, setCatalogDocs] = useState<number | null>(null)
const [showArch, setShowArch] = useState(true)
const bottomRef = useRef<HTMLDivElement>(null)
const loadMeta = useCallback(async () => {
@@ -281,6 +282,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
}
const doclingUiUrl = `${window.location.protocol}//${window.location.hostname}:5001/ui/`
const busy = loading || ingesting || summarizing || reindexing !== null
return (
<div className="flex h-full min-h-[calc(100vh-140px)] flex-col rounded-lg border border-border bg-surface-raised">
@@ -300,6 +302,15 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
<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>
@@ -307,6 +318,12 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
</div>
</header>
{showArch && (
<div className="shrink-0 border-b border-border bg-surface-overlay/20 px-4 py-3">
<RagFlow busy={busy} />
</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>
@@ -480,6 +497,85 @@ 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' },
]
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' },
]
function RagFlow({ busy }: { busy: boolean }) {
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="rounded bg-docker/15 px-1.5 py-0.5 font-mono text-[8px] normal-case text-docker">LangChain · ChromaDB · Docling · sentence-transformers</span>
{busy && (
<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>
)}
</div>
<FlowRow tag="Index" stages={INDEX_STAGES} busy={busy} />
<FlowRow tag="Query" stages={QUERY_STAGES} busy={busy} />
</div>
)
}
function FlowRow({ tag, stages, busy }: { tag: string; stages: typeof INDEX_STAGES; busy: boolean }) {
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', busy && 'rag-flowing')}>
{stages.map((s, i) => (
<Fragment key={`${tag}-${s.label}-${i}`}>
<FlowNode icon={s.icon} label={s.label} sub={s.sub} color={s.color} />
{i < stages.length - 1 && <FlowConnector color={s.color} delay={i * 0.18} />}
</Fragment>
))}
</div>
</div>
)
}
function FlowNode({ icon: Icon, label, sub, color }: { icon: typeof Upload; label: string; sub: string; color: string }) {
return (
<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"
style={{ '--rag-glow': `${color}66` } as React.CSSProperties}
>
<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')}>
+18
View File
@@ -189,3 +189,21 @@
scrollbar-gutter: stable;
}
}
/* ── Knowledge Chat: RAG architecture animated flow ─────────────────────────── */
@keyframes rag-flow { from { background-position: 0 0; } to { background-position: 20px 0; } }
@keyframes rag-dot { 0% { left: 0; opacity: 0; } 12% { opacity: 1; } 88% { opacity: 1; } 100% { left: 100%; opacity: 0; } }
@keyframes rag-node-pulse {
0%, 100% { box-shadow: 0 0 0 0 transparent; }
50% { box-shadow: 0 0 11px 1px var(--rag-glow, rgba(56,189,248,0.4)); }
}
.rag-track { animation: rag-flow 2.2s linear infinite; }
.rag-dot { animation: rag-dot 3s linear infinite; }
.rag-node { animation: rag-node-pulse 3.2s ease-in-out infinite; }
.rag-flowing .rag-track { animation-duration: 0.6s; }
.rag-flowing .rag-dot { animation-duration: 1s; }
.rag-flowing .rag-node { animation-duration: 1.2s; }
@media (prefers-reduced-motion: reduce) {
.rag-track, .rag-dot, .rag-node { animation: none !important; }
}