@gmickel/gno 0.28.1 → 0.29.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 (52) hide show
  1. package/README.md +10 -2
  2. package/package.json +7 -7
  3. package/src/app/constants.ts +4 -2
  4. package/src/cli/commands/mcp/install.ts +4 -4
  5. package/src/cli/commands/mcp/status.ts +7 -7
  6. package/src/cli/commands/skill/install.ts +5 -5
  7. package/src/cli/program.ts +2 -2
  8. package/src/collection/add.ts +10 -0
  9. package/src/collection/types.ts +1 -0
  10. package/src/config/types.ts +12 -2
  11. package/src/core/depth-policy.ts +1 -1
  12. package/src/core/file-ops.ts +38 -0
  13. package/src/llm/registry.ts +20 -4
  14. package/src/serve/AGENTS.md +16 -16
  15. package/src/serve/CLAUDE.md +16 -16
  16. package/src/serve/config-sync.ts +32 -1
  17. package/src/serve/connectors.ts +243 -0
  18. package/src/serve/context.ts +9 -0
  19. package/src/serve/doc-events.ts +31 -1
  20. package/src/serve/embed-scheduler.ts +12 -0
  21. package/src/serve/import-preview.ts +173 -0
  22. package/src/serve/public/app.tsx +101 -7
  23. package/src/serve/public/components/AIModelSelector.tsx +383 -145
  24. package/src/serve/public/components/AddCollectionDialog.tsx +123 -7
  25. package/src/serve/public/components/BootstrapStatus.tsx +133 -0
  26. package/src/serve/public/components/CaptureModal.tsx +5 -2
  27. package/src/serve/public/components/CollectionsEmptyState.tsx +63 -0
  28. package/src/serve/public/components/FirstRunWizard.tsx +622 -0
  29. package/src/serve/public/components/HealthCenter.tsx +128 -0
  30. package/src/serve/public/components/IndexingProgress.tsx +21 -2
  31. package/src/serve/public/components/QuickSwitcher.tsx +62 -36
  32. package/src/serve/public/components/TagInput.tsx +5 -1
  33. package/src/serve/public/components/WikiLinkAutocomplete.tsx +15 -6
  34. package/src/serve/public/components/WorkspaceTabs.tsx +60 -0
  35. package/src/serve/public/hooks/use-doc-events.ts +48 -4
  36. package/src/serve/public/lib/local-history.ts +40 -7
  37. package/src/serve/public/lib/navigation-state.ts +156 -0
  38. package/src/serve/public/lib/workspace-tabs.ts +235 -0
  39. package/src/serve/public/pages/Ask.tsx +11 -1
  40. package/src/serve/public/pages/Browse.tsx +73 -0
  41. package/src/serve/public/pages/Collections.tsx +29 -13
  42. package/src/serve/public/pages/Connectors.tsx +178 -0
  43. package/src/serve/public/pages/Dashboard.tsx +493 -67
  44. package/src/serve/public/pages/DocView.tsx +192 -34
  45. package/src/serve/public/pages/DocumentEditor.tsx +127 -5
  46. package/src/serve/public/pages/Search.tsx +12 -1
  47. package/src/serve/routes/api.ts +532 -62
  48. package/src/serve/server.ts +79 -2
  49. package/src/serve/status-model.ts +149 -0
  50. package/src/serve/status.ts +706 -0
  51. package/src/serve/watch-service.ts +73 -8
  52. package/src/types/electrobun-shell.d.ts +43 -0
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { Config, ModelPreset } from "../../config/types";
9
+ import type { Collection } from "../../config/types";
9
10
  import type {
10
11
  AskResult,
11
12
  Citation,
@@ -13,6 +14,7 @@ import type {
13
14
  SearchOptions,
14
15
  } from "../../pipeline/types";
15
16
  import type { SqliteAdapter } from "../../store/sqlite/adapter";
17
+ import type { DocumentRow } from "../../store/types";
16
18
  import type { DocumentEventBus } from "../doc-events";
17
19
  import type { EmbedScheduler } from "../embed-scheduler";
18
20
  import type { CollectionWatchService } from "../watch-service";
@@ -24,7 +26,12 @@ import {
24
26
  deriveEditableCopyRelPath,
25
27
  getDocumentCapabilities,
26
28
  } from "../../core/document-capabilities";
27
- import { atomicWrite } from "../../core/file-ops";
29
+ import {
30
+ atomicWrite,
31
+ renameFilePath,
32
+ revealFilePath,
33
+ trashFilePath,
34
+ } from "../../core/file-ops";
28
35
  import { normalizeStructuredQueryInput } from "../../core/structured-query";
29
36
  import {
30
37
  normalizeTag,
@@ -42,14 +49,17 @@ import {
42
49
  import { searchHybrid } from "../../pipeline/hybrid";
43
50
  import { validateQueryModes } from "../../pipeline/query-modes";
44
51
  import { searchBm25 } from "../../pipeline/search";
45
- import { applyConfigChange } from "../config-sync";
52
+ import { applyConfigChange, applyConfigChangeTyped } from "../config-sync";
53
+ import { getConnectorStatuses, installConnector } from "../connectors";
46
54
  import {
47
55
  downloadState,
48
56
  reloadServerContext,
49
57
  resetDownloadState,
50
58
  type ServerContext,
51
59
  } from "../context";
60
+ import { analyzeImportPath } from "../import-preview";
52
61
  import { getJobStatus, startJob } from "../jobs";
62
+ import { buildAppStatus, type StatusBuildDeps } from "../status";
53
63
 
54
64
  /** Mutable context holder for hot-reloading presets */
55
65
  export interface ContextHolder {
@@ -71,6 +81,11 @@ export interface ApiError {
71
81
  };
72
82
  }
73
83
 
84
+ export interface InstallConnectorRequestBody {
85
+ connectorId: string;
86
+ reinstall?: boolean;
87
+ }
88
+
74
89
  export interface SearchRequestBody {
75
90
  query: string;
76
91
  // Only BM25 supported in web UI (vector/hybrid require LLM deps)
@@ -145,6 +160,11 @@ export interface CreateCollectionRequestBody {
145
160
  gitPull?: boolean;
146
161
  }
147
162
 
163
+ export interface ImportPreviewRequestBody {
164
+ path: string;
165
+ name?: string;
166
+ }
167
+
148
168
  export interface SyncRequestBody {
149
169
  collection?: string;
150
170
  gitPull?: boolean;
@@ -159,6 +179,11 @@ export interface CreateDocRequestBody {
159
179
  tags?: string[];
160
180
  }
161
181
 
182
+ export interface RenameDocRequestBody {
183
+ name: string;
184
+ uri?: string;
185
+ }
186
+
162
187
  export interface UpdateDocRequestBody {
163
188
  /** New content (optional if only updating tags) */
164
189
  content?: string;
@@ -168,11 +193,14 @@ export interface UpdateDocRequestBody {
168
193
  expectedSourceHash?: string;
169
194
  /** Expected source modified timestamp for optimistic concurrency */
170
195
  expectedModifiedAt?: string;
196
+ /** Exact document URI when docid is not unique across duplicate content */
197
+ uri?: string;
171
198
  }
172
199
 
173
200
  export interface CreateEditableCopyRequestBody {
174
201
  collection?: string;
175
202
  relPath?: string;
203
+ uri?: string;
176
204
  }
177
205
 
178
206
  // ─────────────────────────────────────────────────────────────────────────────
@@ -204,6 +232,11 @@ function hashContent(content: string): string {
204
232
  return hasher.digest("hex");
205
233
  }
206
234
 
235
+ function readRequestedUriFromUrl(req: Request): string | undefined {
236
+ const value = new URL(req.url).searchParams.get("uri");
237
+ return value?.trim() ? value : undefined;
238
+ }
239
+
207
240
  interface SourceMeta {
208
241
  absPath?: string;
209
242
  mime: string;
@@ -270,6 +303,29 @@ async function buildSourceMeta(
270
303
  };
271
304
  }
272
305
 
306
+ async function resolveDocumentReference(
307
+ store: Pick<SqliteAdapter, "getDocumentByDocid" | "getDocumentByUri">,
308
+ docId: string,
309
+ requestedUri?: string
310
+ ): Promise<
311
+ | { ok: true; value: DocumentRow | null }
312
+ | { ok: false; error: { message: string } }
313
+ > {
314
+ if (requestedUri) {
315
+ const byUri = await store.getDocumentByUri(requestedUri);
316
+ if (!byUri.ok) {
317
+ return { ok: false, error: byUri.error };
318
+ }
319
+ return { ok: true, value: byUri.value };
320
+ }
321
+
322
+ const byDocid = await store.getDocumentByDocid(docId);
323
+ if (!byDocid.ok) {
324
+ return { ok: false, error: byDocid.error };
325
+ }
326
+ return { ok: true, value: byDocid.value };
327
+ }
328
+
273
329
  function parseQueryModesInput(value: unknown): {
274
330
  queryModes?: QueryModeInput[];
275
331
  error?: Response;
@@ -384,30 +440,20 @@ export function handleHealth(): Response {
384
440
  * GET /api/status
385
441
  * Returns index status matching status.schema.json.
386
442
  */
387
- export async function handleStatus(store: SqliteAdapter): Promise<Response> {
388
- const result = await store.getStatus();
389
- if (!result.ok) {
390
- return errorResponse("RUNTIME", result.error.message, 500);
443
+ export async function handleStatus(
444
+ ctx: ServerContext,
445
+ deps?: StatusBuildDeps
446
+ ): Promise<Response> {
447
+ try {
448
+ const status = await buildAppStatus(ctx, deps);
449
+ return jsonResponse(status);
450
+ } catch (error) {
451
+ return errorResponse(
452
+ "RUNTIME",
453
+ error instanceof Error ? error.message : "Failed to get status",
454
+ 500
455
+ );
391
456
  }
392
-
393
- const s = result.value;
394
- return jsonResponse({
395
- indexName: s.indexName,
396
- configPath: s.configPath,
397
- dbPath: s.dbPath,
398
- collections: s.collections.map((c) => ({
399
- name: c.name,
400
- path: c.path,
401
- documentCount: c.activeDocuments,
402
- chunkCount: c.totalChunks,
403
- embeddedCount: c.embeddedChunks,
404
- })),
405
- totalDocuments: s.activeDocuments,
406
- totalChunks: s.totalChunks,
407
- embeddingBacklog: s.embeddingBacklog,
408
- lastUpdated: s.lastUpdatedAt,
409
- healthy: s.healthy,
410
- });
411
457
  }
412
458
 
413
459
  /**
@@ -481,32 +527,45 @@ export async function handleCreateCollection(
481
527
  const name = body.name || path.basename(body.path);
482
528
 
483
529
  // Persist config and sync to DB (mutation happens inside with fresh config)
484
- const syncResult = await applyConfigChange(ctxHolder, store, async (cfg) => {
485
- const addResult = await addCollection(cfg, {
486
- path: body.path,
487
- name,
488
- pattern: body.pattern,
489
- include: body.include,
490
- exclude: body.exclude,
491
- });
530
+ const syncResult = await applyConfigChangeTyped<Collection>(
531
+ ctxHolder,
532
+ store,
533
+ async (cfg) => {
534
+ const addResult = await addCollection(cfg, {
535
+ path: body.path,
536
+ name,
537
+ pattern: body.pattern,
538
+ include: body.include,
539
+ exclude: body.exclude,
540
+ });
492
541
 
493
- if (!addResult.ok) {
494
- return { ok: false, error: addResult.message, code: addResult.code };
542
+ if (!addResult.ok) {
543
+ return { ok: false, error: addResult.message, code: addResult.code };
544
+ }
545
+ return {
546
+ ok: true,
547
+ config: addResult.config,
548
+ value: addResult.collection,
549
+ };
495
550
  }
496
- return { ok: true, config: addResult.config };
497
- });
551
+ );
498
552
  if (!syncResult.ok) {
499
553
  // Map mutation error codes to HTTP status codes
500
554
  const statusMap: Record<string, number> = {
501
555
  DUPLICATE: 409,
556
+ DUPLICATE_PATH: 409,
502
557
  PATH_NOT_FOUND: 400,
503
558
  };
504
559
  const status = statusMap[syncResult.code] ?? 500;
505
560
  return errorResponse(syncResult.code, syncResult.error, status);
506
561
  }
507
562
 
508
- // Find the newly added collection from config
509
- const collection = syncResult.config.collections.find((c) => c.name === name);
563
+ const collection =
564
+ syncResult.value ??
565
+ syncResult.config.collections.find((c) => c.path === body.path.trim()) ??
566
+ syncResult.config.collections.find(
567
+ (c) => c.name === name || c.name === name.toLowerCase()
568
+ );
510
569
  if (!collection) {
511
570
  return errorResponse("RUNTIME", "Collection not found after add", 500);
512
571
  }
@@ -515,9 +574,10 @@ export async function handleCreateCollection(
515
574
  gitPull: body.gitPull,
516
575
  runUpdateCmd: true,
517
576
  });
518
- // Notify scheduler after sync completes (triggers debounced embed)
519
577
  if (result.filesAdded > 0 || result.filesUpdated > 0) {
520
- ctxHolder.scheduler?.notifySyncComplete(["add-batch"]);
578
+ if (ctxHolder.scheduler) {
579
+ await ctxHolder.scheduler.triggerNow();
580
+ }
521
581
  }
522
582
  return {
523
583
  collections: [result],
@@ -543,6 +603,41 @@ export async function handleCreateCollection(
543
603
  );
544
604
  }
545
605
 
606
+ /**
607
+ * POST /api/import/preview
608
+ * Preview what GNO will import from a folder before indexing starts.
609
+ */
610
+ export async function handleImportPreview(
611
+ ctxHolder: ContextHolder,
612
+ req: Request
613
+ ): Promise<Response> {
614
+ let body: ImportPreviewRequestBody;
615
+ try {
616
+ body = (await req.json()) as ImportPreviewRequestBody;
617
+ } catch {
618
+ return errorResponse("VALIDATION", "Invalid JSON body");
619
+ }
620
+
621
+ if (!body.path || typeof body.path !== "string") {
622
+ return errorResponse("VALIDATION", "Missing or invalid path");
623
+ }
624
+ if (body.name !== undefined && typeof body.name !== "string") {
625
+ return errorResponse("VALIDATION", "name must be a string");
626
+ }
627
+
628
+ try {
629
+ return jsonResponse({
630
+ preview: await analyzeImportPath(ctxHolder.config, body.path, body.name),
631
+ });
632
+ } catch (error) {
633
+ return errorResponse(
634
+ "RUNTIME",
635
+ error instanceof Error ? error.message : "Failed to preview import",
636
+ 500
637
+ );
638
+ }
639
+ }
640
+
546
641
  /**
547
642
  * DELETE /api/collections/:name
548
643
  * Remove a collection from config.
@@ -638,9 +733,10 @@ export async function handleSync(
638
733
  gitPull: body.gitPull,
639
734
  runUpdateCmd: true,
640
735
  });
641
- // Notify scheduler after sync completes (triggers debounced embed)
642
736
  if (result.totalFilesAdded > 0 || result.totalFilesUpdated > 0) {
643
- ctxHolder.scheduler?.notifySyncComplete(["sync-batch"]);
737
+ if (ctxHolder.scheduler) {
738
+ await ctxHolder.scheduler.triggerNow();
739
+ }
644
740
  }
645
741
  return result;
646
742
  });
@@ -943,10 +1039,15 @@ export async function handleTags(
943
1039
  */
944
1040
  export async function handleDeactivateDoc(
945
1041
  store: SqliteAdapter,
946
- docId: string
1042
+ docId: string,
1043
+ req?: Request
947
1044
  ): Promise<Response> {
948
1045
  // Get document to verify it exists and get collection/relPath
949
- const docResult = await store.getDocumentByDocid(docId);
1046
+ const docResult = await resolveDocumentReference(
1047
+ store,
1048
+ docId,
1049
+ req ? readRequestedUriFromUrl(req) : undefined
1050
+ );
950
1051
  if (!docResult.ok) {
951
1052
  return errorResponse("RUNTIME", docResult.error.message, 500);
952
1053
  }
@@ -970,6 +1071,276 @@ export async function handleDeactivateDoc(
970
1071
  });
971
1072
  }
972
1073
 
1074
+ export async function handleRenameDoc(
1075
+ ctxHolder: ContextHolder,
1076
+ store: SqliteAdapter,
1077
+ docId: string,
1078
+ req: Request,
1079
+ deps?: {
1080
+ renameFilePath?: typeof renameFilePath;
1081
+ syncCollection?: typeof defaultSyncService.syncCollection;
1082
+ }
1083
+ ): Promise<Response> {
1084
+ let body: RenameDocRequestBody;
1085
+ try {
1086
+ body = (await req.json()) as RenameDocRequestBody;
1087
+ } catch {
1088
+ return errorResponse("VALIDATION", "Invalid JSON body");
1089
+ }
1090
+
1091
+ if (!body.name || typeof body.name !== "string") {
1092
+ return errorResponse("VALIDATION", "Missing or invalid name");
1093
+ }
1094
+ if (body.name.includes("/") || body.name.includes("\\")) {
1095
+ return errorResponse("VALIDATION", "name must be a file name, not a path");
1096
+ }
1097
+ if (body.uri !== undefined && typeof body.uri !== "string") {
1098
+ return errorResponse("VALIDATION", "uri must be a string");
1099
+ }
1100
+
1101
+ const docResult = await resolveDocumentReference(store, docId, body.uri);
1102
+ if (!docResult.ok) {
1103
+ return errorResponse("RUNTIME", docResult.error.message, 500);
1104
+ }
1105
+ if (!docResult.value) {
1106
+ return errorResponse("NOT_FOUND", "Document not found", 404);
1107
+ }
1108
+
1109
+ const doc = docResult.value;
1110
+ const resolvedDocPath = await resolveAbsoluteDocPath(
1111
+ ctxHolder.config.collections,
1112
+ doc
1113
+ );
1114
+ if (!resolvedDocPath) {
1115
+ return errorResponse(
1116
+ "NOT_FOUND",
1117
+ `Collection not found: ${doc.collection}`,
1118
+ 404
1119
+ );
1120
+ }
1121
+ const { collection, fullPath } = resolvedDocPath;
1122
+ const file = Bun.file(fullPath);
1123
+ if (!(await file.exists())) {
1124
+ return errorResponse("FILE_NOT_FOUND", "Source file no longer exists", 404);
1125
+ }
1126
+
1127
+ const capabilities = getDocumentCapabilities({
1128
+ sourceExt: doc.sourceExt,
1129
+ sourceMime: doc.sourceMime,
1130
+ contentAvailable: doc.mirrorHash !== null,
1131
+ });
1132
+ if (!capabilities.editable) {
1133
+ return errorResponse(
1134
+ "READ_ONLY",
1135
+ capabilities.reason ??
1136
+ "This document cannot be renamed in place from GNO.",
1137
+ 409
1138
+ );
1139
+ }
1140
+
1141
+ const nodePath = await import("node:path"); // no bun equivalent
1142
+ const directory = nodePath.dirname(doc.relPath);
1143
+ const currentExt = nodePath.extname(doc.relPath);
1144
+ const targetName = nodePath.extname(body.name)
1145
+ ? body.name
1146
+ : `${body.name}${currentExt}`;
1147
+ const nextRelPath =
1148
+ directory === "." ? targetName : `${directory}/${targetName}`;
1149
+ const nextFullPath = nodePath.join(collection.path, nextRelPath);
1150
+
1151
+ if (nextFullPath === fullPath) {
1152
+ return errorResponse("VALIDATION", "New name matches current file");
1153
+ }
1154
+ if (await Bun.file(nextFullPath).exists()) {
1155
+ return errorResponse(
1156
+ "CONFLICT",
1157
+ "A file with that name already exists",
1158
+ 409
1159
+ );
1160
+ }
1161
+
1162
+ try {
1163
+ const syncCollection =
1164
+ deps?.syncCollection ??
1165
+ ((collectionArg, storeArg, optionsArg) =>
1166
+ defaultSyncService.syncCollection(collectionArg, storeArg, optionsArg));
1167
+ ctxHolder.watchService?.suppress(fullPath);
1168
+ ctxHolder.watchService?.suppress(nextFullPath);
1169
+ await (deps?.renameFilePath ?? renameFilePath)(fullPath, nextFullPath);
1170
+ const nextUri = `gno://${collection.name}/${nextRelPath}`;
1171
+ let warning: string | undefined;
1172
+ try {
1173
+ await syncCollection(collection, store, {
1174
+ runUpdateCmd: false,
1175
+ });
1176
+ } catch {
1177
+ warning =
1178
+ "File renamed on disk, but index refresh failed. Run Update All to reconcile the workspace.";
1179
+ }
1180
+ ctxHolder.eventBus?.emit({
1181
+ type: "document-changed",
1182
+ uri: nextUri,
1183
+ collection: collection.name,
1184
+ relPath: nextRelPath,
1185
+ origin: "save",
1186
+ changedAt: new Date().toISOString(),
1187
+ });
1188
+ return jsonResponse({
1189
+ success: true,
1190
+ uri: nextUri,
1191
+ path: nextFullPath,
1192
+ relPath: nextRelPath,
1193
+ warning,
1194
+ });
1195
+ } catch (error) {
1196
+ return errorResponse(
1197
+ "RUNTIME",
1198
+ error instanceof Error ? error.message : "Failed to rename document",
1199
+ 500
1200
+ );
1201
+ }
1202
+ }
1203
+
1204
+ export async function handleTrashDoc(
1205
+ ctxHolder: ContextHolder,
1206
+ store: SqliteAdapter,
1207
+ docId: string,
1208
+ req?: Request,
1209
+ deps?: {
1210
+ trashFilePath?: typeof trashFilePath;
1211
+ syncCollection?: typeof defaultSyncService.syncCollection;
1212
+ }
1213
+ ): Promise<Response> {
1214
+ const docResult = await resolveDocumentReference(
1215
+ store,
1216
+ docId,
1217
+ req ? readRequestedUriFromUrl(req) : undefined
1218
+ );
1219
+ if (!docResult.ok) {
1220
+ return errorResponse("RUNTIME", docResult.error.message, 500);
1221
+ }
1222
+ if (!docResult.value) {
1223
+ return errorResponse("NOT_FOUND", "Document not found", 404);
1224
+ }
1225
+
1226
+ const doc = docResult.value;
1227
+ const resolvedDocPath = await resolveAbsoluteDocPath(
1228
+ ctxHolder.config.collections,
1229
+ doc
1230
+ );
1231
+ if (!resolvedDocPath) {
1232
+ return errorResponse(
1233
+ "NOT_FOUND",
1234
+ `Collection not found: ${doc.collection}`,
1235
+ 404
1236
+ );
1237
+ }
1238
+ const { collection, fullPath } = resolvedDocPath;
1239
+
1240
+ const capabilities = getDocumentCapabilities({
1241
+ sourceExt: doc.sourceExt,
1242
+ sourceMime: doc.sourceMime,
1243
+ contentAvailable: doc.mirrorHash !== null,
1244
+ });
1245
+ if (!capabilities.editable) {
1246
+ return errorResponse(
1247
+ "READ_ONLY",
1248
+ capabilities.reason ??
1249
+ "This document cannot be trashed in place from GNO.",
1250
+ 409
1251
+ );
1252
+ }
1253
+
1254
+ try {
1255
+ const syncCollection =
1256
+ deps?.syncCollection ??
1257
+ ((collectionArg, storeArg, optionsArg) =>
1258
+ defaultSyncService.syncCollection(collectionArg, storeArg, optionsArg));
1259
+ ctxHolder.watchService?.suppress(fullPath);
1260
+ await (deps?.trashFilePath ?? trashFilePath)(fullPath);
1261
+ await store.markInactive(doc.collection, [doc.relPath]);
1262
+ let warning: string | undefined;
1263
+ try {
1264
+ await syncCollection(collection, store, {
1265
+ runUpdateCmd: false,
1266
+ });
1267
+ } catch {
1268
+ warning =
1269
+ "File moved to Trash, but index refresh failed. Run Update All to reconcile the workspace.";
1270
+ }
1271
+ ctxHolder.eventBus?.emit({
1272
+ type: "document-changed",
1273
+ uri: doc.uri,
1274
+ collection: doc.collection,
1275
+ relPath: doc.relPath,
1276
+ origin: "save",
1277
+ changedAt: new Date().toISOString(),
1278
+ });
1279
+ return jsonResponse({
1280
+ success: true,
1281
+ docId: doc.docid,
1282
+ path: fullPath,
1283
+ note: "Moved to Trash and removed from the current index.",
1284
+ warning,
1285
+ });
1286
+ } catch (error) {
1287
+ return errorResponse(
1288
+ "RUNTIME",
1289
+ error instanceof Error ? error.message : "Failed to trash document",
1290
+ 500
1291
+ );
1292
+ }
1293
+ }
1294
+
1295
+ export async function handleRevealDoc(
1296
+ ctxHolder: ContextHolder,
1297
+ store: SqliteAdapter,
1298
+ docId: string,
1299
+ req?: Request,
1300
+ deps?: {
1301
+ revealFilePath?: typeof revealFilePath;
1302
+ }
1303
+ ): Promise<Response> {
1304
+ const docResult = await resolveDocumentReference(
1305
+ store,
1306
+ docId,
1307
+ req ? readRequestedUriFromUrl(req) : undefined
1308
+ );
1309
+ if (!docResult.ok) {
1310
+ return errorResponse("RUNTIME", docResult.error.message, 500);
1311
+ }
1312
+ if (!docResult.value) {
1313
+ return errorResponse("NOT_FOUND", "Document not found", 404);
1314
+ }
1315
+
1316
+ const doc = docResult.value;
1317
+ const resolvedDocPath = await resolveAbsoluteDocPath(
1318
+ ctxHolder.config.collections,
1319
+ doc
1320
+ );
1321
+ if (!resolvedDocPath) {
1322
+ return errorResponse(
1323
+ "NOT_FOUND",
1324
+ `Collection not found: ${doc.collection}`,
1325
+ 404
1326
+ );
1327
+ }
1328
+
1329
+ try {
1330
+ await (deps?.revealFilePath ?? revealFilePath)(resolvedDocPath.fullPath);
1331
+ return jsonResponse({
1332
+ success: true,
1333
+ path: resolvedDocPath.fullPath,
1334
+ });
1335
+ } catch (error) {
1336
+ return errorResponse(
1337
+ "RUNTIME",
1338
+ error instanceof Error ? error.message : "Failed to reveal document",
1339
+ 500
1340
+ );
1341
+ }
1342
+ }
1343
+
973
1344
  /**
974
1345
  * PUT /api/docs/:id
975
1346
  * Update an existing document's content and/or tags.
@@ -1011,6 +1382,9 @@ export async function handleUpdateDoc(
1011
1382
  ) {
1012
1383
  return errorResponse("VALIDATION", "expectedModifiedAt must be a string");
1013
1384
  }
1385
+ if (body.uri !== undefined && typeof body.uri !== "string") {
1386
+ return errorResponse("VALIDATION", "uri must be a string");
1387
+ }
1014
1388
 
1015
1389
  // Validate tags if provided
1016
1390
  let normalizedTags: string[] | undefined;
@@ -1035,7 +1409,7 @@ export async function handleUpdateDoc(
1035
1409
  }
1036
1410
 
1037
1411
  // Get document to verify it exists
1038
- const docResult = await store.getDocumentByDocid(docId);
1412
+ const docResult = await resolveDocumentReference(store, docId, body.uri);
1039
1413
  if (!docResult.ok) {
1040
1414
  return errorResponse("RUNTIME", docResult.error.message, 500);
1041
1415
  }
@@ -1231,8 +1605,11 @@ export async function handleCreateEditableCopy(
1231
1605
  if (body.relPath !== undefined && typeof body.relPath !== "string") {
1232
1606
  return errorResponse("VALIDATION", "relPath must be a string");
1233
1607
  }
1608
+ if (body.uri !== undefined && typeof body.uri !== "string") {
1609
+ return errorResponse("VALIDATION", "uri must be a string");
1610
+ }
1234
1611
 
1235
- const docResult = await store.getDocumentByDocid(docId);
1612
+ const docResult = await resolveDocumentReference(store, docId, body.uri);
1236
1613
  if (!docResult.ok) {
1237
1614
  return errorResponse("RUNTIME", docResult.error.message, 500);
1238
1615
  }
@@ -2041,7 +2418,11 @@ export interface SetPresetRequestBody {
2041
2418
  */
2042
2419
  export async function handleSetPreset(
2043
2420
  ctxHolder: ContextHolder,
2044
- req: Request
2421
+ req: Request,
2422
+ deps?: {
2423
+ applyConfigChangeFn?: typeof applyConfigChange;
2424
+ reloadServerContextFn?: typeof reloadServerContext;
2425
+ }
2045
2426
  ): Promise<Response> {
2046
2427
  let body: SetPresetRequestBody;
2047
2428
  try {
@@ -2060,22 +2441,45 @@ export async function handleSetPreset(
2060
2441
  return errorResponse("NOT_FOUND", `Unknown preset: ${body.presetId}`, 404);
2061
2442
  }
2062
2443
 
2063
- // Update config with new active preset (use getModelConfig to get defaults)
2064
- const currentModelConfig = getModelConfig(ctxHolder.config);
2065
- const newConfig: Config = {
2066
- ...ctxHolder.config,
2067
- models: {
2068
- ...currentModelConfig,
2069
- activePreset: body.presetId,
2070
- },
2071
- };
2072
-
2073
2444
  console.log(`Switching to preset: ${preset.name}`);
2074
2445
 
2075
- // Reload context with new config
2446
+ const syncResult = await (deps?.applyConfigChangeFn ?? applyConfigChange)(
2447
+ ctxHolder,
2448
+ ctxHolder.current.store,
2449
+ async (config) => {
2450
+ const currentModelConfig = getModelConfig(config);
2451
+ return {
2452
+ ok: true,
2453
+ config: {
2454
+ ...config,
2455
+ models: {
2456
+ activePreset: body.presetId,
2457
+ presets: config.models?.presets ?? [],
2458
+ loadTimeout:
2459
+ config.models?.loadTimeout ?? currentModelConfig.loadTimeout,
2460
+ inferenceTimeout:
2461
+ config.models?.inferenceTimeout ??
2462
+ currentModelConfig.inferenceTimeout,
2463
+ expandContextSize:
2464
+ config.models?.expandContextSize ??
2465
+ currentModelConfig.expandContextSize,
2466
+ warmModelTtl:
2467
+ config.models?.warmModelTtl ?? currentModelConfig.warmModelTtl,
2468
+ },
2469
+ },
2470
+ };
2471
+ }
2472
+ );
2473
+
2474
+ if (!syncResult.ok) {
2475
+ return errorResponse("RUNTIME", syncResult.error, 500);
2476
+ }
2477
+
2076
2478
  try {
2077
- ctxHolder.current = await reloadServerContext(ctxHolder.current, newConfig);
2078
- ctxHolder.config = newConfig;
2479
+ ctxHolder.current = await (
2480
+ deps?.reloadServerContextFn ?? reloadServerContext
2481
+ )(ctxHolder.current, syncResult.config);
2482
+ ctxHolder.config = syncResult.config;
2079
2483
  } catch (e) {
2080
2484
  return errorResponse(
2081
2485
  "RUNTIME",
@@ -2159,6 +2563,14 @@ export function handleModelPull(ctxHolder: ContextHolder): Response {
2159
2563
  ctxHolder.config
2160
2564
  );
2161
2565
  console.log("Context reloaded");
2566
+ if (ctxHolder.scheduler) {
2567
+ void ctxHolder.scheduler.triggerNow().catch((error) => {
2568
+ console.error(
2569
+ "Failed to trigger embedding after model download:",
2570
+ error
2571
+ );
2572
+ });
2573
+ }
2162
2574
  } catch (e) {
2163
2575
  console.error("Failed to reload context:", e);
2164
2576
  }
@@ -2262,6 +2674,50 @@ export function handleEmbedStatus(scheduler: EmbedScheduler | null): Response {
2262
2674
  });
2263
2675
  }
2264
2676
 
2677
+ export async function handleConnectors(overrides?: {
2678
+ cwd?: string;
2679
+ homeDir?: string;
2680
+ }): Promise<Response> {
2681
+ return jsonResponse({
2682
+ connectors: await getConnectorStatuses(overrides),
2683
+ });
2684
+ }
2685
+
2686
+ export async function handleInstallConnector(
2687
+ req: Request,
2688
+ overrides?: { cwd?: string; homeDir?: string }
2689
+ ): Promise<Response> {
2690
+ let body: InstallConnectorRequestBody;
2691
+ try {
2692
+ body = (await req.json()) as InstallConnectorRequestBody;
2693
+ } catch {
2694
+ return errorResponse("VALIDATION", "Invalid JSON body");
2695
+ }
2696
+
2697
+ if (!body.connectorId || typeof body.connectorId !== "string") {
2698
+ return errorResponse("VALIDATION", "Missing or invalid connectorId");
2699
+ }
2700
+ if (body.reinstall !== undefined && typeof body.reinstall !== "boolean") {
2701
+ return errorResponse("VALIDATION", "reinstall must be a boolean");
2702
+ }
2703
+
2704
+ try {
2705
+ return jsonResponse({
2706
+ connector: await installConnector(
2707
+ body.connectorId,
2708
+ { reinstall: body.reinstall },
2709
+ overrides
2710
+ ),
2711
+ });
2712
+ } catch (error) {
2713
+ return errorResponse(
2714
+ "RUNTIME",
2715
+ error instanceof Error ? error.message : "Failed to install connector",
2716
+ 500
2717
+ );
2718
+ }
2719
+ }
2720
+
2265
2721
  // ─────────────────────────────────────────────────────────────────────────────
2266
2722
  // Router
2267
2723
  // ─────────────────────────────────────────────────────────────────────────────
@@ -2316,7 +2772,21 @@ export async function routeApi(
2316
2772
  }
2317
2773
 
2318
2774
  if (path === "/api/status") {
2319
- return handleStatus(store);
2775
+ return handleStatus({
2776
+ store,
2777
+ config,
2778
+ vectorIndex: null,
2779
+ embedPort: null,
2780
+ expandPort: null,
2781
+ answerPort: null,
2782
+ rerankPort: null,
2783
+ capabilities: {
2784
+ bm25: true,
2785
+ vector: false,
2786
+ hybrid: false,
2787
+ answer: false,
2788
+ },
2789
+ });
2320
2790
  }
2321
2791
 
2322
2792
  if (path === "/api/collections") {