@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.
- package/README.md +194 -53
- package/assets/badges/license.svg +12 -0
- package/assets/badges/npm.svg +13 -0
- package/assets/badges/twitter.svg +22 -0
- package/assets/badges/website.svg +22 -0
- package/package.json +30 -1
- package/src/cli/commands/ask.ts +11 -186
- package/src/cli/commands/models/pull.ts +9 -4
- package/src/cli/commands/serve.ts +19 -0
- package/src/cli/program.ts +28 -0
- package/src/llm/registry.ts +3 -1
- package/src/pipeline/answer.ts +191 -0
- package/src/serve/CLAUDE.md +91 -0
- package/src/serve/bunfig.toml +2 -0
- package/src/serve/context.ts +181 -0
- package/src/serve/index.ts +7 -0
- package/src/serve/public/app.tsx +56 -0
- package/src/serve/public/components/ai-elements/code-block.tsx +176 -0
- package/src/serve/public/components/ai-elements/conversation.tsx +98 -0
- package/src/serve/public/components/ai-elements/inline-citation.tsx +285 -0
- package/src/serve/public/components/ai-elements/loader.tsx +96 -0
- package/src/serve/public/components/ai-elements/message.tsx +443 -0
- package/src/serve/public/components/ai-elements/prompt-input.tsx +1421 -0
- package/src/serve/public/components/ai-elements/sources.tsx +75 -0
- package/src/serve/public/components/ai-elements/suggestion.tsx +51 -0
- package/src/serve/public/components/preset-selector.tsx +403 -0
- package/src/serve/public/components/ui/badge.tsx +46 -0
- package/src/serve/public/components/ui/button-group.tsx +82 -0
- package/src/serve/public/components/ui/button.tsx +62 -0
- package/src/serve/public/components/ui/card.tsx +92 -0
- package/src/serve/public/components/ui/carousel.tsx +244 -0
- package/src/serve/public/components/ui/collapsible.tsx +31 -0
- package/src/serve/public/components/ui/command.tsx +181 -0
- package/src/serve/public/components/ui/dialog.tsx +141 -0
- package/src/serve/public/components/ui/dropdown-menu.tsx +255 -0
- package/src/serve/public/components/ui/hover-card.tsx +42 -0
- package/src/serve/public/components/ui/input-group.tsx +167 -0
- package/src/serve/public/components/ui/input.tsx +21 -0
- package/src/serve/public/components/ui/progress.tsx +28 -0
- package/src/serve/public/components/ui/scroll-area.tsx +56 -0
- package/src/serve/public/components/ui/select.tsx +188 -0
- package/src/serve/public/components/ui/separator.tsx +26 -0
- package/src/serve/public/components/ui/table.tsx +114 -0
- package/src/serve/public/components/ui/textarea.tsx +18 -0
- package/src/serve/public/components/ui/tooltip.tsx +59 -0
- package/src/serve/public/globals.css +226 -0
- package/src/serve/public/hooks/use-api.ts +112 -0
- package/src/serve/public/index.html +13 -0
- package/src/serve/public/pages/Ask.tsx +442 -0
- package/src/serve/public/pages/Browse.tsx +270 -0
- package/src/serve/public/pages/Dashboard.tsx +202 -0
- package/src/serve/public/pages/DocView.tsx +302 -0
- package/src/serve/public/pages/Search.tsx +335 -0
- package/src/serve/routes/api.ts +763 -0
- package/src/serve/server.ts +249 -0
- package/src/store/sqlite/adapter.ts +47 -0
- 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[]
|
package/src/store/types.ts
CHANGED
|
@@ -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.
|