@gmickel/gno 0.28.2 → 0.29.1

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 (52) hide show
  1. package/README.md +10 -2
  2. package/package.json +1 -1
  3. package/src/app/constants.ts +4 -2
  4. package/src/cli/commands/mcp/install.ts +4 -4
  5. package/src/cli/commands/mcp/status.ts +7 -7
  6. package/src/cli/commands/skill/install.ts +5 -5
  7. package/src/cli/program.ts +2 -2
  8. package/src/collection/add.ts +10 -0
  9. package/src/collection/types.ts +1 -0
  10. package/src/config/types.ts +12 -2
  11. package/src/core/depth-policy.ts +1 -1
  12. package/src/core/file-ops.ts +203 -1
  13. package/src/llm/registry.ts +20 -4
  14. package/src/serve/AGENTS.md +16 -16
  15. package/src/serve/CLAUDE.md +16 -16
  16. package/src/serve/config-sync.ts +32 -1
  17. package/src/serve/connectors.ts +243 -0
  18. package/src/serve/context.ts +9 -0
  19. package/src/serve/doc-events.ts +31 -1
  20. package/src/serve/embed-scheduler.ts +12 -0
  21. package/src/serve/import-preview.ts +173 -0
  22. package/src/serve/public/app.tsx +101 -7
  23. package/src/serve/public/components/AIModelSelector.tsx +383 -145
  24. package/src/serve/public/components/AddCollectionDialog.tsx +123 -7
  25. package/src/serve/public/components/BootstrapStatus.tsx +133 -0
  26. package/src/serve/public/components/CaptureModal.tsx +5 -2
  27. package/src/serve/public/components/CollectionsEmptyState.tsx +63 -0
  28. package/src/serve/public/components/FirstRunWizard.tsx +622 -0
  29. package/src/serve/public/components/HealthCenter.tsx +128 -0
  30. package/src/serve/public/components/IndexingProgress.tsx +21 -2
  31. package/src/serve/public/components/QuickSwitcher.tsx +62 -36
  32. package/src/serve/public/components/TagInput.tsx +5 -1
  33. package/src/serve/public/components/WikiLinkAutocomplete.tsx +15 -6
  34. package/src/serve/public/components/WorkspaceTabs.tsx +60 -0
  35. package/src/serve/public/hooks/use-doc-events.ts +48 -4
  36. package/src/serve/public/lib/local-history.ts +40 -7
  37. package/src/serve/public/lib/navigation-state.ts +156 -0
  38. package/src/serve/public/lib/workspace-tabs.ts +235 -0
  39. package/src/serve/public/pages/Ask.tsx +11 -1
  40. package/src/serve/public/pages/Browse.tsx +73 -0
  41. package/src/serve/public/pages/Collections.tsx +29 -13
  42. package/src/serve/public/pages/Connectors.tsx +178 -0
  43. package/src/serve/public/pages/Dashboard.tsx +493 -67
  44. package/src/serve/public/pages/DocView.tsx +192 -34
  45. package/src/serve/public/pages/DocumentEditor.tsx +127 -5
  46. package/src/serve/public/pages/Search.tsx +12 -1
  47. package/src/serve/routes/api.ts +541 -62
  48. package/src/serve/server.ts +79 -2
  49. package/src/serve/status-model.ts +149 -0
  50. package/src/serve/status.ts +706 -0
  51. package/src/serve/watch-service.ts +73 -8
  52. package/src/types/electrobun-shell.d.ts +43 -0
@@ -11,16 +11,22 @@
11
11
 
12
12
  import {
13
13
  AlertCircle,
14
+ BadgeCheck,
14
15
  Check,
15
16
  ChevronDown,
16
17
  Download,
17
18
  Loader2,
19
+ ScanSearch,
18
20
  Sparkles,
19
21
  } from "lucide-react";
20
22
  import { useCallback, useEffect, useRef, useState } from "react";
23
+ import { createPortal } from "react-dom";
24
+
25
+ import type { AppStatusResponse } from "../../status-model";
21
26
 
22
27
  import { apiFetch } from "../hooks/use-api";
23
28
  import { cn } from "../lib/utils";
29
+ import { Badge } from "./ui/badge";
24
30
 
25
31
  interface Preset {
26
32
  id: string;
@@ -66,12 +72,26 @@ interface DownloadStatus {
66
72
  startedAt: number | null;
67
73
  }
68
74
 
75
+ const PRESET_EXPLANATIONS: Record<string, string> = {
76
+ slim: "Fastest setup. Lowest disk use.",
77
+ balanced: "Better answers. Good default.",
78
+ quality: "Best local answers. Highest disk use.",
79
+ "slim-tuned": "Fine-tuned retrieval in a compact footprint.",
80
+ };
81
+ const BUILTIN_PRESET_IDS = new Set([
82
+ "slim-tuned",
83
+ "slim",
84
+ "balanced",
85
+ "quality",
86
+ ]);
87
+
69
88
  // Extract readable model name from preset name
70
89
  const SIZE_REGEX = /~[\d.]+GB/;
71
90
  const MODEL_URI_SEGMENT_RE = /\/([^/#]+?)(?:\.(?:gguf|bin|safetensors))?$/i;
72
91
 
73
92
  function extractBaseName(name: string): string {
74
- return name.split("(")[0].trim();
93
+ const [firstPart] = name.split("(");
94
+ return firstPart?.trim() ?? name.trim();
75
95
  }
76
96
 
77
97
  function extractSize(name: string): string | null {
@@ -107,16 +127,34 @@ function formatModelRole(uri: string | undefined): string {
107
127
 
108
128
  export interface AIModelSelectorProps {
109
129
  onPresetChange?: (presetId: string) => void;
130
+ showDetails?: boolean;
131
+ showDownloadAction?: boolean;
132
+ showLabel?: boolean;
133
+ }
134
+
135
+ function isCustomPreset(preset: Preset): boolean {
136
+ return !BUILTIN_PRESET_IDS.has(preset.id);
110
137
  }
111
138
 
112
- export function AIModelSelector({ onPresetChange }: AIModelSelectorProps = {}) {
139
+ export function AIModelSelector({
140
+ onPresetChange,
141
+ showDetails = false,
142
+ showDownloadAction = true,
143
+ showLabel = true,
144
+ }: AIModelSelectorProps = {}) {
113
145
  const [presets, setPresets] = useState<Preset[]>([]);
114
146
  const [activeId, setActiveId] = useState<string>("");
115
147
  const [loading, setLoading] = useState(true);
116
148
  const [switching, setSwitching] = useState(false);
117
149
  const [error, setError] = useState<string | null>(null);
118
150
  const [modelsNeeded, setModelsNeeded] = useState(false);
151
+ const [notice, setNotice] = useState<string | null>(null);
119
152
  const [open, setOpen] = useState(false);
153
+ const [menuPosition, setMenuPosition] = useState<{
154
+ left: number;
155
+ top: number;
156
+ width: number;
157
+ } | null>(null);
120
158
 
121
159
  // Download state
122
160
  const [downloading, setDownloading] = useState(false);
@@ -125,21 +163,57 @@ export function AIModelSelector({ onPresetChange }: AIModelSelectorProps = {}) {
125
163
  );
126
164
  const pollInterval = useRef<ReturnType<typeof setInterval> | null>(null);
127
165
  const containerRef = useRef<HTMLDivElement>(null);
166
+ const menuRef = useRef<HTMLDivElement>(null);
167
+ const triggerRef = useRef<HTMLButtonElement>(null);
128
168
 
129
169
  // Click outside to close
130
170
  useEffect(() => {
131
171
  function handleClickOutside(e: MouseEvent) {
172
+ const target = e.target as Node;
132
173
  if (
133
- containerRef.current &&
134
- !containerRef.current.contains(e.target as Node)
174
+ (containerRef.current && containerRef.current.contains(target)) ||
175
+ (menuRef.current && menuRef.current.contains(target))
135
176
  ) {
136
- setOpen(false);
177
+ return;
137
178
  }
179
+ setOpen(false);
138
180
  }
139
181
  document.addEventListener("mousedown", handleClickOutside);
140
182
  return () => document.removeEventListener("mousedown", handleClickOutside);
141
183
  }, []);
142
184
 
185
+ const updateMenuPosition = useCallback(() => {
186
+ if (!triggerRef.current || typeof window === "undefined") {
187
+ return;
188
+ }
189
+
190
+ const rect = triggerRef.current.getBoundingClientRect();
191
+ const width = Math.min(360, window.innerWidth - 32);
192
+ const left = Math.max(
193
+ 16,
194
+ Math.min(rect.left, window.innerWidth - width - 16)
195
+ );
196
+ const top = rect.bottom + 8;
197
+
198
+ setMenuPosition({ left, top, width });
199
+ }, []);
200
+
201
+ useEffect(() => {
202
+ if (!open) {
203
+ return;
204
+ }
205
+
206
+ updateMenuPosition();
207
+
208
+ const handlePosition = () => updateMenuPosition();
209
+ window.addEventListener("resize", handlePosition);
210
+ window.addEventListener("scroll", handlePosition, true);
211
+ return () => {
212
+ window.removeEventListener("resize", handlePosition);
213
+ window.removeEventListener("scroll", handlePosition, true);
214
+ };
215
+ }, [open, updateMenuPosition]);
216
+
143
217
  // Check capabilities
144
218
  const checkCapabilities = useCallback((caps: Capabilities) => {
145
219
  if (!caps.answer) {
@@ -170,6 +244,14 @@ export function AIModelSelector({ onPresetChange }: AIModelSelectorProps = {}) {
170
244
  if (presetsData) {
171
245
  checkCapabilities(presetsData.capabilities);
172
246
  }
247
+ const { data: statusData } =
248
+ await apiFetch<AppStatusResponse>("/api/status");
249
+ if (statusData) {
250
+ setModelsNeeded(
251
+ statusData.bootstrap.models.cachedCount <
252
+ statusData.bootstrap.models.totalCount
253
+ );
254
+ }
173
255
 
174
256
  if (data.failed.length > 0) {
175
257
  setError(`Failed: ${data.failed.map((f) => f.type).join(", ")}`);
@@ -190,6 +272,14 @@ export function AIModelSelector({ onPresetChange }: AIModelSelectorProps = {}) {
190
272
  setLoading(false);
191
273
  });
192
274
 
275
+ void apiFetch<AppStatusResponse>("/api/status").then(({ data }) => {
276
+ if (data) {
277
+ setModelsNeeded(
278
+ data.bootstrap.models.cachedCount < data.bootstrap.models.totalCount
279
+ );
280
+ }
281
+ });
282
+
193
283
  void apiFetch<DownloadStatus>("/api/models/status").then(({ data }) => {
194
284
  if (data?.active) {
195
285
  setDownloading(true);
@@ -212,6 +302,30 @@ export function AIModelSelector({ onPresetChange }: AIModelSelectorProps = {}) {
212
302
  }, [downloading, pollStatus]);
213
303
 
214
304
  const activePreset = presets.find((p) => p.id === activeId);
305
+ const activeExplanation = activePreset
306
+ ? (PRESET_EXPLANATIONS[activePreset.id] ??
307
+ "Switch between presets without redoing setup.")
308
+ : "Select a preset";
309
+
310
+ const syncFromStatus = useCallback(
311
+ async (status: AppStatusResponse | null) => {
312
+ if (!status) {
313
+ return;
314
+ }
315
+
316
+ const readyModels =
317
+ status.bootstrap.models.cachedCount >=
318
+ status.bootstrap.models.totalCount;
319
+ setModelsNeeded(!readyModels);
320
+ checkCapabilities(status.capabilities);
321
+
322
+ if (!readyModels) {
323
+ setNotice("Switched preset. Downloading required models...");
324
+ await handleDownload();
325
+ }
326
+ },
327
+ [checkCapabilities]
328
+ );
215
329
 
216
330
  const handleSelect = async (id: string) => {
217
331
  if (id === activeId || switching || downloading) return;
@@ -240,6 +354,11 @@ export function AIModelSelector({ onPresetChange }: AIModelSelectorProps = {}) {
240
354
  onPresetChange?.(data.activePreset);
241
355
  checkCapabilities(data.capabilities);
242
356
  setOpen(false);
357
+ const presetName = presets.find((preset) => preset.id === id)?.name ?? id;
358
+ setNotice(`Switched to ${presetName}`);
359
+ const { data: statusData } =
360
+ await apiFetch<AppStatusResponse>("/api/status");
361
+ await syncFromStatus(statusData);
243
362
  }
244
363
  };
245
364
 
@@ -262,13 +381,23 @@ export function AIModelSelector({ onPresetChange }: AIModelSelectorProps = {}) {
262
381
  void pollStatus();
263
382
  };
264
383
 
384
+ useEffect(() => {
385
+ if (!notice) {
386
+ return;
387
+ }
388
+ const timer = window.setTimeout(() => setNotice(null), 3000);
389
+ return () => window.clearTimeout(timer);
390
+ }, [notice]);
391
+
265
392
  // Loading skeleton
266
393
  if (loading) {
267
394
  return (
268
395
  <div className="flex items-center gap-2">
269
- <span className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground/40">
270
- Preset
271
- </span>
396
+ {showLabel && (
397
+ <span className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground/40">
398
+ Preset
399
+ </span>
400
+ )}
272
401
  <div
273
402
  className={cn(
274
403
  "h-7 w-24 rounded",
@@ -287,10 +416,17 @@ export function AIModelSelector({ onPresetChange }: AIModelSelectorProps = {}) {
287
416
  return (
288
417
  <div className="relative" ref={containerRef}>
289
418
  {/* Label */}
290
- <div className="flex items-center gap-2">
291
- <span className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground/40">
292
- Preset
293
- </span>
419
+ <div
420
+ className={cn(
421
+ "flex items-start",
422
+ showLabel ? "flex-col gap-2" : "flex-row"
423
+ )}
424
+ >
425
+ {showLabel && (
426
+ <span className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground/40">
427
+ Preset
428
+ </span>
429
+ )}
294
430
 
295
431
  {/* Tube Display Button */}
296
432
  <button
@@ -317,6 +453,7 @@ export function AIModelSelector({ onPresetChange }: AIModelSelectorProps = {}) {
317
453
  )}
318
454
  disabled={switching}
319
455
  onClick={() => setOpen(!open)}
456
+ ref={triggerRef}
320
457
  type="button"
321
458
  >
322
459
  {/* Status indicator */}
@@ -359,155 +496,256 @@ export function AIModelSelector({ onPresetChange }: AIModelSelectorProps = {}) {
359
496
  </div>
360
497
 
361
498
  {/* Dropdown Panel */}
362
- {open && (
363
- <div
364
- className={cn(
365
- "absolute top-full right-0 z-50 mt-2",
366
- "min-w-[240px] rounded-md border p-1",
367
- // Panel styling - instrument panel aesthetic
368
- "border-[hsl(var(--secondary)/0.2)]",
369
- "bg-card/95 backdrop-blur-sm",
370
- "shadow-[0_8px_32px_-8px_hsl(var(--secondary)/0.2),0_0_1px_hsl(var(--secondary)/0.1)]",
371
- // Entrance animation
372
- "animate-in fade-in-0 zoom-in-95 slide-in-from-top-2",
373
- "duration-200"
374
- )}
375
- >
376
- {/* Download progress */}
377
- {downloading && downloadStatus && (
378
- <div className="mb-2 space-y-2 rounded bg-[hsl(var(--secondary)/0.05)] p-2">
379
- <div className="flex items-center justify-between">
380
- <span className="font-mono text-[10px] text-muted-foreground">
381
- {downloadStatus.currentType || "Preparing..."}
382
- </span>
383
- <span className="font-mono text-[10px] text-[hsl(var(--secondary)/0.7)]">
384
- {downloadStatus.progress?.percent.toFixed(0) ?? 0}%
385
- </span>
386
- </div>
387
- {/* Vintage meter bar */}
388
- <div className="relative h-1.5 overflow-hidden rounded-full bg-muted/50">
389
- <div
390
- className={cn(
391
- "absolute inset-y-0 left-0 rounded-full",
392
- "bg-gradient-to-r from-[hsl(var(--secondary)/0.6)] to-[hsl(var(--secondary))]",
393
- "shadow-[0_0_8px_hsl(var(--secondary)/0.5)]",
394
- "transition-all duration-300"
395
- )}
396
- style={{ width: `${downloadStatus.progress?.percent ?? 0}%` }}
397
- />
499
+ {open &&
500
+ menuPosition &&
501
+ createPortal(
502
+ <div
503
+ className={cn(
504
+ "fixed z-[120]",
505
+ "rounded-md border p-1",
506
+ "max-h-[min(420px,calc(100vh-6rem))] overflow-y-auto",
507
+ "border-[hsl(var(--secondary)/0.2)]",
508
+ "bg-card/95 backdrop-blur-sm",
509
+ "shadow-[0_8px_32px_-8px_hsl(var(--secondary)/0.2),0_0_1px_hsl(var(--secondary)/0.1)]",
510
+ "animate-in fade-in-0 zoom-in-95 slide-in-from-top-2",
511
+ "duration-200"
512
+ )}
513
+ ref={menuRef}
514
+ style={{
515
+ left: `${menuPosition.left}px`,
516
+ top: `${menuPosition.top}px`,
517
+ width: `${menuPosition.width}px`,
518
+ }}
519
+ >
520
+ {/* Download progress */}
521
+ {downloading && downloadStatus && (
522
+ <div className="mb-2 space-y-2 rounded bg-[hsl(var(--secondary)/0.05)] p-2">
523
+ <div className="flex items-center justify-between">
524
+ <span className="font-mono text-[10px] text-muted-foreground">
525
+ {downloadStatus.currentType || "Preparing..."}
526
+ </span>
527
+ <span className="font-mono text-[10px] text-[hsl(var(--secondary)/0.7)]">
528
+ {downloadStatus.progress?.percent.toFixed(0) ?? 0}%
529
+ </span>
530
+ </div>
531
+ <div className="relative h-1.5 overflow-hidden rounded-full bg-muted/50">
532
+ <div
533
+ className={cn(
534
+ "absolute inset-y-0 left-0 rounded-full",
535
+ "bg-gradient-to-r from-[hsl(var(--secondary)/0.6)] to-[hsl(var(--secondary))]",
536
+ "shadow-[0_0_8px_hsl(var(--secondary)/0.5)]",
537
+ "transition-all duration-300"
538
+ )}
539
+ style={{
540
+ width: `${downloadStatus.progress?.percent ?? 0}%`,
541
+ }}
542
+ />
543
+ </div>
544
+ {downloadStatus.completed.length > 0 && (
545
+ <p className="font-mono text-[9px] text-muted-foreground/60">
546
+ Done: {downloadStatus.completed.join(", ")}
547
+ </p>
548
+ )}
398
549
  </div>
399
- {downloadStatus.completed.length > 0 && (
400
- <p className="font-mono text-[9px] text-muted-foreground/60">
401
- Done: {downloadStatus.completed.join(", ")}
402
- </p>
403
- )}
404
- </div>
405
- )}
550
+ )}
406
551
 
407
- {/* Preset options */}
408
- <div className="space-y-0.5">
409
- {presets.map((preset) => {
410
- const isActive = preset.id === activeId;
411
- const baseName = extractBaseName(preset.name);
412
- const size = extractSize(preset.name);
552
+ <div className="space-y-0.5">
553
+ {presets.map((preset) => {
554
+ const isActive = preset.id === activeId;
555
+ const baseName = extractBaseName(preset.name);
556
+ const size = extractSize(preset.name);
557
+ const explanation =
558
+ PRESET_EXPLANATIONS[preset.id] ??
559
+ "Pick this if the trade-off fits your machine.";
413
560
 
414
- return (
415
- <button
416
- className={cn(
417
- "group/item flex w-full items-center justify-between gap-3",
418
- "rounded px-3 py-2.5",
419
- "transition-all duration-150",
420
- // Base
421
- "text-muted-foreground",
422
- // Hover
423
- !isActive &&
424
- "hover:bg-[hsl(var(--secondary)/0.08)] hover:text-foreground",
425
- // Active state
426
- isActive && [
427
- "bg-[hsl(var(--secondary)/0.1)]",
428
- "text-[hsl(var(--secondary))]",
429
- ],
430
- // Disabled
431
- (switching || downloading) &&
432
- "pointer-events-none opacity-50"
561
+ return (
562
+ <button
563
+ className={cn(
564
+ "group/item flex w-full items-center justify-between gap-3",
565
+ "rounded px-3 py-2.5 text-left",
566
+ "transition-all duration-150",
567
+ "text-muted-foreground",
568
+ !isActive &&
569
+ "hover:bg-[hsl(var(--secondary)/0.08)] hover:text-foreground",
570
+ isActive && [
571
+ "bg-[hsl(var(--secondary)/0.1)]",
572
+ "text-[hsl(var(--secondary))]",
573
+ ],
574
+ (switching || downloading) &&
575
+ "pointer-events-none opacity-50"
576
+ )}
577
+ disabled={switching || downloading}
578
+ key={preset.id}
579
+ onClick={() => handleSelect(preset.id)}
580
+ type="button"
581
+ >
582
+ <div className="flex flex-col items-start gap-0.5">
583
+ <span
584
+ className={cn(
585
+ "font-medium text-sm",
586
+ isActive && "text-[hsl(var(--secondary))]"
587
+ )}
588
+ >
589
+ {baseName}
590
+ </span>
591
+ {size && (
592
+ <span className="font-mono text-[10px] text-muted-foreground/60">
593
+ {size}
594
+ </span>
595
+ )}
596
+ <span className="text-[10px] text-muted-foreground/80">
597
+ {explanation}
598
+ </span>
599
+ <span className="font-mono text-[9px] text-muted-foreground/50">
600
+ {`expand: ${formatModelRole(preset.expand ?? preset.gen)}`}
601
+ </span>
602
+ <span className="font-mono text-[9px] text-muted-foreground/50">
603
+ {`answer: ${formatModelRole(preset.gen)}`}
604
+ </span>
605
+ </div>
606
+
607
+ {isActive && (
608
+ <Check className="size-4 text-[hsl(var(--secondary))]" />
609
+ )}
610
+ </button>
611
+ );
612
+ })}
613
+ </div>
614
+
615
+ {(error || modelsNeeded) && !downloading && (
616
+ <>
617
+ <div className="my-1 border-t border-border/50" />
618
+ <div className="space-y-2 p-2">
619
+ {error && (
620
+ <p className="font-mono text-[10px] text-amber-500">
621
+ {error}
622
+ </p>
433
623
  )}
434
- disabled={switching || downloading}
435
- key={preset.id}
436
- onClick={() => handleSelect(preset.id)}
437
- type="button"
438
- >
439
- <div className="flex flex-col items-start gap-0.5">
440
- <span
624
+ {modelsNeeded && (
625
+ <button
441
626
  className={cn(
442
- "font-medium text-sm",
443
- isActive && "text-[hsl(var(--secondary))]"
627
+ "flex w-full items-center justify-center gap-2",
628
+ "rounded border px-3 py-2",
629
+ "border-[hsl(var(--secondary)/0.3)]",
630
+ "bg-[hsl(var(--secondary)/0.05)]",
631
+ "font-medium text-[hsl(var(--secondary))] text-xs",
632
+ "transition-all duration-200",
633
+ "hover:border-[hsl(var(--secondary)/0.5)]",
634
+ "hover:bg-[hsl(var(--secondary)/0.1)]",
635
+ "hover:shadow-[0_0_12px_-4px_hsl(var(--secondary)/0.3)]"
444
636
  )}
637
+ onClick={handleDownload}
638
+ type="button"
445
639
  >
446
- {baseName}
447
- </span>
448
- {size && (
449
- <span className="font-mono text-[10px] text-muted-foreground/60">
450
- {size}
451
- </span>
452
- )}
453
- <span className="font-mono text-[9px] text-muted-foreground/50">
454
- {`expand: ${formatModelRole(preset.expand ?? preset.gen)}`}
455
- </span>
456
- <span className="font-mono text-[9px] text-muted-foreground/50">
457
- {`answer: ${formatModelRole(preset.gen)}`}
458
- </span>
459
- </div>
640
+ <Download className="size-3.5" />
641
+ Download Preset Models
642
+ </button>
643
+ )}
644
+ </div>
645
+ </>
646
+ )}
460
647
 
461
- {isActive && (
462
- <Check className="size-4 text-[hsl(var(--secondary))]" />
648
+ <div className="mt-1 border-t border-border/30 px-3 py-2">
649
+ <p className="font-mono text-[9px] text-muted-foreground/50">
650
+ Controls retrieval expansion and AI answers
651
+ </p>
652
+ </div>
653
+ </div>,
654
+ document.body
655
+ )}
656
+
657
+ {showDetails && activePreset && (
658
+ <div className="mt-4 rounded-2xl border border-secondary/20 bg-background/80 shadow-sm">
659
+ <div
660
+ className="border-border/50 border-b"
661
+ style={{ padding: "20px 24px" }}
662
+ >
663
+ <div className="flex flex-wrap items-start justify-between gap-4">
664
+ <div>
665
+ <div className="mb-3 flex flex-wrap items-center gap-2">
666
+ <div className="font-semibold text-base">
667
+ {activePreset.name}
668
+ </div>
669
+ {extractSize(activePreset.name) && (
670
+ <Badge variant="outline">
671
+ {extractSize(activePreset.name)}
672
+ </Badge>
463
673
  )}
464
- </button>
465
- );
466
- })}
674
+ {isCustomPreset(activePreset) && (
675
+ <Badge className="border-secondary/30 bg-secondary/10 text-secondary hover:bg-secondary/10">
676
+ Tuned
677
+ </Badge>
678
+ )}
679
+ </div>
680
+ <p className="text-muted-foreground text-sm">
681
+ {activeExplanation}
682
+ </p>
683
+ </div>
684
+ <Badge variant="outline">{`${presets.length} presets available`}</Badge>
685
+ </div>
467
686
  </div>
468
687
 
469
- {/* Error / Download prompt */}
470
- {(error || modelsNeeded) && !downloading && (
471
- <>
472
- <div className="my-1 border-t border-border/50" />
473
- <div className="space-y-2 p-2">
474
- {error && (
475
- <p className="font-mono text-[10px] text-amber-500">
476
- {error}
477
- </p>
478
- )}
479
- {modelsNeeded && (
480
- <button
481
- className={cn(
482
- "flex w-full items-center justify-center gap-2",
483
- "rounded border px-3 py-2",
484
- "border-[hsl(var(--secondary)/0.3)]",
485
- "bg-[hsl(var(--secondary)/0.05)]",
486
- "font-medium text-[hsl(var(--secondary))] text-xs",
487
- "transition-all duration-200",
488
- "hover:border-[hsl(var(--secondary)/0.5)]",
489
- "hover:bg-[hsl(var(--secondary)/0.1)]",
490
- "hover:shadow-[0_0_12px_-4px_hsl(var(--secondary)/0.3)]"
491
- )}
492
- onClick={handleDownload}
493
- type="button"
494
- >
495
- <Download className="size-3.5" />
496
- Download Preset Models
497
- </button>
498
- )}
688
+ <div style={{ padding: "20px 24px" }}>
689
+ <div className="grid gap-4 md:grid-cols-2">
690
+ <div
691
+ className="rounded-xl border border-border/60 bg-card/70 shadow-none"
692
+ style={{ padding: "16px" }}
693
+ >
694
+ <div className="mb-2 flex items-center gap-2 font-medium text-sm">
695
+ <ScanSearch className="size-4 text-secondary" />
696
+ Retrieval profile
697
+ </div>
698
+ <p className="text-muted-foreground text-sm">
699
+ {activePreset.id === "slim"
700
+ ? "Quickest local setup with the lightest footprint."
701
+ : activePreset.id === "balanced"
702
+ ? "Good general-purpose trade-off for most projects."
703
+ : activePreset.id === "quality"
704
+ ? "Highest local answer quality with heavier resource use."
705
+ : "Custom tuned profile layered on top of the built-in options."}
706
+ </p>
499
707
  </div>
500
- </>
501
- )}
708
+ <div
709
+ className="rounded-xl border border-border/60 bg-card/70 shadow-none"
710
+ style={{ padding: "16px" }}
711
+ >
712
+ <div className="mb-2 flex items-center gap-2 font-medium text-sm">
713
+ <BadgeCheck className="size-4 text-secondary" />
714
+ Active models
715
+ </div>
716
+ <div className="space-y-1 font-mono text-[11px] text-muted-foreground">
717
+ <div>{`expand: ${formatModelRole(activePreset.expand ?? activePreset.gen)}`}</div>
718
+ <div>{`answer: ${formatModelRole(activePreset.gen)}`}</div>
719
+ <div>{`rerank: ${formatModelRole(activePreset.rerank)}`}</div>
720
+ </div>
721
+ </div>
722
+ </div>
502
723
 
503
- {/* Footer note */}
504
- <div className="mt-1 border-t border-border/30 px-3 py-2">
505
- <p className="font-mono text-[9px] text-muted-foreground/50">
506
- Controls retrieval expansion and AI answers
507
- </p>
724
+ {showDownloadAction && (error || modelsNeeded) && !downloading && (
725
+ <div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-500/25 bg-amber-500/10 px-4 py-4">
726
+ <p className="text-amber-500 text-sm">
727
+ {error ?? "This preset still needs local model files."}
728
+ </p>
729
+ <button
730
+ className={cn(
731
+ "flex items-center justify-center gap-2 rounded border px-3 py-2",
732
+ "border-[hsl(var(--secondary)/0.3)] bg-[hsl(var(--secondary)/0.05)]",
733
+ "font-medium text-[hsl(var(--secondary))] text-xs transition-all duration-200",
734
+ "hover:border-[hsl(var(--secondary)/0.5)] hover:bg-[hsl(var(--secondary)/0.1)]"
735
+ )}
736
+ onClick={handleDownload}
737
+ type="button"
738
+ >
739
+ <Download className="size-3.5" />
740
+ Download preset models
741
+ </button>
742
+ </div>
743
+ )}
508
744
  </div>
509
745
  </div>
510
746
  )}
747
+
748
+ {notice && <div className="mt-2 text-primary text-xs">{notice}</div>}
511
749
  </div>
512
750
  );
513
751
  }