@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,763 @@
1
+ /**
2
+ * REST API routes for GNO web UI.
3
+ * All routes return JSON with consistent error format.
4
+ *
5
+ * @module src/serve/routes/api
6
+ */
7
+
8
+ import { modelsPull } from '../../cli/commands/models/pull';
9
+ import type { Config, ModelPreset } from '../../config/types';
10
+ import { getModelConfig, getPreset, listPresets } from '../../llm/registry';
11
+ import {
12
+ generateGroundedAnswer,
13
+ processAnswerResult,
14
+ } from '../../pipeline/answer';
15
+ import { searchHybrid } from '../../pipeline/hybrid';
16
+ import { searchBm25 } from '../../pipeline/search';
17
+ import type { AskResult, Citation, SearchOptions } from '../../pipeline/types';
18
+ import type { SqliteAdapter } from '../../store/sqlite/adapter';
19
+ import {
20
+ downloadState,
21
+ reloadServerContext,
22
+ resetDownloadState,
23
+ type ServerContext,
24
+ } from '../context';
25
+
26
+ /** Mutable context holder for hot-reloading presets */
27
+ export interface ContextHolder {
28
+ current: ServerContext;
29
+ config: Config;
30
+ }
31
+
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+ // Types
34
+ // ─────────────────────────────────────────────────────────────────────────────
35
+
36
+ export interface ApiError {
37
+ error: {
38
+ code: string;
39
+ message: string;
40
+ };
41
+ }
42
+
43
+ export interface SearchRequestBody {
44
+ query: string;
45
+ // Only BM25 supported in web UI (vector/hybrid require LLM deps)
46
+ limit?: number;
47
+ minScore?: number;
48
+ collection?: string;
49
+ }
50
+
51
+ export interface QueryRequestBody {
52
+ query: string;
53
+ limit?: number;
54
+ minScore?: number;
55
+ collection?: string;
56
+ lang?: string;
57
+ noExpand?: boolean;
58
+ noRerank?: boolean;
59
+ }
60
+
61
+ export interface AskRequestBody {
62
+ query: string;
63
+ limit?: number;
64
+ collection?: string;
65
+ lang?: string;
66
+ maxAnswerTokens?: number;
67
+ }
68
+
69
+ // ─────────────────────────────────────────────────────────────────────────────
70
+ // Helpers
71
+ // ─────────────────────────────────────────────────────────────────────────────
72
+
73
+ function jsonResponse(data: unknown, status = 200): Response {
74
+ return Response.json(data, { status });
75
+ }
76
+
77
+ function errorResponse(code: string, message: string, status = 400): Response {
78
+ return jsonResponse({ error: { code, message } }, status);
79
+ }
80
+
81
+ // ─────────────────────────────────────────────────────────────────────────────
82
+ // Route Handlers
83
+ // ─────────────────────────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * GET /api/health
87
+ * Health check endpoint.
88
+ */
89
+ export function handleHealth(): Response {
90
+ return jsonResponse({ ok: true });
91
+ }
92
+
93
+ /**
94
+ * GET /api/status
95
+ * Returns index status matching status.schema.json.
96
+ */
97
+ export async function handleStatus(store: SqliteAdapter): Promise<Response> {
98
+ const result = await store.getStatus();
99
+ if (!result.ok) {
100
+ return errorResponse('RUNTIME', result.error.message, 500);
101
+ }
102
+
103
+ const s = result.value;
104
+ return jsonResponse({
105
+ indexName: s.indexName,
106
+ configPath: s.configPath,
107
+ dbPath: s.dbPath,
108
+ collections: s.collections.map((c) => ({
109
+ name: c.name,
110
+ path: c.path,
111
+ documentCount: c.activeDocuments,
112
+ chunkCount: c.totalChunks,
113
+ embeddedCount: c.embeddedChunks,
114
+ })),
115
+ totalDocuments: s.activeDocuments,
116
+ totalChunks: s.totalChunks,
117
+ embeddingBacklog: s.embeddingBacklog,
118
+ lastUpdated: s.lastUpdatedAt,
119
+ healthy: s.healthy,
120
+ });
121
+ }
122
+
123
+ /**
124
+ * GET /api/collections
125
+ * Returns list of collections.
126
+ */
127
+ export async function handleCollections(
128
+ store: SqliteAdapter
129
+ ): Promise<Response> {
130
+ const result = await store.getCollections();
131
+ if (!result.ok) {
132
+ return errorResponse('RUNTIME', result.error.message, 500);
133
+ }
134
+
135
+ return jsonResponse(
136
+ result.value.map((c) => ({
137
+ name: c.name,
138
+ path: c.path,
139
+ }))
140
+ );
141
+ }
142
+
143
+ /**
144
+ * GET /api/docs
145
+ * Query params: collection, limit (default 20), offset (default 0)
146
+ * Returns paginated document list.
147
+ */
148
+ export async function handleDocs(
149
+ store: SqliteAdapter,
150
+ url: URL
151
+ ): Promise<Response> {
152
+ const collection = url.searchParams.get('collection') || undefined;
153
+
154
+ // Validate limit: positive integer, max 100
155
+ const limitParam = Number(url.searchParams.get('limit'));
156
+ if (
157
+ url.searchParams.has('limit') &&
158
+ (Number.isNaN(limitParam) || limitParam < 1)
159
+ ) {
160
+ return errorResponse('VALIDATION', 'limit must be a positive integer');
161
+ }
162
+ const limit = Math.min(limitParam || 20, 100);
163
+
164
+ // Validate offset: non-negative integer
165
+ const offsetParam = Number(url.searchParams.get('offset'));
166
+ if (
167
+ url.searchParams.has('offset') &&
168
+ (Number.isNaN(offsetParam) || offsetParam < 0)
169
+ ) {
170
+ return errorResponse('VALIDATION', 'offset must be a non-negative integer');
171
+ }
172
+ const offset = offsetParam || 0;
173
+
174
+ const result = await store.listDocumentsPaginated({
175
+ collection,
176
+ limit,
177
+ offset,
178
+ });
179
+
180
+ if (!result.ok) {
181
+ return errorResponse('RUNTIME', result.error.message, 500);
182
+ }
183
+
184
+ const { documents, total } = result.value;
185
+
186
+ return jsonResponse({
187
+ documents: documents.map((doc) => ({
188
+ docid: doc.docid,
189
+ uri: doc.uri,
190
+ title: doc.title,
191
+ collection: doc.collection,
192
+ relPath: doc.relPath,
193
+ sourceExt: doc.sourceExt,
194
+ sourceMime: doc.sourceMime,
195
+ updatedAt: doc.updatedAt,
196
+ })),
197
+ total,
198
+ limit,
199
+ offset,
200
+ });
201
+ }
202
+
203
+ /**
204
+ * GET /api/doc
205
+ * Query params: uri (required)
206
+ * Returns single document with content.
207
+ */
208
+ export async function handleDoc(
209
+ store: SqliteAdapter,
210
+ url: URL
211
+ ): Promise<Response> {
212
+ const uri = url.searchParams.get('uri');
213
+ if (!uri) {
214
+ return errorResponse('VALIDATION', 'Missing uri parameter');
215
+ }
216
+
217
+ const docResult = await store.getDocumentByUri(uri);
218
+ if (!docResult.ok) {
219
+ return errorResponse('RUNTIME', docResult.error.message, 500);
220
+ }
221
+ if (!docResult.value) {
222
+ return errorResponse('NOT_FOUND', 'Document not found', 404);
223
+ }
224
+
225
+ const doc = docResult.value;
226
+ let content: string | null = null;
227
+
228
+ if (doc.mirrorHash) {
229
+ const contentResult = await store.getContent(doc.mirrorHash);
230
+ if (contentResult.ok && contentResult.value) {
231
+ content = contentResult.value;
232
+ }
233
+ }
234
+
235
+ return jsonResponse({
236
+ docid: doc.docid,
237
+ uri: doc.uri,
238
+ title: doc.title,
239
+ content,
240
+ contentAvailable: content !== null,
241
+ collection: doc.collection,
242
+ relPath: doc.relPath,
243
+ source: {
244
+ mime: doc.sourceMime,
245
+ ext: doc.sourceExt,
246
+ modifiedAt: doc.sourceMtime,
247
+ sizeBytes: doc.sourceSize,
248
+ },
249
+ });
250
+ }
251
+
252
+ /**
253
+ * POST /api/search
254
+ * Body: { query, mode?, limit?, minScore?, collection? }
255
+ * Returns search results.
256
+ */
257
+ export async function handleSearch(
258
+ store: SqliteAdapter,
259
+ req: Request
260
+ ): Promise<Response> {
261
+ let body: SearchRequestBody;
262
+ try {
263
+ body = (await req.json()) as SearchRequestBody;
264
+ } catch {
265
+ return errorResponse('VALIDATION', 'Invalid JSON body');
266
+ }
267
+
268
+ if (!body.query || typeof body.query !== 'string') {
269
+ return errorResponse('VALIDATION', 'Missing or invalid query');
270
+ }
271
+
272
+ const query = body.query.trim();
273
+ if (!query) {
274
+ return errorResponse('VALIDATION', 'Query cannot be empty');
275
+ }
276
+
277
+ // Validate limit: positive integer
278
+ if (
279
+ body.limit !== undefined &&
280
+ (typeof body.limit !== 'number' || body.limit < 1)
281
+ ) {
282
+ return errorResponse('VALIDATION', 'limit must be a positive integer');
283
+ }
284
+
285
+ // Validate minScore: number between 0 and 1
286
+ if (
287
+ body.minScore !== undefined &&
288
+ (typeof body.minScore !== 'number' ||
289
+ body.minScore < 0 ||
290
+ body.minScore > 1)
291
+ ) {
292
+ return errorResponse(
293
+ 'VALIDATION',
294
+ 'minScore must be a number between 0 and 1'
295
+ );
296
+ }
297
+
298
+ // Only BM25 supported in web UI (vector/hybrid require LLM ports)
299
+ const options: SearchOptions = {
300
+ limit: Math.min(body.limit || 10, 50),
301
+ minScore: body.minScore,
302
+ collection: body.collection,
303
+ };
304
+
305
+ const result = await searchBm25(store, query, options);
306
+
307
+ if (!result.ok) {
308
+ return errorResponse('RUNTIME', result.error.message, 500);
309
+ }
310
+
311
+ return jsonResponse(result.value);
312
+ }
313
+
314
+ /**
315
+ * POST /api/query
316
+ * Body: { query, limit?, minScore?, collection?, lang?, noExpand?, noRerank? }
317
+ * Returns hybrid search results (BM25 + vector + expansion + reranking).
318
+ */
319
+ export async function handleQuery(
320
+ ctx: ServerContext,
321
+ req: Request
322
+ ): Promise<Response> {
323
+ let body: QueryRequestBody;
324
+ try {
325
+ body = (await req.json()) as QueryRequestBody;
326
+ } catch {
327
+ return errorResponse('VALIDATION', 'Invalid JSON body');
328
+ }
329
+
330
+ if (!body.query || typeof body.query !== 'string') {
331
+ return errorResponse('VALIDATION', 'Missing or invalid query');
332
+ }
333
+
334
+ const query = body.query.trim();
335
+ if (!query) {
336
+ return errorResponse('VALIDATION', 'Query cannot be empty');
337
+ }
338
+
339
+ // Validate limit
340
+ if (
341
+ body.limit !== undefined &&
342
+ (typeof body.limit !== 'number' || body.limit < 1)
343
+ ) {
344
+ return errorResponse('VALIDATION', 'limit must be a positive integer');
345
+ }
346
+
347
+ // Validate minScore
348
+ if (
349
+ body.minScore !== undefined &&
350
+ (typeof body.minScore !== 'number' ||
351
+ body.minScore < 0 ||
352
+ body.minScore > 1)
353
+ ) {
354
+ return errorResponse(
355
+ 'VALIDATION',
356
+ 'minScore must be a number between 0 and 1'
357
+ );
358
+ }
359
+
360
+ const result = await searchHybrid(
361
+ {
362
+ store: ctx.store,
363
+ config: ctx.config,
364
+ vectorIndex: ctx.vectorIndex,
365
+ embedPort: ctx.embedPort,
366
+ genPort: ctx.genPort,
367
+ rerankPort: ctx.rerankPort,
368
+ },
369
+ query,
370
+ {
371
+ limit: Math.min(body.limit ?? 20, 50),
372
+ minScore: body.minScore,
373
+ collection: body.collection,
374
+ lang: body.lang,
375
+ noExpand: body.noExpand,
376
+ noRerank: body.noRerank,
377
+ }
378
+ );
379
+
380
+ if (!result.ok) {
381
+ return errorResponse('RUNTIME', result.error.message, 500);
382
+ }
383
+
384
+ return jsonResponse(result.value);
385
+ }
386
+
387
+ /**
388
+ * POST /api/ask
389
+ * Body: { query, limit?, collection?, lang?, maxAnswerTokens? }
390
+ * Returns AI-generated answer with citations and sources.
391
+ */
392
+ export async function handleAsk(
393
+ ctx: ServerContext,
394
+ req: Request
395
+ ): Promise<Response> {
396
+ let body: AskRequestBody;
397
+ try {
398
+ body = (await req.json()) as AskRequestBody;
399
+ } catch {
400
+ return errorResponse('VALIDATION', 'Invalid JSON body');
401
+ }
402
+
403
+ if (!body.query || typeof body.query !== 'string') {
404
+ return errorResponse('VALIDATION', 'Missing or invalid query');
405
+ }
406
+
407
+ const query = body.query.trim();
408
+ if (!query) {
409
+ return errorResponse('VALIDATION', 'Query cannot be empty');
410
+ }
411
+
412
+ // Check if answer generation is available
413
+ if (!ctx.capabilities.answer) {
414
+ return errorResponse(
415
+ 'UNAVAILABLE',
416
+ 'Answer generation not available. No generation model loaded.',
417
+ 503
418
+ );
419
+ }
420
+
421
+ const limit = Math.min(body.limit ?? 5, 20);
422
+
423
+ // Run hybrid search first
424
+ const searchResult = await searchHybrid(
425
+ {
426
+ store: ctx.store,
427
+ config: ctx.config,
428
+ vectorIndex: ctx.vectorIndex,
429
+ embedPort: ctx.embedPort,
430
+ genPort: ctx.genPort,
431
+ rerankPort: ctx.rerankPort,
432
+ },
433
+ query,
434
+ {
435
+ limit,
436
+ collection: body.collection,
437
+ lang: body.lang,
438
+ }
439
+ );
440
+
441
+ if (!searchResult.ok) {
442
+ return errorResponse('RUNTIME', searchResult.error.message, 500);
443
+ }
444
+
445
+ const results = searchResult.value.results;
446
+
447
+ // Generate grounded answer (requires genPort)
448
+ let answer: string | undefined;
449
+ let citations: Citation[] | undefined;
450
+ let answerGenerated = false;
451
+
452
+ if (ctx.genPort) {
453
+ const maxTokens = body.maxAnswerTokens ?? 512;
454
+ const rawResult = await generateGroundedAnswer(
455
+ ctx.genPort,
456
+ query,
457
+ results,
458
+ maxTokens
459
+ );
460
+
461
+ if (rawResult) {
462
+ const processed = processAnswerResult(rawResult);
463
+ answer = processed.answer;
464
+ citations = processed.citations;
465
+ answerGenerated = true;
466
+ }
467
+ }
468
+
469
+ const askResult: AskResult = {
470
+ query,
471
+ mode: searchResult.value.meta.vectorsUsed ? 'hybrid' : 'bm25_only',
472
+ queryLanguage: searchResult.value.meta.queryLanguage ?? 'und',
473
+ answer,
474
+ citations,
475
+ results,
476
+ meta: {
477
+ expanded: searchResult.value.meta.expanded ?? false,
478
+ reranked: searchResult.value.meta.reranked ?? false,
479
+ vectorsUsed: searchResult.value.meta.vectorsUsed ?? false,
480
+ answerGenerated,
481
+ totalResults: results.length,
482
+ },
483
+ };
484
+
485
+ return jsonResponse(askResult);
486
+ }
487
+
488
+ // ─────────────────────────────────────────────────────────────────────────────
489
+ // Status with capabilities
490
+ // ─────────────────────────────────────────────────────────────────────────────
491
+
492
+ /**
493
+ * GET /api/capabilities
494
+ * Returns server capabilities (what features are available).
495
+ */
496
+ export function handleCapabilities(ctx: ServerContext): Response {
497
+ return jsonResponse({
498
+ bm25: ctx.capabilities.bm25,
499
+ vector: ctx.capabilities.vector,
500
+ hybrid: ctx.capabilities.hybrid,
501
+ answer: ctx.capabilities.answer,
502
+ });
503
+ }
504
+
505
+ // ─────────────────────────────────────────────────────────────────────────────
506
+ // Presets
507
+ // ─────────────────────────────────────────────────────────────────────────────
508
+
509
+ export interface PresetInfo extends ModelPreset {
510
+ active: boolean;
511
+ }
512
+
513
+ /**
514
+ * GET /api/presets
515
+ * Returns available model presets and which is active.
516
+ */
517
+ export function handlePresets(ctx: ServerContext): Response {
518
+ const modelConfig = getModelConfig(ctx.config);
519
+ const presets = listPresets(ctx.config);
520
+ const activeId = modelConfig.activePreset;
521
+
522
+ const presetsWithStatus: PresetInfo[] = presets.map((p) => ({
523
+ ...p,
524
+ active: p.id === activeId,
525
+ }));
526
+
527
+ return jsonResponse({
528
+ presets: presetsWithStatus,
529
+ activePreset: activeId,
530
+ capabilities: ctx.capabilities,
531
+ });
532
+ }
533
+
534
+ export interface SetPresetRequestBody {
535
+ presetId: string;
536
+ }
537
+
538
+ /**
539
+ * POST /api/presets
540
+ * Switch to a different preset and reload LLM context.
541
+ */
542
+ export async function handleSetPreset(
543
+ ctxHolder: ContextHolder,
544
+ req: Request
545
+ ): Promise<Response> {
546
+ let body: SetPresetRequestBody;
547
+ try {
548
+ body = (await req.json()) as SetPresetRequestBody;
549
+ } catch {
550
+ return errorResponse('VALIDATION', 'Invalid JSON body');
551
+ }
552
+
553
+ if (!body.presetId || typeof body.presetId !== 'string') {
554
+ return errorResponse('VALIDATION', 'Missing or invalid presetId');
555
+ }
556
+
557
+ // Validate preset exists
558
+ const preset = getPreset(ctxHolder.config, body.presetId);
559
+ if (!preset) {
560
+ return errorResponse('NOT_FOUND', `Unknown preset: ${body.presetId}`, 404);
561
+ }
562
+
563
+ // Update config with new active preset (use getModelConfig to get defaults)
564
+ const currentModelConfig = getModelConfig(ctxHolder.config);
565
+ const newConfig: Config = {
566
+ ...ctxHolder.config,
567
+ models: {
568
+ ...currentModelConfig,
569
+ activePreset: body.presetId,
570
+ },
571
+ };
572
+
573
+ console.log(`Switching to preset: ${preset.name}`);
574
+
575
+ // Reload context with new config
576
+ try {
577
+ ctxHolder.current = await reloadServerContext(ctxHolder.current, newConfig);
578
+ ctxHolder.config = newConfig;
579
+ } catch (e) {
580
+ return errorResponse(
581
+ 'RUNTIME',
582
+ `Failed to reload context: ${e instanceof Error ? e.message : String(e)}`,
583
+ 500
584
+ );
585
+ }
586
+
587
+ return jsonResponse({
588
+ success: true,
589
+ activePreset: body.presetId,
590
+ capabilities: ctxHolder.current.capabilities,
591
+ });
592
+ }
593
+
594
+ // ─────────────────────────────────────────────────────────────────────────────
595
+ // Model Download
596
+ // ─────────────────────────────────────────────────────────────────────────────
597
+
598
+ /**
599
+ * GET /api/models/status
600
+ * Returns current download status for polling.
601
+ */
602
+ export function handleModelStatus(): Response {
603
+ return jsonResponse({
604
+ active: downloadState.active,
605
+ currentType: downloadState.currentType,
606
+ progress: downloadState.progress,
607
+ completed: downloadState.completed,
608
+ failed: downloadState.failed,
609
+ startedAt: downloadState.startedAt,
610
+ });
611
+ }
612
+
613
+ /**
614
+ * POST /api/models/pull
615
+ * Start downloading models for current preset.
616
+ * Returns immediately; poll /api/models/status for progress.
617
+ */
618
+ export function handleModelPull(ctxHolder: ContextHolder): Response {
619
+ // Don't start if already downloading
620
+ if (downloadState.active) {
621
+ return errorResponse('CONFLICT', 'Download already in progress', 409);
622
+ }
623
+
624
+ // Reset and start
625
+ resetDownloadState();
626
+ downloadState.active = true;
627
+ downloadState.startedAt = Date.now();
628
+
629
+ // Run download in background (don't await)
630
+ // Pass current config so it uses the active preset from UI
631
+ modelsPull({
632
+ config: ctxHolder.config,
633
+ all: true,
634
+ onProgress: (type, progress) => {
635
+ downloadState.currentType = type;
636
+ downloadState.progress = progress;
637
+ },
638
+ })
639
+ .then(async (result) => {
640
+ // Track results
641
+ for (const r of result.results) {
642
+ if (r.ok) {
643
+ if (!r.skipped) {
644
+ downloadState.completed.push(r.type);
645
+ }
646
+ } else {
647
+ downloadState.failed.push({
648
+ type: r.type,
649
+ error: r.error ?? 'Unknown error',
650
+ });
651
+ }
652
+ }
653
+
654
+ // Reload context to pick up new models
655
+ console.log('Models downloaded, reloading context...');
656
+ try {
657
+ ctxHolder.current = await reloadServerContext(
658
+ ctxHolder.current,
659
+ ctxHolder.config
660
+ );
661
+ console.log('Context reloaded');
662
+ } catch (e) {
663
+ console.error('Failed to reload context:', e);
664
+ }
665
+
666
+ downloadState.active = false;
667
+ downloadState.currentType = null;
668
+ downloadState.progress = null;
669
+ })
670
+ .catch((e) => {
671
+ console.error('Model download failed:', e);
672
+ downloadState.active = false;
673
+ downloadState.failed.push({
674
+ type: downloadState.currentType ?? 'embed',
675
+ error: e instanceof Error ? e.message : String(e),
676
+ });
677
+ });
678
+
679
+ return jsonResponse({
680
+ started: true,
681
+ message: 'Download started. Poll /api/models/status for progress.',
682
+ });
683
+ }
684
+
685
+ // ─────────────────────────────────────────────────────────────────────────────
686
+ // Router
687
+ // ─────────────────────────────────────────────────────────────────────────────
688
+
689
+ /**
690
+ * Route an API request to the appropriate handler.
691
+ * Returns null if the path is not an API route.
692
+ * Note: Currently unused since we use routes object in Bun.serve().
693
+ */
694
+ // biome-ignore lint/suspicious/useAwait: handlers are async, kept for potential future use
695
+ export async function routeApi(
696
+ store: SqliteAdapter,
697
+ req: Request,
698
+ url: URL
699
+ ): Promise<Response | null> {
700
+ const path = url.pathname;
701
+
702
+ // CSRF protection: validate Origin for non-GET requests
703
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
704
+ const origin = req.headers.get('origin');
705
+ const secFetchSite = req.headers.get('sec-fetch-site');
706
+
707
+ // Reject cross-origin requests (allow same-origin or no origin for curl)
708
+ if (origin) {
709
+ const originUrl = new URL(origin);
710
+ if (
711
+ originUrl.hostname !== '127.0.0.1' &&
712
+ originUrl.hostname !== 'localhost'
713
+ ) {
714
+ return errorResponse(
715
+ 'FORBIDDEN',
716
+ 'Cross-origin requests not allowed',
717
+ 403
718
+ );
719
+ }
720
+ } else if (
721
+ secFetchSite &&
722
+ secFetchSite !== 'same-origin' &&
723
+ secFetchSite !== 'none'
724
+ ) {
725
+ return errorResponse(
726
+ 'FORBIDDEN',
727
+ 'Cross-origin requests not allowed',
728
+ 403
729
+ );
730
+ }
731
+ }
732
+
733
+ if (path === '/api/health') {
734
+ return handleHealth();
735
+ }
736
+
737
+ if (path === '/api/status') {
738
+ return handleStatus(store);
739
+ }
740
+
741
+ if (path === '/api/collections') {
742
+ return handleCollections(store);
743
+ }
744
+
745
+ if (path === '/api/docs') {
746
+ return handleDocs(store, url);
747
+ }
748
+
749
+ if (path === '/api/doc') {
750
+ return handleDoc(store, url);
751
+ }
752
+
753
+ if (path === '/api/search' && req.method === 'POST') {
754
+ return handleSearch(store, req);
755
+ }
756
+
757
+ // Unknown API route
758
+ if (path.startsWith('/api/')) {
759
+ return errorResponse('NOT_FOUND', `Unknown API endpoint: ${path}`, 404);
760
+ }
761
+
762
+ return null;
763
+ }