@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.
Files changed (43) hide show
  1. package/README.md +55 -2
  2. package/package.json +4 -1
  3. package/src/cli/commands/ask.ts +13 -0
  4. package/src/cli/commands/models/use.ts +1 -0
  5. package/src/cli/commands/query.ts +3 -2
  6. package/src/cli/pager.ts +1 -1
  7. package/src/cli/program.ts +107 -0
  8. package/src/config/types.ts +2 -0
  9. package/src/core/links.ts +92 -20
  10. package/src/ingestion/sync.ts +267 -23
  11. package/src/ingestion/types.ts +2 -0
  12. package/src/ingestion/walker.ts +2 -1
  13. package/src/llm/nodeLlamaCpp/generation.ts +3 -1
  14. package/src/llm/registry.ts +1 -0
  15. package/src/llm/types.ts +2 -0
  16. package/src/mcp/tools/index.ts +34 -1
  17. package/src/mcp/tools/query.ts +26 -2
  18. package/src/mcp/tools/search.ts +10 -0
  19. package/src/mcp/tools/vsearch.ts +10 -0
  20. package/src/pipeline/answer.ts +324 -7
  21. package/src/pipeline/expansion.ts +282 -11
  22. package/src/pipeline/explain.ts +93 -5
  23. package/src/pipeline/hybrid.ts +273 -70
  24. package/src/pipeline/intent.ts +152 -0
  25. package/src/pipeline/query-modes.ts +125 -0
  26. package/src/pipeline/rerank.ts +109 -51
  27. package/src/pipeline/search.ts +58 -4
  28. package/src/pipeline/temporal.ts +257 -0
  29. package/src/pipeline/types.ts +67 -0
  30. package/src/pipeline/vsearch.ts +121 -10
  31. package/src/serve/public/app.tsx +1 -3
  32. package/src/serve/public/globals.built.css +2 -2
  33. package/src/serve/public/lib/retrieval-filters.ts +174 -0
  34. package/src/serve/public/pages/Ask.tsx +378 -109
  35. package/src/serve/public/pages/Browse.tsx +71 -5
  36. package/src/serve/public/pages/DocView.tsx +2 -21
  37. package/src/serve/public/pages/Search.tsx +561 -120
  38. package/src/serve/routes/api.ts +247 -2
  39. package/src/store/migrations/006-document-metadata.ts +104 -0
  40. package/src/store/migrations/007-document-date-fields.ts +24 -0
  41. package/src/store/migrations/index.ts +3 -1
  42. package/src/store/sqlite/adapter.ts +218 -5
  43. package/src/store/types.ts +46 -0
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  ArrowLeft,
3
3
  BookOpen,
4
+ ChevronDown,
4
5
  CornerDownLeft,
5
6
  FileText,
7
+ SlidersHorizontal,
6
8
  Sparkles,
7
9
  } from "lucide-react";
8
10
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -22,9 +24,24 @@ import {
22
24
  import { Badge } from "../components/ui/badge";
23
25
  import { Button } from "../components/ui/button";
24
26
  import { Card, CardContent } from "../components/ui/card";
27
+ import {
28
+ Collapsible,
29
+ CollapsibleContent,
30
+ CollapsibleTrigger,
31
+ } from "../components/ui/collapsible";
32
+ import { Input } from "../components/ui/input";
33
+ import {
34
+ Select,
35
+ SelectContent,
36
+ SelectItem,
37
+ SelectTrigger,
38
+ SelectValue,
39
+ } from "../components/ui/select";
25
40
  import { Textarea } from "../components/ui/textarea";
26
41
  import { apiFetch } from "../hooks/use-api";
27
42
  import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
43
+ import { parseTagsCsv, type TagMode } from "../lib/retrieval-filters";
44
+ import { cn } from "../lib/utils";
28
45
 
29
46
  interface PageProps {
30
47
  navigate: (to: string | number) => void;
@@ -80,6 +97,10 @@ interface ConversationEntry {
80
97
  error?: string;
81
98
  }
82
99
 
100
+ interface Collection {
101
+ name: string;
102
+ }
103
+
83
104
  const THOROUGHNESS_ORDER: Thoroughness[] = ["fast", "balanced", "thorough"];
84
105
 
85
106
  /**
@@ -100,7 +121,6 @@ function renderAnswer(
100
121
 
101
122
  // oxlint-disable-next-line no-cond-assign -- Standard regex match pattern
102
123
  while ((match = citationRegex.exec(answer)) !== null) {
103
- // Add text before citation
104
124
  if (match.index > lastIndex) {
105
125
  parts.push(answer.slice(lastIndex, match.index));
106
126
  }
@@ -128,7 +148,6 @@ function renderAnswer(
128
148
  lastIndex = match.index + match[0].length;
129
149
  }
130
150
 
131
- // Add remaining text
132
151
  if (lastIndex < answer.length) {
133
152
  parts.push(answer.slice(lastIndex));
134
153
  }
@@ -140,105 +159,162 @@ export default function Ask({ navigate }: PageProps) {
140
159
  const [query, setQuery] = useState("");
141
160
  const [conversation, setConversation] = useState<ConversationEntry[]>([]);
142
161
  const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
162
+ const [collections, setCollections] = useState<Collection[]>([]);
143
163
  const [thoroughness, setThoroughness] = useState<Thoroughness>("balanced");
164
+
165
+ const [showAdvanced, setShowAdvanced] = useState(false);
166
+ const [selectedCollection, setSelectedCollection] = useState("");
167
+ const [intent, setIntent] = useState("");
168
+ const [candidateLimit, setCandidateLimit] = useState("");
169
+ const [since, setSince] = useState("");
170
+ const [until, setUntil] = useState("");
171
+ const [category, setCategory] = useState("");
172
+ const [author, setAuthor] = useState("");
173
+ const [tagMode, setTagMode] = useState<TagMode>("any");
174
+ const [tagsInput, setTagsInput] = useState("");
175
+
144
176
  const messagesEndRef = useRef<HTMLDivElement>(null);
145
177
  const textareaRef = useRef<HTMLTextAreaElement>(null);
146
178
 
147
- // Fetch capabilities on mount
179
+ const hybridAvailable = capabilities?.hybrid ?? false;
180
+
148
181
  useEffect(() => {
149
- async function fetchCapabilities() {
150
- const { data } = await apiFetch<Capabilities>("/api/capabilities");
151
- if (data) {
152
- setCapabilities(data);
153
- // Auto-select balanced if hybrid available, otherwise fast
154
- if (data.hybrid) {
155
- setThoroughness("balanced");
156
- } else {
157
- setThoroughness("fast");
158
- }
182
+ async function bootstrap(): Promise<void> {
183
+ const [capsResult, collectionsResult] = await Promise.all([
184
+ apiFetch<Capabilities>("/api/capabilities"),
185
+ apiFetch<Collection[]>("/api/collections"),
186
+ ]);
187
+
188
+ if (capsResult.data) {
189
+ const caps = capsResult.data;
190
+ setCapabilities(caps);
191
+ setThoroughness(caps.hybrid ? "balanced" : "fast");
192
+ }
193
+
194
+ if (collectionsResult.data) {
195
+ setCollections(collectionsResult.data);
159
196
  }
160
197
  }
161
- void fetchCapabilities();
198
+
199
+ void bootstrap();
162
200
  }, []);
163
201
 
164
- // Cycle thoroughness with 't' key
165
202
  const cycleThoroughness = useCallback(() => {
166
203
  setThoroughness((current) => {
204
+ if (!hybridAvailable) {
205
+ return "fast";
206
+ }
167
207
  const currentIdx = THOROUGHNESS_ORDER.indexOf(current);
168
208
  const nextIdx = (currentIdx + 1) % THOROUGHNESS_ORDER.length;
169
209
  return THOROUGHNESS_ORDER[nextIdx];
170
210
  });
171
- }, []);
211
+ }, [hybridAvailable]);
172
212
 
173
213
  const shortcuts = useMemo(
174
214
  () => [{ key: "t", action: cycleThoroughness }],
175
215
  [cycleThoroughness]
176
216
  );
177
-
178
217
  useKeyboardShortcuts(shortcuts);
179
218
 
180
- // Scroll to bottom when conversation updates
181
219
  useEffect(() => {
182
220
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
183
- }, []);
221
+ }, [conversation]);
184
222
 
185
- const handleSubmit = async (e: React.FormEvent) => {
186
- e.preventDefault();
187
- if (!query.trim()) {
188
- return;
189
- }
223
+ const handleSubmit = useCallback(
224
+ async (e: React.FormEvent) => {
225
+ e.preventDefault();
226
+ if (!query.trim()) {
227
+ return;
228
+ }
190
229
 
191
- const entryId = crypto.randomUUID();
192
- const currentQuery = query.trim();
193
-
194
- // Add entry to conversation
195
- setConversation((prev) => [
196
- ...prev,
197
- { id: entryId, query: currentQuery, response: null, loading: true },
198
- ]);
199
- setQuery("");
200
-
201
- // Build request body with thoroughness-mapped params
202
- // fast: BM25-only via noExpand + noRerank
203
- // balanced: with reranking, no expansion
204
- // thorough: full pipeline
205
- const requestBody: Record<string, unknown> = {
206
- query: currentQuery,
207
- limit: 5,
208
- };
209
-
210
- if (thoroughness === "fast") {
211
- requestBody.noExpand = true;
212
- requestBody.noRerank = true;
213
- } else if (thoroughness === "balanced") {
214
- requestBody.noExpand = true;
215
- requestBody.noRerank = false;
216
- } else {
217
- // thorough - full pipeline
218
- requestBody.noExpand = false;
219
- requestBody.noRerank = false;
220
- }
230
+ const entryId = crypto.randomUUID();
231
+ const currentQuery = query.trim();
221
232
 
222
- // Make API call
223
- const { data, error } = await apiFetch<AskResponse>("/api/ask", {
224
- method: "POST",
225
- body: JSON.stringify(requestBody),
226
- });
233
+ setConversation((prev) => [
234
+ ...prev,
235
+ { id: entryId, query: currentQuery, response: null, loading: true },
236
+ ]);
237
+ setQuery("");
227
238
 
228
- // Update conversation with response
229
- setConversation((prev) =>
230
- prev.map((entry) =>
231
- entry.id === entryId
232
- ? {
233
- ...entry,
234
- response: data ?? null,
235
- loading: false,
236
- error: error ?? undefined,
237
- }
238
- : entry
239
- )
240
- );
241
- };
239
+ const requestBody: Record<string, unknown> = {
240
+ query: currentQuery,
241
+ limit: 5,
242
+ };
243
+
244
+ if (selectedCollection) {
245
+ requestBody.collection = selectedCollection;
246
+ }
247
+ if (intent.trim()) {
248
+ requestBody.intent = intent.trim();
249
+ }
250
+ if (candidateLimit.trim()) {
251
+ requestBody.candidateLimit = Number(candidateLimit);
252
+ }
253
+ if (since) {
254
+ requestBody.since = since;
255
+ }
256
+ if (until) {
257
+ requestBody.until = until;
258
+ }
259
+ if (category.trim()) {
260
+ requestBody.category = category.trim();
261
+ }
262
+ if (author.trim()) {
263
+ requestBody.author = author.trim();
264
+ }
265
+
266
+ const normalizedTags = parseTagsCsv(tagsInput);
267
+ if (normalizedTags.length > 0) {
268
+ if (tagMode === "all") {
269
+ requestBody.tagsAll = normalizedTags.join(",");
270
+ } else {
271
+ requestBody.tagsAny = normalizedTags.join(",");
272
+ }
273
+ }
274
+
275
+ if (thoroughness === "fast") {
276
+ requestBody.noExpand = true;
277
+ requestBody.noRerank = true;
278
+ } else if (thoroughness === "balanced") {
279
+ requestBody.noExpand = true;
280
+ requestBody.noRerank = false;
281
+ } else {
282
+ requestBody.noExpand = false;
283
+ requestBody.noRerank = false;
284
+ }
285
+
286
+ const { data, error } = await apiFetch<AskResponse>("/api/ask", {
287
+ method: "POST",
288
+ body: JSON.stringify(requestBody),
289
+ });
290
+
291
+ setConversation((prev) =>
292
+ prev.map((entry) =>
293
+ entry.id === entryId
294
+ ? {
295
+ ...entry,
296
+ response: data ?? null,
297
+ loading: false,
298
+ error: error ?? undefined,
299
+ }
300
+ : entry
301
+ )
302
+ );
303
+ },
304
+ [
305
+ author,
306
+ candidateLimit,
307
+ category,
308
+ intent,
309
+ query,
310
+ selectedCollection,
311
+ since,
312
+ tagMode,
313
+ tagsInput,
314
+ thoroughness,
315
+ until,
316
+ ]
317
+ );
242
318
 
243
319
  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
244
320
  if (e.key === "Enter" && !e.shiftKey) {
@@ -247,11 +323,35 @@ export default function Ask({ navigate }: PageProps) {
247
323
  }
248
324
  };
249
325
 
326
+ const clearFilters = () => {
327
+ setSelectedCollection("");
328
+ setIntent("");
329
+ setCandidateLimit("");
330
+ setSince("");
331
+ setUntil("");
332
+ setCategory("");
333
+ setAuthor("");
334
+ setTagsInput("");
335
+ setTagMode("any");
336
+ };
337
+
250
338
  const answerAvailable = capabilities?.answer ?? false;
251
339
 
340
+ const activeFilterPills = [
341
+ selectedCollection ? `collection:${selectedCollection}` : null,
342
+ intent.trim() ? `intent:${intent.trim()}` : null,
343
+ candidateLimit.trim() ? `candidates:${candidateLimit.trim()}` : null,
344
+ since ? `since:${since}` : null,
345
+ until ? `until:${until}` : null,
346
+ category.trim() ? `category:${category.trim()}` : null,
347
+ author.trim() ? `author:${author.trim()}` : null,
348
+ parseTagsCsv(tagsInput).length > 0
349
+ ? `${tagMode}:${parseTagsCsv(tagsInput).join(",")}`
350
+ : null,
351
+ ].filter((pill): pill is string => Boolean(pill));
352
+
252
353
  return (
253
- <div className="flex min-h-screen flex-col">
254
- {/* Header */}
354
+ <div className="flex h-dvh min-h-0 flex-col overflow-hidden">
255
355
  <header className="glass sticky top-0 z-10 border-border/50 border-b">
256
356
  <div className="flex items-center gap-4 px-8 py-4">
257
357
  <Button
@@ -265,20 +365,16 @@ export default function Ask({ navigate }: PageProps) {
265
365
  </Button>
266
366
  <h1 className="font-semibold text-xl">Ask</h1>
267
367
  <div className="ml-auto flex items-center gap-4">
268
- {/* Search depth selector */}
269
368
  <ThoroughnessSelector
270
369
  disabled={!capabilities?.hybrid}
271
370
  onChange={setThoroughness}
272
371
  value={thoroughness}
273
372
  />
274
373
 
275
- {/* Divider */}
276
374
  <div className="h-6 w-px bg-border/40" />
277
375
 
278
- {/* AI model selector */}
279
376
  <AIModelSelector />
280
377
 
281
- {/* Capability badges */}
282
378
  {capabilities && (
283
379
  <div className="flex items-center gap-2">
284
380
  {capabilities.vector && (
@@ -297,12 +393,193 @@ export default function Ask({ navigate }: PageProps) {
297
393
  </div>
298
394
  </header>
299
395
 
300
- {/* Conversation area */}
301
- <main className="flex-1 overflow-y-auto p-8">
302
- <div className="mx-auto max-w-3xl space-y-8">
303
- {/* Empty state */}
396
+ <main className="min-h-0 flex-1 overflow-y-auto px-6 py-6 md:p-8">
397
+ <div className="mx-auto max-w-3xl space-y-5">
398
+ <Collapsible onOpenChange={setShowAdvanced} open={showAdvanced}>
399
+ <CollapsibleTrigger asChild>
400
+ <Button
401
+ className="gap-2"
402
+ size="sm"
403
+ type="button"
404
+ variant="outline"
405
+ >
406
+ <SlidersHorizontal className="size-4" />
407
+ Advanced Retrieval
408
+ <ChevronDown
409
+ className={cn(
410
+ "size-4 transition-transform",
411
+ showAdvanced && "rotate-180"
412
+ )}
413
+ />
414
+ </Button>
415
+ </CollapsibleTrigger>
416
+ <CollapsibleContent className="pt-3">
417
+ <Card className="border-border/50 bg-card/50">
418
+ <CardContent className="space-y-4 pt-4">
419
+ <div className="grid gap-3 md:grid-cols-2">
420
+ <div>
421
+ <p className="mb-1 text-muted-foreground text-xs">
422
+ Collection
423
+ </p>
424
+ <Select
425
+ onValueChange={(value) =>
426
+ setSelectedCollection(value === "all" ? "" : value)
427
+ }
428
+ value={selectedCollection || "all"}
429
+ >
430
+ <SelectTrigger className="w-full">
431
+ <SelectValue placeholder="All collections" />
432
+ </SelectTrigger>
433
+ <SelectContent>
434
+ <SelectItem value="all">All collections</SelectItem>
435
+ {collections.map((collection) => (
436
+ <SelectItem
437
+ key={collection.name}
438
+ value={collection.name}
439
+ >
440
+ {collection.name}
441
+ </SelectItem>
442
+ ))}
443
+ </SelectContent>
444
+ </Select>
445
+ </div>
446
+
447
+ <div>
448
+ <p className="mb-1 text-muted-foreground text-xs">
449
+ Author
450
+ </p>
451
+ <Input
452
+ onChange={(e) => setAuthor(e.target.value)}
453
+ placeholder="gordon"
454
+ value={author}
455
+ />
456
+ </div>
457
+
458
+ <div className="md:col-span-2">
459
+ <p className="mb-1 text-muted-foreground text-xs">
460
+ Intent
461
+ </p>
462
+ <Input
463
+ onChange={(e) => setIntent(e.target.value)}
464
+ placeholder="Disambiguate ambiguous questions without searching on this text"
465
+ value={intent}
466
+ />
467
+ </div>
468
+
469
+ <div>
470
+ <p className="mb-1 text-muted-foreground text-xs">
471
+ Category
472
+ </p>
473
+ <Input
474
+ onChange={(e) => setCategory(e.target.value)}
475
+ placeholder="engineering, research"
476
+ value={category}
477
+ />
478
+ </div>
479
+
480
+ <div className="grid gap-3 sm:grid-cols-2">
481
+ <div>
482
+ <p className="mb-1 text-muted-foreground text-xs">
483
+ Since
484
+ </p>
485
+ <Input
486
+ onChange={(e) => setSince(e.target.value)}
487
+ type="date"
488
+ value={since}
489
+ />
490
+ </div>
491
+ <div>
492
+ <p className="mb-1 text-muted-foreground text-xs">
493
+ Until
494
+ </p>
495
+ <Input
496
+ onChange={(e) => setUntil(e.target.value)}
497
+ type="date"
498
+ value={until}
499
+ />
500
+ </div>
501
+ </div>
502
+
503
+ <div>
504
+ <p className="mb-1 text-muted-foreground text-xs">
505
+ Candidate limit
506
+ </p>
507
+ <Input
508
+ inputMode="numeric"
509
+ min="1"
510
+ onChange={(e) => setCandidateLimit(e.target.value)}
511
+ placeholder="20"
512
+ type="number"
513
+ value={candidateLimit}
514
+ />
515
+ </div>
516
+
517
+ <div className="md:col-span-2">
518
+ <p className="mb-1 text-muted-foreground text-xs">
519
+ Tags (comma separated)
520
+ </p>
521
+ <Input
522
+ onChange={(e) => setTagsInput(e.target.value)}
523
+ placeholder="project/alpha, urgent"
524
+ value={tagsInput}
525
+ />
526
+ </div>
527
+ </div>
528
+
529
+ <div className="flex flex-wrap items-center gap-2">
530
+ <span className="text-muted-foreground text-xs">
531
+ Tag match:
532
+ </span>
533
+ <Button
534
+ onClick={() => setTagMode("any")}
535
+ size="sm"
536
+ type="button"
537
+ variant={tagMode === "any" ? "default" : "outline"}
538
+ >
539
+ Any
540
+ </Button>
541
+ <Button
542
+ onClick={() => setTagMode("all")}
543
+ size="sm"
544
+ type="button"
545
+ variant={tagMode === "all" ? "default" : "outline"}
546
+ >
547
+ All
548
+ </Button>
549
+ <Button
550
+ className="ml-auto"
551
+ onClick={clearFilters}
552
+ size="sm"
553
+ type="button"
554
+ variant="ghost"
555
+ >
556
+ Clear filters
557
+ </Button>
558
+ </div>
559
+ </CardContent>
560
+ </Card>
561
+ </CollapsibleContent>
562
+ </Collapsible>
563
+
564
+ {activeFilterPills.length > 0 && (
565
+ <div className="flex flex-wrap items-center gap-2">
566
+ <span className="font-mono text-muted-foreground text-xs">
567
+ Filters:
568
+ </span>
569
+ {activeFilterPills.map((pill) => (
570
+ <Badge
571
+ className="font-mono text-[10px]"
572
+ key={pill}
573
+ variant="outline"
574
+ >
575
+ {pill}
576
+ </Badge>
577
+ ))}
578
+ </div>
579
+ )}
580
+
304
581
  {conversation.length === 0 && (
305
- <div className="py-20 text-center">
582
+ <div className="py-10 text-center md:py-14">
306
583
  <Sparkles className="mx-auto mb-4 size-12 text-primary/60" />
307
584
  <h2 className="mb-2 font-medium text-lg">Ask anything</h2>
308
585
  <p className="text-muted-foreground">
@@ -313,17 +590,14 @@ export default function Ask({ navigate }: PageProps) {
313
590
  </div>
314
591
  )}
315
592
 
316
- {/* Conversation entries */}
317
593
  {conversation.map((entry) => (
318
594
  <div className="space-y-4" key={entry.id}>
319
- {/* User query */}
320
595
  <div className="flex justify-end">
321
596
  <div className="max-w-[80%] rounded-lg bg-secondary px-4 py-3">
322
597
  <p className="text-foreground">{entry.query}</p>
323
598
  </div>
324
599
  </div>
325
600
 
326
- {/* AI response */}
327
601
  <div className="space-y-3">
328
602
  {entry.loading && (
329
603
  <div className="flex items-center gap-3">
@@ -344,7 +618,6 @@ export default function Ask({ navigate }: PageProps) {
344
618
 
345
619
  {entry.response && (
346
620
  <>
347
- {/* Answer */}
348
621
  {entry.response.answer && (
349
622
  <div className="prose prose-sm prose-invert max-w-none rounded-lg bg-card/50 p-4">
350
623
  <p className="whitespace-pre-wrap leading-relaxed">
@@ -357,7 +630,6 @@ export default function Ask({ navigate }: PageProps) {
357
630
  </div>
358
631
  )}
359
632
 
360
- {/* Citations */}
361
633
  {entry.response.citations &&
362
634
  entry.response.citations.length > 0 && (
363
635
  <Sources defaultOpen>
@@ -365,26 +637,26 @@ export default function Ask({ navigate }: PageProps) {
365
637
  count={entry.response.citations.length}
366
638
  />
367
639
  <SourcesContent>
368
- {entry.response.citations.map((c, i) => (
640
+ {entry.response.citations.map((citation, i) => (
369
641
  <Source
370
642
  href="#"
371
- key={`${c.docid}-${i}`}
372
- onClick={(e) => {
373
- e.preventDefault();
643
+ key={`${citation.docid}-${i}`}
644
+ onClick={(event) => {
645
+ event.preventDefault();
374
646
  navigate(
375
- `/doc?uri=${encodeURIComponent(c.uri)}`
647
+ `/doc?uri=${encodeURIComponent(citation.uri)}`
376
648
  );
377
649
  }}
378
- title={`[${i + 1}] ${c.uri.split("/").pop()}`}
650
+ title={`[${i + 1}] ${citation.uri.split("/").pop()}`}
379
651
  >
380
652
  <BookOpen className="size-4 shrink-0" />
381
653
  <span className="truncate">
382
- [{i + 1}] {c.uri.split("/").pop()}
654
+ [{i + 1}] {citation.uri.split("/").pop()}
383
655
  </span>
384
- {c.startLine && (
656
+ {citation.startLine && (
385
657
  <span className="ml-1 font-mono text-[10px] text-muted-foreground/60">
386
- L{c.startLine}
387
- {c.endLine && `-${c.endLine}`}
658
+ L{citation.startLine}
659
+ {citation.endLine && `-${citation.endLine}`}
388
660
  </span>
389
661
  )}
390
662
  </Source>
@@ -393,7 +665,6 @@ export default function Ask({ navigate }: PageProps) {
393
665
  </Sources>
394
666
  )}
395
667
 
396
- {/* Meta info */}
397
668
  <div className="flex items-center gap-2 text-muted-foreground/60 text-xs">
398
669
  <span>{entry.response.results.length} results</span>
399
670
  {entry.response.meta.vectorsUsed && (
@@ -414,20 +685,19 @@ export default function Ask({ navigate }: PageProps) {
414
685
  )}
415
686
  </div>
416
687
 
417
- {/* Show retrieved sources if no answer */}
418
688
  {!entry.response.answer &&
419
689
  entry.response.results.length > 0 && (
420
690
  <div className="space-y-2">
421
691
  <p className="font-medium text-muted-foreground text-sm">
422
692
  Search results:
423
693
  </p>
424
- {entry.response.results.map((r, i) => (
694
+ {entry.response.results.map((result, i) => (
425
695
  <Card
426
696
  className="cursor-pointer transition-colors hover:border-primary/50"
427
- key={`${r.docid}-${i}`}
697
+ key={`${result.docid}-${i}`}
428
698
  onClick={() =>
429
699
  navigate(
430
- `/doc?uri=${encodeURIComponent(r.uri)}`
700
+ `/doc?uri=${encodeURIComponent(result.uri)}`
431
701
  )
432
702
  }
433
703
  >
@@ -435,17 +705,18 @@ export default function Ask({ navigate }: PageProps) {
435
705
  <div className="flex items-start justify-between gap-2">
436
706
  <div className="min-w-0">
437
707
  <p className="font-medium text-primary text-sm">
438
- {r.title || r.uri.split("/").pop()}
708
+ {result.title ||
709
+ result.uri.split("/").pop()}
439
710
  </p>
440
711
  <p className="line-clamp-2 text-muted-foreground text-xs">
441
- {r.snippet.slice(0, 200)}...
712
+ {result.snippet.slice(0, 200)}...
442
713
  </p>
443
714
  </div>
444
715
  <Badge
445
716
  className="shrink-0 font-mono text-[10px]"
446
717
  variant="secondary"
447
718
  >
448
- {(r.score * 100).toFixed(0)}%
719
+ {(result.score * 100).toFixed(0)}%
449
720
  </Badge>
450
721
  </div>
451
722
  </CardContent>
@@ -454,7 +725,6 @@ export default function Ask({ navigate }: PageProps) {
454
725
  </div>
455
726
  )}
456
727
 
457
- {/* No results */}
458
728
  {!entry.response.answer &&
459
729
  entry.response.results.length === 0 && (
460
730
  <div className="flex items-center gap-2 text-muted-foreground">
@@ -474,7 +744,6 @@ export default function Ask({ navigate }: PageProps) {
474
744
  </div>
475
745
  </main>
476
746
 
477
- {/* Input area */}
478
747
  <footer className="glass sticky bottom-0 border-border/50 border-t">
479
748
  <form
480
749
  className="mx-auto flex max-w-3xl items-end gap-3 p-4"