offgrid-ai 0.5.5 → 0.6.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/benchmark.mjs +277 -0
- package/src/cli.mjs +17 -1
package/package.json
CHANGED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { mkdir, writeFile, readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { ensureDirs, loadConfig, saveConfig } from "./config.mjs";
|
|
9
|
+
import { pc, createPrompt, renderRows, renderSection } from "./ui.mjs";
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
|
|
13
|
+
// ── Shared utilities (matches local-llm-visual-benchmark/scripts/local-llm/shared/run-utils.mjs) ──
|
|
14
|
+
|
|
15
|
+
export function slugModelId(modelId, maxLength = 80) {
|
|
16
|
+
const hash = createHash("sha256").update(modelId).digest("hex").slice(0, 10);
|
|
17
|
+
const normalized = modelId.normalize("NFKD").replace(/[\u0300-\u036f]/gu, "").toLowerCase();
|
|
18
|
+
const slug = normalized.replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").replace(/-{2,}/gu, "-");
|
|
19
|
+
if (slug.length > 0 && slug.length <= maxLength && slug === normalized) return slug;
|
|
20
|
+
const baseMaxLength = Math.max(1, maxLength - 11);
|
|
21
|
+
const base = slug.slice(0, baseMaxLength).replace(/^-+|-+$/gu, "") || "model";
|
|
22
|
+
return `${base}-${hash}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createRunId(date = new Date()) {
|
|
26
|
+
return date.toISOString().replace(/:/gu, "-").replace(/\./gu, "-");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildToolPrompt(benchmark, kind) {
|
|
30
|
+
if (kind === "data-science") return benchmark.prompt;
|
|
31
|
+
return [
|
|
32
|
+
"Create a complete, self-contained HTML file for the request below.",
|
|
33
|
+
"Write the file as `index.html` in the current working directory.",
|
|
34
|
+
"Do not create any folders, do not infer a filesystem path, and do not print the HTML in chat.",
|
|
35
|
+
"",
|
|
36
|
+
"The HTML must include all CSS and JavaScript inline and must not depend on external network assets.",
|
|
37
|
+
"After building the page, run a visual QA pass with agent-browser or Playwright: open the saved index.html, inspect the rendered result, and fix any obvious layout, animation, console, or viewport issues before you finish.",
|
|
38
|
+
"",
|
|
39
|
+
benchmark.prompt,
|
|
40
|
+
].join("\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function loadBenchmarks(benchDir) {
|
|
44
|
+
const entries = await readdir(benchDir);
|
|
45
|
+
const markdownFiles = entries.filter((f) => f.endsWith(".md")).sort();
|
|
46
|
+
const benchmarks = [];
|
|
47
|
+
for (const filename of markdownFiles) {
|
|
48
|
+
const raw = await readFile(join(benchDir, filename), "utf8");
|
|
49
|
+
// Simple frontmatter parser (no gray-matter dependency)
|
|
50
|
+
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
51
|
+
const frontmatter = match ? match[1] : "";
|
|
52
|
+
const content = match ? match[2].trim() : raw.trim();
|
|
53
|
+
let id = filename.replace(/\.md$/u, "");
|
|
54
|
+
let title = id;
|
|
55
|
+
let description = "";
|
|
56
|
+
let kind = "visual";
|
|
57
|
+
for (const line of frontmatter.split("\n")) {
|
|
58
|
+
const kv = line.match(/^(\w+):\s*(.+)$/);
|
|
59
|
+
if (kv) {
|
|
60
|
+
const [, key, val] = kv;
|
|
61
|
+
if (key === "id") id = val.trim();
|
|
62
|
+
if (key === "title") title = val.trim();
|
|
63
|
+
if (key === "description") description = val.trim();
|
|
64
|
+
if (key === "kind") kind = val.trim();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (id === "ab-test-analysis") kind = "data-science";
|
|
68
|
+
benchmarks.push({ id, title, description, prompt: content, kind });
|
|
69
|
+
}
|
|
70
|
+
return benchmarks;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Benchmark repo linking ────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const BENCHMARK_REPO = "https://github.com/eeshansrivastava89/local-llm-visual-benchmark.git";
|
|
76
|
+
|
|
77
|
+
export async function findBenchmarkRepo() {
|
|
78
|
+
const config = await loadConfig();
|
|
79
|
+
if (config.benchmarkRepoPath && existsSync(join(config.benchmarkRepoPath, "benchmarks"))) {
|
|
80
|
+
return config.benchmarkRepoPath;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function linkBenchmarkRepo(prompt) {
|
|
86
|
+
const existing = await findBenchmarkRepo();
|
|
87
|
+
if (existing) return existing;
|
|
88
|
+
|
|
89
|
+
// Check common locations
|
|
90
|
+
const candidates = [
|
|
91
|
+
join(homedir(), "dev", "local-llm-visual-benchmark"),
|
|
92
|
+
join(homedir(), "projects", "local-llm-visual-benchmark"),
|
|
93
|
+
join(homedir(), "local-llm-visual-benchmark"),
|
|
94
|
+
];
|
|
95
|
+
for (const candidate of candidates) {
|
|
96
|
+
if (existsSync(join(candidate, "benchmarks"))) {
|
|
97
|
+
const config = await loadConfig();
|
|
98
|
+
config.benchmarkRepoPath = candidate;
|
|
99
|
+
await saveConfig(config);
|
|
100
|
+
return candidate;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log(pc.dim("\nThe benchmark gallery needs to be linked to offgrid-ai."));
|
|
105
|
+
console.log(pc.dim("This is the local-llm-visual-benchmark repo that stores prompts and run results.\n"));
|
|
106
|
+
|
|
107
|
+
const choice = await prompt.choice("Link benchmark gallery", [
|
|
108
|
+
{ value: "clone", label: "Clone from GitHub", hint: "git clone into ~/dev" },
|
|
109
|
+
{ value: "manual", label: "Enter path manually", hint: "If you already have it cloned" },
|
|
110
|
+
], "clone");
|
|
111
|
+
|
|
112
|
+
if (choice === "clone") {
|
|
113
|
+
const targetDir = join(homedir(), "dev", "local-llm-visual-benchmark");
|
|
114
|
+
console.log(pc.dim(`\nCloning ${BENCHMARK_REPO}...`));
|
|
115
|
+
try {
|
|
116
|
+
await execFileAsync("git", ["clone", BENCHMARK_REPO, targetDir], { stdio: "pipe" });
|
|
117
|
+
const config = await loadConfig();
|
|
118
|
+
config.benchmarkRepoPath = targetDir;
|
|
119
|
+
await saveConfig(config);
|
|
120
|
+
console.log(pc.green(`✓ Cloned to ${targetDir}`));
|
|
121
|
+
return targetDir;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.log(pc.red(`Clone failed: ${err.message}`));
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Manual path
|
|
129
|
+
const path = await prompt.text("Path to local-llm-visual-benchmark", "");
|
|
130
|
+
if (!path) return null;
|
|
131
|
+
const resolved = resolve(path.replace(/^~/, homedir()));
|
|
132
|
+
if (!existsSync(join(resolved, "benchmarks"))) {
|
|
133
|
+
console.log(pc.red(`No benchmarks/ directory found at ${resolved}`));
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const config = await loadConfig();
|
|
137
|
+
config.benchmarkRepoPath = resolved;
|
|
138
|
+
await saveConfig(config);
|
|
139
|
+
console.log(pc.green(`✓ Linked to ${resolved}`));
|
|
140
|
+
return resolved;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Prepare a benchmark run ────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
export async function benchmarkFlow() {
|
|
146
|
+
await ensureDirs();
|
|
147
|
+
|
|
148
|
+
const prompt = createPrompt();
|
|
149
|
+
try {
|
|
150
|
+
const repoPath = await linkBenchmarkRepo(prompt);
|
|
151
|
+
if (!repoPath) return;
|
|
152
|
+
|
|
153
|
+
// 1. Pick category
|
|
154
|
+
const kind = await prompt.choice("Benchmark category", [
|
|
155
|
+
{ value: "visual", label: "Visual Benchmark", hint: "HTML/CSS/JS animation benchmarks" },
|
|
156
|
+
{ value: "data-science", label: "Data Science", hint: "Analysis and charting benchmarks" },
|
|
157
|
+
], "visual");
|
|
158
|
+
|
|
159
|
+
// 2. Pick benchmark prompt
|
|
160
|
+
const benchDir = join(repoPath, "benchmarks");
|
|
161
|
+
const benchmarks = (await loadBenchmarks(benchDir)).filter((b) => b.kind === kind);
|
|
162
|
+
if (benchmarks.length === 0) {
|
|
163
|
+
console.log(pc.yellow(`No ${kind} benchmarks found in ${benchDir}`));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const benchmarkId = await prompt.choice("Prompt", benchmarks.map((b) => ({
|
|
167
|
+
value: b.id,
|
|
168
|
+
label: b.title,
|
|
169
|
+
hint: b.description || b.id,
|
|
170
|
+
})), benchmarks[0].id);
|
|
171
|
+
const selectedBenchmark = benchmarks.find((b) => b.id === benchmarkId);
|
|
172
|
+
if (!selectedBenchmark) return;
|
|
173
|
+
|
|
174
|
+
// 3. Pick model source
|
|
175
|
+
const { loadProfiles } = await import("./profiles.mjs");
|
|
176
|
+
const { backendFor } = await import("./backends.mjs");
|
|
177
|
+
|
|
178
|
+
const profiles = await loadProfiles();
|
|
179
|
+
const source = await prompt.choice("Model source", [
|
|
180
|
+
{ value: "profile", label: "Use existing profile", hint: "Pick a saved offgrid-ai profile" },
|
|
181
|
+
{ value: "cloud", label: "Custom / cloud", hint: "Free-form model label for cloud runs" },
|
|
182
|
+
], "profile");
|
|
183
|
+
|
|
184
|
+
let modelId, modelSource, backendLabel;
|
|
185
|
+
|
|
186
|
+
if (source === "profile") {
|
|
187
|
+
if (profiles.length === 0) {
|
|
188
|
+
console.log(pc.yellow("No profiles yet. Run: offgrid-ai models"));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const profileId = await prompt.choice("Profile", profiles.map((p) => ({
|
|
192
|
+
value: p.id,
|
|
193
|
+
label: p.label,
|
|
194
|
+
hint: `${backendFor(p.backend).label} · ${p.modelAlias}`,
|
|
195
|
+
})), profiles[0].id);
|
|
196
|
+
const profile = profiles.find((p) => p.id === profileId);
|
|
197
|
+
if (!profile) return;
|
|
198
|
+
modelId = profile.modelAlias;
|
|
199
|
+
modelSource = profile.providerId === "llama-cpp-mtp" ? "llama-cpp-mtp" : profile.backend === "ollama" ? "ollama" : profile.backend === "omlx" ? "omlx" : "llama-cpp";
|
|
200
|
+
backendLabel = backendFor(profile.backend).label;
|
|
201
|
+
} else {
|
|
202
|
+
backendLabel = await prompt.text("Backend label", "cloud");
|
|
203
|
+
modelId = await prompt.text("Model name", "");
|
|
204
|
+
if (!modelId) { console.log(pc.yellow("Model name is required.")); return; }
|
|
205
|
+
modelSource = "cloud";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 4. Create the run directory
|
|
209
|
+
const toolPrompt = buildToolPrompt(selectedBenchmark, kind);
|
|
210
|
+
const now = new Date();
|
|
211
|
+
const runId = createRunId(now);
|
|
212
|
+
const modelSlug = slugModelId(modelId);
|
|
213
|
+
const runsDir = join(repoPath, "runs");
|
|
214
|
+
const benchmarkDirectory = join(runsDir, selectedBenchmark.id);
|
|
215
|
+
const modelDirectory = join(benchmarkDirectory, modelSlug);
|
|
216
|
+
const runDirectory = join(modelDirectory, runId);
|
|
217
|
+
|
|
218
|
+
await mkdir(runDirectory, { recursive: true });
|
|
219
|
+
|
|
220
|
+
const isDs = kind === "data-science";
|
|
221
|
+
const metadata = {
|
|
222
|
+
schemaVersion: 1,
|
|
223
|
+
kind,
|
|
224
|
+
runId,
|
|
225
|
+
benchmark: { id: selectedBenchmark.id, title: selectedBenchmark.title, description: selectedBenchmark.description, prompt: selectedBenchmark.prompt },
|
|
226
|
+
model: { id: modelId, slug: modelSlug },
|
|
227
|
+
status: "prepared",
|
|
228
|
+
createdAt: now.toISOString(),
|
|
229
|
+
updatedAt: now.toISOString(),
|
|
230
|
+
preparedAt: now.toISOString(),
|
|
231
|
+
runDirectory,
|
|
232
|
+
assets: isDs
|
|
233
|
+
? { metadata: "metadata.json", prompt: "prompt.md", rawResponse: "response.raw.txt", ds: { notebook: "analysis.ipynb", summary: "summary.json", chartDistribution: "chart-distribution.png", chartTreatmentEffect: "chart-treatment-effect.png", chartCompletionRates: "chart-completion-rates.png" } }
|
|
234
|
+
: { metadata: "metadata.json", prompt: "prompt.md", html: "index.html", preview: "preview.png", video: "preview.webm", rawResponse: "response.raw.txt" },
|
|
235
|
+
runner: {
|
|
236
|
+
mode: source === "profile" ? "external" : "manual",
|
|
237
|
+
...(modelSource ? { modelSource } : {}),
|
|
238
|
+
...(backendLabel ? { backendLabel } : {}),
|
|
239
|
+
model: modelId,
|
|
240
|
+
retries: 0,
|
|
241
|
+
tokenMetrics: { reported: false },
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// Clean up the baseUrl field — only include if we have a real one
|
|
246
|
+
if (!metadata.runner.baseUrl) delete metadata.runner.baseUrl;
|
|
247
|
+
|
|
248
|
+
await writeFile(join(runDirectory, "metadata.json"), JSON.stringify(metadata, null, 2) + "\n", "utf8");
|
|
249
|
+
await writeFile(join(runDirectory, "prompt.md"), toolPrompt + "\n", "utf8");
|
|
250
|
+
|
|
251
|
+
// 5. Print result
|
|
252
|
+
console.log("");
|
|
253
|
+
console.log(pc.green("✓ Run slot prepared"));
|
|
254
|
+
console.log(renderSection("Run", renderRows([
|
|
255
|
+
["Directory", pc.cyan(runDirectory)],
|
|
256
|
+
["Benchmark", selectedBenchmark.title],
|
|
257
|
+
["Kind", kind],
|
|
258
|
+
["Model", pc.bold(modelId)],
|
|
259
|
+
["Source", backendLabel || modelSource],
|
|
260
|
+
])));
|
|
261
|
+
|
|
262
|
+
console.log("");
|
|
263
|
+
console.log(pc.bold("Next steps"));
|
|
264
|
+
if (source === "profile") {
|
|
265
|
+
console.log(` 1. ${pc.cyan(`cd ${runDirectory}`)}`);
|
|
266
|
+
console.log(` 2. ${pc.cyan("offgrid-ai models")} → select the model → Run`);
|
|
267
|
+
console.log(` 3. In Pi, paste the prompt from ${pc.cyan("prompt.md")}`);
|
|
268
|
+
console.log(` 4. View results: ${pc.cyan(`cd ${repoPath} && npm run dev`)}`);
|
|
269
|
+
} else {
|
|
270
|
+
console.log(` 1. ${pc.cyan(`cd ${runDirectory}`)}`);
|
|
271
|
+
console.log(` 2. Copy the prompt from ${pc.cyan("prompt.md")} into your tool of choice`);
|
|
272
|
+
console.log(` 3. View results: ${pc.cyan(`cd ${repoPath} && npm run dev`)}`);
|
|
273
|
+
}
|
|
274
|
+
} finally {
|
|
275
|
+
prompt.close();
|
|
276
|
+
}
|
|
277
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -59,6 +59,7 @@ export async function run(argv) {
|
|
|
59
59
|
if (command === "run") return runCommand(argv.slice(1));
|
|
60
60
|
if (command === "status") return statusCommand();
|
|
61
61
|
if (command === "stop") return stopCommand(argv.slice(1));
|
|
62
|
+
if (command === "benchmark") return benchmarkCommand();
|
|
62
63
|
if (command === "uninstall" || command === "--uninstall") return uninstallCommand(argv.slice(1));
|
|
63
64
|
if (command === "--verbose") return mainFlow(); // verbose flag handled inside onboardFlow
|
|
64
65
|
|
|
@@ -311,6 +312,11 @@ function actionsForItem(item) {
|
|
|
311
312
|
{ value: "reconfigure", label: "Reconfigure", hint: "Change context, MTP, settings" },
|
|
312
313
|
{ value: "inspect", label: "Details", hint: "Paths, ports, flags" },
|
|
313
314
|
];
|
|
315
|
+
// Benchmark is offered if repo is linked and model is local
|
|
316
|
+
const backend = backendFor(item.profile.backend);
|
|
317
|
+
if (backend.type === "local-server" || backend.type === "managed-server") {
|
|
318
|
+
actions.push({ value: "benchmark", label: "Benchmark", hint: "Prepare a benchmark run" });
|
|
319
|
+
}
|
|
314
320
|
if (!item.fileMissing) {
|
|
315
321
|
actions.push({ value: "remove", label: "Remove", hint: "Delete this setup" });
|
|
316
322
|
}
|
|
@@ -412,6 +418,10 @@ async function performAction(prompt, action, item) {
|
|
|
412
418
|
if (item.type === "managed") return printManagedModelDetails(item.model, BACKENDS[item.backendId]);
|
|
413
419
|
return printGgufModelDetails(item.model, item.drafter);
|
|
414
420
|
}
|
|
421
|
+
if (action === "benchmark") {
|
|
422
|
+
const { benchmarkFlow } = await import("./benchmark.mjs");
|
|
423
|
+
return await benchmarkFlow();
|
|
424
|
+
}
|
|
415
425
|
if (action === "run") {
|
|
416
426
|
if (item.type === "profile") return await runProfile(await readProfile(item.profile.id));
|
|
417
427
|
// Shouldn't reach here for new/managed, but handle gracefully
|
|
@@ -710,6 +720,11 @@ async function removeProfileInteractive(id) {
|
|
|
710
720
|
|
|
711
721
|
// ── Status ──────────────────────────────────────────────────────────────────
|
|
712
722
|
|
|
723
|
+
async function benchmarkCommand() {
|
|
724
|
+
const { benchmarkFlow } = await import("./benchmark.mjs");
|
|
725
|
+
return await benchmarkFlow();
|
|
726
|
+
}
|
|
727
|
+
|
|
713
728
|
async function statusCommand() {
|
|
714
729
|
await ensureDirs();
|
|
715
730
|
const profiles = await loadProfiles();
|
|
@@ -1223,9 +1238,10 @@ function printHelp() {
|
|
|
1223
1238
|
["Start", pc.bold("offgrid-ai")],
|
|
1224
1239
|
["Status", "offgrid-ai status"],
|
|
1225
1240
|
["Stop", "offgrid-ai stop"],
|
|
1241
|
+
["Benchmark", "offgrid-ai benchmark"],
|
|
1226
1242
|
["Uninstall", "offgrid-ai uninstall"],
|
|
1227
1243
|
["Version", "offgrid-ai version"],
|
|
1228
1244
|
]), { formatBorder: pc.cyan }));
|
|
1229
|
-
console.log("\n" + renderCard("How it works", "Run offgrid-ai, choose a local model, and start chatting in Pi.\n\nFirst run walks you through missing tools. After that, offgrid-ai remembers your model setup.", { formatBorder: pc.magenta }));
|
|
1245
|
+
console.log("\n" + renderCard("How it works", "Run offgrid-ai, choose a local model, and start chatting in Pi.\n\nFirst run walks you through missing tools. After that, offgrid-ai remembers your model setup.\n\nFor benchmarks, run offgrid-ai benchmark to prepare a visual or data-science benchmark run.", { formatBorder: pc.magenta }));
|
|
1230
1246
|
console.log("\n" + pc.dim("Tip: use --verbose only when you want detailed install output."));
|
|
1231
1247
|
}
|