offgrid-ai 0.8.11 → 0.8.13
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/commands/models.mjs +33 -4
- package/src/model-catalog.mjs +15 -1
- package/src/model-presenters.mjs +15 -7
- package/src/process.mjs +85 -9
- package/src/ui.mjs +1 -1
package/package.json
CHANGED
package/src/commands/models.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ensureDirs } from "../config.mjs";
|
|
2
2
|
import { backendFor, BACKENDS } from "../backends.mjs";
|
|
3
3
|
import { createProfileFromModel, readProfile, saveProfile, deleteProfile, profileJsonPath } from "../profiles.mjs";
|
|
4
|
-
import { isProfileRunning, stopProfile } from "../process.mjs";
|
|
4
|
+
import { isProfileRunning, isProfileServerUp, stopProfile } from "../process.mjs";
|
|
5
5
|
import { syncPiConfig, removeFromPiConfig } from "../harness-pi.mjs";
|
|
6
6
|
import { configureLocalProfile } from "../profile-setup.mjs";
|
|
7
7
|
import { pc, startInteractive, createPrompt } from "../ui.mjs";
|
|
@@ -40,17 +40,46 @@ export async function modelCommandCenter(initialCatalog) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const runningProfilesNow = [];
|
|
43
|
+
const serverUpIds = new Set();
|
|
43
44
|
for (const profile of normalized.profiles) {
|
|
44
|
-
if (await isProfileRunning(profile))
|
|
45
|
+
if (await isProfileRunning(profile)) {
|
|
46
|
+
runningProfilesNow.push(profile);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (backendFor(profile.backend).type === "managed-server" && await isProfileServerUp(profile)) serverUpIds.add(profile.id);
|
|
45
50
|
}
|
|
46
|
-
printWorkspaceHeader(normalized, runningProfilesNow);
|
|
51
|
+
printWorkspaceHeader(normalized, runningProfilesNow, serverUpIds);
|
|
47
52
|
await printBenchmarkLine();
|
|
48
53
|
|
|
49
54
|
const nameWidth = modelNameWidth(allItems);
|
|
50
55
|
|
|
56
|
+
const statusFor = (item) => {
|
|
57
|
+
if (item.type === "profile") {
|
|
58
|
+
if (item.fileMissing) return "missing";
|
|
59
|
+
if (runningProfilesNow.some((profile) => profile.id === item.profile.id)) return "running";
|
|
60
|
+
if (serverUpIds.has(item.profile.id)) return "serverup";
|
|
61
|
+
return "ready";
|
|
62
|
+
}
|
|
63
|
+
return "setup";
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const groupOrder = ["running", "serverup", "ready", "setup", "missing"];
|
|
67
|
+
const grouped = new Map(groupOrder.map((key) => [key, []]));
|
|
68
|
+
for (const item of allItems) grouped.get(statusFor(item)).push(item);
|
|
69
|
+
|
|
70
|
+
const choices = [];
|
|
71
|
+
for (const group of groupOrder) {
|
|
72
|
+
const bucket = grouped.get(group);
|
|
73
|
+
if (!bucket || bucket.length === 0) continue;
|
|
74
|
+
for (const item of bucket) {
|
|
75
|
+
const opt = modelSelectOption(item, { runningProfilesNow, serverUpIds, nameWidth });
|
|
76
|
+
choices.push({ value: opt.value, label: opt.label });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
51
80
|
const prompt = createPrompt();
|
|
52
81
|
try {
|
|
53
|
-
const selected = await prompt.choice("Select a model",
|
|
82
|
+
const selected = await prompt.choice("Select a model", choices);
|
|
54
83
|
if (!selected) return;
|
|
55
84
|
const item = allItems.find((candidate) => itemKey(candidate) === selected);
|
|
56
85
|
if (!item) return;
|
package/src/model-catalog.mjs
CHANGED
|
@@ -37,10 +37,24 @@ export function itemKey(item) {
|
|
|
37
37
|
return `managed:${item.backendId}:${item.model.id}`;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function profileRecency(item) {
|
|
41
|
+
const updated = item.profile?.updatedAt ?? item.profile?.createdAt;
|
|
42
|
+
const ts = updated ? Date.parse(updated) : NaN;
|
|
43
|
+
return Number.isFinite(ts) ? ts : 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function compareRecency(a, b) {
|
|
47
|
+
const diff = profileRecency(b) - profileRecency(a);
|
|
48
|
+
if (diff !== 0) return diff;
|
|
49
|
+
return String(a.label ?? "").localeCompare(String(b.label ?? ""));
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
export function buildCatalogItems(normalized) {
|
|
41
53
|
const { profiles, newModels, managedItems, drafters } = normalized;
|
|
54
|
+
const profileItems = profiles.map((profile) => ({ type: "profile", profile, label: profile.label, fileMissing: isProfileFileMissing(profile) }));
|
|
55
|
+
profileItems.sort(compareRecency);
|
|
42
56
|
return [
|
|
43
|
-
...
|
|
57
|
+
...profileItems,
|
|
44
58
|
...newModels.map((model) => ({ type: "new", model, label: model.label, drafter: matchDrafter(model.path, drafters) })),
|
|
45
59
|
...managedItems.map(({ model, backendId }) => ({ type: "managed", model, backendId, label: model.label })),
|
|
46
60
|
];
|
package/src/model-presenters.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, statSync } from "node:fs";
|
|
2
2
|
import { BACKENDS, backendFor } from "./backends.mjs";
|
|
3
3
|
import { readCommandArgv } from "./profiles.mjs";
|
|
4
|
-
import { isProfileRunning } from "./process.mjs";
|
|
4
|
+
import { isProfileRunning, isProfileServerUp } from "./process.mjs";
|
|
5
5
|
import { buildPrettyCommand } from "./command.mjs";
|
|
6
6
|
import { pc, formatBytes, renderRows, renderSection } from "./ui.mjs";
|
|
7
7
|
import { capabilitySummary, ggufDetailParts, isProfileFileMissing, profileDetailParts } from "./model-summary.mjs";
|
|
@@ -25,6 +25,7 @@ function optionPad(text, color, width) {
|
|
|
25
25
|
function optionStatusTag(kind) {
|
|
26
26
|
const statuses = {
|
|
27
27
|
running: ["RUNNING", pc.green],
|
|
28
|
+
serverup: ["SERVER UP", pc.yellow],
|
|
28
29
|
ready: ["READY", pc.blue],
|
|
29
30
|
missing: ["MISSING", pc.red],
|
|
30
31
|
setup: ["SETUP", pc.yellow],
|
|
@@ -79,14 +80,16 @@ function optionLabel({ status, source, name, ctx, size, nameWidth }) {
|
|
|
79
80
|
return [status, source, pc.bold(optionPad(name, null, nameWidth)), ctx, pc.dim(size)].join(OPTION_SEPARATOR);
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
export function modelSelectOption(item, { runningProfilesNow, nameWidth }) {
|
|
83
|
+
export function modelSelectOption(item, { runningProfilesNow, serverUpIds, nameWidth }) {
|
|
83
84
|
if (item.type === "profile") {
|
|
84
85
|
const backend = backendFor(item.profile.backend);
|
|
85
86
|
const running = runningProfilesNow.some((profile) => profile.id === item.profile.id);
|
|
87
|
+
const serverUp = !running && !item.fileMissing && serverUpIds?.has(item.profile.id);
|
|
88
|
+
const status = item.fileMissing ? "missing" : running ? "running" : serverUp ? "serverup" : "ready";
|
|
86
89
|
return {
|
|
87
90
|
value: itemKey(item),
|
|
88
91
|
label: optionLabel({
|
|
89
|
-
status: optionStatusTag(
|
|
92
|
+
status: optionStatusTag(status),
|
|
90
93
|
source: optionSourceTag(item.profile.backend, backend.label),
|
|
91
94
|
name: item.profile.label,
|
|
92
95
|
nameWidth,
|
|
@@ -122,15 +125,19 @@ export function modelSelectOption(item, { runningProfilesNow, nameWidth }) {
|
|
|
122
125
|
};
|
|
123
126
|
}
|
|
124
127
|
|
|
125
|
-
export function printWorkspaceHeader(normalized, runningProfilesNow) {
|
|
128
|
+
export function printWorkspaceHeader(normalized, runningProfilesNow, serverUpIds = new Set()) {
|
|
126
129
|
const profiles = normalized.profiles;
|
|
127
|
-
const
|
|
130
|
+
const isRunning = (p) => runningProfilesNow.some((r) => r.id === p.id);
|
|
131
|
+
const isMissing = (p) => isProfileFileMissing(p);
|
|
132
|
+
const readyCount = profiles.filter((p) => !isMissing(p) && !isRunning(p) && !serverUpIds.has(p.id)).length;
|
|
128
133
|
const runningCount = runningProfilesNow.length;
|
|
129
|
-
const
|
|
134
|
+
const serverUpCount = profiles.filter((p) => !isMissing(p) && serverUpIds.has(p.id) && !isRunning(p)).length;
|
|
135
|
+
const missingCount = profiles.filter(isMissing).length;
|
|
130
136
|
const setupCount = normalized.newModels.length + normalized.managedItems.length;
|
|
131
137
|
|
|
132
138
|
const countParts = [];
|
|
133
139
|
if (runningCount > 0) countParts.push(pc.green(`${runningCount} running`));
|
|
140
|
+
if (serverUpCount > 0) countParts.push(pc.yellow(`${serverUpCount} server up, model not loaded`));
|
|
134
141
|
if (readyCount > 0) countParts.push(pc.blue(`${readyCount} model${readyCount === 1 ? "" : "s"} ready`));
|
|
135
142
|
if (missingCount > 0) countParts.push(pc.red(`${missingCount} model${missingCount === 1 ? "" : "s"} missing`));
|
|
136
143
|
if (setupCount > 0) countParts.push(pc.yellow(`${setupCount} model${setupCount === 1 ? "" : "s"} need${setupCount === 1 ? "s" : ""} setup`));
|
|
@@ -153,10 +160,11 @@ export async function printProfileDetails(profile) {
|
|
|
153
160
|
const backend = backendFor(profile.backend);
|
|
154
161
|
const isManaged = backend.type === "managed-server";
|
|
155
162
|
const running = await isProfileRunning(profile);
|
|
163
|
+
const serverUp = !running && isManaged && await isProfileServerUp(profile);
|
|
156
164
|
const fileMissing = !isManaged && isProfileFileMissing(profile);
|
|
157
165
|
console.log("\n" + renderSection("Model overview", renderRows([
|
|
158
166
|
["Name", pc.bold(profile.label)],
|
|
159
|
-
["Status", fileMissing ? pc.red("File missing") : running ? pc.green("Running now") : pc.blue("Ready")],
|
|
167
|
+
["Status", fileMissing ? pc.red("File missing") : running ? pc.green("Running now") : serverUp ? pc.yellow("Server up, model not loaded") : pc.blue("Ready")],
|
|
160
168
|
["Details", profileDetailParts(profile, { fileMissing }).join(pc.dim(" · "))],
|
|
161
169
|
["Server", fileMissing ? pc.red(profile.baseUrl) : profile.baseUrl],
|
|
162
170
|
])));
|
package/src/process.mjs
CHANGED
|
@@ -113,16 +113,31 @@ export async function stopProfile(profile) {
|
|
|
113
113
|
|
|
114
114
|
export async function isProfileRunning(profile) {
|
|
115
115
|
const backend = backendFor(profile.backend);
|
|
116
|
-
if (backend.type === "managed-server")
|
|
116
|
+
if (backend.type === "managed-server") {
|
|
117
|
+
return await serverReady(profile.baseUrl) && (await modelLoadedOnServer(profile));
|
|
118
|
+
}
|
|
117
119
|
const state = await readState(profile.id);
|
|
118
120
|
return Boolean(state?.pid && pidAlive(state.pid));
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
export async function isProfileServerUp(profile) {
|
|
124
|
+
return await serverReady(profile.baseUrl);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function modelLoadedOnServer(profile) {
|
|
128
|
+
const backend = backendFor(profile.backend);
|
|
129
|
+
if (backend.id === "ollama") return modelIdsMatch(await ollamaLoadedModelIds(profile), expectedModelIds(profile));
|
|
130
|
+
if (backend.id === "omlx") return modelIdsMatch(await omlxLoadedModelIds(profile), expectedModelIds(profile));
|
|
131
|
+
const { matches } = await serverMatchesProfile(profile);
|
|
132
|
+
return matches;
|
|
133
|
+
}
|
|
134
|
+
|
|
121
135
|
export async function profileRuntimeStatus(profile) {
|
|
122
136
|
const backend = backendFor(profile.backend);
|
|
123
137
|
if (backend.type === "managed-server") {
|
|
124
138
|
const ready = await serverReady(profile.baseUrl);
|
|
125
|
-
|
|
139
|
+
const modelLoaded = ready ? await modelLoadedOnServer(profile) : false;
|
|
140
|
+
return { state: null, pid: null, running: ready && modelLoaded, ready, serverUp: ready, modelLoaded, rssBytes: null, startedAt: null };
|
|
126
141
|
}
|
|
127
142
|
const state = await readState(profile.id);
|
|
128
143
|
const running = Boolean(state?.pid && pidAlive(state.pid));
|
|
@@ -180,18 +195,79 @@ export async function waitForReady(profile, pid, rawLogPath) {
|
|
|
180
195
|
// ── Internals ──────────────────────────────────────────────────────────────
|
|
181
196
|
|
|
182
197
|
async function serverModelIds(baseUrl) {
|
|
198
|
+
const body = await fetchJson(`${baseUrl.replace(/\/$/, "")}/models`);
|
|
199
|
+
return (Array.isArray(body?.data) ? body.data : [])
|
|
200
|
+
.map((model) => String(model?.id ?? "").trim())
|
|
201
|
+
.filter(Boolean);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function ollamaLoadedModelIds(profile) {
|
|
205
|
+
const body = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/ps`);
|
|
206
|
+
return (Array.isArray(body?.models) ? body.models : [])
|
|
207
|
+
.flatMap((model) => [model?.name, model?.model])
|
|
208
|
+
.map((id) => String(id ?? "").trim())
|
|
209
|
+
.filter(Boolean);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function omlxLoadedModelIds(profile) {
|
|
213
|
+
const status = await fetchJson(`${profile.baseUrl.replace(/\/$/, "")}/models/status`);
|
|
214
|
+
const fromStatus = (Array.isArray(status?.models) ? status.models : [])
|
|
215
|
+
.filter((model) => model?.loaded === true)
|
|
216
|
+
.flatMap((model) => [model?.id, model?.name, model?.model, model?.alias])
|
|
217
|
+
.map((id) => String(id ?? "").trim())
|
|
218
|
+
.filter(Boolean);
|
|
219
|
+
if (Number(status?.loaded_count) === 0) return fromStatus;
|
|
220
|
+
|
|
221
|
+
const summary = await fetchJson(`${apiRootUrl(profile.baseUrl)}/api/status`);
|
|
222
|
+
const fromSummary = (Array.isArray(summary?.loaded_models) ? summary.loaded_models : [])
|
|
223
|
+
.map((id) => String(id ?? "").trim())
|
|
224
|
+
.filter(Boolean);
|
|
225
|
+
return [...fromStatus, ...fromSummary];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function fetchJson(url) {
|
|
183
229
|
try {
|
|
184
|
-
const response = await fetch(
|
|
185
|
-
if (!response.ok) return
|
|
186
|
-
|
|
187
|
-
return (Array.isArray(body?.data) ? body.data : [])
|
|
188
|
-
.map((model) => String(model?.id ?? "").trim())
|
|
189
|
-
.filter(Boolean);
|
|
230
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
|
|
231
|
+
if (!response.ok) return null;
|
|
232
|
+
return await response.json();
|
|
190
233
|
} catch {
|
|
191
|
-
return
|
|
234
|
+
return null;
|
|
192
235
|
}
|
|
193
236
|
}
|
|
194
237
|
|
|
238
|
+
function apiRootUrl(baseUrl) {
|
|
239
|
+
try {
|
|
240
|
+
const url = new URL(baseUrl);
|
|
241
|
+
url.pathname = url.pathname.replace(/\/v1\/?$/u, "") || "/";
|
|
242
|
+
url.search = "";
|
|
243
|
+
url.hash = "";
|
|
244
|
+
return url.toString().replace(/\/$/u, "");
|
|
245
|
+
} catch {
|
|
246
|
+
return String(baseUrl).replace(/\/v1\/?$/u, "").replace(/\/$/u, "");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function modelIdsMatch(actualIds, expectedIds) {
|
|
251
|
+
const actual = normalizedModelIds(actualIds);
|
|
252
|
+
const expected = normalizedModelIds(expectedIds);
|
|
253
|
+
return [...expected].some((id) => actual.has(id));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function normalizedModelIds(ids) {
|
|
257
|
+
const normalized = new Set();
|
|
258
|
+
for (const id of ids) {
|
|
259
|
+
const value = normalizeModelId(id);
|
|
260
|
+
if (!value) continue;
|
|
261
|
+
normalized.add(value);
|
|
262
|
+
if (value.endsWith(":latest")) normalized.add(value.slice(0, -":latest".length));
|
|
263
|
+
}
|
|
264
|
+
return normalized;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function normalizeModelId(id) {
|
|
268
|
+
return String(id ?? "").trim().toLowerCase();
|
|
269
|
+
}
|
|
270
|
+
|
|
195
271
|
function expectedModelIds(profile) {
|
|
196
272
|
const fileName = profile.modelPath ? basename(profile.modelPath) : null;
|
|
197
273
|
return [
|
package/src/ui.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export function createPrompt() {
|
|
|
36
36
|
async choice(label, choices, defaultValue) {
|
|
37
37
|
return handleCancel(await select({
|
|
38
38
|
message: label, initialValue: defaultValue,
|
|
39
|
-
options: choices.map((c) => ({ value: c.value, label: c.label ?? c.value, hint: c.hint })),
|
|
39
|
+
options: choices.map((c) => ({ value: c.value, label: c.label ?? c.value, hint: c.hint, disabled: c.disabled })),
|
|
40
40
|
}));
|
|
41
41
|
},
|
|
42
42
|
close() {},
|