@gmickel/gno 0.16.0 → 0.17.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 +36 -1
- package/package.json +4 -1
- package/src/cli/commands/ask.ts +9 -0
- package/src/cli/commands/query.ts +3 -2
- package/src/cli/pager.ts +1 -1
- package/src/cli/program.ts +89 -0
- package/src/core/links.ts +92 -20
- package/src/ingestion/sync.ts +267 -23
- package/src/ingestion/types.ts +2 -0
- package/src/ingestion/walker.ts +2 -1
- package/src/mcp/tools/index.ts +30 -1
- package/src/mcp/tools/query.ts +22 -2
- package/src/mcp/tools/search.ts +8 -0
- package/src/mcp/tools/vsearch.ts +8 -0
- package/src/pipeline/answer.ts +324 -7
- package/src/pipeline/expansion.ts +243 -7
- package/src/pipeline/explain.ts +93 -5
- package/src/pipeline/hybrid.ts +240 -57
- package/src/pipeline/query-modes.ts +125 -0
- package/src/pipeline/rerank.ts +34 -13
- package/src/pipeline/search.ts +41 -3
- package/src/pipeline/temporal.ts +257 -0
- package/src/pipeline/types.ts +58 -0
- package/src/pipeline/vsearch.ts +107 -9
- package/src/serve/public/app.tsx +1 -3
- package/src/serve/public/globals.built.css +2 -2
- package/src/serve/public/lib/retrieval-filters.ts +167 -0
- package/src/serve/public/pages/Ask.tsx +339 -109
- package/src/serve/public/pages/Browse.tsx +71 -5
- package/src/serve/public/pages/DocView.tsx +2 -21
- package/src/serve/public/pages/Search.tsx +507 -120
- package/src/serve/routes/api.ts +202 -2
- package/src/store/migrations/006-document-metadata.ts +104 -0
- package/src/store/migrations/007-document-date-fields.ts +24 -0
- package/src/store/migrations/index.ts +3 -1
- package/src/store/sqlite/adapter.ts +218 -5
- package/src/store/types.ts +46 -0
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
ArrowLeft,
|
|
3
|
+
ChevronDown,
|
|
4
|
+
FileText,
|
|
5
|
+
Search as SearchIcon,
|
|
6
|
+
SlidersHorizontal,
|
|
7
|
+
XIcon,
|
|
8
|
+
} from "lucide-react";
|
|
2
9
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
3
10
|
|
|
4
11
|
import { Loader } from "../components/ai-elements/loader";
|
|
@@ -10,9 +17,28 @@ import {
|
|
|
10
17
|
import { Badge } from "../components/ui/badge";
|
|
11
18
|
import { Button } from "../components/ui/button";
|
|
12
19
|
import { Card, CardContent } from "../components/ui/card";
|
|
20
|
+
import {
|
|
21
|
+
Collapsible,
|
|
22
|
+
CollapsibleContent,
|
|
23
|
+
CollapsibleTrigger,
|
|
24
|
+
} from "../components/ui/collapsible";
|
|
13
25
|
import { Input } from "../components/ui/input";
|
|
26
|
+
import {
|
|
27
|
+
Select,
|
|
28
|
+
SelectContent,
|
|
29
|
+
SelectItem,
|
|
30
|
+
SelectTrigger,
|
|
31
|
+
SelectValue,
|
|
32
|
+
} from "../components/ui/select";
|
|
14
33
|
import { apiFetch } from "../hooks/use-api";
|
|
15
34
|
import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
|
|
35
|
+
import {
|
|
36
|
+
applyFiltersToUrl,
|
|
37
|
+
parseFiltersFromSearch,
|
|
38
|
+
type QueryModeEntry,
|
|
39
|
+
type QueryModeType,
|
|
40
|
+
type TagMode,
|
|
41
|
+
} from "../lib/retrieval-filters";
|
|
16
42
|
import { cn } from "../lib/utils";
|
|
17
43
|
|
|
18
44
|
/**
|
|
@@ -56,49 +82,6 @@ function renderSnippet(snippet: string): React.ReactNode {
|
|
|
56
82
|
return parts;
|
|
57
83
|
}
|
|
58
84
|
|
|
59
|
-
/**
|
|
60
|
-
* Tag grammar validation (matches src/core/tags.ts).
|
|
61
|
-
* Validates that a tag follows the grammar for filtering.
|
|
62
|
-
*/
|
|
63
|
-
const TAG_SEGMENT_REGEX = /^[\p{Ll}\p{Lo}\p{N}][\p{Ll}\p{Lo}\p{N}\-.]*$/u;
|
|
64
|
-
|
|
65
|
-
function normalizeTag(tag: string): string {
|
|
66
|
-
return tag.trim().normalize("NFC").toLowerCase();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function isValidTag(tag: string): boolean {
|
|
70
|
-
if (tag.length === 0) return false;
|
|
71
|
-
if (tag.startsWith("/") || tag.endsWith("/")) return false;
|
|
72
|
-
const segments = tag.split("/");
|
|
73
|
-
for (const segment of segments) {
|
|
74
|
-
if (segment.length === 0) return false;
|
|
75
|
-
if (!TAG_SEGMENT_REGEX.test(segment)) return false;
|
|
76
|
-
}
|
|
77
|
-
return true;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Parse and validate tags from URL search params */
|
|
81
|
-
function parseTagsFromUrl(): string[] {
|
|
82
|
-
const params = new URLSearchParams(window.location.search);
|
|
83
|
-
const tagsAny = params.get("tagsAny");
|
|
84
|
-
if (!tagsAny) return [];
|
|
85
|
-
return tagsAny
|
|
86
|
-
.split(",")
|
|
87
|
-
.map((t) => normalizeTag(t))
|
|
88
|
-
.filter((t) => t.length > 0 && isValidTag(t));
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Update URL with tag filters */
|
|
92
|
-
function updateUrlWithTags(tags: string[]): void {
|
|
93
|
-
const url = new URL(window.location.href);
|
|
94
|
-
if (tags.length > 0) {
|
|
95
|
-
url.searchParams.set("tagsAny", tags.join(","));
|
|
96
|
-
} else {
|
|
97
|
-
url.searchParams.delete("tagsAny");
|
|
98
|
-
}
|
|
99
|
-
window.history.replaceState({}, "", url.toString());
|
|
100
|
-
}
|
|
101
|
-
|
|
102
85
|
interface PageProps {
|
|
103
86
|
navigate: (to: string | number) => void;
|
|
104
87
|
}
|
|
@@ -124,6 +107,11 @@ interface SearchResponse {
|
|
|
124
107
|
expanded?: boolean;
|
|
125
108
|
reranked?: boolean;
|
|
126
109
|
vectorsUsed?: boolean;
|
|
110
|
+
queryModes?: {
|
|
111
|
+
term: number;
|
|
112
|
+
intent: number;
|
|
113
|
+
hyde: boolean;
|
|
114
|
+
};
|
|
127
115
|
};
|
|
128
116
|
}
|
|
129
117
|
|
|
@@ -134,9 +122,24 @@ interface Capabilities {
|
|
|
134
122
|
answer: boolean;
|
|
135
123
|
}
|
|
136
124
|
|
|
125
|
+
interface Collection {
|
|
126
|
+
name: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
137
129
|
const THOROUGHNESS_ORDER: Thoroughness[] = ["fast", "balanced", "thorough"];
|
|
138
130
|
|
|
131
|
+
const QUERY_MODE_LABEL: Record<QueryModeType, string> = {
|
|
132
|
+
term: "Term",
|
|
133
|
+
intent: "Intent",
|
|
134
|
+
hyde: "HyDE",
|
|
135
|
+
};
|
|
136
|
+
|
|
139
137
|
export default function Search({ navigate }: PageProps) {
|
|
138
|
+
const initialFilters = useMemo(
|
|
139
|
+
() => parseFiltersFromSearch(window.location.search),
|
|
140
|
+
[]
|
|
141
|
+
);
|
|
142
|
+
|
|
140
143
|
const [query, setQuery] = useState("");
|
|
141
144
|
const [thoroughness, setThoroughness] = useState<Thoroughness>("balanced");
|
|
142
145
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
@@ -145,18 +148,64 @@ export default function Search({ navigate }: PageProps) {
|
|
|
145
148
|
const [error, setError] = useState<string | null>(null);
|
|
146
149
|
const [searched, setSearched] = useState(false);
|
|
147
150
|
const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
|
|
151
|
+
const [collections, setCollections] = useState<Collection[]>([]);
|
|
152
|
+
|
|
153
|
+
const [showAdvanced, setShowAdvanced] = useState(
|
|
154
|
+
Boolean(
|
|
155
|
+
initialFilters.collection ||
|
|
156
|
+
initialFilters.since ||
|
|
157
|
+
initialFilters.until ||
|
|
158
|
+
initialFilters.category ||
|
|
159
|
+
initialFilters.author ||
|
|
160
|
+
initialFilters.queryModes.length > 0
|
|
161
|
+
)
|
|
162
|
+
);
|
|
148
163
|
|
|
149
|
-
|
|
150
|
-
const [
|
|
151
|
-
|
|
164
|
+
const [activeTags, setActiveTags] = useState<string[]>(initialFilters.tags);
|
|
165
|
+
const [tagMode, setTagMode] = useState<TagMode>(initialFilters.tagMode);
|
|
166
|
+
const [selectedCollection, setSelectedCollection] = useState(
|
|
167
|
+
initialFilters.collection
|
|
168
|
+
);
|
|
169
|
+
const [since, setSince] = useState(initialFilters.since);
|
|
170
|
+
const [until, setUntil] = useState(initialFilters.until);
|
|
171
|
+
const [category, setCategory] = useState(initialFilters.category);
|
|
172
|
+
const [author, setAuthor] = useState(initialFilters.author);
|
|
173
|
+
const [queryModes, setQueryModes] = useState<QueryModeEntry[]>(
|
|
174
|
+
initialFilters.queryModes
|
|
152
175
|
);
|
|
176
|
+
const [queryModeDraft, setQueryModeDraft] = useState<QueryModeType>("term");
|
|
177
|
+
const [queryModeText, setQueryModeText] = useState("");
|
|
178
|
+
const [queryModeError, setQueryModeError] = useState<string | null>(null);
|
|
179
|
+
const [showMobileTags, setShowMobileTags] = useState(false);
|
|
180
|
+
|
|
181
|
+
const hybridAvailable = capabilities?.hybrid ?? false;
|
|
182
|
+
const forceHybridForModes = thoroughness === "fast" && queryModes.length > 0;
|
|
153
183
|
|
|
154
|
-
// Sync URL
|
|
184
|
+
// Sync URL as filter state changes.
|
|
155
185
|
useEffect(() => {
|
|
156
|
-
|
|
157
|
-
|
|
186
|
+
const url = new URL(window.location.href);
|
|
187
|
+
applyFiltersToUrl(url, {
|
|
188
|
+
collection: selectedCollection,
|
|
189
|
+
since,
|
|
190
|
+
until,
|
|
191
|
+
category,
|
|
192
|
+
author,
|
|
193
|
+
tagMode,
|
|
194
|
+
tags: activeTags,
|
|
195
|
+
queryModes,
|
|
196
|
+
});
|
|
197
|
+
window.history.replaceState({}, "", url.toString());
|
|
198
|
+
}, [
|
|
199
|
+
activeTags,
|
|
200
|
+
author,
|
|
201
|
+
category,
|
|
202
|
+
queryModes,
|
|
203
|
+
selectedCollection,
|
|
204
|
+
since,
|
|
205
|
+
tagMode,
|
|
206
|
+
until,
|
|
207
|
+
]);
|
|
158
208
|
|
|
159
|
-
// Tag filter handlers
|
|
160
209
|
const handleTagSelect = useCallback((tag: string) => {
|
|
161
210
|
setActiveTags((prev) => (prev.includes(tag) ? prev : [...prev, tag]));
|
|
162
211
|
}, []);
|
|
@@ -165,28 +214,29 @@ export default function Search({ navigate }: PageProps) {
|
|
|
165
214
|
setActiveTags((prev) => prev.filter((t) => t !== tag));
|
|
166
215
|
}, []);
|
|
167
216
|
|
|
168
|
-
// Fetch capabilities on mount
|
|
169
217
|
useEffect(() => {
|
|
170
|
-
async function
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
218
|
+
async function bootstrap(): Promise<void> {
|
|
219
|
+
const [capabilitiesResult, collectionsResult] = await Promise.all([
|
|
220
|
+
apiFetch<Capabilities>("/api/capabilities"),
|
|
221
|
+
apiFetch<Collection[]>("/api/collections"),
|
|
222
|
+
]);
|
|
223
|
+
|
|
224
|
+
if (capabilitiesResult.data) {
|
|
225
|
+
const caps = capabilitiesResult.data;
|
|
226
|
+
setCapabilities(caps);
|
|
227
|
+
setThoroughness(caps.hybrid ? "balanced" : "fast");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (collectionsResult.data) {
|
|
231
|
+
setCollections(collectionsResult.data);
|
|
180
232
|
}
|
|
181
233
|
}
|
|
182
|
-
|
|
234
|
+
|
|
235
|
+
void bootstrap();
|
|
183
236
|
}, []);
|
|
184
237
|
|
|
185
|
-
// Cycle thoroughness with 't' key (only cycles to supported modes)
|
|
186
|
-
const hybridAvailable = capabilities?.hybrid ?? false;
|
|
187
238
|
const cycleThoroughness = useCallback(() => {
|
|
188
239
|
setThoroughness((current) => {
|
|
189
|
-
// If hybrid not available, stay on fast
|
|
190
240
|
if (!hybridAvailable) return "fast";
|
|
191
241
|
const currentIdx = THOROUGHNESS_ORDER.indexOf(current);
|
|
192
242
|
const nextIdx = (currentIdx + 1) % THOROUGHNESS_ORDER.length;
|
|
@@ -198,9 +248,30 @@ export default function Search({ navigate }: PageProps) {
|
|
|
198
248
|
() => [{ key: "t", action: cycleThoroughness }],
|
|
199
249
|
[cycleThoroughness]
|
|
200
250
|
);
|
|
201
|
-
|
|
202
251
|
useKeyboardShortcuts(shortcuts);
|
|
203
252
|
|
|
253
|
+
const handleAddQueryMode = useCallback(() => {
|
|
254
|
+
const text = queryModeText.trim();
|
|
255
|
+
if (!text) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (
|
|
259
|
+
queryModeDraft === "hyde" &&
|
|
260
|
+
queryModes.some((queryMode) => queryMode.mode === "hyde")
|
|
261
|
+
) {
|
|
262
|
+
setQueryModeError("Only one HyDE mode is allowed.");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
setQueryModes((prev) => [...prev, { mode: queryModeDraft, text }]);
|
|
266
|
+
setQueryModeText("");
|
|
267
|
+
setQueryModeError(null);
|
|
268
|
+
}, [queryModeDraft, queryModeText, queryModes]);
|
|
269
|
+
|
|
270
|
+
const handleRemoveQueryMode = useCallback((index: number) => {
|
|
271
|
+
setQueryModes((prev) => prev.filter((_, i) => i !== index));
|
|
272
|
+
setQueryModeError(null);
|
|
273
|
+
}, []);
|
|
274
|
+
|
|
204
275
|
const handleSearch = useCallback(
|
|
205
276
|
async (e?: React.FormEvent) => {
|
|
206
277
|
e?.preventDefault();
|
|
@@ -212,30 +283,50 @@ export default function Search({ navigate }: PageProps) {
|
|
|
212
283
|
setError(null);
|
|
213
284
|
setSearched(true);
|
|
214
285
|
|
|
215
|
-
|
|
216
|
-
const useBm25 = thoroughness === "fast";
|
|
286
|
+
const useBm25 = thoroughness === "fast" && queryModes.length === 0;
|
|
217
287
|
const endpoint = useBm25 ? "/api/search" : "/api/query";
|
|
288
|
+
const body: Record<string, unknown> = {
|
|
289
|
+
query,
|
|
290
|
+
limit: 20,
|
|
291
|
+
};
|
|
218
292
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
293
|
+
if (selectedCollection) {
|
|
294
|
+
body.collection = selectedCollection;
|
|
295
|
+
}
|
|
296
|
+
if (since) {
|
|
297
|
+
body.since = since;
|
|
298
|
+
}
|
|
299
|
+
if (until) {
|
|
300
|
+
body.until = until;
|
|
301
|
+
}
|
|
302
|
+
if (category.trim()) {
|
|
303
|
+
body.category = category.trim();
|
|
304
|
+
}
|
|
305
|
+
if (author.trim()) {
|
|
306
|
+
body.author = author.trim();
|
|
307
|
+
}
|
|
223
308
|
if (activeTags.length > 0) {
|
|
224
|
-
|
|
309
|
+
if (tagMode === "all") {
|
|
310
|
+
body.tagsAll = activeTags.join(",");
|
|
311
|
+
} else {
|
|
312
|
+
body.tagsAny = activeTags.join(",");
|
|
313
|
+
}
|
|
225
314
|
}
|
|
226
315
|
|
|
227
316
|
if (!useBm25) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (thoroughness === "balanced") {
|
|
317
|
+
if (thoroughness === "fast") {
|
|
318
|
+
body.noExpand = true;
|
|
319
|
+
body.noRerank = true;
|
|
320
|
+
} else if (thoroughness === "balanced") {
|
|
232
321
|
body.noExpand = true;
|
|
233
322
|
body.noRerank = false;
|
|
234
323
|
} else {
|
|
235
|
-
// thorough
|
|
236
324
|
body.noExpand = false;
|
|
237
325
|
body.noRerank = false;
|
|
238
326
|
}
|
|
327
|
+
if (queryModes.length > 0) {
|
|
328
|
+
body.queryModes = queryModes;
|
|
329
|
+
}
|
|
239
330
|
}
|
|
240
331
|
|
|
241
332
|
const { data, error: fetchError } = await apiFetch<SearchResponse>(
|
|
@@ -256,18 +347,37 @@ export default function Search({ navigate }: PageProps) {
|
|
|
256
347
|
setMeta(data.meta);
|
|
257
348
|
}
|
|
258
349
|
},
|
|
259
|
-
[
|
|
350
|
+
[
|
|
351
|
+
activeTags,
|
|
352
|
+
author,
|
|
353
|
+
category,
|
|
354
|
+
query,
|
|
355
|
+
queryModes,
|
|
356
|
+
selectedCollection,
|
|
357
|
+
since,
|
|
358
|
+
tagMode,
|
|
359
|
+
thoroughness,
|
|
360
|
+
until,
|
|
361
|
+
]
|
|
260
362
|
);
|
|
261
363
|
|
|
262
|
-
// Re-search when
|
|
364
|
+
// Re-search when filters change after an initial search.
|
|
263
365
|
useEffect(() => {
|
|
264
366
|
if (searched && query.trim()) {
|
|
265
367
|
void handleSearch();
|
|
266
368
|
}
|
|
267
369
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
268
|
-
}, [
|
|
370
|
+
}, [
|
|
371
|
+
activeTags,
|
|
372
|
+
author,
|
|
373
|
+
category,
|
|
374
|
+
queryModes,
|
|
375
|
+
selectedCollection,
|
|
376
|
+
since,
|
|
377
|
+
tagMode,
|
|
378
|
+
until,
|
|
379
|
+
]);
|
|
269
380
|
|
|
270
|
-
// Description for current thoroughness
|
|
271
381
|
const thoroughnessDesc =
|
|
272
382
|
thoroughness === "fast"
|
|
273
383
|
? "Keyword search (BM25)"
|
|
@@ -275,9 +385,31 @@ export default function Search({ navigate }: PageProps) {
|
|
|
275
385
|
? "Hybrid + reranking"
|
|
276
386
|
: "Full pipeline with expansion";
|
|
277
387
|
|
|
388
|
+
const activeFilterPills = [
|
|
389
|
+
selectedCollection ? `collection:${selectedCollection}` : null,
|
|
390
|
+
since ? `since:${since}` : null,
|
|
391
|
+
until ? `until:${until}` : null,
|
|
392
|
+
category.trim() ? `category:${category.trim()}` : null,
|
|
393
|
+
author.trim() ? `author:${author.trim()}` : null,
|
|
394
|
+
queryModes.length > 0 ? `${queryModes.length} query mode(s)` : null,
|
|
395
|
+
].filter((pill): pill is string => Boolean(pill));
|
|
396
|
+
|
|
397
|
+
const hasActiveFilters =
|
|
398
|
+
activeFilterPills.length > 0 || activeTags.length > 0;
|
|
399
|
+
|
|
400
|
+
const clearAdvancedFilters = () => {
|
|
401
|
+
setSelectedCollection("");
|
|
402
|
+
setSince("");
|
|
403
|
+
setUntil("");
|
|
404
|
+
setCategory("");
|
|
405
|
+
setAuthor("");
|
|
406
|
+
setQueryModes([]);
|
|
407
|
+
setQueryModeText("");
|
|
408
|
+
setQueryModeError(null);
|
|
409
|
+
};
|
|
410
|
+
|
|
278
411
|
return (
|
|
279
412
|
<div className="min-h-screen">
|
|
280
|
-
{/* Header */}
|
|
281
413
|
<header className="glass sticky top-0 z-10 border-border/50 border-b">
|
|
282
414
|
<div className="flex items-center gap-4 px-8 py-4">
|
|
283
415
|
<Button
|
|
@@ -293,9 +425,7 @@ export default function Search({ navigate }: PageProps) {
|
|
|
293
425
|
</div>
|
|
294
426
|
</header>
|
|
295
427
|
|
|
296
|
-
{/* Main content with sidebar */}
|
|
297
428
|
<div className="flex">
|
|
298
|
-
{/* Sidebar - Tag Facets */}
|
|
299
429
|
<aside className="sticky top-[65px] hidden h-[calc(100vh-65px)] w-64 shrink-0 overflow-y-auto border-border/30 border-r lg:block">
|
|
300
430
|
<TagFacets
|
|
301
431
|
activeTags={activeTags}
|
|
@@ -305,13 +435,10 @@ export default function Search({ navigate }: PageProps) {
|
|
|
305
435
|
/>
|
|
306
436
|
</aside>
|
|
307
437
|
|
|
308
|
-
{/* Main content */}
|
|
309
438
|
<main className="min-w-0 flex-1 p-8">
|
|
310
439
|
<div className="mx-auto max-w-3xl">
|
|
311
|
-
|
|
312
|
-
<form className="mb-6" onSubmit={handleSearch}>
|
|
440
|
+
<form className="mb-6 space-y-4" onSubmit={handleSearch}>
|
|
313
441
|
<div className="group relative">
|
|
314
|
-
{/* Gradient border effect on focus */}
|
|
315
442
|
<div className="pointer-events-none absolute -inset-[1px] rounded-lg bg-gradient-to-r from-primary/50 via-primary to-primary/50 opacity-0 blur-sm transition-opacity duration-300 group-focus-within:opacity-100" />
|
|
316
443
|
<div className="relative">
|
|
317
444
|
<SearchIcon className="absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground transition-colors duration-200 group-focus-within:text-primary" />
|
|
@@ -334,8 +461,7 @@ export default function Search({ navigate }: PageProps) {
|
|
|
334
461
|
</div>
|
|
335
462
|
</div>
|
|
336
463
|
|
|
337
|
-
|
|
338
|
-
<div className="mt-4 flex flex-wrap items-center gap-4">
|
|
464
|
+
<div className="flex flex-wrap items-center gap-4">
|
|
339
465
|
<ThoroughnessSelector
|
|
340
466
|
disabled={
|
|
341
467
|
loading || (!hybridAvailable && thoroughness !== "fast")
|
|
@@ -348,40 +474,292 @@ export default function Search({ navigate }: PageProps) {
|
|
|
348
474
|
{thoroughnessDesc}
|
|
349
475
|
</span>
|
|
350
476
|
|
|
477
|
+
{forceHybridForModes && (
|
|
478
|
+
<span className="text-amber-500/80 text-xs">
|
|
479
|
+
query modes active: using hybrid endpoint
|
|
480
|
+
</span>
|
|
481
|
+
)}
|
|
482
|
+
|
|
351
483
|
{!hybridAvailable && thoroughness !== "fast" && (
|
|
352
484
|
<span className="text-amber-500/70 text-xs">
|
|
353
485
|
(vectors not available)
|
|
354
486
|
</span>
|
|
355
487
|
)}
|
|
356
488
|
</div>
|
|
489
|
+
|
|
490
|
+
<Collapsible onOpenChange={setShowAdvanced} open={showAdvanced}>
|
|
491
|
+
<CollapsibleTrigger asChild>
|
|
492
|
+
<Button
|
|
493
|
+
className="gap-2"
|
|
494
|
+
size="sm"
|
|
495
|
+
type="button"
|
|
496
|
+
variant="outline"
|
|
497
|
+
>
|
|
498
|
+
<SlidersHorizontal className="size-4" />
|
|
499
|
+
Advanced Retrieval
|
|
500
|
+
<ChevronDown
|
|
501
|
+
className={cn(
|
|
502
|
+
"size-4 transition-transform",
|
|
503
|
+
showAdvanced && "rotate-180"
|
|
504
|
+
)}
|
|
505
|
+
/>
|
|
506
|
+
</Button>
|
|
507
|
+
</CollapsibleTrigger>
|
|
508
|
+
<CollapsibleContent className="pt-3">
|
|
509
|
+
<Card className="border-border/50 bg-card/50">
|
|
510
|
+
<CardContent className="space-y-4 pt-4">
|
|
511
|
+
<div className="grid gap-3 md:grid-cols-2">
|
|
512
|
+
<div>
|
|
513
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
514
|
+
Collection
|
|
515
|
+
</p>
|
|
516
|
+
<Select
|
|
517
|
+
onValueChange={(value) =>
|
|
518
|
+
setSelectedCollection(
|
|
519
|
+
value === "all" ? "" : value
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
value={selectedCollection || "all"}
|
|
523
|
+
>
|
|
524
|
+
<SelectTrigger className="w-full">
|
|
525
|
+
<SelectValue placeholder="All collections" />
|
|
526
|
+
</SelectTrigger>
|
|
527
|
+
<SelectContent>
|
|
528
|
+
<SelectItem value="all">
|
|
529
|
+
All collections
|
|
530
|
+
</SelectItem>
|
|
531
|
+
{collections.map((collection) => (
|
|
532
|
+
<SelectItem
|
|
533
|
+
key={collection.name}
|
|
534
|
+
value={collection.name}
|
|
535
|
+
>
|
|
536
|
+
{collection.name}
|
|
537
|
+
</SelectItem>
|
|
538
|
+
))}
|
|
539
|
+
</SelectContent>
|
|
540
|
+
</Select>
|
|
541
|
+
</div>
|
|
542
|
+
|
|
543
|
+
<div>
|
|
544
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
545
|
+
Author
|
|
546
|
+
</p>
|
|
547
|
+
<Input
|
|
548
|
+
onChange={(e) => setAuthor(e.target.value)}
|
|
549
|
+
placeholder="gordon"
|
|
550
|
+
value={author}
|
|
551
|
+
/>
|
|
552
|
+
</div>
|
|
553
|
+
|
|
554
|
+
<div>
|
|
555
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
556
|
+
Category
|
|
557
|
+
</p>
|
|
558
|
+
<Input
|
|
559
|
+
onChange={(e) => setCategory(e.target.value)}
|
|
560
|
+
placeholder="engineering, research"
|
|
561
|
+
value={category}
|
|
562
|
+
/>
|
|
563
|
+
</div>
|
|
564
|
+
|
|
565
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
566
|
+
<div>
|
|
567
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
568
|
+
Since
|
|
569
|
+
</p>
|
|
570
|
+
<Input
|
|
571
|
+
onChange={(e) => setSince(e.target.value)}
|
|
572
|
+
type="date"
|
|
573
|
+
value={since}
|
|
574
|
+
/>
|
|
575
|
+
</div>
|
|
576
|
+
<div>
|
|
577
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
578
|
+
Until
|
|
579
|
+
</p>
|
|
580
|
+
<Input
|
|
581
|
+
onChange={(e) => setUntil(e.target.value)}
|
|
582
|
+
type="date"
|
|
583
|
+
value={until}
|
|
584
|
+
/>
|
|
585
|
+
</div>
|
|
586
|
+
</div>
|
|
587
|
+
</div>
|
|
588
|
+
|
|
589
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
590
|
+
<span className="text-muted-foreground text-xs">
|
|
591
|
+
Tag match:
|
|
592
|
+
</span>
|
|
593
|
+
<Button
|
|
594
|
+
onClick={() => setTagMode("any")}
|
|
595
|
+
size="sm"
|
|
596
|
+
type="button"
|
|
597
|
+
variant={tagMode === "any" ? "default" : "outline"}
|
|
598
|
+
>
|
|
599
|
+
Any
|
|
600
|
+
</Button>
|
|
601
|
+
<Button
|
|
602
|
+
onClick={() => setTagMode("all")}
|
|
603
|
+
size="sm"
|
|
604
|
+
type="button"
|
|
605
|
+
variant={tagMode === "all" ? "default" : "outline"}
|
|
606
|
+
>
|
|
607
|
+
All
|
|
608
|
+
</Button>
|
|
609
|
+
|
|
610
|
+
<Button
|
|
611
|
+
className="ml-auto"
|
|
612
|
+
onClick={clearAdvancedFilters}
|
|
613
|
+
size="sm"
|
|
614
|
+
type="button"
|
|
615
|
+
variant="ghost"
|
|
616
|
+
>
|
|
617
|
+
Clear advanced
|
|
618
|
+
</Button>
|
|
619
|
+
</div>
|
|
620
|
+
|
|
621
|
+
<div className="space-y-2">
|
|
622
|
+
<p className="text-muted-foreground text-xs">
|
|
623
|
+
Query modes (term, intent, hyde)
|
|
624
|
+
</p>
|
|
625
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
626
|
+
<Select
|
|
627
|
+
onValueChange={(value) =>
|
|
628
|
+
setQueryModeDraft(value as QueryModeType)
|
|
629
|
+
}
|
|
630
|
+
value={queryModeDraft}
|
|
631
|
+
>
|
|
632
|
+
<SelectTrigger className="w-[120px]">
|
|
633
|
+
<SelectValue />
|
|
634
|
+
</SelectTrigger>
|
|
635
|
+
<SelectContent>
|
|
636
|
+
<SelectItem value="term">Term</SelectItem>
|
|
637
|
+
<SelectItem value="intent">Intent</SelectItem>
|
|
638
|
+
<SelectItem value="hyde">HyDE</SelectItem>
|
|
639
|
+
</SelectContent>
|
|
640
|
+
</Select>
|
|
641
|
+
<Input
|
|
642
|
+
className="min-w-[220px] flex-1"
|
|
643
|
+
onChange={(e) => setQueryModeText(e.target.value)}
|
|
644
|
+
onKeyDown={(e) => {
|
|
645
|
+
if (e.key === "Enter") {
|
|
646
|
+
e.preventDefault();
|
|
647
|
+
handleAddQueryMode();
|
|
648
|
+
}
|
|
649
|
+
}}
|
|
650
|
+
placeholder="Add query mode text"
|
|
651
|
+
value={queryModeText}
|
|
652
|
+
/>
|
|
653
|
+
<Button
|
|
654
|
+
onClick={handleAddQueryMode}
|
|
655
|
+
size="sm"
|
|
656
|
+
type="button"
|
|
657
|
+
variant="outline"
|
|
658
|
+
>
|
|
659
|
+
Add mode
|
|
660
|
+
</Button>
|
|
661
|
+
</div>
|
|
662
|
+
|
|
663
|
+
{queryModeError && (
|
|
664
|
+
<p className="text-destructive text-xs">
|
|
665
|
+
{queryModeError}
|
|
666
|
+
</p>
|
|
667
|
+
)}
|
|
668
|
+
|
|
669
|
+
{queryModes.length > 0 && (
|
|
670
|
+
<div className="flex flex-wrap gap-2">
|
|
671
|
+
{queryModes.map((queryMode, index) => (
|
|
672
|
+
<button
|
|
673
|
+
className={cn(
|
|
674
|
+
"group inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10",
|
|
675
|
+
"px-2.5 py-1 font-mono text-[11px] text-primary transition-all duration-150",
|
|
676
|
+
"hover:border-primary/50 hover:bg-primary/20"
|
|
677
|
+
)}
|
|
678
|
+
key={`${queryMode.mode}:${queryMode.text}:${index}`}
|
|
679
|
+
onClick={() => handleRemoveQueryMode(index)}
|
|
680
|
+
type="button"
|
|
681
|
+
>
|
|
682
|
+
<span>{`${QUERY_MODE_LABEL[queryMode.mode]}: ${queryMode.text}`}</span>
|
|
683
|
+
<XIcon className="size-3 opacity-60 transition-opacity group-hover:opacity-100" />
|
|
684
|
+
</button>
|
|
685
|
+
))}
|
|
686
|
+
</div>
|
|
687
|
+
)}
|
|
688
|
+
</div>
|
|
689
|
+
</CardContent>
|
|
690
|
+
</Card>
|
|
691
|
+
</CollapsibleContent>
|
|
692
|
+
</Collapsible>
|
|
357
693
|
</form>
|
|
358
694
|
|
|
359
|
-
|
|
360
|
-
|
|
695
|
+
<div className="mb-6 lg:hidden">
|
|
696
|
+
<Collapsible
|
|
697
|
+
onOpenChange={setShowMobileTags}
|
|
698
|
+
open={showMobileTags}
|
|
699
|
+
>
|
|
700
|
+
<CollapsibleTrigger asChild>
|
|
701
|
+
<Button
|
|
702
|
+
className="gap-2"
|
|
703
|
+
size="sm"
|
|
704
|
+
type="button"
|
|
705
|
+
variant="outline"
|
|
706
|
+
>
|
|
707
|
+
<SlidersHorizontal className="size-4" />
|
|
708
|
+
Tags
|
|
709
|
+
<ChevronDown
|
|
710
|
+
className={cn(
|
|
711
|
+
"size-4 transition-transform",
|
|
712
|
+
showMobileTags && "rotate-180"
|
|
713
|
+
)}
|
|
714
|
+
/>
|
|
715
|
+
</Button>
|
|
716
|
+
</CollapsibleTrigger>
|
|
717
|
+
<CollapsibleContent className="pt-3">
|
|
718
|
+
<TagFacets
|
|
719
|
+
activeTags={activeTags}
|
|
720
|
+
className="rounded-md border border-border/50"
|
|
721
|
+
onTagRemove={handleTagRemove}
|
|
722
|
+
onTagSelect={handleTagSelect}
|
|
723
|
+
/>
|
|
724
|
+
</CollapsibleContent>
|
|
725
|
+
</Collapsible>
|
|
726
|
+
</div>
|
|
727
|
+
|
|
728
|
+
{hasActiveFilters && (
|
|
361
729
|
<div className="mb-6 flex flex-wrap items-center gap-2">
|
|
362
730
|
<span className="font-mono text-muted-foreground text-xs">
|
|
363
|
-
|
|
731
|
+
Filters:
|
|
364
732
|
</span>
|
|
733
|
+
{activeFilterPills.map((pill) => (
|
|
734
|
+
<Badge
|
|
735
|
+
className="font-mono text-[10px]"
|
|
736
|
+
key={pill}
|
|
737
|
+
variant="outline"
|
|
738
|
+
>
|
|
739
|
+
{pill}
|
|
740
|
+
</Badge>
|
|
741
|
+
))}
|
|
365
742
|
{activeTags.map((tag) => (
|
|
366
743
|
<button
|
|
367
744
|
className={cn(
|
|
368
|
-
"group inline-flex items-center gap-1",
|
|
369
|
-
"
|
|
370
|
-
"px-2.5 py-1 font-mono text-xs text-primary",
|
|
371
|
-
"transition-all duration-150",
|
|
745
|
+
"group inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10",
|
|
746
|
+
"px-2.5 py-1 font-mono text-xs text-primary transition-all duration-150",
|
|
372
747
|
"hover:border-primary/50 hover:bg-primary/20"
|
|
373
748
|
)}
|
|
374
749
|
key={tag}
|
|
375
750
|
onClick={() => handleTagRemove(tag)}
|
|
376
751
|
type="button"
|
|
377
752
|
>
|
|
378
|
-
<span>{tag}</span>
|
|
753
|
+
<span>{`${tagMode}:${tag}`}</span>
|
|
379
754
|
<XIcon className="size-3 opacity-60 transition-opacity group-hover:opacity-100" />
|
|
380
755
|
</button>
|
|
381
756
|
))}
|
|
382
757
|
<button
|
|
383
758
|
className="font-mono text-muted-foreground text-xs underline-offset-2 hover:text-foreground hover:underline"
|
|
384
|
-
onClick={() =>
|
|
759
|
+
onClick={() => {
|
|
760
|
+
setActiveTags([]);
|
|
761
|
+
clearAdvancedFilters();
|
|
762
|
+
}}
|
|
385
763
|
type="button"
|
|
386
764
|
>
|
|
387
765
|
Clear all
|
|
@@ -389,7 +767,6 @@ export default function Search({ navigate }: PageProps) {
|
|
|
389
767
|
</div>
|
|
390
768
|
)}
|
|
391
769
|
|
|
392
|
-
{/* Error */}
|
|
393
770
|
{error && (
|
|
394
771
|
<Card className="mb-6 border-destructive bg-destructive/10">
|
|
395
772
|
<CardContent className="py-4 text-destructive">
|
|
@@ -398,7 +775,6 @@ export default function Search({ navigate }: PageProps) {
|
|
|
398
775
|
</Card>
|
|
399
776
|
)}
|
|
400
777
|
|
|
401
|
-
{/* Loading */}
|
|
402
778
|
{loading && (
|
|
403
779
|
<div className="flex flex-col items-center justify-center gap-4 py-20">
|
|
404
780
|
<Loader className="text-primary" size={32} />
|
|
@@ -406,7 +782,6 @@ export default function Search({ navigate }: PageProps) {
|
|
|
406
782
|
</div>
|
|
407
783
|
)}
|
|
408
784
|
|
|
409
|
-
{/* Empty state */}
|
|
410
785
|
{!loading && searched && results.length === 0 && !error && (
|
|
411
786
|
<div className="py-20 text-center">
|
|
412
787
|
<div className="relative mx-auto mb-6 size-20">
|
|
@@ -416,10 +791,8 @@ export default function Search({ navigate }: PageProps) {
|
|
|
416
791
|
</div>
|
|
417
792
|
<h3 className="mb-2 font-semibold text-xl">No matches found</h3>
|
|
418
793
|
<p className="mx-auto mb-6 max-w-sm text-muted-foreground">
|
|
419
|
-
We couldn't find any documents matching "{query}"
|
|
420
|
-
|
|
421
|
-
` with tags: ${activeTags.join(", ")}`}
|
|
422
|
-
. Try different keywords or remove some filters.
|
|
794
|
+
We couldn't find any documents matching "{query}". Try fewer
|
|
795
|
+
filters, different keywords, or another depth mode.
|
|
423
796
|
</p>
|
|
424
797
|
<div className="flex justify-center gap-3">
|
|
425
798
|
<Button
|
|
@@ -429,9 +802,12 @@ export default function Search({ navigate }: PageProps) {
|
|
|
429
802
|
>
|
|
430
803
|
Clear search
|
|
431
804
|
</Button>
|
|
432
|
-
{
|
|
805
|
+
{hasActiveFilters && (
|
|
433
806
|
<Button
|
|
434
|
-
onClick={() =>
|
|
807
|
+
onClick={() => {
|
|
808
|
+
setActiveTags([]);
|
|
809
|
+
clearAdvancedFilters();
|
|
810
|
+
}}
|
|
435
811
|
size="sm"
|
|
436
812
|
variant="outline"
|
|
437
813
|
>
|
|
@@ -445,7 +821,6 @@ export default function Search({ navigate }: PageProps) {
|
|
|
445
821
|
</div>
|
|
446
822
|
)}
|
|
447
823
|
|
|
448
|
-
{/* Results */}
|
|
449
824
|
{!loading && results.length > 0 && (
|
|
450
825
|
<div className="space-y-4">
|
|
451
826
|
<div className="mb-6 flex items-center justify-between">
|
|
@@ -478,40 +853,52 @@ export default function Search({ navigate }: PageProps) {
|
|
|
478
853
|
reranked
|
|
479
854
|
</Badge>
|
|
480
855
|
)}
|
|
856
|
+
{meta.queryModes &&
|
|
857
|
+
(meta.queryModes.term > 0 ||
|
|
858
|
+
meta.queryModes.intent > 0 ||
|
|
859
|
+
meta.queryModes.hyde) && (
|
|
860
|
+
<Badge
|
|
861
|
+
className="font-mono text-[10px]"
|
|
862
|
+
variant="secondary"
|
|
863
|
+
>
|
|
864
|
+
modes
|
|
865
|
+
</Badge>
|
|
866
|
+
)}
|
|
481
867
|
</div>
|
|
482
868
|
)}
|
|
483
869
|
</div>
|
|
484
|
-
{results.map((
|
|
870
|
+
{results.map((result, i) => (
|
|
485
871
|
<Card
|
|
486
872
|
className="group animate-fade-in cursor-pointer opacity-0 transition-all hover:border-primary/50 hover:bg-card/80"
|
|
487
|
-
key={`${
|
|
873
|
+
key={`${result.docid}-${i}`}
|
|
488
874
|
onClick={() =>
|
|
489
|
-
navigate(`/doc?uri=${encodeURIComponent(
|
|
875
|
+
navigate(`/doc?uri=${encodeURIComponent(result.uri)}`)
|
|
490
876
|
}
|
|
491
877
|
style={{ animationDelay: `${i * 0.05}s` }}
|
|
492
878
|
>
|
|
493
879
|
<CardContent className="py-4">
|
|
494
880
|
<div className="mb-2 flex items-start justify-between gap-4">
|
|
495
881
|
<h3 className="font-medium text-primary underline-offset-2 group-hover:underline">
|
|
496
|
-
{
|
|
882
|
+
{result.title || result.uri.split("/").pop()}
|
|
497
883
|
</h3>
|
|
498
884
|
<Badge
|
|
499
885
|
className="shrink-0 font-mono text-xs"
|
|
500
886
|
variant="secondary"
|
|
501
887
|
>
|
|
502
|
-
{(
|
|
888
|
+
{(result.score * 100).toFixed(0)}%
|
|
503
889
|
</Badge>
|
|
504
890
|
</div>
|
|
505
891
|
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
|
|
506
|
-
{renderSnippet(
|
|
892
|
+
{renderSnippet(result.snippet)}
|
|
507
893
|
</p>
|
|
508
894
|
<div className="mt-2 flex items-center gap-2">
|
|
509
895
|
<p className="truncate font-mono text-muted-foreground/60 text-xs">
|
|
510
|
-
{
|
|
896
|
+
{result.uri}
|
|
511
897
|
</p>
|
|
512
|
-
{
|
|
898
|
+
{result.snippetRange && (
|
|
513
899
|
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/40">
|
|
514
|
-
L{
|
|
900
|
+
L{result.snippetRange.startLine}-
|
|
901
|
+
{result.snippetRange.endLine}
|
|
515
902
|
</span>
|
|
516
903
|
)}
|
|
517
904
|
</div>
|