offgrid-ai 0.17.0 → 0.18.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/package.json +1 -1
- package/resources/recommendations.json +8 -8
- package/src/commands/main.mjs +1 -4
- package/src/commands/models.mjs +199 -15
- package/src/commands/onboard.mjs +6 -106
- package/src/commands/run.mjs +1 -0
- package/src/commands/status.mjs +1 -0
- package/src/commands/stop.mjs +1 -0
- package/src/discovery-shared.mjs +2 -3
- package/src/download.mjs +221 -0
- package/src/harness-pi.mjs +2 -3
- package/src/huggingface.mjs +72 -72
- package/src/managed.mjs +1 -6
- package/src/model-presenters.mjs +1 -23
- package/src/model-summary.mjs +2 -2
- package/src/omlx-runtime.mjs +29 -4
- package/src/process.mjs +3 -5
- package/src/profiles.mjs +1 -1
- package/src/runtime.mjs +2 -2
- package/src/ui.mjs +2 -0
- package/resources/hf-download.py +0 -79
- package/src/backend-installers.mjs +0 -42
package/package.json
CHANGED
|
@@ -2,56 +2,56 @@
|
|
|
2
2
|
"models": [
|
|
3
3
|
{
|
|
4
4
|
"id": "gemma-4-e2b",
|
|
5
|
-
"label": "Gemma 4 E2B",
|
|
5
|
+
"label": "Gemma 4 E2B (Q4_K_S)",
|
|
6
6
|
"minRamGb": 8,
|
|
7
7
|
"gguf": "unsloth/gemma-4-E2B-it-GGUF/gemma-4-E2B-it-Q4_K_S.gguf",
|
|
8
8
|
"mlx": "mlx-community/gemma-4-e2b-it-4bit"
|
|
9
9
|
},
|
|
10
10
|
{
|
|
11
11
|
"id": "qwen-3.5-9b",
|
|
12
|
-
"label": "Qwen 3.5 9B",
|
|
12
|
+
"label": "Qwen 3.5 9B (Q4_K_S)",
|
|
13
13
|
"minRamGb": 16,
|
|
14
14
|
"gguf": "unsloth/Qwen3.5-9B-GGUF/Qwen3.5-9B-UD-Q4_K_S.gguf",
|
|
15
15
|
"mlx": "lmstudio-community/Qwen3.5-9B-MLX-4bit"
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
"id": "gemma-4-12b-qat",
|
|
19
|
-
"label": "Gemma 4 12B",
|
|
19
|
+
"label": "Gemma 4 12B (Q4_K_XL)",
|
|
20
20
|
"minRamGb": 24,
|
|
21
21
|
"gguf": "unsloth/gemma-4-12B-it-qat-GGUF/gemma-4-12B-it-qat-UD-Q4_K_XL.gguf",
|
|
22
22
|
"mlx": "mlx-community/gemma-4-12B-it-qat-4bit"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"id": "gemma-4-26b",
|
|
26
|
-
"label": "Gemma 4 26B",
|
|
26
|
+
"label": "Gemma 4 26B (Q4_K_XL)",
|
|
27
27
|
"minRamGb": 32,
|
|
28
28
|
"gguf": "unsloth/gemma-4-26B-A4B-it-qat-GGUF/gemma-4-26B-A4B-it-qat-UD-Q4_K_XL.gguf",
|
|
29
29
|
"mlx": "mlx-community/gemma-4-26b-a4b-4bit"
|
|
30
30
|
},
|
|
31
31
|
{
|
|
32
32
|
"id": "qwen-3.6-35b-compact",
|
|
33
|
-
"label": "Qwen 3.6 35B",
|
|
33
|
+
"label": "Qwen 3.6 35B (Q4_K_S)",
|
|
34
34
|
"minRamGb": 32,
|
|
35
35
|
"gguf": "unsloth/Qwen3.6-35B-A3B-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_S.gguf",
|
|
36
36
|
"mlx": "mlx-community/Qwen3.6-35B-A3B-4bit"
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
"id": "qwen-3.6-35b",
|
|
40
|
-
"label": "Qwen 3.6 35B",
|
|
40
|
+
"label": "Qwen 3.6 35B (Q4_K_M)",
|
|
41
41
|
"minRamGb": 48,
|
|
42
42
|
"gguf": "unsloth/Qwen3.6-35B-A3B-GGUF/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf",
|
|
43
43
|
"mlx": ""
|
|
44
44
|
},
|
|
45
45
|
{
|
|
46
46
|
"id": "gemma-4-31b",
|
|
47
|
-
"label": "Gemma 4 31B",
|
|
47
|
+
"label": "Gemma 4 31B (Q4_K_XL)",
|
|
48
48
|
"minRamGb": 64,
|
|
49
49
|
"gguf": "unsloth/gemma-4-31B-it-qat-GGUF/gemma-4-31B-it-qat-UD-Q4_K_XL.gguf",
|
|
50
50
|
"mlx": "mlx-community/gemma-4-31b-4bit"
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
53
|
"id": "qwen-3.6-27b",
|
|
54
|
-
"label": "Qwen 3.6 27B",
|
|
54
|
+
"label": "Qwen 3.6 27B (Q4_K_M)",
|
|
55
55
|
"minRamGb": 64,
|
|
56
56
|
"gguf": "unsloth/Qwen3.6-27B-MTP-GGUF/Qwen3.6-27B-Q4_K_M.gguf",
|
|
57
57
|
"mlx": "mlx-community/Qwen3.6-27B-4bit"
|
package/src/commands/main.mjs
CHANGED
|
@@ -59,10 +59,7 @@ export async function mainFlow() {
|
|
|
59
59
|
|
|
60
60
|
startInteractive("offgrid-ai");
|
|
61
61
|
printStatusHeader({ llamaBinary, managedModels, piInstalled, omlxInstalled: await hasOmlx(), profiles });
|
|
62
|
-
console.log(pc.dim("
|
|
63
|
-
console.log(pc.dim(" LM Studio Open LM Studio app, browse and download"));
|
|
64
|
-
console.log(pc.dim(" oMLX Open oMLX app, browse and download"));
|
|
65
|
-
console.log(pc.dim(" HuggingFace hf download mlx-community/gemma-4-e2b-it-4bit"));
|
|
62
|
+
console.log(pc.dim(" No models? Pick \"↓ Download a model\" below — offgrid-ai downloads from HuggingFace"));
|
|
66
63
|
console.log("");
|
|
67
64
|
return await modelCommandCenter({ profiles, ggufModels, managedModels, drafters });
|
|
68
65
|
}
|
package/src/commands/models.mjs
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import { ensureDirs, getModelScanDirs, addModelScanDir, removeModelScanDir, DEFAULT_MODEL_DIRS, findLlamaServer, HF_HUB_DIR } from "../config.mjs";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
+
import { rm, unlink } from "node:fs/promises";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
6
|
+
import { stripVTControlCharacters } from "node:util";
|
|
5
7
|
import { backendFor, BACKENDS } from "../backends.mjs";
|
|
6
8
|
import { createProfileFromModel, readProfile, saveProfile, deleteProfile, profileJsonPath } from "../profiles.mjs";
|
|
7
9
|
import { isProfileRunning, isProfileServerUp, modelAvailableOnServer, stopProfile } from "../process.mjs";
|
|
8
10
|
import { syncPiConfig, removeFromPiConfig, hasPi } from "../harness-pi.mjs";
|
|
9
|
-
import { hasOmlx } from "../omlx-runtime.mjs";
|
|
11
|
+
import { hasOmlx, offerOmlxRestart } from "../omlx-runtime.mjs";
|
|
10
12
|
import { configureLocalProfile, configureManagedProfile } from "../profile-setup.mjs";
|
|
13
|
+
import { findOmlxModelDir } from "../mlx-discovery.mjs";
|
|
11
14
|
import { pc, startInteractive, createPrompt, modelSelect, renderCard, renderRows } from "../ui.mjs";
|
|
12
15
|
import { buildCatalogItems, createManagedProfile, itemKey, loadModelCatalog, normalizeCatalog } from "../model-catalog.mjs";
|
|
13
16
|
import { modelSelectOption, modelNameWidth, inferBackendId, formatSourceLabel, discoverySourceForItem, printGgufModelDetails, printManagedModelDetails, printProfileDetails } from "../model-presenters.mjs";
|
|
14
17
|
import { runProfile } from "./run.mjs";
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
import { downloadFlow } from "../download.mjs";
|
|
19
|
+
import { execFileAsync } from "../exec.mjs";
|
|
17
20
|
|
|
18
21
|
export async function modelsCommand(argv) {
|
|
19
22
|
await ensureDirs();
|
|
@@ -66,6 +69,15 @@ async function showModelPicker(catalog) {
|
|
|
66
69
|
if (!(await modelAvailableOnServer(profile))) modelMissingIds.add(profile.id);
|
|
67
70
|
}
|
|
68
71
|
}
|
|
72
|
+
// Flag all missing profiles (file missing for llama.cpp, model missing
|
|
73
|
+
// for oMLX managed-server) so actionsForItem/performAction can handle both
|
|
74
|
+
// cases uniformly.
|
|
75
|
+
for (const item of allItems) {
|
|
76
|
+
if (item.type === "profile") {
|
|
77
|
+
item.missing = item.fileMissing || modelMissingIds.has(item.profile.id);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
69
81
|
const nameWidth = modelNameWidth(allItems);
|
|
70
82
|
|
|
71
83
|
const statusFor = (item) => {
|
|
@@ -114,7 +126,10 @@ async function showModelPicker(catalog) {
|
|
|
114
126
|
groups.push({ separator: ` ${pc.yellow("Needs setup (" + setupItems.length + ")")}`, items: groupItems });
|
|
115
127
|
}
|
|
116
128
|
|
|
117
|
-
groups.push({ separator: " ", items: [
|
|
129
|
+
groups.push({ separator: " ", items: [
|
|
130
|
+
{ value: "__download__", label: `${pc.dim("○")} ${pc.green("↓ Download a model")}` },
|
|
131
|
+
{ value: "__settings__", label: `${pc.dim("○")} ${pc.cyan("⚙ Status & settings")}` },
|
|
132
|
+
] });
|
|
118
133
|
|
|
119
134
|
const prompt = createPrompt();
|
|
120
135
|
try {
|
|
@@ -123,7 +138,14 @@ async function showModelPicker(catalog) {
|
|
|
123
138
|
|
|
124
139
|
if (selected === "__settings__") {
|
|
125
140
|
await settingsFlow(prompt);
|
|
126
|
-
|
|
141
|
+
console.log("");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (selected === "__download__") {
|
|
146
|
+
await downloadFlow(prompt);
|
|
147
|
+
console.log("");
|
|
148
|
+
return;
|
|
127
149
|
}
|
|
128
150
|
|
|
129
151
|
const item = allItems.find((candidate) => itemKey(candidate) === selected);
|
|
@@ -133,6 +155,7 @@ async function showModelPicker(catalog) {
|
|
|
133
155
|
const action = await prompt.choice(item.label, actions, actions[0].value);
|
|
134
156
|
if (!action) return;
|
|
135
157
|
await performAction(prompt, action, item);
|
|
158
|
+
console.log("");
|
|
136
159
|
} finally {
|
|
137
160
|
prompt.close();
|
|
138
161
|
}
|
|
@@ -144,13 +167,13 @@ function formatActions(rawActions) {
|
|
|
144
167
|
const width = Math.max(17, maxName + 2);
|
|
145
168
|
return rawActions.map((a) => {
|
|
146
169
|
const name = a.dimmed ? pc.dim(pc.strikethrough(a.name.padEnd(width).slice(0, width))) : pc.bold(a.name.padEnd(width).slice(0, width));
|
|
147
|
-
const desc = a.dimmed ? pc.red("
|
|
170
|
+
const desc = a.dimmed ? pc.red("not available") : pc.dim(a.desc);
|
|
148
171
|
return { value: a.value, label: name + sep + desc };
|
|
149
172
|
});
|
|
150
173
|
}
|
|
151
174
|
|
|
152
175
|
function actionsForItem(item) {
|
|
153
|
-
const missing = item.type === "profile" && item.
|
|
176
|
+
const missing = item.type === "profile" && item.missing;
|
|
154
177
|
if (item.type === "profile") {
|
|
155
178
|
const available = [
|
|
156
179
|
{ value: "inspect", name: "Details", desc: "Paths, ports, flags" },
|
|
@@ -161,7 +184,8 @@ function actionsForItem(item) {
|
|
|
161
184
|
{ value: "reconfigure", name: "Reconfigure", desc: "Change context, MTP, settings" },
|
|
162
185
|
);
|
|
163
186
|
}
|
|
164
|
-
available.push({ value: "
|
|
187
|
+
available.push({ value: "remove_config", name: "Remove configuration", desc: "Delete this setup, keep model files" });
|
|
188
|
+
available.push({ value: "delete_model", name: "Delete model", desc: "Permanently remove from disk" });
|
|
165
189
|
if (missing) {
|
|
166
190
|
available.unshift(
|
|
167
191
|
{ value: "run", name: "Start chatting", desc: "Launch and open Pi", dimmed: true },
|
|
@@ -174,18 +198,22 @@ function actionsForItem(item) {
|
|
|
174
198
|
return formatActions([
|
|
175
199
|
{ value: "setup", name: "Set up", desc: "Configure and save" },
|
|
176
200
|
{ value: "inspect", name: "Details", desc: "Model info" },
|
|
201
|
+
{ value: "delete_model", name: "Delete model", desc: "Permanently remove from disk" },
|
|
177
202
|
]);
|
|
178
203
|
}
|
|
179
204
|
return formatActions([
|
|
180
205
|
{ value: "setup", name: "Set up", desc: `Connect via ${BACKENDS[item.backendId].label}` },
|
|
181
206
|
{ value: "inspect", name: "Details", desc: "Model info" },
|
|
207
|
+
{ value: "delete_model", name: "Delete model", desc: "Permanently remove from disk" },
|
|
182
208
|
]);
|
|
183
209
|
}
|
|
184
210
|
|
|
185
211
|
async function performAction(prompt, action, item) {
|
|
186
|
-
const missing = item.type === "profile" && item.
|
|
212
|
+
const missing = item.type === "profile" && item.missing;
|
|
187
213
|
if (missing && ["run", "reconfigure"].includes(action)) {
|
|
188
|
-
|
|
214
|
+
const backend = item.type === "profile" ? backendFor(item.profile.backend) : null;
|
|
215
|
+
const reason = backend?.type === "managed-server" ? "model is no longer available on the server" : "model file is no longer on disk";
|
|
216
|
+
console.log(pc.red(`This model's ${reason}. Remove the setup or restore the model.`));
|
|
189
217
|
return;
|
|
190
218
|
}
|
|
191
219
|
if (action === "inspect") {
|
|
@@ -195,7 +223,8 @@ async function performAction(prompt, action, item) {
|
|
|
195
223
|
}
|
|
196
224
|
if (action === "run") return await runItem(item);
|
|
197
225
|
if (action === "reconfigure" || action === "setup") return await setupItem(prompt, item);
|
|
198
|
-
if (action === "
|
|
226
|
+
if (action === "remove_config" && item.type === "profile") return await removeProfileInteractive(item.profile.id);
|
|
227
|
+
if (action === "delete_model") return await deleteModelFromSource(prompt, item);
|
|
199
228
|
}
|
|
200
229
|
|
|
201
230
|
async function runItem(item) {
|
|
@@ -257,6 +286,161 @@ async function removeProfileInteractive(id) {
|
|
|
257
286
|
console.log(pc.green(`Removed ${profile.label} (${profile.id})`));
|
|
258
287
|
}
|
|
259
288
|
|
|
289
|
+
// ── Delete model from source ───────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
/** Extract HuggingFace repo ID from a cache path. */
|
|
292
|
+
function hfRepoFromPath(path) {
|
|
293
|
+
const hubPart = path.slice(HF_HUB_DIR.length);
|
|
294
|
+
const match = hubPart.match(/models--(.+?)(?=\/|$)/);
|
|
295
|
+
if (!match) return null;
|
|
296
|
+
return match[1].replace(/--/g, "/");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Determine where a model's files live on disk. */
|
|
300
|
+
async function modelLocationForItem(item) {
|
|
301
|
+
if (item.type === "profile") {
|
|
302
|
+
const backend = backendFor(item.profile.backend);
|
|
303
|
+
if (backend.type === "managed-server") {
|
|
304
|
+
const modelId = item.profile.omlxModel || item.profile.modelAlias || item.profile.id;
|
|
305
|
+
// oMLX model IDs may not include the org prefix, so search recursively
|
|
306
|
+
const dir = await findOmlxModelDir(modelId);
|
|
307
|
+
return { kind: "mlx", dir: dir ?? join(homedir(), ".omlx", "models", ...modelId.replace(/--/g, "/").split("/").filter(Boolean)), modelId };
|
|
308
|
+
}
|
|
309
|
+
const modelPath = item.profile.modelPath;
|
|
310
|
+
if (!modelPath) return { kind: "unknown" };
|
|
311
|
+
if (modelPath.startsWith(HF_HUB_DIR)) {
|
|
312
|
+
return { kind: "hf-cache", path: modelPath, repoId: hfRepoFromPath(modelPath) };
|
|
313
|
+
}
|
|
314
|
+
return { kind: "file", path: modelPath };
|
|
315
|
+
}
|
|
316
|
+
if (item.type === "new") {
|
|
317
|
+
const modelPath = item.model?.path;
|
|
318
|
+
if (!modelPath) return { kind: "unknown" };
|
|
319
|
+
if (modelPath.startsWith(HF_HUB_DIR)) {
|
|
320
|
+
return { kind: "hf-cache", path: modelPath, repoId: hfRepoFromPath(modelPath) };
|
|
321
|
+
}
|
|
322
|
+
return { kind: "file", path: modelPath };
|
|
323
|
+
}
|
|
324
|
+
if (item.type === "managed") {
|
|
325
|
+
const modelId = item.model?.id;
|
|
326
|
+
if (!modelId) return { kind: "unknown" };
|
|
327
|
+
// oMLX model IDs may not include the org prefix, so search recursively
|
|
328
|
+
const dir = await findOmlxModelDir(modelId);
|
|
329
|
+
return { kind: "mlx", dir: dir ?? join(homedir(), ".omlx", "models", ...modelId.replace(/--/g, "/").split("/").filter(Boolean)), modelId };
|
|
330
|
+
}
|
|
331
|
+
return { kind: "unknown" };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function deleteModelFromSource(prompt, item) {
|
|
335
|
+
const loc = await modelLocationForItem(item);
|
|
336
|
+
|
|
337
|
+
if (loc.kind === "unknown") {
|
|
338
|
+
console.log(pc.yellow("Could not determine where this model's files are located."));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Show what will be deleted
|
|
343
|
+
let locationLabel;
|
|
344
|
+
if (loc.kind === "hf-cache") {
|
|
345
|
+
locationLabel = loc.path ?? loc.repoId;
|
|
346
|
+
} else if (loc.kind === "mlx") {
|
|
347
|
+
locationLabel = loc.dir;
|
|
348
|
+
} else if (loc.kind === "file") {
|
|
349
|
+
locationLabel = loc.path;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
console.log(pc.yellow("\nThis will permanently delete " + (item.type === "profile" ? "the configuration and the model from:" : "the model from:")));
|
|
353
|
+
console.log(pc.dim(` ${locationLabel}`));
|
|
354
|
+
|
|
355
|
+
const confirmed = await prompt.yesNo("Delete this model?", false);
|
|
356
|
+
if (!confirmed) {
|
|
357
|
+
console.log(pc.dim("Cancelled."));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Stop running server if needed
|
|
362
|
+
if (item.type === "profile" && await isProfileRunning(item.profile)) {
|
|
363
|
+
console.log(pc.dim("Stopping running server..."));
|
|
364
|
+
await stopProfile(item.profile);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Delete files
|
|
368
|
+
if (loc.kind === "hf-cache" && loc.repoId) {
|
|
369
|
+
const cacheDir = join(HF_HUB_DIR, `models--${loc.repoId.replace(/\//g, "--")}`);
|
|
370
|
+
try {
|
|
371
|
+
const { stdout } = await execFileAsync("hf", ["cache", "rm", `model/${loc.repoId}`, "--yes"], { timeout: 30000 });
|
|
372
|
+
if (stdout.trim()) console.log(pc.dim(stdout.trim()));
|
|
373
|
+
// Verify the directory is actually gone
|
|
374
|
+
if (existsSync(cacheDir)) {
|
|
375
|
+
console.log(pc.red(`✗ Model still exists at ${cacheDir}`));
|
|
376
|
+
console.log(pc.dim(`Delete manually: hf cache rm model/${loc.repoId}`));
|
|
377
|
+
} else {
|
|
378
|
+
console.log(pc.green(`✓ Deleted ${loc.repoId} from HuggingFace cache`));
|
|
379
|
+
}
|
|
380
|
+
} catch (err) {
|
|
381
|
+
const detail = err.stderr?.trim() || err.message;
|
|
382
|
+
console.log(pc.red(`✗ Failed: ${detail}`));
|
|
383
|
+
console.log(pc.dim(`Delete manually: hf cache rm model/${loc.repoId}`));
|
|
384
|
+
}
|
|
385
|
+
} else if (loc.kind === "mlx") {
|
|
386
|
+
const omlxModelsRoot = join(homedir(), ".omlx", "models");
|
|
387
|
+
// Safety guard: never delete outside ~/.omlx/models/
|
|
388
|
+
if (!loc.dir.startsWith(omlxModelsRoot + "/") && loc.dir !== omlxModelsRoot) {
|
|
389
|
+
console.log(pc.red(`✗ Refusing to delete: path is outside ~/.omlx/models/`));
|
|
390
|
+
console.log(pc.dim(` Target: ${loc.dir}`));
|
|
391
|
+
console.log(pc.dim(`Delete manually if needed: rm -rf ${loc.dir}`));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (!existsSync(loc.dir)) {
|
|
395
|
+
console.log(pc.yellow(`Directory not found: ${loc.dir}`));
|
|
396
|
+
console.log(pc.dim("Model files may have already been removed, or oMLX loaded them from a different location."));
|
|
397
|
+
} else {
|
|
398
|
+
try {
|
|
399
|
+
await rm(loc.dir, { recursive: true, force: true });
|
|
400
|
+
} catch (err) {
|
|
401
|
+
console.log(pc.red(`✗ Failed: ${err.message}`));
|
|
402
|
+
console.log(pc.dim(`Delete manually: rm -rf ${loc.dir}`));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
// Verify deletion
|
|
406
|
+
if (existsSync(loc.dir)) {
|
|
407
|
+
console.log(pc.red(`✗ Directory still exists: ${loc.dir}`));
|
|
408
|
+
console.log(pc.dim(`Delete manually: rm -rf ${loc.dir}`));
|
|
409
|
+
} else {
|
|
410
|
+
console.log(pc.green(`✓ Deleted ${loc.dir}`));
|
|
411
|
+
await offerOmlxRestart(prompt, "to update its model list");
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
} else if (loc.kind === "file") {
|
|
415
|
+
if (!existsSync(loc.path)) {
|
|
416
|
+
console.log(pc.yellow(`File not found: ${loc.path}`));
|
|
417
|
+
console.log(pc.dim("Model file may have already been removed."));
|
|
418
|
+
} else {
|
|
419
|
+
try {
|
|
420
|
+
await unlink(loc.path);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
console.log(pc.red(`✗ Failed: ${err.message}`));
|
|
423
|
+
console.log(pc.dim(`Delete manually: rm ${loc.path}`));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
// Verify deletion
|
|
427
|
+
if (existsSync(loc.path)) {
|
|
428
|
+
console.log(pc.red(`✗ File still exists: ${loc.path}`));
|
|
429
|
+
console.log(pc.dim(`Delete manually: rm ${loc.path}`));
|
|
430
|
+
} else {
|
|
431
|
+
console.log(pc.green(`✓ Deleted ${loc.path}`));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Remove profile configuration if one exists
|
|
437
|
+
if (item.type === "profile") {
|
|
438
|
+
await removeFromPiConfig(item.profile);
|
|
439
|
+
await deleteProfile(item.profile.id);
|
|
440
|
+
console.log(pc.dim(`Removed configuration: ${item.profile.id}`));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
260
444
|
// ── Settings & discovery path management ───────────────────────────────────
|
|
261
445
|
|
|
262
446
|
async function settingsFlow(prompt) {
|
|
@@ -268,7 +452,7 @@ async function settingsFlow(prompt) {
|
|
|
268
452
|
let omlxServerUp = false;
|
|
269
453
|
if (omlxInstalled) {
|
|
270
454
|
try {
|
|
271
|
-
const res = await fetch(
|
|
455
|
+
const res = await fetch(`${BACKENDS.omlx.defaultBaseUrl}/models`, { signal: AbortSignal.timeout(2000) });
|
|
272
456
|
omlxServerUp = res.ok;
|
|
273
457
|
} catch { /* server down */ }
|
|
274
458
|
}
|
|
@@ -302,11 +486,11 @@ async function settingsFlow(prompt) {
|
|
|
302
486
|
const choices = [
|
|
303
487
|
{ value: "add", label: "Add discovery path" },
|
|
304
488
|
...(customDirs.length > 0 ? [{ value: "remove", label: "Remove discovery path" }] : []),
|
|
305
|
-
{ value: "
|
|
489
|
+
{ value: "done", label: "Done" },
|
|
306
490
|
];
|
|
307
|
-
const action = await prompt.choice("Settings", choices, "
|
|
491
|
+
const action = await prompt.choice("Settings", choices, "done");
|
|
308
492
|
|
|
309
|
-
if (!action || action === "
|
|
493
|
+
if (!action || action === "done") return;
|
|
310
494
|
|
|
311
495
|
if (action === "add") {
|
|
312
496
|
const dir = await prompt.text("Path to model directory", "");
|
package/src/commands/onboard.mjs
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
import { ensureDirs, findLlamaServer
|
|
1
|
+
import { ensureDirs, findLlamaServer } from "../config.mjs";
|
|
2
2
|
import { BACKENDS } from "../backends.mjs";
|
|
3
3
|
import { scanGgufModels } from "../scan.mjs";
|
|
4
4
|
import { hasPi } from "../harness-pi.mjs";
|
|
5
5
|
import { offerManagedLlamaRuntimeUpdate } from "../runtime.mjs";
|
|
6
6
|
import { ensureOmlxRuntime } from "../omlx-runtime.mjs";
|
|
7
7
|
import { scanManagedModels } from "../managed.mjs";
|
|
8
|
-
import {
|
|
9
|
-
import { recommendedModel, selectFormat, allFittingModels } from "../recommendations.mjs";
|
|
10
|
-
import { hasHuggingfaceHub, resolveHfDownload, downloadToHfCache } from "../huggingface.mjs";
|
|
11
|
-
import { detectHardware, getFreeDiskBytes, installedRamGB } from "../hardware.mjs";
|
|
8
|
+
import { downloadFlow } from "../download.mjs";
|
|
12
9
|
import { runCommand } from "../exec.mjs";
|
|
13
|
-
import { pc,
|
|
10
|
+
import { pc, renderRows, renderSection, startInteractive, createPrompt } from "../ui.mjs";
|
|
14
11
|
|
|
15
12
|
export async function onboardFlow() {
|
|
16
13
|
await ensureDirs();
|
|
@@ -37,12 +34,10 @@ export async function onboardFlow() {
|
|
|
37
34
|
if (hasModels) {
|
|
38
35
|
printFoundModels(ggufModels, managedModels, llamaBinary);
|
|
39
36
|
} else {
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
if (downloaded) return;
|
|
37
|
+
const downloaded = await downloadFlow(prompt);
|
|
38
|
+
if (!downloaded) {
|
|
39
|
+
console.log(pc.dim("\nRun offgrid-ai again when you've downloaded a model."));
|
|
44
40
|
}
|
|
45
|
-
await offerBackendInstall(prompt, run);
|
|
46
41
|
return;
|
|
47
42
|
}
|
|
48
43
|
|
|
@@ -109,98 +104,3 @@ function printFoundModels(ggufModels, managedModels, llamaBinary) {
|
|
|
109
104
|
}
|
|
110
105
|
}
|
|
111
106
|
|
|
112
|
-
async function offerModelDownload(prompt) {
|
|
113
|
-
const hardware = detectHardware();
|
|
114
|
-
const candidates = allFittingModels(hardware)
|
|
115
|
-
.map((entry) => ({ entry, format: selectFormat(entry, hardware) }))
|
|
116
|
-
.filter((item) => item.format === "gguf");
|
|
117
|
-
if (candidates.length === 0) {
|
|
118
|
-
console.log(pc.yellow("No curated models fit your hardware."));
|
|
119
|
-
return false;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const primary = candidates[0];
|
|
123
|
-
console.log(renderSection("Download a recommended model", renderRows([
|
|
124
|
-
["Model", pc.bold(primary.entry.label)],
|
|
125
|
-
["Format", primary.format],
|
|
126
|
-
["Minimum RAM", String(primary.entry.minRamGb) + " GB"],
|
|
127
|
-
["Your RAM", installedRamGB() + " GB"],
|
|
128
|
-
]), { formatBorder: pc.cyan }));
|
|
129
|
-
|
|
130
|
-
const shouldDownload = await prompt.yesNo("Download " + primary.entry.label + " (" + primary.format + ")?", true);
|
|
131
|
-
if (!shouldDownload) return false;
|
|
132
|
-
|
|
133
|
-
const hfRef = primary.entry.gguf;
|
|
134
|
-
try {
|
|
135
|
-
const plan = await resolveHfDownload(hfRef);
|
|
136
|
-
console.log(pc.dim("Total size: " + formatBytes(plan.totalSizeBytes)));
|
|
137
|
-
const freeBytes = getFreeDiskBytes(HF_HUB_DIR);
|
|
138
|
-
if (plan.totalSizeBytes > 0 && freeBytes < plan.totalSizeBytes * 1.1) {
|
|
139
|
-
console.log(pc.red(`Not enough disk space in ${HF_HUB_DIR}: need ~${formatBytes(plan.totalSizeBytes)}, only ${formatBytes(freeBytes)} free.`));
|
|
140
|
-
return false;
|
|
141
|
-
}
|
|
142
|
-
await downloadToHfCache(plan, {
|
|
143
|
-
onProgress({ percentage }) {
|
|
144
|
-
process.stdout.write(pc.cyan("\r " + percentage + "% downloaded"));
|
|
145
|
-
},
|
|
146
|
-
});
|
|
147
|
-
process.stdout.write("\n");
|
|
148
|
-
console.log(pc.green("✓ Download complete. Run offgrid-ai to use the model."));
|
|
149
|
-
return true;
|
|
150
|
-
} catch (err) {
|
|
151
|
-
console.log(pc.red("Download failed: " + err.message));
|
|
152
|
-
return false;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
async function offerBackendInstall(prompt, run) {
|
|
157
|
-
console.log(pc.yellow("\nNo models found."));
|
|
158
|
-
console.log(pc.dim("You need at least one model backend to use offgrid-ai.\n"));
|
|
159
|
-
const choice = await prompt.choice("Install a model backend?", BACKEND_INSTALL_CHOICES, "lmstudio");
|
|
160
|
-
const model = recommendedModel();
|
|
161
|
-
|
|
162
|
-
if (choice === "skip") {
|
|
163
|
-
console.log(pc.dim("Run offgrid-ai again when you've set up a model backend."));
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
if (choice === "all") {
|
|
167
|
-
await installAllBackends(prompt, run, model);
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
await installBackend(prompt, run, choice, model);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
async function installBackend(prompt, run, backendId, model) {
|
|
174
|
-
const installer = BACKEND_INSTALLERS[backendId];
|
|
175
|
-
if (!(await ensureHomebrewFor(prompt, run, installer.label))) return;
|
|
176
|
-
console.log(pc.cyan(`Installing ${installer.label} via Homebrew...`));
|
|
177
|
-
try {
|
|
178
|
-
await runInstallerCommands(run, installer);
|
|
179
|
-
installer.success(model);
|
|
180
|
-
} catch {
|
|
181
|
-
console.log(pc.red(`✗ ${installer.label} installation failed.`));
|
|
182
|
-
console.log(pc.dim(installer.failure));
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
async function installAllBackends(prompt, run, model) {
|
|
187
|
-
if (!(await ensureHomebrewFor(prompt, run, "model backends"))) return;
|
|
188
|
-
const installed = [];
|
|
189
|
-
for (const installer of Object.values(BACKEND_INSTALLERS)) {
|
|
190
|
-
console.log(pc.cyan(`Installing ${installer.label} via Homebrew...`));
|
|
191
|
-
try {
|
|
192
|
-
await runInstallerCommands(run, installer);
|
|
193
|
-
installed.push(installer.label);
|
|
194
|
-
} catch {
|
|
195
|
-
console.log(pc.yellow(installer.allFailure));
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
if (installed.length > 0) {
|
|
199
|
-
console.log(pc.green(`\n✓ Installed: ${installed.join(", ")}`));
|
|
200
|
-
console.log(pc.dim(`Recommended for your machine (${installedRamGB()}GB RAM): ${model.label}`));
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
async function runInstallerCommands(run, installer) {
|
|
205
|
-
for (const [cmd, args, label] of installer.commands) await run(cmd, args, label);
|
|
206
|
-
}
|
package/src/commands/run.mjs
CHANGED
|
@@ -46,6 +46,7 @@ export async function runProfile(profile, options = {}) {
|
|
|
46
46
|
|
|
47
47
|
printMemoryEstimate(profile, isManaged);
|
|
48
48
|
await launchHarness(profile, options, isManaged, withHarness, backend);
|
|
49
|
+
console.log("");
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
async function ensureLocalServer(profile, backend, options) {
|
package/src/commands/status.mjs
CHANGED
package/src/commands/stop.mjs
CHANGED
|
@@ -34,6 +34,7 @@ export async function stopCommand(argv) {
|
|
|
34
34
|
|
|
35
35
|
const targets = selected === "__all" ? running : running.filter((item) => item.profile.id === selected);
|
|
36
36
|
for (const { profile } of targets) await printStopResult(profile);
|
|
37
|
+
console.log("");
|
|
37
38
|
} finally {
|
|
38
39
|
prompt.close();
|
|
39
40
|
}
|
package/src/discovery-shared.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// between the two format-specific scanners.
|
|
1
|
+
// Helpers for the GGUF scanner (scan.mjs). Centralizes model-size and embedding-type
|
|
2
|
+
// constants so they're defined once rather than duplicated across scanners.
|
|
4
3
|
|
|
5
4
|
import { basename, dirname } from "node:path";
|
|
6
5
|
|