offgrid-ai 0.6.5 → 0.6.7
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/README.md +1 -1
- package/install.sh +1 -1
- package/package.json +4 -2
- package/src/autodetect.mjs +7 -8
- package/src/backend-installers.mjs +57 -0
- package/src/backends.mjs +20 -10
- package/src/cli.mjs +12 -1238
- package/src/commands/benchmark.mjs +4 -0
- package/src/commands/main.mjs +89 -0
- package/src/commands/models.mjs +170 -0
- package/src/commands/onboard.mjs +177 -0
- package/src/commands/run.mjs +146 -0
- package/src/commands/status.mjs +38 -0
- package/src/commands/stop.mjs +64 -0
- package/src/commands/uninstall.mjs +96 -0
- package/src/exec.mjs +31 -0
- package/src/managed.mjs +31 -0
- package/src/model-catalog.mjs +58 -0
- package/src/model-presenters.mjs +190 -0
- package/src/model-summary.mjs +61 -0
- package/src/profile-setup.mjs +20 -13
- package/src/profiles.mjs +6 -13
- package/src/recommendations.mjs +17 -0
- package/src/scan.mjs +2 -21
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { ensureDirs } from "../config.mjs";
|
|
2
|
+
import { readProfile, loadProfiles } from "../profiles.mjs";
|
|
3
|
+
import { stopProfile, profileRuntimeStatus } from "../process.mjs";
|
|
4
|
+
import { pc, startInteractive, createPrompt, parseOptions } from "../ui.mjs";
|
|
5
|
+
|
|
6
|
+
export async function stopCommand(argv) {
|
|
7
|
+
await ensureDirs();
|
|
8
|
+
const { positional, options } = parseOptions(argv);
|
|
9
|
+
|
|
10
|
+
if (options.all) return stopAll();
|
|
11
|
+
if (positional[0]) return stopOne(positional[0]);
|
|
12
|
+
|
|
13
|
+
const running = await runningProfiles();
|
|
14
|
+
if (running.length === 0) {
|
|
15
|
+
console.log(pc.dim("No offgrid-ai servers are running."));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!process.stdin.isTTY) {
|
|
20
|
+
for (const { profile, status } of running) console.log(` ${pc.green("●")} ${pc.bold(profile.label)} · pid ${status.pid}`);
|
|
21
|
+
console.log(pc.dim("Stop with: offgrid-ai stop <id>"));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
startInteractive("offgrid-ai stop");
|
|
26
|
+
const prompt = createPrompt();
|
|
27
|
+
try {
|
|
28
|
+
const choices = running.map(({ profile, status }) => ({ value: profile.id, label: profile.label, hint: `pid ${status.pid} · ${profile.baseUrl}` }));
|
|
29
|
+
if (running.length > 1) choices.unshift({ value: "__all", label: "Stop all", hint: `${running.length} servers` });
|
|
30
|
+
choices.push({ value: "__cancel", label: "Cancel" });
|
|
31
|
+
|
|
32
|
+
const selected = await prompt.choice("Stop", choices, choices[0].value);
|
|
33
|
+
if (selected === "__cancel") return;
|
|
34
|
+
|
|
35
|
+
const targets = selected === "__all" ? running : running.filter((item) => item.profile.id === selected);
|
|
36
|
+
for (const { profile } of targets) await printStopResult(profile);
|
|
37
|
+
} finally {
|
|
38
|
+
prompt.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function stopOne(id) {
|
|
43
|
+
await printStopResult(await readProfile(id));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function stopAll() {
|
|
47
|
+
const running = await runningProfiles();
|
|
48
|
+
if (running.length === 0) {
|
|
49
|
+
console.log(pc.dim("No offgrid-ai servers are running."));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
for (const { profile } of running) await printStopResult(profile);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runningProfiles() {
|
|
56
|
+
const profiles = await loadProfiles();
|
|
57
|
+
const statuses = await Promise.all(profiles.map(async (profile) => ({ profile, status: await profileRuntimeStatus(profile) })));
|
|
58
|
+
return statuses.filter((item) => item.status.running);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function printStopResult(profile) {
|
|
62
|
+
const result = await stopProfile(profile);
|
|
63
|
+
console.log(result.stopped ? pc.green(result.message) : pc.yellow(result.message));
|
|
64
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync, rmSync } from "node:fs";
|
|
2
|
+
import { DATA_DIR } from "../config.mjs";
|
|
3
|
+
import { removeInstallerPathEntries } from "../shell-path.mjs";
|
|
4
|
+
import { stopProfile } from "../process.mjs";
|
|
5
|
+
import { runCommand } from "../exec.mjs";
|
|
6
|
+
import { pc, startInteractive, createPrompt, parseOptions } from "../ui.mjs";
|
|
7
|
+
import { runningProfiles } from "./stop.mjs";
|
|
8
|
+
|
|
9
|
+
export async function uninstallCommand(argv) {
|
|
10
|
+
const { options } = parseOptions(argv);
|
|
11
|
+
const force = options.force || options.f;
|
|
12
|
+
|
|
13
|
+
if (!process.stdin.isTTY && !force) throw new Error("Non-interactive uninstall requires --force to avoid accidental data loss.");
|
|
14
|
+
|
|
15
|
+
if (force) {
|
|
16
|
+
await stopTrackedServers();
|
|
17
|
+
await removeDataDir();
|
|
18
|
+
await removeShellPath();
|
|
19
|
+
await removeSelf();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
startInteractive("offgrid-ai uninstall");
|
|
24
|
+
const prompt = createPrompt();
|
|
25
|
+
try {
|
|
26
|
+
console.log(pc.bold("offgrid-ai uninstall\n"));
|
|
27
|
+
const running = await runningProfiles();
|
|
28
|
+
if (running.length > 0) {
|
|
29
|
+
console.log(pc.yellow(`${running.length} server(s) still running. Stopping...`));
|
|
30
|
+
for (const { profile } of running) await stopProfile(profile);
|
|
31
|
+
console.log(pc.green("All servers stopped."));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const mode = await prompt.choice("Choose uninstall type", [
|
|
35
|
+
{ value: "keep-data", label: "Uninstall app only", hint: `keep profiles and settings in ${DATA_DIR}` },
|
|
36
|
+
{ value: "delete-data", label: "Full uninstall", hint: "delete profiles/settings, then uninstall app" },
|
|
37
|
+
{ value: "cancel", label: "Cancel" },
|
|
38
|
+
], "keep-data");
|
|
39
|
+
|
|
40
|
+
if (mode === "cancel") {
|
|
41
|
+
console.log(pc.dim("Cancelled."));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (mode === "delete-data") await removeDataDir();
|
|
46
|
+
else console.log(pc.dim(`Keeping ${DATA_DIR} for when you reinstall.`));
|
|
47
|
+
|
|
48
|
+
await removeShellPath();
|
|
49
|
+
await removeSelf();
|
|
50
|
+
} finally {
|
|
51
|
+
prompt.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function stopTrackedServers() {
|
|
56
|
+
const running = await runningProfiles();
|
|
57
|
+
for (const { profile } of running) {
|
|
58
|
+
const result = await stopProfile(profile);
|
|
59
|
+
console.log(result.stopped ? pc.green(`✓ ${result.message}`) : pc.dim(result.message));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function removeDataDir() {
|
|
64
|
+
if (existsSync(DATA_DIR)) {
|
|
65
|
+
try {
|
|
66
|
+
rmSync(DATA_DIR, { recursive: true, force: true });
|
|
67
|
+
console.log(pc.green(`✓ Removed ${DATA_DIR}`));
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.log(pc.red(`Failed to remove ${DATA_DIR}: ${err.message}`));
|
|
70
|
+
console.log(pc.dim(`Remove it manually: rm -rf ${DATA_DIR}`));
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
console.log(pc.dim(`${DATA_DIR} doesn't exist — already clean.`));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function removeShellPath() {
|
|
78
|
+
const cleaned = await removeInstallerPathEntries();
|
|
79
|
+
if (cleaned.length === 0) {
|
|
80
|
+
console.log(pc.dim("No offgrid-ai PATH entries found in shell configs."));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
for (const rcFile of cleaned) console.log(pc.green(`✓ Cleaned PATH from ${rcFile}`));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function removeSelf() {
|
|
87
|
+
console.log(pc.cyan("\nUninstalling offgrid-ai..."));
|
|
88
|
+
try {
|
|
89
|
+
await runCommand("npm", ["uninstall", "-g", "offgrid-ai"], { label: "npm uninstall", verbose: process.argv.includes("--verbose") });
|
|
90
|
+
console.log(pc.green("\n✓ offgrid-ai has been uninstalled."));
|
|
91
|
+
console.log(pc.dim("Reinstall anytime with: npm install -g offgrid-ai@latest --prefer-online"));
|
|
92
|
+
} catch {
|
|
93
|
+
console.log(pc.red("\n✗ Could not auto-uninstall. Run this manually:"));
|
|
94
|
+
console.log(pc.bold(" npm uninstall -g offgrid-ai"));
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/exec.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
|
|
5
|
+
export const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
export function runCommand(cmd, args, { label = cmd, verbose = false } = {}) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const child = spawn(cmd, args, { stdio: verbose ? "inherit" : ["ignore", "pipe", "pipe"] });
|
|
10
|
+
let stderr = "";
|
|
11
|
+
if (!verbose) child.stderr?.on("data", (data) => { stderr += data; });
|
|
12
|
+
child.on("close", (code) => {
|
|
13
|
+
if (code === 0) resolve();
|
|
14
|
+
else reject(new Error(lastStderrLines(stderr) || `${label} exited with code ${code}`));
|
|
15
|
+
});
|
|
16
|
+
child.on("error", reject);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function commandExists(name) {
|
|
21
|
+
try {
|
|
22
|
+
await execFileAsync("which", [name]);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function lastStderrLines(stderr) {
|
|
30
|
+
return String(stderr).split("\n").filter((line) => line.trim()).slice(-3).join("\n");
|
|
31
|
+
}
|
package/src/managed.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { BACKENDS } from "./backends.mjs";
|
|
3
|
+
import { commandExists } from "./exec.mjs";
|
|
4
|
+
|
|
5
|
+
export const MANAGED_BACKEND_IDS = ["ollama", "omlx"];
|
|
6
|
+
|
|
7
|
+
export async function scanManagedModels() {
|
|
8
|
+
const results = [];
|
|
9
|
+
for (const backendId of MANAGED_BACKEND_IDS) {
|
|
10
|
+
const backend = BACKENDS[backendId];
|
|
11
|
+
try {
|
|
12
|
+
const models = await backend.scanModels();
|
|
13
|
+
results.push({ backendId, models });
|
|
14
|
+
} catch {
|
|
15
|
+
// Managed backends are optional and may not be running.
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return results;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function hasLmStudioInstalled() {
|
|
22
|
+
return existsSync("/Applications/LM Studio.app");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function hasOllamaInstalled() {
|
|
26
|
+
return commandExists("ollama");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function hasOmlxInstalled() {
|
|
30
|
+
return commandExists("omlx");
|
|
31
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { scanGgufModels, matchDrafter } from "./scan.mjs";
|
|
2
|
+
import { loadProfiles, normalizeProfile, sanitizeProfileId } from "./profiles.mjs";
|
|
3
|
+
import { scanManagedModels } from "./managed.mjs";
|
|
4
|
+
import { isProfileFileMissing } from "./model-summary.mjs";
|
|
5
|
+
|
|
6
|
+
export async function loadModelCatalog() {
|
|
7
|
+
const [profiles, { models: ggufModels, drafters }, managedModels] = await Promise.all([
|
|
8
|
+
loadProfiles(),
|
|
9
|
+
scanGgufModels(),
|
|
10
|
+
scanManagedModels(),
|
|
11
|
+
]);
|
|
12
|
+
return normalizeCatalog({ profiles, ggufModels, drafters, managedModels });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function normalizeCatalog(catalog) {
|
|
16
|
+
if (catalog.newModels && catalog.managedItems) return catalog;
|
|
17
|
+
const { profiles, ggufModels, drafters, managedModels } = catalog;
|
|
18
|
+
const profiledPaths = new Set(profiles.map((profile) => profile.modelPath).filter(Boolean));
|
|
19
|
+
const newModels = ggufModels.filter((model) => !profiledPaths.has(model.path));
|
|
20
|
+
const managedItems = [];
|
|
21
|
+
for (const { backendId, models } of managedModels) {
|
|
22
|
+
const profiledAliases = new Set(
|
|
23
|
+
profiles
|
|
24
|
+
.filter((profile) => profile.backend === backendId)
|
|
25
|
+
.map((profile) => backendId === "ollama" ? `ollama:${profile.ollamaModel ?? profile.modelAlias}` : `omlx:${profile.omlxModel ?? profile.modelAlias}`),
|
|
26
|
+
);
|
|
27
|
+
for (const model of models) {
|
|
28
|
+
if (!profiledAliases.has(`${backendId}:${model.id}`)) managedItems.push({ model, backendId });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { profiles, ggufModels, drafters, managedModels, newModels, managedItems };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function itemKey(item) {
|
|
35
|
+
if (item.type === "profile") return `profile:${item.profile.id}`;
|
|
36
|
+
if (item.type === "new") return `new:${item.model.path}`;
|
|
37
|
+
return `managed:${item.backendId}:${item.model.id}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildCatalogItems(normalized) {
|
|
41
|
+
const { profiles, newModels, managedItems, drafters } = normalized;
|
|
42
|
+
return [
|
|
43
|
+
...profiles.map((profile) => ({ type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) })),
|
|
44
|
+
...newModels.map((model) => ({ type: "new", model, label: model.label, drafter: matchDrafter(model.path, drafters) })),
|
|
45
|
+
...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label })),
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createManagedProfile(model, backendId) {
|
|
50
|
+
return normalizeProfile({
|
|
51
|
+
id: `${backendId}-${sanitizeProfileId(model.id)}`,
|
|
52
|
+
label: model.label,
|
|
53
|
+
backend: backendId,
|
|
54
|
+
modelAlias: model.aliasSuggestion,
|
|
55
|
+
...(backendId === "ollama" ? { ollamaModel: model.id } : {}),
|
|
56
|
+
...(backendId === "omlx" ? { omlxModel: model.id } : {}),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { BACKENDS, backendFor } from "./backends.mjs";
|
|
3
|
+
import { readCommandArgv } from "./profiles.mjs";
|
|
4
|
+
import { isProfileRunning } from "./process.mjs";
|
|
5
|
+
import { detectCapabilities } from "./autodetect.mjs";
|
|
6
|
+
import { buildPrettyCommand } from "./command.mjs";
|
|
7
|
+
import { pc, formatBytes, renderRows, renderSection, renderCard } from "./ui.mjs";
|
|
8
|
+
import { capabilitySummary, ggufDetailParts, ggufMtpLabel, isProfileFileMissing, profileDetailParts, profileMtpLabel } from "./model-summary.mjs";
|
|
9
|
+
import { itemKey } from "./model-catalog.mjs";
|
|
10
|
+
|
|
11
|
+
const OPTION_SEPARATOR = pc.dim(" │ ");
|
|
12
|
+
const OPTION_STATUS_WIDTH = 12;
|
|
13
|
+
const OPTION_SOURCE_WIDTH = 14;
|
|
14
|
+
|
|
15
|
+
function optionTag(text, color, width) {
|
|
16
|
+
const padded = String(text).padEnd(width);
|
|
17
|
+
return color ? color(padded) : padded;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function optionStatusTag(kind) {
|
|
21
|
+
const statuses = {
|
|
22
|
+
running: ["RUNNING", pc.green],
|
|
23
|
+
ready: ["READY", pc.green],
|
|
24
|
+
missing: ["FILE MISSING", pc.red],
|
|
25
|
+
setup: ["NEEDS SETUP", pc.yellow],
|
|
26
|
+
};
|
|
27
|
+
const [text, color] = statuses[kind] ?? [kind, pc.dim];
|
|
28
|
+
return optionTag(text, color, OPTION_STATUS_WIDTH);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function optionSourceTag(sourceId, label) {
|
|
32
|
+
const colors = {
|
|
33
|
+
"llama-cpp": pc.cyan,
|
|
34
|
+
"llama-cpp-mtp": pc.blue,
|
|
35
|
+
ollama: pc.green,
|
|
36
|
+
omlx: pc.magenta,
|
|
37
|
+
gguf: pc.cyan,
|
|
38
|
+
};
|
|
39
|
+
return optionTag(label, colors[sourceId] ?? pc.dim, OPTION_SOURCE_WIDTH);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function optionLabel({ status, source, name, details = [] }) {
|
|
43
|
+
return [status, source, pc.bold(name), ...details].filter(Boolean).join(OPTION_SEPARATOR);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function modelSelectOption(item, { runningProfilesNow }) {
|
|
47
|
+
if (item.type === "profile") {
|
|
48
|
+
const backend = backendFor(item.profile.backend);
|
|
49
|
+
const running = runningProfilesNow.some((profile) => profile.id === item.profile.id);
|
|
50
|
+
return {
|
|
51
|
+
value: itemKey(item),
|
|
52
|
+
label: optionLabel({
|
|
53
|
+
status: optionStatusTag(item.fileMissing ? "missing" : running ? "running" : "ready"),
|
|
54
|
+
source: optionSourceTag(item.profile.backend, backend.label),
|
|
55
|
+
name: item.profile.label,
|
|
56
|
+
}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (item.type === "new") {
|
|
60
|
+
return {
|
|
61
|
+
value: itemKey(item),
|
|
62
|
+
label: optionLabel({ status: optionStatusTag("setup"), source: optionSourceTag("gguf", "GGUF file"), name: item.model.label }),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const backend = BACKENDS[item.backendId];
|
|
66
|
+
return {
|
|
67
|
+
value: itemKey(item),
|
|
68
|
+
label: optionLabel({ status: optionStatusTag("setup"), source: optionSourceTag(item.backendId, backend.label), name: item.model.label }),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function printModelCards(items, { runningProfilesNow, drafters }) {
|
|
73
|
+
const profiles = items.filter((item) => item.type === "profile");
|
|
74
|
+
const newModels = items.filter((item) => item.type === "new");
|
|
75
|
+
const managed = items.filter((item) => item.type === "managed");
|
|
76
|
+
|
|
77
|
+
if (profiles.length > 0) {
|
|
78
|
+
console.log("\n" + pc.bold("Ready to chat"));
|
|
79
|
+
for (const item of profiles) {
|
|
80
|
+
const { profile } = item;
|
|
81
|
+
const backend = backendFor(profile.backend);
|
|
82
|
+
const running = runningProfilesNow.some((runningProfile) => runningProfile.id === profile.id);
|
|
83
|
+
const status = item.fileMissing ? pc.red("File missing") : running ? pc.green("Running now") : "Ready";
|
|
84
|
+
const border = item.fileMissing ? pc.red : running ? pc.green : pc.dim;
|
|
85
|
+
const detailParts = [item.fileMissing ? pc.red("File not found") : profileDetailParts(profile, { drafters }).filter(Boolean)[0]];
|
|
86
|
+
const mtpLabel = profileMtpLabel(profile, drafters);
|
|
87
|
+
if (mtpLabel) detailParts.push(mtpLabel);
|
|
88
|
+
const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx` : null;
|
|
89
|
+
if (ctxLabel) detailParts.push(ctxLabel);
|
|
90
|
+
console.log(renderCard(profile.label, renderRows([
|
|
91
|
+
["Status", status],
|
|
92
|
+
["Details", detailParts.join(pc.dim(" · "))],
|
|
93
|
+
["Runs with", backend.label],
|
|
94
|
+
]), { formatBorder: border }));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (newModels.length > 0) {
|
|
99
|
+
console.log("\n" + pc.bold("Needs setup"));
|
|
100
|
+
for (const item of newModels) {
|
|
101
|
+
const caps = detectCapabilities(item.model.path, item.model.mmprojPath);
|
|
102
|
+
const detailParts = [capabilitySummary(caps)];
|
|
103
|
+
const mtpLabel = ggufMtpLabel(item.model, item.drafter);
|
|
104
|
+
if (mtpLabel) detailParts.push(mtpLabel);
|
|
105
|
+
detailParts.push(formatBytes(item.model.sizeBytes));
|
|
106
|
+
console.log(renderCard(item.model.label, renderRows([
|
|
107
|
+
["Status", pc.yellow("Needs setup")],
|
|
108
|
+
["Details", detailParts.join(pc.dim(" · "))],
|
|
109
|
+
]), { formatBorder: pc.yellow }));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const backendId of ["ollama", "omlx"]) {
|
|
114
|
+
const managedForBackend = managed.filter((item) => item.backendId === backendId);
|
|
115
|
+
if (managedForBackend.length === 0) continue;
|
|
116
|
+
const backend = BACKENDS[backendId];
|
|
117
|
+
console.log("\n" + pc.bold(`Via ${backend.label}`));
|
|
118
|
+
for (const item of managedForBackend) {
|
|
119
|
+
console.log(renderCard(item.model.label, renderRows([
|
|
120
|
+
["Details", [item.model.id, item.model.quant].filter(Boolean).join(pc.dim(" · "))],
|
|
121
|
+
]), { formatBorder: pc.dim }));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function printProfileDetails(profile) {
|
|
127
|
+
const backend = backendFor(profile.backend);
|
|
128
|
+
const isManaged = backend.type === "managed-server";
|
|
129
|
+
const running = await isProfileRunning(profile);
|
|
130
|
+
const fileMissing = !isManaged && isProfileFileMissing(profile);
|
|
131
|
+
console.log("\n" + renderSection("Model overview", renderRows([
|
|
132
|
+
["Name", pc.bold(profile.label)],
|
|
133
|
+
["Status", fileMissing ? pc.red("File missing") : running ? pc.green("Running now") : "Ready"],
|
|
134
|
+
["Details", profileDetailParts(profile, { fileMissing }).join(pc.dim(" · "))],
|
|
135
|
+
["Server", fileMissing ? pc.red(profile.baseUrl) : profile.baseUrl],
|
|
136
|
+
])));
|
|
137
|
+
|
|
138
|
+
const detailRows = [
|
|
139
|
+
["Setup ID", profile.id],
|
|
140
|
+
["Runs with", backend.label],
|
|
141
|
+
["Model alias", profile.modelAlias],
|
|
142
|
+
...(profile.capabilities ? [["Detected", capabilitySummary(profile.capabilities)]] : []),
|
|
143
|
+
];
|
|
144
|
+
if (!isManaged) {
|
|
145
|
+
detailRows.push(
|
|
146
|
+
["Local file", fileMissing ? pc.red(`${profile.modelPath} (not found)`) : profile.modelPath ?? "unknown"],
|
|
147
|
+
["Vision file", profile.mmprojPath ? (existsSync(profile.mmprojPath) ? profile.mmprojPath : pc.red(`${profile.mmprojPath} (not found)`)) : "none"],
|
|
148
|
+
["Model size", profile.modelPath && existsSync(profile.modelPath) ? formatBytes(statSync(profile.modelPath).size) : "unknown"],
|
|
149
|
+
);
|
|
150
|
+
if (profile.drafterPath) {
|
|
151
|
+
detailRows.push(["Drafter", existsSync(profile.drafterPath) ? profile.drafterPath : pc.red(`${profile.drafterPath} (not found)`)]);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
console.log("\n" + renderSection("Model details", renderRows(detailRows), { columns: 110 }));
|
|
155
|
+
|
|
156
|
+
if (fileMissing) console.log("\n" + pc.red("⚠ This model's file is no longer on disk. Remove this setup or move the file back."));
|
|
157
|
+
|
|
158
|
+
if (!isManaged && profile.commandArgv) {
|
|
159
|
+
const commandArgv = await readCommandArgv(profile);
|
|
160
|
+
console.log("\n" + renderSection("llama-server command", pc.dim(buildPrettyCommand({ ...profile, commandArgv })), { columns: 120 }));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function printGgufModelDetails(model, drafter) {
|
|
165
|
+
const { caps, parts } = ggufDetailParts(model, drafter);
|
|
166
|
+
parts.push(formatBytes(model.sizeBytes));
|
|
167
|
+
console.log("\n" + renderSection("Downloaded model", renderRows([
|
|
168
|
+
["Name", pc.bold(model.label)],
|
|
169
|
+
["Status", pc.yellow("Needs one-time setup")],
|
|
170
|
+
["Details", parts.join(pc.dim(" · "))],
|
|
171
|
+
])));
|
|
172
|
+
const detailRows = [
|
|
173
|
+
["Local file", model.path],
|
|
174
|
+
["Vision file", model.mmprojPath ?? "none"],
|
|
175
|
+
["Detected", capabilitySummary(caps)],
|
|
176
|
+
["Quant", model.quant ?? "unknown"],
|
|
177
|
+
];
|
|
178
|
+
if (drafter) detailRows.push(["Drafter", drafter.path], ["Drafter size", formatBytes(drafter.sizeBytes)]);
|
|
179
|
+
console.log("\n" + renderSection("Model details", renderRows(detailRows), { columns: 110 }));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function printManagedModelDetails(model, backend) {
|
|
183
|
+
console.log("\n" + renderSection(`${backend.label} model`, renderRows([
|
|
184
|
+
["Name", pc.bold(model.label)],
|
|
185
|
+
["Status", pc.green(`Local model via ${backend.label}`)],
|
|
186
|
+
["Model ID", pc.cyan(model.id)],
|
|
187
|
+
["Quant", model.quant ?? "unknown"],
|
|
188
|
+
["Family", model.family ?? "unknown"],
|
|
189
|
+
])));
|
|
190
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { backendFor } from "./backends.mjs";
|
|
4
|
+
import { matchDrafter } from "./scan.mjs";
|
|
5
|
+
import { detectCapabilities } from "./autodetect.mjs";
|
|
6
|
+
import { pc, humanCapabilitySummary } from "./ui.mjs";
|
|
7
|
+
|
|
8
|
+
export function isProfileFileMissing(profile) {
|
|
9
|
+
const backend = backendFor(profile.backend);
|
|
10
|
+
if (backend.type === "managed-server") return false;
|
|
11
|
+
if (!profile.modelPath) return true;
|
|
12
|
+
return !existsSync(profile.modelPath);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function capabilitySummary(caps) {
|
|
16
|
+
const parts = [];
|
|
17
|
+
if (caps.architecture) parts.push(caps.architecture);
|
|
18
|
+
if (caps.quant) parts.push(caps.quant);
|
|
19
|
+
if (caps.mtp) parts.push("MTP");
|
|
20
|
+
if (caps.qat) parts.push("QAT");
|
|
21
|
+
if (caps.thinking) parts.push("thinking");
|
|
22
|
+
if (caps.vision) parts.push("vision");
|
|
23
|
+
return parts.length > 0 ? parts.join(" · ") : "standard GGUF";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function profileMtpLabel(profile, drafters, { detailed = false } = {}) {
|
|
27
|
+
if (profile.drafterPath) {
|
|
28
|
+
return detailed ? pc.green(`MTP enabled (drafter: ${basename(profile.drafterPath)})`) : pc.green("MTP enabled");
|
|
29
|
+
}
|
|
30
|
+
if (drafters && profile.modelPath && matchDrafter(profile.modelPath, drafters)) return pc.yellow("MTP available");
|
|
31
|
+
if (profile.capabilities?.architecture === "gemma4") {
|
|
32
|
+
return detailed ? pc.yellow("MTP available — download a drafter model to enable 2× speedup") : pc.yellow("MTP: needs drafter");
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function ggufMtpLabel(model, drafter) {
|
|
38
|
+
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
39
|
+
if (caps.mtp || Boolean(drafter)) return pc.green("MTP ✓");
|
|
40
|
+
if (caps.architecture === "gemma4") return pc.yellow("MTP: needs drafter");
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function profileDetailParts(profile, { fileMissing = false, drafters = null } = {}) {
|
|
45
|
+
const parts = [fileMissing ? pc.red("File not found") : humanCapabilitySummary(profile.capabilities ?? {})];
|
|
46
|
+
const mtpLabel = profileMtpLabel(profile, drafters, { detailed: true });
|
|
47
|
+
if (mtpLabel) parts.push(mtpLabel);
|
|
48
|
+
const ctxLabel = profile.flags?.ctxSize ? `${(profile.flags.ctxSize / 1000).toFixed(0)}k ctx` : null;
|
|
49
|
+
if (ctxLabel) parts.push(ctxLabel);
|
|
50
|
+
return parts;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ggufDetailParts(model, drafter) {
|
|
54
|
+
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
55
|
+
const parts = [humanCapabilitySummary(caps)];
|
|
56
|
+
const mtpLabel = ggufMtpLabel(model, drafter);
|
|
57
|
+
if (mtpLabel) parts.push(mtpLabel);
|
|
58
|
+
const ctxLabel = caps.ctxSize ? `${(caps.ctxSize / 1000).toFixed(0)}k ctx` : null;
|
|
59
|
+
if (ctxLabel) parts.push(ctxLabel);
|
|
60
|
+
return { caps, parts };
|
|
61
|
+
}
|
package/src/profile-setup.mjs
CHANGED
|
@@ -34,16 +34,14 @@ export async function configureLocalProfile(prompt, profile) {
|
|
|
34
34
|
let configured = profile;
|
|
35
35
|
// Re-detect capabilities from the model file and check for drafters
|
|
36
36
|
// so that re-setup can pick up MTP availability, vision changes, etc.
|
|
37
|
-
const
|
|
38
|
-
const freshCaps = { ...detectCapabilities(profile.modelPath, profile.mmprojPath), ...hfSource };
|
|
37
|
+
const freshCaps = detectCapabilities(profile.modelPath, profile.mmprojPath);
|
|
39
38
|
let drafterPath = profile.drafterPath ?? null;
|
|
40
|
-
if (!drafterPath
|
|
39
|
+
if (!drafterPath) {
|
|
41
40
|
const { drafters } = await scanGgufModels();
|
|
42
41
|
const drafter = matchDrafter(profile.modelPath, drafters);
|
|
43
42
|
if (drafter) drafterPath = drafter.path;
|
|
44
43
|
}
|
|
45
|
-
const
|
|
46
|
-
const hasMtp = freshCaps.mtp || Boolean(drafterPath) || hfGemma4Mtp;
|
|
44
|
+
const hasMtp = freshCaps.mtp || Boolean(drafterPath);
|
|
47
45
|
const caps = { ...freshCaps, mtp: hasMtp };
|
|
48
46
|
// If MTP is newly available, switch backend and add drafter path
|
|
49
47
|
if (hasMtp && configured.backend !== "llama-cpp-mtp") {
|
|
@@ -73,7 +71,7 @@ export async function configureLocalProfile(prompt, profile) {
|
|
|
73
71
|
console.log(renderSection("MTP detected", renderRows([
|
|
74
72
|
["Backend", "llama.cpp MTP"],
|
|
75
73
|
["Port", String(LLAMA_CPP_MTP_PORT)],
|
|
76
|
-
["Flags", `--spec-type draft-mtp --spec-draft-n-max 4${configured.drafterPath
|
|
74
|
+
["Flags", `--spec-type draft-mtp --spec-draft-n-max 4${configured.drafterPath ? " --spec-draft-model <drafter>" : ""}`],
|
|
77
75
|
])));
|
|
78
76
|
if (drafterInfo) console.log(pc.dim(drafterInfo));
|
|
79
77
|
const useMtp = await prompt.yesNo("Use MTP speculative decoding?", true);
|
|
@@ -139,20 +137,29 @@ export function applyRuntimeFlagOverrides(profile, overrides) {
|
|
|
139
137
|
return applyProfileFlags(profile, flags);
|
|
140
138
|
}
|
|
141
139
|
|
|
142
|
-
function applyMtpDefaults(profile) {
|
|
140
|
+
export function applyMtpDefaults(profile) {
|
|
143
141
|
const flags = { ...profile.flags, port: LLAMA_CPP_MTP_PORT };
|
|
144
142
|
const edits = {
|
|
145
143
|
values: { "--spec-type": "draft-mtp", "--spec-draft-n-max": 4 },
|
|
146
144
|
};
|
|
147
|
-
if (profile.drafterPath
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
145
|
+
if (profile.drafterPath) edits.values["--spec-draft-model"] = profile.drafterPath;
|
|
146
|
+
return applyProfileFlags({
|
|
147
|
+
...profile,
|
|
148
|
+
backend: "llama-cpp-mtp",
|
|
149
|
+
providerId: "llama-cpp-mtp",
|
|
150
|
+
capabilities: { ...(profile.capabilities ?? {}), mtp: true },
|
|
151
|
+
}, flags, edits);
|
|
151
152
|
}
|
|
152
153
|
|
|
153
|
-
function removeMtpDefaults(profile) {
|
|
154
|
+
export function removeMtpDefaults(profile) {
|
|
154
155
|
const flags = { ...profile.flags, port: LLAMA_CPP_PORT };
|
|
155
|
-
return applyProfileFlags({
|
|
156
|
+
return applyProfileFlags({
|
|
157
|
+
...profile,
|
|
158
|
+
backend: "llama-cpp",
|
|
159
|
+
providerId: "llama-cpp",
|
|
160
|
+
drafterPath: null,
|
|
161
|
+
capabilities: { ...(profile.capabilities ?? {}), mtp: false },
|
|
162
|
+
}, flags, {
|
|
156
163
|
remove: ["--spec-type", "--spec-draft-n-max", "--spec-draft-model"],
|
|
157
164
|
});
|
|
158
165
|
}
|
package/src/profiles.mjs
CHANGED
|
@@ -134,15 +134,11 @@ export function normalizeProfile(profile) {
|
|
|
134
134
|
export async function createProfileFromModel(model, backendId, drafterPath) {
|
|
135
135
|
const { detectCapabilities } = await import("./autodetect.mjs");
|
|
136
136
|
const caps = detectCapabilities(model.path, model.mmprojPath);
|
|
137
|
-
// If a drafter is provided, this model supports MTP regardless of filename
|
|
138
|
-
|
|
139
|
-
// discovery so root mtp-*.gguf drafters do not need offgrid-ai file matching.
|
|
140
|
-
const hfGemma4Mtp = Boolean(model.hfRepo && /gemma-?4/i.test(`${model.hfRepo} ${model.label}`));
|
|
141
|
-
const hasMtp = caps.mtp || Boolean(drafterPath) || hfGemma4Mtp;
|
|
137
|
+
// If a drafter is provided, this model supports MTP regardless of filename
|
|
138
|
+
const hasMtp = caps.mtp || Boolean(drafterPath);
|
|
142
139
|
const backend = backendId ?? (hasMtp ? "llama-cpp-mtp" : "llama-cpp");
|
|
143
|
-
const hfSource = model.hfRepo ? { hfRepo: model.hfRepo, hfVariant: model.hfVariant } : {};
|
|
144
140
|
const { flags, argv } = computeFlags(
|
|
145
|
-
{ ...caps, mtp: hasMtp
|
|
141
|
+
{ ...caps, mtp: hasMtp },
|
|
146
142
|
model.path,
|
|
147
143
|
model.mmprojPath,
|
|
148
144
|
drafterPath ?? null,
|
|
@@ -154,12 +150,11 @@ export async function createProfileFromModel(model, backendId, drafterPath) {
|
|
|
154
150
|
backend,
|
|
155
151
|
providerId: backend,
|
|
156
152
|
modelAlias: model.aliasSuggestion,
|
|
157
|
-
source: model.
|
|
153
|
+
source: model.source,
|
|
158
154
|
modelPath: model.path,
|
|
159
|
-
mmprojPath: model.
|
|
160
|
-
...hfSource,
|
|
155
|
+
mmprojPath: model.mmprojPath,
|
|
161
156
|
drafterPath: drafterPath ?? null,
|
|
162
|
-
capabilities: summarizeCapabilities({ ...caps, mtp: hasMtp
|
|
157
|
+
capabilities: summarizeCapabilities({ ...caps, mtp: hasMtp }),
|
|
163
158
|
preset: null, // no presets — auto-detected
|
|
164
159
|
flags,
|
|
165
160
|
commandArgv: argv,
|
|
@@ -177,8 +172,6 @@ function summarizeCapabilities(caps) {
|
|
|
177
172
|
quant: caps.quant,
|
|
178
173
|
metaCtx: caps.metaCtx,
|
|
179
174
|
mmprojProjectorType: caps.mmprojProjectorType,
|
|
180
|
-
hfRepo: caps.hfRepo,
|
|
181
|
-
hfVariant: caps.hfVariant,
|
|
182
175
|
ctxSize: caps.ctxSize,
|
|
183
176
|
};
|
|
184
177
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { totalmem } from "node:os";
|
|
2
|
+
|
|
3
|
+
const MODEL_TIERS = [
|
|
4
|
+
{ maxGB: 8, lms: "google/gemma-4-e2b", ollama: "gemma4:e2b", label: "Gemma 4 E2B (2B effective)" },
|
|
5
|
+
{ maxGB: 16, lms: "google/gemma-4-e4b", ollama: "gemma4:e4b", label: "Gemma 4 E4B (4B effective)" },
|
|
6
|
+
{ maxGB: 32, lms: "qwen/qwen3.5-9b", ollama: "qwen3.5:9b-q4_K_M", label: "Qwen 3.5 9B" },
|
|
7
|
+
{ maxGB: Infinity, lms: "qwen/qwen3.6-35b-a3b", ollama: "qwen3.6:35b-a3b", label: "Qwen 3.6 35B-A3B" },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
export function recommendedModel() {
|
|
11
|
+
const gb = totalmem() / (1024 ** 3);
|
|
12
|
+
return MODEL_TIERS.find((tier) => gb <= tier.maxGB) ?? MODEL_TIERS[MODEL_TIERS.length - 1];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function installedRamGB() {
|
|
16
|
+
return (totalmem() / (1024 ** 3)).toFixed(0);
|
|
17
|
+
}
|