@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,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
|
+
}
|