@gmickel/gno 0.17.0 → 0.19.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.
@@ -6,6 +6,7 @@ import {
6
6
  FileText,
7
7
  SlidersHorizontal,
8
8
  Sparkles,
9
+ XIcon,
9
10
  } from "lucide-react";
10
11
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11
12
 
@@ -40,7 +41,12 @@ import {
40
41
  import { Textarea } from "../components/ui/textarea";
41
42
  import { apiFetch } from "../hooks/use-api";
42
43
  import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts";
43
- import { parseTagsCsv, type TagMode } from "../lib/retrieval-filters";
44
+ import {
45
+ parseTagsCsv,
46
+ type QueryModeEntry,
47
+ type QueryModeType,
48
+ type TagMode,
49
+ } from "../lib/retrieval-filters";
44
50
  import { cn } from "../lib/utils";
45
51
 
46
52
  interface PageProps {
@@ -79,6 +85,11 @@ interface AskResponse {
79
85
  vectorsUsed: boolean;
80
86
  answerGenerated: boolean;
81
87
  totalResults: number;
88
+ queryModes?: {
89
+ term: number;
90
+ intent: number;
91
+ hyde: boolean;
92
+ };
82
93
  };
83
94
  }
84
95
 
@@ -103,6 +114,12 @@ interface Collection {
103
114
 
104
115
  const THOROUGHNESS_ORDER: Thoroughness[] = ["fast", "balanced", "thorough"];
105
116
 
117
+ const QUERY_MODE_LABEL: Record<QueryModeType, string> = {
118
+ term: "Term",
119
+ intent: "Intent",
120
+ hyde: "HyDE",
121
+ };
122
+
106
123
  /**
107
124
  * Render answer text with clickable citation badges.
108
125
  * Citations like [1] become clickable to navigate to source.
@@ -164,12 +181,19 @@ export default function Ask({ navigate }: PageProps) {
164
181
 
165
182
  const [showAdvanced, setShowAdvanced] = useState(false);
166
183
  const [selectedCollection, setSelectedCollection] = useState("");
184
+ const [intent, setIntent] = useState("");
185
+ const [candidateLimit, setCandidateLimit] = useState("");
186
+ const [exclude, setExclude] = useState("");
167
187
  const [since, setSince] = useState("");
168
188
  const [until, setUntil] = useState("");
169
189
  const [category, setCategory] = useState("");
170
190
  const [author, setAuthor] = useState("");
171
191
  const [tagMode, setTagMode] = useState<TagMode>("any");
172
192
  const [tagsInput, setTagsInput] = useState("");
193
+ const [queryModes, setQueryModes] = useState<QueryModeEntry[]>([]);
194
+ const [queryModeDraft, setQueryModeDraft] = useState<QueryModeType>("term");
195
+ const [queryModeText, setQueryModeText] = useState("");
196
+ const [queryModeError, setQueryModeError] = useState<string | null>(null);
173
197
 
174
198
  const messagesEndRef = useRef<HTMLDivElement>(null);
175
199
  const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -218,6 +242,28 @@ export default function Ask({ navigate }: PageProps) {
218
242
  messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
219
243
  }, [conversation]);
220
244
 
245
+ const handleAddQueryMode = useCallback(() => {
246
+ const text = queryModeText.trim();
247
+ if (!text) {
248
+ return;
249
+ }
250
+ if (
251
+ queryModeDraft === "hyde" &&
252
+ queryModes.some((queryMode) => queryMode.mode === "hyde")
253
+ ) {
254
+ setQueryModeError("Only one HyDE mode is allowed.");
255
+ return;
256
+ }
257
+ setQueryModes((prev) => [...prev, { mode: queryModeDraft, text }]);
258
+ setQueryModeText("");
259
+ setQueryModeError(null);
260
+ }, [queryModeDraft, queryModeText, queryModes]);
261
+
262
+ const handleRemoveQueryMode = useCallback((index: number) => {
263
+ setQueryModes((prev) => prev.filter((_, i) => i !== index));
264
+ setQueryModeError(null);
265
+ }, []);
266
+
221
267
  const handleSubmit = useCallback(
222
268
  async (e: React.FormEvent) => {
223
269
  e.preventDefault();
@@ -242,6 +288,15 @@ export default function Ask({ navigate }: PageProps) {
242
288
  if (selectedCollection) {
243
289
  requestBody.collection = selectedCollection;
244
290
  }
291
+ if (intent.trim()) {
292
+ requestBody.intent = intent.trim();
293
+ }
294
+ if (candidateLimit.trim()) {
295
+ requestBody.candidateLimit = Number(candidateLimit);
296
+ }
297
+ if (exclude.trim()) {
298
+ requestBody.exclude = exclude.trim();
299
+ }
245
300
  if (since) {
246
301
  requestBody.since = since;
247
302
  }
@@ -274,6 +329,9 @@ export default function Ask({ navigate }: PageProps) {
274
329
  requestBody.noExpand = false;
275
330
  requestBody.noRerank = false;
276
331
  }
332
+ if (queryModes.length > 0) {
333
+ requestBody.queryModes = queryModes;
334
+ }
277
335
 
278
336
  const { data, error } = await apiFetch<AskResponse>("/api/ask", {
279
337
  method: "POST",
@@ -295,8 +353,12 @@ export default function Ask({ navigate }: PageProps) {
295
353
  },
296
354
  [
297
355
  author,
356
+ candidateLimit,
298
357
  category,
358
+ exclude,
359
+ intent,
299
360
  query,
361
+ queryModes,
300
362
  selectedCollection,
301
363
  since,
302
364
  tagMode,
@@ -315,18 +377,28 @@ export default function Ask({ navigate }: PageProps) {
315
377
 
316
378
  const clearFilters = () => {
317
379
  setSelectedCollection("");
380
+ setIntent("");
381
+ setCandidateLimit("");
382
+ setExclude("");
318
383
  setSince("");
319
384
  setUntil("");
320
385
  setCategory("");
321
386
  setAuthor("");
322
387
  setTagsInput("");
323
388
  setTagMode("any");
389
+ setQueryModes([]);
390
+ setQueryModeText("");
391
+ setQueryModeError(null);
324
392
  };
325
393
 
326
394
  const answerAvailable = capabilities?.answer ?? false;
327
395
 
328
396
  const activeFilterPills = [
329
397
  selectedCollection ? `collection:${selectedCollection}` : null,
398
+ intent.trim() ? `intent:${intent.trim()}` : null,
399
+ candidateLimit.trim() ? `candidates:${candidateLimit.trim()}` : null,
400
+ exclude.trim() ? `exclude:${exclude.trim()}` : null,
401
+ queryModes.length > 0 ? `${queryModes.length} query mode(s)` : null,
330
402
  since ? `since:${since}` : null,
331
403
  until ? `until:${until}` : null,
332
404
  category.trim() ? `category:${category.trim()}` : null,
@@ -441,6 +513,28 @@ export default function Ask({ navigate }: PageProps) {
441
513
  />
442
514
  </div>
443
515
 
516
+ <div className="md:col-span-2">
517
+ <p className="mb-1 text-muted-foreground text-xs">
518
+ Intent
519
+ </p>
520
+ <Input
521
+ onChange={(e) => setIntent(e.target.value)}
522
+ placeholder="Disambiguate ambiguous questions without searching on this text"
523
+ value={intent}
524
+ />
525
+ </div>
526
+
527
+ <div className="md:col-span-2">
528
+ <p className="mb-1 text-muted-foreground text-xs">
529
+ Exclude
530
+ </p>
531
+ <Input
532
+ onChange={(e) => setExclude(e.target.value)}
533
+ placeholder="team reviews, hiring, onboarding"
534
+ value={exclude}
535
+ />
536
+ </div>
537
+
444
538
  <div>
445
539
  <p className="mb-1 text-muted-foreground text-xs">
446
540
  Category
@@ -475,6 +569,20 @@ export default function Ask({ navigate }: PageProps) {
475
569
  </div>
476
570
  </div>
477
571
 
572
+ <div>
573
+ <p className="mb-1 text-muted-foreground text-xs">
574
+ Candidate limit
575
+ </p>
576
+ <Input
577
+ inputMode="numeric"
578
+ min="1"
579
+ onChange={(e) => setCandidateLimit(e.target.value)}
580
+ placeholder="20"
581
+ type="number"
582
+ value={candidateLimit}
583
+ />
584
+ </div>
585
+
478
586
  <div className="md:col-span-2">
479
587
  <p className="mb-1 text-muted-foreground text-xs">
480
588
  Tags (comma separated)
@@ -517,6 +625,75 @@ export default function Ask({ navigate }: PageProps) {
517
625
  Clear filters
518
626
  </Button>
519
627
  </div>
628
+
629
+ <div className="space-y-2">
630
+ <p className="text-muted-foreground text-xs">
631
+ Query modes (term, intent, hyde)
632
+ </p>
633
+ <div className="flex flex-wrap items-center gap-2">
634
+ <Select
635
+ onValueChange={(value) =>
636
+ setQueryModeDraft(value as QueryModeType)
637
+ }
638
+ value={queryModeDraft}
639
+ >
640
+ <SelectTrigger className="w-[120px]">
641
+ <SelectValue />
642
+ </SelectTrigger>
643
+ <SelectContent>
644
+ <SelectItem value="term">Term</SelectItem>
645
+ <SelectItem value="intent">Intent</SelectItem>
646
+ <SelectItem value="hyde">HyDE</SelectItem>
647
+ </SelectContent>
648
+ </Select>
649
+ <Input
650
+ className="min-w-[220px] flex-1"
651
+ onChange={(e) => setQueryModeText(e.target.value)}
652
+ onKeyDown={(e) => {
653
+ if (e.key === "Enter") {
654
+ e.preventDefault();
655
+ handleAddQueryMode();
656
+ }
657
+ }}
658
+ placeholder="Add query mode text"
659
+ value={queryModeText}
660
+ />
661
+ <Button
662
+ onClick={handleAddQueryMode}
663
+ size="sm"
664
+ type="button"
665
+ variant="outline"
666
+ >
667
+ Add mode
668
+ </Button>
669
+ </div>
670
+
671
+ {queryModeError && (
672
+ <p className="text-destructive text-xs">
673
+ {queryModeError}
674
+ </p>
675
+ )}
676
+
677
+ {queryModes.length > 0 && (
678
+ <div className="flex flex-wrap gap-2">
679
+ {queryModes.map((queryMode, index) => (
680
+ <button
681
+ className={cn(
682
+ "group inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10",
683
+ "px-2.5 py-1 font-mono text-[11px] text-primary transition-all duration-150",
684
+ "hover:border-primary/50 hover:bg-primary/20"
685
+ )}
686
+ key={`${queryMode.mode}:${queryMode.text}:${index}`}
687
+ onClick={() => handleRemoveQueryMode(index)}
688
+ type="button"
689
+ >
690
+ <span>{`${QUERY_MODE_LABEL[queryMode.mode]}: ${queryMode.text}`}</span>
691
+ <XIcon className="size-3 opacity-60 transition-opacity group-hover:opacity-100" />
692
+ </button>
693
+ ))}
694
+ </div>
695
+ )}
696
+ </div>
520
697
  </CardContent>
521
698
  </Card>
522
699
  </CollapsibleContent>
@@ -644,6 +821,17 @@ export default function Ask({ navigate }: PageProps) {
644
821
  expanded
645
822
  </Badge>
646
823
  )}
824
+ {entry.response.meta.queryModes &&
825
+ (entry.response.meta.queryModes.term > 0 ||
826
+ entry.response.meta.queryModes.intent > 0 ||
827
+ entry.response.meta.queryModes.hyde) && (
828
+ <Badge
829
+ className="font-mono text-[9px]"
830
+ variant="outline"
831
+ >
832
+ query modes
833
+ </Badge>
834
+ )}
647
835
  </div>
648
836
 
649
837
  {!entry.response.answer &&
@@ -153,6 +153,9 @@ export default function Search({ navigate }: PageProps) {
153
153
  const [showAdvanced, setShowAdvanced] = useState(
154
154
  Boolean(
155
155
  initialFilters.collection ||
156
+ initialFilters.intent ||
157
+ initialFilters.candidateLimit ||
158
+ initialFilters.exclude ||
156
159
  initialFilters.since ||
157
160
  initialFilters.until ||
158
161
  initialFilters.category ||
@@ -166,6 +169,11 @@ export default function Search({ navigate }: PageProps) {
166
169
  const [selectedCollection, setSelectedCollection] = useState(
167
170
  initialFilters.collection
168
171
  );
172
+ const [intent, setIntent] = useState(initialFilters.intent);
173
+ const [candidateLimit, setCandidateLimit] = useState(
174
+ initialFilters.candidateLimit
175
+ );
176
+ const [exclude, setExclude] = useState(initialFilters.exclude);
169
177
  const [since, setSince] = useState(initialFilters.since);
170
178
  const [until, setUntil] = useState(initialFilters.until);
171
179
  const [category, setCategory] = useState(initialFilters.category);
@@ -179,13 +187,18 @@ export default function Search({ navigate }: PageProps) {
179
187
  const [showMobileTags, setShowMobileTags] = useState(false);
180
188
 
181
189
  const hybridAvailable = capabilities?.hybrid ?? false;
182
- const forceHybridForModes = thoroughness === "fast" && queryModes.length > 0;
190
+ const forceHybridForModes =
191
+ thoroughness === "fast" &&
192
+ (queryModes.length > 0 || intent.trim().length > 0);
183
193
 
184
194
  // Sync URL as filter state changes.
185
195
  useEffect(() => {
186
196
  const url = new URL(window.location.href);
187
197
  applyFiltersToUrl(url, {
188
198
  collection: selectedCollection,
199
+ intent,
200
+ candidateLimit,
201
+ exclude,
189
202
  since,
190
203
  until,
191
204
  category,
@@ -198,7 +211,10 @@ export default function Search({ navigate }: PageProps) {
198
211
  }, [
199
212
  activeTags,
200
213
  author,
214
+ candidateLimit,
201
215
  category,
216
+ exclude,
217
+ intent,
202
218
  queryModes,
203
219
  selectedCollection,
204
220
  since,
@@ -283,7 +299,10 @@ export default function Search({ navigate }: PageProps) {
283
299
  setError(null);
284
300
  setSearched(true);
285
301
 
286
- const useBm25 = thoroughness === "fast" && queryModes.length === 0;
302
+ const useBm25 =
303
+ thoroughness === "fast" &&
304
+ queryModes.length === 0 &&
305
+ intent.trim().length === 0;
287
306
  const endpoint = useBm25 ? "/api/search" : "/api/query";
288
307
  const body: Record<string, unknown> = {
289
308
  query,
@@ -293,6 +312,15 @@ export default function Search({ navigate }: PageProps) {
293
312
  if (selectedCollection) {
294
313
  body.collection = selectedCollection;
295
314
  }
315
+ if (intent.trim()) {
316
+ body.intent = intent.trim();
317
+ }
318
+ if (candidateLimit.trim()) {
319
+ body.candidateLimit = Number(candidateLimit);
320
+ }
321
+ if (exclude.trim()) {
322
+ body.exclude = exclude.trim();
323
+ }
296
324
  if (since) {
297
325
  body.since = since;
298
326
  }
@@ -350,7 +378,10 @@ export default function Search({ navigate }: PageProps) {
350
378
  [
351
379
  activeTags,
352
380
  author,
381
+ candidateLimit,
353
382
  category,
383
+ exclude,
384
+ intent,
354
385
  query,
355
386
  queryModes,
356
387
  selectedCollection,
@@ -370,7 +401,10 @@ export default function Search({ navigate }: PageProps) {
370
401
  }, [
371
402
  activeTags,
372
403
  author,
404
+ candidateLimit,
373
405
  category,
406
+ exclude,
407
+ intent,
374
408
  queryModes,
375
409
  selectedCollection,
376
410
  since,
@@ -387,6 +421,9 @@ export default function Search({ navigate }: PageProps) {
387
421
 
388
422
  const activeFilterPills = [
389
423
  selectedCollection ? `collection:${selectedCollection}` : null,
424
+ intent.trim() ? `intent:${intent.trim()}` : null,
425
+ candidateLimit.trim() ? `candidates:${candidateLimit.trim()}` : null,
426
+ exclude.trim() ? `exclude:${exclude.trim()}` : null,
390
427
  since ? `since:${since}` : null,
391
428
  until ? `until:${until}` : null,
392
429
  category.trim() ? `category:${category.trim()}` : null,
@@ -399,6 +436,9 @@ export default function Search({ navigate }: PageProps) {
399
436
 
400
437
  const clearAdvancedFilters = () => {
401
438
  setSelectedCollection("");
439
+ setIntent("");
440
+ setCandidateLimit("");
441
+ setExclude("");
402
442
  setSince("");
403
443
  setUntil("");
404
444
  setCategory("");
@@ -551,6 +591,28 @@ export default function Search({ navigate }: PageProps) {
551
591
  />
552
592
  </div>
553
593
 
594
+ <div className="md:col-span-2">
595
+ <p className="mb-1 text-muted-foreground text-xs">
596
+ Intent
597
+ </p>
598
+ <Input
599
+ onChange={(e) => setIntent(e.target.value)}
600
+ placeholder="Disambiguate ambiguous queries without searching on this text"
601
+ value={intent}
602
+ />
603
+ </div>
604
+
605
+ <div className="md:col-span-2">
606
+ <p className="mb-1 text-muted-foreground text-xs">
607
+ Exclude
608
+ </p>
609
+ <Input
610
+ onChange={(e) => setExclude(e.target.value)}
611
+ placeholder="team reviews, hiring, onboarding"
612
+ value={exclude}
613
+ />
614
+ </div>
615
+
554
616
  <div>
555
617
  <p className="mb-1 text-muted-foreground text-xs">
556
618
  Category
@@ -584,6 +646,20 @@ export default function Search({ navigate }: PageProps) {
584
646
  />
585
647
  </div>
586
648
  </div>
649
+
650
+ <div>
651
+ <p className="mb-1 text-muted-foreground text-xs">
652
+ Candidate limit
653
+ </p>
654
+ <Input
655
+ inputMode="numeric"
656
+ min="1"
657
+ onChange={(e) => setCandidateLimit(e.target.value)}
658
+ placeholder="20"
659
+ type="number"
660
+ value={candidateLimit}
661
+ />
662
+ </div>
587
663
  </div>
588
664
 
589
665
  <div className="flex flex-wrap items-center gap-2">