qmd-web / src /components /PipelineView.tsx
shreyask's picture
Deploy qmd-web
8a8f6ee verified
import { Fragment, useState } from 'react';
import type { ExpandedQuery, ScoredChunk, RRFResult, RerankedResult, FinalResult } from '../types';
import ExpansionColumn from './ExpansionColumn';
import SearchColumn from './SearchColumn';
import FusionColumn from './FusionColumn';
import ResultCard from './ResultCard';
export interface PipelineState {
expansion: { status: 'idle' | 'running' | 'done' | 'error'; data?: ExpandedQuery; error?: string };
search: { status: 'idle' | 'running' | 'done'; data?: { bm25Hits: ScoredChunk[]; vectorHits: ScoredChunk[] } };
rrf: { status: 'idle' | 'done'; data?: { merged: RRFResult[] } };
rerank: { status: 'idle' | 'running' | 'done'; data?: { before: RRFResult[]; after: RerankedResult[] } };
blend: { status: 'idle' | 'done'; data?: { finalResults: FinalResult[] } };
}
interface PipelineViewProps {
state: PipelineState;
query?: string;
intent?: string;
}
const STAGES = [
{
label: 'User Query',
accent: '#5c6bc0',
info: 'The original search query you typed. This is the starting point for the entire pipeline.',
},
{
label: 'Query Expansion',
accent: '#f57f17',
info: 'A compact 1.7B LLM (cached locally) generates lexical keywords (lex), semantic sentences (vec), and a hypothetical document (HyDE). When BM25 already has a strong exact match, expansion is skipped.',
},
{
label: 'Parallel Search',
accent: '#00897b',
info: 'The original query always runs through BM25 and vector search. Lex variants route only to BM25, while vec and HyDE variants route to vector search, mirroring qmd\'s typed retrieval flow.',
},
{
label: 'Fusion & Reranking',
accent: '#388e3c',
info: 'Results are merged via Reciprocal Rank Fusion (RRF), then a cross-encoder reranker (Qwen3-Reranker-0.6B) re-scores the top candidates. Final ranking blends reranker confidence with RRF position.',
},
];
function InfoTooltip({ text }: { text: string }) {
const [open, setOpen] = useState(false);
return (
<span
style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onClick={(e) => { e.stopPropagation(); setOpen(o => !o); }}
>
<span style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '16px',
height: '16px',
borderRadius: '50%',
border: '1px solid var(--border)',
background: 'var(--bg-card)',
color: 'var(--text-muted)',
fontSize: '0.62rem',
fontWeight: 700,
cursor: 'help',
flexShrink: 0,
lineHeight: 1,
}}>
?
</span>
{open && (
<div style={{
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginTop: '6px',
padding: '0.6rem 0.75rem',
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: '6px',
boxShadow: '0 4px 16px var(--shadow)',
fontSize: '0.72rem',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontWeight: 400,
color: 'var(--text)',
lineHeight: 1.55,
width: '220px',
zIndex: 100,
textTransform: 'none',
letterSpacing: 'normal',
}}>
{text}
</div>
)}
</span>
);
}
function StageHeader({ label, accent, info }: { label: string; accent: string; info: string }) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
marginBottom: '0.75rem',
paddingBottom: '0.5rem',
borderBottom: '1px solid var(--stage-divider)',
}}>
<span style={{
width: '3px',
height: '14px',
borderRadius: '2px',
background: accent,
flexShrink: 0,
}} />
<h3 style={{
margin: 0,
fontSize: '0.78rem',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontWeight: 700,
color: accent,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
{label}
</h3>
<InfoTooltip text={info} />
</div>
);
}
// Horizontal header band showing stage flow: Stage 1 › Stage 2 › Stage 3 › Stage 4
function StageFlowHeader() {
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.3rem',
padding: '0.55rem 0.85rem',
borderBottom: '1px solid var(--pipeline-border)',
flexWrap: 'wrap',
}}>
{STAGES.map((stage, i) => (
<Fragment key={stage.label}>
{i > 0 && (
<span style={{
color: 'var(--text-muted)',
fontSize: '0.7rem',
opacity: 0.5,
margin: '0 0.15rem',
}}>
{'\u203A'}
</span>
)}
<span style={{
fontSize: '0.68rem',
fontWeight: 700,
fontFamily: 'system-ui, -apple-system, sans-serif',
color: stage.accent,
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}>
{stage.label}
</span>
</Fragment>
))}
</div>
);
}
function QueryColumn({ query, intent, accent, info }: { query?: string; intent?: string; accent: string; info: string }) {
return (
<div>
<StageHeader label="User Query" accent={accent} info={info} />
{query ? (
<>
<div style={{
padding: '0.55rem 0.75rem',
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: '6px',
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
fontSize: '0.82rem',
color: 'var(--text)',
wordBreak: 'break-word',
lineHeight: 1.5,
}}>
{query}
</div>
{intent && (
<div style={{
marginTop: '0.35rem',
padding: '0.4rem 0.65rem',
background: 'var(--bg-card)',
border: '1px solid #f57f1730',
borderRadius: '6px',
fontSize: '0.72rem',
lineHeight: 1.4,
}}>
<span style={{
fontFamily: 'system-ui, -apple-system, sans-serif',
fontWeight: 700,
color: '#f57f17',
textTransform: 'uppercase',
fontSize: '0.62rem',
letterSpacing: '0.04em',
}}>
Intent
</span>
<div style={{
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
color: 'var(--text-secondary)',
marginTop: '0.15rem',
}}>
{intent}
</div>
</div>
)}
</>
) : (
<p style={{
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSize: '0.8rem',
color: 'var(--text-muted)',
margin: 0,
}}>
No query yet.
</p>
)}
</div>
);
}
function FinalResultsPanel({ results }: { results: FinalResult[] }) {
return (
<div style={{
padding: '0.85rem',
borderTop: '1px solid var(--pipeline-border)',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '0.4rem',
marginBottom: '0.6rem',
}}>
<span style={{
width: '3px',
height: '14px',
borderRadius: '2px',
background: '#1b5e20',
flexShrink: 0,
}} />
<h3 style={{
margin: 0,
fontSize: '0.82rem',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontWeight: 700,
color: '#1b5e20',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
Final Results
</h3>
<span style={{
fontSize: '0.68rem',
fontFamily: 'system-ui, -apple-system, sans-serif',
color: 'var(--text-muted)',
}}>
({results.length} docs)
</span>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '0.4rem',
}}>
{results.slice(0, 5).map(r => (
<ResultCard
key={r.docId}
title={r.title}
score={r.score}
snippet={r.bestChunk}
/>
))}
</div>
</div>
);
}
export default function PipelineView({ state, query, intent }: PipelineViewProps) {
const blendDone = state.blend.status === 'done';
const finalResults = state.blend.data?.finalResults;
return (
<>
<style>{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
<div style={{
borderRadius: '10px',
overflow: 'hidden',
border: '1px solid var(--pipeline-border)',
background: 'var(--pipeline-bg)',
boxShadow: '0 2px 12px var(--shadow)',
marginBottom: '1.5rem',
}}>
{/* Flow header band */}
<StageFlowHeader />
{/* Process row: 4 stages, align-items: start so columns shrink to content */}
<div
className="pipeline-grid"
style={{
display: 'grid',
gridTemplateColumns: 'minmax(100px, 0.6fr) minmax(110px, 0.7fr) minmax(200px, 1.5fr) minmax(200px, 1.5fr)',
gap: '0',
alignItems: 'start',
}}
>
{STAGES.map((col, i) => (
<div
key={col.label}
className="pipeline-cell"
style={{
padding: '0.85rem',
borderTop: `3px solid ${col.accent}`,
borderRight: i < STAGES.length - 1 ? '1px solid var(--pipeline-border)' : 'none',
}}
>
{i === 0 && <QueryColumn query={query} intent={intent} accent={col.accent} info={col.info} />}
{i === 1 && <ExpansionColumn state={state.expansion} accent={col.accent} info={col.info} />}
{i === 2 && <SearchColumn state={state.search} accent={col.accent} info={col.info} />}
{i === 3 && (
<FusionColumn state={{
rrf: state.rrf,
rerank: state.rerank,
finalResults: finalResults,
}} accent={col.accent} info={col.info} />
)}
</div>
))}
</div>
{/* Results row: full-width below the process stages */}
{blendDone && finalResults && finalResults.length > 0 && (
<FinalResultsPanel results={finalResults} />
)}
</div>
<style>{`
@media (max-width: 768px) {
.pipeline-grid {
grid-template-columns: 1fr !important;
}
.pipeline-cell {
border-right: none !important;
border-bottom: 1px solid var(--pipeline-border);
}
.pipeline-cell:last-child {
border-bottom: none;
}
}
`}</style>
</>
);
}
export { InfoTooltip, StageHeader };