threadshelf 1.2.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/CHANGELOG.md +185 -0
- package/LICENSE +21 -0
- package/README.md +763 -0
- package/SECURITY.md +75 -0
- package/bin/threadshelf-mcp.js +12 -0
- package/bin/threadshelf.js +87 -0
- package/dist/mcp/server.js +388 -0
- package/dist/src/chunking.js +72 -0
- package/dist/src/cli.js +24 -0
- package/dist/src/embedding.js +59 -0
- package/dist/src/env.js +2 -0
- package/dist/src/generation/config.js +344 -0
- package/dist/src/generation/downloader.js +172 -0
- package/dist/src/generation/error-log.js +34 -0
- package/dist/src/generation/filesystem-browser.js +83 -0
- package/dist/src/generation/gguf-metadata.js +179 -0
- package/dist/src/generation/hardware.js +87 -0
- package/dist/src/generation/llama-install.js +563 -0
- package/dist/src/generation/llama-process.js +576 -0
- package/dist/src/generation/llama-profile.js +136 -0
- package/dist/src/generation/master-prompts.js +155 -0
- package/dist/src/generation/model-catalog.js +276 -0
- package/dist/src/generation/model-discovery.js +60 -0
- package/dist/src/generation/model-download.js +151 -0
- package/dist/src/generation/openai-compatible.js +231 -0
- package/dist/src/generation/providers/llama-cpp.js +97 -0
- package/dist/src/generation/providers/openrouter.js +106 -0
- package/dist/src/generation/quick-setup.js +215 -0
- package/dist/src/generation/registry.js +23 -0
- package/dist/src/generation/service.js +100 -0
- package/dist/src/generation/threads.js +311 -0
- package/dist/src/generation/types.js +1 -0
- package/dist/src/ingest-cli.js +95 -0
- package/dist/src/ingest.js +257 -0
- package/dist/src/load-env.js +17 -0
- package/dist/src/model-label.js +15 -0
- package/dist/src/parser.js +811 -0
- package/dist/src/paths.js +79 -0
- package/dist/src/routes/collections.js +97 -0
- package/dist/src/routes/files.js +136 -0
- package/dist/src/routes/generation.js +536 -0
- package/dist/src/routes/health.js +6 -0
- package/dist/src/routes/index.js +21 -0
- package/dist/src/routes/ingest.js +300 -0
- package/dist/src/routes/insights.js +24 -0
- package/dist/src/routes/loopback.js +15 -0
- package/dist/src/routes/model-catalog.js +178 -0
- package/dist/src/routes/search.js +57 -0
- package/dist/src/routes/stream-abort.js +23 -0
- package/dist/src/routes/thread.js +43 -0
- package/dist/src/search-cli.js +93 -0
- package/dist/src/server.js +78 -0
- package/dist/src/services/collections.js +58 -0
- package/dist/src/services/insights.js +111 -0
- package/dist/src/services/search.js +68 -0
- package/dist/src/services/stats.js +35 -0
- package/dist/src/services/thread.js +140 -0
- package/dist/src/store.js +1138 -0
- package/dist/src/validation.js +250 -0
- package/dist/src/watch.js +83 -0
- package/package.json +103 -0
- package/public/assets/index-CIm_Idqi.js +38 -0
- package/public/assets/index-Dv09K2vS.css +1 -0
- package/public/favicon.svg +6 -0
- package/public/index.html +28 -0
- package/scripts/openrouter-export-all.js +228 -0
- package/scripts/openrouter-export-browser.js +153 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import multer from 'multer';
|
|
3
|
+
import { join, basename, dirname, normalize, resolve, relative, isAbsolute } from 'path';
|
|
4
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
5
|
+
import { copyFile, mkdir, rm } from 'fs/promises';
|
|
6
|
+
import { randomUUID } from 'crypto';
|
|
7
|
+
import { dataPath } from '../paths.js';
|
|
8
|
+
import { ingestFolder, listExportFiles } from '../ingest.js';
|
|
9
|
+
import { listSourceFilesInCollection } from '../store.js';
|
|
10
|
+
import { ValidationError, normalizeCollectionName, normalizeCollectionSelector, normalizeBoolean, } from '../validation.js';
|
|
11
|
+
import { abortOnDisconnect, isAbortError } from './stream-abort.js';
|
|
12
|
+
const router = Router();
|
|
13
|
+
const UPLOADS_DIR = process.env.UPLOADS_DIR || dataPath('uploads');
|
|
14
|
+
const INCOMING_DIR = join(UPLOADS_DIR, '.incoming');
|
|
15
|
+
const MAX_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB
|
|
16
|
+
const MAX_UPLOAD_FILES = 1000;
|
|
17
|
+
const upload = multer({
|
|
18
|
+
storage: multer.diskStorage({
|
|
19
|
+
destination: (_req, _file, callback) => {
|
|
20
|
+
mkdirSync(INCOMING_DIR, { recursive: true });
|
|
21
|
+
callback(null, INCOMING_DIR);
|
|
22
|
+
},
|
|
23
|
+
filename: (_req, _file, callback) => callback(null, randomUUID()),
|
|
24
|
+
}),
|
|
25
|
+
limits: {
|
|
26
|
+
fileSize: MAX_UPLOAD_SIZE,
|
|
27
|
+
files: MAX_UPLOAD_FILES,
|
|
28
|
+
fields: 10,
|
|
29
|
+
parts: MAX_UPLOAD_FILES + 10,
|
|
30
|
+
fieldNameSize: 4096,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
const tryNormalizeCollectionName = (value) => {
|
|
34
|
+
try {
|
|
35
|
+
return normalizeCollectionName(value);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const safeUploadRelativePath = (rawPath) => {
|
|
42
|
+
const normalized = normalize(String(rawPath || '').replace(/\\/g, '/'));
|
|
43
|
+
if (!normalized || isAbsolute(normalized) || normalized.startsWith('..'))
|
|
44
|
+
return null;
|
|
45
|
+
return normalized;
|
|
46
|
+
};
|
|
47
|
+
const isInsideDirectory = (parentDir, childPath) => {
|
|
48
|
+
const parent = resolve(parentDir);
|
|
49
|
+
const child = resolve(childPath);
|
|
50
|
+
const rel = relative(parent, child);
|
|
51
|
+
return !!rel && !rel.startsWith('..') && !isAbsolute(rel);
|
|
52
|
+
};
|
|
53
|
+
const getUploadedFiles = (files) => {
|
|
54
|
+
return Array.isArray(files) ? files : [];
|
|
55
|
+
};
|
|
56
|
+
const cleanupUploadedTemps = async (files) => {
|
|
57
|
+
await Promise.all(files.map((file) => (file.path ? rm(file.path, { force: true }) : Promise.resolve())));
|
|
58
|
+
};
|
|
59
|
+
const getUploadTarget = (files, requestedCollection) => {
|
|
60
|
+
const firstPath = (files[0]?.fieldname ?? '').replace(/\\/g, '/');
|
|
61
|
+
const topDir = firstPath.split('/').filter(Boolean)[0];
|
|
62
|
+
const collectionName = requestedCollection || tryNormalizeCollectionName(topDir || 'upload') || 'upload';
|
|
63
|
+
return {
|
|
64
|
+
collectionName,
|
|
65
|
+
targetDir: join(UPLOADS_DIR, collectionName),
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
const uploadRelativePath = (fieldname) => {
|
|
69
|
+
const rel = fieldname.replace(/\\/g, '/');
|
|
70
|
+
const parts = rel.split('/').filter(Boolean);
|
|
71
|
+
const pathInsideFolder = parts.length > 1 ? join(...parts.slice(1)) : parts[0];
|
|
72
|
+
return {
|
|
73
|
+
rel,
|
|
74
|
+
relativePath: safeUploadRelativePath(pathInsideFolder ?? ''),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
const ensureUploadTarget = async (targetDir) => {
|
|
78
|
+
if (!existsSync(UPLOADS_DIR))
|
|
79
|
+
await mkdir(UPLOADS_DIR, { recursive: true });
|
|
80
|
+
if (!existsSync(targetDir))
|
|
81
|
+
await mkdir(targetDir, { recursive: true });
|
|
82
|
+
};
|
|
83
|
+
const persistUploadedFiles = async (files, targetDir, onFile) => {
|
|
84
|
+
await ensureUploadTarget(targetDir);
|
|
85
|
+
for (const [index, file] of files.entries()) {
|
|
86
|
+
const { rel, relativePath } = uploadRelativePath(file.fieldname);
|
|
87
|
+
if (!relativePath) {
|
|
88
|
+
return { ok: false, error: `Unsafe upload path: ${rel}` };
|
|
89
|
+
}
|
|
90
|
+
const outPath = join(targetDir, relativePath);
|
|
91
|
+
if (!isInsideDirectory(targetDir, outPath)) {
|
|
92
|
+
return { ok: false, error: `Upload path escapes target directory: ${rel}` };
|
|
93
|
+
}
|
|
94
|
+
const outDir = dirname(outPath);
|
|
95
|
+
if (!existsSync(outDir))
|
|
96
|
+
await mkdir(outDir, { recursive: true });
|
|
97
|
+
await copyFile(file.path, outPath);
|
|
98
|
+
onFile?.(index + 1, relativePath);
|
|
99
|
+
}
|
|
100
|
+
return { ok: true };
|
|
101
|
+
};
|
|
102
|
+
const writeEvent = (res, payload) => {
|
|
103
|
+
if (res.destroyed || res.writableEnded)
|
|
104
|
+
return;
|
|
105
|
+
res.write(`${JSON.stringify(payload)}\n`);
|
|
106
|
+
};
|
|
107
|
+
const canonicalSourcePath = (filePath) => {
|
|
108
|
+
const canonical = resolve(filePath).replace(/\\/g, '/');
|
|
109
|
+
return process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
|
110
|
+
};
|
|
111
|
+
router.get('/api/ingest-preview', async (req, res) => {
|
|
112
|
+
const folderPath = req.query.folderPath;
|
|
113
|
+
let collection;
|
|
114
|
+
try {
|
|
115
|
+
collection = normalizeCollectionSelector(req.query.collection);
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
if (e instanceof ValidationError) {
|
|
119
|
+
return res.status(400).json({ error: e.message, field: e.field });
|
|
120
|
+
}
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
if (!folderPath || typeof folderPath !== 'string' || folderPath.trim().length === 0) {
|
|
124
|
+
return res.status(400).json({ error: 'Missing folderPath' });
|
|
125
|
+
}
|
|
126
|
+
if (folderPath.length > 4096) {
|
|
127
|
+
return res.status(400).json({ error: 'folderPath too long' });
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const files = await listExportFiles(folderPath);
|
|
131
|
+
const existingFiles = collection === 'all' ? [] : await listSourceFilesInCollection(collection);
|
|
132
|
+
const existingPaths = new Set(existingFiles.map(canonicalSourcePath));
|
|
133
|
+
const duplicates = files
|
|
134
|
+
.map((filePath) => ({
|
|
135
|
+
sourceFile: filePath,
|
|
136
|
+
name: basename(filePath),
|
|
137
|
+
canonicalPath: canonicalSourcePath(filePath),
|
|
138
|
+
}))
|
|
139
|
+
.filter((file) => existingPaths.has(file.canonicalPath));
|
|
140
|
+
res.json({ collection, files, duplicates });
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
console.error('[/api/ingest-preview]', e);
|
|
144
|
+
res.status(500).json({ error: e.message });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
router.post('/api/ingest-upload', upload.any(), async (req, res) => {
|
|
148
|
+
const files = getUploadedFiles(req.files);
|
|
149
|
+
let clearFirst;
|
|
150
|
+
try {
|
|
151
|
+
clearFirst = normalizeBoolean(req.body?.clearFirst, { field: 'clearFirst' });
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
await cleanupUploadedTemps(files);
|
|
155
|
+
if (e instanceof ValidationError) {
|
|
156
|
+
return res.status(400).json({ error: e.message, field: e.field });
|
|
157
|
+
}
|
|
158
|
+
throw e;
|
|
159
|
+
}
|
|
160
|
+
if (files.length === 0) {
|
|
161
|
+
return res
|
|
162
|
+
.status(400)
|
|
163
|
+
.json({ error: 'No files uploaded. Choose a folder with JSON export files.' });
|
|
164
|
+
}
|
|
165
|
+
const requestedCollection = tryNormalizeCollectionName(req.body?.collectionName);
|
|
166
|
+
const { collectionName, targetDir } = getUploadTarget(files, requestedCollection);
|
|
167
|
+
try {
|
|
168
|
+
const persisted = await persistUploadedFiles(files, targetDir);
|
|
169
|
+
if (!persisted.ok) {
|
|
170
|
+
return res.status(400).json({ status: 'error', error: persisted.error });
|
|
171
|
+
}
|
|
172
|
+
const result = await ingestFolder(collectionName, targetDir, { clearFirst });
|
|
173
|
+
res.json({ status: 'completed', result, collectionName });
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
console.error('[/api/ingest-upload]', e);
|
|
177
|
+
res.status(500).json({ status: 'error', error: e.message });
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
await cleanupUploadedTemps(files);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
router.post('/api/ingest-upload-progress', upload.any(), async (req, res) => {
|
|
184
|
+
const files = getUploadedFiles(req.files);
|
|
185
|
+
let clearFirst;
|
|
186
|
+
try {
|
|
187
|
+
clearFirst = normalizeBoolean(req.body?.clearFirst, { field: 'clearFirst' });
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
await cleanupUploadedTemps(files);
|
|
191
|
+
if (e instanceof ValidationError) {
|
|
192
|
+
return res.status(400).json({ error: e.message, field: e.field });
|
|
193
|
+
}
|
|
194
|
+
throw e;
|
|
195
|
+
}
|
|
196
|
+
const requestedCollection = tryNormalizeCollectionName(req.body?.collectionName);
|
|
197
|
+
if (!files.length) {
|
|
198
|
+
return res.status(400).json({ status: 'error', error: 'No files uploaded' });
|
|
199
|
+
}
|
|
200
|
+
const { collectionName, targetDir } = getUploadTarget(files, requestedCollection);
|
|
201
|
+
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
202
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
203
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
204
|
+
if (typeof res.flushHeaders === 'function')
|
|
205
|
+
res.flushHeaders();
|
|
206
|
+
const controller = abortOnDisconnect(req, res, 'Indexing stopped');
|
|
207
|
+
try {
|
|
208
|
+
writeEvent(res, {
|
|
209
|
+
status: 'starting',
|
|
210
|
+
phase: 'uploading',
|
|
211
|
+
totalFiles: files.length,
|
|
212
|
+
processedFiles: 0,
|
|
213
|
+
totalChunks: 0,
|
|
214
|
+
totalTokens: 0,
|
|
215
|
+
elapsedMs: 0,
|
|
216
|
+
});
|
|
217
|
+
const persisted = await persistUploadedFiles(files, targetDir, (processedFiles, relativePath) => {
|
|
218
|
+
writeEvent(res, {
|
|
219
|
+
status: 'progress',
|
|
220
|
+
phase: 'uploading',
|
|
221
|
+
totalFiles: files.length,
|
|
222
|
+
processedFiles,
|
|
223
|
+
currentFile: relativePath,
|
|
224
|
+
totalChunks: 0,
|
|
225
|
+
totalTokens: 0,
|
|
226
|
+
elapsedMs: 0,
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
if (!persisted.ok) {
|
|
230
|
+
writeEvent(res, { status: 'error', error: persisted.error });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const onProgress = (data) => writeEvent(res, data);
|
|
234
|
+
const result = await ingestFolder(collectionName, targetDir, {
|
|
235
|
+
clearFirst,
|
|
236
|
+
onProgress,
|
|
237
|
+
signal: controller.signal,
|
|
238
|
+
});
|
|
239
|
+
writeEvent(res, { status: 'completed', result, collectionName });
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
if (!isAbortError(e)) {
|
|
243
|
+
console.error('[/api/ingest-upload-progress]', e);
|
|
244
|
+
writeEvent(res, { status: 'error', error: e.message });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
await cleanupUploadedTemps(files);
|
|
249
|
+
res.end();
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
router.post('/api/ingest-progress', async (req, res) => {
|
|
253
|
+
const folderPath = req.body?.folderPath;
|
|
254
|
+
let clearFirst;
|
|
255
|
+
try {
|
|
256
|
+
clearFirst = normalizeBoolean(req.body?.clearFirst, { field: 'clearFirst' });
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
if (e instanceof ValidationError) {
|
|
260
|
+
return res.status(400).json({ error: e.message, field: e.field });
|
|
261
|
+
}
|
|
262
|
+
throw e;
|
|
263
|
+
}
|
|
264
|
+
if (!folderPath || typeof folderPath !== 'string') {
|
|
265
|
+
return res.status(400).json({ error: 'Missing folderPath' });
|
|
266
|
+
}
|
|
267
|
+
if (folderPath.length > 4096 || folderPath.includes('\0')) {
|
|
268
|
+
return res.status(400).json({ error: 'Invalid folderPath' });
|
|
269
|
+
}
|
|
270
|
+
const collectionName = tryNormalizeCollectionName(req.body?.collection) ||
|
|
271
|
+
tryNormalizeCollectionName(basename(folderPath)) ||
|
|
272
|
+
'chunks';
|
|
273
|
+
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
274
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
275
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
276
|
+
if (typeof res.flushHeaders === 'function')
|
|
277
|
+
res.flushHeaders();
|
|
278
|
+
const controller = abortOnDisconnect(req, res, 'Indexing stopped');
|
|
279
|
+
const onProgress = (data) => {
|
|
280
|
+
res.write(`${JSON.stringify(data)}\n`);
|
|
281
|
+
};
|
|
282
|
+
try {
|
|
283
|
+
const result = await ingestFolder(collectionName, folderPath, {
|
|
284
|
+
clearFirst,
|
|
285
|
+
onProgress,
|
|
286
|
+
signal: controller.signal,
|
|
287
|
+
});
|
|
288
|
+
res.write(`${JSON.stringify({ status: 'completed', result })}\n`);
|
|
289
|
+
}
|
|
290
|
+
catch (e) {
|
|
291
|
+
if (!isAbortError(e)) {
|
|
292
|
+
console.error('[/api/ingest-progress]', e);
|
|
293
|
+
writeEvent(res, { status: 'error', error: e.message });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
res.end();
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
export default router;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { getInsights } from '../services/insights.js';
|
|
3
|
+
import { ValidationError, normalizeCollectionSelector } from '../validation.js';
|
|
4
|
+
const router = Router();
|
|
5
|
+
router.get('/api/insights', async (req, res) => {
|
|
6
|
+
let collection;
|
|
7
|
+
try {
|
|
8
|
+
collection = normalizeCollectionSelector(req.query?.collection);
|
|
9
|
+
}
|
|
10
|
+
catch (e) {
|
|
11
|
+
if (e instanceof ValidationError) {
|
|
12
|
+
return res.status(400).json({ error: e.message, field: e.field });
|
|
13
|
+
}
|
|
14
|
+
throw e;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
res.json(await getInsights(collection));
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
console.error('[/api/insights]', e);
|
|
21
|
+
res.status(500).json({ error: e.message });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
export default router;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { isLoopbackRequest } from '../generation/filesystem-browser.js';
|
|
2
|
+
/**
|
|
3
|
+
* Local generation controls — installing runtimes, browsing the filesystem,
|
|
4
|
+
* downloading models — are never exposed beyond the machine running the server.
|
|
5
|
+
*/
|
|
6
|
+
export const requireLoopback = (req, res, next) => {
|
|
7
|
+
const forwardedFor = [req.headers['x-forwarded-for'], req.headers['x-real-ip']]
|
|
8
|
+
.flatMap((value) => (Array.isArray(value) ? value : value ? [value] : []))
|
|
9
|
+
.map(String);
|
|
10
|
+
if (!isLoopbackRequest(req.socket.remoteAddress, forwardedFor, req.headers.forwarded, req.hostname)) {
|
|
11
|
+
res.status(403).json({ error: 'Local generation controls are available only from localhost' });
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
next();
|
|
15
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { requireLoopback } from './loopback.js';
|
|
3
|
+
import { abortOnDisconnect, isAbortError } from './stream-abort.js';
|
|
4
|
+
import { inspectHardware, judgeFit } from '../generation/hardware.js';
|
|
5
|
+
import { CatalogError, getCatalogModel, huggingFaceToken, searchCatalogModels, } from '../generation/model-catalog.js';
|
|
6
|
+
import { downloadModel, planModelDownload } from '../generation/model-download.js';
|
|
7
|
+
import { buildQuickSetupPlan, quickSetupFingerprint, quickSetupFingerprintMatches, runQuickSetupPlan, } from '../generation/quick-setup.js';
|
|
8
|
+
const router = Router();
|
|
9
|
+
const sorts = new Set(['downloads', 'likes', 'trending', 'recent']);
|
|
10
|
+
const variants = new Set(['cpu', 'vulkan', 'cuda', 'rocm', 'sycl']);
|
|
11
|
+
const fail = (res, error) => {
|
|
12
|
+
if (error instanceof CatalogError) {
|
|
13
|
+
res.status(error.status && error.status < 600 ? error.status : 502).json({
|
|
14
|
+
error: error.message,
|
|
15
|
+
});
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
console.error('[/api/generation/catalog]', error);
|
|
19
|
+
res.status(502).json({ error: error instanceof Error ? error.message : 'Catalog request failed' });
|
|
20
|
+
};
|
|
21
|
+
/** NDJSON, matching the ingest and chat streams the client already consumes. */
|
|
22
|
+
const openStream = (res) => {
|
|
23
|
+
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
24
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
25
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
26
|
+
if (typeof res.flushHeaders === 'function')
|
|
27
|
+
res.flushHeaders();
|
|
28
|
+
return (event) => {
|
|
29
|
+
res.write(`${JSON.stringify(event)}\n`);
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
router.get('/api/generation/hardware', requireLoopback, async (_req, res) => {
|
|
33
|
+
try {
|
|
34
|
+
res.json(await inspectHardware());
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
fail(res, error);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
router.get('/api/generation/catalog/search', requireLoopback, async (req, res) => {
|
|
41
|
+
try {
|
|
42
|
+
const sort = String(req.query.sort || 'downloads');
|
|
43
|
+
const [result, hardware] = await Promise.all([
|
|
44
|
+
searchCatalogModels({
|
|
45
|
+
query: typeof req.query.q === 'string' ? req.query.q : '',
|
|
46
|
+
sort: sorts.has(sort) ? sort : 'downloads',
|
|
47
|
+
limit: Number(req.query.limit) || 24,
|
|
48
|
+
author: typeof req.query.author === 'string' ? req.query.author : undefined,
|
|
49
|
+
}),
|
|
50
|
+
inspectHardware(),
|
|
51
|
+
]);
|
|
52
|
+
res.json({ ...result, hardware });
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
fail(res, error);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
router.get('/api/generation/catalog/model', requireLoopback, async (req, res) => {
|
|
59
|
+
try {
|
|
60
|
+
const id = typeof req.query.id === 'string' ? req.query.id : '';
|
|
61
|
+
const [detail, hardware] = await Promise.all([getCatalogModel(id), inspectHardware()]);
|
|
62
|
+
res.json({
|
|
63
|
+
model: {
|
|
64
|
+
...detail,
|
|
65
|
+
// Fit is judged server-side so every surface agrees on one verdict.
|
|
66
|
+
quants: detail.quants.map((quant) => ({
|
|
67
|
+
...quant,
|
|
68
|
+
fit: judgeFit(quant.totalBytes, hardware),
|
|
69
|
+
})),
|
|
70
|
+
},
|
|
71
|
+
hardware,
|
|
72
|
+
tokenConfigured: Boolean(huggingFaceToken()),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
fail(res, error);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
router.post('/api/generation/catalog/download', requireLoopback, async (req, res) => {
|
|
80
|
+
const controller = abortOnDisconnect(req, res, 'Download cancelled');
|
|
81
|
+
try {
|
|
82
|
+
const plan = await planModelDownload({
|
|
83
|
+
repoId: String(req.body?.repoId ?? ''),
|
|
84
|
+
quant: req.body?.quant ? String(req.body.quant) : undefined,
|
|
85
|
+
includeProjector: req.body?.includeProjector === true,
|
|
86
|
+
});
|
|
87
|
+
const send = openStream(res);
|
|
88
|
+
send({ type: 'plan', plan });
|
|
89
|
+
await downloadModel(plan, {
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
onProgress: (progress) => send({ type: 'progress', ...progress }),
|
|
92
|
+
});
|
|
93
|
+
send({ type: 'done', primaryPath: plan.primaryPath, directory: plan.directory });
|
|
94
|
+
res.end();
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (isAbortError(error) || controller.signal.aborted) {
|
|
98
|
+
res.end();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (res.headersSent) {
|
|
102
|
+
res.write(`${JSON.stringify({ type: 'error', error: error instanceof Error ? error.message : 'Download failed' })}\n`);
|
|
103
|
+
res.end();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
fail(res, error);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
router.get('/api/generation/setup/plan', requireLoopback, async (req, res) => {
|
|
110
|
+
try {
|
|
111
|
+
const variant = String(req.query.variant || '');
|
|
112
|
+
res.json(await buildQuickSetupPlan({
|
|
113
|
+
variant: variants.has(variant) ? variant : undefined,
|
|
114
|
+
repoId: typeof req.query.model === 'string' ? req.query.model : undefined,
|
|
115
|
+
quant: typeof req.query.quant === 'string' ? req.query.quant : undefined,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
fail(res, error);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
router.post('/api/generation/setup/run', requireLoopback, async (req, res) => {
|
|
123
|
+
const controller = abortOnDisconnect(req, res, 'Setup cancelled');
|
|
124
|
+
try {
|
|
125
|
+
// The confirmation flag is the recorded consent for this download.
|
|
126
|
+
if (req.body?.confirm !== true) {
|
|
127
|
+
res.status(400).json({ error: 'Setup requires explicit confirmation' });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const variant = String(req.body?.variant || '');
|
|
131
|
+
// Re-resolved server-side rather than taken from the request: a client must
|
|
132
|
+
// never be able to hand the server a URL to fetch and execute. The pins keep
|
|
133
|
+
// that resolution deterministic, so it lands on what the user approved.
|
|
134
|
+
const plan = await buildQuickSetupPlan({
|
|
135
|
+
variant: variants.has(variant) ? variant : undefined,
|
|
136
|
+
repoId: typeof req.body?.model === 'string' ? req.body.model : undefined,
|
|
137
|
+
quant: typeof req.body?.quant === 'string' ? req.body.quant : undefined,
|
|
138
|
+
releaseTag: typeof req.body?.releaseTag === 'string' ? req.body.releaseTag : undefined,
|
|
139
|
+
});
|
|
140
|
+
// The approved plan is identified by its digests, sizes and versions. If a
|
|
141
|
+
// nightly build moved, the catalog returned different files, or free VRAM
|
|
142
|
+
// changed the recommendation, the user is shown the new plan to approve
|
|
143
|
+
// rather than handed a download they never agreed to.
|
|
144
|
+
const approved = req.body?.fingerprint;
|
|
145
|
+
const current = quickSetupFingerprint(plan);
|
|
146
|
+
if (!quickSetupFingerprintMatches(plan, approved)) {
|
|
147
|
+
res.status(409).json({
|
|
148
|
+
error: typeof approved === 'string' && approved
|
|
149
|
+
? 'The setup plan changed since it was shown. Review the new plan and confirm again.'
|
|
150
|
+
: 'Setup requires approval of the exact plan shown. Review the plan and confirm again.',
|
|
151
|
+
plan,
|
|
152
|
+
fingerprint: current,
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const send = openStream(res);
|
|
157
|
+
send({ type: 'plan', plan });
|
|
158
|
+
const result = await runQuickSetupPlan(plan, {
|
|
159
|
+
signal: controller.signal,
|
|
160
|
+
onProgress: (progress) => send({ type: 'progress', ...progress }),
|
|
161
|
+
});
|
|
162
|
+
send({ type: 'done', ...result });
|
|
163
|
+
res.end();
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (isAbortError(error) || controller.signal.aborted) {
|
|
167
|
+
res.end();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (res.headersSent) {
|
|
171
|
+
res.write(`${JSON.stringify({ type: 'error', error: error instanceof Error ? error.message : 'Setup failed' })}\n`);
|
|
172
|
+
res.end();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
fail(res, error);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
export default router;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { searchAcrossCollections } from '../services/search.js';
|
|
3
|
+
import { ValidationError, normalizeCollectionSelector, normalizeQuery, normalizeCount, normalizeRoles, normalizeBoolean, normalizeOptionalString, normalizeDateRange, normalizeSearchMode, } from '../validation.js';
|
|
4
|
+
const router = Router();
|
|
5
|
+
router.get('/api/search', async (req, res) => {
|
|
6
|
+
let q;
|
|
7
|
+
let collection;
|
|
8
|
+
let n;
|
|
9
|
+
let roles;
|
|
10
|
+
let keywordBoost;
|
|
11
|
+
let model;
|
|
12
|
+
let from;
|
|
13
|
+
let to;
|
|
14
|
+
let mode;
|
|
15
|
+
let origin;
|
|
16
|
+
try {
|
|
17
|
+
q = normalizeQuery(req.query?.q);
|
|
18
|
+
collection = normalizeCollectionSelector(req.query?.collection);
|
|
19
|
+
n = normalizeCount(req.query?.n, { defaultValue: 15 });
|
|
20
|
+
roles = normalizeRoles(req.query?.roles) || null;
|
|
21
|
+
keywordBoost = normalizeBoolean(req.query?.keywordBoost, { field: 'keywordBoost' });
|
|
22
|
+
model = normalizeOptionalString(req.query?.model, { field: 'model' });
|
|
23
|
+
({ from, to } = normalizeDateRange(req.query?.from, req.query?.to));
|
|
24
|
+
mode = normalizeSearchMode(req.query?.mode);
|
|
25
|
+
const rawOrigin = normalizeOptionalString(req.query?.origin, { field: 'origin' });
|
|
26
|
+
if (rawOrigin && rawOrigin !== 'threadshelf' && rawOrigin !== 'archive') {
|
|
27
|
+
throw new ValidationError('Invalid origin: expected threadshelf or archive', {
|
|
28
|
+
field: 'origin',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
origin = rawOrigin;
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
if (e instanceof ValidationError) {
|
|
35
|
+
return res.status(400).json({ error: e.message, field: e.field });
|
|
36
|
+
}
|
|
37
|
+
throw e;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const results = await searchAcrossCollections(q, collection, {
|
|
41
|
+
n,
|
|
42
|
+
roles: roles ?? undefined,
|
|
43
|
+
keywordBoost,
|
|
44
|
+
model,
|
|
45
|
+
from,
|
|
46
|
+
to,
|
|
47
|
+
mode,
|
|
48
|
+
origin,
|
|
49
|
+
});
|
|
50
|
+
res.json({ results });
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
console.error('[/api/search]', e);
|
|
54
|
+
res.status(500).json({ error: e.message, results: [] });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
export default router;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aborts long-running streamed work when the client goes away.
|
|
3
|
+
*
|
|
4
|
+
* Both events are needed: `req`'s `aborted` covers a dropped connection, while
|
|
5
|
+
* `res`'s `close` is what actually fires when a browser stops reading a streamed
|
|
6
|
+
* response (an `AbortController` on the fetch caller). Listening to only one of
|
|
7
|
+
* them leaves the server downloading gigabytes after the user pressed Cancel.
|
|
8
|
+
*/
|
|
9
|
+
export const abortOnDisconnect = (req, res, message = 'Client disconnected') => {
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
const stop = () => {
|
|
12
|
+
if (!controller.signal.aborted) {
|
|
13
|
+
controller.abort(new DOMException(message, 'AbortError'));
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
req.once('aborted', stop);
|
|
17
|
+
res.once('close', () => {
|
|
18
|
+
if (!res.writableEnded)
|
|
19
|
+
stop();
|
|
20
|
+
});
|
|
21
|
+
return controller;
|
|
22
|
+
};
|
|
23
|
+
export const isAbortError = (error) => error instanceof Error && error.name === 'AbortError';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { loadThread, NotFoundError, BadRequestError } from '../services/thread.js';
|
|
3
|
+
import { ValidationError, normalizeCollectionSelector } from '../validation.js';
|
|
4
|
+
const router = Router();
|
|
5
|
+
router.get('/api/thread', async (req, res) => {
|
|
6
|
+
const sourceFile = req.query?.sourceFile;
|
|
7
|
+
const conversationKey = req.query?.conversationKey;
|
|
8
|
+
let collection;
|
|
9
|
+
try {
|
|
10
|
+
collection = normalizeCollectionSelector(req.query?.collection);
|
|
11
|
+
}
|
|
12
|
+
catch (e) {
|
|
13
|
+
if (e instanceof ValidationError) {
|
|
14
|
+
return res.status(400).json({ error: e.message, field: e.field });
|
|
15
|
+
}
|
|
16
|
+
throw e;
|
|
17
|
+
}
|
|
18
|
+
if (!sourceFile || typeof sourceFile !== 'string') {
|
|
19
|
+
return res.status(400).json({ error: 'Missing sourceFile' });
|
|
20
|
+
}
|
|
21
|
+
if (sourceFile.includes('\0') || sourceFile.length > 4096) {
|
|
22
|
+
return res.status(400).json({ error: 'Invalid sourceFile' });
|
|
23
|
+
}
|
|
24
|
+
if (conversationKey != null &&
|
|
25
|
+
(typeof conversationKey !== 'string' ||
|
|
26
|
+
conversationKey.includes('\0') ||
|
|
27
|
+
conversationKey.length > 4096)) {
|
|
28
|
+
return res.status(400).json({ error: 'Invalid conversationKey' });
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const thread = await loadThread(sourceFile, collection, typeof conversationKey === 'string' ? conversationKey : undefined);
|
|
32
|
+
res.json(thread);
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
if (e instanceof NotFoundError)
|
|
36
|
+
return res.status(404).json({ error: e.message });
|
|
37
|
+
if (e instanceof BadRequestError)
|
|
38
|
+
return res.status(400).json({ error: e.message });
|
|
39
|
+
console.error('[/api/thread]', e);
|
|
40
|
+
res.status(500).json({ error: e.message });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
export default router;
|