@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,249 @@
1
+ /**
2
+ * Bun.serve() web server for GNO web UI.
3
+ * Uses Bun's fullstack dev server with HTML imports.
4
+ * Opens DB once at startup, closes on shutdown.
5
+ *
6
+ * @module src/serve/server
7
+ */
8
+
9
+ import { getIndexDbPath } from '../app/constants';
10
+ import { getConfigPaths, isInitialized, loadConfig } from '../config';
11
+ import { SqliteAdapter } from '../store/sqlite/adapter';
12
+ import { createServerContext, disposeServerContext } from './context';
13
+ // HTML import - Bun handles bundling TSX/CSS automatically via routes
14
+ import homepage from './public/index.html';
15
+ import {
16
+ handleAsk,
17
+ handleCapabilities,
18
+ handleCollections,
19
+ handleDoc,
20
+ handleDocs,
21
+ handleHealth,
22
+ handleModelPull,
23
+ handleModelStatus,
24
+ handlePresets,
25
+ handleQuery,
26
+ handleSearch,
27
+ handleSetPreset,
28
+ handleStatus,
29
+ } from './routes/api';
30
+
31
+ export interface ServeOptions {
32
+ /** Port to listen on (default: 3000) */
33
+ port?: number;
34
+ /** Config path override */
35
+ configPath?: string;
36
+ /** Index name (from --index flag) */
37
+ index?: string;
38
+ }
39
+
40
+ export interface ServeResult {
41
+ success: boolean;
42
+ error?: string;
43
+ }
44
+
45
+ // Hostname parsing helpers - preserved for future fetch handler use
46
+ // function parseHostname(host: string): string { ... }
47
+ // function isLoopback(hostname: string): boolean { ... }
48
+
49
+ /**
50
+ * Get CSP based on environment.
51
+ * Dev mode allows WebSocket connections for HMR.
52
+ */
53
+ function getCspHeader(isDev: boolean): string {
54
+ // Local fonts only - no Google Fonts for true offline-first
55
+ const base = [
56
+ "default-src 'self'",
57
+ "script-src 'self'",
58
+ "style-src 'self' 'unsafe-inline'",
59
+ "font-src 'self'",
60
+ "img-src 'self' data: blob:",
61
+ "frame-ancestors 'none'",
62
+ "base-uri 'none'", // Prevent base tag injection
63
+ "object-src 'none'", // Prevent plugin execution
64
+ ];
65
+
66
+ // Dev mode: allow WebSocket for HMR
67
+ if (isDev) {
68
+ base.push("connect-src 'self' ws:");
69
+ } else {
70
+ base.push("connect-src 'self'");
71
+ }
72
+
73
+ return base.join('; ');
74
+ }
75
+
76
+ /**
77
+ * Apply security headers to a Response.
78
+ */
79
+ function withSecurityHeaders(response: Response, isDev: boolean): Response {
80
+ const headers = new Headers(response.headers);
81
+ headers.set('Content-Security-Policy', getCspHeader(isDev));
82
+ headers.set('X-Content-Type-Options', 'nosniff');
83
+ headers.set('X-Frame-Options', 'DENY');
84
+ headers.set('Referrer-Policy', 'no-referrer');
85
+ headers.set('Cross-Origin-Resource-Policy', 'same-origin');
86
+
87
+ return new Response(response.body, {
88
+ status: response.status,
89
+ statusText: response.statusText,
90
+ headers,
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Start the web server.
96
+ * Opens DB once, closes on SIGINT/SIGTERM.
97
+ */
98
+ export async function startServer(
99
+ options: ServeOptions = {}
100
+ ): Promise<ServeResult> {
101
+ const port = options.port ?? 3000;
102
+ const isDev = process.env.NODE_ENV !== 'production';
103
+
104
+ // Check initialization
105
+ const initialized = await isInitialized(options.configPath);
106
+ if (!initialized) {
107
+ return { success: false, error: 'GNO not initialized. Run: gno init' };
108
+ }
109
+
110
+ // Load config
111
+ const configResult = await loadConfig(options.configPath);
112
+ if (!configResult.ok) {
113
+ return { success: false, error: configResult.error.message };
114
+ }
115
+ const config = configResult.value;
116
+
117
+ // Open database once for server lifetime
118
+ const store = new SqliteAdapter();
119
+ const dbPath = getIndexDbPath(options.index);
120
+ // Use actual config path (from options or default) for consistency
121
+ const paths = getConfigPaths();
122
+ const actualConfigPath = options.configPath ?? paths.configFile;
123
+ store.setConfigPath(actualConfigPath);
124
+
125
+ const openResult = await store.open(dbPath, config.ftsTokenizer);
126
+ if (!openResult.ok) {
127
+ return { success: false, error: openResult.error.message };
128
+ }
129
+
130
+ // Create server context with LLM ports for hybrid search and AI answers
131
+ // Use holder pattern to allow hot-reloading presets
132
+ const ctxHolder = {
133
+ current: await createServerContext(store, config),
134
+ config, // Keep original config for reloading
135
+ };
136
+
137
+ // Shutdown controller for clean lifecycle
138
+ const shutdownController = new AbortController();
139
+
140
+ // Graceful shutdown handler
141
+ const shutdown = async () => {
142
+ console.log('\nShutting down...');
143
+ await disposeServerContext(ctxHolder.current);
144
+ await store.close();
145
+ shutdownController.abort();
146
+ };
147
+
148
+ process.once('SIGINT', shutdown);
149
+ process.once('SIGTERM', shutdown);
150
+
151
+ // Start server with try/catch for port-in-use etc.
152
+ let server: ReturnType<typeof Bun.serve>;
153
+ try {
154
+ server = Bun.serve({
155
+ port,
156
+ hostname: '127.0.0.1', // Loopback only - no LAN exposure
157
+
158
+ // Enable development mode for HMR and console logging
159
+ development: isDev,
160
+
161
+ // Routes object - Bun handles HTML bundling and /_bun/* assets automatically
162
+ routes: {
163
+ // SPA routes - all serve the same React app
164
+ '/': homepage,
165
+ '/search': homepage,
166
+ '/browse': homepage,
167
+ '/doc': homepage,
168
+ '/ask': homepage,
169
+
170
+ // API routes
171
+ '/api/health': {
172
+ GET: () => withSecurityHeaders(handleHealth(), isDev),
173
+ },
174
+ '/api/status': {
175
+ GET: async () =>
176
+ withSecurityHeaders(await handleStatus(store), isDev),
177
+ },
178
+ '/api/collections': {
179
+ GET: async () =>
180
+ withSecurityHeaders(await handleCollections(store), isDev),
181
+ },
182
+ '/api/docs': {
183
+ GET: async (req: Request) => {
184
+ const url = new URL(req.url);
185
+ return withSecurityHeaders(await handleDocs(store, url), isDev);
186
+ },
187
+ },
188
+ '/api/doc': {
189
+ GET: async (req: Request) => {
190
+ const url = new URL(req.url);
191
+ return withSecurityHeaders(await handleDoc(store, url), isDev);
192
+ },
193
+ },
194
+ '/api/search': {
195
+ POST: async (req: Request) =>
196
+ withSecurityHeaders(await handleSearch(store, req), isDev),
197
+ },
198
+ '/api/query': {
199
+ POST: async (req: Request) =>
200
+ withSecurityHeaders(
201
+ await handleQuery(ctxHolder.current, req),
202
+ isDev
203
+ ),
204
+ },
205
+ '/api/ask': {
206
+ POST: async (req: Request) =>
207
+ withSecurityHeaders(await handleAsk(ctxHolder.current, req), isDev),
208
+ },
209
+ '/api/capabilities': {
210
+ GET: () =>
211
+ withSecurityHeaders(handleCapabilities(ctxHolder.current), isDev),
212
+ },
213
+ '/api/presets': {
214
+ GET: () =>
215
+ withSecurityHeaders(handlePresets(ctxHolder.current), isDev),
216
+ POST: async (req: Request) =>
217
+ withSecurityHeaders(await handleSetPreset(ctxHolder, req), isDev),
218
+ },
219
+ '/api/models/status': {
220
+ GET: () => withSecurityHeaders(handleModelStatus(), isDev),
221
+ },
222
+ '/api/models/pull': {
223
+ POST: () => withSecurityHeaders(handleModelPull(ctxHolder), isDev),
224
+ },
225
+ },
226
+
227
+ // No fetch fallback - let Bun handle /_bun/* assets and return 404 for others
228
+ });
229
+ } catch (e) {
230
+ await store.close();
231
+ return {
232
+ success: false,
233
+ error: e instanceof Error ? e.message : String(e),
234
+ };
235
+ }
236
+
237
+ console.log(`GNO server running at http://localhost:${server.port}`);
238
+ console.log('Press Ctrl+C to stop');
239
+
240
+ // Block until shutdown signal
241
+ await new Promise<void>((resolve) => {
242
+ shutdownController.signal.addEventListener('abort', () => resolve(), {
243
+ once: true,
244
+ });
245
+ });
246
+
247
+ server.stop(true);
248
+ return { success: true };
249
+ }
@@ -487,6 +487,53 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
487
487
  }
488
488
  }
489
489
 
490
+ async listDocumentsPaginated(options: {
491
+ collection?: string;
492
+ limit: number;
493
+ offset: number;
494
+ }): Promise<StoreResult<{ documents: DocumentRow[]; total: number }>> {
495
+ try {
496
+ const db = this.ensureOpen();
497
+ const { collection, limit, offset } = options;
498
+
499
+ // Get total count
500
+ const countRow = collection
501
+ ? db
502
+ .query<{ count: number }, [string]>(
503
+ 'SELECT COUNT(*) as count FROM documents WHERE collection = ?'
504
+ )
505
+ .get(collection)
506
+ : db
507
+ .query<{ count: number }, []>(
508
+ 'SELECT COUNT(*) as count FROM documents'
509
+ )
510
+ .get();
511
+
512
+ const total = countRow?.count ?? 0;
513
+
514
+ // Get paginated documents
515
+ const rows = collection
516
+ ? db
517
+ .query<DbDocumentRow, [string, number, number]>(
518
+ 'SELECT * FROM documents WHERE collection = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
519
+ )
520
+ .all(collection, limit, offset)
521
+ : db
522
+ .query<DbDocumentRow, [number, number]>(
523
+ 'SELECT * FROM documents ORDER BY updated_at DESC LIMIT ? OFFSET ?'
524
+ )
525
+ .all(limit, offset);
526
+
527
+ return ok({ documents: rows.map(mapDocumentRow), total });
528
+ } catch (cause) {
529
+ return err(
530
+ 'QUERY_FAILED',
531
+ cause instanceof Error ? cause.message : 'Failed to list documents',
532
+ cause
533
+ );
534
+ }
535
+ }
536
+
490
537
  async markInactive(
491
538
  collection: string,
492
539
  relPaths: string[]
@@ -399,6 +399,16 @@ export interface StorePort {
399
399
  */
400
400
  listDocuments(collection?: string): Promise<StoreResult<DocumentRow[]>>;
401
401
 
402
+ /**
403
+ * List documents with pagination support.
404
+ * Returns documents and total count for efficient browsing.
405
+ */
406
+ listDocumentsPaginated(options: {
407
+ collection?: string;
408
+ limit: number;
409
+ offset: number;
410
+ }): Promise<StoreResult<{ documents: DocumentRow[]; total: number }>>;
411
+
402
412
  /**
403
413
  * Mark documents as inactive (soft delete).
404
414
  * Returns count of affected documents.