feat: add React UI components for 4-column pipeline visualization
Browse filesCreates all 8 components for the in-browser QMD search pipeline demo:
QueryInput (with example query buttons), ModelStatus (per-model progress
bars), PipelineView (4-column grid), ExpansionColumn (HyDE/Vec/Lex cards),
SearchColumn (vector + BM25 hits with expandable chunks), FusionColumn
(RRF ranking + before/after rerank comparison + final blended results),
ResultCard (score badge + expandable snippet), and DocumentManager
(file upload + paste modal). All inline styles, no CSS framework.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- src/components/DocumentManager.tsx +274 -0
- src/components/ExpansionColumn.tsx +136 -0
- src/components/FusionColumn.tsx +273 -0
- src/components/ModelStatus.tsx +154 -0
- src/components/PipelineView.tsx +122 -0
- src/components/QueryInput.tsx +93 -0
- src/components/ResultCard.tsx +95 -0
- src/components/SearchColumn.tsx +183 -0
src/components/DocumentManager.tsx
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useRef, useState } from 'react';
|
| 2 |
+
|
| 3 |
+
interface DocumentManagerProps {
|
| 4 |
+
documents: Array<{ id: string; title: string; filepath: string }>;
|
| 5 |
+
onUpload: (files: FileList) => void;
|
| 6 |
+
onPaste: (text: string, filename: string) => void;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
function PasteModal({ onClose, onConfirm }: { onClose: () => void; onConfirm: (text: string, filename: string) => void }) {
|
| 10 |
+
const [text, setText] = useState('');
|
| 11 |
+
const [filename, setFilename] = useState('pasted-document.md');
|
| 12 |
+
|
| 13 |
+
function handleConfirm() {
|
| 14 |
+
const trimmed = text.trim();
|
| 15 |
+
if (!trimmed) return;
|
| 16 |
+
onConfirm(trimmed, filename.trim() || 'pasted-document.md');
|
| 17 |
+
onClose();
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
return (
|
| 21 |
+
<div style={{
|
| 22 |
+
position: 'fixed',
|
| 23 |
+
inset: 0,
|
| 24 |
+
background: 'rgba(0,0,0,0.4)',
|
| 25 |
+
display: 'flex',
|
| 26 |
+
alignItems: 'center',
|
| 27 |
+
justifyContent: 'center',
|
| 28 |
+
zIndex: 1000,
|
| 29 |
+
}}
|
| 30 |
+
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
| 31 |
+
>
|
| 32 |
+
<div style={{
|
| 33 |
+
background: '#fff',
|
| 34 |
+
borderRadius: '10px',
|
| 35 |
+
padding: '1.5rem',
|
| 36 |
+
width: '90%',
|
| 37 |
+
maxWidth: '560px',
|
| 38 |
+
boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
|
| 39 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 40 |
+
}}>
|
| 41 |
+
<h3 style={{ margin: '0 0 1rem 0', fontSize: '1rem', color: '#1a1a1a' }}>
|
| 42 |
+
Paste Document
|
| 43 |
+
</h3>
|
| 44 |
+
|
| 45 |
+
<div style={{ marginBottom: '0.75rem' }}>
|
| 46 |
+
<label style={{ fontSize: '0.8rem', color: '#555', display: 'block', marginBottom: '0.3rem' }}>
|
| 47 |
+
Filename
|
| 48 |
+
</label>
|
| 49 |
+
<input
|
| 50 |
+
type="text"
|
| 51 |
+
value={filename}
|
| 52 |
+
onChange={e => setFilename(e.target.value)}
|
| 53 |
+
style={{
|
| 54 |
+
width: '100%',
|
| 55 |
+
padding: '0.45rem 0.65rem',
|
| 56 |
+
fontSize: '0.85rem',
|
| 57 |
+
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
|
| 58 |
+
border: '1px solid #ccc',
|
| 59 |
+
borderRadius: '5px',
|
| 60 |
+
boxSizing: 'border-box',
|
| 61 |
+
}}
|
| 62 |
+
/>
|
| 63 |
+
</div>
|
| 64 |
+
|
| 65 |
+
<div style={{ marginBottom: '1rem' }}>
|
| 66 |
+
<label style={{ fontSize: '0.8rem', color: '#555', display: 'block', marginBottom: '0.3rem' }}>
|
| 67 |
+
Content (Markdown or plain text)
|
| 68 |
+
</label>
|
| 69 |
+
<textarea
|
| 70 |
+
value={text}
|
| 71 |
+
onChange={e => setText(e.target.value)}
|
| 72 |
+
rows={12}
|
| 73 |
+
placeholder="Paste your document content here…"
|
| 74 |
+
style={{
|
| 75 |
+
width: '100%',
|
| 76 |
+
padding: '0.5rem 0.65rem',
|
| 77 |
+
fontSize: '0.8rem',
|
| 78 |
+
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
|
| 79 |
+
border: '1px solid #ccc',
|
| 80 |
+
borderRadius: '5px',
|
| 81 |
+
resize: 'vertical',
|
| 82 |
+
boxSizing: 'border-box',
|
| 83 |
+
lineHeight: 1.5,
|
| 84 |
+
}}
|
| 85 |
+
/>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
|
| 89 |
+
<button
|
| 90 |
+
onClick={onClose}
|
| 91 |
+
style={{
|
| 92 |
+
padding: '0.5rem 1rem',
|
| 93 |
+
fontSize: '0.85rem',
|
| 94 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 95 |
+
background: '#f5f5f5',
|
| 96 |
+
color: '#555',
|
| 97 |
+
border: '1px solid #ddd',
|
| 98 |
+
borderRadius: '5px',
|
| 99 |
+
cursor: 'pointer',
|
| 100 |
+
}}
|
| 101 |
+
>
|
| 102 |
+
Cancel
|
| 103 |
+
</button>
|
| 104 |
+
<button
|
| 105 |
+
onClick={handleConfirm}
|
| 106 |
+
disabled={!text.trim()}
|
| 107 |
+
style={{
|
| 108 |
+
padding: '0.5rem 1rem',
|
| 109 |
+
fontSize: '0.85rem',
|
| 110 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 111 |
+
background: text.trim() ? '#4285F4' : '#ccc',
|
| 112 |
+
color: '#fff',
|
| 113 |
+
border: 'none',
|
| 114 |
+
borderRadius: '5px',
|
| 115 |
+
cursor: text.trim() ? 'pointer' : 'not-allowed',
|
| 116 |
+
fontWeight: 600,
|
| 117 |
+
}}
|
| 118 |
+
>
|
| 119 |
+
Add Document
|
| 120 |
+
</button>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
export default function DocumentManager({ documents, onUpload, onPaste }: DocumentManagerProps) {
|
| 128 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 129 |
+
const [pasteOpen, setPasteOpen] = useState(false);
|
| 130 |
+
|
| 131 |
+
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
| 132 |
+
const files = e.target.files;
|
| 133 |
+
if (files && files.length > 0) {
|
| 134 |
+
onUpload(files);
|
| 135 |
+
}
|
| 136 |
+
// Reset so the same file can be re-uploaded
|
| 137 |
+
e.target.value = '';
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
return (
|
| 141 |
+
<div style={{
|
| 142 |
+
padding: '1rem',
|
| 143 |
+
background: '#f8f8f8',
|
| 144 |
+
border: '1px solid #e0e0e0',
|
| 145 |
+
borderRadius: '8px',
|
| 146 |
+
marginBottom: '1.5rem',
|
| 147 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 148 |
+
}}>
|
| 149 |
+
<div style={{
|
| 150 |
+
display: 'flex',
|
| 151 |
+
alignItems: 'center',
|
| 152 |
+
justifyContent: 'space-between',
|
| 153 |
+
marginBottom: '0.6rem',
|
| 154 |
+
}}>
|
| 155 |
+
<h3 style={{
|
| 156 |
+
margin: 0,
|
| 157 |
+
fontSize: '0.85rem',
|
| 158 |
+
fontWeight: 600,
|
| 159 |
+
color: '#444',
|
| 160 |
+
textTransform: 'uppercase',
|
| 161 |
+
letterSpacing: '0.05em',
|
| 162 |
+
}}>
|
| 163 |
+
Documents
|
| 164 |
+
<span style={{
|
| 165 |
+
marginLeft: '0.5rem',
|
| 166 |
+
fontSize: '0.75rem',
|
| 167 |
+
fontWeight: 400,
|
| 168 |
+
color: '#888',
|
| 169 |
+
}}>
|
| 170 |
+
({documents.length})
|
| 171 |
+
</span>
|
| 172 |
+
</h3>
|
| 173 |
+
<div style={{ display: 'flex', gap: '0.4rem' }}>
|
| 174 |
+
<button
|
| 175 |
+
onClick={() => fileInputRef.current?.click()}
|
| 176 |
+
style={{
|
| 177 |
+
padding: '0.3rem 0.7rem',
|
| 178 |
+
fontSize: '0.78rem',
|
| 179 |
+
background: '#fff',
|
| 180 |
+
color: '#4285F4',
|
| 181 |
+
border: '1px solid #4285F4',
|
| 182 |
+
borderRadius: '5px',
|
| 183 |
+
cursor: 'pointer',
|
| 184 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 185 |
+
fontWeight: 500,
|
| 186 |
+
}}
|
| 187 |
+
>
|
| 188 |
+
Upload
|
| 189 |
+
</button>
|
| 190 |
+
<button
|
| 191 |
+
onClick={() => setPasteOpen(true)}
|
| 192 |
+
style={{
|
| 193 |
+
padding: '0.3rem 0.7rem',
|
| 194 |
+
fontSize: '0.78rem',
|
| 195 |
+
background: '#fff',
|
| 196 |
+
color: '#34a853',
|
| 197 |
+
border: '1px solid #34a853',
|
| 198 |
+
borderRadius: '5px',
|
| 199 |
+
cursor: 'pointer',
|
| 200 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 201 |
+
fontWeight: 500,
|
| 202 |
+
}}
|
| 203 |
+
>
|
| 204 |
+
Paste
|
| 205 |
+
</button>
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<input
|
| 210 |
+
ref={fileInputRef}
|
| 211 |
+
type="file"
|
| 212 |
+
accept=".md,.txt"
|
| 213 |
+
multiple
|
| 214 |
+
style={{ display: 'none' }}
|
| 215 |
+
onChange={handleFileChange}
|
| 216 |
+
/>
|
| 217 |
+
|
| 218 |
+
{documents.length === 0 ? (
|
| 219 |
+
<p style={{ fontSize: '0.82rem', color: '#999', margin: 0 }}>
|
| 220 |
+
No documents loaded. Upload .md or .txt files, or paste text.
|
| 221 |
+
</p>
|
| 222 |
+
) : (
|
| 223 |
+
<div style={{ maxHeight: '180px', overflowY: 'auto' }}>
|
| 224 |
+
{documents.map(doc => (
|
| 225 |
+
<div key={doc.id} style={{
|
| 226 |
+
display: 'flex',
|
| 227 |
+
alignItems: 'center',
|
| 228 |
+
padding: '0.35rem 0.6rem',
|
| 229 |
+
background: '#fff',
|
| 230 |
+
border: '1px solid #e0e0e0',
|
| 231 |
+
borderRadius: '5px',
|
| 232 |
+
marginBottom: '0.3rem',
|
| 233 |
+
gap: '0.5rem',
|
| 234 |
+
}}>
|
| 235 |
+
<span style={{
|
| 236 |
+
fontSize: '0.75rem',
|
| 237 |
+
color: '#ccc',
|
| 238 |
+
flexShrink: 0,
|
| 239 |
+
}}>
|
| 240 |
+
▪
|
| 241 |
+
</span>
|
| 242 |
+
<span style={{
|
| 243 |
+
flex: 1,
|
| 244 |
+
fontSize: '0.8rem',
|
| 245 |
+
fontWeight: 500,
|
| 246 |
+
color: '#333',
|
| 247 |
+
overflow: 'hidden',
|
| 248 |
+
textOverflow: 'ellipsis',
|
| 249 |
+
whiteSpace: 'nowrap',
|
| 250 |
+
}}>
|
| 251 |
+
{doc.title}
|
| 252 |
+
</span>
|
| 253 |
+
<span style={{
|
| 254 |
+
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
|
| 255 |
+
fontSize: '0.68rem',
|
| 256 |
+
color: '#aaa',
|
| 257 |
+
flexShrink: 0,
|
| 258 |
+
}}>
|
| 259 |
+
{doc.filepath}
|
| 260 |
+
</span>
|
| 261 |
+
</div>
|
| 262 |
+
))}
|
| 263 |
+
</div>
|
| 264 |
+
)}
|
| 265 |
+
|
| 266 |
+
{pasteOpen && (
|
| 267 |
+
<PasteModal
|
| 268 |
+
onClose={() => setPasteOpen(false)}
|
| 269 |
+
onConfirm={onPaste}
|
| 270 |
+
/>
|
| 271 |
+
)}
|
| 272 |
+
</div>
|
| 273 |
+
);
|
| 274 |
+
}
|
src/components/ExpansionColumn.tsx
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ExpandedQuery } from '../types';
|
| 2 |
+
|
| 3 |
+
interface ExpansionColumnState {
|
| 4 |
+
status: 'idle' | 'running' | 'done' | 'error';
|
| 5 |
+
data?: ExpandedQuery;
|
| 6 |
+
error?: string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
interface ExpansionColumnProps {
|
| 10 |
+
state: ExpansionColumnState;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function Spinner() {
|
| 14 |
+
return (
|
| 15 |
+
<span style={{
|
| 16 |
+
display: 'inline-block',
|
| 17 |
+
width: '16px',
|
| 18 |
+
height: '16px',
|
| 19 |
+
border: '2px solid #ddd',
|
| 20 |
+
borderTopColor: '#f9a825',
|
| 21 |
+
borderRadius: '50%',
|
| 22 |
+
animation: 'spin 0.7s linear infinite',
|
| 23 |
+
}} />
|
| 24 |
+
);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function ExpansionCard({ label, content }: { label: string; content: string | string[] }) {
|
| 28 |
+
const text = Array.isArray(content) ? content.join('\n') : content;
|
| 29 |
+
return (
|
| 30 |
+
<div style={{
|
| 31 |
+
background: '#fff',
|
| 32 |
+
border: '1px solid #e0e0e0',
|
| 33 |
+
borderRadius: '6px',
|
| 34 |
+
padding: '0.65rem 0.85rem',
|
| 35 |
+
marginBottom: '0.5rem',
|
| 36 |
+
}}>
|
| 37 |
+
<div style={{
|
| 38 |
+
fontSize: '0.72rem',
|
| 39 |
+
fontWeight: 700,
|
| 40 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 41 |
+
color: '#f57f17',
|
| 42 |
+
textTransform: 'uppercase',
|
| 43 |
+
letterSpacing: '0.06em',
|
| 44 |
+
marginBottom: '0.4rem',
|
| 45 |
+
}}>
|
| 46 |
+
{label}
|
| 47 |
+
</div>
|
| 48 |
+
<div style={{
|
| 49 |
+
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
|
| 50 |
+
fontSize: '0.72rem',
|
| 51 |
+
color: '#333',
|
| 52 |
+
lineHeight: 1.6,
|
| 53 |
+
whiteSpace: 'pre-wrap',
|
| 54 |
+
wordBreak: 'break-word',
|
| 55 |
+
}}>
|
| 56 |
+
{text}
|
| 57 |
+
</div>
|
| 58 |
+
</div>
|
| 59 |
+
);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
export default function ExpansionColumn({ state }: ExpansionColumnProps) {
|
| 63 |
+
const isIdle = state.status === 'idle';
|
| 64 |
+
const isRunning = state.status === 'running';
|
| 65 |
+
const isDone = state.status === 'done';
|
| 66 |
+
const isError = state.status === 'error';
|
| 67 |
+
|
| 68 |
+
return (
|
| 69 |
+
<div style={{ opacity: isIdle ? 0.45 : 1, transition: 'opacity 0.3s' }}>
|
| 70 |
+
<div style={{
|
| 71 |
+
display: 'flex',
|
| 72 |
+
alignItems: 'center',
|
| 73 |
+
gap: '0.5rem',
|
| 74 |
+
marginBottom: '0.75rem',
|
| 75 |
+
}}>
|
| 76 |
+
<h3 style={{
|
| 77 |
+
margin: 0,
|
| 78 |
+
fontSize: '0.8rem',
|
| 79 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 80 |
+
fontWeight: 700,
|
| 81 |
+
color: '#5d4037',
|
| 82 |
+
textTransform: 'uppercase',
|
| 83 |
+
letterSpacing: '0.05em',
|
| 84 |
+
}}>
|
| 85 |
+
Query Expansion
|
| 86 |
+
</h3>
|
| 87 |
+
{isRunning && <Spinner />}
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
{isIdle && (
|
| 91 |
+
<p style={{
|
| 92 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 93 |
+
fontSize: '0.8rem',
|
| 94 |
+
color: '#999',
|
| 95 |
+
margin: 0,
|
| 96 |
+
}}>
|
| 97 |
+
Awaiting query…
|
| 98 |
+
</p>
|
| 99 |
+
)}
|
| 100 |
+
|
| 101 |
+
{isRunning && (
|
| 102 |
+
<p style={{
|
| 103 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 104 |
+
fontSize: '0.8rem',
|
| 105 |
+
color: '#888',
|
| 106 |
+
margin: 0,
|
| 107 |
+
fontStyle: 'italic',
|
| 108 |
+
}}>
|
| 109 |
+
Generating expanded queries…
|
| 110 |
+
</p>
|
| 111 |
+
)}
|
| 112 |
+
|
| 113 |
+
{isError && (
|
| 114 |
+
<div style={{
|
| 115 |
+
padding: '0.65rem',
|
| 116 |
+
background: '#fce4ec',
|
| 117 |
+
border: '1px solid #ef9a9a',
|
| 118 |
+
borderRadius: '6px',
|
| 119 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 120 |
+
fontSize: '0.8rem',
|
| 121 |
+
color: '#c62828',
|
| 122 |
+
}}>
|
| 123 |
+
Error: {state.error}
|
| 124 |
+
</div>
|
| 125 |
+
)}
|
| 126 |
+
|
| 127 |
+
{isDone && state.data && (
|
| 128 |
+
<>
|
| 129 |
+
<ExpansionCard label="HyDE (Hypothetical Document)" content={state.data.hyde} />
|
| 130 |
+
<ExpansionCard label="Vec Sentences" content={state.data.vec} />
|
| 131 |
+
<ExpansionCard label="Lex Keywords" content={state.data.lex} />
|
| 132 |
+
</>
|
| 133 |
+
)}
|
| 134 |
+
</div>
|
| 135 |
+
);
|
| 136 |
+
}
|
src/components/FusionColumn.tsx
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { RRFResult, RerankedResult, FinalResult } from '../types';
|
| 2 |
+
import ResultCard from './ResultCard';
|
| 3 |
+
|
| 4 |
+
interface FusionColumnState {
|
| 5 |
+
rrf: { status: 'idle' | 'done'; data?: { merged: RRFResult[] } };
|
| 6 |
+
rerank: { status: 'idle' | 'running' | 'done'; data?: { before: RRFResult[]; after: RerankedResult[] } };
|
| 7 |
+
blend: { status: 'idle' | 'done'; data?: { finalResults: FinalResult[] } };
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
interface FusionColumnProps {
|
| 11 |
+
state: FusionColumnState;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function Spinner() {
|
| 15 |
+
return (
|
| 16 |
+
<span style={{
|
| 17 |
+
display: 'inline-block',
|
| 18 |
+
width: '16px',
|
| 19 |
+
height: '16px',
|
| 20 |
+
border: '2px solid #ddd',
|
| 21 |
+
borderTopColor: '#43a047',
|
| 22 |
+
borderRadius: '50%',
|
| 23 |
+
animation: 'spin 0.7s linear infinite',
|
| 24 |
+
}} />
|
| 25 |
+
);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function SectionHeader({ label, color, badge }: { label: string; color: string; badge?: string }) {
|
| 29 |
+
return (
|
| 30 |
+
<div style={{
|
| 31 |
+
fontSize: '0.72rem',
|
| 32 |
+
fontWeight: 700,
|
| 33 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 34 |
+
color,
|
| 35 |
+
textTransform: 'uppercase',
|
| 36 |
+
letterSpacing: '0.06em',
|
| 37 |
+
marginBottom: '0.4rem',
|
| 38 |
+
display: 'flex',
|
| 39 |
+
alignItems: 'center',
|
| 40 |
+
gap: '0.4rem',
|
| 41 |
+
}}>
|
| 42 |
+
{label}
|
| 43 |
+
{badge && (
|
| 44 |
+
<span style={{ color: '#999', fontWeight: 400, fontSize: '0.68rem' }}>{badge}</span>
|
| 45 |
+
)}
|
| 46 |
+
</div>
|
| 47 |
+
);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function RRFRow({ result, rank }: { result: RRFResult; rank: number }) {
|
| 51 |
+
return (
|
| 52 |
+
<div style={{
|
| 53 |
+
display: 'flex',
|
| 54 |
+
alignItems: 'center',
|
| 55 |
+
gap: '0.5rem',
|
| 56 |
+
padding: '0.35rem 0.55rem',
|
| 57 |
+
background: '#fff',
|
| 58 |
+
border: '1px solid #e0e0e0',
|
| 59 |
+
borderRadius: '5px',
|
| 60 |
+
marginBottom: '0.25rem',
|
| 61 |
+
fontSize: '0.75rem',
|
| 62 |
+
}}>
|
| 63 |
+
<span style={{
|
| 64 |
+
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
|
| 65 |
+
color: '#aaa',
|
| 66 |
+
fontSize: '0.68rem',
|
| 67 |
+
minWidth: '18px',
|
| 68 |
+
}}>
|
| 69 |
+
#{rank}
|
| 70 |
+
</span>
|
| 71 |
+
<span style={{
|
| 72 |
+
flex: 1,
|
| 73 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 74 |
+
color: '#1a1a1a',
|
| 75 |
+
fontWeight: 500,
|
| 76 |
+
overflow: 'hidden',
|
| 77 |
+
textOverflow: 'ellipsis',
|
| 78 |
+
whiteSpace: 'nowrap',
|
| 79 |
+
}}>
|
| 80 |
+
{result.title}
|
| 81 |
+
</span>
|
| 82 |
+
<span style={{
|
| 83 |
+
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
|
| 84 |
+
fontSize: '0.68rem',
|
| 85 |
+
color: '#2e7d32',
|
| 86 |
+
fontWeight: 700,
|
| 87 |
+
flexShrink: 0,
|
| 88 |
+
}}>
|
| 89 |
+
{result.score.toFixed(4)}
|
| 90 |
+
</span>
|
| 91 |
+
</div>
|
| 92 |
+
);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function BeforeAfterComparison({ before, after }: { before: RRFResult[]; after: RerankedResult[] }) {
|
| 96 |
+
const top5before = before.slice(0, 5);
|
| 97 |
+
const top5after = [...after].sort((a, b) => b.blendedScore - a.blendedScore).slice(0, 5);
|
| 98 |
+
|
| 99 |
+
return (
|
| 100 |
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.5rem' }}>
|
| 101 |
+
<div>
|
| 102 |
+
<div style={{
|
| 103 |
+
fontSize: '0.68rem',
|
| 104 |
+
fontWeight: 600,
|
| 105 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 106 |
+
color: '#888',
|
| 107 |
+
marginBottom: '0.3rem',
|
| 108 |
+
textAlign: 'center',
|
| 109 |
+
}}>
|
| 110 |
+
Before
|
| 111 |
+
</div>
|
| 112 |
+
{top5before.map((r, i) => (
|
| 113 |
+
<div key={r.docId} style={{
|
| 114 |
+
padding: '0.3rem 0.4rem',
|
| 115 |
+
background: '#fff',
|
| 116 |
+
border: '1px solid #e0e0e0',
|
| 117 |
+
borderRadius: '4px',
|
| 118 |
+
marginBottom: '0.2rem',
|
| 119 |
+
fontSize: '0.68rem',
|
| 120 |
+
display: 'flex',
|
| 121 |
+
gap: '0.3rem',
|
| 122 |
+
}}>
|
| 123 |
+
<span style={{
|
| 124 |
+
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
|
| 125 |
+
color: '#bbb',
|
| 126 |
+
}}>
|
| 127 |
+
{i + 1}.
|
| 128 |
+
</span>
|
| 129 |
+
<span style={{
|
| 130 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 131 |
+
overflow: 'hidden',
|
| 132 |
+
textOverflow: 'ellipsis',
|
| 133 |
+
whiteSpace: 'nowrap',
|
| 134 |
+
color: '#333',
|
| 135 |
+
}}>
|
| 136 |
+
{r.title}
|
| 137 |
+
</span>
|
| 138 |
+
</div>
|
| 139 |
+
))}
|
| 140 |
+
</div>
|
| 141 |
+
<div>
|
| 142 |
+
<div style={{
|
| 143 |
+
fontSize: '0.68rem',
|
| 144 |
+
fontWeight: 600,
|
| 145 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 146 |
+
color: '#388e3c',
|
| 147 |
+
marginBottom: '0.3rem',
|
| 148 |
+
textAlign: 'center',
|
| 149 |
+
}}>
|
| 150 |
+
After Rerank
|
| 151 |
+
</div>
|
| 152 |
+
{top5after.map((r, i) => (
|
| 153 |
+
<div key={r.docId} style={{
|
| 154 |
+
padding: '0.3rem 0.4rem',
|
| 155 |
+
background: '#f1f8e9',
|
| 156 |
+
border: '1px solid #c8e6c9',
|
| 157 |
+
borderRadius: '4px',
|
| 158 |
+
marginBottom: '0.2rem',
|
| 159 |
+
fontSize: '0.68rem',
|
| 160 |
+
display: 'flex',
|
| 161 |
+
gap: '0.3rem',
|
| 162 |
+
}}>
|
| 163 |
+
<span style={{
|
| 164 |
+
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
|
| 165 |
+
color: '#81c784',
|
| 166 |
+
}}>
|
| 167 |
+
{i + 1}.
|
| 168 |
+
</span>
|
| 169 |
+
<span style={{
|
| 170 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 171 |
+
overflow: 'hidden',
|
| 172 |
+
textOverflow: 'ellipsis',
|
| 173 |
+
whiteSpace: 'nowrap',
|
| 174 |
+
color: '#2e7d32',
|
| 175 |
+
fontWeight: 500,
|
| 176 |
+
}}>
|
| 177 |
+
{r.title}
|
| 178 |
+
</span>
|
| 179 |
+
</div>
|
| 180 |
+
))}
|
| 181 |
+
</div>
|
| 182 |
+
</div>
|
| 183 |
+
);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
export default function FusionColumn({ state }: FusionColumnProps) {
|
| 187 |
+
const rrfDone = state.rrf.status === 'done';
|
| 188 |
+
const rerankRunning = state.rerank.status === 'running';
|
| 189 |
+
const rerankDone = state.rerank.status === 'done';
|
| 190 |
+
const blendDone = state.blend.status === 'done';
|
| 191 |
+
const isIdle = !rrfDone && !rerankRunning && !rerankDone && !blendDone;
|
| 192 |
+
|
| 193 |
+
return (
|
| 194 |
+
<div style={{ opacity: isIdle ? 0.45 : 1, transition: 'opacity 0.3s' }}>
|
| 195 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
| 196 |
+
<h3 style={{
|
| 197 |
+
margin: 0,
|
| 198 |
+
fontSize: '0.8rem',
|
| 199 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 200 |
+
fontWeight: 700,
|
| 201 |
+
color: '#1b5e20',
|
| 202 |
+
textTransform: 'uppercase',
|
| 203 |
+
letterSpacing: '0.05em',
|
| 204 |
+
}}>
|
| 205 |
+
Fusion & Reranking
|
| 206 |
+
</h3>
|
| 207 |
+
{rerankRunning && <Spinner />}
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
{isIdle && (
|
| 211 |
+
<p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.8rem', color: '#999', margin: 0 }}>
|
| 212 |
+
Awaiting search…
|
| 213 |
+
</p>
|
| 214 |
+
)}
|
| 215 |
+
|
| 216 |
+
{/* RRF Merged */}
|
| 217 |
+
{rrfDone && state.rrf.data && (
|
| 218 |
+
<div style={{ marginBottom: '0.85rem' }}>
|
| 219 |
+
<SectionHeader
|
| 220 |
+
label="RRF Fusion"
|
| 221 |
+
color="#558b2f"
|
| 222 |
+
badge={`(${state.rrf.data.merged.length} docs)`}
|
| 223 |
+
/>
|
| 224 |
+
{state.rrf.data.merged.slice(0, 5).map((r, i) => (
|
| 225 |
+
<RRFRow key={r.docId} result={r} rank={i + 1} />
|
| 226 |
+
))}
|
| 227 |
+
{state.rrf.data.merged.length > 5 && (
|
| 228 |
+
<div style={{ fontSize: '0.72rem', color: '#999', fontFamily: 'system-ui, -apple-system, sans-serif', paddingLeft: '0.25rem' }}>
|
| 229 |
+
+{state.rrf.data.merged.length - 5} more
|
| 230 |
+
</div>
|
| 231 |
+
)}
|
| 232 |
+
</div>
|
| 233 |
+
)}
|
| 234 |
+
|
| 235 |
+
{/* Rerank running */}
|
| 236 |
+
{rerankRunning && !rerankDone && (
|
| 237 |
+
<p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.8rem', color: '#888', margin: '0 0 0.75rem 0', fontStyle: 'italic' }}>
|
| 238 |
+
Reranking with cross-encoder…
|
| 239 |
+
</p>
|
| 240 |
+
)}
|
| 241 |
+
|
| 242 |
+
{/* Before/After rerank */}
|
| 243 |
+
{rerankDone && state.rerank.data && (
|
| 244 |
+
<div style={{ marginBottom: '0.85rem' }}>
|
| 245 |
+
<SectionHeader label="Reranking" color="#33691e" />
|
| 246 |
+
<BeforeAfterComparison
|
| 247 |
+
before={state.rerank.data.before}
|
| 248 |
+
after={state.rerank.data.after}
|
| 249 |
+
/>
|
| 250 |
+
</div>
|
| 251 |
+
)}
|
| 252 |
+
|
| 253 |
+
{/* Final blended results */}
|
| 254 |
+
{blendDone && state.blend.data && (
|
| 255 |
+
<div>
|
| 256 |
+
<SectionHeader
|
| 257 |
+
label="Final Results"
|
| 258 |
+
color="#1b5e20"
|
| 259 |
+
badge={`(${state.blend.data.finalResults.length} docs)`}
|
| 260 |
+
/>
|
| 261 |
+
{state.blend.data.finalResults.slice(0, 5).map(r => (
|
| 262 |
+
<ResultCard
|
| 263 |
+
key={r.docId}
|
| 264 |
+
title={r.title}
|
| 265 |
+
score={r.score}
|
| 266 |
+
snippet={r.bestChunk}
|
| 267 |
+
/>
|
| 268 |
+
))}
|
| 269 |
+
</div>
|
| 270 |
+
)}
|
| 271 |
+
</div>
|
| 272 |
+
);
|
| 273 |
+
}
|
src/components/ModelStatus.tsx
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ModelState } from '../types';
|
| 2 |
+
|
| 3 |
+
interface ModelStatusProps {
|
| 4 |
+
models: ModelState[];
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
const STATUS_COLOR: Record<ModelState['status'], string> = {
|
| 8 |
+
pending: '#9e9e9e',
|
| 9 |
+
downloading: '#1976d2',
|
| 10 |
+
loading: '#f9a825',
|
| 11 |
+
ready: '#388e3c',
|
| 12 |
+
error: '#d32f2f',
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
const STATUS_LABEL: Record<ModelState['status'], string> = {
|
| 16 |
+
pending: 'Pending',
|
| 17 |
+
downloading: 'Downloading',
|
| 18 |
+
loading: 'Loading',
|
| 19 |
+
ready: 'Ready',
|
| 20 |
+
error: 'Error',
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
function ProgressBar({ progress, color }: { progress: number; color: string }) {
|
| 24 |
+
return (
|
| 25 |
+
<div style={{
|
| 26 |
+
height: '4px',
|
| 27 |
+
background: '#e0e0e0',
|
| 28 |
+
borderRadius: '2px',
|
| 29 |
+
overflow: 'hidden',
|
| 30 |
+
marginTop: '4px',
|
| 31 |
+
}}>
|
| 32 |
+
<div style={{
|
| 33 |
+
height: '100%',
|
| 34 |
+
width: `${Math.round(progress * 100)}%`,
|
| 35 |
+
background: color,
|
| 36 |
+
borderRadius: '2px',
|
| 37 |
+
transition: 'width 0.3s ease',
|
| 38 |
+
}} />
|
| 39 |
+
</div>
|
| 40 |
+
);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function ModelRow({ model }: { model: ModelState }) {
|
| 44 |
+
const color = STATUS_COLOR[model.status];
|
| 45 |
+
const showProgress = model.status === 'downloading' || model.status === 'loading';
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<div style={{
|
| 49 |
+
padding: '0.5rem 0.75rem',
|
| 50 |
+
background: '#fff',
|
| 51 |
+
border: '1px solid #e0e0e0',
|
| 52 |
+
borderRadius: '6px',
|
| 53 |
+
marginBottom: '0.4rem',
|
| 54 |
+
}}>
|
| 55 |
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
| 56 |
+
<span style={{
|
| 57 |
+
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
|
| 58 |
+
fontSize: '0.78rem',
|
| 59 |
+
color: '#333',
|
| 60 |
+
}}>
|
| 61 |
+
{model.name}
|
| 62 |
+
</span>
|
| 63 |
+
<span style={{
|
| 64 |
+
fontSize: '0.72rem',
|
| 65 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 66 |
+
fontWeight: 600,
|
| 67 |
+
color,
|
| 68 |
+
display: 'flex',
|
| 69 |
+
alignItems: 'center',
|
| 70 |
+
gap: '0.3rem',
|
| 71 |
+
}}>
|
| 72 |
+
{model.status === 'ready' && (
|
| 73 |
+
<span style={{ fontSize: '0.85rem' }}>✓</span>
|
| 74 |
+
)}
|
| 75 |
+
{model.status === 'error' && (
|
| 76 |
+
<span style={{ fontSize: '0.85rem' }}>✗</span>
|
| 77 |
+
)}
|
| 78 |
+
{STATUS_LABEL[model.status]}
|
| 79 |
+
{showProgress && (
|
| 80 |
+
<span style={{ color: '#888', fontWeight: 400 }}>
|
| 81 |
+
{Math.round(model.progress * 100)}%
|
| 82 |
+
</span>
|
| 83 |
+
)}
|
| 84 |
+
</span>
|
| 85 |
+
</div>
|
| 86 |
+
{showProgress && <ProgressBar progress={model.progress} color={color} />}
|
| 87 |
+
{model.status === 'error' && model.error && (
|
| 88 |
+
<div style={{
|
| 89 |
+
marginTop: '4px',
|
| 90 |
+
fontSize: '0.72rem',
|
| 91 |
+
color: '#d32f2f',
|
| 92 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 93 |
+
}}>
|
| 94 |
+
{model.error}
|
| 95 |
+
</div>
|
| 96 |
+
)}
|
| 97 |
+
</div>
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
export default function ModelStatus({ models }: ModelStatusProps) {
|
| 102 |
+
const allReady = models.length > 0 && models.every(m => m.status === 'ready');
|
| 103 |
+
|
| 104 |
+
return (
|
| 105 |
+
<div style={{
|
| 106 |
+
padding: '1rem',
|
| 107 |
+
background: '#f8f8f8',
|
| 108 |
+
border: '1px solid #e0e0e0',
|
| 109 |
+
borderRadius: '8px',
|
| 110 |
+
marginBottom: '1.5rem',
|
| 111 |
+
}}>
|
| 112 |
+
<div style={{
|
| 113 |
+
display: 'flex',
|
| 114 |
+
alignItems: 'center',
|
| 115 |
+
justifyContent: 'space-between',
|
| 116 |
+
marginBottom: '0.6rem',
|
| 117 |
+
}}>
|
| 118 |
+
<h3 style={{
|
| 119 |
+
margin: 0,
|
| 120 |
+
fontSize: '0.85rem',
|
| 121 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 122 |
+
fontWeight: 600,
|
| 123 |
+
color: '#444',
|
| 124 |
+
textTransform: 'uppercase',
|
| 125 |
+
letterSpacing: '0.05em',
|
| 126 |
+
}}>
|
| 127 |
+
Models
|
| 128 |
+
</h3>
|
| 129 |
+
{allReady && (
|
| 130 |
+
<span style={{
|
| 131 |
+
fontSize: '0.75rem',
|
| 132 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 133 |
+
color: '#388e3c',
|
| 134 |
+
fontWeight: 600,
|
| 135 |
+
}}>
|
| 136 |
+
All ready
|
| 137 |
+
</span>
|
| 138 |
+
)}
|
| 139 |
+
</div>
|
| 140 |
+
{models.map(m => (
|
| 141 |
+
<ModelRow key={m.name} model={m} />
|
| 142 |
+
))}
|
| 143 |
+
{models.length === 0 && (
|
| 144 |
+
<div style={{
|
| 145 |
+
color: '#999',
|
| 146 |
+
fontSize: '0.85rem',
|
| 147 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 148 |
+
}}>
|
| 149 |
+
No models configured.
|
| 150 |
+
</div>
|
| 151 |
+
)}
|
| 152 |
+
</div>
|
| 153 |
+
);
|
| 154 |
+
}
|
src/components/PipelineView.tsx
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ExpandedQuery, ScoredChunk, RRFResult, RerankedResult, FinalResult } from '../types';
|
| 2 |
+
import ExpansionColumn from './ExpansionColumn';
|
| 3 |
+
import SearchColumn from './SearchColumn';
|
| 4 |
+
import FusionColumn from './FusionColumn';
|
| 5 |
+
|
| 6 |
+
export interface PipelineState {
|
| 7 |
+
expansion: { status: 'idle' | 'running' | 'done' | 'error'; data?: ExpandedQuery; error?: string };
|
| 8 |
+
search: { status: 'idle' | 'running' | 'done'; data?: { bm25Hits: ScoredChunk[]; vectorHits: ScoredChunk[] } };
|
| 9 |
+
rrf: { status: 'idle' | 'done'; data?: { merged: RRFResult[] } };
|
| 10 |
+
rerank: { status: 'idle' | 'running' | 'done'; data?: { before: RRFResult[]; after: RerankedResult[] } };
|
| 11 |
+
blend: { status: 'idle' | 'done'; data?: { finalResults: FinalResult[] } };
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
interface PipelineViewProps {
|
| 15 |
+
state: PipelineState;
|
| 16 |
+
query?: string;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const COLUMNS = [
|
| 20 |
+
{ label: 'User Query', bg: '#E8F0FE', headerColor: '#1a237e' },
|
| 21 |
+
{ label: 'Query Expansion', bg: '#FFF8E1', headerColor: '#5d4037' },
|
| 22 |
+
{ label: 'Parallel Search', bg: '#E0F2F1', headerColor: '#004d40' },
|
| 23 |
+
{ label: 'Result Fusion & Reranking', bg: '#E8F5E9', headerColor: '#1b5e20' },
|
| 24 |
+
];
|
| 25 |
+
|
| 26 |
+
function QueryColumn({ query }: { query?: string }) {
|
| 27 |
+
return (
|
| 28 |
+
<div>
|
| 29 |
+
<h3 style={{
|
| 30 |
+
margin: '0 0 0.75rem 0',
|
| 31 |
+
fontSize: '0.8rem',
|
| 32 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 33 |
+
fontWeight: 700,
|
| 34 |
+
color: '#1a237e',
|
| 35 |
+
textTransform: 'uppercase',
|
| 36 |
+
letterSpacing: '0.05em',
|
| 37 |
+
}}>
|
| 38 |
+
User Query
|
| 39 |
+
</h3>
|
| 40 |
+
{query ? (
|
| 41 |
+
<div style={{
|
| 42 |
+
padding: '0.65rem 0.85rem',
|
| 43 |
+
background: '#fff',
|
| 44 |
+
border: '1px solid #c5cae9',
|
| 45 |
+
borderRadius: '6px',
|
| 46 |
+
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
|
| 47 |
+
fontSize: '0.85rem',
|
| 48 |
+
color: '#1a237e',
|
| 49 |
+
wordBreak: 'break-word',
|
| 50 |
+
lineHeight: 1.5,
|
| 51 |
+
}}>
|
| 52 |
+
{query}
|
| 53 |
+
</div>
|
| 54 |
+
) : (
|
| 55 |
+
<p style={{
|
| 56 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 57 |
+
fontSize: '0.8rem',
|
| 58 |
+
color: '#999',
|
| 59 |
+
margin: 0,
|
| 60 |
+
}}>
|
| 61 |
+
No query yet.
|
| 62 |
+
</p>
|
| 63 |
+
)}
|
| 64 |
+
</div>
|
| 65 |
+
);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export default function PipelineView({ state, query }: PipelineViewProps) {
|
| 69 |
+
return (
|
| 70 |
+
<>
|
| 71 |
+
{/* Inject keyframes for spinner */}
|
| 72 |
+
<style>{`
|
| 73 |
+
@keyframes spin {
|
| 74 |
+
to { transform: rotate(360deg); }
|
| 75 |
+
}
|
| 76 |
+
`}</style>
|
| 77 |
+
|
| 78 |
+
<div style={{
|
| 79 |
+
display: 'grid',
|
| 80 |
+
gridTemplateColumns: 'repeat(4, 1fr)',
|
| 81 |
+
gap: '0',
|
| 82 |
+
borderRadius: '10px',
|
| 83 |
+
overflow: 'hidden',
|
| 84 |
+
border: '1px solid #d0d0d0',
|
| 85 |
+
boxShadow: '0 2px 12px rgba(0,0,0,0.07)',
|
| 86 |
+
}}>
|
| 87 |
+
{/* Column backgrounds are rendered as wrappers */}
|
| 88 |
+
{COLUMNS.map((col, i) => (
|
| 89 |
+
<div
|
| 90 |
+
key={col.label}
|
| 91 |
+
style={{
|
| 92 |
+
background: col.bg,
|
| 93 |
+
padding: '1rem',
|
| 94 |
+
borderRight: i < COLUMNS.length - 1 ? '1px solid #d0d0d0' : 'none',
|
| 95 |
+
minHeight: '300px',
|
| 96 |
+
}}
|
| 97 |
+
>
|
| 98 |
+
{i === 0 && <QueryColumn query={query} />}
|
| 99 |
+
{i === 1 && <ExpansionColumn state={state.expansion} />}
|
| 100 |
+
{i === 2 && <SearchColumn state={state.search} />}
|
| 101 |
+
{i === 3 && (
|
| 102 |
+
<FusionColumn state={{
|
| 103 |
+
rrf: state.rrf,
|
| 104 |
+
rerank: state.rerank,
|
| 105 |
+
blend: state.blend,
|
| 106 |
+
}} />
|
| 107 |
+
)}
|
| 108 |
+
</div>
|
| 109 |
+
))}
|
| 110 |
+
</div>
|
| 111 |
+
|
| 112 |
+
{/* Responsive: stack on small screens */}
|
| 113 |
+
<style>{`
|
| 114 |
+
@media (max-width: 768px) {
|
| 115 |
+
.pipeline-grid {
|
| 116 |
+
grid-template-columns: 1fr !important;
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
`}</style>
|
| 120 |
+
</>
|
| 121 |
+
);
|
| 122 |
+
}
|
src/components/QueryInput.tsx
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { EXAMPLE_QUERIES } from '../constants';
|
| 3 |
+
|
| 4 |
+
interface QueryInputProps {
|
| 5 |
+
onSearch: (query: string) => void;
|
| 6 |
+
disabled: boolean;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export default function QueryInput({ onSearch, disabled }: QueryInputProps) {
|
| 10 |
+
const [query, setQuery] = useState('');
|
| 11 |
+
|
| 12 |
+
function handleSubmit(e: React.FormEvent) {
|
| 13 |
+
e.preventDefault();
|
| 14 |
+
const trimmed = query.trim();
|
| 15 |
+
if (trimmed) onSearch(trimmed);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function handleExample(q: string) {
|
| 19 |
+
setQuery(q);
|
| 20 |
+
onSearch(q);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<div style={{ marginBottom: '1.5rem' }}>
|
| 25 |
+
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '0.5rem' }}>
|
| 26 |
+
<input
|
| 27 |
+
type="text"
|
| 28 |
+
value={query}
|
| 29 |
+
onChange={e => setQuery(e.target.value)}
|
| 30 |
+
disabled={disabled}
|
| 31 |
+
placeholder={disabled ? 'Loading models…' : 'Enter a search query…'}
|
| 32 |
+
style={{
|
| 33 |
+
flex: 1,
|
| 34 |
+
padding: '0.6rem 0.9rem',
|
| 35 |
+
fontSize: '1rem',
|
| 36 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 37 |
+
border: '1px solid #ccc',
|
| 38 |
+
borderRadius: '6px',
|
| 39 |
+
background: disabled ? '#f5f5f5' : '#fff',
|
| 40 |
+
color: disabled ? '#999' : '#111',
|
| 41 |
+
outline: 'none',
|
| 42 |
+
transition: 'border-color 0.15s',
|
| 43 |
+
}}
|
| 44 |
+
onFocus={e => { if (!disabled) e.target.style.borderColor = '#4285F4'; }}
|
| 45 |
+
onBlur={e => { e.target.style.borderColor = '#ccc'; }}
|
| 46 |
+
/>
|
| 47 |
+
<button
|
| 48 |
+
type="submit"
|
| 49 |
+
disabled={disabled || !query.trim()}
|
| 50 |
+
style={{
|
| 51 |
+
padding: '0.6rem 1.2rem',
|
| 52 |
+
fontSize: '1rem',
|
| 53 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 54 |
+
background: disabled || !query.trim() ? '#ccc' : '#4285F4',
|
| 55 |
+
color: '#fff',
|
| 56 |
+
border: 'none',
|
| 57 |
+
borderRadius: '6px',
|
| 58 |
+
cursor: disabled || !query.trim() ? 'not-allowed' : 'pointer',
|
| 59 |
+
transition: 'background 0.15s',
|
| 60 |
+
fontWeight: 600,
|
| 61 |
+
}}
|
| 62 |
+
>
|
| 63 |
+
Search
|
| 64 |
+
</button>
|
| 65 |
+
</form>
|
| 66 |
+
|
| 67 |
+
<div style={{ marginTop: '0.6rem', display: 'flex', gap: '0.4rem', flexWrap: 'wrap', alignItems: 'center' }}>
|
| 68 |
+
<span style={{ fontSize: '0.8rem', color: '#666', fontFamily: 'system-ui, -apple-system, sans-serif' }}>
|
| 69 |
+
Examples:
|
| 70 |
+
</span>
|
| 71 |
+
{EXAMPLE_QUERIES.map(q => (
|
| 72 |
+
<button
|
| 73 |
+
key={q}
|
| 74 |
+
onClick={() => handleExample(q)}
|
| 75 |
+
disabled={disabled}
|
| 76 |
+
style={{
|
| 77 |
+
padding: '0.25rem 0.6rem',
|
| 78 |
+
fontSize: '0.8rem',
|
| 79 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 80 |
+
background: '#f0f4ff',
|
| 81 |
+
color: disabled ? '#aaa' : '#4285F4',
|
| 82 |
+
border: '1px solid #c5d5ff',
|
| 83 |
+
borderRadius: '4px',
|
| 84 |
+
cursor: disabled ? 'not-allowed' : 'pointer',
|
| 85 |
+
}}
|
| 86 |
+
>
|
| 87 |
+
{q}
|
| 88 |
+
</button>
|
| 89 |
+
))}
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
);
|
| 93 |
+
}
|
src/components/ResultCard.tsx
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
|
| 3 |
+
interface ResultCardProps {
|
| 4 |
+
title: string;
|
| 5 |
+
score: number;
|
| 6 |
+
snippet: string;
|
| 7 |
+
expanded?: boolean;
|
| 8 |
+
onToggle?: () => void;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function ScoreBadge({ score }: { score: number }) {
|
| 12 |
+
const pct = Math.round(score * 100);
|
| 13 |
+
const bg = pct >= 80 ? '#e8f5e9' : pct >= 50 ? '#fff8e1' : '#fce4ec';
|
| 14 |
+
const color = pct >= 80 ? '#2e7d32' : pct >= 50 ? '#f57f17' : '#c62828';
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<span style={{
|
| 18 |
+
display: 'inline-block',
|
| 19 |
+
padding: '0.15rem 0.45rem',
|
| 20 |
+
borderRadius: '4px',
|
| 21 |
+
background: bg,
|
| 22 |
+
color,
|
| 23 |
+
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
|
| 24 |
+
fontSize: '0.72rem',
|
| 25 |
+
fontWeight: 700,
|
| 26 |
+
}}>
|
| 27 |
+
{pct}%
|
| 28 |
+
</span>
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export default function ResultCard({ title, score, snippet, expanded: expandedProp, onToggle }: ResultCardProps) {
|
| 33 |
+
const [localExpanded, setLocalExpanded] = useState(false);
|
| 34 |
+
const isControlled = expandedProp !== undefined;
|
| 35 |
+
const expanded = isControlled ? expandedProp : localExpanded;
|
| 36 |
+
|
| 37 |
+
function handleToggle() {
|
| 38 |
+
if (isControlled) {
|
| 39 |
+
onToggle?.();
|
| 40 |
+
} else {
|
| 41 |
+
setLocalExpanded(e => !e);
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
const preview = snippet.length > 200 ? snippet.slice(0, 200) + '…' : snippet;
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<div
|
| 49 |
+
onClick={handleToggle}
|
| 50 |
+
style={{
|
| 51 |
+
padding: '0.65rem 0.85rem',
|
| 52 |
+
background: '#fff',
|
| 53 |
+
border: '1px solid #e0e0e0',
|
| 54 |
+
borderRadius: '6px',
|
| 55 |
+
marginBottom: '0.4rem',
|
| 56 |
+
cursor: 'pointer',
|
| 57 |
+
transition: 'box-shadow 0.15s',
|
| 58 |
+
}}
|
| 59 |
+
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)'; }}
|
| 60 |
+
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = 'none'; }}
|
| 61 |
+
>
|
| 62 |
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
|
| 63 |
+
<span style={{
|
| 64 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 65 |
+
fontSize: '0.85rem',
|
| 66 |
+
fontWeight: 600,
|
| 67 |
+
color: '#1a1a1a',
|
| 68 |
+
overflow: 'hidden',
|
| 69 |
+
textOverflow: 'ellipsis',
|
| 70 |
+
whiteSpace: 'nowrap',
|
| 71 |
+
flex: 1,
|
| 72 |
+
}}>
|
| 73 |
+
{title}
|
| 74 |
+
</span>
|
| 75 |
+
<ScoreBadge score={score} />
|
| 76 |
+
<span style={{ color: '#999', fontSize: '0.75rem', flexShrink: 0 }}>
|
| 77 |
+
{expanded ? '▲' : '▼'}
|
| 78 |
+
</span>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<div style={{
|
| 82 |
+
marginTop: '0.4rem',
|
| 83 |
+
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
|
| 84 |
+
fontSize: '0.72rem',
|
| 85 |
+
color: '#555',
|
| 86 |
+
lineHeight: 1.5,
|
| 87 |
+
whiteSpace: expanded ? 'pre-wrap' : 'nowrap',
|
| 88 |
+
overflow: 'hidden',
|
| 89 |
+
textOverflow: expanded ? 'unset' : 'ellipsis',
|
| 90 |
+
}}>
|
| 91 |
+
{expanded ? snippet : preview}
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
);
|
| 95 |
+
}
|
src/components/SearchColumn.tsx
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import type { ScoredChunk } from '../types';
|
| 3 |
+
|
| 4 |
+
interface SearchColumnState {
|
| 5 |
+
status: 'idle' | 'running' | 'done';
|
| 6 |
+
data?: { bm25Hits: ScoredChunk[]; vectorHits: ScoredChunk[] };
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
interface SearchColumnProps {
|
| 10 |
+
state: SearchColumnState;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function Spinner() {
|
| 14 |
+
return (
|
| 15 |
+
<span style={{
|
| 16 |
+
display: 'inline-block',
|
| 17 |
+
width: '16px',
|
| 18 |
+
height: '16px',
|
| 19 |
+
border: '2px solid #ddd',
|
| 20 |
+
borderTopColor: '#00897b',
|
| 21 |
+
borderRadius: '50%',
|
| 22 |
+
animation: 'spin 0.7s linear infinite',
|
| 23 |
+
}} />
|
| 24 |
+
);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function ScoreBadge({ score, source }: { score: number; source: 'bm25' | 'vector' }) {
|
| 28 |
+
const label = source === 'bm25'
|
| 29 |
+
? score.toFixed(2)
|
| 30 |
+
: (score * 100).toFixed(1) + '%';
|
| 31 |
+
const bg = source === 'vector' ? '#e0f2f1' : '#e8eaf6';
|
| 32 |
+
const color = source === 'vector' ? '#00695c' : '#283593';
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<span style={{
|
| 36 |
+
padding: '0.1rem 0.35rem',
|
| 37 |
+
borderRadius: '4px',
|
| 38 |
+
background: bg,
|
| 39 |
+
color,
|
| 40 |
+
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
|
| 41 |
+
fontSize: '0.68rem',
|
| 42 |
+
fontWeight: 700,
|
| 43 |
+
flexShrink: 0,
|
| 44 |
+
}}>
|
| 45 |
+
{label}
|
| 46 |
+
</span>
|
| 47 |
+
);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function HitRow({ hit }: { hit: ScoredChunk }) {
|
| 51 |
+
const [open, setOpen] = useState(false);
|
| 52 |
+
return (
|
| 53 |
+
<div
|
| 54 |
+
onClick={() => setOpen(o => !o)}
|
| 55 |
+
style={{
|
| 56 |
+
padding: '0.45rem 0.65rem',
|
| 57 |
+
background: '#fff',
|
| 58 |
+
border: '1px solid #e0e0e0',
|
| 59 |
+
borderRadius: '5px',
|
| 60 |
+
marginBottom: '0.3rem',
|
| 61 |
+
cursor: 'pointer',
|
| 62 |
+
fontSize: '0.78rem',
|
| 63 |
+
}}
|
| 64 |
+
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = '0 1px 5px rgba(0,0,0,0.08)'; }}
|
| 65 |
+
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = 'none'; }}
|
| 66 |
+
>
|
| 67 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
|
| 68 |
+
<span style={{
|
| 69 |
+
flex: 1,
|
| 70 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 71 |
+
fontWeight: 600,
|
| 72 |
+
color: '#1a1a1a',
|
| 73 |
+
overflow: 'hidden',
|
| 74 |
+
textOverflow: 'ellipsis',
|
| 75 |
+
whiteSpace: 'nowrap',
|
| 76 |
+
}}>
|
| 77 |
+
{hit.chunk.title}
|
| 78 |
+
</span>
|
| 79 |
+
<ScoreBadge score={hit.score} source={hit.source} />
|
| 80 |
+
<span style={{ color: '#bbb', fontSize: '0.65rem' }}>{open ? '▲' : '▼'}</span>
|
| 81 |
+
</div>
|
| 82 |
+
{open && (
|
| 83 |
+
<div style={{
|
| 84 |
+
marginTop: '0.4rem',
|
| 85 |
+
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', monospace",
|
| 86 |
+
fontSize: '0.68rem',
|
| 87 |
+
color: '#555',
|
| 88 |
+
lineHeight: 1.55,
|
| 89 |
+
whiteSpace: 'pre-wrap',
|
| 90 |
+
wordBreak: 'break-word',
|
| 91 |
+
borderTop: '1px solid #f0f0f0',
|
| 92 |
+
paddingTop: '0.4rem',
|
| 93 |
+
}}>
|
| 94 |
+
{hit.chunk.text}
|
| 95 |
+
</div>
|
| 96 |
+
)}
|
| 97 |
+
</div>
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function HitsSection({ label, hits, color }: { label: string; hits: ScoredChunk[]; color: string }) {
|
| 102 |
+
const top = hits.slice(0, 5);
|
| 103 |
+
return (
|
| 104 |
+
<div style={{ marginBottom: '0.85rem' }}>
|
| 105 |
+
<div style={{
|
| 106 |
+
fontSize: '0.72rem',
|
| 107 |
+
fontWeight: 700,
|
| 108 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 109 |
+
color,
|
| 110 |
+
textTransform: 'uppercase',
|
| 111 |
+
letterSpacing: '0.06em',
|
| 112 |
+
marginBottom: '0.4rem',
|
| 113 |
+
}}>
|
| 114 |
+
{label} <span style={{ color: '#999', fontWeight: 400 }}>({hits.length} hits)</span>
|
| 115 |
+
</div>
|
| 116 |
+
{top.map((hit, i) => (
|
| 117 |
+
<HitRow key={`${hit.chunk.docId}-${hit.chunk.chunkIndex}-${i}`} hit={hit} />
|
| 118 |
+
))}
|
| 119 |
+
{hits.length > 5 && (
|
| 120 |
+
<div style={{
|
| 121 |
+
fontSize: '0.72rem',
|
| 122 |
+
color: '#999',
|
| 123 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 124 |
+
paddingLeft: '0.25rem',
|
| 125 |
+
}}>
|
| 126 |
+
+{hits.length - 5} more
|
| 127 |
+
</div>
|
| 128 |
+
)}
|
| 129 |
+
</div>
|
| 130 |
+
);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
export default function SearchColumn({ state }: SearchColumnProps) {
|
| 134 |
+
const isIdle = state.status === 'idle';
|
| 135 |
+
const isRunning = state.status === 'running';
|
| 136 |
+
const isDone = state.status === 'done';
|
| 137 |
+
|
| 138 |
+
return (
|
| 139 |
+
<div style={{ opacity: isIdle ? 0.45 : 1, transition: 'opacity 0.3s' }}>
|
| 140 |
+
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
| 141 |
+
<h3 style={{
|
| 142 |
+
margin: 0,
|
| 143 |
+
fontSize: '0.8rem',
|
| 144 |
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
| 145 |
+
fontWeight: 700,
|
| 146 |
+
color: '#004d40',
|
| 147 |
+
textTransform: 'uppercase',
|
| 148 |
+
letterSpacing: '0.05em',
|
| 149 |
+
}}>
|
| 150 |
+
Parallel Search
|
| 151 |
+
</h3>
|
| 152 |
+
{isRunning && <Spinner />}
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
{isIdle && (
|
| 156 |
+
<p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.8rem', color: '#999', margin: 0 }}>
|
| 157 |
+
Awaiting expansion…
|
| 158 |
+
</p>
|
| 159 |
+
)}
|
| 160 |
+
|
| 161 |
+
{isRunning && (
|
| 162 |
+
<p style={{ fontFamily: 'system-ui, -apple-system, sans-serif', fontSize: '0.8rem', color: '#888', margin: 0, fontStyle: 'italic' }}>
|
| 163 |
+
Running vector + BM25 search…
|
| 164 |
+
</p>
|
| 165 |
+
)}
|
| 166 |
+
|
| 167 |
+
{isDone && state.data && (
|
| 168 |
+
<>
|
| 169 |
+
<HitsSection
|
| 170 |
+
label="Vector Search"
|
| 171 |
+
hits={state.data.vectorHits}
|
| 172 |
+
color="#00695c"
|
| 173 |
+
/>
|
| 174 |
+
<HitsSection
|
| 175 |
+
label="BM25 Search"
|
| 176 |
+
hits={state.data.bm25Hits}
|
| 177 |
+
color="#283593"
|
| 178 |
+
/>
|
| 179 |
+
</>
|
| 180 |
+
)}
|
| 181 |
+
</div>
|
| 182 |
+
);
|
| 183 |
+
}
|