| 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> |
| ); |
| } |
|
|
| |
| 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 }; |
|
|