Knowledge Chat: clickable pipeline steps with explanations + stronger live pulsing

- Each pipeline node is now a button; clicking shows a detail card (what the step does + the LangChain/tech component)
- Stronger active-stage pulse (expanding ring + brighter glow)
- Chat auto-opens the How-it-works panel so live per-stage pulsing is always visible while answering
This commit is contained in:
mo
2026-06-28 12:56:38 +00:00
parent f080c242de
commit fcf85b0e4c
2 changed files with 53 additions and 23 deletions
@@ -195,6 +195,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
}
const onSendAgent = async (msg: string) => {
setShowArch(true)
setLoading(true)
setActiveStage(0)
const steps: AgentStep[] = []
@@ -257,6 +258,7 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
const STAGE_INDEX: Record<string, number> = { question: 0, embed: 1, retrieve: 2, context: 3, llm: 4, answer: 5 }
const onSendChatStream = async (msg: string) => {
setShowArch(true)
setLoading(true)
setActiveStage(0)
let answer = ''
@@ -576,29 +578,36 @@ export function KnowledgeChatView({ onGpuActivity }: Props = {}) {
}
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 },
{ icon: Upload, label: 'Upload', sub: 'PDF·DOCX·MD', color: '#38bdf8', lc: false, tech: 'FastAPI upload', desc: 'Your file (PDF, DOCX, Markdown, CSV, images) is uploaded to the RAG service and saved to disk for processing.' },
{ icon: ScanText, label: 'Docling', sub: 'OCR · tables', color: '#22d3ee', lc: false, tech: 'Docling serve', desc: 'Docling parses the document with layout-aware OCR and table extraction, turning it into clean Markdown text.' },
{ icon: Scissors, label: 'Split', sub: 'TextSplitter', color: '#a78bfa', lc: true, tech: 'LangChain · RecursiveCharacterTextSplitter', desc: 'The text is cut into ~800-character chunks with 120-char overlap, so each piece fits the embedder and keeps surrounding context.' },
{ icon: Binary, label: 'Embed', sub: 'MiniLM 384d', color: '#f472b6', lc: true, tech: 'LangChain · HuggingFaceEmbeddings (MiniLM)', desc: 'Every chunk is converted into a 384-dimension vector that numerically captures its meaning.' },
{ icon: Database, label: 'ChromaDB', sub: 'vector store', color: '#34d399', lc: true, tech: 'LangChain · Chroma vector store', desc: 'The vectors plus metadata (source file, chunk number) are stored in ChromaDB for fast similarity search.' },
]
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 },
{ icon: MessageSquare, label: 'Question', sub: 'user', color: '#38bdf8', lc: false, tech: 'user input', desc: 'Your question enters the pipeline as plain text.' },
{ icon: Binary, label: 'Embed', sub: 'MiniLM', color: '#f472b6', lc: true, tech: 'LangChain · HuggingFaceEmbeddings (MiniLM)', desc: 'The question is embedded into the exact same 384-dim vector space as the document chunks.' },
{ icon: Search, label: 'Retrieve', sub: 'as_retriever', color: '#34d399', lc: true, tech: 'LangChain · Chroma retriever', desc: 'ChromaDB returns the chunks whose vectors are closest to the question (we fetch top-k×3, then filter out noise).' },
{ icon: Layers, label: 'Context', sub: 'top-5 chunks', color: '#fbbf24', lc: false, tech: 'context builder', desc: 'The best chunks are assembled into a single context block, each tagged with its source file for citations.' },
{ icon: Cpu, label: 'LLM', sub: 'ChatOpenAI', color: '#818cf8', lc: true, tech: 'LangChain · ChatOpenAI (gpt-4o)', desc: 'The context and your question are sent to the LLM, which writes a grounded answer — streamed back token by token.' },
{ icon: Sparkles, label: 'Answer', sub: '+ sources', color: '#34d399', lc: false, tech: 'answer + sources', desc: 'The streamed answer is shown with its source chunks, and the whole run is logged to the Traces viewer.' },
]
const STAGE_LABEL = ['Question', 'Embed', 'Retrieve', 'Context', 'LLM', 'Answer']
function RagFlow({ loading, ingesting, activeStage, smithUrl, project }: { loading: boolean; ingesting: boolean; activeStage: number | null; smithUrl?: string | null; project?: string }) {
const [sel, setSel] = useState<{ row: 'Index' | 'Query'; i: number } | null>(null)
const selStages = sel?.row === 'Index' ? INDEX_STAGES : QUERY_STAGES
const selStage = sel ? selStages[sel.i] : null
const SelIcon = selStage?.icon
const pick = (row: 'Index' | 'Query', i: number) =>
setSel((cur) => (cur && cur.row === row && cur.i === i ? null : { row, i }))
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-surface-overlay px-1.5 py-0.5 font-mono text-[8px] normal-case text-foreground-faint">click a step</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">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" /> orchestrated by LangChain
</span>
@@ -617,13 +626,30 @@ function RagFlow({ loading, ingesting, activeStage, smithUrl, project }: { loadi
</span>
)}
</div>
<FlowRow tag="Index" stages={INDEX_STAGES} flowing={ingesting} />
<FlowRow tag="Query" stages={QUERY_STAGES} flowing={loading} activeIndex={activeStage} />
<FlowRow tag="Index" stages={INDEX_STAGES} flowing={ingesting} selectedIndex={sel?.row === 'Index' ? sel.i : null} onSelect={(i) => pick('Index', i)} />
<FlowRow tag="Query" stages={QUERY_STAGES} flowing={loading} activeIndex={activeStage} selectedIndex={sel?.row === 'Query' ? sel.i : null} onSelect={(i) => pick('Query', i)} />
{selStage && (
<div className="mt-1 flex items-start gap-2 rounded-lg border border-border bg-surface-raised/80 p-2.5">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md" style={{ background: `${selStage.color}22` }}>
{SelIcon && <SelIcon className="h-4 w-4" style={{ color: selStage.color }} />}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold text-foreground">{sel?.row} · {selStage.label}</span>
<span className={cn('rounded px-1.5 py-0.5 font-mono text-[8px]', selStage.lc ? 'bg-emerald-500/15 text-emerald-300' : 'bg-surface-overlay text-foreground-muted')}>{selStage.tech}</span>
</div>
<p className="mt-1 text-[11px] leading-snug text-foreground-muted">{selStage.desc}</p>
</div>
<button type="button" onClick={() => setSel(null)} className="shrink-0 rounded p-0.5 text-foreground-faint hover:bg-surface-overlay" title="Close">
<X className="h-3 w-3" />
</button>
</div>
)}
</div>
)
}
function FlowRow({ tag, stages, flowing, activeIndex }: { tag: string; stages: typeof INDEX_STAGES; flowing: boolean; activeIndex?: number | null }) {
function FlowRow({ tag, stages, flowing, activeIndex, selectedIndex, onSelect }: { tag: string; stages: typeof INDEX_STAGES; flowing: boolean; activeIndex?: number | null; selectedIndex?: number | null; onSelect?: (i: number) => void }) {
return (
<div className="flex items-center gap-2">
<span className="w-10 shrink-0 text-[8px] font-semibold uppercase text-foreground-faint">{tag}</span>
@@ -633,7 +659,7 @@ function FlowRow({ tag, stages, flowing, activeIndex }: { tag: string; stages: t
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} />
<FlowNode icon={s.icon} label={s.label} sub={s.sub} color={s.color} lc={s.lc} state={state} selected={selectedIndex === i} onClick={() => onSelect?.(i)} />
{i < stages.length - 1 && <FlowConnector color={s.color} delay={i * 0.18} />}
</Fragment>
)
@@ -643,13 +669,16 @@ function FlowRow({ tag, stages, flowing, activeIndex }: { tag: string; stages: t
)
}
function FlowNode({ icon: Icon, label, sub, color, lc, state }: { icon: typeof Upload; label: string; sub: string; color: string; lc: boolean; state: 'idle' | 'active' | 'done' }) {
function FlowNode({ icon: Icon, label, sub, color, lc, state, selected, onClick }: { icon: typeof Upload; label: string; sub: string; color: string; lc: boolean; state: 'idle' | 'active' | 'done'; selected?: boolean; onClick?: () => void }) {
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}
<button
type="button"
onClick={onClick}
title={`${label} — click for details`}
className={cn('relative flex min-w-[64px] shrink-0 cursor-pointer flex-col items-center gap-0.5 rounded-lg border border-border bg-surface-raised px-2 py-1.5 text-center transition-all hover:-translate-y-0.5 hover:border-foreground-faint', cls)}
style={{ '--rag-glow': glow, '--rag-line': color, outline: selected ? `2px solid ${color}` : undefined, outlineOffset: '2px' } 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>
@@ -657,7 +686,7 @@ function FlowNode({ icon: Icon, label, sub, color, lc, state }: { icon: typeof U
<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>
</button>
)
}
+4 -3
View File
@@ -211,9 +211,10 @@
/* 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); }
0% { box-shadow: 0 0 0 0 var(--rag-glow), 0 0 10px 1px var(--rag-glow); transform: scale(1); }
50% { box-shadow: 0 0 0 6px transparent, 0 0 22px 5px var(--rag-glow); transform: scale(1.12); }
100% { box-shadow: 0 0 0 0 var(--rag-glow), 0 0 10px 1px var(--rag-glow); transform: scale(1); }
}
.rag-node-active { animation: rag-active-pulse 0.8s ease-in-out infinite; border-color: var(--rag-line) !important; z-index: 1; }
.rag-node-active { animation: rag-active-pulse 0.75s 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; } }