@gmickel/gno 0.7.0 → 0.8.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 (209) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -50
  3. package/THIRD_PARTY_NOTICES.md +22 -0
  4. package/assets/screenshots/webui-ask-answer.png +0 -0
  5. package/assets/screenshots/webui-collections.png +0 -0
  6. package/assets/screenshots/webui-editor.png +0 -0
  7. package/assets/screenshots/webui-home.png +0 -0
  8. package/assets/skill/SKILL.md +12 -12
  9. package/assets/skill/cli-reference.md +59 -57
  10. package/assets/skill/examples.md +8 -7
  11. package/assets/skill/mcp-reference.md +8 -4
  12. package/package.json +31 -24
  13. package/src/app/constants.ts +43 -42
  14. package/src/cli/colors.ts +1 -1
  15. package/src/cli/commands/ask.ts +44 -43
  16. package/src/cli/commands/cleanup.ts +9 -8
  17. package/src/cli/commands/collection/add.ts +12 -12
  18. package/src/cli/commands/collection/index.ts +4 -4
  19. package/src/cli/commands/collection/list.ts +26 -25
  20. package/src/cli/commands/collection/remove.ts +10 -10
  21. package/src/cli/commands/collection/rename.ts +10 -10
  22. package/src/cli/commands/context/add.ts +1 -1
  23. package/src/cli/commands/context/check.ts +17 -17
  24. package/src/cli/commands/context/index.ts +4 -4
  25. package/src/cli/commands/context/list.ts +11 -11
  26. package/src/cli/commands/context/rm.ts +1 -1
  27. package/src/cli/commands/doctor.ts +86 -84
  28. package/src/cli/commands/embed.ts +30 -28
  29. package/src/cli/commands/get.ts +27 -26
  30. package/src/cli/commands/index-cmd.ts +9 -9
  31. package/src/cli/commands/index.ts +16 -16
  32. package/src/cli/commands/init.ts +13 -12
  33. package/src/cli/commands/ls.ts +20 -19
  34. package/src/cli/commands/mcp/config.ts +30 -28
  35. package/src/cli/commands/mcp/index.ts +4 -4
  36. package/src/cli/commands/mcp/install.ts +17 -17
  37. package/src/cli/commands/mcp/paths.ts +133 -133
  38. package/src/cli/commands/mcp/status.ts +21 -21
  39. package/src/cli/commands/mcp/uninstall.ts +13 -13
  40. package/src/cli/commands/mcp.ts +2 -2
  41. package/src/cli/commands/models/clear.ts +12 -11
  42. package/src/cli/commands/models/index.ts +5 -5
  43. package/src/cli/commands/models/list.ts +31 -30
  44. package/src/cli/commands/models/path.ts +1 -1
  45. package/src/cli/commands/models/pull.ts +19 -18
  46. package/src/cli/commands/models/use.ts +4 -4
  47. package/src/cli/commands/multi-get.ts +38 -36
  48. package/src/cli/commands/query.ts +21 -20
  49. package/src/cli/commands/ref-parser.ts +10 -10
  50. package/src/cli/commands/reset.ts +40 -39
  51. package/src/cli/commands/search.ts +14 -13
  52. package/src/cli/commands/serve.ts +4 -4
  53. package/src/cli/commands/shared.ts +11 -10
  54. package/src/cli/commands/skill/index.ts +5 -5
  55. package/src/cli/commands/skill/install.ts +18 -17
  56. package/src/cli/commands/skill/paths-cmd.ts +11 -10
  57. package/src/cli/commands/skill/paths.ts +23 -23
  58. package/src/cli/commands/skill/show.ts +13 -12
  59. package/src/cli/commands/skill/uninstall.ts +16 -15
  60. package/src/cli/commands/status.ts +25 -24
  61. package/src/cli/commands/update.ts +3 -3
  62. package/src/cli/commands/vsearch.ts +17 -16
  63. package/src/cli/context.ts +5 -5
  64. package/src/cli/errors.ts +3 -3
  65. package/src/cli/format/search-results.ts +37 -37
  66. package/src/cli/options.ts +43 -43
  67. package/src/cli/program.ts +455 -459
  68. package/src/cli/progress.ts +1 -1
  69. package/src/cli/run.ts +24 -23
  70. package/src/collection/add.ts +9 -8
  71. package/src/collection/index.ts +3 -3
  72. package/src/collection/remove.ts +7 -6
  73. package/src/collection/types.ts +6 -6
  74. package/src/config/defaults.ts +1 -1
  75. package/src/config/index.ts +5 -5
  76. package/src/config/loader.ts +19 -18
  77. package/src/config/paths.ts +9 -8
  78. package/src/config/saver.ts +14 -13
  79. package/src/config/types.ts +53 -52
  80. package/src/converters/adapters/markitdownTs/adapter.ts +21 -19
  81. package/src/converters/adapters/officeparser/adapter.ts +18 -16
  82. package/src/converters/canonicalize.ts +12 -12
  83. package/src/converters/errors.ts +26 -22
  84. package/src/converters/index.ts +8 -8
  85. package/src/converters/mime.ts +25 -25
  86. package/src/converters/native/markdown.ts +10 -9
  87. package/src/converters/native/plaintext.ts +8 -7
  88. package/src/converters/path.ts +2 -2
  89. package/src/converters/pipeline.ts +11 -10
  90. package/src/converters/registry.ts +8 -8
  91. package/src/converters/types.ts +14 -14
  92. package/src/converters/versions.ts +4 -4
  93. package/src/index.ts +4 -4
  94. package/src/ingestion/chunker.ts +10 -9
  95. package/src/ingestion/index.ts +6 -6
  96. package/src/ingestion/language.ts +62 -62
  97. package/src/ingestion/sync.ts +50 -49
  98. package/src/ingestion/types.ts +10 -10
  99. package/src/ingestion/walker.ts +14 -13
  100. package/src/llm/cache.ts +51 -49
  101. package/src/llm/errors.ts +40 -36
  102. package/src/llm/index.ts +9 -9
  103. package/src/llm/lockfile.ts +6 -6
  104. package/src/llm/nodeLlamaCpp/adapter.ts +13 -12
  105. package/src/llm/nodeLlamaCpp/embedding.ts +9 -8
  106. package/src/llm/nodeLlamaCpp/generation.ts +7 -6
  107. package/src/llm/nodeLlamaCpp/lifecycle.ts +11 -10
  108. package/src/llm/nodeLlamaCpp/rerank.ts +6 -5
  109. package/src/llm/policy.ts +5 -5
  110. package/src/llm/registry.ts +6 -5
  111. package/src/llm/types.ts +2 -2
  112. package/src/mcp/resources/index.ts +15 -13
  113. package/src/mcp/server.ts +25 -23
  114. package/src/mcp/tools/get.ts +25 -23
  115. package/src/mcp/tools/index.ts +32 -29
  116. package/src/mcp/tools/multi-get.ts +34 -32
  117. package/src/mcp/tools/query.ts +29 -27
  118. package/src/mcp/tools/search.ts +14 -12
  119. package/src/mcp/tools/status.ts +12 -11
  120. package/src/mcp/tools/vsearch.ts +26 -24
  121. package/src/pipeline/answer.ts +9 -9
  122. package/src/pipeline/chunk-lookup.ts +1 -1
  123. package/src/pipeline/contextual.ts +4 -4
  124. package/src/pipeline/expansion.ts +23 -21
  125. package/src/pipeline/explain.ts +21 -21
  126. package/src/pipeline/fusion.ts +9 -9
  127. package/src/pipeline/hybrid.ts +41 -42
  128. package/src/pipeline/index.ts +10 -10
  129. package/src/pipeline/query-language.ts +39 -39
  130. package/src/pipeline/rerank.ts +8 -7
  131. package/src/pipeline/search.ts +22 -22
  132. package/src/pipeline/types.ts +8 -8
  133. package/src/pipeline/vsearch.ts +21 -24
  134. package/src/serve/CLAUDE.md +21 -15
  135. package/src/serve/config-sync.ts +9 -8
  136. package/src/serve/context.ts +19 -18
  137. package/src/serve/index.ts +1 -1
  138. package/src/serve/jobs.ts +7 -7
  139. package/src/serve/public/app.tsx +79 -25
  140. package/src/serve/public/components/AddCollectionDialog.tsx +382 -0
  141. package/src/serve/public/components/CaptureButton.tsx +60 -0
  142. package/src/serve/public/components/CaptureModal.tsx +365 -0
  143. package/src/serve/public/components/IndexingProgress.tsx +333 -0
  144. package/src/serve/public/components/ShortcutHelpModal.tsx +106 -0
  145. package/src/serve/public/components/ai-elements/code-block.tsx +42 -32
  146. package/src/serve/public/components/ai-elements/conversation.tsx +16 -14
  147. package/src/serve/public/components/ai-elements/inline-citation.tsx +33 -32
  148. package/src/serve/public/components/ai-elements/loader.tsx +5 -4
  149. package/src/serve/public/components/ai-elements/message.tsx +39 -37
  150. package/src/serve/public/components/ai-elements/prompt-input.tsx +97 -95
  151. package/src/serve/public/components/ai-elements/sources.tsx +12 -10
  152. package/src/serve/public/components/ai-elements/suggestion.tsx +10 -9
  153. package/src/serve/public/components/editor/CodeMirrorEditor.tsx +142 -0
  154. package/src/serve/public/components/editor/MarkdownPreview.tsx +311 -0
  155. package/src/serve/public/components/editor/index.ts +6 -0
  156. package/src/serve/public/components/preset-selector.tsx +29 -28
  157. package/src/serve/public/components/ui/badge.tsx +13 -12
  158. package/src/serve/public/components/ui/button-group.tsx +13 -12
  159. package/src/serve/public/components/ui/button.tsx +23 -22
  160. package/src/serve/public/components/ui/card.tsx +16 -16
  161. package/src/serve/public/components/ui/carousel.tsx +36 -35
  162. package/src/serve/public/components/ui/collapsible.tsx +1 -1
  163. package/src/serve/public/components/ui/command.tsx +17 -15
  164. package/src/serve/public/components/ui/dialog.tsx +13 -12
  165. package/src/serve/public/components/ui/dropdown-menu.tsx +13 -12
  166. package/src/serve/public/components/ui/hover-card.tsx +6 -5
  167. package/src/serve/public/components/ui/input-group.tsx +45 -43
  168. package/src/serve/public/components/ui/input.tsx +6 -6
  169. package/src/serve/public/components/ui/progress.tsx +5 -4
  170. package/src/serve/public/components/ui/scroll-area.tsx +11 -10
  171. package/src/serve/public/components/ui/select.tsx +19 -18
  172. package/src/serve/public/components/ui/separator.tsx +6 -5
  173. package/src/serve/public/components/ui/table.tsx +18 -18
  174. package/src/serve/public/components/ui/textarea.tsx +4 -4
  175. package/src/serve/public/components/ui/tooltip.tsx +5 -4
  176. package/src/serve/public/globals.css +27 -4
  177. package/src/serve/public/hooks/use-api.ts +8 -8
  178. package/src/serve/public/hooks/useCaptureModal.tsx +83 -0
  179. package/src/serve/public/hooks/useKeyboardShortcuts.ts +85 -0
  180. package/src/serve/public/index.html +4 -4
  181. package/src/serve/public/lib/utils.ts +6 -0
  182. package/src/serve/public/pages/Ask.tsx +27 -26
  183. package/src/serve/public/pages/Browse.tsx +28 -27
  184. package/src/serve/public/pages/Collections.tsx +439 -0
  185. package/src/serve/public/pages/Dashboard.tsx +166 -40
  186. package/src/serve/public/pages/DocView.tsx +258 -73
  187. package/src/serve/public/pages/DocumentEditor.tsx +510 -0
  188. package/src/serve/public/pages/Search.tsx +80 -58
  189. package/src/serve/routes/api.ts +272 -155
  190. package/src/serve/security.ts +4 -4
  191. package/src/serve/server.ts +66 -48
  192. package/src/store/index.ts +5 -5
  193. package/src/store/migrations/001-initial.ts +24 -23
  194. package/src/store/migrations/002-documents-fts.ts +7 -6
  195. package/src/store/migrations/index.ts +4 -4
  196. package/src/store/migrations/runner.ts +17 -15
  197. package/src/store/sqlite/adapter.ts +123 -121
  198. package/src/store/sqlite/fts5-snowball.ts +24 -23
  199. package/src/store/sqlite/index.ts +1 -1
  200. package/src/store/sqlite/setup.ts +12 -12
  201. package/src/store/sqlite/types.ts +4 -4
  202. package/src/store/types.ts +19 -19
  203. package/src/store/vector/index.ts +3 -3
  204. package/src/store/vector/sqlite-vec.ts +23 -20
  205. package/src/store/vector/stats.ts +10 -8
  206. package/src/store/vector/types.ts +2 -2
  207. package/vendor/fts5-snowball/README.md +6 -6
  208. package/assets/screenshots/webui-ask-answer.jpg +0 -0
  209. package/assets/screenshots/webui-home.jpg +0 -0
@@ -1,15 +1,16 @@
1
- import { ArrowLeft, ChevronRight, FileText, FolderOpen } from 'lucide-react';
2
- import { useEffect, useState } from 'react';
3
- import { Loader } from '../components/ai-elements/loader';
4
- import { Badge } from '../components/ui/badge';
5
- import { Button } from '../components/ui/button';
1
+ import { ArrowLeft, ChevronRight, FileText, FolderOpen } from "lucide-react";
2
+ import { useEffect, useState } from "react";
3
+
4
+ import { Loader } from "../components/ai-elements/loader";
5
+ import { Badge } from "../components/ui/badge";
6
+ import { Button } from "../components/ui/button";
6
7
  import {
7
8
  Select,
8
9
  SelectContent,
9
10
  SelectItem,
10
11
  SelectTrigger,
11
12
  SelectValue,
12
- } from '../components/ui/select';
13
+ } from "../components/ui/select";
13
14
  import {
14
15
  Table,
15
16
  TableBody,
@@ -17,8 +18,8 @@ import {
17
18
  TableHead,
18
19
  TableHeader,
19
20
  TableRow,
20
- } from '../components/ui/table';
21
- import { apiFetch } from '../hooks/use-api';
21
+ } from "../components/ui/table";
22
+ import { apiFetch } from "../hooks/use-api";
22
23
 
23
24
  interface PageProps {
24
25
  navigate: (to: string | number) => void;
@@ -47,7 +48,7 @@ interface DocsResponse {
47
48
 
48
49
  export default function Browse({ navigate }: PageProps) {
49
50
  const [collections, setCollections] = useState<Collection[]>([]);
50
- const [selected, setSelected] = useState<string>('');
51
+ const [selected, setSelected] = useState<string>("");
51
52
  const [docs, setDocs] = useState<Document[]>([]);
52
53
  const [total, setTotal] = useState(0);
53
54
  const [offset, setOffset] = useState(0);
@@ -58,14 +59,14 @@ export default function Browse({ navigate }: PageProps) {
58
59
  // Parse collection from URL on mount
59
60
  useEffect(() => {
60
61
  const params = new URLSearchParams(window.location.search);
61
- const collection = params.get('collection');
62
+ const collection = params.get("collection");
62
63
  if (collection) {
63
64
  setSelected(collection);
64
65
  }
65
66
  }, []);
66
67
 
67
68
  useEffect(() => {
68
- apiFetch<Collection[]>('/api/collections').then(({ data }) => {
69
+ void apiFetch<Collection[]>("/api/collections").then(({ data }) => {
69
70
  if (data) {
70
71
  setCollections(data);
71
72
  }
@@ -78,7 +79,7 @@ export default function Browse({ navigate }: PageProps) {
78
79
  ? `/api/docs?collection=${encodeURIComponent(selected)}&limit=${limit}&offset=${offset}`
79
80
  : `/api/docs?limit=${limit}&offset=${offset}`;
80
81
 
81
- apiFetch<DocsResponse>(url).then(({ data }) => {
82
+ void apiFetch<DocsResponse>(url).then(({ data }) => {
82
83
  setLoading(false);
83
84
  setInitialLoad(false);
84
85
  if (data) {
@@ -91,15 +92,15 @@ export default function Browse({ navigate }: PageProps) {
91
92
  }, [selected, offset]);
92
93
 
93
94
  const handleCollectionChange = (value: string) => {
94
- const newSelected = value === 'all' ? '' : value;
95
+ const newSelected = value === "all" ? "" : value;
95
96
  setSelected(newSelected);
96
97
  setOffset(0);
97
98
  setDocs([]);
98
99
  // Update URL for shareable deep-links
99
100
  const url = newSelected
100
101
  ? `/browse?collection=${encodeURIComponent(newSelected)}`
101
- : '/browse';
102
- window.history.pushState({}, '', url);
102
+ : "/browse";
103
+ window.history.pushState({}, "", url);
103
104
  };
104
105
 
105
106
  const handleLoadMore = () => {
@@ -108,16 +109,16 @@ export default function Browse({ navigate }: PageProps) {
108
109
 
109
110
  const getExtBadgeVariant = (ext: string) => {
110
111
  switch (ext.toLowerCase()) {
111
- case '.md':
112
- case '.markdown':
113
- return 'default';
114
- case '.pdf':
115
- return 'destructive';
116
- case '.docx':
117
- case '.doc':
118
- return 'secondary';
112
+ case ".md":
113
+ case ".markdown":
114
+ return "default";
115
+ case ".pdf":
116
+ return "destructive";
117
+ case ".docx":
118
+ case ".doc":
119
+ return "secondary";
119
120
  default:
120
- return 'outline';
121
+ return "outline";
121
122
  }
122
123
  };
123
124
 
@@ -141,7 +142,7 @@ export default function Browse({ navigate }: PageProps) {
141
142
  <div className="flex items-center gap-4">
142
143
  <Select
143
144
  onValueChange={handleCollectionChange}
144
- value={selected || 'all'}
145
+ value={selected || "all"}
145
146
  >
146
147
  <SelectTrigger className="w-[200px]">
147
148
  <FolderOpen className="mr-2 size-4 text-muted-foreground" />
@@ -179,8 +180,8 @@ export default function Browse({ navigate }: PageProps) {
179
180
  <h3 className="mb-2 font-medium text-lg">No documents found</h3>
180
181
  <p className="text-muted-foreground">
181
182
  {selected
182
- ? 'This collection is empty'
183
- : 'Index some documents to get started'}
183
+ ? "This collection is empty"
184
+ : "Index some documents to get started"}
184
185
  </p>
185
186
  </div>
186
187
  )}
@@ -0,0 +1,439 @@
1
+ /**
2
+ * Collections page - List and manage document collections.
3
+ *
4
+ * Features:
5
+ * - Grid of collection cards with stats
6
+ * - Re-index action per collection
7
+ * - Remove collection with confirmation
8
+ * - Refresh button
9
+ * - Empty state
10
+ */
11
+
12
+ import {
13
+ AlertCircleIcon,
14
+ DatabaseIcon,
15
+ FileTextIcon,
16
+ FolderIcon,
17
+ FolderMinusIcon,
18
+ FolderPlusIcon,
19
+ LayersIcon,
20
+ Loader2Icon,
21
+ MoreVerticalIcon,
22
+ RefreshCwIcon,
23
+ } from "lucide-react";
24
+ import { useCallback, useEffect, useState } from "react";
25
+
26
+ import { AddCollectionDialog } from "../components/AddCollectionDialog";
27
+ import { Loader } from "../components/ai-elements/loader";
28
+ import { Badge } from "../components/ui/badge";
29
+ import { Button } from "../components/ui/button";
30
+ import {
31
+ Card,
32
+ CardContent,
33
+ CardHeader,
34
+ CardTitle,
35
+ } from "../components/ui/card";
36
+ import {
37
+ Dialog,
38
+ DialogContent,
39
+ DialogDescription,
40
+ DialogFooter,
41
+ DialogHeader,
42
+ DialogTitle,
43
+ } from "../components/ui/dialog";
44
+ import {
45
+ DropdownMenu,
46
+ DropdownMenuContent,
47
+ DropdownMenuItem,
48
+ DropdownMenuSeparator,
49
+ DropdownMenuTrigger,
50
+ } from "../components/ui/dropdown-menu";
51
+ import {
52
+ Tooltip,
53
+ TooltipContent,
54
+ TooltipProvider,
55
+ TooltipTrigger,
56
+ } from "../components/ui/tooltip";
57
+ import { apiFetch } from "../hooks/use-api";
58
+
59
+ interface PageProps {
60
+ navigate: (to: string | number) => void;
61
+ }
62
+
63
+ interface CollectionStats {
64
+ name: string;
65
+ path: string;
66
+ documentCount: number;
67
+ chunkCount: number;
68
+ embeddedCount: number;
69
+ }
70
+
71
+ interface StatusResponse {
72
+ collections: CollectionStats[];
73
+ totalDocuments: number;
74
+ lastUpdated: string | null;
75
+ healthy: boolean;
76
+ }
77
+
78
+ interface SyncResponse {
79
+ jobId: string;
80
+ }
81
+
82
+ interface CollectionCardProps {
83
+ collection: CollectionStats;
84
+ onReindex: () => void;
85
+ onRemove: () => void;
86
+ isReindexing: boolean;
87
+ }
88
+
89
+ function formatNumber(n: number): string {
90
+ if (n >= 1000000) {
91
+ return `${(n / 1000000).toFixed(1)}M`;
92
+ }
93
+ if (n >= 1000) {
94
+ return `${(n / 1000).toFixed(1)}K`;
95
+ }
96
+ return n.toString();
97
+ }
98
+
99
+ function truncatePath(path: string, maxLength = 40): string {
100
+ if (path.length <= maxLength) return path;
101
+ const start = path.slice(0, 15);
102
+ const end = path.slice(-22);
103
+ return `${start}...${end}`;
104
+ }
105
+
106
+ function CollectionCard({
107
+ collection,
108
+ onReindex,
109
+ onRemove,
110
+ isReindexing,
111
+ }: CollectionCardProps) {
112
+ const embedPercent =
113
+ collection.chunkCount > 0
114
+ ? Math.round((collection.embeddedCount / collection.chunkCount) * 100)
115
+ : 100;
116
+
117
+ return (
118
+ <Card className="group relative overflow-hidden transition-all hover:border-primary/30">
119
+ <CardHeader className="pb-2">
120
+ <div className="flex items-start justify-between gap-2">
121
+ <div className="flex min-w-0 items-center gap-2">
122
+ <div className="rounded-lg bg-primary/10 p-2">
123
+ <FolderIcon className="size-5 text-primary" />
124
+ </div>
125
+ <CardTitle className="truncate text-lg">
126
+ {collection.name}
127
+ </CardTitle>
128
+ </div>
129
+
130
+ <DropdownMenu>
131
+ <DropdownMenuTrigger asChild>
132
+ <Button
133
+ className="shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
134
+ size="icon-sm"
135
+ variant="ghost"
136
+ >
137
+ <MoreVerticalIcon className="size-4" />
138
+ </Button>
139
+ </DropdownMenuTrigger>
140
+ <DropdownMenuContent align="end">
141
+ <DropdownMenuItem disabled={isReindexing} onClick={onReindex}>
142
+ {isReindexing ? (
143
+ <Loader2Icon className="mr-2 size-4 animate-spin" />
144
+ ) : (
145
+ <RefreshCwIcon className="mr-2 size-4" />
146
+ )}
147
+ Re-index
148
+ </DropdownMenuItem>
149
+ <DropdownMenuSeparator />
150
+ <DropdownMenuItem
151
+ className="text-destructive focus:text-destructive"
152
+ onClick={onRemove}
153
+ >
154
+ <FolderMinusIcon className="mr-2 size-4" />
155
+ Remove
156
+ </DropdownMenuItem>
157
+ </DropdownMenuContent>
158
+ </DropdownMenu>
159
+ </div>
160
+
161
+ <TooltipProvider>
162
+ <Tooltip>
163
+ <TooltipTrigger asChild>
164
+ <p className="truncate font-mono text-muted-foreground text-xs">
165
+ {truncatePath(collection.path)}
166
+ </p>
167
+ </TooltipTrigger>
168
+ <TooltipContent className="max-w-xs break-all">
169
+ <p className="font-mono text-xs">{collection.path}</p>
170
+ </TooltipContent>
171
+ </Tooltip>
172
+ </TooltipProvider>
173
+ </CardHeader>
174
+
175
+ <CardContent className="pt-2">
176
+ <div className="grid grid-cols-3 gap-3">
177
+ <div className="flex items-center gap-2">
178
+ <FileTextIcon className="size-4 text-muted-foreground" />
179
+ <div>
180
+ <div className="font-medium text-sm">
181
+ {formatNumber(collection.documentCount)}
182
+ </div>
183
+ <div className="text-muted-foreground text-xs">documents</div>
184
+ </div>
185
+ </div>
186
+ <div className="flex items-center gap-2">
187
+ <LayersIcon className="size-4 text-muted-foreground" />
188
+ <div>
189
+ <div className="font-medium text-sm">
190
+ {formatNumber(collection.chunkCount)}
191
+ </div>
192
+ <div className="text-muted-foreground text-xs">chunks</div>
193
+ </div>
194
+ </div>
195
+ <div className="flex items-center gap-2">
196
+ <DatabaseIcon className="size-4 text-muted-foreground" />
197
+ <div>
198
+ <div className="font-medium text-sm">{embedPercent}%</div>
199
+ <div className="text-muted-foreground text-xs">embedded</div>
200
+ </div>
201
+ </div>
202
+ </div>
203
+
204
+ {isReindexing && (
205
+ <div className="mt-3 flex items-center gap-2 text-muted-foreground text-sm">
206
+ <Loader2Icon className="size-4 animate-spin" />
207
+ <span>Re-indexing...</span>
208
+ </div>
209
+ )}
210
+ </CardContent>
211
+ </Card>
212
+ );
213
+ }
214
+
215
+ export default function Collections({ navigate: _navigate }: PageProps) {
216
+ const [collections, setCollections] = useState<CollectionStats[]>([]);
217
+ const [loading, setLoading] = useState(true);
218
+ const [error, setError] = useState<string | null>(null);
219
+ const [refreshing, setRefreshing] = useState(false);
220
+ const [reindexingCollections, setReindexingCollections] = useState<
221
+ Set<string>
222
+ >(new Set());
223
+ const [removeDialog, setRemoveDialog] = useState<CollectionStats | null>(
224
+ null
225
+ );
226
+ const [removing, setRemoving] = useState(false);
227
+ const [addDialogOpen, setAddDialogOpen] = useState(false);
228
+
229
+ const loadCollections = useCallback(async () => {
230
+ const { data, error: err } = await apiFetch<StatusResponse>("/api/status");
231
+ if (err) {
232
+ setError(err);
233
+ } else if (data) {
234
+ setCollections(data.collections);
235
+ setError(null);
236
+ }
237
+ }, []);
238
+
239
+ // Initial load
240
+ useEffect(() => {
241
+ void loadCollections().finally(() => setLoading(false));
242
+ }, [loadCollections]);
243
+
244
+ // Refresh
245
+ const handleRefresh = async () => {
246
+ setRefreshing(true);
247
+ await loadCollections();
248
+ setRefreshing(false);
249
+ };
250
+
251
+ // Re-index collection
252
+ const handleReindex = async (name: string) => {
253
+ setReindexingCollections((prev) => new Set([...prev, name]));
254
+
255
+ const { error: err } = await apiFetch<SyncResponse>("/api/sync", {
256
+ method: "POST",
257
+ body: JSON.stringify({ collection: name }),
258
+ });
259
+
260
+ if (err) {
261
+ // Show error briefly then remove from reindexing state
262
+ setTimeout(() => {
263
+ setReindexingCollections((prev) => {
264
+ const next = new Set(prev);
265
+ next.delete(name);
266
+ return next;
267
+ });
268
+ }, 1000);
269
+ } else {
270
+ // Poll for completion (simple approach - could use IndexingProgress component)
271
+ setTimeout(async () => {
272
+ await loadCollections();
273
+ setReindexingCollections((prev) => {
274
+ const next = new Set(prev);
275
+ next.delete(name);
276
+ return next;
277
+ });
278
+ }, 3000);
279
+ }
280
+ };
281
+
282
+ // Remove collection
283
+ const handleRemove = async () => {
284
+ if (!removeDialog) return;
285
+
286
+ setRemoving(true);
287
+ const { error: err } = await apiFetch(
288
+ `/api/collections/${encodeURIComponent(removeDialog.name)}`,
289
+ { method: "DELETE" }
290
+ );
291
+
292
+ setRemoving(false);
293
+ setRemoveDialog(null);
294
+
295
+ if (!err) {
296
+ await loadCollections();
297
+ }
298
+ };
299
+
300
+ // Loading state
301
+ if (loading) {
302
+ return (
303
+ <div className="flex min-h-screen items-center justify-center">
304
+ <div className="flex flex-col items-center gap-4">
305
+ <Loader className="text-primary" size={32} />
306
+ <p className="text-muted-foreground">Loading collections...</p>
307
+ </div>
308
+ </div>
309
+ );
310
+ }
311
+
312
+ return (
313
+ <div className="min-h-screen">
314
+ {/* Header */}
315
+ <header className="glass sticky top-0 z-10 border-border/50 border-b">
316
+ <div className="flex items-center justify-between px-8 py-4">
317
+ <div className="flex items-center gap-3">
318
+ <FolderIcon className="size-5 text-primary" />
319
+ <h1 className="font-semibold text-xl">Collections</h1>
320
+ <Badge className="font-mono" variant="outline">
321
+ {collections.length}
322
+ </Badge>
323
+ </div>
324
+
325
+ <div className="flex items-center gap-2">
326
+ <Button
327
+ disabled={refreshing}
328
+ onClick={handleRefresh}
329
+ size="sm"
330
+ variant="outline"
331
+ >
332
+ {refreshing ? (
333
+ <Loader2Icon className="mr-1.5 size-4 animate-spin" />
334
+ ) : (
335
+ <RefreshCwIcon className="mr-1.5 size-4" />
336
+ )}
337
+ Refresh
338
+ </Button>
339
+ <Button onClick={() => setAddDialogOpen(true)} size="sm">
340
+ <FolderPlusIcon className="mr-1.5 size-4" />
341
+ Add Collection
342
+ </Button>
343
+ </div>
344
+ </div>
345
+ </header>
346
+
347
+ <main className="p-8">
348
+ {/* Error */}
349
+ {error && (
350
+ <Card className="mx-auto mb-6 max-w-md border-destructive bg-destructive/10">
351
+ <CardContent className="py-4 text-center">
352
+ <AlertCircleIcon className="mx-auto mb-2 size-8 text-destructive" />
353
+ <p className="text-destructive">{error}</p>
354
+ <Button
355
+ className="mt-3"
356
+ onClick={() => void loadCollections()}
357
+ size="sm"
358
+ variant="outline"
359
+ >
360
+ Retry
361
+ </Button>
362
+ </CardContent>
363
+ </Card>
364
+ )}
365
+
366
+ {/* Empty state */}
367
+ {!error && collections.length === 0 && (
368
+ <div className="mx-auto max-w-md py-16 text-center">
369
+ <div className="mx-auto mb-4 flex size-16 items-center justify-center rounded-full bg-muted">
370
+ <FolderIcon className="size-8 text-muted-foreground" />
371
+ </div>
372
+ <h2 className="mb-2 font-semibold text-xl">No collections yet</h2>
373
+ <p className="mb-6 text-muted-foreground">
374
+ Add your first collection to start indexing documents.
375
+ </p>
376
+ <Button onClick={() => setAddDialogOpen(true)}>
377
+ <FolderPlusIcon className="mr-2 size-4" />
378
+ Add Collection
379
+ </Button>
380
+ </div>
381
+ )}
382
+
383
+ {/* Collections grid */}
384
+ {!error && collections.length > 0 && (
385
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
386
+ {collections.map((collection) => (
387
+ <CollectionCard
388
+ collection={collection}
389
+ isReindexing={reindexingCollections.has(collection.name)}
390
+ key={collection.name}
391
+ onReindex={() => void handleReindex(collection.name)}
392
+ onRemove={() => setRemoveDialog(collection)}
393
+ />
394
+ ))}
395
+ </div>
396
+ )}
397
+ </main>
398
+
399
+ {/* Add collection dialog */}
400
+ <AddCollectionDialog
401
+ onOpenChange={setAddDialogOpen}
402
+ onSuccess={() => void loadCollections()}
403
+ open={addDialogOpen}
404
+ />
405
+
406
+ {/* Remove confirmation dialog */}
407
+ <Dialog
408
+ onOpenChange={(open) => !open && setRemoveDialog(null)}
409
+ open={!!removeDialog}
410
+ >
411
+ <DialogContent>
412
+ <DialogHeader>
413
+ <DialogTitle>Remove collection</DialogTitle>
414
+ <DialogDescription>
415
+ Are you sure you want to remove{" "}
416
+ <strong>{removeDialog?.name}</strong>? Indexed documents will be
417
+ kept in the database but won't appear in searches.
418
+ </DialogDescription>
419
+ </DialogHeader>
420
+ <DialogFooter className="gap-2 sm:gap-0">
421
+ <Button onClick={() => setRemoveDialog(null)} variant="outline">
422
+ Cancel
423
+ </Button>
424
+ <Button
425
+ disabled={removing}
426
+ onClick={handleRemove}
427
+ variant="destructive"
428
+ >
429
+ {removing && (
430
+ <Loader2Icon className="mr-1.5 size-4 animate-spin" />
431
+ )}
432
+ Remove
433
+ </Button>
434
+ </DialogFooter>
435
+ </DialogContent>
436
+ </Dialog>
437
+ </div>
438
+ );
439
+ }