agentgui 1.0.308 → 1.0.310
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/CLAUDE.md +110 -20
- package/lib/ipfs-publish.js +136 -0
- package/lib/model-downloader.js +288 -0
- package/package.json +1 -1
- package/scripts/publish-models-to-ipfs.js +172 -0
- package/server.js +114 -46
package/CLAUDE.md
CHANGED
|
@@ -258,23 +258,113 @@ TTS https://huggingface.co/datasets/AnEntrypoint/sttttsmodels/resolve/main/
|
|
|
258
258
|
|
|
259
259
|
**Why Bundled Models?** Enables air-gapped deployments, reduces network load, supports edge environments with poor connectivity
|
|
260
260
|
|
|
261
|
-
### Implementation
|
|
262
|
-
|
|
263
|
-
| Phase | Description |
|
|
264
|
-
|
|
265
|
-
| 1 |
|
|
266
|
-
| 2 |
|
|
267
|
-
| 3 |
|
|
268
|
-
| 4 |
|
|
269
|
-
| 5 |
|
|
270
|
-
| 6 |
|
|
271
|
-
| 7 |
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
261
|
+
### Implementation Status
|
|
262
|
+
|
|
263
|
+
| Phase | Description | Status | File(s) |
|
|
264
|
+
|-------|-------------|--------|---------|
|
|
265
|
+
| 1 | IPFS gateway discovery | ✅ DONE | `webtalk/ipfs-downloader.js` |
|
|
266
|
+
| 2 | 3-layer fallback chain | ✅ DONE | `lib/model-downloader.js` |
|
|
267
|
+
| 3 | Metrics collection | ✅ DONE | `lib/model-downloader.js` (JSON storage) |
|
|
268
|
+
| 4 | Manifest generation with SHA-256 | ✅ DONE | Generated to `~/.gmgui/models/.manifests.json` |
|
|
269
|
+
| 5 | Metrics API endpoints | ✅ DONE | `server.js` (4 endpoints added) |
|
|
270
|
+
| 6 | IPFS publishing script | ✅ DONE | `scripts/publish-models-to-ipfs.js` |
|
|
271
|
+
| 7 | Database IPFS tables | ✅ EXISTS | `database.js` (ipfs_cids, ipfs_downloads) |
|
|
272
|
+
| 8 | Integration into ensureModels | ⏳ TODO | Need to wire into `server.js` |
|
|
273
|
+
| 9 | Publish to IPFS (get real CIDs) | ⏳ TODO | Requires Pinata API keys |
|
|
274
|
+
| 10 | Update database.js with real CIDs | ⏳ TODO | After publishing |
|
|
275
|
+
| 11 | Stale-while-revalidate checks | 📋 FUTURE | Background job |
|
|
276
|
+
| 12 | Bundled models | 📋 FUTURE | Tarball creation |
|
|
277
|
+
| 13 | Peer-to-peer discovery | 📋 FUTURE | mDNS implementation |
|
|
278
|
+
|
|
279
|
+
### Current Model Inventory
|
|
280
|
+
|
|
281
|
+
**Models Downloaded Locally**: `~/.gmgui/models/`
|
|
282
|
+
|
|
283
|
+
**Whisper Base** (280.15 MB) - 7 files:
|
|
284
|
+
- `config.json` (0.00 MB) - SHA256: `f4d0608f7d918166...`
|
|
285
|
+
- `tokenizer.json` (2.37 MB) - SHA256: `27fc476bfe7f1729...`
|
|
286
|
+
- `tokenizer_config.json` (0.27 MB) - SHA256: `2e036e4dbacfdeb7...`
|
|
287
|
+
- `onnx/encoder_model.onnx` (78.65 MB) - SHA256: `a9f3b752833b49e8...`
|
|
288
|
+
- `onnx/decoder_model_merged.onnx` (198.86 MB) - SHA256: `514903744bb1b458...`
|
|
289
|
+
|
|
290
|
+
**TTS Models** (189.40 MB) - 6 files:
|
|
291
|
+
- `mimi_encoder.onnx` (69.78 MB) - SHA256: `360f050cd0b1e1c9...`
|
|
292
|
+
- `flow_lm_main_int8.onnx` (72.81 MB) - SHA256: `fd5cdd7f7ab05f63...`
|
|
293
|
+
- `mimi_decoder_int8.onnx` (21.63 MB) - SHA256: `501e16f51cf3fb91...`
|
|
294
|
+
- `text_conditioner.onnx` (15.63 MB) - SHA256: `80ea69f46d8153a9...`
|
|
295
|
+
- `flow_lm_flow_int8.onnx` (9.50 MB) - SHA256: `8d627d235c44a597...`
|
|
296
|
+
- `tokenizer.model` (0.06 MB) - SHA256: `d461765ae1795666...`
|
|
297
|
+
|
|
298
|
+
**Manifest Location**: `~/.gmgui/models/.manifests.json` (auto-generated with full SHA-256 hashes)
|
|
299
|
+
|
|
300
|
+
### Next Steps to Complete Task 1C
|
|
301
|
+
|
|
302
|
+
#### 1. Publish Models to IPFS (Get Real CIDs)
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
# Get free Pinata API keys at https://www.pinata.cloud/
|
|
306
|
+
export PINATA_API_KEY=your_api_key
|
|
307
|
+
export PINATA_SECRET_KEY=your_secret_key
|
|
308
|
+
|
|
309
|
+
# Run publishing script
|
|
310
|
+
node scripts/publish-models-to-ipfs.js
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
This will output real IPFS CIDs for both model sets.
|
|
314
|
+
|
|
315
|
+
#### 2. Update database.js with Real CIDs
|
|
316
|
+
|
|
317
|
+
Replace placeholder CIDs in `database.js` (lines 389-390):
|
|
318
|
+
```javascript
|
|
319
|
+
const WHISPER_CID = 'bafybeidyw252ecy4vs46bbmezrtw325gl2ymdltosmzqgx4edjsc3fbofy'; // PLACEHOLDER
|
|
320
|
+
const TTS_CID = 'bafybeidyw252ecy4vs46bbmezrtw325gl2ymdltosmzqgx4edjsc3fbofy'; // PLACEHOLDER
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Update with real CIDs from step 1.
|
|
324
|
+
|
|
325
|
+
#### 3. Integrate Fallback Chain
|
|
326
|
+
|
|
327
|
+
Modify `server.js` `ensureModelsDownloaded()` (starting line 66) to use the new 3-layer fallback:
|
|
328
|
+
|
|
329
|
+
```javascript
|
|
330
|
+
import { downloadWithFallback } from './lib/model-downloader.js';
|
|
331
|
+
import { queries } from './database.js';
|
|
332
|
+
|
|
333
|
+
// Get IPFS CIDs from database
|
|
334
|
+
const whisperCidRecord = queries.getIpfsCidByModel('whisper-base', 'stt');
|
|
335
|
+
const ttsCidRecord = queries.getIpfsCidByModel('tts', 'voice');
|
|
336
|
+
|
|
337
|
+
// Load manifest
|
|
338
|
+
const manifestPath = path.join(modelsDir, '.manifests.json');
|
|
339
|
+
const manifests = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
340
|
+
|
|
341
|
+
// For each required file, use fallback chain:
|
|
342
|
+
for (const [filename, fileInfo] of Object.entries(manifests['whisper-base'].files)) {
|
|
343
|
+
const destPath = path.join(whisperDir, filename);
|
|
344
|
+
await downloadWithFallback({
|
|
345
|
+
ipfsCid: `${whisperCidRecord.cid}/${filename}`,
|
|
346
|
+
huggingfaceUrl: `https://huggingface.co/onnx-community/whisper-base/resolve/main/${filename}`,
|
|
347
|
+
destPath,
|
|
348
|
+
manifest: fileInfo,
|
|
349
|
+
minBytes: fileInfo.size * 0.8,
|
|
350
|
+
preferredLayer: 'ipfs'
|
|
351
|
+
}, (progress) => {
|
|
352
|
+
broadcastModelProgress({ ...progress, file: filename, type: 'whisper' });
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### Metrics API Endpoints (Live)
|
|
358
|
+
|
|
359
|
+
- `GET /gm/api/metrics/downloads` - All download metrics (last 24 hours)
|
|
360
|
+
- `GET /gm/api/metrics/downloads/summary` - Aggregated statistics
|
|
361
|
+
- `GET /gm/api/metrics/downloads/health` - Per-layer health status (success rates, latency)
|
|
362
|
+
- `POST /gm/api/metrics/downloads/reset` - Clear metrics history
|
|
363
|
+
|
|
364
|
+
### Architecture Files Created
|
|
365
|
+
|
|
366
|
+
- `lib/model-downloader.js` - 3-layer fallback implementation with metrics
|
|
367
|
+
- `lib/ipfs-publish.js` - Local IPFS publishing (requires kubo)
|
|
368
|
+
- `scripts/publish-models-to-ipfs.js` - Pinata-based publishing (no local IPFS needed)
|
|
369
|
+
- `~/.gmgui/models/.manifests.json` - Auto-generated with SHA-256 hashes
|
|
370
|
+
- `~/.gmgui/models/.metrics.json` - Download metrics (auto-rotated daily)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import { create } from 'ipfs-http-client';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
|
|
9
|
+
export async function publishToIPFS(dirPath, options = {}) {
|
|
10
|
+
const {
|
|
11
|
+
gateway = '/ip4/127.0.0.1/tcp/5001',
|
|
12
|
+
pinToServices = ['pinata', 'lighthouse'],
|
|
13
|
+
onProgress = null
|
|
14
|
+
} = options;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const ipfs = create({ url: gateway });
|
|
18
|
+
|
|
19
|
+
const dir = path.resolve(dirPath);
|
|
20
|
+
if (!fs.existsSync(dir)) {
|
|
21
|
+
throw new Error(`Directory not found: ${dir}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const files = [];
|
|
25
|
+
function addFiles(currentPath, basePath) {
|
|
26
|
+
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
29
|
+
const relativePath = path.relative(basePath, fullPath);
|
|
30
|
+
|
|
31
|
+
if (entry.isDirectory()) {
|
|
32
|
+
addFiles(fullPath, basePath);
|
|
33
|
+
} else {
|
|
34
|
+
files.push({
|
|
35
|
+
path: relativePath,
|
|
36
|
+
content: fs.readFileSync(fullPath)
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
addFiles(dir, dir);
|
|
43
|
+
|
|
44
|
+
if (onProgress) {
|
|
45
|
+
onProgress({ status: 'preparing', fileCount: files.length });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let uploadedCount = 0;
|
|
49
|
+
const results = [];
|
|
50
|
+
|
|
51
|
+
for await (const result of ipfs.addAll(files, { wrapWithDirectory: true, pin: true })) {
|
|
52
|
+
uploadedCount++;
|
|
53
|
+
results.push(result);
|
|
54
|
+
|
|
55
|
+
if (onProgress && result.path !== '') {
|
|
56
|
+
onProgress({
|
|
57
|
+
status: 'uploading',
|
|
58
|
+
file: result.path,
|
|
59
|
+
cid: result.cid.toString(),
|
|
60
|
+
uploaded: uploadedCount,
|
|
61
|
+
total: files.length
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const rootCID = results[results.length - 1].cid.toString();
|
|
67
|
+
|
|
68
|
+
if (onProgress) {
|
|
69
|
+
onProgress({
|
|
70
|
+
status: 'complete',
|
|
71
|
+
rootCID,
|
|
72
|
+
fileCount: files.length,
|
|
73
|
+
results
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
rootCID,
|
|
79
|
+
files: results.filter(r => r.path !== '').map(r => ({
|
|
80
|
+
path: r.path,
|
|
81
|
+
cid: r.cid.toString(),
|
|
82
|
+
size: r.size
|
|
83
|
+
}))
|
|
84
|
+
};
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw new Error(`IPFS publish failed: ${error.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function publishModels() {
|
|
91
|
+
const modelsDir = path.join(os.homedir(), '.gmgui', 'models');
|
|
92
|
+
const whisperDir = path.join(modelsDir, 'onnx-community', 'whisper-base');
|
|
93
|
+
const ttsDir = path.join(modelsDir, 'tts');
|
|
94
|
+
|
|
95
|
+
console.log('Publishing models to IPFS...\n');
|
|
96
|
+
|
|
97
|
+
const results = {};
|
|
98
|
+
|
|
99
|
+
if (fs.existsSync(whisperDir)) {
|
|
100
|
+
console.log('Publishing Whisper models...');
|
|
101
|
+
try {
|
|
102
|
+
const whisperResult = await publishToIPFS(whisperDir, {
|
|
103
|
+
onProgress: (progress) => {
|
|
104
|
+
if (progress.status === 'uploading') {
|
|
105
|
+
console.log(` ${progress.uploaded}/${progress.total}: ${progress.file}`);
|
|
106
|
+
} else if (progress.status === 'complete') {
|
|
107
|
+
console.log(`✓ Whisper CID: ${progress.rootCID}\n`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
results.whisper = whisperResult;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error(`✗ Whisper publish failed: ${error.message}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (fs.existsSync(ttsDir)) {
|
|
118
|
+
console.log('Publishing TTS models...');
|
|
119
|
+
try {
|
|
120
|
+
const ttsResult = await publishToIPFS(ttsDir, {
|
|
121
|
+
onProgress: (progress) => {
|
|
122
|
+
if (progress.status === 'uploading') {
|
|
123
|
+
console.log(` ${progress.uploaded}/${progress.total}: ${progress.file}`);
|
|
124
|
+
} else if (progress.status === 'complete') {
|
|
125
|
+
console.log(`✓ TTS CID: ${progress.rootCID}\n`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
results.tts = ttsResult;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error(`✗ TTS publish failed: ${error.message}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return results;
|
|
136
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { createRequire } from 'module';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
|
|
9
|
+
const GATEWAYS = [
|
|
10
|
+
'https://cloudflare-ipfs.com/ipfs/',
|
|
11
|
+
'https://dweb.link/ipfs/',
|
|
12
|
+
'https://gateway.pinata.cloud/ipfs/',
|
|
13
|
+
'https://ipfs.io/ipfs/'
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const METRICS_PATH = path.join(os.homedir(), '.gmgui', 'models', '.metrics.json');
|
|
17
|
+
|
|
18
|
+
function recordMetric(metric) {
|
|
19
|
+
const metricsDir = path.dirname(METRICS_PATH);
|
|
20
|
+
if (!fs.existsSync(metricsDir)) {
|
|
21
|
+
fs.mkdirSync(metricsDir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let metrics = [];
|
|
25
|
+
if (fs.existsSync(METRICS_PATH)) {
|
|
26
|
+
try {
|
|
27
|
+
metrics = JSON.parse(fs.readFileSync(METRICS_PATH, 'utf8'));
|
|
28
|
+
} catch (e) {
|
|
29
|
+
metrics = [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
metrics.push({
|
|
34
|
+
...metric,
|
|
35
|
+
timestamp: new Date().toISOString()
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
|
39
|
+
metrics = metrics.filter(m => new Date(m.timestamp).getTime() > oneDayAgo);
|
|
40
|
+
|
|
41
|
+
fs.writeFileSync(METRICS_PATH, JSON.stringify(metrics, null, 2));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function verifyFileIntegrity(filepath, expectedHash, minBytes) {
|
|
45
|
+
if (!fs.existsSync(filepath)) {
|
|
46
|
+
return { valid: false, reason: 'file_not_found' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const stats = fs.statSync(filepath);
|
|
50
|
+
if (minBytes && stats.size < minBytes) {
|
|
51
|
+
return { valid: false, reason: 'size_too_small', actual: stats.size, expected: minBytes };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (expectedHash) {
|
|
55
|
+
const hash = crypto.createHash('sha256');
|
|
56
|
+
const data = fs.readFileSync(filepath);
|
|
57
|
+
hash.update(data);
|
|
58
|
+
const actualHash = hash.digest('hex');
|
|
59
|
+
|
|
60
|
+
if (actualHash !== expectedHash) {
|
|
61
|
+
return { valid: false, reason: 'hash_mismatch', actual: actualHash, expected: expectedHash };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { valid: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function downloadFromIPFS(cid, destPath, manifest, onProgress) {
|
|
69
|
+
const startTime = Date.now();
|
|
70
|
+
|
|
71
|
+
for (let gatewayIndex = 0; gatewayIndex < GATEWAYS.length; gatewayIndex++) {
|
|
72
|
+
const gateway = GATEWAYS[gatewayIndex];
|
|
73
|
+
const gatewayName = new URL(gateway).hostname;
|
|
74
|
+
|
|
75
|
+
for (let retry = 0; retry < 2; retry++) {
|
|
76
|
+
try {
|
|
77
|
+
if (onProgress) {
|
|
78
|
+
onProgress({
|
|
79
|
+
layer: 'ipfs',
|
|
80
|
+
gateway: gatewayName,
|
|
81
|
+
attempt: retry + 1,
|
|
82
|
+
status: 'attempting'
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { downloadWithProgress } = require('webtalk/ipfs-downloader');
|
|
87
|
+
const url = `${gateway}${cid}`;
|
|
88
|
+
|
|
89
|
+
await downloadWithProgress(url, destPath, (progress) => {
|
|
90
|
+
if (onProgress) {
|
|
91
|
+
onProgress({
|
|
92
|
+
layer: 'ipfs',
|
|
93
|
+
gateway: gatewayName,
|
|
94
|
+
status: 'downloading',
|
|
95
|
+
...progress
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const verification = verifyFileIntegrity(
|
|
101
|
+
destPath,
|
|
102
|
+
manifest?.sha256,
|
|
103
|
+
manifest?.size ? manifest.size * 0.8 : null
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (!verification.valid) {
|
|
107
|
+
if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
|
|
108
|
+
throw new Error(`Verification failed: ${verification.reason}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
recordMetric({
|
|
112
|
+
modelType: 'model',
|
|
113
|
+
layer: 'ipfs',
|
|
114
|
+
gateway: gatewayName,
|
|
115
|
+
status: 'success',
|
|
116
|
+
latency_ms: Date.now() - startTime,
|
|
117
|
+
bytes_downloaded: fs.statSync(destPath).size
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return { success: true, source: 'ipfs', gateway: gatewayName };
|
|
121
|
+
} catch (error) {
|
|
122
|
+
recordMetric({
|
|
123
|
+
modelType: 'model',
|
|
124
|
+
layer: 'ipfs',
|
|
125
|
+
gateway: gatewayName,
|
|
126
|
+
status: 'error',
|
|
127
|
+
error_type: error.name,
|
|
128
|
+
error_message: error.message,
|
|
129
|
+
latency_ms: Date.now() - startTime
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (retry < 1) {
|
|
133
|
+
await new Promise(resolve => setTimeout(resolve, 1000 * (retry + 1)));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
throw new Error('All IPFS gateways exhausted');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function downloadFromHuggingFace(url, destPath, minBytes, onProgress) {
|
|
143
|
+
const startTime = Date.now();
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
if (onProgress) {
|
|
147
|
+
onProgress({
|
|
148
|
+
layer: 'huggingface',
|
|
149
|
+
status: 'attempting'
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const { downloadFile } = require('webtalk/whisper-models');
|
|
154
|
+
await downloadFile(url, destPath);
|
|
155
|
+
|
|
156
|
+
const verification = verifyFileIntegrity(destPath, null, minBytes);
|
|
157
|
+
if (!verification.valid) {
|
|
158
|
+
if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
|
|
159
|
+
throw new Error(`Verification failed: ${verification.reason}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
recordMetric({
|
|
163
|
+
modelType: 'model',
|
|
164
|
+
layer: 'huggingface',
|
|
165
|
+
status: 'success',
|
|
166
|
+
latency_ms: Date.now() - startTime,
|
|
167
|
+
bytes_downloaded: fs.statSync(destPath).size
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return { success: true, source: 'huggingface' };
|
|
171
|
+
} catch (error) {
|
|
172
|
+
recordMetric({
|
|
173
|
+
modelType: 'model',
|
|
174
|
+
layer: 'huggingface',
|
|
175
|
+
status: 'error',
|
|
176
|
+
error_type: error.name,
|
|
177
|
+
error_message: error.message,
|
|
178
|
+
latency_ms: Date.now() - startTime
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function downloadWithFallback(options, onProgress) {
|
|
186
|
+
const {
|
|
187
|
+
ipfsCid,
|
|
188
|
+
huggingfaceUrl,
|
|
189
|
+
destPath,
|
|
190
|
+
manifest,
|
|
191
|
+
minBytes,
|
|
192
|
+
preferredLayer = 'ipfs'
|
|
193
|
+
} = options;
|
|
194
|
+
|
|
195
|
+
const dir = path.dirname(destPath);
|
|
196
|
+
if (!fs.existsSync(dir)) {
|
|
197
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (fs.existsSync(destPath)) {
|
|
201
|
+
const verification = verifyFileIntegrity(destPath, manifest?.sha256, minBytes);
|
|
202
|
+
if (verification.valid) {
|
|
203
|
+
recordMetric({
|
|
204
|
+
modelType: 'model',
|
|
205
|
+
layer: 'cache',
|
|
206
|
+
status: 'hit'
|
|
207
|
+
});
|
|
208
|
+
return { success: true, source: 'cache' };
|
|
209
|
+
} else {
|
|
210
|
+
console.warn(`Cache invalid (${verification.reason}), re-downloading...`);
|
|
211
|
+
const backupPath = `${destPath}.bak`;
|
|
212
|
+
if (fs.existsSync(backupPath)) fs.unlinkSync(backupPath);
|
|
213
|
+
fs.renameSync(destPath, backupPath);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const layers = preferredLayer === 'ipfs'
|
|
218
|
+
? ['ipfs', 'huggingface']
|
|
219
|
+
: ['huggingface', 'ipfs'];
|
|
220
|
+
|
|
221
|
+
for (const layer of layers) {
|
|
222
|
+
try {
|
|
223
|
+
if (layer === 'ipfs' && ipfsCid) {
|
|
224
|
+
return await downloadFromIPFS(ipfsCid, destPath, manifest, onProgress);
|
|
225
|
+
} else if (layer === 'huggingface' && huggingfaceUrl) {
|
|
226
|
+
return await downloadFromHuggingFace(huggingfaceUrl, destPath, minBytes, onProgress);
|
|
227
|
+
}
|
|
228
|
+
} catch (error) {
|
|
229
|
+
console.warn(`${layer} layer failed:`, error.message);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
recordMetric({
|
|
235
|
+
modelType: 'model',
|
|
236
|
+
status: 'all_layers_exhausted'
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
throw new Error('All download layers exhausted');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function getMetrics() {
|
|
243
|
+
if (!fs.existsSync(METRICS_PATH)) {
|
|
244
|
+
return [];
|
|
245
|
+
}
|
|
246
|
+
return JSON.parse(fs.readFileSync(METRICS_PATH, 'utf8'));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function getMetricsSummary() {
|
|
250
|
+
const metrics = getMetrics();
|
|
251
|
+
|
|
252
|
+
const summary = {
|
|
253
|
+
total: metrics.length,
|
|
254
|
+
cache_hits: metrics.filter(m => m.layer === 'cache' && m.status === 'hit').length,
|
|
255
|
+
ipfs: {
|
|
256
|
+
success: metrics.filter(m => m.layer === 'ipfs' && m.status === 'success').length,
|
|
257
|
+
error: metrics.filter(m => m.layer === 'ipfs' && m.status === 'error').length,
|
|
258
|
+
avg_latency: 0
|
|
259
|
+
},
|
|
260
|
+
huggingface: {
|
|
261
|
+
success: metrics.filter(m => m.layer === 'huggingface' && m.status === 'success').length,
|
|
262
|
+
error: metrics.filter(m => m.layer === 'huggingface' && m.status === 'error').length,
|
|
263
|
+
avg_latency: 0
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const ipfsSuccess = metrics.filter(m => m.layer === 'ipfs' && m.status === 'success');
|
|
268
|
+
if (ipfsSuccess.length > 0) {
|
|
269
|
+
summary.ipfs.avg_latency = Math.round(
|
|
270
|
+
ipfsSuccess.reduce((sum, m) => sum + m.latency_ms, 0) / ipfsSuccess.length
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const hfSuccess = metrics.filter(m => m.layer === 'huggingface' && m.status === 'success');
|
|
275
|
+
if (hfSuccess.length > 0) {
|
|
276
|
+
summary.huggingface.avg_latency = Math.round(
|
|
277
|
+
hfSuccess.reduce((sum, m) => sum + m.latency_ms, 0) / hfSuccess.length
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return summary;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function resetMetrics() {
|
|
285
|
+
if (fs.existsSync(METRICS_PATH)) {
|
|
286
|
+
fs.unlinkSync(METRICS_PATH);
|
|
287
|
+
}
|
|
288
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import https from 'https';
|
|
7
|
+
import FormData from 'form-data';
|
|
8
|
+
|
|
9
|
+
const PINATA_API_KEY = process.env.PINATA_API_KEY || '';
|
|
10
|
+
const PINATA_SECRET_KEY = process.env.PINATA_SECRET_KEY || '';
|
|
11
|
+
|
|
12
|
+
async function uploadToPinata(dirPath, folderName) {
|
|
13
|
+
if (!PINATA_API_KEY || !PINATA_SECRET_KEY) {
|
|
14
|
+
throw new Error('PINATA_API_KEY and PINATA_SECRET_KEY environment variables required');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const form = new FormData();
|
|
18
|
+
|
|
19
|
+
function addFilesRecursive(currentPath, basePath) {
|
|
20
|
+
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
const fullPath = path.join(currentPath, entry.name);
|
|
23
|
+
const relativePath = path.relative(basePath, fullPath);
|
|
24
|
+
|
|
25
|
+
if (entry.isDirectory()) {
|
|
26
|
+
addFilesRecursive(fullPath, basePath);
|
|
27
|
+
} else {
|
|
28
|
+
form.append('file', fs.createReadStream(fullPath), {
|
|
29
|
+
filepath: `${folderName}/${relativePath}`
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
addFilesRecursive(dirPath, dirPath);
|
|
36
|
+
|
|
37
|
+
const metadata = JSON.stringify({
|
|
38
|
+
name: folderName,
|
|
39
|
+
keyvalues: {
|
|
40
|
+
type: 'model',
|
|
41
|
+
timestamp: new Date().toISOString()
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
form.append('pinataMetadata', metadata);
|
|
45
|
+
|
|
46
|
+
const options = JSON.stringify({
|
|
47
|
+
wrapWithDirectory: false
|
|
48
|
+
});
|
|
49
|
+
form.append('pinataOptions', options);
|
|
50
|
+
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const req = https.request({
|
|
53
|
+
method: 'POST',
|
|
54
|
+
hostname: 'api.pinata.cloud',
|
|
55
|
+
path: '/pinning/pinFileToIPFS',
|
|
56
|
+
headers: {
|
|
57
|
+
...form.getHeaders(),
|
|
58
|
+
pinata_api_key: PINATA_API_KEY,
|
|
59
|
+
pinata_secret_api_key: PINATA_SECRET_KEY
|
|
60
|
+
}
|
|
61
|
+
}, (res) => {
|
|
62
|
+
let data = '';
|
|
63
|
+
res.on('data', chunk => data += chunk);
|
|
64
|
+
res.on('end', () => {
|
|
65
|
+
if (res.statusCode === 200) {
|
|
66
|
+
resolve(JSON.parse(data));
|
|
67
|
+
} else {
|
|
68
|
+
reject(new Error(`Pinata API error: ${res.statusCode} - ${data}`));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
req.on('error', reject);
|
|
74
|
+
form.pipe(req);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function publishViaPinata() {
|
|
79
|
+
const modelsDir = path.join(os.homedir(), '.gmgui', 'models');
|
|
80
|
+
const whisperDir = path.join(modelsDir, 'onnx-community', 'whisper-base');
|
|
81
|
+
const ttsDir = path.join(modelsDir, 'tts');
|
|
82
|
+
|
|
83
|
+
console.log('Publishing models to IPFS via Pinata...\n');
|
|
84
|
+
|
|
85
|
+
const results = {};
|
|
86
|
+
|
|
87
|
+
if (fs.existsSync(whisperDir)) {
|
|
88
|
+
console.log('Publishing Whisper models...');
|
|
89
|
+
try {
|
|
90
|
+
const result = await uploadToPinata(whisperDir, 'whisper-base');
|
|
91
|
+
results.whisper = {
|
|
92
|
+
cid: result.IpfsHash,
|
|
93
|
+
size: result.PinSize,
|
|
94
|
+
timestamp: result.Timestamp
|
|
95
|
+
};
|
|
96
|
+
console.log(`✓ Whisper CID: ${result.IpfsHash}`);
|
|
97
|
+
console.log(` Size: ${(result.PinSize / 1024 / 1024).toFixed(2)} MB\n`);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.error(`✗ Whisper failed: ${error.message}\n`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (fs.existsSync(ttsDir)) {
|
|
104
|
+
console.log('Publishing TTS models...');
|
|
105
|
+
try {
|
|
106
|
+
const result = await uploadToPinata(ttsDir, 'tts-models');
|
|
107
|
+
results.tts = {
|
|
108
|
+
cid: result.IpfsHash,
|
|
109
|
+
size: result.PinSize,
|
|
110
|
+
timestamp: result.Timestamp
|
|
111
|
+
};
|
|
112
|
+
console.log(`✓ TTS CID: ${result.IpfsHash}`);
|
|
113
|
+
console.log(` Size: ${(result.PinSize / 1024 / 1024).toFixed(2)} MB\n`);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error(`✗ TTS failed: ${error.message}\n`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const manifestPath = path.join(modelsDir, '.manifests.json');
|
|
120
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
121
|
+
|
|
122
|
+
if (results.whisper) {
|
|
123
|
+
manifest['whisper-base'].ipfsHash = results.whisper.cid;
|
|
124
|
+
manifest['whisper-base'].publishedAt = new Date().toISOString();
|
|
125
|
+
}
|
|
126
|
+
if (results.tts) {
|
|
127
|
+
manifest['tts-models'].ipfsHash = results.tts.cid;
|
|
128
|
+
manifest['tts-models'].publishedAt = new Date().toISOString();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
132
|
+
console.log(`✓ Updated manifests at ${manifestPath}`);
|
|
133
|
+
|
|
134
|
+
console.log('\n=== IPFS GATEWAYS ===');
|
|
135
|
+
if (results.whisper) {
|
|
136
|
+
console.log('\nWhisper models:');
|
|
137
|
+
console.log(` Cloudflare: https://cloudflare-ipfs.com/ipfs/${results.whisper.cid}`);
|
|
138
|
+
console.log(` dweb.link: https://dweb.link/ipfs/${results.whisper.cid}`);
|
|
139
|
+
console.log(` Pinata: https://gateway.pinata.cloud/ipfs/${results.whisper.cid}`);
|
|
140
|
+
}
|
|
141
|
+
if (results.tts) {
|
|
142
|
+
console.log('\nTTS models:');
|
|
143
|
+
console.log(` Cloudflare: https://cloudflare-ipfs.com/ipfs/${results.tts.cid}`);
|
|
144
|
+
console.log(` dweb.link: https://dweb.link/ipfs/${results.tts.cid}`);
|
|
145
|
+
console.log(` Pinata: https://gateway.pinata.cloud/ipfs/${results.tts.cid}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return results;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log('AgentGUI Model Publishing Tool\n');
|
|
152
|
+
console.log('This script publishes Whisper and TTS models to IPFS via Pinata.');
|
|
153
|
+
console.log('Required: PINATA_API_KEY and PINATA_SECRET_KEY environment variables.\n');
|
|
154
|
+
console.log('Get free API keys at: https://www.pinata.cloud/\n');
|
|
155
|
+
|
|
156
|
+
if (!PINATA_API_KEY || !PINATA_SECRET_KEY) {
|
|
157
|
+
console.error('ERROR: Missing Pinata credentials');
|
|
158
|
+
console.error('Set environment variables:');
|
|
159
|
+
console.error(' export PINATA_API_KEY=your_api_key');
|
|
160
|
+
console.error(' export PINATA_SECRET_KEY=your_secret_key\n');
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
publishViaPinata()
|
|
165
|
+
.then(results => {
|
|
166
|
+
console.log('\n✓ Publishing complete!');
|
|
167
|
+
console.log(JSON.stringify(results, null, 2));
|
|
168
|
+
})
|
|
169
|
+
.catch(error => {
|
|
170
|
+
console.error('\n✗ Publishing failed:', error.message);
|
|
171
|
+
process.exit(1);
|
|
172
|
+
});
|
package/server.js
CHANGED
|
@@ -319,30 +319,11 @@ const modelCache = new Map();
|
|
|
319
319
|
|
|
320
320
|
const AGENT_MODEL_COMMANDS = {
|
|
321
321
|
'claude-code': 'claude models',
|
|
322
|
+
'gemini': 'gemini models',
|
|
322
323
|
'opencode': 'opencode models',
|
|
323
324
|
'kilo': 'kilo models',
|
|
324
325
|
};
|
|
325
326
|
|
|
326
|
-
const AGENT_DEFAULT_MODELS = {
|
|
327
|
-
'gemini': [
|
|
328
|
-
{ id: '', label: 'Default' },
|
|
329
|
-
{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
|
|
330
|
-
{ id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
|
331
|
-
{ id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }
|
|
332
|
-
],
|
|
333
|
-
'goose': [
|
|
334
|
-
{ id: '', label: 'Default' },
|
|
335
|
-
{ id: 'claude-sonnet-4-5', label: 'Sonnet 4.5' },
|
|
336
|
-
{ id: 'claude-opus-4-5', label: 'Opus 4.5' }
|
|
337
|
-
],
|
|
338
|
-
'codex': [
|
|
339
|
-
{ id: '', label: 'Default' },
|
|
340
|
-
{ id: 'o4-mini', label: 'o4-mini' },
|
|
341
|
-
{ id: 'o3', label: 'o3' },
|
|
342
|
-
{ id: 'o3-mini', label: 'o3-mini' }
|
|
343
|
-
]
|
|
344
|
-
};
|
|
345
|
-
|
|
346
327
|
async function fetchClaudeModelsFromAPI() {
|
|
347
328
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
348
329
|
if (!apiKey) return null;
|
|
@@ -377,31 +358,59 @@ async function fetchClaudeModelsFromAPI() {
|
|
|
377
358
|
} catch { return null; }
|
|
378
359
|
}
|
|
379
360
|
|
|
361
|
+
async function fetchGeminiModelsFromAPI() {
|
|
362
|
+
const apiKey = process.env.GOOGLE_GENAI_API_KEY;
|
|
363
|
+
if (!apiKey) return null;
|
|
364
|
+
try {
|
|
365
|
+
const https = await import('https');
|
|
366
|
+
return new Promise((resolve) => {
|
|
367
|
+
const req = https.default.request({
|
|
368
|
+
hostname: 'generativelanguage.googleapis.com',
|
|
369
|
+
path: '/v1beta/models?key=' + apiKey,
|
|
370
|
+
method: 'GET',
|
|
371
|
+
timeout: 8000
|
|
372
|
+
}, (res) => {
|
|
373
|
+
let body = '';
|
|
374
|
+
res.on('data', d => body += d);
|
|
375
|
+
res.on('end', () => {
|
|
376
|
+
try {
|
|
377
|
+
const data = JSON.parse(body);
|
|
378
|
+
const items = (data.models || []).filter(m => m.name && m.name.includes('gemini'));
|
|
379
|
+
if (items.length === 0) return resolve(null);
|
|
380
|
+
const models = [{ id: '', label: 'Default' }];
|
|
381
|
+
for (const m of items) {
|
|
382
|
+
const modelId = m.name.replace(/^models\//, '');
|
|
383
|
+
const label = modelId.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
384
|
+
models.push({ id: modelId, label });
|
|
385
|
+
}
|
|
386
|
+
resolve(models);
|
|
387
|
+
} catch { resolve(null); }
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
req.on('error', () => resolve(null));
|
|
391
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
392
|
+
req.end();
|
|
393
|
+
});
|
|
394
|
+
} catch { return null; }
|
|
395
|
+
}
|
|
396
|
+
|
|
380
397
|
async function getModelsForAgent(agentId) {
|
|
381
398
|
const cached = modelCache.get(agentId);
|
|
382
399
|
if (cached && Date.now() - cached.timestamp < 3600000) {
|
|
383
400
|
return cached.models;
|
|
384
401
|
}
|
|
385
402
|
|
|
403
|
+
let models = null;
|
|
404
|
+
|
|
386
405
|
if (agentId === 'claude-code') {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
if (lines.length > 0) {
|
|
396
|
-
const models = [{ id: '', label: 'Default' }];
|
|
397
|
-
for (const line of lines) {
|
|
398
|
-
models.push({ id: line, label: line });
|
|
399
|
-
}
|
|
400
|
-
modelCache.set(agentId, { models, timestamp: Date.now() });
|
|
401
|
-
return models;
|
|
402
|
-
}
|
|
403
|
-
} catch (_) {}
|
|
404
|
-
return [{ id: '', label: 'Default' }];
|
|
406
|
+
models = await fetchClaudeModelsFromAPI();
|
|
407
|
+
} else if (agentId === 'gemini') {
|
|
408
|
+
models = await fetchGeminiModelsFromAPI();
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (models) {
|
|
412
|
+
modelCache.set(agentId, { models, timestamp: Date.now() });
|
|
413
|
+
return models;
|
|
405
414
|
}
|
|
406
415
|
|
|
407
416
|
if (AGENT_MODEL_COMMANDS[agentId]) {
|
|
@@ -409,7 +418,7 @@ async function getModelsForAgent(agentId) {
|
|
|
409
418
|
const result = execSync(AGENT_MODEL_COMMANDS[agentId], { encoding: 'utf-8', timeout: 15000 });
|
|
410
419
|
const lines = result.split('\n').map(l => l.trim()).filter(Boolean);
|
|
411
420
|
if (lines.length > 0) {
|
|
412
|
-
|
|
421
|
+
models = [{ id: '', label: 'Default' }];
|
|
413
422
|
for (const line of lines) {
|
|
414
423
|
models.push({ id: line, label: line });
|
|
415
424
|
}
|
|
@@ -419,12 +428,6 @@ async function getModelsForAgent(agentId) {
|
|
|
419
428
|
} catch (_) {}
|
|
420
429
|
}
|
|
421
430
|
|
|
422
|
-
if (AGENT_DEFAULT_MODELS[agentId]) {
|
|
423
|
-
const models = AGENT_DEFAULT_MODELS[agentId];
|
|
424
|
-
modelCache.set(agentId, { models, timestamp: Date.now() });
|
|
425
|
-
return models;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
431
|
const { getRegisteredAgents } = await import('./lib/claude-runner.js');
|
|
429
432
|
const agents = getRegisteredAgents();
|
|
430
433
|
const agent = agents.find(a => a.id === agentId);
|
|
@@ -435,7 +438,7 @@ async function getModelsForAgent(agentId) {
|
|
|
435
438
|
const result = execSync(modelCmd, { encoding: 'utf-8', timeout: 15000 });
|
|
436
439
|
const lines = result.split('\n').map(l => l.trim()).filter(Boolean);
|
|
437
440
|
if (lines.length > 0) {
|
|
438
|
-
|
|
441
|
+
models = [{ id: '', label: 'Default' }];
|
|
439
442
|
for (const line of lines) {
|
|
440
443
|
models.push({ id: line, label: line });
|
|
441
444
|
}
|
|
@@ -2546,6 +2549,71 @@ const server = http.createServer(async (req, res) => {
|
|
|
2546
2549
|
return;
|
|
2547
2550
|
}
|
|
2548
2551
|
|
|
2552
|
+
if (pathOnly === '/api/metrics/downloads' && req.method === 'GET') {
|
|
2553
|
+
try {
|
|
2554
|
+
const { getMetrics } = await import('./lib/model-downloader.js');
|
|
2555
|
+
const metrics = getMetrics();
|
|
2556
|
+
sendJSON(req, res, 200, { metrics });
|
|
2557
|
+
} catch (err) {
|
|
2558
|
+
sendJSON(req, res, 500, { error: err.message });
|
|
2559
|
+
}
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
if (pathOnly === '/api/metrics/downloads/summary' && req.method === 'GET') {
|
|
2564
|
+
try {
|
|
2565
|
+
const { getMetricsSummary } = await import('./lib/model-downloader.js');
|
|
2566
|
+
const summary = getMetricsSummary();
|
|
2567
|
+
sendJSON(req, res, 200, summary);
|
|
2568
|
+
} catch (err) {
|
|
2569
|
+
sendJSON(req, res, 500, { error: err.message });
|
|
2570
|
+
}
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
if (pathOnly === '/api/metrics/downloads/health' && req.method === 'GET') {
|
|
2575
|
+
try {
|
|
2576
|
+
const { getMetricsSummary } = await import('./lib/model-downloader.js');
|
|
2577
|
+
const summary = getMetricsSummary();
|
|
2578
|
+
const health = {
|
|
2579
|
+
ipfs: {
|
|
2580
|
+
status: summary.ipfs.success > 0 ? 'healthy' : summary.ipfs.error > 0 ? 'degraded' : 'unknown',
|
|
2581
|
+
success_rate: summary.ipfs.success + summary.ipfs.error > 0
|
|
2582
|
+
? ((summary.ipfs.success / (summary.ipfs.success + summary.ipfs.error)) * 100).toFixed(2)
|
|
2583
|
+
: 0,
|
|
2584
|
+
avg_latency_ms: summary.ipfs.avg_latency
|
|
2585
|
+
},
|
|
2586
|
+
huggingface: {
|
|
2587
|
+
status: summary.huggingface.success > 0 ? 'healthy' : summary.huggingface.error > 0 ? 'degraded' : 'unknown',
|
|
2588
|
+
success_rate: summary.huggingface.success + summary.huggingface.error > 0
|
|
2589
|
+
? ((summary.huggingface.success / (summary.huggingface.success + summary.huggingface.error)) * 100).toFixed(2)
|
|
2590
|
+
: 0,
|
|
2591
|
+
avg_latency_ms: summary.huggingface.avg_latency
|
|
2592
|
+
},
|
|
2593
|
+
cache: {
|
|
2594
|
+
hit_rate: summary.total > 0
|
|
2595
|
+
? ((summary.cache_hits / summary.total) * 100).toFixed(2)
|
|
2596
|
+
: 0
|
|
2597
|
+
}
|
|
2598
|
+
};
|
|
2599
|
+
sendJSON(req, res, 200, health);
|
|
2600
|
+
} catch (err) {
|
|
2601
|
+
sendJSON(req, res, 500, { error: err.message });
|
|
2602
|
+
}
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
if (pathOnly === '/api/metrics/downloads/reset' && req.method === 'POST') {
|
|
2607
|
+
try {
|
|
2608
|
+
const { resetMetrics } = await import('./lib/model-downloader.js');
|
|
2609
|
+
resetMetrics();
|
|
2610
|
+
sendJSON(req, res, 200, { ok: true, message: 'Metrics reset' });
|
|
2611
|
+
} catch (err) {
|
|
2612
|
+
sendJSON(req, res, 500, { error: err.message });
|
|
2613
|
+
}
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2549
2617
|
if (pathOnly === '/api/speech-status' && req.method === 'POST') {
|
|
2550
2618
|
const body = await parseBody(req);
|
|
2551
2619
|
if (body.forceDownload) {
|