| | import { useState } from 'react'; |
| | import type { ScoredChunk } from '../types'; |
| | import { InfoTooltip } from './PipelineView'; |
| |
|
| | interface SearchColumnState { |
| | status: 'idle' | 'running' | 'done'; |
| | data?: { bm25Hits: ScoredChunk[]; vectorHits: ScoredChunk[] }; |
| | } |
| |
|
| | interface SearchColumnProps { |
| | state: SearchColumnState; |
| | accent: string; |
| | info: string; |
| | } |
| |
|
| | function Spinner({ color }: { color: string }) { |
| | return ( |
| | <span style={{ |
| | display: 'inline-block', |
| | width: '14px', |
| | height: '14px', |
| | border: '2px solid var(--border)', |
| | borderTopColor: color, |
| | borderRadius: '50%', |
| | animation: 'spin 0.7s linear infinite', |
| | }} /> |
| | ); |
| | } |
| |
|
| | function ScoreBadge({ score, source }: { score: number; source: 'bm25' | 'vector' }) { |
| | const label = source === 'bm25' |
| | ? score.toFixed(2) |
| | : (score * 100).toFixed(1) + '%'; |
| |
|
| | return ( |
| | <span style={{ |
| | padding: '0.1rem 0.35rem', |
| | borderRadius: '4px', |
| | background: 'var(--bg-card)', |
| | border: '1px solid var(--border)', |
| | color: 'var(--text-secondary)', |
| | fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace", |
| | fontSize: '0.65rem', |
| | fontWeight: 700, |
| | flexShrink: 0, |
| | }}> |
| | {label} |
| | </span> |
| | ); |
| | } |
| |
|
| | function HitRow({ hit }: { hit: ScoredChunk }) { |
| | const [open, setOpen] = useState(false); |
| | return ( |
| | <div |
| | onClick={() => setOpen(o => !o)} |
| | style={{ |
| | padding: '0.4rem 0.6rem', |
| | background: 'var(--bg-card)', |
| | border: '1px solid var(--border)', |
| | borderRadius: '5px', |
| | marginBottom: '0.25rem', |
| | cursor: 'pointer', |
| | fontSize: '0.75rem', |
| | }} |
| | onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = '0 1px 5px var(--shadow)'; }} |
| | onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = 'none'; }} |
| | > |
| | <div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}> |
| | <span style={{ |
| | flex: 1, |
| | fontFamily: 'system-ui, -apple-system, sans-serif', |
| | fontWeight: 600, |
| | color: 'var(--text)', |
| | overflow: 'hidden', |
| | textOverflow: 'ellipsis', |
| | whiteSpace: 'nowrap', |
| | fontSize: '0.73rem', |
| | }}> |
| | {hit.chunk.title} |
| | </span> |
| | <ScoreBadge score={hit.score} source={hit.source} /> |
| | <span style={{ color: 'var(--text-muted)', fontSize: '0.6rem' }}>{open ? '\u25B2' : '\u25BC'}</span> |
| | </div> |
| | {open && ( |
| | <div style={{ |
| | marginTop: '0.35rem', |
| | fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace", |
| | fontSize: '0.65rem', |
| | color: 'var(--text-secondary)', |
| | lineHeight: 1.5, |
| | whiteSpace: 'pre-wrap', |
| | wordBreak: 'break-word', |
| | borderTop: '1px solid var(--border-light)', |
| | paddingTop: '0.35rem', |
| | }}> |
| | {hit.chunk.text} |
| | </div> |
| | )} |
| | </div> |
| | ); |
| | } |
| |
|
| | |
| | function dedupeByDoc(hits: ScoredChunk[]): ScoredChunk[] { |
| | const best = new Map<string, ScoredChunk>(); |
| | for (const hit of hits) { |
| | const existing = best.get(hit.chunk.docId); |
| | if (!existing || hit.score > existing.score) { |
| | best.set(hit.chunk.docId, hit); |
| | } |
| | } |
| | return [...best.values()].sort((a, b) => b.score - a.score); |
| | } |
| |
|
| | function HitsSection({ label, hits, color, dedupe }: { label: string; hits: ScoredChunk[]; color: string; dedupe?: boolean }) { |
| | const displayHits = dedupe ? dedupeByDoc(hits) : hits; |
| | const top = displayHits.slice(0, 5); |
| | return ( |
| | <div style={{ marginBottom: '0.7rem' }}> |
| | <div style={{ |
| | fontSize: '0.68rem', |
| | fontWeight: 700, |
| | fontFamily: 'system-ui, -apple-system, sans-serif', |
| | color, |
| | textTransform: 'uppercase', |
| | letterSpacing: '0.06em', |
| | marginBottom: '0.35rem', |
| | }}> |
| | {label} <span style={{ color: 'var(--text-muted)', fontWeight: 400 }}>({displayHits.length} docs)</span> |
| | </div> |
| | {top.map((hit, i) => ( |
| | <HitRow key={`${hit.chunk.docId}-${hit.chunk.chunkIndex}-${i}`} hit={hit} /> |
| | ))} |
| | {displayHits.length > 5 && ( |
| | <div style={{ |
| | fontSize: '0.68rem', |
| | color: 'var(--text-muted)', |
| | fontFamily: 'system-ui, -apple-system, sans-serif', |
| | paddingLeft: '0.25rem', |
| | }}> |
| | +{displayHits.length - 5} more |
| | </div> |
| | )} |
| | </div> |
| | ); |
| | } |
| |
|
| | export default function SearchColumn({ state, accent, info }: SearchColumnProps) { |
| | const isIdle = state.status === 'idle'; |
| | const isRunning = state.status === 'running'; |
| | const isDone = state.status === 'done'; |
| |
|
| | return ( |
| | <div style={{ opacity: isIdle ? 0.45 : 1, transition: 'opacity 0.3s' }}> |
| | <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', |
| | }}> |
| | Parallel Search |
| | </h3> |
| | {isRunning && <Spinner color={accent} />} |
| | <InfoTooltip text={info} /> |
| | </div> |
| | |
| | {isIdle && ( |
| | <p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.75rem', color: 'var(--text-muted)', margin: 0 }}> |
| | Awaiting expansion... |
| | </p> |
| | )} |
| | |
| | {isRunning && ( |
| | <p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.75rem', color: 'var(--text-secondary)', margin: 0, fontStyle: 'italic' }}> |
| | Running vector + BM25 search... |
| | </p> |
| | )} |
| | |
| | {isDone && state.data && ( |
| | <> |
| | <HitsSection |
| | label="Vector Search" |
| | hits={state.data.vectorHits} |
| | color="#00695c" |
| | dedupe |
| | /> |
| | <HitsSection |
| | label="BM25 Search" |
| | hits={state.data.bm25Hits} |
| | color="#5c6bc0" |
| | dedupe |
| | /> |
| | </> |
| | )} |
| | </div> |
| | ); |
| | } |
| |
|