termux-vision 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cache.js CHANGED
@@ -1,317 +1,321 @@
1
- /**
2
- * Model Cache Management and Artifact Discovery for Node.js.
3
- * Open-Source under Apache License 2.0.
4
- */
5
-
6
- 'use strict';
7
-
8
- const fs = require('fs');
9
- const path = require('path');
10
- const os = require('os');
11
- const https = require('https');
12
- const http = require('http');
13
- const crypto = require('crypto');
14
-
15
- const {
16
- ModelNotFoundError,
17
- NoInstalledModelsError,
18
- ModelDownloadError
19
- } = require('./errors');
20
-
21
- const CATALOG = {
22
- 'smolvlm-500m-q4': {
23
- modelId: 'smolvlm-500m-q4',
24
- adapter: 'smolvlm',
25
- tier: 'M',
26
- estimatedMemoryMb: 750,
27
- contextLimit: 2048,
28
- preferredResolution: 384,
29
- artifacts: [
30
- {
31
- role: 'language_model',
32
- filename: 'smolvlm-500m-instruct-q4_k_m.gguf',
33
- sizeBytes: 350000000,
34
- downloadUrl: 'https://huggingface.co/HuggingFaceTB/SmolVLM-500M-Instruct-GGUF/resolve/main/smolvlm-500m-instruct-q4_k_m.gguf'
35
- },
36
- {
37
- role: 'vision_projector',
38
- filename: 'mmproj-smolvlm-500m-instruct-f16.gguf',
39
- sizeBytes: 200000000,
40
- downloadUrl: 'https://huggingface.co/HuggingFaceTB/SmolVLM-500M-Instruct-GGUF/resolve/main/mmproj-smolvlm-500m-instruct-f16.gguf'
41
- }
42
- ]
43
- },
44
- 'qwen2-vl-2b-q4': {
45
- modelId: 'qwen2-vl-2b-q4',
46
- adapter: 'qwen2vl',
47
- tier: 'L',
48
- estimatedMemoryMb: 2100,
49
- contextLimit: 8192,
50
- preferredResolution: 384,
51
- artifacts: [
52
- {
53
- role: 'language_model',
54
- filename: 'Qwen2-VL-2B-Instruct-Q4_K_M.gguf',
55
- sizeBytes: 986046944,
56
- downloadUrl: 'https://huggingface.co/second-state/Qwen2-VL-2B-Instruct-GGUF/resolve/main/Qwen2-VL-2B-Instruct-Q4_K_M.gguf'
57
- },
58
- {
59
- role: 'vision_projector',
60
- filename: 'Qwen2-VL-2B-Instruct-vision-encoder.gguf',
61
- sizeBytes: 2600000000,
62
- downloadUrl: 'https://huggingface.co/second-state/Qwen2-VL-2B-Instruct-GGUF/resolve/main/Qwen2-VL-2B-Instruct-vision-encoder.gguf'
63
- }
64
- ]
65
- }
66
- };
67
-
68
- class ModelCacheManager {
69
- constructor(customCacheRoot = null) {
70
- this.cacheRoot = customCacheRoot || path.join(os.homedir(), '.cache', 'termux-vision');
71
- this.legacyCache = path.join(os.homedir(), '.cache', 'vlm_models');
72
- this.modelsDir = path.join(this.cacheRoot, 'models');
73
- this.downloadsDir = path.join(this.cacheRoot, 'downloads');
74
-
75
- fs.mkdirSync(this.modelsDir, { recursive: true });
76
- fs.mkdirSync(this.downloadsDir, { recursive: true });
77
- }
78
-
79
- getAvailableCatalogModels() {
80
- return Object.keys(CATALOG).sort();
81
- }
82
-
83
- isModelInstalled(modelId) {
84
- if (CATALOG[modelId] && fs.existsSync(this.legacyCache)) {
85
- const cat = CATALOG[modelId];
86
- const allExist = cat.artifacts.every(a => {
87
- const fp = path.join(this.legacyCache, a.filename);
88
- return fs.existsSync(fp) && fs.statSync(fp).size > 10000000;
89
- });
90
- if (allExist) return true;
91
- }
92
-
93
- const mdir = path.join(this.modelsDir, modelId);
94
- if (!fs.existsSync(mdir)) return false;
95
-
96
- // Check ready marker or custom gguf + mmproj
97
- const readyMarker = path.join(mdir, 'READY');
98
- if (fs.existsSync(readyMarker)) return true;
99
-
100
- if (fs.statSync(mdir).isDirectory()) {
101
- const files = fs.readdirSync(mdir);
102
- const ggufs = files.filter(f => f.endsWith('.gguf') && fs.statSync(path.join(mdir, f)).size > 0);
103
- const visionGgufs = ggufs.filter(f => /mmproj|encoder|projector/i.test(f));
104
- const textGgufs = ggufs.filter(f => !/mmproj|encoder|projector/i.test(f));
105
- if ((textGgufs.length > 0 && visionGgufs.length > 0) || ggufs.length >= 2) {
106
- return true;
107
- }
108
- }
109
- return false;
110
- }
111
-
112
- listInstalled() {
113
- const results = [];
114
- if (fs.existsSync(this.modelsDir)) {
115
- const entries = fs.readdirSync(this.modelsDir);
116
- for (const name of entries) {
117
- const mdir = path.join(this.modelsDir, name);
118
- if (fs.statSync(mdir).isDirectory() && this.isModelInstalled(name)) {
119
- let totalBytes = 0;
120
- const files = fs.readdirSync(mdir);
121
- for (const f of files) {
122
- try {
123
- totalBytes += fs.statSync(path.join(mdir, f)).size;
124
- } catch (e) {}
125
- }
126
- const tier = CATALOG[name] ? CATALOG[name].tier : 'CUSTOM';
127
- results.push({
128
- modelId: name,
129
- tier: tier,
130
- state: 'READY',
131
- sizeMb: Math.round(totalBytes / (1024 * 1024)),
132
- path: mdir
133
- });
134
- }
135
- }
136
- }
137
-
138
- if (fs.existsSync(this.legacyCache)) {
139
- for (const [key, val] of Object.entries(CATALOG)) {
140
- if (!results.find(r => r.modelId === key)) {
141
- const allFound = val.artifacts.every(a => {
142
- const fpath = path.join(this.legacyCache, a.filename);
143
- return fs.existsSync(fpath) && fs.statSync(fpath).size > 10000000;
144
- });
145
- if (allFound) {
146
- results.push({
147
- modelId: key,
148
- tier: val.tier,
149
- state: 'READY',
150
- sizeMb: Math.round(val.artifacts.reduce((acc, a) => acc + (a.sizeBytes || 0), 0) / (1024 * 1024)),
151
- path: this.legacyCache
152
- });
153
- }
154
- }
155
- }
156
- }
157
- return results;
158
- }
159
-
160
- requireInstalledModel(modelId, mmprojPath = null) {
161
- // 1. Direct file path check (Custom 싸제 GGUF)
162
- const expanded = path.resolve(modelId.replace(/^~(?=$|\/|\\)/, os.homedir()));
163
- if (fs.existsSync(expanded) && fs.statSync(expanded).isFile()) {
164
- const dir = path.dirname(expanded);
165
- const filename = path.basename(expanded);
166
- let visionPath = mmprojPath ? path.resolve(mmprojPath.replace(/^~(?=$|\/|\\)/, os.homedir())) : null;
167
- if (!visionPath) {
168
- const files = fs.readdirSync(dir);
169
- const candidate = files.find(f => f.endsWith('.gguf') && /mmproj|encoder|projector/i.test(f));
170
- if (candidate) visionPath = path.join(dir, candidate);
171
- }
172
- return {
173
- manifest: {
174
- modelId: path.parse(filename).name,
175
- adapter: /smol/i.test(filename) ? 'smolvlm' : 'qwen2vl',
176
- tier: 'CUSTOM',
177
- contextLimit: 2048
178
- },
179
- textModelPath: expanded,
180
- visionModelPath: visionPath
181
- };
182
- }
183
-
184
- // 2. Installed model search
185
- const installed = this.listInstalled();
186
- const installedIds = installed.map(m => m.modelId);
187
-
188
- if (installedIds.length === 0) {
189
- throw new NoInstalledModelsError(this.getAvailableCatalogModels());
190
- }
191
-
192
- if (!installedIds.includes(modelId)) {
193
- throw new ModelNotFoundError(modelId, installedIds, this.getAvailableCatalogModels());
194
- }
195
-
196
- const mdir = path.join(this.modelsDir, modelId);
197
- let textPath = null;
198
- let visionPath = mmprojPath ? path.resolve(mmprojPath) : null;
199
-
200
- if (CATALOG[modelId]) {
201
- const cat = CATALOG[modelId];
202
- const searchDirs = [mdir, this.legacyCache];
203
- for (const sDir of searchDirs) {
204
- if (!fs.existsSync(sDir)) continue;
205
- for (const a of cat.artifacts) {
206
- const p = path.join(sDir, a.filename);
207
- if (fs.existsSync(p)) {
208
- if (a.role === 'language_model') textPath = p;
209
- if (a.role === 'vision_projector') visionPath = p;
210
- }
211
- }
212
- if (textPath && visionPath) break;
213
- }
214
- return {
215
- manifest: cat,
216
- textModelPath: textPath,
217
- visionModelPath: visionPath
218
- };
219
- }
220
-
221
- // Custom directory
222
- const files = fs.readdirSync(mdir);
223
- const ggufs = files.filter(f => f.endsWith('.gguf'));
224
- const visionFiles = ggufs.filter(f => /mmproj|encoder|projector/i.test(f));
225
- const textFiles = ggufs.filter(f => !/mmproj|encoder|projector/i.test(f));
226
-
227
- textPath = textFiles.length > 0 ? path.join(mdir, textFiles[0]) : (ggufs.length > 0 ? path.join(mdir, ggufs[0]) : null);
228
- if (!visionPath) {
229
- visionPath = visionFiles.length > 0 ? path.join(mdir, visionFiles[0]) : (ggufs.length > 1 ? path.join(mdir, ggufs[1]) : null);
230
- }
231
-
232
- return {
233
- manifest: {
234
- modelId: modelId,
235
- adapter: /smol/i.test(modelId) ? 'smolvlm' : 'qwen2vl',
236
- tier: 'CUSTOM',
237
- contextLimit: 2048
238
- },
239
- textModelPath: textPath,
240
- visionModelPath: visionPath
241
- };
242
- }
243
-
244
- async downloadFile(url, destPath, onProgress = null) {
245
- const partialPath = destPath + '.partial';
246
- if (fs.existsSync(partialPath)) fs.unlinkSync(partialPath);
247
-
248
- return new Promise((resolve, reject) => {
249
- const getFollow = (currentUrl) => {
250
- const client = currentUrl.startsWith('https') ? https : http;
251
- const req = client.get(currentUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (termux-vision node)' } }, (res) => {
252
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
253
- return getFollow(res.headers.location);
254
- }
255
- if (res.statusCode !== 200) {
256
- return reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
257
- }
258
- const total = parseInt(res.headers['content-length'] || '0', 10);
259
- let downloaded = 0;
260
- const out = fs.createWriteStream(partialPath);
261
-
262
- res.on('data', (chunk) => {
263
- downloaded += chunk.length;
264
- if (onProgress) onProgress(path.basename(destPath), downloaded, total);
265
- });
266
-
267
- res.pipe(out);
268
- out.on('finish', () => {
269
- out.close(() => {
270
- if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
271
- fs.renameSync(partialPath, destPath);
272
- resolve();
273
- });
274
- });
275
- });
276
- req.on('error', (err) => {
277
- if (fs.existsSync(partialPath)) try { fs.unlinkSync(partialPath); } catch (e) {}
278
- reject(err);
279
- });
280
- };
281
- getFollow(url);
282
- });
283
- }
284
-
285
- async install(modelId, onProgress = null) {
286
- if (!CATALOG[modelId]) {
287
- const installed = this.listInstalled().map(m => m.modelId);
288
- throw new ModelDownloadError(modelId, `Model '${modelId}' is not in catalog.`, installed);
289
- }
290
- const manifest = CATALOG[modelId];
291
- const targetDir = path.join(this.modelsDir, modelId);
292
- fs.mkdirSync(targetDir, { recursive: true });
293
-
294
- for (const art of manifest.artifacts) {
295
- const dest = path.join(targetDir, art.filename);
296
- if (fs.existsSync(dest) && fs.statSync(dest).size > 0) continue;
297
- await this.downloadFile(art.downloadUrl, dest, onProgress);
298
- }
299
-
300
- fs.writeFileSync(path.join(targetDir, 'READY'), 'OK\n', 'utf-8');
301
- fs.writeFileSync(path.join(targetDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf-8');
302
- }
303
-
304
- remove(modelId) {
305
- const mdir = path.join(this.modelsDir, modelId);
306
- if (fs.existsSync(mdir)) {
307
- fs.rmSync(mdir, { recursive: true, force: true });
308
- return true;
309
- }
310
- return false;
311
- }
312
- }
313
-
314
- module.exports = {
315
- CATALOG,
316
- ModelCacheManager
317
- };
1
+ /**
2
+ * Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
3
+ * SPDX-License-Identifier: Apache-2.0 OR Commercial-License
4
+ */
5
+ /**
6
+ * Model Cache Management and Artifact Discovery for Node.js.
7
+ * Open-Source under Apache License 2.0.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+ const https = require('https');
16
+ const http = require('http');
17
+ const crypto = require('crypto');
18
+
19
+ const {
20
+ ModelNotFoundError,
21
+ NoInstalledModelsError,
22
+ ModelDownloadError
23
+ } = require('./errors');
24
+
25
+ const CATALOG = {
26
+ 'smolvlm-500m-q4': {
27
+ modelId: 'smolvlm-500m-q4',
28
+ adapter: 'smolvlm',
29
+ tier: 'M',
30
+ estimatedMemoryMb: 750,
31
+ contextLimit: 2048,
32
+ preferredResolution: 384,
33
+ artifacts: [
34
+ {
35
+ role: 'language_model',
36
+ filename: 'smolvlm-500m-instruct-q4_k_m.gguf',
37
+ sizeBytes: 350000000,
38
+ downloadUrl: 'https://huggingface.co/HuggingFaceTB/SmolVLM-500M-Instruct-GGUF/resolve/main/smolvlm-500m-instruct-q4_k_m.gguf'
39
+ },
40
+ {
41
+ role: 'vision_projector',
42
+ filename: 'mmproj-smolvlm-500m-instruct-f16.gguf',
43
+ sizeBytes: 200000000,
44
+ downloadUrl: 'https://huggingface.co/HuggingFaceTB/SmolVLM-500M-Instruct-GGUF/resolve/main/mmproj-smolvlm-500m-instruct-f16.gguf'
45
+ }
46
+ ]
47
+ },
48
+ 'qwen2-vl-2b-q4': {
49
+ modelId: 'qwen2-vl-2b-q4',
50
+ adapter: 'qwen2vl',
51
+ tier: 'L',
52
+ estimatedMemoryMb: 2100,
53
+ contextLimit: 8192,
54
+ preferredResolution: 384,
55
+ artifacts: [
56
+ {
57
+ role: 'language_model',
58
+ filename: 'Qwen2-VL-2B-Instruct-Q4_K_M.gguf',
59
+ sizeBytes: 986046944,
60
+ downloadUrl: 'https://huggingface.co/second-state/Qwen2-VL-2B-Instruct-GGUF/resolve/main/Qwen2-VL-2B-Instruct-Q4_K_M.gguf'
61
+ },
62
+ {
63
+ role: 'vision_projector',
64
+ filename: 'Qwen2-VL-2B-Instruct-vision-encoder.gguf',
65
+ sizeBytes: 2600000000,
66
+ downloadUrl: 'https://huggingface.co/second-state/Qwen2-VL-2B-Instruct-GGUF/resolve/main/Qwen2-VL-2B-Instruct-vision-encoder.gguf'
67
+ }
68
+ ]
69
+ }
70
+ };
71
+
72
+ class ModelCacheManager {
73
+ constructor(customCacheRoot = null) {
74
+ this.cacheRoot = customCacheRoot || path.join(os.homedir(), '.cache', 'termux-vision');
75
+ this.legacyCache = path.join(os.homedir(), '.cache', 'vlm_models');
76
+ this.modelsDir = path.join(this.cacheRoot, 'models');
77
+ this.downloadsDir = path.join(this.cacheRoot, 'downloads');
78
+
79
+ fs.mkdirSync(this.modelsDir, { recursive: true });
80
+ fs.mkdirSync(this.downloadsDir, { recursive: true });
81
+ }
82
+
83
+ getAvailableCatalogModels() {
84
+ return Object.keys(CATALOG).sort();
85
+ }
86
+
87
+ isModelInstalled(modelId) {
88
+ if (CATALOG[modelId] && fs.existsSync(this.legacyCache)) {
89
+ const cat = CATALOG[modelId];
90
+ const allExist = cat.artifacts.every(a => {
91
+ const fp = path.join(this.legacyCache, a.filename);
92
+ return fs.existsSync(fp) && fs.statSync(fp).size > 10000000;
93
+ });
94
+ if (allExist) return true;
95
+ }
96
+
97
+ const mdir = path.join(this.modelsDir, modelId);
98
+ if (!fs.existsSync(mdir)) return false;
99
+
100
+ // Check ready marker or custom gguf + mmproj
101
+ const readyMarker = path.join(mdir, 'READY');
102
+ if (fs.existsSync(readyMarker)) return true;
103
+
104
+ if (fs.statSync(mdir).isDirectory()) {
105
+ const files = fs.readdirSync(mdir);
106
+ const ggufs = files.filter(f => f.endsWith('.gguf') && fs.statSync(path.join(mdir, f)).size > 0);
107
+ const visionGgufs = ggufs.filter(f => /mmproj|encoder|projector/i.test(f));
108
+ const textGgufs = ggufs.filter(f => !/mmproj|encoder|projector/i.test(f));
109
+ if ((textGgufs.length > 0 && visionGgufs.length > 0) || ggufs.length >= 2) {
110
+ return true;
111
+ }
112
+ }
113
+ return false;
114
+ }
115
+
116
+ listInstalled() {
117
+ const results = [];
118
+ if (fs.existsSync(this.modelsDir)) {
119
+ const entries = fs.readdirSync(this.modelsDir);
120
+ for (const name of entries) {
121
+ const mdir = path.join(this.modelsDir, name);
122
+ if (fs.statSync(mdir).isDirectory() && this.isModelInstalled(name)) {
123
+ let totalBytes = 0;
124
+ const files = fs.readdirSync(mdir);
125
+ for (const f of files) {
126
+ try {
127
+ totalBytes += fs.statSync(path.join(mdir, f)).size;
128
+ } catch (e) {}
129
+ }
130
+ const tier = CATALOG[name] ? CATALOG[name].tier : 'CUSTOM';
131
+ results.push({
132
+ modelId: name,
133
+ tier: tier,
134
+ state: 'READY',
135
+ sizeMb: Math.round(totalBytes / (1024 * 1024)),
136
+ path: mdir
137
+ });
138
+ }
139
+ }
140
+ }
141
+
142
+ if (fs.existsSync(this.legacyCache)) {
143
+ for (const [key, val] of Object.entries(CATALOG)) {
144
+ if (!results.find(r => r.modelId === key)) {
145
+ const allFound = val.artifacts.every(a => {
146
+ const fpath = path.join(this.legacyCache, a.filename);
147
+ return fs.existsSync(fpath) && fs.statSync(fpath).size > 10000000;
148
+ });
149
+ if (allFound) {
150
+ results.push({
151
+ modelId: key,
152
+ tier: val.tier,
153
+ state: 'READY',
154
+ sizeMb: Math.round(val.artifacts.reduce((acc, a) => acc + (a.sizeBytes || 0), 0) / (1024 * 1024)),
155
+ path: this.legacyCache
156
+ });
157
+ }
158
+ }
159
+ }
160
+ }
161
+ return results;
162
+ }
163
+
164
+ requireInstalledModel(modelId, mmprojPath = null) {
165
+ // 1. Direct file path check (Custom 싸제 GGUF)
166
+ const expanded = path.resolve(modelId.replace(/^~(?=$|\/|\\)/, os.homedir()));
167
+ if (fs.existsSync(expanded) && fs.statSync(expanded).isFile()) {
168
+ const dir = path.dirname(expanded);
169
+ const filename = path.basename(expanded);
170
+ let visionPath = mmprojPath ? path.resolve(mmprojPath.replace(/^~(?=$|\/|\\)/, os.homedir())) : null;
171
+ if (!visionPath) {
172
+ const files = fs.readdirSync(dir);
173
+ const candidate = files.find(f => f.endsWith('.gguf') && /mmproj|encoder|projector/i.test(f));
174
+ if (candidate) visionPath = path.join(dir, candidate);
175
+ }
176
+ return {
177
+ manifest: {
178
+ modelId: path.parse(filename).name,
179
+ adapter: /smol/i.test(filename) ? 'smolvlm' : 'qwen2vl',
180
+ tier: 'CUSTOM',
181
+ contextLimit: 2048
182
+ },
183
+ textModelPath: expanded,
184
+ visionModelPath: visionPath
185
+ };
186
+ }
187
+
188
+ // 2. Installed model search
189
+ const installed = this.listInstalled();
190
+ const installedIds = installed.map(m => m.modelId);
191
+
192
+ if (installedIds.length === 0) {
193
+ throw new NoInstalledModelsError(this.getAvailableCatalogModels());
194
+ }
195
+
196
+ if (!installedIds.includes(modelId)) {
197
+ throw new ModelNotFoundError(modelId, installedIds, this.getAvailableCatalogModels());
198
+ }
199
+
200
+ const mdir = path.join(this.modelsDir, modelId);
201
+ let textPath = null;
202
+ let visionPath = mmprojPath ? path.resolve(mmprojPath) : null;
203
+
204
+ if (CATALOG[modelId]) {
205
+ const cat = CATALOG[modelId];
206
+ const searchDirs = [mdir, this.legacyCache];
207
+ for (const sDir of searchDirs) {
208
+ if (!fs.existsSync(sDir)) continue;
209
+ for (const a of cat.artifacts) {
210
+ const p = path.join(sDir, a.filename);
211
+ if (fs.existsSync(p)) {
212
+ if (a.role === 'language_model') textPath = p;
213
+ if (a.role === 'vision_projector') visionPath = p;
214
+ }
215
+ }
216
+ if (textPath && visionPath) break;
217
+ }
218
+ return {
219
+ manifest: cat,
220
+ textModelPath: textPath,
221
+ visionModelPath: visionPath
222
+ };
223
+ }
224
+
225
+ // Custom directory
226
+ const files = fs.readdirSync(mdir);
227
+ const ggufs = files.filter(f => f.endsWith('.gguf'));
228
+ const visionFiles = ggufs.filter(f => /mmproj|encoder|projector/i.test(f));
229
+ const textFiles = ggufs.filter(f => !/mmproj|encoder|projector/i.test(f));
230
+
231
+ textPath = textFiles.length > 0 ? path.join(mdir, textFiles[0]) : (ggufs.length > 0 ? path.join(mdir, ggufs[0]) : null);
232
+ if (!visionPath) {
233
+ visionPath = visionFiles.length > 0 ? path.join(mdir, visionFiles[0]) : (ggufs.length > 1 ? path.join(mdir, ggufs[1]) : null);
234
+ }
235
+
236
+ return {
237
+ manifest: {
238
+ modelId: modelId,
239
+ adapter: /smol/i.test(modelId) ? 'smolvlm' : 'qwen2vl',
240
+ tier: 'CUSTOM',
241
+ contextLimit: 2048
242
+ },
243
+ textModelPath: textPath,
244
+ visionModelPath: visionPath
245
+ };
246
+ }
247
+
248
+ async downloadFile(url, destPath, onProgress = null) {
249
+ const partialPath = destPath + '.partial';
250
+ if (fs.existsSync(partialPath)) fs.unlinkSync(partialPath);
251
+
252
+ return new Promise((resolve, reject) => {
253
+ const getFollow = (currentUrl) => {
254
+ const client = currentUrl.startsWith('https') ? https : http;
255
+ const req = client.get(currentUrl, { headers: { 'User-Agent': 'Mozilla/5.0 (termux-vision node)' } }, (res) => {
256
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
257
+ return getFollow(res.headers.location);
258
+ }
259
+ if (res.statusCode !== 200) {
260
+ return reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
261
+ }
262
+ const total = parseInt(res.headers['content-length'] || '0', 10);
263
+ let downloaded = 0;
264
+ const out = fs.createWriteStream(partialPath);
265
+
266
+ res.on('data', (chunk) => {
267
+ downloaded += chunk.length;
268
+ if (onProgress) onProgress(path.basename(destPath), downloaded, total);
269
+ });
270
+
271
+ res.pipe(out);
272
+ out.on('finish', () => {
273
+ out.close(() => {
274
+ if (fs.existsSync(destPath)) fs.unlinkSync(destPath);
275
+ fs.renameSync(partialPath, destPath);
276
+ resolve();
277
+ });
278
+ });
279
+ });
280
+ req.on('error', (err) => {
281
+ if (fs.existsSync(partialPath)) try { fs.unlinkSync(partialPath); } catch (e) {}
282
+ reject(err);
283
+ });
284
+ };
285
+ getFollow(url);
286
+ });
287
+ }
288
+
289
+ async install(modelId, onProgress = null) {
290
+ if (!CATALOG[modelId]) {
291
+ const installed = this.listInstalled().map(m => m.modelId);
292
+ throw new ModelDownloadError(modelId, `Model '${modelId}' is not in catalog.`, installed);
293
+ }
294
+ const manifest = CATALOG[modelId];
295
+ const targetDir = path.join(this.modelsDir, modelId);
296
+ fs.mkdirSync(targetDir, { recursive: true });
297
+
298
+ for (const art of manifest.artifacts) {
299
+ const dest = path.join(targetDir, art.filename);
300
+ if (fs.existsSync(dest) && fs.statSync(dest).size > 0) continue;
301
+ await this.downloadFile(art.downloadUrl, dest, onProgress);
302
+ }
303
+
304
+ fs.writeFileSync(path.join(targetDir, 'READY'), 'OK\n', 'utf-8');
305
+ fs.writeFileSync(path.join(targetDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf-8');
306
+ }
307
+
308
+ remove(modelId) {
309
+ const mdir = path.join(this.modelsDir, modelId);
310
+ if (fs.existsSync(mdir)) {
311
+ fs.rmSync(mdir, { recursive: true, force: true });
312
+ return true;
313
+ }
314
+ return false;
315
+ }
316
+ }
317
+
318
+ module.exports = {
319
+ CATALOG,
320
+ ModelCacheManager
321
+ };