shreyask Claude Sonnet 4.6 commited on
Commit
5e17e07
·
verified ·
1 Parent(s): a1d52ca

feat: add React UI components for 4-column pipeline visualization

Browse files

Creates 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 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
+ }