@gmickel/gno 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +194 -53
- package/assets/badges/license.svg +12 -0
- package/assets/badges/npm.svg +13 -0
- package/assets/badges/twitter.svg +22 -0
- package/assets/badges/website.svg +22 -0
- package/package.json +30 -1
- package/src/cli/commands/ask.ts +11 -186
- package/src/cli/commands/models/pull.ts +9 -4
- package/src/cli/commands/serve.ts +19 -0
- package/src/cli/program.ts +28 -0
- package/src/llm/registry.ts +3 -1
- package/src/pipeline/answer.ts +191 -0
- package/src/serve/CLAUDE.md +91 -0
- package/src/serve/bunfig.toml +2 -0
- package/src/serve/context.ts +181 -0
- package/src/serve/index.ts +7 -0
- package/src/serve/public/app.tsx +56 -0
- package/src/serve/public/components/ai-elements/code-block.tsx +176 -0
- package/src/serve/public/components/ai-elements/conversation.tsx +98 -0
- package/src/serve/public/components/ai-elements/inline-citation.tsx +285 -0
- package/src/serve/public/components/ai-elements/loader.tsx +96 -0
- package/src/serve/public/components/ai-elements/message.tsx +443 -0
- package/src/serve/public/components/ai-elements/prompt-input.tsx +1421 -0
- package/src/serve/public/components/ai-elements/sources.tsx +75 -0
- package/src/serve/public/components/ai-elements/suggestion.tsx +51 -0
- package/src/serve/public/components/preset-selector.tsx +403 -0
- package/src/serve/public/components/ui/badge.tsx +46 -0
- package/src/serve/public/components/ui/button-group.tsx +82 -0
- package/src/serve/public/components/ui/button.tsx +62 -0
- package/src/serve/public/components/ui/card.tsx +92 -0
- package/src/serve/public/components/ui/carousel.tsx +244 -0
- package/src/serve/public/components/ui/collapsible.tsx +31 -0
- package/src/serve/public/components/ui/command.tsx +181 -0
- package/src/serve/public/components/ui/dialog.tsx +141 -0
- package/src/serve/public/components/ui/dropdown-menu.tsx +255 -0
- package/src/serve/public/components/ui/hover-card.tsx +42 -0
- package/src/serve/public/components/ui/input-group.tsx +167 -0
- package/src/serve/public/components/ui/input.tsx +21 -0
- package/src/serve/public/components/ui/progress.tsx +28 -0
- package/src/serve/public/components/ui/scroll-area.tsx +56 -0
- package/src/serve/public/components/ui/select.tsx +188 -0
- package/src/serve/public/components/ui/separator.tsx +26 -0
- package/src/serve/public/components/ui/table.tsx +114 -0
- package/src/serve/public/components/ui/textarea.tsx +18 -0
- package/src/serve/public/components/ui/tooltip.tsx +59 -0
- package/src/serve/public/globals.css +226 -0
- package/src/serve/public/hooks/use-api.ts +112 -0
- package/src/serve/public/index.html +13 -0
- package/src/serve/public/pages/Ask.tsx +442 -0
- package/src/serve/public/pages/Browse.tsx +270 -0
- package/src/serve/public/pages/Dashboard.tsx +202 -0
- package/src/serve/public/pages/DocView.tsx +302 -0
- package/src/serve/public/pages/Search.tsx +335 -0
- package/src/serve/routes/api.ts +763 -0
- package/src/serve/server.ts +249 -0
- package/src/store/sqlite/adapter.ts +47 -0
- package/src/store/types.ts +10 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArrowLeft,
|
|
3
|
+
Calendar,
|
|
4
|
+
FileText,
|
|
5
|
+
FolderOpen,
|
|
6
|
+
HardDrive,
|
|
7
|
+
} from 'lucide-react';
|
|
8
|
+
import { useEffect, useState } from 'react';
|
|
9
|
+
import {
|
|
10
|
+
CodeBlock,
|
|
11
|
+
CodeBlockCopyButton,
|
|
12
|
+
} from '../components/ai-elements/code-block';
|
|
13
|
+
import { Loader } from '../components/ai-elements/loader';
|
|
14
|
+
import { Badge } from '../components/ui/badge';
|
|
15
|
+
import { Button } from '../components/ui/button';
|
|
16
|
+
import {
|
|
17
|
+
Card,
|
|
18
|
+
CardContent,
|
|
19
|
+
CardHeader,
|
|
20
|
+
CardTitle,
|
|
21
|
+
} from '../components/ui/card';
|
|
22
|
+
import { Separator } from '../components/ui/separator';
|
|
23
|
+
import { apiFetch } from '../hooks/use-api';
|
|
24
|
+
|
|
25
|
+
interface PageProps {
|
|
26
|
+
navigate: (to: string | number) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface DocData {
|
|
30
|
+
docid: string;
|
|
31
|
+
uri: string;
|
|
32
|
+
title: string | null;
|
|
33
|
+
content: string | null;
|
|
34
|
+
contentAvailable: boolean;
|
|
35
|
+
collection: string;
|
|
36
|
+
relPath: string;
|
|
37
|
+
source: {
|
|
38
|
+
mime: string;
|
|
39
|
+
ext: string;
|
|
40
|
+
modifiedAt?: string;
|
|
41
|
+
sizeBytes?: number;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatBytes(bytes: number): string {
|
|
46
|
+
if (bytes === 0) {
|
|
47
|
+
return '0 B';
|
|
48
|
+
}
|
|
49
|
+
const k = 1024;
|
|
50
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
51
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
52
|
+
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatDate(dateStr: string): string {
|
|
56
|
+
try {
|
|
57
|
+
return new Date(dateStr).toLocaleDateString('en-US', {
|
|
58
|
+
year: 'numeric',
|
|
59
|
+
month: 'short',
|
|
60
|
+
day: 'numeric',
|
|
61
|
+
hour: '2-digit',
|
|
62
|
+
minute: '2-digit',
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
return dateStr;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Shiki BundledLanguage subset we actually use
|
|
70
|
+
type SupportedLanguage =
|
|
71
|
+
| 'markdown'
|
|
72
|
+
| 'javascript'
|
|
73
|
+
| 'jsx'
|
|
74
|
+
| 'typescript'
|
|
75
|
+
| 'tsx'
|
|
76
|
+
| 'python'
|
|
77
|
+
| 'rust'
|
|
78
|
+
| 'go'
|
|
79
|
+
| 'json'
|
|
80
|
+
| 'yaml'
|
|
81
|
+
| 'html'
|
|
82
|
+
| 'css'
|
|
83
|
+
| 'sql'
|
|
84
|
+
| 'bash'
|
|
85
|
+
| 'text';
|
|
86
|
+
|
|
87
|
+
function getLanguageFromExt(ext: string): SupportedLanguage {
|
|
88
|
+
const map: Record<string, SupportedLanguage> = {
|
|
89
|
+
'.md': 'markdown',
|
|
90
|
+
'.markdown': 'markdown',
|
|
91
|
+
'.js': 'javascript',
|
|
92
|
+
'.jsx': 'jsx',
|
|
93
|
+
'.ts': 'typescript',
|
|
94
|
+
'.tsx': 'tsx',
|
|
95
|
+
'.py': 'python',
|
|
96
|
+
'.rs': 'rust',
|
|
97
|
+
'.go': 'go',
|
|
98
|
+
'.json': 'json',
|
|
99
|
+
'.yaml': 'yaml',
|
|
100
|
+
'.yml': 'yaml',
|
|
101
|
+
'.html': 'html',
|
|
102
|
+
'.css': 'css',
|
|
103
|
+
'.sql': 'sql',
|
|
104
|
+
'.sh': 'bash',
|
|
105
|
+
'.bash': 'bash',
|
|
106
|
+
};
|
|
107
|
+
return map[ext.toLowerCase()] || 'text';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default function DocView({ navigate }: PageProps) {
|
|
111
|
+
const [doc, setDoc] = useState<DocData | null>(null);
|
|
112
|
+
const [error, setError] = useState<string | null>(null);
|
|
113
|
+
const [loading, setLoading] = useState(true);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const params = new URLSearchParams(window.location.search);
|
|
117
|
+
const uri = params.get('uri');
|
|
118
|
+
|
|
119
|
+
if (!uri) {
|
|
120
|
+
setError('No document URI provided');
|
|
121
|
+
setLoading(false);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
apiFetch<DocData>(`/api/doc?uri=${encodeURIComponent(uri)}`).then(
|
|
126
|
+
({ data, error }) => {
|
|
127
|
+
setLoading(false);
|
|
128
|
+
if (error) {
|
|
129
|
+
setError(error);
|
|
130
|
+
} else if (data) {
|
|
131
|
+
setDoc(data);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
const isCodeFile =
|
|
138
|
+
doc?.source.ext &&
|
|
139
|
+
[
|
|
140
|
+
'.md',
|
|
141
|
+
'.js',
|
|
142
|
+
'.jsx',
|
|
143
|
+
'.ts',
|
|
144
|
+
'.tsx',
|
|
145
|
+
'.py',
|
|
146
|
+
'.rs',
|
|
147
|
+
'.go',
|
|
148
|
+
'.json',
|
|
149
|
+
'.yaml',
|
|
150
|
+
'.yml',
|
|
151
|
+
'.html',
|
|
152
|
+
'.css',
|
|
153
|
+
'.sql',
|
|
154
|
+
'.sh',
|
|
155
|
+
'.bash',
|
|
156
|
+
].includes(doc.source.ext.toLowerCase());
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div className="min-h-screen">
|
|
160
|
+
{/* Header */}
|
|
161
|
+
<header className="glass sticky top-0 z-10 border-border/50 border-b">
|
|
162
|
+
<div className="flex items-center gap-4 px-8 py-4">
|
|
163
|
+
<Button
|
|
164
|
+
className="gap-2"
|
|
165
|
+
onClick={() => navigate(-1)}
|
|
166
|
+
size="sm"
|
|
167
|
+
variant="ghost"
|
|
168
|
+
>
|
|
169
|
+
<ArrowLeft className="size-4" />
|
|
170
|
+
Back
|
|
171
|
+
</Button>
|
|
172
|
+
<Separator className="h-6" orientation="vertical" />
|
|
173
|
+
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
174
|
+
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
|
175
|
+
<h1 className="truncate font-semibold text-xl">
|
|
176
|
+
{doc?.title || 'Document'}
|
|
177
|
+
</h1>
|
|
178
|
+
</div>
|
|
179
|
+
{doc?.source.ext && (
|
|
180
|
+
<Badge className="shrink-0 font-mono" variant="outline">
|
|
181
|
+
{doc.source.ext}
|
|
182
|
+
</Badge>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
</header>
|
|
186
|
+
|
|
187
|
+
<main className="mx-auto max-w-5xl p-8">
|
|
188
|
+
{/* Loading */}
|
|
189
|
+
{loading && (
|
|
190
|
+
<div className="flex flex-col items-center justify-center gap-4 py-20">
|
|
191
|
+
<Loader className="text-primary" size={32} />
|
|
192
|
+
<p className="text-muted-foreground">Loading document...</p>
|
|
193
|
+
</div>
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
{/* Error */}
|
|
197
|
+
{error && (
|
|
198
|
+
<Card className="border-destructive bg-destructive/10">
|
|
199
|
+
<CardContent className="py-6 text-center">
|
|
200
|
+
<FileText className="mx-auto mb-4 size-12 text-destructive" />
|
|
201
|
+
<h3 className="mb-2 font-medium text-destructive text-lg">
|
|
202
|
+
Failed to load document
|
|
203
|
+
</h3>
|
|
204
|
+
<p className="text-muted-foreground">{error}</p>
|
|
205
|
+
</CardContent>
|
|
206
|
+
</Card>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
{/* Document */}
|
|
210
|
+
{doc && (
|
|
211
|
+
<div className="animate-fade-in space-y-6 opacity-0">
|
|
212
|
+
{/* Metadata */}
|
|
213
|
+
<Card>
|
|
214
|
+
<CardContent className="py-4">
|
|
215
|
+
<div className="grid gap-4 sm:grid-cols-3">
|
|
216
|
+
<div className="flex items-center gap-3">
|
|
217
|
+
<FolderOpen className="size-4 text-muted-foreground" />
|
|
218
|
+
<div>
|
|
219
|
+
<div className="text-muted-foreground text-xs">
|
|
220
|
+
Collection
|
|
221
|
+
</div>
|
|
222
|
+
<div className="font-medium">
|
|
223
|
+
{doc.collection || 'Unknown'}
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
{doc.source.sizeBytes !== undefined && (
|
|
228
|
+
<div className="flex items-center gap-3">
|
|
229
|
+
<HardDrive className="size-4 text-muted-foreground" />
|
|
230
|
+
<div>
|
|
231
|
+
<div className="text-muted-foreground text-xs">
|
|
232
|
+
Size
|
|
233
|
+
</div>
|
|
234
|
+
<div className="font-medium">
|
|
235
|
+
{formatBytes(doc.source.sizeBytes)}
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
{doc.source.modifiedAt && (
|
|
241
|
+
<div className="flex items-center gap-3">
|
|
242
|
+
<Calendar className="size-4 text-muted-foreground" />
|
|
243
|
+
<div>
|
|
244
|
+
<div className="text-muted-foreground text-xs">
|
|
245
|
+
Modified
|
|
246
|
+
</div>
|
|
247
|
+
<div className="font-medium">
|
|
248
|
+
{formatDate(doc.source.modifiedAt)}
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
)}
|
|
253
|
+
</div>
|
|
254
|
+
<div className="mt-4 border-border/50 border-t pt-4">
|
|
255
|
+
<div className="mb-1 text-muted-foreground text-xs">Path</div>
|
|
256
|
+
<code className="break-all font-mono text-muted-foreground text-sm">
|
|
257
|
+
{doc.uri}
|
|
258
|
+
</code>
|
|
259
|
+
</div>
|
|
260
|
+
</CardContent>
|
|
261
|
+
</Card>
|
|
262
|
+
|
|
263
|
+
{/* Content */}
|
|
264
|
+
<Card>
|
|
265
|
+
<CardHeader className="pb-0">
|
|
266
|
+
<CardTitle className="flex items-center gap-2 text-lg">
|
|
267
|
+
<FileText className="size-4" />
|
|
268
|
+
Content
|
|
269
|
+
</CardTitle>
|
|
270
|
+
</CardHeader>
|
|
271
|
+
<CardContent className="pt-4">
|
|
272
|
+
{!doc.contentAvailable && (
|
|
273
|
+
<div className="rounded-lg border border-border/50 bg-muted/30 p-6 text-center">
|
|
274
|
+
<p className="text-muted-foreground">
|
|
275
|
+
Content not available (document may need re-indexing)
|
|
276
|
+
</p>
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
{doc.contentAvailable && isCodeFile && (
|
|
280
|
+
<CodeBlock
|
|
281
|
+
code={doc.content ?? ''}
|
|
282
|
+
language={getLanguageFromExt(doc.source.ext)}
|
|
283
|
+
showLineNumbers
|
|
284
|
+
>
|
|
285
|
+
<CodeBlockCopyButton />
|
|
286
|
+
</CodeBlock>
|
|
287
|
+
)}
|
|
288
|
+
{doc.contentAvailable && !isCodeFile && (
|
|
289
|
+
<div className="rounded-lg border border-border/50 bg-muted/30 p-6">
|
|
290
|
+
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed">
|
|
291
|
+
{doc.content}
|
|
292
|
+
</pre>
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
</CardContent>
|
|
296
|
+
</Card>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
</main>
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { ArrowLeft, FileText, Search as SearchIcon, Zap } from 'lucide-react';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Loader } from '../components/ai-elements/loader';
|
|
4
|
+
import { Badge } from '../components/ui/badge';
|
|
5
|
+
import { Button } from '../components/ui/button';
|
|
6
|
+
import { ButtonGroup } from '../components/ui/button-group';
|
|
7
|
+
import { Card, CardContent } from '../components/ui/card';
|
|
8
|
+
import { Input } from '../components/ui/input';
|
|
9
|
+
import { apiFetch } from '../hooks/use-api';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Render snippet with <mark> tags as highlighted spans.
|
|
13
|
+
* Only allows mark tags - strips all other HTML for safety.
|
|
14
|
+
*/
|
|
15
|
+
function renderSnippet(snippet: string): React.ReactNode {
|
|
16
|
+
const parts: React.ReactNode[] = [];
|
|
17
|
+
let remaining = snippet;
|
|
18
|
+
let key = 0;
|
|
19
|
+
|
|
20
|
+
while (remaining.length > 0) {
|
|
21
|
+
const markStart = remaining.indexOf('<mark>');
|
|
22
|
+
if (markStart === -1) {
|
|
23
|
+
parts.push(remaining);
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (markStart > 0) {
|
|
28
|
+
parts.push(remaining.slice(0, markStart));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const markEnd = remaining.indexOf('</mark>', markStart);
|
|
32
|
+
if (markEnd === -1) {
|
|
33
|
+
parts.push(remaining.slice(markStart));
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const highlighted = remaining.slice(markStart + 6, markEnd);
|
|
38
|
+
parts.push(
|
|
39
|
+
<mark
|
|
40
|
+
className="rounded bg-primary/20 px-0.5 font-medium text-primary"
|
|
41
|
+
key={key++}
|
|
42
|
+
>
|
|
43
|
+
{highlighted}
|
|
44
|
+
</mark>
|
|
45
|
+
);
|
|
46
|
+
remaining = remaining.slice(markEnd + 7);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return parts;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface PageProps {
|
|
53
|
+
navigate: (to: string | number) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface SearchResult {
|
|
57
|
+
docid: string;
|
|
58
|
+
uri: string;
|
|
59
|
+
title?: string;
|
|
60
|
+
snippet: string;
|
|
61
|
+
score: number;
|
|
62
|
+
snippetRange?: {
|
|
63
|
+
startLine: number;
|
|
64
|
+
endLine: number;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface SearchResponse {
|
|
69
|
+
results: SearchResult[];
|
|
70
|
+
meta: {
|
|
71
|
+
query: string;
|
|
72
|
+
mode: string;
|
|
73
|
+
totalResults: number;
|
|
74
|
+
expanded?: boolean;
|
|
75
|
+
reranked?: boolean;
|
|
76
|
+
vectorsUsed?: boolean;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface Capabilities {
|
|
81
|
+
bm25: boolean;
|
|
82
|
+
vector: boolean;
|
|
83
|
+
hybrid: boolean;
|
|
84
|
+
answer: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type SearchMode = 'bm25' | 'hybrid';
|
|
88
|
+
|
|
89
|
+
export default function Search({ navigate }: PageProps) {
|
|
90
|
+
const [query, setQuery] = useState('');
|
|
91
|
+
const [mode, setMode] = useState<SearchMode>('bm25');
|
|
92
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
93
|
+
const [meta, setMeta] = useState<SearchResponse['meta'] | null>(null);
|
|
94
|
+
const [loading, setLoading] = useState(false);
|
|
95
|
+
const [error, setError] = useState<string | null>(null);
|
|
96
|
+
const [searched, setSearched] = useState(false);
|
|
97
|
+
const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
|
|
98
|
+
|
|
99
|
+
// Fetch capabilities on mount
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
async function fetchCapabilities() {
|
|
102
|
+
const { data } = await apiFetch<Capabilities>('/api/capabilities');
|
|
103
|
+
if (data) {
|
|
104
|
+
setCapabilities(data);
|
|
105
|
+
// Auto-select hybrid if available
|
|
106
|
+
if (data.hybrid) {
|
|
107
|
+
setMode('hybrid');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
fetchCapabilities();
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
const handleSearch = async (e: React.FormEvent) => {
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
if (!query.trim()) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
setLoading(true);
|
|
121
|
+
setError(null);
|
|
122
|
+
setSearched(true);
|
|
123
|
+
|
|
124
|
+
// Use /api/query for hybrid, /api/search for bm25
|
|
125
|
+
const endpoint = mode === 'hybrid' ? '/api/query' : '/api/search';
|
|
126
|
+
|
|
127
|
+
const { data, error } = await apiFetch<SearchResponse>(endpoint, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
body: JSON.stringify({ query, limit: 20 }),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
setLoading(false);
|
|
133
|
+
if (error) {
|
|
134
|
+
setError(error);
|
|
135
|
+
setResults([]);
|
|
136
|
+
setMeta(null);
|
|
137
|
+
} else if (data) {
|
|
138
|
+
setResults(data.results);
|
|
139
|
+
setMeta(data.meta);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const hybridAvailable = capabilities?.hybrid ?? false;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div className="min-h-screen">
|
|
147
|
+
{/* Header */}
|
|
148
|
+
<header className="glass sticky top-0 z-10 border-border/50 border-b">
|
|
149
|
+
<div className="flex items-center gap-4 px-8 py-4">
|
|
150
|
+
<Button
|
|
151
|
+
className="gap-2"
|
|
152
|
+
onClick={() => navigate(-1)}
|
|
153
|
+
size="sm"
|
|
154
|
+
variant="ghost"
|
|
155
|
+
>
|
|
156
|
+
<ArrowLeft className="size-4" />
|
|
157
|
+
Back
|
|
158
|
+
</Button>
|
|
159
|
+
<h1 className="font-semibold text-xl">Search</h1>
|
|
160
|
+
</div>
|
|
161
|
+
</header>
|
|
162
|
+
|
|
163
|
+
<main className="mx-auto max-w-4xl p-8">
|
|
164
|
+
{/* Search Form */}
|
|
165
|
+
<form className="mb-8" onSubmit={handleSearch}>
|
|
166
|
+
<div className="relative">
|
|
167
|
+
<SearchIcon className="absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
|
168
|
+
<Input
|
|
169
|
+
className="border-border bg-card py-6 pr-4 pl-12 text-lg focus:border-primary"
|
|
170
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
171
|
+
placeholder="Search your documents..."
|
|
172
|
+
type="text"
|
|
173
|
+
value={query}
|
|
174
|
+
/>
|
|
175
|
+
<Button
|
|
176
|
+
className="absolute top-1/2 right-2 -translate-y-1/2"
|
|
177
|
+
disabled={loading || !query.trim()}
|
|
178
|
+
size="sm"
|
|
179
|
+
type="submit"
|
|
180
|
+
>
|
|
181
|
+
{loading ? <Loader size={16} /> : 'Search'}
|
|
182
|
+
</Button>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{/* Mode selector */}
|
|
186
|
+
<div className="mt-4 flex items-center gap-4">
|
|
187
|
+
<span className="text-muted-foreground text-sm">Mode:</span>
|
|
188
|
+
<ButtonGroup>
|
|
189
|
+
<Button
|
|
190
|
+
className={
|
|
191
|
+
mode === 'bm25'
|
|
192
|
+
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
|
193
|
+
: ''
|
|
194
|
+
}
|
|
195
|
+
onClick={() => setMode('bm25')}
|
|
196
|
+
size="sm"
|
|
197
|
+
type="button"
|
|
198
|
+
variant={mode === 'bm25' ? 'default' : 'outline'}
|
|
199
|
+
>
|
|
200
|
+
BM25
|
|
201
|
+
</Button>
|
|
202
|
+
<Button
|
|
203
|
+
className={
|
|
204
|
+
mode === 'hybrid'
|
|
205
|
+
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
|
206
|
+
: ''
|
|
207
|
+
}
|
|
208
|
+
disabled={!hybridAvailable}
|
|
209
|
+
onClick={() => setMode('hybrid')}
|
|
210
|
+
size="sm"
|
|
211
|
+
title={
|
|
212
|
+
hybridAvailable
|
|
213
|
+
? 'Hybrid search with vector + reranking'
|
|
214
|
+
: 'Hybrid search not available (no embedding model)'
|
|
215
|
+
}
|
|
216
|
+
type="button"
|
|
217
|
+
variant={mode === 'hybrid' ? 'default' : 'outline'}
|
|
218
|
+
>
|
|
219
|
+
<Zap className="mr-1 size-3" />
|
|
220
|
+
Hybrid
|
|
221
|
+
</Button>
|
|
222
|
+
</ButtonGroup>
|
|
223
|
+
<span className="text-muted-foreground/70 text-xs">
|
|
224
|
+
{mode === 'bm25'
|
|
225
|
+
? 'Keyword-based full-text search'
|
|
226
|
+
: 'BM25 + vector + query expansion + reranking'}
|
|
227
|
+
</span>
|
|
228
|
+
</div>
|
|
229
|
+
</form>
|
|
230
|
+
|
|
231
|
+
{/* Error */}
|
|
232
|
+
{error && (
|
|
233
|
+
<Card className="mb-6 border-destructive bg-destructive/10">
|
|
234
|
+
<CardContent className="py-4 text-destructive">{error}</CardContent>
|
|
235
|
+
</Card>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{/* Loading */}
|
|
239
|
+
{loading && (
|
|
240
|
+
<div className="flex flex-col items-center justify-center gap-4 py-20">
|
|
241
|
+
<Loader className="text-primary" size={32} />
|
|
242
|
+
<p className="text-muted-foreground">Searching...</p>
|
|
243
|
+
</div>
|
|
244
|
+
)}
|
|
245
|
+
|
|
246
|
+
{/* Empty state */}
|
|
247
|
+
{!loading && searched && results.length === 0 && !error && (
|
|
248
|
+
<div className="py-20 text-center">
|
|
249
|
+
<FileText className="mx-auto mb-4 size-12 text-muted-foreground" />
|
|
250
|
+
<h3 className="mb-2 font-medium text-lg">No results found</h3>
|
|
251
|
+
<p className="text-muted-foreground">
|
|
252
|
+
Try adjusting your search terms
|
|
253
|
+
</p>
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
|
|
257
|
+
{/* Results */}
|
|
258
|
+
{!loading && results.length > 0 && (
|
|
259
|
+
<div className="space-y-4">
|
|
260
|
+
<div className="mb-6 flex items-center justify-between">
|
|
261
|
+
<p className="text-muted-foreground text-sm">
|
|
262
|
+
{results.length} result{results.length !== 1 ? 's' : ''}
|
|
263
|
+
</p>
|
|
264
|
+
{meta && (
|
|
265
|
+
<div className="flex items-center gap-2">
|
|
266
|
+
{meta.vectorsUsed && (
|
|
267
|
+
<Badge
|
|
268
|
+
className="font-mono text-[10px]"
|
|
269
|
+
variant="secondary"
|
|
270
|
+
>
|
|
271
|
+
vectors
|
|
272
|
+
</Badge>
|
|
273
|
+
)}
|
|
274
|
+
{meta.expanded && (
|
|
275
|
+
<Badge
|
|
276
|
+
className="font-mono text-[10px]"
|
|
277
|
+
variant="secondary"
|
|
278
|
+
>
|
|
279
|
+
expanded
|
|
280
|
+
</Badge>
|
|
281
|
+
)}
|
|
282
|
+
{meta.reranked && (
|
|
283
|
+
<Badge
|
|
284
|
+
className="font-mono text-[10px]"
|
|
285
|
+
variant="secondary"
|
|
286
|
+
>
|
|
287
|
+
reranked
|
|
288
|
+
</Badge>
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
</div>
|
|
293
|
+
{results.map((r, i) => (
|
|
294
|
+
<Card
|
|
295
|
+
className="group animate-fade-in cursor-pointer opacity-0 transition-all hover:border-primary/50 hover:bg-card/80"
|
|
296
|
+
key={`${r.docid}-${i}`}
|
|
297
|
+
onClick={() =>
|
|
298
|
+
navigate(`/doc?uri=${encodeURIComponent(r.uri)}`)
|
|
299
|
+
}
|
|
300
|
+
style={{ animationDelay: `${i * 0.05}s` }}
|
|
301
|
+
>
|
|
302
|
+
<CardContent className="py-4">
|
|
303
|
+
<div className="mb-2 flex items-start justify-between gap-4">
|
|
304
|
+
<h3 className="font-medium text-primary underline-offset-2 group-hover:underline">
|
|
305
|
+
{r.title || r.uri.split('/').pop()}
|
|
306
|
+
</h3>
|
|
307
|
+
<Badge
|
|
308
|
+
className="shrink-0 font-mono text-xs"
|
|
309
|
+
variant="secondary"
|
|
310
|
+
>
|
|
311
|
+
{(r.score * 100).toFixed(0)}%
|
|
312
|
+
</Badge>
|
|
313
|
+
</div>
|
|
314
|
+
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
|
|
315
|
+
{renderSnippet(r.snippet)}
|
|
316
|
+
</p>
|
|
317
|
+
<div className="mt-2 flex items-center gap-2">
|
|
318
|
+
<p className="truncate font-mono text-muted-foreground/60 text-xs">
|
|
319
|
+
{r.uri}
|
|
320
|
+
</p>
|
|
321
|
+
{r.snippetRange && (
|
|
322
|
+
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/40">
|
|
323
|
+
L{r.snippetRange.startLine}-{r.snippetRange.endLine}
|
|
324
|
+
</span>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
</CardContent>
|
|
328
|
+
</Card>
|
|
329
|
+
))}
|
|
330
|
+
</div>
|
|
331
|
+
)}
|
|
332
|
+
</main>
|
|
333
|
+
</div>
|
|
334
|
+
);
|
|
335
|
+
}
|