@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.
- package/README.md +10 -2
- package/package.json +7 -7
- package/src/app/constants.ts +4 -2
- package/src/cli/commands/mcp/install.ts +4 -4
- package/src/cli/commands/mcp/status.ts +7 -7
- package/src/cli/commands/skill/install.ts +5 -5
- package/src/cli/program.ts +2 -2
- package/src/collection/add.ts +10 -0
- package/src/collection/types.ts +1 -0
- package/src/config/types.ts +12 -2
- package/src/core/depth-policy.ts +1 -1
- package/src/core/file-ops.ts +38 -0
- package/src/llm/registry.ts +20 -4
- package/src/serve/AGENTS.md +16 -16
- package/src/serve/CLAUDE.md +16 -16
- package/src/serve/config-sync.ts +32 -1
- package/src/serve/connectors.ts +243 -0
- package/src/serve/context.ts +9 -0
- package/src/serve/doc-events.ts +31 -1
- package/src/serve/embed-scheduler.ts +12 -0
- package/src/serve/import-preview.ts +173 -0
- package/src/serve/public/app.tsx +101 -7
- package/src/serve/public/components/AIModelSelector.tsx +383 -145
- package/src/serve/public/components/AddCollectionDialog.tsx +123 -7
- package/src/serve/public/components/BootstrapStatus.tsx +133 -0
- package/src/serve/public/components/CaptureModal.tsx +5 -2
- package/src/serve/public/components/CollectionsEmptyState.tsx +63 -0
- package/src/serve/public/components/FirstRunWizard.tsx +622 -0
- package/src/serve/public/components/HealthCenter.tsx +128 -0
- package/src/serve/public/components/IndexingProgress.tsx +21 -2
- package/src/serve/public/components/QuickSwitcher.tsx +62 -36
- package/src/serve/public/components/TagInput.tsx +5 -1
- package/src/serve/public/components/WikiLinkAutocomplete.tsx +15 -6
- package/src/serve/public/components/WorkspaceTabs.tsx +60 -0
- package/src/serve/public/hooks/use-doc-events.ts +48 -4
- package/src/serve/public/lib/local-history.ts +40 -7
- package/src/serve/public/lib/navigation-state.ts +156 -0
- package/src/serve/public/lib/workspace-tabs.ts +235 -0
- package/src/serve/public/pages/Ask.tsx +11 -1
- package/src/serve/public/pages/Browse.tsx +73 -0
- package/src/serve/public/pages/Collections.tsx +29 -13
- package/src/serve/public/pages/Connectors.tsx +178 -0
- package/src/serve/public/pages/Dashboard.tsx +493 -67
- package/src/serve/public/pages/DocView.tsx +192 -34
- package/src/serve/public/pages/DocumentEditor.tsx +127 -5
- package/src/serve/public/pages/Search.tsx +12 -1
- package/src/serve/routes/api.ts +532 -62
- package/src/serve/server.ts +79 -2
- package/src/serve/status-model.ts +149 -0
- package/src/serve/status.ts +706 -0
- package/src/serve/watch-service.ts +73 -8
- package/src/types/electrobun-shell.d.ts +43 -0
package/src/serve/routes/api.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
494
|
-
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
2078
|
-
|
|
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(
|
|
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") {
|