@gmickel/gno 0.16.0 → 0.18.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 +55 -2
- package/package.json +4 -1
- package/src/cli/commands/ask.ts +13 -0
- package/src/cli/commands/models/use.ts +1 -0
- package/src/cli/commands/query.ts +3 -2
- package/src/cli/pager.ts +1 -1
- package/src/cli/program.ts +107 -0
- package/src/config/types.ts +2 -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/llm/nodeLlamaCpp/generation.ts +3 -1
- package/src/llm/registry.ts +1 -0
- package/src/llm/types.ts +2 -0
- package/src/mcp/tools/index.ts +34 -1
- package/src/mcp/tools/query.ts +26 -2
- package/src/mcp/tools/search.ts +10 -0
- package/src/mcp/tools/vsearch.ts +10 -0
- package/src/pipeline/answer.ts +324 -7
- package/src/pipeline/expansion.ts +282 -11
- package/src/pipeline/explain.ts +93 -5
- package/src/pipeline/hybrid.ts +273 -70
- package/src/pipeline/intent.ts +152 -0
- package/src/pipeline/query-modes.ts +125 -0
- package/src/pipeline/rerank.ts +109 -51
- package/src/pipeline/search.ts +58 -4
- package/src/pipeline/temporal.ts +257 -0
- package/src/pipeline/types.ts +67 -0
- package/src/pipeline/vsearch.ts +121 -10
- 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 +174 -0
- package/src/serve/public/pages/Ask.tsx +378 -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 +561 -120
- package/src/serve/routes/api.ts +247 -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,76 @@ 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.intent ||
|
|
157
|
+
initialFilters.candidateLimit ||
|
|
158
|
+
initialFilters.since ||
|
|
159
|
+
initialFilters.until ||
|
|
160
|
+
initialFilters.category ||
|
|
161
|
+
initialFilters.author ||
|
|
162
|
+
initialFilters.queryModes.length > 0
|
|
163
|
+
)
|
|
164
|
+
);
|
|
148
165
|
|
|
149
|
-
|
|
150
|
-
const [
|
|
151
|
-
|
|
166
|
+
const [activeTags, setActiveTags] = useState<string[]>(initialFilters.tags);
|
|
167
|
+
const [tagMode, setTagMode] = useState<TagMode>(initialFilters.tagMode);
|
|
168
|
+
const [selectedCollection, setSelectedCollection] = useState(
|
|
169
|
+
initialFilters.collection
|
|
152
170
|
);
|
|
171
|
+
const [intent, setIntent] = useState(initialFilters.intent);
|
|
172
|
+
const [candidateLimit, setCandidateLimit] = useState(
|
|
173
|
+
initialFilters.candidateLimit
|
|
174
|
+
);
|
|
175
|
+
const [since, setSince] = useState(initialFilters.since);
|
|
176
|
+
const [until, setUntil] = useState(initialFilters.until);
|
|
177
|
+
const [category, setCategory] = useState(initialFilters.category);
|
|
178
|
+
const [author, setAuthor] = useState(initialFilters.author);
|
|
179
|
+
const [queryModes, setQueryModes] = useState<QueryModeEntry[]>(
|
|
180
|
+
initialFilters.queryModes
|
|
181
|
+
);
|
|
182
|
+
const [queryModeDraft, setQueryModeDraft] = useState<QueryModeType>("term");
|
|
183
|
+
const [queryModeText, setQueryModeText] = useState("");
|
|
184
|
+
const [queryModeError, setQueryModeError] = useState<string | null>(null);
|
|
185
|
+
const [showMobileTags, setShowMobileTags] = useState(false);
|
|
186
|
+
|
|
187
|
+
const hybridAvailable = capabilities?.hybrid ?? false;
|
|
188
|
+
const forceHybridForModes =
|
|
189
|
+
thoroughness === "fast" &&
|
|
190
|
+
(queryModes.length > 0 || intent.trim().length > 0);
|
|
153
191
|
|
|
154
|
-
// Sync URL
|
|
192
|
+
// Sync URL as filter state changes.
|
|
155
193
|
useEffect(() => {
|
|
156
|
-
|
|
157
|
-
|
|
194
|
+
const url = new URL(window.location.href);
|
|
195
|
+
applyFiltersToUrl(url, {
|
|
196
|
+
collection: selectedCollection,
|
|
197
|
+
intent,
|
|
198
|
+
candidateLimit,
|
|
199
|
+
since,
|
|
200
|
+
until,
|
|
201
|
+
category,
|
|
202
|
+
author,
|
|
203
|
+
tagMode,
|
|
204
|
+
tags: activeTags,
|
|
205
|
+
queryModes,
|
|
206
|
+
});
|
|
207
|
+
window.history.replaceState({}, "", url.toString());
|
|
208
|
+
}, [
|
|
209
|
+
activeTags,
|
|
210
|
+
author,
|
|
211
|
+
candidateLimit,
|
|
212
|
+
category,
|
|
213
|
+
intent,
|
|
214
|
+
queryModes,
|
|
215
|
+
selectedCollection,
|
|
216
|
+
since,
|
|
217
|
+
tagMode,
|
|
218
|
+
until,
|
|
219
|
+
]);
|
|
158
220
|
|
|
159
|
-
// Tag filter handlers
|
|
160
221
|
const handleTagSelect = useCallback((tag: string) => {
|
|
161
222
|
setActiveTags((prev) => (prev.includes(tag) ? prev : [...prev, tag]));
|
|
162
223
|
}, []);
|
|
@@ -165,28 +226,29 @@ export default function Search({ navigate }: PageProps) {
|
|
|
165
226
|
setActiveTags((prev) => prev.filter((t) => t !== tag));
|
|
166
227
|
}, []);
|
|
167
228
|
|
|
168
|
-
// Fetch capabilities on mount
|
|
169
229
|
useEffect(() => {
|
|
170
|
-
async function
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
230
|
+
async function bootstrap(): Promise<void> {
|
|
231
|
+
const [capabilitiesResult, collectionsResult] = await Promise.all([
|
|
232
|
+
apiFetch<Capabilities>("/api/capabilities"),
|
|
233
|
+
apiFetch<Collection[]>("/api/collections"),
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
if (capabilitiesResult.data) {
|
|
237
|
+
const caps = capabilitiesResult.data;
|
|
238
|
+
setCapabilities(caps);
|
|
239
|
+
setThoroughness(caps.hybrid ? "balanced" : "fast");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (collectionsResult.data) {
|
|
243
|
+
setCollections(collectionsResult.data);
|
|
180
244
|
}
|
|
181
245
|
}
|
|
182
|
-
|
|
246
|
+
|
|
247
|
+
void bootstrap();
|
|
183
248
|
}, []);
|
|
184
249
|
|
|
185
|
-
// Cycle thoroughness with 't' key (only cycles to supported modes)
|
|
186
|
-
const hybridAvailable = capabilities?.hybrid ?? false;
|
|
187
250
|
const cycleThoroughness = useCallback(() => {
|
|
188
251
|
setThoroughness((current) => {
|
|
189
|
-
// If hybrid not available, stay on fast
|
|
190
252
|
if (!hybridAvailable) return "fast";
|
|
191
253
|
const currentIdx = THOROUGHNESS_ORDER.indexOf(current);
|
|
192
254
|
const nextIdx = (currentIdx + 1) % THOROUGHNESS_ORDER.length;
|
|
@@ -198,9 +260,30 @@ export default function Search({ navigate }: PageProps) {
|
|
|
198
260
|
() => [{ key: "t", action: cycleThoroughness }],
|
|
199
261
|
[cycleThoroughness]
|
|
200
262
|
);
|
|
201
|
-
|
|
202
263
|
useKeyboardShortcuts(shortcuts);
|
|
203
264
|
|
|
265
|
+
const handleAddQueryMode = useCallback(() => {
|
|
266
|
+
const text = queryModeText.trim();
|
|
267
|
+
if (!text) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (
|
|
271
|
+
queryModeDraft === "hyde" &&
|
|
272
|
+
queryModes.some((queryMode) => queryMode.mode === "hyde")
|
|
273
|
+
) {
|
|
274
|
+
setQueryModeError("Only one HyDE mode is allowed.");
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
setQueryModes((prev) => [...prev, { mode: queryModeDraft, text }]);
|
|
278
|
+
setQueryModeText("");
|
|
279
|
+
setQueryModeError(null);
|
|
280
|
+
}, [queryModeDraft, queryModeText, queryModes]);
|
|
281
|
+
|
|
282
|
+
const handleRemoveQueryMode = useCallback((index: number) => {
|
|
283
|
+
setQueryModes((prev) => prev.filter((_, i) => i !== index));
|
|
284
|
+
setQueryModeError(null);
|
|
285
|
+
}, []);
|
|
286
|
+
|
|
204
287
|
const handleSearch = useCallback(
|
|
205
288
|
async (e?: React.FormEvent) => {
|
|
206
289
|
e?.preventDefault();
|
|
@@ -212,30 +295,59 @@ export default function Search({ navigate }: PageProps) {
|
|
|
212
295
|
setError(null);
|
|
213
296
|
setSearched(true);
|
|
214
297
|
|
|
215
|
-
|
|
216
|
-
|
|
298
|
+
const useBm25 =
|
|
299
|
+
thoroughness === "fast" &&
|
|
300
|
+
queryModes.length === 0 &&
|
|
301
|
+
intent.trim().length === 0;
|
|
217
302
|
const endpoint = useBm25 ? "/api/search" : "/api/query";
|
|
303
|
+
const body: Record<string, unknown> = {
|
|
304
|
+
query,
|
|
305
|
+
limit: 20,
|
|
306
|
+
};
|
|
218
307
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
308
|
+
if (selectedCollection) {
|
|
309
|
+
body.collection = selectedCollection;
|
|
310
|
+
}
|
|
311
|
+
if (intent.trim()) {
|
|
312
|
+
body.intent = intent.trim();
|
|
313
|
+
}
|
|
314
|
+
if (candidateLimit.trim()) {
|
|
315
|
+
body.candidateLimit = Number(candidateLimit);
|
|
316
|
+
}
|
|
317
|
+
if (since) {
|
|
318
|
+
body.since = since;
|
|
319
|
+
}
|
|
320
|
+
if (until) {
|
|
321
|
+
body.until = until;
|
|
322
|
+
}
|
|
323
|
+
if (category.trim()) {
|
|
324
|
+
body.category = category.trim();
|
|
325
|
+
}
|
|
326
|
+
if (author.trim()) {
|
|
327
|
+
body.author = author.trim();
|
|
328
|
+
}
|
|
223
329
|
if (activeTags.length > 0) {
|
|
224
|
-
|
|
330
|
+
if (tagMode === "all") {
|
|
331
|
+
body.tagsAll = activeTags.join(",");
|
|
332
|
+
} else {
|
|
333
|
+
body.tagsAny = activeTags.join(",");
|
|
334
|
+
}
|
|
225
335
|
}
|
|
226
336
|
|
|
227
337
|
if (!useBm25) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
if (thoroughness === "balanced") {
|
|
338
|
+
if (thoroughness === "fast") {
|
|
339
|
+
body.noExpand = true;
|
|
340
|
+
body.noRerank = true;
|
|
341
|
+
} else if (thoroughness === "balanced") {
|
|
232
342
|
body.noExpand = true;
|
|
233
343
|
body.noRerank = false;
|
|
234
344
|
} else {
|
|
235
|
-
// thorough
|
|
236
345
|
body.noExpand = false;
|
|
237
346
|
body.noRerank = false;
|
|
238
347
|
}
|
|
348
|
+
if (queryModes.length > 0) {
|
|
349
|
+
body.queryModes = queryModes;
|
|
350
|
+
}
|
|
239
351
|
}
|
|
240
352
|
|
|
241
353
|
const { data, error: fetchError } = await apiFetch<SearchResponse>(
|
|
@@ -256,18 +368,41 @@ export default function Search({ navigate }: PageProps) {
|
|
|
256
368
|
setMeta(data.meta);
|
|
257
369
|
}
|
|
258
370
|
},
|
|
259
|
-
[
|
|
371
|
+
[
|
|
372
|
+
activeTags,
|
|
373
|
+
author,
|
|
374
|
+
candidateLimit,
|
|
375
|
+
category,
|
|
376
|
+
intent,
|
|
377
|
+
query,
|
|
378
|
+
queryModes,
|
|
379
|
+
selectedCollection,
|
|
380
|
+
since,
|
|
381
|
+
tagMode,
|
|
382
|
+
thoroughness,
|
|
383
|
+
until,
|
|
384
|
+
]
|
|
260
385
|
);
|
|
261
386
|
|
|
262
|
-
// Re-search when
|
|
387
|
+
// Re-search when filters change after an initial search.
|
|
263
388
|
useEffect(() => {
|
|
264
389
|
if (searched && query.trim()) {
|
|
265
390
|
void handleSearch();
|
|
266
391
|
}
|
|
267
392
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
268
|
-
}, [
|
|
393
|
+
}, [
|
|
394
|
+
activeTags,
|
|
395
|
+
author,
|
|
396
|
+
candidateLimit,
|
|
397
|
+
category,
|
|
398
|
+
intent,
|
|
399
|
+
queryModes,
|
|
400
|
+
selectedCollection,
|
|
401
|
+
since,
|
|
402
|
+
tagMode,
|
|
403
|
+
until,
|
|
404
|
+
]);
|
|
269
405
|
|
|
270
|
-
// Description for current thoroughness
|
|
271
406
|
const thoroughnessDesc =
|
|
272
407
|
thoroughness === "fast"
|
|
273
408
|
? "Keyword search (BM25)"
|
|
@@ -275,9 +410,35 @@ export default function Search({ navigate }: PageProps) {
|
|
|
275
410
|
? "Hybrid + reranking"
|
|
276
411
|
: "Full pipeline with expansion";
|
|
277
412
|
|
|
413
|
+
const activeFilterPills = [
|
|
414
|
+
selectedCollection ? `collection:${selectedCollection}` : null,
|
|
415
|
+
intent.trim() ? `intent:${intent.trim()}` : null,
|
|
416
|
+
candidateLimit.trim() ? `candidates:${candidateLimit.trim()}` : null,
|
|
417
|
+
since ? `since:${since}` : null,
|
|
418
|
+
until ? `until:${until}` : null,
|
|
419
|
+
category.trim() ? `category:${category.trim()}` : null,
|
|
420
|
+
author.trim() ? `author:${author.trim()}` : null,
|
|
421
|
+
queryModes.length > 0 ? `${queryModes.length} query mode(s)` : null,
|
|
422
|
+
].filter((pill): pill is string => Boolean(pill));
|
|
423
|
+
|
|
424
|
+
const hasActiveFilters =
|
|
425
|
+
activeFilterPills.length > 0 || activeTags.length > 0;
|
|
426
|
+
|
|
427
|
+
const clearAdvancedFilters = () => {
|
|
428
|
+
setSelectedCollection("");
|
|
429
|
+
setIntent("");
|
|
430
|
+
setCandidateLimit("");
|
|
431
|
+
setSince("");
|
|
432
|
+
setUntil("");
|
|
433
|
+
setCategory("");
|
|
434
|
+
setAuthor("");
|
|
435
|
+
setQueryModes([]);
|
|
436
|
+
setQueryModeText("");
|
|
437
|
+
setQueryModeError(null);
|
|
438
|
+
};
|
|
439
|
+
|
|
278
440
|
return (
|
|
279
441
|
<div className="min-h-screen">
|
|
280
|
-
{/* Header */}
|
|
281
442
|
<header className="glass sticky top-0 z-10 border-border/50 border-b">
|
|
282
443
|
<div className="flex items-center gap-4 px-8 py-4">
|
|
283
444
|
<Button
|
|
@@ -293,9 +454,7 @@ export default function Search({ navigate }: PageProps) {
|
|
|
293
454
|
</div>
|
|
294
455
|
</header>
|
|
295
456
|
|
|
296
|
-
{/* Main content with sidebar */}
|
|
297
457
|
<div className="flex">
|
|
298
|
-
{/* Sidebar - Tag Facets */}
|
|
299
458
|
<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
459
|
<TagFacets
|
|
301
460
|
activeTags={activeTags}
|
|
@@ -305,13 +464,10 @@ export default function Search({ navigate }: PageProps) {
|
|
|
305
464
|
/>
|
|
306
465
|
</aside>
|
|
307
466
|
|
|
308
|
-
{/* Main content */}
|
|
309
467
|
<main className="min-w-0 flex-1 p-8">
|
|
310
468
|
<div className="mx-auto max-w-3xl">
|
|
311
|
-
|
|
312
|
-
<form className="mb-6" onSubmit={handleSearch}>
|
|
469
|
+
<form className="mb-6 space-y-4" onSubmit={handleSearch}>
|
|
313
470
|
<div className="group relative">
|
|
314
|
-
{/* Gradient border effect on focus */}
|
|
315
471
|
<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
472
|
<div className="relative">
|
|
317
473
|
<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 +490,7 @@ export default function Search({ navigate }: PageProps) {
|
|
|
334
490
|
</div>
|
|
335
491
|
</div>
|
|
336
492
|
|
|
337
|
-
|
|
338
|
-
<div className="mt-4 flex flex-wrap items-center gap-4">
|
|
493
|
+
<div className="flex flex-wrap items-center gap-4">
|
|
339
494
|
<ThoroughnessSelector
|
|
340
495
|
disabled={
|
|
341
496
|
loading || (!hybridAvailable && thoroughness !== "fast")
|
|
@@ -348,40 +503,317 @@ export default function Search({ navigate }: PageProps) {
|
|
|
348
503
|
{thoroughnessDesc}
|
|
349
504
|
</span>
|
|
350
505
|
|
|
506
|
+
{forceHybridForModes && (
|
|
507
|
+
<span className="text-amber-500/80 text-xs">
|
|
508
|
+
query modes active: using hybrid endpoint
|
|
509
|
+
</span>
|
|
510
|
+
)}
|
|
511
|
+
|
|
351
512
|
{!hybridAvailable && thoroughness !== "fast" && (
|
|
352
513
|
<span className="text-amber-500/70 text-xs">
|
|
353
514
|
(vectors not available)
|
|
354
515
|
</span>
|
|
355
516
|
)}
|
|
356
517
|
</div>
|
|
518
|
+
|
|
519
|
+
<Collapsible onOpenChange={setShowAdvanced} open={showAdvanced}>
|
|
520
|
+
<CollapsibleTrigger asChild>
|
|
521
|
+
<Button
|
|
522
|
+
className="gap-2"
|
|
523
|
+
size="sm"
|
|
524
|
+
type="button"
|
|
525
|
+
variant="outline"
|
|
526
|
+
>
|
|
527
|
+
<SlidersHorizontal className="size-4" />
|
|
528
|
+
Advanced Retrieval
|
|
529
|
+
<ChevronDown
|
|
530
|
+
className={cn(
|
|
531
|
+
"size-4 transition-transform",
|
|
532
|
+
showAdvanced && "rotate-180"
|
|
533
|
+
)}
|
|
534
|
+
/>
|
|
535
|
+
</Button>
|
|
536
|
+
</CollapsibleTrigger>
|
|
537
|
+
<CollapsibleContent className="pt-3">
|
|
538
|
+
<Card className="border-border/50 bg-card/50">
|
|
539
|
+
<CardContent className="space-y-4 pt-4">
|
|
540
|
+
<div className="grid gap-3 md:grid-cols-2">
|
|
541
|
+
<div>
|
|
542
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
543
|
+
Collection
|
|
544
|
+
</p>
|
|
545
|
+
<Select
|
|
546
|
+
onValueChange={(value) =>
|
|
547
|
+
setSelectedCollection(
|
|
548
|
+
value === "all" ? "" : value
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
value={selectedCollection || "all"}
|
|
552
|
+
>
|
|
553
|
+
<SelectTrigger className="w-full">
|
|
554
|
+
<SelectValue placeholder="All collections" />
|
|
555
|
+
</SelectTrigger>
|
|
556
|
+
<SelectContent>
|
|
557
|
+
<SelectItem value="all">
|
|
558
|
+
All collections
|
|
559
|
+
</SelectItem>
|
|
560
|
+
{collections.map((collection) => (
|
|
561
|
+
<SelectItem
|
|
562
|
+
key={collection.name}
|
|
563
|
+
value={collection.name}
|
|
564
|
+
>
|
|
565
|
+
{collection.name}
|
|
566
|
+
</SelectItem>
|
|
567
|
+
))}
|
|
568
|
+
</SelectContent>
|
|
569
|
+
</Select>
|
|
570
|
+
</div>
|
|
571
|
+
|
|
572
|
+
<div>
|
|
573
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
574
|
+
Author
|
|
575
|
+
</p>
|
|
576
|
+
<Input
|
|
577
|
+
onChange={(e) => setAuthor(e.target.value)}
|
|
578
|
+
placeholder="gordon"
|
|
579
|
+
value={author}
|
|
580
|
+
/>
|
|
581
|
+
</div>
|
|
582
|
+
|
|
583
|
+
<div className="md:col-span-2">
|
|
584
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
585
|
+
Intent
|
|
586
|
+
</p>
|
|
587
|
+
<Input
|
|
588
|
+
onChange={(e) => setIntent(e.target.value)}
|
|
589
|
+
placeholder="Disambiguate ambiguous queries without searching on this text"
|
|
590
|
+
value={intent}
|
|
591
|
+
/>
|
|
592
|
+
</div>
|
|
593
|
+
|
|
594
|
+
<div>
|
|
595
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
596
|
+
Category
|
|
597
|
+
</p>
|
|
598
|
+
<Input
|
|
599
|
+
onChange={(e) => setCategory(e.target.value)}
|
|
600
|
+
placeholder="engineering, research"
|
|
601
|
+
value={category}
|
|
602
|
+
/>
|
|
603
|
+
</div>
|
|
604
|
+
|
|
605
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
606
|
+
<div>
|
|
607
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
608
|
+
Since
|
|
609
|
+
</p>
|
|
610
|
+
<Input
|
|
611
|
+
onChange={(e) => setSince(e.target.value)}
|
|
612
|
+
type="date"
|
|
613
|
+
value={since}
|
|
614
|
+
/>
|
|
615
|
+
</div>
|
|
616
|
+
<div>
|
|
617
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
618
|
+
Until
|
|
619
|
+
</p>
|
|
620
|
+
<Input
|
|
621
|
+
onChange={(e) => setUntil(e.target.value)}
|
|
622
|
+
type="date"
|
|
623
|
+
value={until}
|
|
624
|
+
/>
|
|
625
|
+
</div>
|
|
626
|
+
</div>
|
|
627
|
+
|
|
628
|
+
<div>
|
|
629
|
+
<p className="mb-1 text-muted-foreground text-xs">
|
|
630
|
+
Candidate limit
|
|
631
|
+
</p>
|
|
632
|
+
<Input
|
|
633
|
+
inputMode="numeric"
|
|
634
|
+
min="1"
|
|
635
|
+
onChange={(e) => setCandidateLimit(e.target.value)}
|
|
636
|
+
placeholder="20"
|
|
637
|
+
type="number"
|
|
638
|
+
value={candidateLimit}
|
|
639
|
+
/>
|
|
640
|
+
</div>
|
|
641
|
+
</div>
|
|
642
|
+
|
|
643
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
644
|
+
<span className="text-muted-foreground text-xs">
|
|
645
|
+
Tag match:
|
|
646
|
+
</span>
|
|
647
|
+
<Button
|
|
648
|
+
onClick={() => setTagMode("any")}
|
|
649
|
+
size="sm"
|
|
650
|
+
type="button"
|
|
651
|
+
variant={tagMode === "any" ? "default" : "outline"}
|
|
652
|
+
>
|
|
653
|
+
Any
|
|
654
|
+
</Button>
|
|
655
|
+
<Button
|
|
656
|
+
onClick={() => setTagMode("all")}
|
|
657
|
+
size="sm"
|
|
658
|
+
type="button"
|
|
659
|
+
variant={tagMode === "all" ? "default" : "outline"}
|
|
660
|
+
>
|
|
661
|
+
All
|
|
662
|
+
</Button>
|
|
663
|
+
|
|
664
|
+
<Button
|
|
665
|
+
className="ml-auto"
|
|
666
|
+
onClick={clearAdvancedFilters}
|
|
667
|
+
size="sm"
|
|
668
|
+
type="button"
|
|
669
|
+
variant="ghost"
|
|
670
|
+
>
|
|
671
|
+
Clear advanced
|
|
672
|
+
</Button>
|
|
673
|
+
</div>
|
|
674
|
+
|
|
675
|
+
<div className="space-y-2">
|
|
676
|
+
<p className="text-muted-foreground text-xs">
|
|
677
|
+
Query modes (term, intent, hyde)
|
|
678
|
+
</p>
|
|
679
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
680
|
+
<Select
|
|
681
|
+
onValueChange={(value) =>
|
|
682
|
+
setQueryModeDraft(value as QueryModeType)
|
|
683
|
+
}
|
|
684
|
+
value={queryModeDraft}
|
|
685
|
+
>
|
|
686
|
+
<SelectTrigger className="w-[120px]">
|
|
687
|
+
<SelectValue />
|
|
688
|
+
</SelectTrigger>
|
|
689
|
+
<SelectContent>
|
|
690
|
+
<SelectItem value="term">Term</SelectItem>
|
|
691
|
+
<SelectItem value="intent">Intent</SelectItem>
|
|
692
|
+
<SelectItem value="hyde">HyDE</SelectItem>
|
|
693
|
+
</SelectContent>
|
|
694
|
+
</Select>
|
|
695
|
+
<Input
|
|
696
|
+
className="min-w-[220px] flex-1"
|
|
697
|
+
onChange={(e) => setQueryModeText(e.target.value)}
|
|
698
|
+
onKeyDown={(e) => {
|
|
699
|
+
if (e.key === "Enter") {
|
|
700
|
+
e.preventDefault();
|
|
701
|
+
handleAddQueryMode();
|
|
702
|
+
}
|
|
703
|
+
}}
|
|
704
|
+
placeholder="Add query mode text"
|
|
705
|
+
value={queryModeText}
|
|
706
|
+
/>
|
|
707
|
+
<Button
|
|
708
|
+
onClick={handleAddQueryMode}
|
|
709
|
+
size="sm"
|
|
710
|
+
type="button"
|
|
711
|
+
variant="outline"
|
|
712
|
+
>
|
|
713
|
+
Add mode
|
|
714
|
+
</Button>
|
|
715
|
+
</div>
|
|
716
|
+
|
|
717
|
+
{queryModeError && (
|
|
718
|
+
<p className="text-destructive text-xs">
|
|
719
|
+
{queryModeError}
|
|
720
|
+
</p>
|
|
721
|
+
)}
|
|
722
|
+
|
|
723
|
+
{queryModes.length > 0 && (
|
|
724
|
+
<div className="flex flex-wrap gap-2">
|
|
725
|
+
{queryModes.map((queryMode, index) => (
|
|
726
|
+
<button
|
|
727
|
+
className={cn(
|
|
728
|
+
"group inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10",
|
|
729
|
+
"px-2.5 py-1 font-mono text-[11px] text-primary transition-all duration-150",
|
|
730
|
+
"hover:border-primary/50 hover:bg-primary/20"
|
|
731
|
+
)}
|
|
732
|
+
key={`${queryMode.mode}:${queryMode.text}:${index}`}
|
|
733
|
+
onClick={() => handleRemoveQueryMode(index)}
|
|
734
|
+
type="button"
|
|
735
|
+
>
|
|
736
|
+
<span>{`${QUERY_MODE_LABEL[queryMode.mode]}: ${queryMode.text}`}</span>
|
|
737
|
+
<XIcon className="size-3 opacity-60 transition-opacity group-hover:opacity-100" />
|
|
738
|
+
</button>
|
|
739
|
+
))}
|
|
740
|
+
</div>
|
|
741
|
+
)}
|
|
742
|
+
</div>
|
|
743
|
+
</CardContent>
|
|
744
|
+
</Card>
|
|
745
|
+
</CollapsibleContent>
|
|
746
|
+
</Collapsible>
|
|
357
747
|
</form>
|
|
358
748
|
|
|
359
|
-
|
|
360
|
-
|
|
749
|
+
<div className="mb-6 lg:hidden">
|
|
750
|
+
<Collapsible
|
|
751
|
+
onOpenChange={setShowMobileTags}
|
|
752
|
+
open={showMobileTags}
|
|
753
|
+
>
|
|
754
|
+
<CollapsibleTrigger asChild>
|
|
755
|
+
<Button
|
|
756
|
+
className="gap-2"
|
|
757
|
+
size="sm"
|
|
758
|
+
type="button"
|
|
759
|
+
variant="outline"
|
|
760
|
+
>
|
|
761
|
+
<SlidersHorizontal className="size-4" />
|
|
762
|
+
Tags
|
|
763
|
+
<ChevronDown
|
|
764
|
+
className={cn(
|
|
765
|
+
"size-4 transition-transform",
|
|
766
|
+
showMobileTags && "rotate-180"
|
|
767
|
+
)}
|
|
768
|
+
/>
|
|
769
|
+
</Button>
|
|
770
|
+
</CollapsibleTrigger>
|
|
771
|
+
<CollapsibleContent className="pt-3">
|
|
772
|
+
<TagFacets
|
|
773
|
+
activeTags={activeTags}
|
|
774
|
+
className="rounded-md border border-border/50"
|
|
775
|
+
onTagRemove={handleTagRemove}
|
|
776
|
+
onTagSelect={handleTagSelect}
|
|
777
|
+
/>
|
|
778
|
+
</CollapsibleContent>
|
|
779
|
+
</Collapsible>
|
|
780
|
+
</div>
|
|
781
|
+
|
|
782
|
+
{hasActiveFilters && (
|
|
361
783
|
<div className="mb-6 flex flex-wrap items-center gap-2">
|
|
362
784
|
<span className="font-mono text-muted-foreground text-xs">
|
|
363
|
-
|
|
785
|
+
Filters:
|
|
364
786
|
</span>
|
|
787
|
+
{activeFilterPills.map((pill) => (
|
|
788
|
+
<Badge
|
|
789
|
+
className="font-mono text-[10px]"
|
|
790
|
+
key={pill}
|
|
791
|
+
variant="outline"
|
|
792
|
+
>
|
|
793
|
+
{pill}
|
|
794
|
+
</Badge>
|
|
795
|
+
))}
|
|
365
796
|
{activeTags.map((tag) => (
|
|
366
797
|
<button
|
|
367
798
|
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",
|
|
799
|
+
"group inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10",
|
|
800
|
+
"px-2.5 py-1 font-mono text-xs text-primary transition-all duration-150",
|
|
372
801
|
"hover:border-primary/50 hover:bg-primary/20"
|
|
373
802
|
)}
|
|
374
803
|
key={tag}
|
|
375
804
|
onClick={() => handleTagRemove(tag)}
|
|
376
805
|
type="button"
|
|
377
806
|
>
|
|
378
|
-
<span>{tag}</span>
|
|
807
|
+
<span>{`${tagMode}:${tag}`}</span>
|
|
379
808
|
<XIcon className="size-3 opacity-60 transition-opacity group-hover:opacity-100" />
|
|
380
809
|
</button>
|
|
381
810
|
))}
|
|
382
811
|
<button
|
|
383
812
|
className="font-mono text-muted-foreground text-xs underline-offset-2 hover:text-foreground hover:underline"
|
|
384
|
-
onClick={() =>
|
|
813
|
+
onClick={() => {
|
|
814
|
+
setActiveTags([]);
|
|
815
|
+
clearAdvancedFilters();
|
|
816
|
+
}}
|
|
385
817
|
type="button"
|
|
386
818
|
>
|
|
387
819
|
Clear all
|
|
@@ -389,7 +821,6 @@ export default function Search({ navigate }: PageProps) {
|
|
|
389
821
|
</div>
|
|
390
822
|
)}
|
|
391
823
|
|
|
392
|
-
{/* Error */}
|
|
393
824
|
{error && (
|
|
394
825
|
<Card className="mb-6 border-destructive bg-destructive/10">
|
|
395
826
|
<CardContent className="py-4 text-destructive">
|
|
@@ -398,7 +829,6 @@ export default function Search({ navigate }: PageProps) {
|
|
|
398
829
|
</Card>
|
|
399
830
|
)}
|
|
400
831
|
|
|
401
|
-
{/* Loading */}
|
|
402
832
|
{loading && (
|
|
403
833
|
<div className="flex flex-col items-center justify-center gap-4 py-20">
|
|
404
834
|
<Loader className="text-primary" size={32} />
|
|
@@ -406,7 +836,6 @@ export default function Search({ navigate }: PageProps) {
|
|
|
406
836
|
</div>
|
|
407
837
|
)}
|
|
408
838
|
|
|
409
|
-
{/* Empty state */}
|
|
410
839
|
{!loading && searched && results.length === 0 && !error && (
|
|
411
840
|
<div className="py-20 text-center">
|
|
412
841
|
<div className="relative mx-auto mb-6 size-20">
|
|
@@ -416,10 +845,8 @@ export default function Search({ navigate }: PageProps) {
|
|
|
416
845
|
</div>
|
|
417
846
|
<h3 className="mb-2 font-semibold text-xl">No matches found</h3>
|
|
418
847
|
<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.
|
|
848
|
+
We couldn't find any documents matching "{query}". Try fewer
|
|
849
|
+
filters, different keywords, or another depth mode.
|
|
423
850
|
</p>
|
|
424
851
|
<div className="flex justify-center gap-3">
|
|
425
852
|
<Button
|
|
@@ -429,9 +856,12 @@ export default function Search({ navigate }: PageProps) {
|
|
|
429
856
|
>
|
|
430
857
|
Clear search
|
|
431
858
|
</Button>
|
|
432
|
-
{
|
|
859
|
+
{hasActiveFilters && (
|
|
433
860
|
<Button
|
|
434
|
-
onClick={() =>
|
|
861
|
+
onClick={() => {
|
|
862
|
+
setActiveTags([]);
|
|
863
|
+
clearAdvancedFilters();
|
|
864
|
+
}}
|
|
435
865
|
size="sm"
|
|
436
866
|
variant="outline"
|
|
437
867
|
>
|
|
@@ -445,7 +875,6 @@ export default function Search({ navigate }: PageProps) {
|
|
|
445
875
|
</div>
|
|
446
876
|
)}
|
|
447
877
|
|
|
448
|
-
{/* Results */}
|
|
449
878
|
{!loading && results.length > 0 && (
|
|
450
879
|
<div className="space-y-4">
|
|
451
880
|
<div className="mb-6 flex items-center justify-between">
|
|
@@ -478,40 +907,52 @@ export default function Search({ navigate }: PageProps) {
|
|
|
478
907
|
reranked
|
|
479
908
|
</Badge>
|
|
480
909
|
)}
|
|
910
|
+
{meta.queryModes &&
|
|
911
|
+
(meta.queryModes.term > 0 ||
|
|
912
|
+
meta.queryModes.intent > 0 ||
|
|
913
|
+
meta.queryModes.hyde) && (
|
|
914
|
+
<Badge
|
|
915
|
+
className="font-mono text-[10px]"
|
|
916
|
+
variant="secondary"
|
|
917
|
+
>
|
|
918
|
+
modes
|
|
919
|
+
</Badge>
|
|
920
|
+
)}
|
|
481
921
|
</div>
|
|
482
922
|
)}
|
|
483
923
|
</div>
|
|
484
|
-
{results.map((
|
|
924
|
+
{results.map((result, i) => (
|
|
485
925
|
<Card
|
|
486
926
|
className="group animate-fade-in cursor-pointer opacity-0 transition-all hover:border-primary/50 hover:bg-card/80"
|
|
487
|
-
key={`${
|
|
927
|
+
key={`${result.docid}-${i}`}
|
|
488
928
|
onClick={() =>
|
|
489
|
-
navigate(`/doc?uri=${encodeURIComponent(
|
|
929
|
+
navigate(`/doc?uri=${encodeURIComponent(result.uri)}`)
|
|
490
930
|
}
|
|
491
931
|
style={{ animationDelay: `${i * 0.05}s` }}
|
|
492
932
|
>
|
|
493
933
|
<CardContent className="py-4">
|
|
494
934
|
<div className="mb-2 flex items-start justify-between gap-4">
|
|
495
935
|
<h3 className="font-medium text-primary underline-offset-2 group-hover:underline">
|
|
496
|
-
{
|
|
936
|
+
{result.title || result.uri.split("/").pop()}
|
|
497
937
|
</h3>
|
|
498
938
|
<Badge
|
|
499
939
|
className="shrink-0 font-mono text-xs"
|
|
500
940
|
variant="secondary"
|
|
501
941
|
>
|
|
502
|
-
{(
|
|
942
|
+
{(result.score * 100).toFixed(0)}%
|
|
503
943
|
</Badge>
|
|
504
944
|
</div>
|
|
505
945
|
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
|
|
506
|
-
{renderSnippet(
|
|
946
|
+
{renderSnippet(result.snippet)}
|
|
507
947
|
</p>
|
|
508
948
|
<div className="mt-2 flex items-center gap-2">
|
|
509
949
|
<p className="truncate font-mono text-muted-foreground/60 text-xs">
|
|
510
|
-
{
|
|
950
|
+
{result.uri}
|
|
511
951
|
</p>
|
|
512
|
-
{
|
|
952
|
+
{result.snippetRange && (
|
|
513
953
|
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/40">
|
|
514
|
-
L{
|
|
954
|
+
L{result.snippetRange.startLine}-
|
|
955
|
+
{result.snippetRange.endLine}
|
|
515
956
|
</span>
|
|
516
957
|
)}
|
|
517
958
|
</div>
|