agentgui 1.0.309 → 1.0.311

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 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 Roadmap
262
-
263
- | Phase | Description | Priority |
264
- |-------|-------------|----------|
265
- | 1 | Integrate IPFS gateway discovery (default configurable) | HIGH |
266
- | 2 | Refactor `ensureModelsDownloaded()` to use fallback chain | HIGH |
267
- | 3 | Add metrics collection to download layer | HIGH |
268
- | 4 | Implement manifest-based version tracking | MEDIUM |
269
- | 5 | Add stale-while-revalidate background checks | MEDIUM |
270
- | 6 | Integrate bundled models option | LOW |
271
- | 7 | Add peer-to-peer discovery | LOW |
272
-
273
- ### Critical TODOs Before Implementation
274
-
275
- 1. Publish whisper-base to IPFS obtain ipfsHash
276
- 2. Publish TTS models to IPFS obtain ipfsHash
277
- 3. Create manifest templates for both models
278
- 4. Design metrics storage schema (SQLite vs JSON)
279
- 5. Plan background check scheduler
280
- 6. Define dashboard UI for metrics visualization
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.309",
3
+ "version": "1.0.311",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -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
@@ -2549,6 +2549,71 @@ const server = http.createServer(async (req, res) => {
2549
2549
  return;
2550
2550
  }
2551
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
+
2552
2617
  if (pathOnly === '/api/speech-status' && req.method === 'POST') {
2553
2618
  const body = await parseBody(req);
2554
2619
  if (body.forceDownload) {
@@ -519,7 +519,7 @@ class StreamingRenderer {
519
519
 
520
520
  const thinking = block.thinking || '';
521
521
  div.innerHTML = `
522
- <details open>
522
+ <details>
523
523
  <summary>
524
524
  <svg viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
525
525
  <span>Thinking Process</span>
@@ -741,7 +741,6 @@ class StreamingRenderer {
741
741
 
742
742
  const details = document.createElement('details');
743
743
  details.className = 'block-tool-use folded-tool permanently-expanded';
744
- details.setAttribute('open', '');
745
744
  if (block.id) details.dataset.toolUseId = block.id;
746
745
  details.classList.add(this._getBlockTypeClass('tool_use'));
747
746
  details.classList.add(this._getToolColorClass(toolName));
@@ -1294,7 +1293,6 @@ class StreamingRenderer {
1294
1293
  renderBlockSystem(block, context) {
1295
1294
  const details = document.createElement('details');
1296
1295
  details.className = 'folded-tool folded-tool-info permanently-expanded';
1297
- details.setAttribute('open', '');
1298
1296
  details.dataset.eventType = 'system';
1299
1297
  details.classList.add(this._getBlockTypeClass('system'));
1300
1298
  const desc = block.model ? this.escapeHtml(block.model) : 'Session';
@@ -1332,7 +1330,6 @@ class StreamingRenderer {
1332
1330
 
1333
1331
  const details = document.createElement('details');
1334
1332
  details.className = isError ? 'folded-tool folded-tool-error permanently-expanded' : 'folded-tool permanently-expanded';
1335
- details.setAttribute('open', '');
1336
1333
  details.dataset.eventType = 'result';
1337
1334
  details.classList.add(this._getBlockTypeClass(isError ? 'error' : 'result'));
1338
1335
 
@@ -1766,7 +1763,6 @@ class StreamingRenderer {
1766
1763
 
1767
1764
  const details = document.createElement('details');
1768
1765
  details.className = 'folded-tool folded-tool-error permanently-expanded';
1769
- details.setAttribute('open', '');
1770
1766
  details.dataset.eventId = event.id || '';
1771
1767
  details.dataset.eventType = 'error';
1772
1768
  const summary = document.createElement('summary');