agentgui 1.0.318 → 1.0.320
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/.prd +262 -0
- package/database.js +2 -2
- package/lib/download-metrics.js +79 -0
- package/lib/file-verification.js +26 -0
- package/lib/model-downloader.js +3 -101
- package/package.json +2 -1
- package/server.js +59 -27
- package/static/index.html +20 -0
package/.prd
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# Model Download Fallback System - Integration PRD
|
|
2
|
+
|
|
3
|
+
## CURRENT STATE VERIFIED
|
|
4
|
+
|
|
5
|
+
### Phase 1-7 Status (CLAIMED DONE)
|
|
6
|
+
✓ lib/model-downloader.js exists (289 lines, 8247 bytes)
|
|
7
|
+
- Exports: downloadWithFallback, getMetrics, getMetricsSummary, resetMetrics
|
|
8
|
+
- Implements 3-layer fallback: IPFS → HuggingFace → cache
|
|
9
|
+
- Parameters: ipfsCid, huggingfaceUrl, destPath, manifest, minBytes, preferredLayer
|
|
10
|
+
- Verification: SHA-256 hash + size check + ONNX format validation
|
|
11
|
+
|
|
12
|
+
✓ scripts/publish-models-to-ipfs.js exists (173 lines, 5655 bytes)
|
|
13
|
+
- Requires: PINATA_API_KEY, PINATA_SECRET_KEY
|
|
14
|
+
- Uploads to Pinata, returns CIDs for whisper-base and tts-models
|
|
15
|
+
- Not yet executed (no real CIDs)
|
|
16
|
+
|
|
17
|
+
✓ database.js IPFS infrastructure exists
|
|
18
|
+
- Tables: ipfs_cids, ipfs_downloads
|
|
19
|
+
- Query: getIpfsCidByModel(modelName, modelType)
|
|
20
|
+
- Initialization: Lines 385-415 with PLACEHOLDER CIDs
|
|
21
|
+
- WHISPER_CID = 'bafybeidyw252ecy4vs46bbmezrtw325gl2ymdltosmzqgx4edjsc3fbofy' (PLACEHOLDER)
|
|
22
|
+
- TTS_CID = 'bafybeidyw252ecy4vs46bbmezrtw325gl2ymdltosmzqgx4edjsc3fbofy' (PLACEHOLDER)
|
|
23
|
+
|
|
24
|
+
✓ Metrics API endpoints exist in server.js (lines 2552-2606)
|
|
25
|
+
- GET /api/metrics/downloads
|
|
26
|
+
- GET /api/metrics/downloads/summary
|
|
27
|
+
- GET /api/metrics/downloads/health
|
|
28
|
+
- POST /api/metrics/downloads/reset
|
|
29
|
+
|
|
30
|
+
✓ Manifest exists at ~/.gmgui/models/.manifests.json
|
|
31
|
+
- whisper-base: 7 files, 293.76 MB total, SHA-256 hashes present
|
|
32
|
+
- tts-models: 6 files, 198.60 MB total, SHA-256 hashes present
|
|
33
|
+
- Generated: 2026-02-21T03:49:32.766Z
|
|
34
|
+
|
|
35
|
+
✓ Metrics file exists at ~/.gmgui/models/.metrics.json
|
|
36
|
+
|
|
37
|
+
### Phase 8-10 Status (TODO)
|
|
38
|
+
✗ lib/model-downloader.js NOT imported in server.js
|
|
39
|
+
✗ server.js ensureModelsDownloaded() uses old webtalk/ipfs-downloader (lines 66-125)
|
|
40
|
+
✗ Real IPFS CIDs not obtained (Pinata script not executed)
|
|
41
|
+
✗ database.js still has placeholder CIDs
|
|
42
|
+
|
|
43
|
+
## DEPENDENCIES & BLOCKING RELATIONSHIPS
|
|
44
|
+
|
|
45
|
+
### Wave 1 (COMPLETE - all items removed from work breakdown)
|
|
46
|
+
|
|
47
|
+
### Wave 2 (COMPLETE)
|
|
48
|
+
✓ Pinata API keys: BLOCKED - not available, user needs to sign up at https://www.pinata.cloud/
|
|
49
|
+
✓ Integration strategy designed - 150-200 line implementation, backward compatible, per-file iteration
|
|
50
|
+
|
|
51
|
+
### Wave 3 (Depends on Wave 2)
|
|
52
|
+
6. Execute publish-models-to-ipfs.js to get real CIDs (if keys available)
|
|
53
|
+
- Blocked by: Wave 2 item 4 (API keys)
|
|
54
|
+
- Blocks: Database CID update
|
|
55
|
+
|
|
56
|
+
7. Add model-downloader import to server.js
|
|
57
|
+
- Blocked by: Wave 2 item 5 (integration strategy)
|
|
58
|
+
- Blocks: Integration implementation
|
|
59
|
+
|
|
60
|
+
### Wave 4 (Depends on Wave 3)
|
|
61
|
+
8. Update database.js with real IPFS CIDs
|
|
62
|
+
- Blocked by: Wave 3 item 6 (real CIDs obtained)
|
|
63
|
+
- Blocks: IPFS layer activation
|
|
64
|
+
|
|
65
|
+
9. Replace ensureModelsDownloaded implementation
|
|
66
|
+
- Blocked by: Wave 3 item 7 (import added)
|
|
67
|
+
- Blocks: End-to-end testing
|
|
68
|
+
|
|
69
|
+
### Wave 5 (Depends on Wave 4, final verification)
|
|
70
|
+
10. Test fallback chain with fresh cache (delete ~/.gmgui/models, verify IPFS layer 1)
|
|
71
|
+
- Blocked by: Wave 4 items 8, 9
|
|
72
|
+
- Blocks: Nothing (end-to-end test)
|
|
73
|
+
|
|
74
|
+
11. Test fallback to HuggingFace (simulate IPFS failure)
|
|
75
|
+
- Blocked by: Wave 4 items 8, 9
|
|
76
|
+
- Blocks: Nothing (end-to-end test)
|
|
77
|
+
|
|
78
|
+
12. Verify metrics collection after real downloads
|
|
79
|
+
- Blocked by: Wave 4 items 8, 9
|
|
80
|
+
- Blocks: Nothing (validation)
|
|
81
|
+
|
|
82
|
+
## UNKNOWNS TO RESOLVE
|
|
83
|
+
|
|
84
|
+
### Code Implementation Unknowns (Wave 1 RESOLVED)
|
|
85
|
+
✓ downloadWithFallback IPFS gateway list: YES - cycles all 4 gateways (Cloudflare, dweb.link, Pinata, ipfs.io) × 2 retries = 8 attempts
|
|
86
|
+
✓ downloadWithFallback error handling: YES - properly catches and continues on gateway failure
|
|
87
|
+
✓ downloadWithFallback progress reporting: YES - onProgress receives correct status for each layer
|
|
88
|
+
✓ Manifest integration: YES - downloadWithFallback correctly validates against manifest SHA-256
|
|
89
|
+
✓ File path construction: Direct iteration over manifest.files object, each key is relative path
|
|
90
|
+
|
|
91
|
+
### Integration Unknowns (Wave 2 IN PROGRESS)
|
|
92
|
+
- Current ensureModelsDownloaded flow: what does webtalk/ipfs-downloader.ensureModels do? (TO INVESTIGATE)
|
|
93
|
+
- Backward compatibility: can we replace ensureModels without breaking existing installs? (TO INVESTIGATE)
|
|
94
|
+
- Progress broadcasting: how to integrate downloadWithFallback progress with broadcastModelProgress? (TO DESIGN)
|
|
95
|
+
- Error handling: what happens if all 3 layers fail? (TO DESIGN - already throws error, need UI integration)
|
|
96
|
+
- Concurrent download handling: does model-downloader handle multiple simultaneous downloads? (TO INVESTIGATE)
|
|
97
|
+
|
|
98
|
+
### Environment Unknowns
|
|
99
|
+
- Pinata API keys: are they available? (blocks real IPFS publishing)
|
|
100
|
+
- IPFS gateway reachability: can we access Cloudflare/dweb.link/Pinata from this environment?
|
|
101
|
+
- Network constraints: timeouts sufficient for 280MB + 198MB downloads?
|
|
102
|
+
|
|
103
|
+
### Database Unknowns
|
|
104
|
+
- CID format: are placeholder CIDs correct format (bafybei...)?
|
|
105
|
+
- Gateway URL: should database store per-gateway URLs or single gateway?
|
|
106
|
+
- Download tracking: is recordDownloadStart properly integrated?
|
|
107
|
+
|
|
108
|
+
## EDGE CASES & CORNER CASES
|
|
109
|
+
|
|
110
|
+
### Network Scenarios
|
|
111
|
+
- All IPFS gateways unreachable → fallback to HuggingFace
|
|
112
|
+
- HuggingFace rate limiting → retry logic sufficient?
|
|
113
|
+
- Partial download (connection drop mid-file) → temp file cleanup?
|
|
114
|
+
- Network switching during download → resume or restart?
|
|
115
|
+
- Firewall blocking IPFS ports → fallback working?
|
|
116
|
+
|
|
117
|
+
### File System Scenarios
|
|
118
|
+
- Disk full during download → cleanup and error reporting?
|
|
119
|
+
- Permission denied on ~/.gmgui/models → fallback directory?
|
|
120
|
+
- Corrupted manifest file → regenerate or error?
|
|
121
|
+
- Partial manifest (missing sha256 for some files) → skip validation?
|
|
122
|
+
- Concurrent writes to same file → locking mechanism?
|
|
123
|
+
|
|
124
|
+
### Cache Scenarios
|
|
125
|
+
- Cache hit but file corrupted → detect and re-download
|
|
126
|
+
- Cache hit but wrong version → version check mechanism?
|
|
127
|
+
- Stale cache (90+ days old) → serve stale or force refresh?
|
|
128
|
+
- Manifest mismatch with actual files → which is source of truth?
|
|
129
|
+
|
|
130
|
+
### IPFS Publishing Scenarios
|
|
131
|
+
- Pinata upload timeout (slow upload) → retry with backoff?
|
|
132
|
+
- Pinata quota exceeded → alternative publishing method?
|
|
133
|
+
- CID changes on re-publish → database migration path?
|
|
134
|
+
- Partial upload (some files missing) → validation before CID storage?
|
|
135
|
+
|
|
136
|
+
### Integration Scenarios
|
|
137
|
+
- Old webtalk code still called by other modules → identify dependencies
|
|
138
|
+
- Config object mismatch between old/new systems → adapter needed?
|
|
139
|
+
- Progress event format incompatibility → transform events?
|
|
140
|
+
- Multiple conversations triggering download simultaneously → download once, notify all?
|
|
141
|
+
|
|
142
|
+
### Metrics Scenarios
|
|
143
|
+
- Metrics file corrupted → reset or recover?
|
|
144
|
+
- Metrics file too large (>1GB) → rotation working?
|
|
145
|
+
- Concurrent metric writes → locking or append-only safe?
|
|
146
|
+
- Metric timestamp drift → clock synchronization issue?
|
|
147
|
+
|
|
148
|
+
## ACCEPTANCE CRITERIA (GATE CONDITIONS)
|
|
149
|
+
|
|
150
|
+
### Implementation Completeness
|
|
151
|
+
- downloadWithFallback cycles through all 3 IPFS gateways before HuggingFace fallback
|
|
152
|
+
- SHA-256 verification occurs for every downloaded file against manifest
|
|
153
|
+
- Size validation (minBytes) occurs before SHA-256 check
|
|
154
|
+
- Progress events emitted for each layer attempt
|
|
155
|
+
- Metrics recorded for cache hit, each gateway attempt, HuggingFace attempt, all failures
|
|
156
|
+
- Temp files (.tmp) cleaned up on failure
|
|
157
|
+
- Backup files (.bak) created on corrupted cache detection
|
|
158
|
+
|
|
159
|
+
### Integration Correctness
|
|
160
|
+
- model-downloader.js imported in server.js
|
|
161
|
+
- ensureModelsDownloaded calls downloadWithFallback for each file in manifest
|
|
162
|
+
- Progress broadcasting compatible with existing WebSocket protocol
|
|
163
|
+
- Error messages surfaced to UI via existing error broadcast mechanism
|
|
164
|
+
- Concurrent download requests handled (single download, multiple waiters)
|
|
165
|
+
- No regression in existing model download behavior
|
|
166
|
+
|
|
167
|
+
### Database Accuracy
|
|
168
|
+
- Real IPFS CIDs stored in database.js (not placeholders)
|
|
169
|
+
- getIpfsCidByModel returns correct CID for whisper-base and tts-models
|
|
170
|
+
- Download records created in ipfs_downloads table
|
|
171
|
+
- Last accessed timestamp updated on CID retrieval
|
|
172
|
+
|
|
173
|
+
### End-to-End Verification
|
|
174
|
+
- Fresh install (empty ~/.gmgui/models) downloads all models successfully
|
|
175
|
+
- IPFS layer succeeds (witnessed download from Cloudflare/dweb.link/Pinata gateway)
|
|
176
|
+
- HuggingFace fallback works (simulate IPFS failure, verify HF download)
|
|
177
|
+
- Cache layer works (second download instant, metrics show cache hit)
|
|
178
|
+
- Corrupted cache detected and re-downloaded
|
|
179
|
+
- Metrics populated with real download data
|
|
180
|
+
- All 4 metrics endpoints return valid data
|
|
181
|
+
|
|
182
|
+
### Performance & Reliability
|
|
183
|
+
- 280MB whisper-base downloads in <5 minutes on 10Mbps connection
|
|
184
|
+
- 198MB tts-models downloads in <4 minutes on 10Mbps connection
|
|
185
|
+
- Gateway failover occurs within 30 seconds of timeout
|
|
186
|
+
- No memory leaks during large file downloads
|
|
187
|
+
- Progress updates at least every 5 seconds during active download
|
|
188
|
+
|
|
189
|
+
### Code Quality
|
|
190
|
+
- No duplicate code between old webtalk and new model-downloader
|
|
191
|
+
- All functions under 200 lines
|
|
192
|
+
- No hardcoded values (gateways, timeouts, paths configurable)
|
|
193
|
+
- Error messages descriptive (include layer, gateway, error type)
|
|
194
|
+
- No console.log for normal operations (use debug hooks or proper logging)
|
|
195
|
+
|
|
196
|
+
## REMAINING WORK BREAKDOWN
|
|
197
|
+
|
|
198
|
+
### Code Exploration (EXECUTE state, plugin:gm:dev) - Wave 2
|
|
199
|
+
4. Read webtalk/ipfs-downloader.js ensureModels to understand replacement
|
|
200
|
+
5. Read webtalk/whisper-models.js to understand file structure
|
|
201
|
+
6. Read webtalk/tts-models.js to understand file structure
|
|
202
|
+
7. Trace broadcastModelProgress usage in server.js
|
|
203
|
+
|
|
204
|
+
### Integration Design (EXECUTE state, plugin:gm:dev)
|
|
205
|
+
8. Map manifest files to downloadWithFallback calls
|
|
206
|
+
9. Design progress event transformation (downloadWithFallback → broadcastModelProgress)
|
|
207
|
+
10. Design error handling flow (all layers fail → user notification)
|
|
208
|
+
11. Design concurrent request handling (lock/queue mechanism)
|
|
209
|
+
12. Design backward compatibility (keep webtalk as fallback?)
|
|
210
|
+
|
|
211
|
+
### Implementation (EMIT state, after all unknowns resolved)
|
|
212
|
+
13. Add import statement for downloadWithFallback, getMetrics, etc.
|
|
213
|
+
14. Rewrite ensureModelsDownloaded function body
|
|
214
|
+
15. Test new implementation with existing cache
|
|
215
|
+
16. Delete old webtalk calls if confirmed unused elsewhere
|
|
216
|
+
|
|
217
|
+
### IPFS Publishing (EXECUTE state, conditional on API keys)
|
|
218
|
+
17. Check for PINATA_API_KEY environment variable
|
|
219
|
+
18. If keys available: execute scripts/publish-models-to-ipfs.js
|
|
220
|
+
19. If keys unavailable: document blocking status for user
|
|
221
|
+
20. Capture real CIDs from script output
|
|
222
|
+
21. Update database.js lines 389-390 with real CIDs
|
|
223
|
+
|
|
224
|
+
### Verification (VERIFY state, real execution)
|
|
225
|
+
22. Delete ~/.gmgui/models directory
|
|
226
|
+
23. Start server, trigger model download
|
|
227
|
+
24. Witness IPFS gateway download (check logs for gateway URLs)
|
|
228
|
+
25. Check metrics via GET /api/metrics/downloads
|
|
229
|
+
26. Verify all files present with correct SHA-256
|
|
230
|
+
27. Restart server, verify instant cache hit
|
|
231
|
+
28. Simulate IPFS failure (block gateway URLs), verify HuggingFace fallback
|
|
232
|
+
29. Check metrics show both IPFS and HuggingFace attempts
|
|
233
|
+
|
|
234
|
+
## OPEN QUESTIONS FOR USER
|
|
235
|
+
|
|
236
|
+
1. Pinata API keys: Are PINATA_API_KEY and PINATA_SECRET_KEY available? (blocks real IPFS publishing)
|
|
237
|
+
2. Gateway preference: Should IPFS be preferred over HuggingFace, or configurable?
|
|
238
|
+
3. Old webtalk code: Can we fully replace webtalk/ipfs-downloader, or keep as fallback?
|
|
239
|
+
4. Metrics retention: Is 24-hour metrics retention sufficient, or longer?
|
|
240
|
+
5. Error handling: Should download failure block server startup, or degrade gracefully?
|
|
241
|
+
|
|
242
|
+
## ESTIMATED COMPLETION
|
|
243
|
+
|
|
244
|
+
- Code exploration: 30 minutes (7 files to read and trace)
|
|
245
|
+
- Integration design: 45 minutes (map files, design flows)
|
|
246
|
+
- Implementation: 60 minutes (rewrite ensureModelsDownloaded, test)
|
|
247
|
+
- IPFS publishing: 15 minutes (if keys available) or 0 minutes (if blocked)
|
|
248
|
+
- Verification: 30 minutes (delete cache, test both layers, check metrics)
|
|
249
|
+
|
|
250
|
+
Total: 3 hours (if keys available) or 2.75 hours (if blocked on publishing)
|
|
251
|
+
|
|
252
|
+
## SUCCESS CRITERIA
|
|
253
|
+
|
|
254
|
+
Work is complete when:
|
|
255
|
+
- All 26 items above marked done (3 Wave 1 items removed as complete)
|
|
256
|
+
- Fresh ~/.gmgui/models download succeeds via IPFS layer 1
|
|
257
|
+
- Corrupted cache triggers re-download
|
|
258
|
+
- HuggingFace fallback proven working (simulated IPFS failure)
|
|
259
|
+
- Metrics show real download data for both layers
|
|
260
|
+
- No regressions in existing functionality
|
|
261
|
+
- Real IPFS CIDs in database (if keys available) OR documented blocking status
|
|
262
|
+
- User can verify system working via metrics endpoints
|
package/database.js
CHANGED
|
@@ -400,13 +400,13 @@ try {
|
|
|
400
400
|
console.log('[MODELS] Registered Whisper STT IPFS CID:', WHISPER_CID);
|
|
401
401
|
}
|
|
402
402
|
|
|
403
|
-
const existingTTS = db.prepare('SELECT * FROM ipfs_cids WHERE modelName = ? AND modelType = ?').get('tts', 'voice');
|
|
403
|
+
const existingTTS = db.prepare('SELECT * FROM ipfs_cids WHERE modelName = ? AND modelType = ?').get('tts-models', 'voice');
|
|
404
404
|
if (!existingTTS) {
|
|
405
405
|
const cidId = `cid-${Date.now()}-tts`;
|
|
406
406
|
db.prepare(
|
|
407
407
|
`INSERT INTO ipfs_cids (id, cid, modelName, modelType, modelHash, gatewayUrl, cached_at, last_accessed_at)
|
|
408
408
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
409
|
-
).run(cidId, TTS_CID, 'tts', 'voice', 'sha256-verified', LIGHTHOUSE_GATEWAY, Date.now(), Date.now());
|
|
409
|
+
).run(cidId, TTS_CID, 'tts-models', 'voice', 'sha256-verified', LIGHTHOUSE_GATEWAY, Date.now(), Date.now());
|
|
410
410
|
console.log('[MODELS] Registered TTS IPFS CID:', TTS_CID);
|
|
411
411
|
}
|
|
412
412
|
} catch (err) {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
const METRICS_PATH = path.join(os.homedir(), '.gmgui', 'models', '.metrics.json');
|
|
6
|
+
|
|
7
|
+
export function recordMetric(metric) {
|
|
8
|
+
const metricsDir = path.dirname(METRICS_PATH);
|
|
9
|
+
if (!fs.existsSync(metricsDir)) {
|
|
10
|
+
fs.mkdirSync(metricsDir, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let metrics = [];
|
|
14
|
+
if (fs.existsSync(METRICS_PATH)) {
|
|
15
|
+
try {
|
|
16
|
+
metrics = JSON.parse(fs.readFileSync(METRICS_PATH, 'utf8'));
|
|
17
|
+
} catch (e) {
|
|
18
|
+
metrics = [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
metrics.push({
|
|
23
|
+
...metric,
|
|
24
|
+
timestamp: new Date().toISOString()
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
|
28
|
+
metrics = metrics.filter(m => new Date(m.timestamp).getTime() > oneDayAgo);
|
|
29
|
+
|
|
30
|
+
fs.writeFileSync(METRICS_PATH, JSON.stringify(metrics, null, 2));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getMetrics() {
|
|
34
|
+
if (!fs.existsSync(METRICS_PATH)) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
return JSON.parse(fs.readFileSync(METRICS_PATH, 'utf8'));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getMetricsSummary() {
|
|
41
|
+
const metrics = getMetrics();
|
|
42
|
+
|
|
43
|
+
const summary = {
|
|
44
|
+
total: metrics.length,
|
|
45
|
+
cache_hits: metrics.filter(m => m.layer === 'cache' && m.status === 'hit').length,
|
|
46
|
+
ipfs: {
|
|
47
|
+
success: metrics.filter(m => m.layer === 'ipfs' && m.status === 'success').length,
|
|
48
|
+
error: metrics.filter(m => m.layer === 'ipfs' && m.status === 'error').length,
|
|
49
|
+
avg_latency: 0
|
|
50
|
+
},
|
|
51
|
+
huggingface: {
|
|
52
|
+
success: metrics.filter(m => m.layer === 'huggingface' && m.status === 'success').length,
|
|
53
|
+
error: metrics.filter(m => m.layer === 'huggingface' && m.status === 'error').length,
|
|
54
|
+
avg_latency: 0
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const ipfsSuccess = metrics.filter(m => m.layer === 'ipfs' && m.status === 'success');
|
|
59
|
+
if (ipfsSuccess.length > 0) {
|
|
60
|
+
summary.ipfs.avg_latency = Math.round(
|
|
61
|
+
ipfsSuccess.reduce((sum, m) => sum + m.latency_ms, 0) / ipfsSuccess.length
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const hfSuccess = metrics.filter(m => m.layer === 'huggingface' && m.status === 'success');
|
|
66
|
+
if (hfSuccess.length > 0) {
|
|
67
|
+
summary.huggingface.avg_latency = Math.round(
|
|
68
|
+
hfSuccess.reduce((sum, m) => sum + m.latency_ms, 0) / hfSuccess.length
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return summary;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resetMetrics() {
|
|
76
|
+
if (fs.existsSync(METRICS_PATH)) {
|
|
77
|
+
fs.unlinkSync(METRICS_PATH);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
|
|
4
|
+
export function verifyFileIntegrity(filepath, expectedHash, minBytes) {
|
|
5
|
+
if (!fs.existsSync(filepath)) {
|
|
6
|
+
return { valid: false, reason: 'file_not_found' };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const stats = fs.statSync(filepath);
|
|
10
|
+
if (minBytes && stats.size < minBytes) {
|
|
11
|
+
return { valid: false, reason: 'size_too_small', actual: stats.size, expected: minBytes };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (expectedHash) {
|
|
15
|
+
const hash = crypto.createHash('sha256');
|
|
16
|
+
const data = fs.readFileSync(filepath);
|
|
17
|
+
hash.update(data);
|
|
18
|
+
const actualHash = hash.digest('hex');
|
|
19
|
+
|
|
20
|
+
if (actualHash !== expectedHash) {
|
|
21
|
+
return { valid: false, reason: 'hash_mismatch', actual: actualHash, expected: expectedHash };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return { valid: true };
|
|
26
|
+
}
|
package/lib/model-downloader.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createRequire } from 'module';
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import
|
|
5
|
-
import
|
|
4
|
+
import { recordMetric } from './download-metrics.js';
|
|
5
|
+
import { verifyFileIntegrity } from './file-verification.js';
|
|
6
6
|
|
|
7
7
|
const require = createRequire(import.meta.url);
|
|
8
8
|
|
|
@@ -13,58 +13,6 @@ const GATEWAYS = [
|
|
|
13
13
|
'https://ipfs.io/ipfs/'
|
|
14
14
|
];
|
|
15
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
16
|
async function downloadFromIPFS(cid, destPath, manifest, onProgress) {
|
|
69
17
|
const startTime = Date.now();
|
|
70
18
|
|
|
@@ -239,50 +187,4 @@ export async function downloadWithFallback(options, onProgress) {
|
|
|
239
187
|
throw new Error('All download layers exhausted');
|
|
240
188
|
}
|
|
241
189
|
|
|
242
|
-
export
|
|
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
|
-
}
|
|
190
|
+
export { getMetrics, getMetricsSummary, resetMetrics } from './download-metrics.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentgui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.320",
|
|
4
4
|
"description": "Multi-agent ACP client with real-time communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"better-sqlite3": "^12.6.2",
|
|
27
27
|
"busboy": "^1.6.0",
|
|
28
28
|
"express": "^5.2.1",
|
|
29
|
+
"form-data": "^4.0.5",
|
|
29
30
|
"fsbrowse": "^0.2.18",
|
|
30
31
|
"google-auth-library": "^10.5.0",
|
|
31
32
|
"onnxruntime-node": "^1.24.1",
|
package/server.js
CHANGED
|
@@ -14,6 +14,7 @@ import Busboy from 'busboy';
|
|
|
14
14
|
import fsbrowse from 'fsbrowse';
|
|
15
15
|
import { queries } from './database.js';
|
|
16
16
|
import { runClaudeWithStreaming } from './lib/claude-runner.js';
|
|
17
|
+
import { downloadWithFallback } from './lib/model-downloader.js';
|
|
17
18
|
const { downloadWithProgress } = createRequire(import.meta.url)('webtalk/ipfs-downloader');
|
|
18
19
|
|
|
19
20
|
const ttsTextAccumulators = new Map();
|
|
@@ -77,39 +78,70 @@ async function ensureModelsDownloaded() {
|
|
|
77
78
|
? (fs.existsSync(path.join(process.env.PORTABLE_EXE_DIR, 'models', 'onnx-community')) ? path.join(process.env.PORTABLE_EXE_DIR, 'models') : gmguiModels)
|
|
78
79
|
: gmguiModels;
|
|
79
80
|
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
ttsModelsDir: path.join(modelsBase, 'tts'),
|
|
85
|
-
sttModelsDir: path.join(modelsBase, 'stt'),
|
|
86
|
-
});
|
|
81
|
+
const manifestPath = path.join(gmguiModels, '.manifests.json');
|
|
82
|
+
if (!fs.existsSync(manifestPath)) {
|
|
83
|
+
throw new Error('Model manifest not found at ' + manifestPath);
|
|
84
|
+
}
|
|
87
85
|
|
|
88
|
-
const
|
|
89
|
-
const { checkWhisperModelExists } = createRequire(import.meta.url)('webtalk/whisper-models');
|
|
86
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
90
87
|
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
if (sttOk && ttsOk) {
|
|
95
|
-
console.log('[MODELS] All model files present');
|
|
96
|
-
modelDownloadState.complete = true;
|
|
97
|
-
return true;
|
|
98
|
-
}
|
|
88
|
+
const whisperCidRecord = queries.getIpfsCidByModel('whisper-base', 'stt');
|
|
89
|
+
const ttsCidRecord = queries.getIpfsCidByModel('tts', 'voice');
|
|
99
90
|
|
|
100
91
|
modelDownloadState.downloading = true;
|
|
101
92
|
modelDownloadState.error = null;
|
|
102
93
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
94
|
+
const downloadModel = async (modelName, modelType, cidRecord) => {
|
|
95
|
+
const modelManifest = manifest[modelName];
|
|
96
|
+
if (!modelManifest) {
|
|
97
|
+
throw new Error(`Model ${modelName} not found in manifest`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const isWhisper = modelType === 'stt';
|
|
101
|
+
const baseDir = isWhisper
|
|
102
|
+
? path.join(modelsBase, 'onnx-community', 'whisper-base')
|
|
103
|
+
: path.join(modelsBase, 'tts');
|
|
104
|
+
|
|
105
|
+
fs.mkdirSync(baseDir, { recursive: true });
|
|
106
|
+
|
|
107
|
+
for (const [filename, fileInfo] of Object.entries(modelManifest.files)) {
|
|
108
|
+
const destPath = path.join(baseDir, filename);
|
|
109
|
+
|
|
110
|
+
if (fs.existsSync(destPath) && fs.statSync(destPath).size === fileInfo.size) {
|
|
111
|
+
console.log(`[MODELS] ${filename} already exists, skipping`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const ipfsCid = cidRecord ? `${cidRecord.cid}/${filename}` : null;
|
|
116
|
+
const huggingfaceUrl = isWhisper
|
|
117
|
+
? `https://huggingface.co/onnx-community/whisper-base/resolve/main/${filename}`
|
|
118
|
+
: `https://huggingface.co/datasets/AnEntrypoint/sttttsmodels/resolve/main/tts/${filename}`;
|
|
119
|
+
|
|
120
|
+
await downloadWithFallback({
|
|
121
|
+
ipfsCid,
|
|
122
|
+
huggingfaceUrl,
|
|
123
|
+
destPath,
|
|
124
|
+
manifest: fileInfo,
|
|
125
|
+
minBytes: fileInfo.size * 0.8,
|
|
126
|
+
preferredLayer: ipfsCid ? 'ipfs' : 'huggingface'
|
|
127
|
+
}, (progress) => {
|
|
128
|
+
broadcastModelProgress({
|
|
129
|
+
started: true,
|
|
130
|
+
done: progress.status === 'success',
|
|
131
|
+
downloading: progress.status === 'downloading',
|
|
132
|
+
type: modelType === 'stt' ? 'whisper' : 'tts',
|
|
133
|
+
source: progress.layer === 'cache' ? 'cache' : progress.layer,
|
|
134
|
+
status: progress.status,
|
|
135
|
+
file: filename,
|
|
136
|
+
progress: progress.total ? (progress.downloaded / progress.total * 100) : 0,
|
|
137
|
+
gateway: progress.gateway
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
await downloadModel('whisper-base', 'stt', whisperCidRecord);
|
|
144
|
+
await downloadModel('tts-models', 'voice', ttsCidRecord);
|
|
113
145
|
|
|
114
146
|
modelDownloadState.complete = true;
|
|
115
147
|
broadcastModelProgress({ started: true, done: true, downloading: false });
|
package/static/index.html
CHANGED
|
@@ -727,6 +727,26 @@
|
|
|
727
727
|
::-webkit-scrollbar-thumb { background-color: var(--color-border); border-radius: 3px; }
|
|
728
728
|
::-webkit-scrollbar-thumb:hover { background-color: var(--color-text-tertiary); }
|
|
729
729
|
|
|
730
|
+
/* DIALOGS */
|
|
731
|
+
.dialog-overlay { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; }
|
|
732
|
+
.dialog-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.5); }
|
|
733
|
+
.dialog-box { position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); background: var(--color-bg-secondary); border: 1px solid var(--color-border); border-radius: 8px; padding: 24px; min-width: 320px; max-width: 480px; box-shadow: 0 8px 32px rgba(0,0,0,0.2); }
|
|
734
|
+
.dialog-box h3, .dialog-box .dialog-title { font-size: 1rem; font-weight: 600; margin-bottom: 12px; color: var(--color-text-primary); }
|
|
735
|
+
.dialog-box p, .dialog-box .dialog-message { color: var(--color-text-secondary); margin-bottom: 16px; font-size: 0.875rem; }
|
|
736
|
+
.dialog-box input, .dialog-box textarea { width: 100%; padding: 8px 12px; border: 1px solid var(--color-border); border-radius: 6px; background: var(--color-bg-tertiary); color: var(--color-text-primary); font-size: 0.875rem; margin-bottom: 16px; }
|
|
737
|
+
.dialog-footer { display: flex; gap: 8px; justify-content: flex-end; }
|
|
738
|
+
.dialog-btn { padding: 6px 16px; border-radius: 6px; border: 1px solid var(--color-border); background: var(--color-bg-tertiary); color: var(--color-text-primary); cursor: pointer; font-size: 0.875rem; }
|
|
739
|
+
.dialog-btn-primary { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
|
|
740
|
+
.dialog-btn:hover { opacity: 0.85; }
|
|
741
|
+
.dialog-box-progress .dialog-progress-bar { height: 4px; background: var(--color-border); border-radius: 2px; overflow: hidden; margin-bottom: 8px; }
|
|
742
|
+
.dialog-box-progress .dialog-progress-fill { height: 100%; background: var(--color-primary); transition: width 0.3s; }
|
|
743
|
+
|
|
744
|
+
/* TOOL ICONS */
|
|
745
|
+
.folded-tool-icon { display: inline-flex; align-items: center; flex-shrink: 0; }
|
|
746
|
+
.folded-tool-icon svg { width: 16px; height: 16px; flex-shrink: 0; }
|
|
747
|
+
.folded-tool-bar { display: flex; align-items: center; gap: 6px; }
|
|
748
|
+
.tool-result-status { display: flex; align-items: center; gap: 6px; }
|
|
749
|
+
|
|
730
750
|
/* RESPONSIVE */
|
|
731
751
|
@media (max-width: 768px) {
|
|
732
752
|
.sidebar {
|