offgrid-ai 0.4.7 → 0.5.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/src/cli.mjs +126 -182
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -79,7 +79,7 @@ export async function mainFlow() {
|
|
|
79
79
|
|
|
80
80
|
// 1. Check what backends are available
|
|
81
81
|
const llamaBinary = await findLlamaServer();
|
|
82
|
-
const { models: ggufModels } = await scanGgufModels();
|
|
82
|
+
const { models: ggufModels, drafters } = await scanGgufModels();
|
|
83
83
|
const managedModels = await scanManagedModels();
|
|
84
84
|
const profiles = await loadProfiles();
|
|
85
85
|
const hasAnyBackend = llamaBinary || managedModels.some((m) => m.models.length > 0);
|
|
@@ -153,7 +153,7 @@ export async function mainFlow() {
|
|
|
153
153
|
|
|
154
154
|
// 6. Interactive: one command center after onboarding.
|
|
155
155
|
startInteractive("offgrid-ai");
|
|
156
|
-
return await modelCommandCenter({ profiles, ggufModels, managedModels });
|
|
156
|
+
return await modelCommandCenter({ profiles, ggufModels, managedModels, drafters });
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
// ── Model command center ────────────────────────────────────────────────────
|
|
@@ -174,25 +174,53 @@ async function modelsCommand(argv) {
|
|
|
174
174
|
|
|
175
175
|
async function modelCommandCenter(catalog) {
|
|
176
176
|
const normalized = normalizeCatalog(catalog);
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
177
|
+
const { profiles } = normalized;
|
|
178
|
+
const runningProfilesNow = [];
|
|
179
|
+
for (const profile of profiles) {
|
|
180
|
+
if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
|
|
181
|
+
}
|
|
182
|
+
const fileMissingCount = profiles.filter((p) => isProfileFileMissing(p)).length;
|
|
183
|
+
|
|
184
|
+
// Summary card
|
|
185
|
+
const summaryBorder = fileMissingCount > 0 ? pc.red : normalized.newModels.length > 0 ? pc.yellow : pc.dim;
|
|
186
|
+
console.log("\n" + renderCard("Your local AI workspace", renderRows([
|
|
187
|
+
["Setups", `${profiles.length} saved`],
|
|
188
|
+
["Need setup", normalized.newModels.length > 0 ? pc.yellow(`${normalized.newModels.length} model${normalized.newModels.length === 1 ? "" : "s"}`) : pc.dim("none")],
|
|
189
|
+
["Running", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
|
|
190
|
+
["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
|
|
191
|
+
]), { formatBorder: summaryBorder }));
|
|
180
192
|
|
|
181
|
-
if (
|
|
193
|
+
if (!process.stdin.isTTY) return;
|
|
194
|
+
const allItems = buildCatalogItems(normalized);
|
|
195
|
+
if (allItems.length === 0) return;
|
|
182
196
|
|
|
183
197
|
const prompt = createPrompt();
|
|
184
198
|
try {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
199
|
+
while (true) {
|
|
200
|
+
// Build model select options
|
|
201
|
+
const options = allItems.map((item) => modelSelectOption(item, { runningProfilesNow, drafters: normalized.drafters }));
|
|
202
|
+
const selected = await prompt.choice("Select a model", options);
|
|
203
|
+
if (!selected) break;
|
|
204
|
+
const item = allItems.find((i) => itemKey(i) === selected);
|
|
205
|
+
if (!item) break;
|
|
206
|
+
|
|
207
|
+
// Build contextual actions
|
|
208
|
+
const actions = actionsForItem(item);
|
|
209
|
+
if (actions.length === 1) {
|
|
210
|
+
// Only one action — just do it
|
|
211
|
+
await performAction(prompt, actions[0].value, item);
|
|
212
|
+
} else {
|
|
213
|
+
const action = await prompt.choice(item.label, actions, actions[0].value);
|
|
214
|
+
if (!action) continue;
|
|
215
|
+
if (action === "back") continue;
|
|
216
|
+
await performAction(prompt, action, item);
|
|
217
|
+
}
|
|
218
|
+
// After action, refresh running state and loop back
|
|
219
|
+
runningProfilesNow.length = 0;
|
|
220
|
+
for (const profile of profiles) {
|
|
221
|
+
if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
196
224
|
} finally {
|
|
197
225
|
prompt.close();
|
|
198
226
|
}
|
|
@@ -232,167 +260,107 @@ function normalizeCatalog(catalog) {
|
|
|
232
260
|
return { profiles, ggufModels, drafters, managedModels, newModels, managedItems };
|
|
233
261
|
}
|
|
234
262
|
|
|
235
|
-
|
|
236
|
-
const itemNumber = (predicate) => {
|
|
237
|
-
const index = items.findIndex(predicate);
|
|
238
|
-
return index === -1 ? " " : String(index + 1).padStart(2, " ");
|
|
239
|
-
};
|
|
240
|
-
const runningProfilesNow = [];
|
|
241
|
-
for (const profile of profiles) {
|
|
242
|
-
if (await isProfileRunning(profile)) runningProfilesNow.push(profile);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const fileMissingCount = profiles.filter((p) => isProfileFileMissing(p)).length;
|
|
246
|
-
|
|
247
|
-
const summaryBorder = fileMissingCount > 0 ? pc.red : newModels.length > 0 ? pc.yellow : pc.dim;
|
|
248
|
-
console.log("\n" + renderCard("Your local AI workspace", renderRows([
|
|
249
|
-
["Setups", `${profiles.length} saved`],
|
|
250
|
-
["Need setup", newModels.length > 0 ? pc.yellow(`${newModels.length} model${newModels.length === 1 ? "" : "s"}`) : pc.dim("none")],
|
|
251
|
-
["Running", runningProfilesNow.length > 0 ? pc.green(String(runningProfilesNow.length)) : pc.dim("none")],
|
|
252
|
-
["File missing", fileMissingCount > 0 ? pc.red(`${fileMissingCount} setup${fileMissingCount === 1 ? "" : "s"}`) : pc.dim("none")],
|
|
253
|
-
["Next step", fileMissingCount > 0 ? pc.red("Remove or fix missing setups") : profiles.length > 0 ? "Start chatting" : newModels.length > 0 ? pc.yellow("Set up a downloaded model") : "Download a model"],
|
|
254
|
-
]), { formatBorder: summaryBorder }));
|
|
255
|
-
|
|
256
|
-
console.log("\n" + pc.bold("Ready to chat"));
|
|
257
|
-
if (profiles.length === 0) {
|
|
258
|
-
console.log(renderCard("No saved setups yet", "Downloaded models will appear below. Set one up once, then it will be ready from here.", { formatBorder: pc.yellow }));
|
|
259
|
-
} else {
|
|
260
|
-
for (const profile of profiles) {
|
|
261
|
-
const running = runningProfilesNow.some((item) => item.id === profile.id);
|
|
262
|
-
const fileMissing = isProfileFileMissing(profile);
|
|
263
|
-
const num = itemNumber((item) => item.type === "profile" && item.profile.id === profile.id);
|
|
264
|
-
console.log(profileCatalogCard(num, profile, { running, fileMissing, drafters }));
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
console.log("\n" + pc.bold("Needs one-time setup"));
|
|
269
|
-
if (newModels.length === 0) {
|
|
270
|
-
console.log(renderCard("All set", "Every downloaded local model already has a saved setup.", { formatBorder: pc.dim }));
|
|
271
|
-
} else {
|
|
272
|
-
for (const model of newModels.slice(0, 20)) {
|
|
273
|
-
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
274
|
-
const drafter = matchDrafter(model.path, drafters);
|
|
275
|
-
const num = itemNumber((item) => item.type === "new" && item.model.path === model.path);
|
|
276
|
-
console.log(downloadedModelCard(num, model, caps, { mtpAvailable: caps.mtp || Boolean(drafter), drafter }));
|
|
277
|
-
}
|
|
278
|
-
if (newModels.length > 20) console.log(pc.dim(` ... and ${newModels.length - 20} more`));
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
for (const backendId of ["ollama", "omlx"]) {
|
|
282
|
-
const backendItems = managedItems.filter((item) => item.backendId === backendId);
|
|
283
|
-
if (backendItems.length === 0) continue;
|
|
284
|
-
const be = BACKENDS[backendId];
|
|
285
|
-
console.log("\n" + pc.bold(`Local models via ${be.label}`));
|
|
286
|
-
for (const { model } of backendItems.slice(0, 10)) {
|
|
287
|
-
const num = itemNumber((item) => item.type === "managed" && item.backendId === backendId && item.model.id === model.id);
|
|
288
|
-
console.log(managedModelCard(num, model, be));
|
|
289
|
-
}
|
|
290
|
-
if (backendItems.length > 10) console.log(pc.dim(` ... and ${backendItems.length - 10} more`));
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function isProfileFileMissing(profile) {
|
|
295
|
-
const backend = backendFor(profile.backend);
|
|
296
|
-
if (backend.type === "managed-server") return false;
|
|
297
|
-
if (!profile.modelPath) return true; // no path recorded means we can't verify
|
|
298
|
-
return !existsSync(profile.modelPath);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function profileCatalogCard(num, profile, { running, fileMissing, drafters }) {
|
|
302
|
-
const backend = backendFor(profile.backend);
|
|
303
|
-
const caps = profile.capabilities ?? {};
|
|
304
|
-
let status;
|
|
305
|
-
if (fileMissing) {
|
|
306
|
-
status = pc.red("File missing");
|
|
307
|
-
} else if (running) {
|
|
308
|
-
status = pc.green("Running now");
|
|
309
|
-
} else {
|
|
310
|
-
status = "Ready";
|
|
311
|
-
}
|
|
312
|
-
const border = fileMissing ? pc.red : running ? pc.green : pc.dim;
|
|
313
|
-
const mtpDrafter = profile.drafterPath
|
|
314
|
-
? pc.green("MTP")
|
|
315
|
-
: (drafters ? matchDrafter(profile.modelPath, drafters) : null)
|
|
316
|
-
? pc.yellow("MTP available")
|
|
317
|
-
: (caps.architecture === "gemma4")
|
|
318
|
-
? pc.yellow("MTP: needs drafter")
|
|
319
|
-
: null;
|
|
320
|
-
const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx` : null;
|
|
321
|
-
const capLabel = fileMissing ? pc.red("File not found") : humanCapabilitySummary(caps);
|
|
322
|
-
const detailParts = [capLabel];
|
|
323
|
-
if (mtpDrafter) detailParts.push(mtpDrafter);
|
|
324
|
-
if (ctxLabel) detailParts.push(ctxLabel);
|
|
325
|
-
const detailLine = detailParts.join(pc.dim(" · "));
|
|
326
|
-
return renderCard(`${num}. ${profile.label}`, renderRows([
|
|
327
|
-
["Status", status],
|
|
328
|
-
["Details", detailLine],
|
|
329
|
-
["Runs with", backend.label],
|
|
330
|
-
]), { formatBorder: border });
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function downloadedModelCard(num, model, caps, { mtpAvailable } = {}) {
|
|
334
|
-
const mtpLabel = mtpAvailable
|
|
335
|
-
? pc.green("MTP ✓")
|
|
336
|
-
: (caps.architecture === "gemma4")
|
|
337
|
-
? pc.yellow("MTP: needs drafter")
|
|
338
|
-
: null;
|
|
339
|
-
const detailParts = [humanCapabilitySummary(caps)];
|
|
340
|
-
if (mtpLabel) detailParts.push(mtpLabel);
|
|
341
|
-
detailParts.push(formatBytes(model.sizeBytes));
|
|
342
|
-
return renderCard(`${num}. ${model.label}`, renderRows([
|
|
343
|
-
["Status", pc.yellow("Needs setup")],
|
|
344
|
-
["Details", detailParts.join(pc.dim(" · "))],
|
|
345
|
-
]), { formatBorder: pc.yellow });
|
|
346
|
-
}
|
|
263
|
+
// ── Catalog item helpers ───────────────────────────────────────────────────
|
|
347
264
|
|
|
348
|
-
function
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
]), { formatBorder: pc.dim });
|
|
265
|
+
function itemKey(item) {
|
|
266
|
+
if (item.type === "profile") return `profile:${item.profile.id}`;
|
|
267
|
+
if (item.type === "new") return `new:${item.model.path}`;
|
|
268
|
+
return `managed:${item.backendId}:${item.model.id}`;
|
|
353
269
|
}
|
|
354
270
|
|
|
355
|
-
function
|
|
271
|
+
function buildCatalogItems(normalized) {
|
|
272
|
+
const { profiles, newModels, managedItems, drafters } = normalized;
|
|
356
273
|
return [
|
|
357
|
-
...profiles.map((profile) => ({ type: "profile", profile, label: profile.label,
|
|
274
|
+
...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) })),
|
|
358
275
|
...newModels.map((model) => {
|
|
359
276
|
const drafter = matchDrafter(model.path, drafters);
|
|
360
|
-
return { type: "new", model, label: model.label,
|
|
277
|
+
return { type: "new", model, label: model.label, drafter };
|
|
361
278
|
}),
|
|
362
|
-
...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label
|
|
279
|
+
...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label })),
|
|
363
280
|
];
|
|
364
281
|
}
|
|
365
282
|
|
|
366
|
-
|
|
367
|
-
if (
|
|
368
|
-
|
|
369
|
-
|
|
283
|
+
function modelSelectOption(item, { runningProfilesNow }) {
|
|
284
|
+
if (item.type === "profile") {
|
|
285
|
+
const { profile } = item;
|
|
286
|
+
const running = runningProfilesNow.some((r) => r.id === profile.id);
|
|
287
|
+
const fileMissing = item.fileMissing;
|
|
288
|
+
const caps = profile.capabilities ?? {};
|
|
289
|
+
let status;
|
|
290
|
+
if (fileMissing) status = pc.red("File missing");
|
|
291
|
+
else if (running) status = pc.green("Running");
|
|
292
|
+
else status = pc.dim("Ready");
|
|
293
|
+
const detailParts = [status];
|
|
294
|
+
if (humanCapabilitySummary(caps)) detailParts.push(humanCapabilitySummary(caps));
|
|
295
|
+
if (profile.drafterPath) detailParts.push(pc.green("MTP"));
|
|
296
|
+
if (profile.flags?.ctxSize) detailParts.push(`${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx`);
|
|
297
|
+
return { value: itemKey(item), label: profile.label, hint: detailParts.join(pc.dim(" · ")) };
|
|
370
298
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
299
|
+
if (item.type === "new") {
|
|
300
|
+
const { model, drafter } = item;
|
|
301
|
+
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
302
|
+
const detailParts = [pc.yellow("Needs setup")];
|
|
303
|
+
if (humanCapabilitySummary(caps)) detailParts.push(humanCapabilitySummary(caps));
|
|
304
|
+
if (caps.mtp || drafter) detailParts.push(pc.green("MTP ✓"));
|
|
305
|
+
else if (caps.architecture === "gemma4") detailParts.push(pc.yellow("MTP: needs drafter"));
|
|
306
|
+
detailParts.push(formatBytes(model.sizeBytes));
|
|
307
|
+
return { value: itemKey(item), label: model.label, hint: detailParts.join(pc.dim(" · ")) };
|
|
378
308
|
}
|
|
309
|
+
// managed
|
|
310
|
+
const { model, backendId } = item;
|
|
311
|
+
const backend = BACKENDS[backendId];
|
|
312
|
+
return { value: itemKey(item), label: model.label, hint: `${backend.label} · ${model.id}` };
|
|
313
|
+
}
|
|
379
314
|
|
|
380
|
-
|
|
381
|
-
if (
|
|
382
|
-
|
|
383
|
-
|
|
315
|
+
function actionsForItem(item) {
|
|
316
|
+
if (item.type === "profile") {
|
|
317
|
+
const actions = [
|
|
318
|
+
{ value: "run", label: "Start chatting", hint: "Launch and open Pi" },
|
|
319
|
+
{ value: "reconfigure", label: "Reconfigure", hint: "Change context, MTP, settings" },
|
|
320
|
+
{ value: "inspect", label: "Details", hint: "Paths, ports, flags" },
|
|
321
|
+
];
|
|
322
|
+
if (!item.fileMissing) {
|
|
323
|
+
actions.push({ value: "remove", label: "Remove", hint: "Delete this setup" });
|
|
324
|
+
}
|
|
325
|
+
return actions;
|
|
384
326
|
}
|
|
385
|
-
|
|
327
|
+
if (item.type === "new") {
|
|
328
|
+
return [
|
|
329
|
+
{ value: "setup", label: "Set up", hint: "Configure and save" },
|
|
330
|
+
{ value: "inspect", label: "Details", hint: "Model info" },
|
|
331
|
+
];
|
|
332
|
+
}
|
|
333
|
+
// managed
|
|
334
|
+
return [
|
|
335
|
+
{ value: "setup", label: "Set up", hint: `Connect via ${BACKENDS[item.backendId].label}` },
|
|
336
|
+
{ value: "inspect", label: "Details", hint: "Model info" },
|
|
337
|
+
];
|
|
386
338
|
}
|
|
387
339
|
|
|
388
|
-
|
|
340
|
+
function isProfileFileMissing(profile) {
|
|
341
|
+
const backend = backendFor(profile.backend);
|
|
342
|
+
if (backend.type === "managed-server") return false;
|
|
343
|
+
if (!profile.modelPath) return true;
|
|
344
|
+
return !existsSync(profile.modelPath);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function performAction(prompt, action, item) {
|
|
389
348
|
if (action === "inspect") {
|
|
390
349
|
if (item.type === "profile") return await printProfileDetails(await readProfile(item.profile.id));
|
|
391
350
|
if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
|
|
392
351
|
return printGgufModelDetails(item.model, item.drafter);
|
|
393
352
|
}
|
|
394
|
-
|
|
395
|
-
|
|
353
|
+
if (action === "run") {
|
|
354
|
+
if (item.type === "profile") return await runProfile(await readProfile(item.profile.id));
|
|
355
|
+
// Shouldn't reach here for new/managed, but handle gracefully
|
|
356
|
+
const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
|
|
357
|
+
const configured = await configureLocalProfile(prompt, profile);
|
|
358
|
+
if (!configured) return;
|
|
359
|
+
await saveProfile(configured);
|
|
360
|
+
await syncPiConfig(configured);
|
|
361
|
+
return await runProfile(configured);
|
|
362
|
+
}
|
|
363
|
+
if (action === "reconfigure" || action === "setup") {
|
|
396
364
|
if (item.type === "profile") {
|
|
397
365
|
const profile = await readProfile(item.profile.id);
|
|
398
366
|
const configured = await configureLocalProfile(prompt, profile);
|
|
@@ -411,26 +379,10 @@ async function handleCatalogAction(prompt, action, item) {
|
|
|
411
379
|
await saveProfile(configured);
|
|
412
380
|
return await syncPiConfig(configured);
|
|
413
381
|
}
|
|
414
|
-
|
|
415
|
-
if (action === "run") {
|
|
416
|
-
if (item.type === "profile") return await runProfile(await readProfile(item.profile.id));
|
|
417
|
-
if (item.type === "managed") {
|
|
418
|
-
const profile = createManagedProfile(item.model, item.backendId);
|
|
419
|
-
await saveProfile(profile);
|
|
420
|
-
await syncPiConfig(profile);
|
|
421
|
-
return await runProfile(profile);
|
|
422
|
-
}
|
|
423
|
-
const profile = await createProfileFromModel(item.model, null, item.drafter?.path);
|
|
424
|
-
const configured = await configureLocalProfile(prompt, profile);
|
|
425
|
-
if (!configured) return;
|
|
426
|
-
await saveProfile(configured);
|
|
427
|
-
await syncPiConfig(configured);
|
|
428
|
-
return await runProfile(configured);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
382
|
if (action === "remove" && item.type === "profile") return await removeProfileInteractive(item.profile.id);
|
|
432
383
|
}
|
|
433
384
|
|
|
385
|
+
|
|
434
386
|
async function printProfileDetails(profile) {
|
|
435
387
|
const backend = backendFor(profile.backend);
|
|
436
388
|
const isManaged = backend.type === "managed-server";
|
|
@@ -694,14 +646,6 @@ async function removeProfileInteractive(id) {
|
|
|
694
646
|
|
|
695
647
|
// ── Benchmark (stub) ────────────────────────────────────────────────────────
|
|
696
648
|
|
|
697
|
-
async function benchmarkFlow() {
|
|
698
|
-
console.log("\n" + renderCard("Benchmark", renderRows([
|
|
699
|
-
["Status", pc.yellow("Coming soon")],
|
|
700
|
-
["What it will do", "Compare local models with repeatable prompts"],
|
|
701
|
-
["For now", "Start a model with offgrid-ai, then run benchmarks manually"],
|
|
702
|
-
]), { formatBorder: pc.yellow }));
|
|
703
|
-
}
|
|
704
|
-
|
|
705
649
|
// ── Status ──────────────────────────────────────────────────────────────────
|
|
706
650
|
|
|
707
651
|
async function statusCommand() {
|