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