@gmickel/gno 0.3.4 → 0.4.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 (57) hide show
  1. package/README.md +194 -53
  2. package/assets/badges/license.svg +12 -0
  3. package/assets/badges/npm.svg +13 -0
  4. package/assets/badges/twitter.svg +22 -0
  5. package/assets/badges/website.svg +22 -0
  6. package/package.json +30 -1
  7. package/src/cli/commands/ask.ts +11 -186
  8. package/src/cli/commands/models/pull.ts +9 -4
  9. package/src/cli/commands/serve.ts +19 -0
  10. package/src/cli/program.ts +28 -0
  11. package/src/llm/registry.ts +3 -1
  12. package/src/pipeline/answer.ts +191 -0
  13. package/src/serve/CLAUDE.md +91 -0
  14. package/src/serve/bunfig.toml +2 -0
  15. package/src/serve/context.ts +181 -0
  16. package/src/serve/index.ts +7 -0
  17. package/src/serve/public/app.tsx +56 -0
  18. package/src/serve/public/components/ai-elements/code-block.tsx +176 -0
  19. package/src/serve/public/components/ai-elements/conversation.tsx +98 -0
  20. package/src/serve/public/components/ai-elements/inline-citation.tsx +285 -0
  21. package/src/serve/public/components/ai-elements/loader.tsx +96 -0
  22. package/src/serve/public/components/ai-elements/message.tsx +443 -0
  23. package/src/serve/public/components/ai-elements/prompt-input.tsx +1421 -0
  24. package/src/serve/public/components/ai-elements/sources.tsx +75 -0
  25. package/src/serve/public/components/ai-elements/suggestion.tsx +51 -0
  26. package/src/serve/public/components/preset-selector.tsx +403 -0
  27. package/src/serve/public/components/ui/badge.tsx +46 -0
  28. package/src/serve/public/components/ui/button-group.tsx +82 -0
  29. package/src/serve/public/components/ui/button.tsx +62 -0
  30. package/src/serve/public/components/ui/card.tsx +92 -0
  31. package/src/serve/public/components/ui/carousel.tsx +244 -0
  32. package/src/serve/public/components/ui/collapsible.tsx +31 -0
  33. package/src/serve/public/components/ui/command.tsx +181 -0
  34. package/src/serve/public/components/ui/dialog.tsx +141 -0
  35. package/src/serve/public/components/ui/dropdown-menu.tsx +255 -0
  36. package/src/serve/public/components/ui/hover-card.tsx +42 -0
  37. package/src/serve/public/components/ui/input-group.tsx +167 -0
  38. package/src/serve/public/components/ui/input.tsx +21 -0
  39. package/src/serve/public/components/ui/progress.tsx +28 -0
  40. package/src/serve/public/components/ui/scroll-area.tsx +56 -0
  41. package/src/serve/public/components/ui/select.tsx +188 -0
  42. package/src/serve/public/components/ui/separator.tsx +26 -0
  43. package/src/serve/public/components/ui/table.tsx +114 -0
  44. package/src/serve/public/components/ui/textarea.tsx +18 -0
  45. package/src/serve/public/components/ui/tooltip.tsx +59 -0
  46. package/src/serve/public/globals.css +226 -0
  47. package/src/serve/public/hooks/use-api.ts +112 -0
  48. package/src/serve/public/index.html +13 -0
  49. package/src/serve/public/pages/Ask.tsx +442 -0
  50. package/src/serve/public/pages/Browse.tsx +270 -0
  51. package/src/serve/public/pages/Dashboard.tsx +202 -0
  52. package/src/serve/public/pages/DocView.tsx +302 -0
  53. package/src/serve/public/pages/Search.tsx +335 -0
  54. package/src/serve/routes/api.ts +763 -0
  55. package/src/serve/server.ts +249 -0
  56. package/src/store/sqlite/adapter.ts +47 -0
  57. package/src/store/types.ts +10 -0
@@ -0,0 +1,112 @@
1
+ import { useCallback, useState } from 'react';
2
+
3
+ interface ApiState<T> {
4
+ data: T | null;
5
+ loading: boolean;
6
+ error: string | null;
7
+ }
8
+
9
+ /**
10
+ * Safely parse JSON response, checking Content-Type first.
11
+ */
12
+ async function parseJsonSafe(
13
+ res: Response
14
+ ): Promise<{ json: unknown; parseError: string | null }> {
15
+ const ct = res.headers.get('content-type') ?? '';
16
+ const isJson = ct.includes('application/json');
17
+
18
+ if (!isJson) {
19
+ // Non-JSON response - return text as error context
20
+ const text = await res.text();
21
+ return {
22
+ json: null,
23
+ parseError: text.slice(0, 200) || `Non-JSON response: ${res.status}`,
24
+ };
25
+ }
26
+
27
+ try {
28
+ const json = await res.json();
29
+ return { json, parseError: null };
30
+ } catch {
31
+ return { json: null, parseError: 'Invalid JSON response' };
32
+ }
33
+ }
34
+
35
+ export function useApi<T>() {
36
+ const [state, setState] = useState<ApiState<T>>({
37
+ data: null,
38
+ loading: false,
39
+ error: null,
40
+ });
41
+
42
+ const request = useCallback(
43
+ async (endpoint: string, options?: RequestInit): Promise<T | null> => {
44
+ setState({ data: null, loading: true, error: null });
45
+
46
+ try {
47
+ const res = await fetch(endpoint, {
48
+ headers: { 'Content-Type': 'application/json' },
49
+ ...options,
50
+ });
51
+
52
+ const { json, parseError } = await parseJsonSafe(res);
53
+
54
+ if (parseError) {
55
+ setState({ data: null, loading: false, error: parseError });
56
+ return null;
57
+ }
58
+
59
+ if (!res.ok) {
60
+ const apiError = json as { error?: { message?: string } };
61
+ const msg =
62
+ apiError.error?.message || `Request failed: ${res.status}`;
63
+ setState({ data: null, loading: false, error: msg });
64
+ return null;
65
+ }
66
+
67
+ setState({ data: json as T, loading: false, error: null });
68
+ return json as T;
69
+ } catch (err) {
70
+ const msg = err instanceof Error ? err.message : 'Network error';
71
+ setState({ data: null, loading: false, error: msg });
72
+ return null;
73
+ }
74
+ },
75
+ []
76
+ );
77
+
78
+ return { ...state, request };
79
+ }
80
+
81
+ export async function apiFetch<T>(
82
+ endpoint: string,
83
+ options?: RequestInit
84
+ ): Promise<{ data: T | null; error: string | null }> {
85
+ try {
86
+ const res = await fetch(endpoint, {
87
+ headers: { 'Content-Type': 'application/json' },
88
+ ...options,
89
+ });
90
+
91
+ const { json, parseError } = await parseJsonSafe(res);
92
+
93
+ if (parseError) {
94
+ return { data: null, error: parseError };
95
+ }
96
+
97
+ if (!res.ok) {
98
+ const apiError = json as { error?: { message?: string } };
99
+ return {
100
+ data: null,
101
+ error: apiError.error?.message || `Request failed: ${res.status}`,
102
+ };
103
+ }
104
+
105
+ return { data: json as T, error: null };
106
+ } catch (err) {
107
+ return {
108
+ data: null,
109
+ error: err instanceof Error ? err.message : 'Network error',
110
+ };
111
+ }
112
+ }
@@ -0,0 +1,13 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>GNO - Local Knowledge Index</title>
7
+ <link rel="stylesheet" href="./globals.css">
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="./app.tsx"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,442 @@
1
+ import {
2
+ ArrowLeft,
3
+ BookOpen,
4
+ CornerDownLeft,
5
+ FileText,
6
+ Sparkles,
7
+ } from 'lucide-react';
8
+ import { useEffect, useRef, useState } from 'react';
9
+ import { Loader } from '../components/ai-elements/loader';
10
+ import {
11
+ Source,
12
+ Sources,
13
+ SourcesContent,
14
+ SourcesTrigger,
15
+ } from '../components/ai-elements/sources';
16
+ import { Badge } from '../components/ui/badge';
17
+ import { Button } from '../components/ui/button';
18
+ import { Card, CardContent } from '../components/ui/card';
19
+ import { Textarea } from '../components/ui/textarea';
20
+ import { apiFetch } from '../hooks/use-api';
21
+
22
+ interface PageProps {
23
+ navigate: (to: string | number) => void;
24
+ }
25
+
26
+ interface Citation {
27
+ docid: string;
28
+ uri: string;
29
+ startLine?: number;
30
+ endLine?: number;
31
+ }
32
+
33
+ interface SearchResult {
34
+ docid: string;
35
+ uri: string;
36
+ title?: string;
37
+ snippet: string;
38
+ score: number;
39
+ snippetRange?: {
40
+ startLine: number;
41
+ endLine: number;
42
+ };
43
+ }
44
+
45
+ interface AskResponse {
46
+ query: string;
47
+ mode: string;
48
+ queryLanguage: string;
49
+ answer?: string;
50
+ citations?: Citation[];
51
+ results: SearchResult[];
52
+ meta: {
53
+ expanded: boolean;
54
+ reranked: boolean;
55
+ vectorsUsed: boolean;
56
+ answerGenerated: boolean;
57
+ totalResults: number;
58
+ };
59
+ }
60
+
61
+ interface Capabilities {
62
+ bm25: boolean;
63
+ vector: boolean;
64
+ hybrid: boolean;
65
+ answer: boolean;
66
+ }
67
+
68
+ interface ConversationEntry {
69
+ id: string;
70
+ query: string;
71
+ response: AskResponse | null;
72
+ loading: boolean;
73
+ error?: string;
74
+ }
75
+
76
+ /**
77
+ * Render answer text with clickable citation badges.
78
+ * Citations like [1] become clickable to navigate to source.
79
+ */
80
+ function renderAnswer(
81
+ answer: string,
82
+ citations: Citation[],
83
+ navigate: (to: string) => void
84
+ ): React.ReactNode {
85
+ const parts: React.ReactNode[] = [];
86
+ let key = 0;
87
+
88
+ const citationRegex = /\[(\d+)\]/g;
89
+ let lastIndex = 0;
90
+ let match: RegExpExecArray | null;
91
+
92
+ // biome-ignore lint/suspicious/noAssignInExpressions: Standard regex match pattern
93
+ while ((match = citationRegex.exec(answer)) !== null) {
94
+ // Add text before citation
95
+ if (match.index > lastIndex) {
96
+ parts.push(answer.slice(lastIndex, match.index));
97
+ }
98
+
99
+ const citationNum = Number(match[1]);
100
+ const citation = citations[citationNum - 1];
101
+
102
+ if (citation) {
103
+ parts.push(
104
+ <button
105
+ className="mx-0.5 inline-flex items-center rounded bg-primary/20 px-1.5 py-0.5 font-mono text-primary text-xs transition-colors hover:bg-primary/30"
106
+ key={key++}
107
+ onClick={() =>
108
+ navigate(`/doc?uri=${encodeURIComponent(citation.uri)}`)
109
+ }
110
+ type="button"
111
+ >
112
+ {citationNum}
113
+ </button>
114
+ );
115
+ } else {
116
+ parts.push(match[0]);
117
+ }
118
+
119
+ lastIndex = match.index + match[0].length;
120
+ }
121
+
122
+ // Add remaining text
123
+ if (lastIndex < answer.length) {
124
+ parts.push(answer.slice(lastIndex));
125
+ }
126
+
127
+ return parts;
128
+ }
129
+
130
+ export default function Ask({ navigate }: PageProps) {
131
+ const [query, setQuery] = useState('');
132
+ const [conversation, setConversation] = useState<ConversationEntry[]>([]);
133
+ const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
134
+ const messagesEndRef = useRef<HTMLDivElement>(null);
135
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
136
+
137
+ // Fetch capabilities on mount
138
+ useEffect(() => {
139
+ async function fetchCapabilities() {
140
+ const { data } = await apiFetch<Capabilities>('/api/capabilities');
141
+ if (data) {
142
+ setCapabilities(data);
143
+ }
144
+ }
145
+ fetchCapabilities();
146
+ }, []);
147
+
148
+ // Scroll to bottom when conversation updates
149
+ useEffect(() => {
150
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
151
+ }, []);
152
+
153
+ const handleSubmit = async (e: React.FormEvent) => {
154
+ e.preventDefault();
155
+ if (!query.trim()) {
156
+ return;
157
+ }
158
+
159
+ const entryId = crypto.randomUUID();
160
+ const currentQuery = query.trim();
161
+
162
+ // Add entry to conversation
163
+ setConversation((prev) => [
164
+ ...prev,
165
+ { id: entryId, query: currentQuery, response: null, loading: true },
166
+ ]);
167
+ setQuery('');
168
+
169
+ // Make API call
170
+ const { data, error } = await apiFetch<AskResponse>('/api/ask', {
171
+ method: 'POST',
172
+ body: JSON.stringify({ query: currentQuery, limit: 5 }),
173
+ });
174
+
175
+ // Update conversation with response
176
+ setConversation((prev) =>
177
+ prev.map((entry) =>
178
+ entry.id === entryId
179
+ ? {
180
+ ...entry,
181
+ response: data ?? null,
182
+ loading: false,
183
+ error: error ?? undefined,
184
+ }
185
+ : entry
186
+ )
187
+ );
188
+ };
189
+
190
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
191
+ if (e.key === 'Enter' && !e.shiftKey) {
192
+ e.preventDefault();
193
+ handleSubmit(e);
194
+ }
195
+ };
196
+
197
+ const answerAvailable = capabilities?.answer ?? false;
198
+
199
+ return (
200
+ <div className="flex min-h-screen flex-col">
201
+ {/* Header */}
202
+ <header className="glass sticky top-0 z-10 border-border/50 border-b">
203
+ <div className="flex items-center gap-4 px-8 py-4">
204
+ <Button
205
+ className="gap-2"
206
+ onClick={() => navigate(-1)}
207
+ size="sm"
208
+ variant="ghost"
209
+ >
210
+ <ArrowLeft className="size-4" />
211
+ Back
212
+ </Button>
213
+ <h1 className="font-semibold text-xl">Ask</h1>
214
+ {capabilities && (
215
+ <div className="ml-auto flex items-center gap-2">
216
+ {capabilities.vector && (
217
+ <Badge className="font-mono text-[10px]" variant="secondary">
218
+ vectors
219
+ </Badge>
220
+ )}
221
+ {capabilities.answer && (
222
+ <Badge className="font-mono text-[10px]" variant="secondary">
223
+ AI
224
+ </Badge>
225
+ )}
226
+ </div>
227
+ )}
228
+ </div>
229
+ </header>
230
+
231
+ {/* Conversation area */}
232
+ <main className="flex-1 overflow-y-auto p-8">
233
+ <div className="mx-auto max-w-3xl space-y-8">
234
+ {/* Empty state */}
235
+ {conversation.length === 0 && (
236
+ <div className="py-20 text-center">
237
+ <Sparkles className="mx-auto mb-4 size-12 text-primary/60" />
238
+ <h2 className="mb-2 font-medium text-lg">Ask anything</h2>
239
+ <p className="text-muted-foreground">
240
+ {answerAvailable
241
+ ? 'Get AI-powered answers with citations from your documents'
242
+ : 'AI answers not available. Install a generation model to enable.'}
243
+ </p>
244
+ </div>
245
+ )}
246
+
247
+ {/* Conversation entries */}
248
+ {conversation.map((entry) => (
249
+ <div className="space-y-4" key={entry.id}>
250
+ {/* User query */}
251
+ <div className="flex justify-end">
252
+ <div className="max-w-[80%] rounded-lg bg-secondary px-4 py-3">
253
+ <p className="text-foreground">{entry.query}</p>
254
+ </div>
255
+ </div>
256
+
257
+ {/* AI response */}
258
+ <div className="space-y-3">
259
+ {entry.loading && (
260
+ <div className="flex items-center gap-3">
261
+ <Loader className="text-primary" size={20} />
262
+ <span className="text-muted-foreground text-sm">
263
+ Thinking...
264
+ </span>
265
+ </div>
266
+ )}
267
+
268
+ {entry.error && (
269
+ <Card className="border-destructive bg-destructive/10">
270
+ <CardContent className="py-4 text-destructive">
271
+ {entry.error}
272
+ </CardContent>
273
+ </Card>
274
+ )}
275
+
276
+ {entry.response && (
277
+ <>
278
+ {/* Answer */}
279
+ {entry.response.answer && (
280
+ <div className="prose prose-sm prose-invert max-w-none rounded-lg bg-card/50 p-4">
281
+ <p className="whitespace-pre-wrap leading-relaxed">
282
+ {renderAnswer(
283
+ entry.response.answer,
284
+ entry.response.citations ?? [],
285
+ navigate
286
+ )}
287
+ </p>
288
+ </div>
289
+ )}
290
+
291
+ {/* Citations */}
292
+ {entry.response.citations &&
293
+ entry.response.citations.length > 0 && (
294
+ <Sources defaultOpen>
295
+ <SourcesTrigger
296
+ count={entry.response.citations.length}
297
+ />
298
+ <SourcesContent>
299
+ {entry.response.citations.map((c, i) => (
300
+ <Source
301
+ href="#"
302
+ key={`${c.docid}-${i}`}
303
+ onClick={(e) => {
304
+ e.preventDefault();
305
+ navigate(
306
+ `/doc?uri=${encodeURIComponent(c.uri)}`
307
+ );
308
+ }}
309
+ title={`[${i + 1}] ${c.uri.split('/').pop()}`}
310
+ >
311
+ <BookOpen className="size-4 shrink-0" />
312
+ <span className="truncate">
313
+ [{i + 1}] {c.uri.split('/').pop()}
314
+ </span>
315
+ {c.startLine && (
316
+ <span className="ml-1 font-mono text-[10px] text-muted-foreground/60">
317
+ L{c.startLine}
318
+ {c.endLine && `-${c.endLine}`}
319
+ </span>
320
+ )}
321
+ </Source>
322
+ ))}
323
+ </SourcesContent>
324
+ </Sources>
325
+ )}
326
+
327
+ {/* Meta info */}
328
+ <div className="flex items-center gap-2 text-muted-foreground/60 text-xs">
329
+ <span>{entry.response.results.length} results</span>
330
+ {entry.response.meta.vectorsUsed && (
331
+ <Badge
332
+ className="font-mono text-[9px]"
333
+ variant="outline"
334
+ >
335
+ hybrid
336
+ </Badge>
337
+ )}
338
+ {entry.response.meta.expanded && (
339
+ <Badge
340
+ className="font-mono text-[9px]"
341
+ variant="outline"
342
+ >
343
+ expanded
344
+ </Badge>
345
+ )}
346
+ </div>
347
+
348
+ {/* Show retrieved sources if no answer */}
349
+ {!entry.response.answer &&
350
+ entry.response.results.length > 0 && (
351
+ <div className="space-y-2">
352
+ <p className="font-medium text-muted-foreground text-sm">
353
+ Search results:
354
+ </p>
355
+ {entry.response.results.map((r, i) => (
356
+ <Card
357
+ className="cursor-pointer transition-colors hover:border-primary/50"
358
+ key={`${r.docid}-${i}`}
359
+ onClick={() =>
360
+ navigate(
361
+ `/doc?uri=${encodeURIComponent(r.uri)}`
362
+ )
363
+ }
364
+ >
365
+ <CardContent className="py-3">
366
+ <div className="flex items-start justify-between gap-2">
367
+ <div className="min-w-0">
368
+ <p className="font-medium text-primary text-sm">
369
+ {r.title || r.uri.split('/').pop()}
370
+ </p>
371
+ <p className="line-clamp-2 text-muted-foreground text-xs">
372
+ {r.snippet.slice(0, 200)}...
373
+ </p>
374
+ </div>
375
+ <Badge
376
+ className="shrink-0 font-mono text-[10px]"
377
+ variant="secondary"
378
+ >
379
+ {(r.score * 100).toFixed(0)}%
380
+ </Badge>
381
+ </div>
382
+ </CardContent>
383
+ </Card>
384
+ ))}
385
+ </div>
386
+ )}
387
+
388
+ {/* No results */}
389
+ {!entry.response.answer &&
390
+ entry.response.results.length === 0 && (
391
+ <div className="flex items-center gap-2 text-muted-foreground">
392
+ <FileText className="size-4" />
393
+ <span className="text-sm">
394
+ No relevant results found
395
+ </span>
396
+ </div>
397
+ )}
398
+ </>
399
+ )}
400
+ </div>
401
+ </div>
402
+ ))}
403
+
404
+ <div ref={messagesEndRef} />
405
+ </div>
406
+ </main>
407
+
408
+ {/* Input area */}
409
+ <footer className="glass sticky bottom-0 border-border/50 border-t">
410
+ <form
411
+ className="mx-auto flex max-w-3xl items-end gap-3 p-4"
412
+ onSubmit={handleSubmit}
413
+ >
414
+ <div className="relative flex-1">
415
+ <Textarea
416
+ className="min-h-[60px] resize-none pr-12"
417
+ disabled={!answerAvailable}
418
+ onChange={(e) => setQuery(e.target.value)}
419
+ onKeyDown={handleKeyDown}
420
+ placeholder={
421
+ answerAvailable
422
+ ? 'Ask a question about your documents...'
423
+ : 'AI answers not available'
424
+ }
425
+ ref={textareaRef}
426
+ rows={1}
427
+ value={query}
428
+ />
429
+ <Button
430
+ className="absolute right-2 bottom-2"
431
+ disabled={!(query.trim() && answerAvailable)}
432
+ size="icon-sm"
433
+ type="submit"
434
+ >
435
+ <CornerDownLeft className="size-4" />
436
+ </Button>
437
+ </div>
438
+ </form>
439
+ </footer>
440
+ </div>
441
+ );
442
+ }