pudu-ai 0.2.2
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/LICENSE +21 -0
- package/README.md +158 -0
- package/bin/pudu-ai.mjs +30 -0
- package/dist/cli/index.js +3068 -0
- package/package.json +65 -0
|
@@ -0,0 +1,3068 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
#!/usr/bin/env node
|
|
3
|
+
|
|
4
|
+
// src/cli/parse-args.ts
|
|
5
|
+
function parseArgs(argv) {
|
|
6
|
+
const args2 = argv.slice(2);
|
|
7
|
+
const flags = new Set(args2.filter((a) => a.startsWith("-")));
|
|
8
|
+
const rest = args2.filter((a) => !a.startsWith("-"));
|
|
9
|
+
const command = rest[0] ?? "dashboard";
|
|
10
|
+
const known = [
|
|
11
|
+
"dashboard",
|
|
12
|
+
"hardware",
|
|
13
|
+
"models",
|
|
14
|
+
"recommend",
|
|
15
|
+
"benchmark",
|
|
16
|
+
"compare",
|
|
17
|
+
"history",
|
|
18
|
+
"doctor",
|
|
19
|
+
"report",
|
|
20
|
+
"tasks",
|
|
21
|
+
"launch"
|
|
22
|
+
];
|
|
23
|
+
const isKnown = known.includes(command);
|
|
24
|
+
let addPath;
|
|
25
|
+
if (command === "models" && rest[1] === "add-path") addPath = rest[2];
|
|
26
|
+
const presetIndex = args2.findIndex((a) => a === "--preset");
|
|
27
|
+
const preset = presetIndex >= 0 ? args2[presetIndex + 1] : void 0;
|
|
28
|
+
const langIndex = args2.findIndex((a) => a === "--lang" || a === "--locale");
|
|
29
|
+
const lang = langIndex >= 0 ? args2[langIndex + 1] : void 0;
|
|
30
|
+
const forIndex = args2.findIndex((a) => a === "--for");
|
|
31
|
+
const scopeIndex = args2.findIndex((a) => a === "--scope");
|
|
32
|
+
const priorityIndex = args2.findIndex((a) => a === "--priority");
|
|
33
|
+
return {
|
|
34
|
+
command: isKnown ? command : "dashboard",
|
|
35
|
+
positional: isKnown ? rest.slice(1) : rest,
|
|
36
|
+
json: flags.has("--json"),
|
|
37
|
+
csv: flags.has("--csv"),
|
|
38
|
+
network: !flags.has("--no-network"),
|
|
39
|
+
color: !flags.has("--no-color"),
|
|
40
|
+
verbose: flags.has("--verbose"),
|
|
41
|
+
preset,
|
|
42
|
+
markdown: flags.has("--markdown"),
|
|
43
|
+
help: flags.has("-h") || flags.has("--help"),
|
|
44
|
+
addPath,
|
|
45
|
+
lang,
|
|
46
|
+
forKinds: forIndex >= 0 ? args2[forIndex + 1] : void 0,
|
|
47
|
+
scope: scopeIndex >= 0 ? args2[scopeIndex + 1] : void 0,
|
|
48
|
+
priority: priorityIndex >= 0 ? args2[priorityIndex + 1] : void 0,
|
|
49
|
+
yes: flags.has("--yes")
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/storage/config.ts
|
|
54
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
55
|
+
import { z } from "zod";
|
|
56
|
+
|
|
57
|
+
// src/storage/paths.ts
|
|
58
|
+
import os from "node:os";
|
|
59
|
+
import path from "node:path";
|
|
60
|
+
import { mkdir } from "node:fs/promises";
|
|
61
|
+
function homeDir() {
|
|
62
|
+
return path.join(os.homedir(), ".pudu-ai");
|
|
63
|
+
}
|
|
64
|
+
function configPath() {
|
|
65
|
+
return path.join(homeDir(), "config.json");
|
|
66
|
+
}
|
|
67
|
+
function benchmarksDir() {
|
|
68
|
+
return path.join(homeDir(), "benchmarks");
|
|
69
|
+
}
|
|
70
|
+
function telemetryDir() {
|
|
71
|
+
return path.join(homeDir(), "telemetry");
|
|
72
|
+
}
|
|
73
|
+
function cacheDir() {
|
|
74
|
+
return path.join(homeDir(), "cache");
|
|
75
|
+
}
|
|
76
|
+
function canirunCachePath() {
|
|
77
|
+
return path.join(cacheDir(), "canirun-models.json");
|
|
78
|
+
}
|
|
79
|
+
async function ensureStorage() {
|
|
80
|
+
await mkdir(benchmarksDir(), { recursive: true });
|
|
81
|
+
await mkdir(telemetryDir(), { recursive: true });
|
|
82
|
+
await mkdir(cacheDir(), { recursive: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// src/storage/config.ts
|
|
86
|
+
var configSchema = z.object({
|
|
87
|
+
schemaVersion: z.literal(1),
|
|
88
|
+
modelPaths: z.array(z.string()).default([]),
|
|
89
|
+
network: z.boolean().default(true)
|
|
90
|
+
});
|
|
91
|
+
var fallback = { schemaVersion: 1, modelPaths: [], network: true };
|
|
92
|
+
async function loadConfig() {
|
|
93
|
+
await ensureStorage();
|
|
94
|
+
try {
|
|
95
|
+
const raw = await readFile(configPath(), "utf8");
|
|
96
|
+
return configSchema.parse(JSON.parse(raw));
|
|
97
|
+
} catch {
|
|
98
|
+
await saveConfig(fallback);
|
|
99
|
+
return fallback;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function saveConfig(config) {
|
|
103
|
+
await ensureStorage();
|
|
104
|
+
await writeFile(configPath(), `${JSON.stringify(config, null, 2)}
|
|
105
|
+
`, "utf8");
|
|
106
|
+
}
|
|
107
|
+
async function addModelPath(dir) {
|
|
108
|
+
const config = await loadConfig();
|
|
109
|
+
if (!config.modelPaths.includes(dir)) config.modelPaths.push(dir);
|
|
110
|
+
await saveConfig(config);
|
|
111
|
+
return config;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/hardware/detect.ts
|
|
115
|
+
import os5 from "node:os";
|
|
116
|
+
|
|
117
|
+
// src/platform/macos/hardware.ts
|
|
118
|
+
import os2 from "node:os";
|
|
119
|
+
|
|
120
|
+
// src/shared/process.ts
|
|
121
|
+
import { execa } from "execa";
|
|
122
|
+
var children = /* @__PURE__ */ new Set();
|
|
123
|
+
var installed = false;
|
|
124
|
+
function installSignalHandlers() {
|
|
125
|
+
if (installed) return;
|
|
126
|
+
installed = true;
|
|
127
|
+
const stop = () => {
|
|
128
|
+
void killAll("SIGTERM");
|
|
129
|
+
};
|
|
130
|
+
process.once("SIGINT", stop);
|
|
131
|
+
process.once("SIGTERM", stop);
|
|
132
|
+
}
|
|
133
|
+
function spawnTracked(file, args2 = [], options = {}) {
|
|
134
|
+
installSignalHandlers();
|
|
135
|
+
const subprocess = execa(file, args2, {
|
|
136
|
+
reject: false,
|
|
137
|
+
timeout: options.timeout ?? 3e4,
|
|
138
|
+
...options
|
|
139
|
+
});
|
|
140
|
+
children.add(subprocess);
|
|
141
|
+
void subprocess.finally(() => {
|
|
142
|
+
children.delete(subprocess);
|
|
143
|
+
});
|
|
144
|
+
return subprocess;
|
|
145
|
+
}
|
|
146
|
+
function toText(value) {
|
|
147
|
+
if (typeof value === "string") return value;
|
|
148
|
+
if (value instanceof Uint8Array) return Buffer.from(value).toString("utf8");
|
|
149
|
+
if (Array.isArray(value)) return value.map(String).join("\n");
|
|
150
|
+
if (value == null) return "";
|
|
151
|
+
return String(value);
|
|
152
|
+
}
|
|
153
|
+
async function runCommand(file, args2 = [], options = {}) {
|
|
154
|
+
const result = await spawnTracked(file, args2, options);
|
|
155
|
+
return {
|
|
156
|
+
stdout: toText(result.stdout),
|
|
157
|
+
stderr: toText(result.stderr),
|
|
158
|
+
exitCode: result.exitCode,
|
|
159
|
+
timedOut: Boolean(result.timedOut)
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
async function killAll(signal = "SIGTERM") {
|
|
163
|
+
for (const child of [...children]) {
|
|
164
|
+
child.kill(signal);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/shared/bytes.ts
|
|
169
|
+
var GiB = 1024 ** 3;
|
|
170
|
+
var MiB = 1024 ** 2;
|
|
171
|
+
function bytesToGiB(bytes) {
|
|
172
|
+
return bytes / GiB;
|
|
173
|
+
}
|
|
174
|
+
function parseSizeToBytes(input) {
|
|
175
|
+
const match = input.trim().match(/^([\d.]+)\s*(B|KB|MB|GB|TB|KiB|MiB|GiB|TiB)$/i);
|
|
176
|
+
if (!match) return void 0;
|
|
177
|
+
const value = Number(match[1]);
|
|
178
|
+
const unit = match[2].toLowerCase();
|
|
179
|
+
const map = {
|
|
180
|
+
b: 1,
|
|
181
|
+
kb: 1e3,
|
|
182
|
+
mb: 1e3 ** 2,
|
|
183
|
+
gb: 1e3 ** 3,
|
|
184
|
+
tb: 1e3 ** 4,
|
|
185
|
+
kib: 1024,
|
|
186
|
+
mib: 1024 ** 2,
|
|
187
|
+
gib: 1024 ** 3,
|
|
188
|
+
tib: 1024 ** 4
|
|
189
|
+
};
|
|
190
|
+
const factor = map[unit];
|
|
191
|
+
if (!factor || Number.isNaN(value)) return void 0;
|
|
192
|
+
return value * factor;
|
|
193
|
+
}
|
|
194
|
+
function formatBytes(bytes, digits = 1) {
|
|
195
|
+
if (!Number.isFinite(bytes)) return "N/A";
|
|
196
|
+
const abs = Math.abs(bytes);
|
|
197
|
+
if (abs >= GiB) return `${(bytes / GiB).toFixed(digits)} GB`;
|
|
198
|
+
if (abs >= MiB) return `${(bytes / MiB).toFixed(digits)} MB`;
|
|
199
|
+
if (abs >= 1024) return `${(bytes / 1024).toFixed(digits)} KB`;
|
|
200
|
+
return `${Math.round(bytes)} B`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/platform/macos/hardware.ts
|
|
204
|
+
function parseAppleSilicon(chip) {
|
|
205
|
+
const match = chip.match(/Apple\s+(M\d+)(?:\s+(Pro|Max|Ultra))?/i);
|
|
206
|
+
if (!match) return void 0;
|
|
207
|
+
const generation = match[1].toUpperCase().replace(/^M/, "M");
|
|
208
|
+
const variant = match[2] ?? "base";
|
|
209
|
+
return { generation, variant };
|
|
210
|
+
}
|
|
211
|
+
function parseVmStat(output, pageSize) {
|
|
212
|
+
const num = (label) => {
|
|
213
|
+
const row = output.match(new RegExp(`${label}:\\s+([\\d.]+)`));
|
|
214
|
+
if (!row) return void 0;
|
|
215
|
+
return Number(row[1]);
|
|
216
|
+
};
|
|
217
|
+
const free = num("Pages free") ?? 0;
|
|
218
|
+
const speculative = num("Pages speculative") ?? 0;
|
|
219
|
+
const inactive = num("Pages inactive") ?? 0;
|
|
220
|
+
const purgeable = num("Pages purgeable") ?? 0;
|
|
221
|
+
const availableBytes = (free + speculative + inactive + purgeable) * pageSize;
|
|
222
|
+
return { availableBytes };
|
|
223
|
+
}
|
|
224
|
+
function parseMemoryPressure(output) {
|
|
225
|
+
const match = output.match(/System-wide memory free percentage:\s+(\d+)/i);
|
|
226
|
+
if (match) {
|
|
227
|
+
const free = Number(match[1]);
|
|
228
|
+
if (free >= 50) return "Nominal";
|
|
229
|
+
if (free >= 25) return "Warn";
|
|
230
|
+
return "Critical";
|
|
231
|
+
}
|
|
232
|
+
if (/warn/i.test(output)) return "Warn";
|
|
233
|
+
if (/critical/i.test(output)) return "Critical";
|
|
234
|
+
if (/nominal/i.test(output)) return "Nominal";
|
|
235
|
+
return void 0;
|
|
236
|
+
}
|
|
237
|
+
function parseMacosSwapUsage(output) {
|
|
238
|
+
const match = output.match(/used\s*=\s*([\d.]+)\s*([KMGT])?/i);
|
|
239
|
+
if (!match) return void 0;
|
|
240
|
+
const value = Number(match[1]);
|
|
241
|
+
if (Number.isNaN(value)) return void 0;
|
|
242
|
+
const unit = (match[2] ?? "M").toUpperCase();
|
|
243
|
+
const factor = { K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }[unit] ?? 1024 ** 2;
|
|
244
|
+
return value * factor;
|
|
245
|
+
}
|
|
246
|
+
async function detectMacosHardware() {
|
|
247
|
+
const [brand, physical, logical, memsize, pagesize, perf, eff, hwModel, swVers, profiler, vmstat, pressure, swap] = await Promise.all([
|
|
248
|
+
runCommand("sysctl", ["-n", "machdep.cpu.brand_string"], { timeout: 5e3 }),
|
|
249
|
+
runCommand("sysctl", ["-n", "hw.physicalcpu"], { timeout: 5e3 }),
|
|
250
|
+
runCommand("sysctl", ["-n", "hw.logicalcpu"], { timeout: 5e3 }),
|
|
251
|
+
runCommand("sysctl", ["-n", "hw.memsize"], { timeout: 5e3 }),
|
|
252
|
+
runCommand("sysctl", ["-n", "hw.pagesize"], { timeout: 5e3 }),
|
|
253
|
+
runCommand("sysctl", ["-n", "hw.perflevel0.physicalcpu"], { timeout: 5e3 }),
|
|
254
|
+
runCommand("sysctl", ["-n", "hw.perflevel1.physicalcpu"], { timeout: 5e3 }),
|
|
255
|
+
runCommand("sysctl", ["-n", "hw.model"], { timeout: 5e3 }),
|
|
256
|
+
runCommand("sw_vers", ["-productVersion"], { timeout: 5e3 }),
|
|
257
|
+
runCommand("system_profiler", ["SPHardwareDataType"], { timeout: 15e3 }),
|
|
258
|
+
runCommand("vm_stat", [], { timeout: 5e3 }),
|
|
259
|
+
runCommand("memory_pressure", [], { timeout: 5e3 }),
|
|
260
|
+
runCommand("sysctl", ["-n", "vm.swapusage"], { timeout: 5e3 })
|
|
261
|
+
]);
|
|
262
|
+
const profilerText = profiler.stdout;
|
|
263
|
+
const chip = profilerText.match(/Chip:\s+(.+)/)?.[1]?.trim() ?? brand.stdout.trim() ?? os2.cpus()[0]?.model;
|
|
264
|
+
const modelName = profilerText.match(/Model Name:\s+(.+)/)?.[1]?.trim();
|
|
265
|
+
const memoryLine = profilerText.match(/Memory:\s+(.+)/)?.[1]?.trim();
|
|
266
|
+
const totalFromProfiler = memoryLine ? parseSizeToBytes(memoryLine.replace("GB", "GiB")) : void 0;
|
|
267
|
+
const totalBytes = totalFromProfiler ?? Number(memsize.stdout.trim()) ?? os2.totalmem();
|
|
268
|
+
const pageSize = Number(pagesize.stdout.trim()) || 16384;
|
|
269
|
+
const vm = parseVmStat(vmstat.stdout, pageSize);
|
|
270
|
+
const appleSilicon = chip ? parseAppleSilicon(chip) : void 0;
|
|
271
|
+
return {
|
|
272
|
+
os: "macos",
|
|
273
|
+
osVersion: swVers.stdout.trim() || void 0,
|
|
274
|
+
arch: os2.arch(),
|
|
275
|
+
machineModel: modelName ?? hwModel.stdout.trim() ?? void 0,
|
|
276
|
+
cpu: {
|
|
277
|
+
name: chip,
|
|
278
|
+
physicalCores: Number(physical.stdout.trim()) || os2.cpus().length,
|
|
279
|
+
logicalCores: Number(logical.stdout.trim()) || os2.cpus().length,
|
|
280
|
+
performanceCores: Number(perf.stdout.trim()) || void 0,
|
|
281
|
+
efficiencyCores: Number(eff.stdout.trim()) || void 0,
|
|
282
|
+
appleSilicon
|
|
283
|
+
},
|
|
284
|
+
gpu: {
|
|
285
|
+
name: chip,
|
|
286
|
+
metal: Boolean(appleSilicon) || os2.arch() === "arm64"
|
|
287
|
+
},
|
|
288
|
+
memory: {
|
|
289
|
+
totalBytes,
|
|
290
|
+
availableBytes: vm.availableBytes ?? os2.freemem(),
|
|
291
|
+
unified: Boolean(appleSilicon) || os2.arch() === "arm64",
|
|
292
|
+
swapUsedBytes: parseMacosSwapUsage(swap.stdout),
|
|
293
|
+
pressure: parseMemoryPressure(pressure.stdout + pressure.stderr)
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function parseMacosMemoryPressure(output) {
|
|
298
|
+
return parseMemoryPressure(output);
|
|
299
|
+
}
|
|
300
|
+
function parseMacosVmStat(output, pageSize) {
|
|
301
|
+
return parseVmStat(output, pageSize);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// src/platform/linux/hardware.ts
|
|
305
|
+
import os3 from "node:os";
|
|
306
|
+
async function detectLinuxHardware() {
|
|
307
|
+
return {
|
|
308
|
+
os: "linux",
|
|
309
|
+
arch: os3.arch(),
|
|
310
|
+
cpu: {
|
|
311
|
+
name: os3.cpus()[0]?.model,
|
|
312
|
+
logicalCores: os3.cpus().length
|
|
313
|
+
},
|
|
314
|
+
gpu: {},
|
|
315
|
+
memory: {
|
|
316
|
+
totalBytes: os3.totalmem(),
|
|
317
|
+
availableBytes: os3.freemem(),
|
|
318
|
+
unified: false
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/platform/windows/hardware.ts
|
|
324
|
+
import os4 from "node:os";
|
|
325
|
+
async function detectWindowsHardware() {
|
|
326
|
+
return {
|
|
327
|
+
os: "windows",
|
|
328
|
+
arch: os4.arch(),
|
|
329
|
+
cpu: {
|
|
330
|
+
name: os4.cpus()[0]?.model,
|
|
331
|
+
logicalCores: os4.cpus().length
|
|
332
|
+
},
|
|
333
|
+
gpu: {},
|
|
334
|
+
memory: {
|
|
335
|
+
totalBytes: os4.totalmem(),
|
|
336
|
+
availableBytes: os4.freemem(),
|
|
337
|
+
unified: false
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/hardware/detect.ts
|
|
343
|
+
function detectOs() {
|
|
344
|
+
switch (process.platform) {
|
|
345
|
+
case "darwin":
|
|
346
|
+
return "macos";
|
|
347
|
+
case "linux":
|
|
348
|
+
return "linux";
|
|
349
|
+
case "win32":
|
|
350
|
+
return "windows";
|
|
351
|
+
default:
|
|
352
|
+
return "unknown";
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async function detectHardware() {
|
|
356
|
+
const osName = detectOs();
|
|
357
|
+
if (osName === "macos") return detectMacosHardware();
|
|
358
|
+
if (osName === "linux") return detectLinuxHardware();
|
|
359
|
+
if (osName === "windows") return detectWindowsHardware();
|
|
360
|
+
return {
|
|
361
|
+
os: "unknown",
|
|
362
|
+
arch: os5.arch(),
|
|
363
|
+
cpu: { name: os5.cpus()[0]?.model, logicalCores: os5.cpus().length },
|
|
364
|
+
gpu: {},
|
|
365
|
+
memory: {
|
|
366
|
+
totalBytes: os5.totalmem(),
|
|
367
|
+
availableBytes: os5.freemem(),
|
|
368
|
+
unified: false
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// src/models/discovery.ts
|
|
374
|
+
import os7 from "node:os";
|
|
375
|
+
import path5 from "node:path";
|
|
376
|
+
|
|
377
|
+
// src/shared/which.ts
|
|
378
|
+
import { access } from "node:fs/promises";
|
|
379
|
+
import { constants } from "node:fs";
|
|
380
|
+
import path2 from "node:path";
|
|
381
|
+
async function commandExists(name) {
|
|
382
|
+
if (name.includes("/") || name.includes("\\")) {
|
|
383
|
+
try {
|
|
384
|
+
await access(name, constants.X_OK);
|
|
385
|
+
return name;
|
|
386
|
+
} catch {
|
|
387
|
+
return void 0;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
const pathEnv = process.env.PATH ?? "";
|
|
391
|
+
const parts = pathEnv.split(path2.delimiter);
|
|
392
|
+
for (const dir of parts) {
|
|
393
|
+
const candidate = path2.join(dir, name);
|
|
394
|
+
try {
|
|
395
|
+
await access(candidate, constants.X_OK);
|
|
396
|
+
return candidate;
|
|
397
|
+
} catch {
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return void 0;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/adapters/ollama/parse.ts
|
|
405
|
+
function parseOllamaList(output) {
|
|
406
|
+
const lines = output.split(/\r?\n/).map((l) => l.trimEnd()).filter(Boolean);
|
|
407
|
+
if (lines.length === 0) return [];
|
|
408
|
+
const header = lines[0] ?? "";
|
|
409
|
+
if (!/^NAME\s+/i.test(header)) {
|
|
410
|
+
return lines.flatMap((line) => parseDataLine(line));
|
|
411
|
+
}
|
|
412
|
+
return lines.slice(1).flatMap((line) => parseDataLine(line));
|
|
413
|
+
}
|
|
414
|
+
function parseDataLine(line) {
|
|
415
|
+
const parts = line.trim().split(/\s{2,}/);
|
|
416
|
+
if (parts.length < 2) return [];
|
|
417
|
+
const name = parts[0];
|
|
418
|
+
if (!name || /^NAME$/i.test(name)) return [];
|
|
419
|
+
const digest = parts[1];
|
|
420
|
+
const sizeRaw = parts[2];
|
|
421
|
+
const modified = parts.slice(3).join(" ") || void 0;
|
|
422
|
+
return [
|
|
423
|
+
{
|
|
424
|
+
id: name,
|
|
425
|
+
name,
|
|
426
|
+
source: "ollama",
|
|
427
|
+
digest,
|
|
428
|
+
sizeBytes: sizeRaw ? parseSizeToBytes(sizeRaw.replace(/([A-Z]+)$/i, " $1")) : void 0,
|
|
429
|
+
modifiedAt: modified
|
|
430
|
+
}
|
|
431
|
+
];
|
|
432
|
+
}
|
|
433
|
+
function parseOllamaModelfile(modelfile) {
|
|
434
|
+
const match = modelfile.match(/^FROM\s+(.+)$/m);
|
|
435
|
+
return { from: match?.[1]?.trim() };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// src/adapters/ollama/index.ts
|
|
439
|
+
var ollamaAdapter = {
|
|
440
|
+
id: "ollama",
|
|
441
|
+
label: "Ollama",
|
|
442
|
+
async detect() {
|
|
443
|
+
return Boolean(await commandExists("ollama"));
|
|
444
|
+
},
|
|
445
|
+
async version() {
|
|
446
|
+
const result = await runCommand("ollama", ["--version"], { timeout: 8e3 });
|
|
447
|
+
const text = `${result.stdout} ${result.stderr}`;
|
|
448
|
+
const match = text.match(/(\d+\.\d+\.\d+)/);
|
|
449
|
+
return match?.[1];
|
|
450
|
+
},
|
|
451
|
+
async listModels() {
|
|
452
|
+
if (!await this.detect()) return [];
|
|
453
|
+
const result = await runCommand("ollama", ["list"], { timeout: 15e3 });
|
|
454
|
+
const models = parseOllamaList(result.stdout);
|
|
455
|
+
const resolved = await Promise.all(
|
|
456
|
+
models.map(async (model) => {
|
|
457
|
+
const artifact = await resolveOllamaBlob(model.id);
|
|
458
|
+
return { ...model, artifactPath: artifact?.path };
|
|
459
|
+
})
|
|
460
|
+
);
|
|
461
|
+
return resolved;
|
|
462
|
+
},
|
|
463
|
+
async resolveModel(id) {
|
|
464
|
+
return resolveOllamaBlob(id);
|
|
465
|
+
},
|
|
466
|
+
async benchmarkCapabilities() {
|
|
467
|
+
return ["llama-bench"];
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
async function resolveOllamaBlob(id) {
|
|
471
|
+
const result = await runCommand("ollama", ["show", "--modelfile", id], { timeout: 15e3 });
|
|
472
|
+
if (result.exitCode !== 0) return void 0;
|
|
473
|
+
const { from } = parseOllamaModelfile(result.stdout);
|
|
474
|
+
if (!from) return void 0;
|
|
475
|
+
const format = from.endsWith(".gguf") || from.includes("sha256-") ? "gguf" : "unknown";
|
|
476
|
+
return { id, path: from, format };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// src/adapters/lmstudio/index.ts
|
|
480
|
+
import os6 from "node:os";
|
|
481
|
+
import path4 from "node:path";
|
|
482
|
+
import { access as access2 } from "node:fs/promises";
|
|
483
|
+
|
|
484
|
+
// src/adapters/llamacpp/index.ts
|
|
485
|
+
import { readdir, stat } from "node:fs/promises";
|
|
486
|
+
import path3 from "node:path";
|
|
487
|
+
var BINARIES = ["llama-bench", "llama-cli", "llama-server"];
|
|
488
|
+
async function detectLlamaCpp() {
|
|
489
|
+
const entries = await Promise.all(BINARIES.map(async (bin) => [bin, Boolean(await commandExists(bin))]));
|
|
490
|
+
return Object.fromEntries(entries);
|
|
491
|
+
}
|
|
492
|
+
async function scanGgufDirectories(directories, options = {}) {
|
|
493
|
+
const maxDepth = options.maxDepth ?? 1;
|
|
494
|
+
const source = options.source ?? "gguf";
|
|
495
|
+
const models = [];
|
|
496
|
+
for (const dir of directories) {
|
|
497
|
+
await walk(dir, 0, maxDepth, source, models);
|
|
498
|
+
}
|
|
499
|
+
return models;
|
|
500
|
+
}
|
|
501
|
+
async function walk(dir, depth, maxDepth, source, models) {
|
|
502
|
+
let entries = [];
|
|
503
|
+
try {
|
|
504
|
+
entries = await readdir(dir);
|
|
505
|
+
} catch {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
for (const entry of entries) {
|
|
509
|
+
const full = path3.join(dir, entry);
|
|
510
|
+
try {
|
|
511
|
+
const info = await stat(full);
|
|
512
|
+
if (info.isFile() && entry.toLowerCase().endsWith(".gguf")) {
|
|
513
|
+
models.push({
|
|
514
|
+
id: entry.replace(/\.gguf$/i, ""),
|
|
515
|
+
name: entry,
|
|
516
|
+
source,
|
|
517
|
+
sizeBytes: info.size,
|
|
518
|
+
artifactPath: full
|
|
519
|
+
});
|
|
520
|
+
} else if (info.isDirectory() && depth < maxDepth && !entry.startsWith(".")) {
|
|
521
|
+
await walk(full, depth + 1, maxDepth, source, models);
|
|
522
|
+
}
|
|
523
|
+
} catch {
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
var llamaCppAdapter = {
|
|
529
|
+
id: "llamacpp",
|
|
530
|
+
label: "llama.cpp",
|
|
531
|
+
async detect() {
|
|
532
|
+
const found = await detectLlamaCpp();
|
|
533
|
+
return found["llama-cli"] || found["llama-server"] || found["llama-bench"];
|
|
534
|
+
},
|
|
535
|
+
async version() {
|
|
536
|
+
return void 0;
|
|
537
|
+
},
|
|
538
|
+
async listModels() {
|
|
539
|
+
return [];
|
|
540
|
+
},
|
|
541
|
+
async resolveModel(id) {
|
|
542
|
+
if (!id.endsWith(".gguf")) return void 0;
|
|
543
|
+
return { id, path: id, format: "gguf" };
|
|
544
|
+
},
|
|
545
|
+
async benchmarkCapabilities() {
|
|
546
|
+
const found = await detectLlamaCpp();
|
|
547
|
+
return found["llama-bench"] ? ["llama-bench"] : ["none"];
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
// src/adapters/lmstudio/index.ts
|
|
552
|
+
function lmStudioCandidateDirs() {
|
|
553
|
+
const home = os6.homedir();
|
|
554
|
+
return [
|
|
555
|
+
path4.join(home, ".lmstudio", "models"),
|
|
556
|
+
path4.join(home, ".cache", "lm-studio", "models"),
|
|
557
|
+
path4.join(home, "Library", "Application Support", "LM Studio", "models")
|
|
558
|
+
];
|
|
559
|
+
}
|
|
560
|
+
async function existingDirs() {
|
|
561
|
+
const found = [];
|
|
562
|
+
for (const dir of lmStudioCandidateDirs()) {
|
|
563
|
+
try {
|
|
564
|
+
await access2(dir);
|
|
565
|
+
found.push(dir);
|
|
566
|
+
} catch {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return found;
|
|
571
|
+
}
|
|
572
|
+
var lmStudioAdapter = {
|
|
573
|
+
id: "lmstudio",
|
|
574
|
+
label: "LM Studio",
|
|
575
|
+
async detect() {
|
|
576
|
+
return (await existingDirs()).length > 0;
|
|
577
|
+
},
|
|
578
|
+
async version() {
|
|
579
|
+
return void 0;
|
|
580
|
+
},
|
|
581
|
+
async listModels() {
|
|
582
|
+
const dirs = await existingDirs();
|
|
583
|
+
return scanGgufDirectories(dirs, { maxDepth: 3, source: "lmstudio" });
|
|
584
|
+
},
|
|
585
|
+
async resolveModel(id) {
|
|
586
|
+
const models = await this.listModels();
|
|
587
|
+
const hit = models.find((model) => model.id === id || model.name === id);
|
|
588
|
+
if (!hit?.artifactPath) return void 0;
|
|
589
|
+
return { id: hit.id, path: hit.artifactPath, format: "gguf" };
|
|
590
|
+
},
|
|
591
|
+
async benchmarkCapabilities() {
|
|
592
|
+
return ["llama-bench"];
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
// src/models/discovery.ts
|
|
597
|
+
function defaultGgufDirs() {
|
|
598
|
+
const home = os7.homedir();
|
|
599
|
+
return [
|
|
600
|
+
path5.join(home, "Models"),
|
|
601
|
+
path5.join(home, "models"),
|
|
602
|
+
path5.join(home, ".cache", "llama.cpp"),
|
|
603
|
+
path5.join(home, ".local", "share", "llama.cpp")
|
|
604
|
+
];
|
|
605
|
+
}
|
|
606
|
+
async function discoverLocalModels() {
|
|
607
|
+
const config = await loadConfig();
|
|
608
|
+
const ollama = await ollamaAdapter.listModels();
|
|
609
|
+
const lmstudio = await lmStudioAdapter.listModels();
|
|
610
|
+
const gguf = await scanGgufDirectories([...defaultGgufDirs(), ...config.modelPaths], { maxDepth: 1 });
|
|
611
|
+
const seen = /* @__PURE__ */ new Set();
|
|
612
|
+
const merged = [];
|
|
613
|
+
for (const model of [...ollama, ...lmstudio, ...gguf]) {
|
|
614
|
+
const key = `${model.source}:${model.id}:${model.artifactPath ?? ""}`;
|
|
615
|
+
if (seen.has(key)) continue;
|
|
616
|
+
seen.add(key);
|
|
617
|
+
merged.push(model);
|
|
618
|
+
}
|
|
619
|
+
return merged;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// src/adapters/mlx/index.ts
|
|
623
|
+
var mlxAdapter = {
|
|
624
|
+
id: "mlx",
|
|
625
|
+
label: "MLX",
|
|
626
|
+
async detect() {
|
|
627
|
+
return Boolean(await commandExists("mlx_lm"));
|
|
628
|
+
},
|
|
629
|
+
async version() {
|
|
630
|
+
return void 0;
|
|
631
|
+
},
|
|
632
|
+
async listModels() {
|
|
633
|
+
return [];
|
|
634
|
+
},
|
|
635
|
+
async resolveModel() {
|
|
636
|
+
return void 0;
|
|
637
|
+
},
|
|
638
|
+
async benchmarkCapabilities() {
|
|
639
|
+
return ["none"];
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
// src/runtimes/registry.ts
|
|
644
|
+
var runtimes = [ollamaAdapter, llamaCppAdapter, lmStudioAdapter, mlxAdapter];
|
|
645
|
+
async function detectRuntimes() {
|
|
646
|
+
const llamaBench = Boolean(await commandExists("llama-bench"));
|
|
647
|
+
const statuses = [];
|
|
648
|
+
for (const runtime of runtimes) {
|
|
649
|
+
const detected = await runtime.detect();
|
|
650
|
+
const version = detected ? await runtime.version() : void 0;
|
|
651
|
+
statuses.push({ id: runtime.id, label: runtime.label, detected, version });
|
|
652
|
+
}
|
|
653
|
+
statuses.push({
|
|
654
|
+
id: "llama-bench",
|
|
655
|
+
label: "llama-bench",
|
|
656
|
+
detected: llamaBench
|
|
657
|
+
});
|
|
658
|
+
return statuses;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// src/adapters/canirun/schema.ts
|
|
662
|
+
import { z as z2 } from "zod";
|
|
663
|
+
var catalogModelSchema = z2.object({
|
|
664
|
+
id: z2.string(),
|
|
665
|
+
name: z2.string(),
|
|
666
|
+
provider: z2.string().optional(),
|
|
667
|
+
family: z2.string().optional(),
|
|
668
|
+
params: z2.string().optional(),
|
|
669
|
+
paramsBillions: z2.number().optional(),
|
|
670
|
+
architecture: z2.string().optional(),
|
|
671
|
+
useCase: z2.array(z2.string()).optional(),
|
|
672
|
+
url: z2.string().optional()
|
|
673
|
+
});
|
|
674
|
+
var catalogResponseSchema = z2.object({
|
|
675
|
+
count: z2.number().optional(),
|
|
676
|
+
models: z2.array(catalogModelSchema)
|
|
677
|
+
});
|
|
678
|
+
var compatibilityResponseSchema = z2.object({
|
|
679
|
+
compatible: z2.boolean().optional(),
|
|
680
|
+
status: z2.string().optional(),
|
|
681
|
+
grade: z2.enum(["S", "A", "B", "C", "D", "F"]),
|
|
682
|
+
score: z2.number().optional(),
|
|
683
|
+
modelId: z2.string().optional(),
|
|
684
|
+
quantization: z2.string().optional(),
|
|
685
|
+
recommendedQuantization: z2.string().optional(),
|
|
686
|
+
estimated: z2.object({
|
|
687
|
+
tokensPerSecond: z2.number().optional(),
|
|
688
|
+
modelSizeGb: z2.number().optional(),
|
|
689
|
+
vramRequiredGb: z2.number().optional(),
|
|
690
|
+
ramRequiredGb: z2.number().optional(),
|
|
691
|
+
memoryHeadroomGb: z2.number().optional()
|
|
692
|
+
}).optional(),
|
|
693
|
+
notes: z2.array(z2.string()).optional()
|
|
694
|
+
});
|
|
695
|
+
var recommendItemSchema = z2.object({
|
|
696
|
+
modelId: z2.string().optional(),
|
|
697
|
+
id: z2.string().optional(),
|
|
698
|
+
name: z2.string().optional(),
|
|
699
|
+
grade: z2.enum(["S", "A", "B", "C", "D", "F"]).optional(),
|
|
700
|
+
quantization: z2.string().optional(),
|
|
701
|
+
useCase: z2.array(z2.string()).optional(),
|
|
702
|
+
estimated: z2.object({
|
|
703
|
+
tokensPerSecond: z2.number().optional()
|
|
704
|
+
}).optional()
|
|
705
|
+
}).passthrough();
|
|
706
|
+
var recommendResponseSchema = z2.object({
|
|
707
|
+
recommendations: z2.array(recommendItemSchema).optional()
|
|
708
|
+
}).passthrough();
|
|
709
|
+
|
|
710
|
+
// src/adapters/canirun/index.ts
|
|
711
|
+
var BASE = "https://canirun.ai";
|
|
712
|
+
function hardwarePayload(hardware) {
|
|
713
|
+
return {
|
|
714
|
+
cpu: {
|
|
715
|
+
name: hardware.cpu.name,
|
|
716
|
+
cores: hardware.cpu.physicalCores,
|
|
717
|
+
threads: hardware.cpu.logicalCores
|
|
718
|
+
},
|
|
719
|
+
ramGb: Number(bytesToGiB(hardware.memory.totalBytes).toFixed(1)),
|
|
720
|
+
gpu: hardware.gpu.name ? { name: hardware.gpu.name } : void 0
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
async function postJson(path7, body, timeoutMs) {
|
|
724
|
+
const controller = new AbortController();
|
|
725
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
726
|
+
try {
|
|
727
|
+
const response = await fetch(`${BASE}${path7}`, {
|
|
728
|
+
method: "POST",
|
|
729
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
730
|
+
body: JSON.stringify(body),
|
|
731
|
+
signal: controller.signal
|
|
732
|
+
});
|
|
733
|
+
if (!response.ok) throw new Error(`CanIRun ${path7} ${response.status}`);
|
|
734
|
+
return await response.json();
|
|
735
|
+
} finally {
|
|
736
|
+
clearTimeout(timer);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
async function getJson(path7, timeoutMs) {
|
|
740
|
+
const controller = new AbortController();
|
|
741
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
742
|
+
try {
|
|
743
|
+
const response = await fetch(`${BASE}${path7}`, {
|
|
744
|
+
headers: { accept: "application/json" },
|
|
745
|
+
signal: controller.signal
|
|
746
|
+
});
|
|
747
|
+
if (!response.ok) throw new Error(`CanIRun ${path7} ${response.status}`);
|
|
748
|
+
return await response.json();
|
|
749
|
+
} finally {
|
|
750
|
+
clearTimeout(timer);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
async function fetchCatalog() {
|
|
754
|
+
const json = await getJson("/api/models", 15e3);
|
|
755
|
+
const parsed = catalogResponseSchema.parse(json);
|
|
756
|
+
return parsed.models;
|
|
757
|
+
}
|
|
758
|
+
async function fetchCompatibility(hardware, modelId) {
|
|
759
|
+
const json = await postJson(
|
|
760
|
+
"/api/compatibility",
|
|
761
|
+
{ hardware: hardwarePayload(hardware), modelId },
|
|
762
|
+
15e3
|
|
763
|
+
);
|
|
764
|
+
const parsed = compatibilityResponseSchema.parse(json);
|
|
765
|
+
return {
|
|
766
|
+
modelId: parsed.modelId ?? modelId,
|
|
767
|
+
source: "estimated",
|
|
768
|
+
grade: parsed.grade,
|
|
769
|
+
status: parsed.status,
|
|
770
|
+
recommendedQuantization: parsed.recommendedQuantization ?? parsed.quantization,
|
|
771
|
+
estimatedTokensPerSecond: parsed.estimated?.tokensPerSecond,
|
|
772
|
+
estimatedRamGb: parsed.estimated?.ramRequiredGb ?? parsed.estimated?.vramRequiredGb,
|
|
773
|
+
notes: parsed.notes
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
async function fetchRecommendations(hardware, useCase, limit = 5) {
|
|
777
|
+
const json = await postJson(
|
|
778
|
+
"/api/recommend",
|
|
779
|
+
{ hardware: hardwarePayload(hardware), useCase, limit },
|
|
780
|
+
15e3
|
|
781
|
+
);
|
|
782
|
+
const parsed = recommendResponseSchema.parse(json);
|
|
783
|
+
const items = parsed.recommendations ?? [];
|
|
784
|
+
return items.map((item) => ({
|
|
785
|
+
useCase: useCase ?? "general",
|
|
786
|
+
model: {
|
|
787
|
+
id: String(item.modelId ?? item.id ?? "unknown"),
|
|
788
|
+
name: String(item.name ?? item.modelId ?? item.id ?? "unknown")
|
|
789
|
+
},
|
|
790
|
+
grade: item.grade ?? "C",
|
|
791
|
+
quantization: item.quantization,
|
|
792
|
+
estimatedTokensPerSecond: item.estimated?.tokensPerSecond
|
|
793
|
+
}));
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// src/models/match.ts
|
|
797
|
+
function normalizeModelId(id) {
|
|
798
|
+
return id.toLowerCase().replace(/[:/]/g, "-").replace(/[^a-z0-9.-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
799
|
+
}
|
|
800
|
+
function idsLikelyMatch(a, b) {
|
|
801
|
+
const na2 = normalizeModelId(a);
|
|
802
|
+
const nb = normalizeModelId(b);
|
|
803
|
+
return na2 === nb || na2.includes(nb) || nb.includes(na2);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// src/storage/cache.ts
|
|
807
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
808
|
+
async function saveCatalogCache(models) {
|
|
809
|
+
await ensureStorage();
|
|
810
|
+
const payload = { savedAt: (/* @__PURE__ */ new Date()).toISOString(), models };
|
|
811
|
+
await writeFile2(canirunCachePath(), `${JSON.stringify(payload, null, 2)}
|
|
812
|
+
`, "utf8");
|
|
813
|
+
}
|
|
814
|
+
async function loadCatalogCache() {
|
|
815
|
+
try {
|
|
816
|
+
const raw = await readFile2(canirunCachePath(), "utf8");
|
|
817
|
+
const parsed = JSON.parse(raw);
|
|
818
|
+
return parsed.models;
|
|
819
|
+
} catch {
|
|
820
|
+
return void 0;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// src/i18n/en.ts
|
|
825
|
+
var en = {
|
|
826
|
+
appTitle: "PUDU-AI",
|
|
827
|
+
appSubtitle: "Local AI Hardware & Benchmark Lab",
|
|
828
|
+
aboutDashboard: "Opens the live lab: your machine, installed models, and estimates. Use it to see what can run here before you benchmark or launch a tool.",
|
|
829
|
+
aboutHardware: "Detects CPU, GPU, and Unified Memory/RAM. Use it to know what this computer can host before pulling a model.",
|
|
830
|
+
aboutModels: "Lists installed models vs catalog fits. FIT/EST. SPEED are estimated (CanIRun.ai by midudev); MEASURED is llama-bench history. Use it to separate \u201Calready here\u201D from \u201Cmight run\u201D.",
|
|
831
|
+
aboutRecommend: "Suggests models by use case for this hardware. Estimates only, unless you already measured. Use it to pick a size class, not a quality score.",
|
|
832
|
+
aboutTasks: "Asks what you want (code, video, image, transcription, chat) and returns small OpenCode-style harnesses per model, in this language. Use it to try a model on one concrete job.",
|
|
833
|
+
aboutBenchmark: "Runs llama-bench on a local GGUF and records measured t/s plus system telemetry. Use it when you need real numbers, not estimates.",
|
|
834
|
+
aboutCompare: "Compares saved measured runs (speed, memory, power). Quality is never inferred from speed. Use it after two or more benchmarks.",
|
|
835
|
+
aboutHistory: "Shows local benchmark files in ~/.pudu-ai/benchmarks. Use it to replay what this machine actually did.",
|
|
836
|
+
aboutDoctor: "Checks Node, Apple Silicon/Metal, Ollama, llama-bench, and the CanIRun API. Use it to see what is missing before a bench or launch.",
|
|
837
|
+
aboutReport: "Writes the latest measured run as Markdown. Use it to paste results into GitHub or notes.",
|
|
838
|
+
aboutLaunch: "Explains Ollama integrations (OpenCode, OpenClaw, Hermes, Claude Code). Pull/launch only with --yes if the model grade is S\u2013B and speed is enough. Use it to wire a coding agent only when this hardware can carry it.",
|
|
839
|
+
aboutAddPath: "Adds a GGUF folder to scan (not the whole disk). Use it so llama-bench can find models outside Ollama.",
|
|
840
|
+
nav: "[B] Benchmark [M] Models [R] Recommend [T] Tasks [H] Hardware [C] Compare [L] History [Q] Quit",
|
|
841
|
+
machine: "MACHINE",
|
|
842
|
+
runtimes: "LOCAL AI RUNTIMES",
|
|
843
|
+
installedModels: "INSTALLED MODELS",
|
|
844
|
+
compatible: "COMPATIBLE (catalog, estimated)",
|
|
845
|
+
recommended: "RECOMMENDED FOR THIS MACHINE",
|
|
846
|
+
recommendedHint: "Estimated values come from CanIRun.ai by midudev (canirun.ai \xB7 midu.dev) or a local fallback \u2014 not measured",
|
|
847
|
+
credits: "Measured: llama-bench + OS telemetry. Estimated: CanIRun.ai by midudev (https://canirun.ai, https://github.com/midudev/canirun.ai, https://midu.dev). Labelled separately; never mixed.",
|
|
848
|
+
noModels: "No local models detected",
|
|
849
|
+
notTested: "Not tested",
|
|
850
|
+
detected: "detected",
|
|
851
|
+
notDetected: "not detected",
|
|
852
|
+
unknownMachine: "Unknown machine",
|
|
853
|
+
cpuNa: "CPU N/A",
|
|
854
|
+
selectBenchmark: "Select model to benchmark",
|
|
855
|
+
enterToRun: "Enter to run llama-bench \xB7 Esc back",
|
|
856
|
+
noGguf: "No GGUF-resolvable models to benchmark.",
|
|
857
|
+
performance: "PERFORMANCE",
|
|
858
|
+
system: "SYSTEM",
|
|
859
|
+
powerThermals: "POWER / THERMALS",
|
|
860
|
+
elapsed: "Elapsed",
|
|
861
|
+
measuredAfter: "Prompt / generation t/s appear after llama-bench finishes (measured).",
|
|
862
|
+
permissionsHint: "GPU % and power need extra permissions; shown as N/A when not measured.",
|
|
863
|
+
cancelHint: "Ctrl+C / Q cancels llama-bench and telemetry",
|
|
864
|
+
saved: "Saved",
|
|
865
|
+
hardwareHint: "Power, GPU %, and thermals require extra OS permissions and show N/A when unavailable.",
|
|
866
|
+
modelsHint: "FIT and EST. SPEED are estimated. MEASURED comes from local llama-bench history.",
|
|
867
|
+
historyEmpty: "No benchmark history in ~/.pudu-ai/benchmarks",
|
|
868
|
+
compareNeedTwo: "Need at least two measured benchmarks to compare.",
|
|
869
|
+
compareTitle: "LOCAL MODEL BENCHMARKS (measured)",
|
|
870
|
+
winner: "Winner",
|
|
871
|
+
qualityNote: "Quality (not derived from speed; see catalog metadata)",
|
|
872
|
+
doctorTitle: "Pudu-AI Doctor",
|
|
873
|
+
doctorReady: "Ready to benchmark {count} installed models.",
|
|
874
|
+
doctorNoBench: "llama-bench unavailable. Install llama.cpp, e.g. `brew install llama.cpp`. Pudu-AI will not install native dependencies.",
|
|
875
|
+
loading: "Inspecting hardware, runtimes, and local models\u2026",
|
|
876
|
+
addedPath: "Added model path {path}",
|
|
877
|
+
modelNotFound: "Model not found: {id}",
|
|
878
|
+
noHistory: "No benchmark history.",
|
|
879
|
+
jsonNeedModel: "Pass a model id for JSON mode, e.g. npx pudu-ai benchmark qwen3:8b --json",
|
|
880
|
+
useCaseCoding: "Coding",
|
|
881
|
+
useCaseGeneral: "General",
|
|
882
|
+
useCaseReasoning: "Reasoning",
|
|
883
|
+
useCaseLightweight: "Lightweight",
|
|
884
|
+
estimated: "Estimated",
|
|
885
|
+
measured: "Measured",
|
|
886
|
+
scoreLabel: "Pudu-AI Score",
|
|
887
|
+
reportTitle: "Pudu-AI Benchmark",
|
|
888
|
+
benchmarkTitle: "Pudu-AI Benchmark",
|
|
889
|
+
tasksTitle: "TASK HARNESSES",
|
|
890
|
+
tasksHint: "Small OpenCode-style tasks in your selected language. Installed models first. Catalog matches are Estimated (CanIRun.ai by midudev).",
|
|
891
|
+
tasksKinds: "Work types",
|
|
892
|
+
tasksScope: "Scope",
|
|
893
|
+
tasksPriority: "Priority",
|
|
894
|
+
tasksInstalled: "installed",
|
|
895
|
+
tasksNotInstalled: "not installed",
|
|
896
|
+
tasksEmpty: "No matching models for those work types on this machine.",
|
|
897
|
+
tasksQ1: "What do you want to do? (space to toggle, enter next)",
|
|
898
|
+
tasksQ2: "Use only installed models, or also catalog estimates?",
|
|
899
|
+
tasksQ3: "Priority?",
|
|
900
|
+
tasksOptCode: "Code",
|
|
901
|
+
tasksOptVideo: "Video",
|
|
902
|
+
tasksOptImage: "Image",
|
|
903
|
+
tasksOptTranscription: "Transcription",
|
|
904
|
+
tasksOptChat: "Tasks / chat",
|
|
905
|
+
tasksOptInstalled: "Installed only",
|
|
906
|
+
tasksOptAll: "Installed + catalog (estimated)",
|
|
907
|
+
tasksOptSpeed: "Speed",
|
|
908
|
+
tasksOptBalanced: "Balanced",
|
|
909
|
+
tasksOptQuality: "Quality",
|
|
910
|
+
taskCodeReviewTitle: "Review a local diff",
|
|
911
|
+
taskCodeReviewPrompt: "In OpenCode: open a git diff and ask this model to list bugs, missing tests, and a 5-line summary. Do not apply patches unless you ask.",
|
|
912
|
+
taskCodeTestsTitle: "Write one failing test",
|
|
913
|
+
taskCodeTestsPrompt: "Pick one function. Ask the model for a single Vitest/Jest test that fails on the current bug. Do not generate extra files.",
|
|
914
|
+
taskChatPlanTitle: "Turn a goal into a task list",
|
|
915
|
+
taskChatPlanPrompt: "Give the model one goal. Require a numbered plan of 5 harness-sized tasks. No implementation yet.",
|
|
916
|
+
taskChatAgentTitle: "Agent loop on one file",
|
|
917
|
+
taskChatAgentPrompt: "Point the model at one file and one acceptance check. It may edit only that file, then stop.",
|
|
918
|
+
taskImageCaptionTitle: "Caption a local image",
|
|
919
|
+
taskImageCaptionPrompt: "If this is a vision/image model, caption one local image in your UI language. If it is text-only, skip and say N/A.",
|
|
920
|
+
taskImageBriefTitle: "Image generation brief",
|
|
921
|
+
taskImageBriefPrompt: "Ask for a 6-line prompt brief (subject, lens, light, negative prompt) for one still. Do not invent that the model rendered the image unless it did.",
|
|
922
|
+
taskVideoBoardTitle: "8-shot storyboard",
|
|
923
|
+
taskVideoBoardPrompt: "Ask for 8 shots: duration, camera, action, on-screen text. Keep it local. Do not claim a render exists.",
|
|
924
|
+
taskVideoShotTitle: "Shot list from a script",
|
|
925
|
+
taskVideoShotPrompt: "Paste a short script. Ask for a shot list and estimated seconds. Text-only models may plan; they cannot encode video.",
|
|
926
|
+
taskTranscribeCleanTitle: "Clean a transcript",
|
|
927
|
+
taskTranscribeCleanPrompt: "Paste noisy speech-to-text. Ask the model to punctuate, remove fillers, and keep speaker labels. This is not ASR; it edits text.",
|
|
928
|
+
taskTranscribeActionsTitle: "Actions from a transcript",
|
|
929
|
+
taskTranscribeActionsPrompt: "From a meeting transcript, extract owners, due dates, and open questions as a checklist in your UI language.",
|
|
930
|
+
launchTitle: "OLLAMA INTEGRATIONS",
|
|
931
|
+
launchHint: "Explain-only by default. Pull/install/launch run only with --yes and only if a recommended model fits this hardware (grade S\u2013B; measured speed when available). Docs: OpenCode, OpenClaw, Hermes, Claude Code via Ollama.",
|
|
932
|
+
launchNeedOllama: "Ollama is not detected. Install Ollama first. Pudu-AI will not install it.",
|
|
933
|
+
launchNoModel: "No coding/chat model on this machine meets the hardware bar for this integration.",
|
|
934
|
+
launchGradeFail: "Grade {grade} is below the allowed set ({allowed}).",
|
|
935
|
+
launchSlowFail: "Measured {tps} t/s is below the {min} t/s agent floor.",
|
|
936
|
+
launchEstimateWeak: "Only a weak estimate is available; will not pull or launch.",
|
|
937
|
+
launchOk: "Eligible on this hardware for recommended tasks.",
|
|
938
|
+
launchModel: "Model",
|
|
939
|
+
launchRunHint: "To pull (if needed) and launch: npx pudu-ai launch {tool} --yes",
|
|
940
|
+
launchBlocked: "Blocked. No pull, install, or launch.",
|
|
941
|
+
launchNeedTool: "Pass a tool: opencode | openclaw | hermes | claude",
|
|
942
|
+
launchUnknown: "Unknown integration.",
|
|
943
|
+
launchPulling: "Pulling {model} with ollama pull\u2026",
|
|
944
|
+
launchPullFail: "ollama pull failed.",
|
|
945
|
+
launchExecFail: "ollama launch failed.",
|
|
946
|
+
help: `Pudu-AI \u2014 local AI hardware & benchmark lab
|
|
947
|
+
|
|
948
|
+
Usage:
|
|
949
|
+
npx pudu-ai
|
|
950
|
+
npx pudu-ai hardware
|
|
951
|
+
npx pudu-ai models
|
|
952
|
+
npx pudu-ai models add-path ~/Models
|
|
953
|
+
npx pudu-ai recommend
|
|
954
|
+
npx pudu-ai tasks
|
|
955
|
+
npx pudu-ai tasks --for code,image --scope all --priority speed
|
|
956
|
+
npx pudu-ai benchmark [model]
|
|
957
|
+
npx pudu-ai compare
|
|
958
|
+
npx pudu-ai history
|
|
959
|
+
npx pudu-ai doctor
|
|
960
|
+
npx pudu-ai report --markdown
|
|
961
|
+
npx pudu-ai launch
|
|
962
|
+
npx pudu-ai launch opencode
|
|
963
|
+
npx pudu-ai launch opencode --yes
|
|
964
|
+
|
|
965
|
+
Flags:
|
|
966
|
+
--json Machine-readable JSON (no TUI)
|
|
967
|
+
--csv CSV output
|
|
968
|
+
--no-network Skip CanIRun API; use cache/local estimates
|
|
969
|
+
--no-color Disable ANSI color
|
|
970
|
+
--verbose Debug logs to stderr
|
|
971
|
+
--preset quick | standard | stress
|
|
972
|
+
--lang en | es
|
|
973
|
+
--for code,video,image,transcription,chat
|
|
974
|
+
--scope installed | all
|
|
975
|
+
--priority speed | balanced | quality
|
|
976
|
+
--yes Execute pull/launch only if the model is eligible
|
|
977
|
+
|
|
978
|
+
Credits:
|
|
979
|
+
Measured values come from llama-bench and OS telemetry.
|
|
980
|
+
Estimated values come from CanIRun.ai by midudev
|
|
981
|
+
(https://canirun.ai \xB7 https://github.com/midudev/canirun.ai \xB7 https://midu.dev)
|
|
982
|
+
or a local fallback, and are labelled as such.
|
|
983
|
+
`
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
// src/i18n/es.ts
|
|
987
|
+
var es = {
|
|
988
|
+
appTitle: "PUDU-AI",
|
|
989
|
+
appSubtitle: "Laboratorio local de hardware y benchmarks de IA",
|
|
990
|
+
aboutDashboard: "Abre el laboratorio en vivo: equipo, modelos instalados y estimaciones. Sirve para ver qu\xE9 puede correr aqu\xED antes de medir o lanzar una herramienta.",
|
|
991
|
+
aboutHardware: "Detecta CPU, GPU y memoria unificada/RAM. Sirve para saber qu\xE9 puede hospedar este equipo antes de descargar un modelo.",
|
|
992
|
+
aboutModels: "Lista instalados frente al cat\xE1logo. FIT/EST. SPEED son estimados (CanIRun.ai de midudev); MEASURED es historial de llama-bench. Sirve para separar \u201Cya est\xE1\u201D de \u201Cpodr\xEDa correr\u201D.",
|
|
993
|
+
aboutRecommend: "Sugiere modelos por caso de uso para este hardware. Solo estimaciones, salvo que ya hayas medido. Sirve para elegir un tama\xF1o, no una nota de calidad.",
|
|
994
|
+
aboutTasks: "Pregunta qu\xE9 quieres (c\xF3digo, v\xEDdeo, imagen, transcripci\xF3n, chat) y devuelve harnesses peque\xF1os estilo OpenCode por modelo, en este idioma. Sirve para probar un modelo en una tarea concreta.",
|
|
995
|
+
aboutBenchmark: "Ejecuta llama-bench sobre un GGUF local y guarda t/s medidos m\xE1s telemetr\xEDa. Sirve cuando necesitas n\xFAmeros reales, no estimaciones.",
|
|
996
|
+
aboutCompare: "Compara corridas medidas (velocidad, memoria, potencia). La calidad no se infiere de la velocidad. Sirve despu\xE9s de dos o m\xE1s benchmarks.",
|
|
997
|
+
aboutHistory: "Muestra los JSON locales en ~/.pudu-ai/benchmarks. Sirve para ver qu\xE9 hizo realmente esta m\xE1quina.",
|
|
998
|
+
aboutDoctor: "Revisa Node, Apple Silicon/Metal, Ollama, llama-bench y la API de CanIRun. Sirve para ver qu\xE9 falta antes de un bench o un launch.",
|
|
999
|
+
aboutReport: "Exporta la \xFAltima corrida medida en Markdown. Sirve para pegar resultados en GitHub o notas.",
|
|
1000
|
+
aboutLaunch: "Explica las integraciones de Ollama (OpenCode, OpenClaw, Hermes, Claude Code). Pull/launch solo con --yes si la nota es S\u2013B y hay velocidad suficiente. Sirve para conectar un agente de c\xF3digo solo cuando el hardware lo aguanta.",
|
|
1001
|
+
aboutAddPath: "A\xF1ade una carpeta GGUF a escanear (no todo el disco). Sirve para que llama-bench encuentre modelos fuera de Ollama.",
|
|
1002
|
+
nav: "[B] Benchmark [M] Modelos [R] Recomendaciones [T] Tareas [H] Hardware [C] Comparar [L] Historial [Q] Salir",
|
|
1003
|
+
machine: "EQUIPO",
|
|
1004
|
+
runtimes: "RUNTIMES DE IA LOCAL",
|
|
1005
|
+
installedModels: "MODELOS INSTALADOS",
|
|
1006
|
+
compatible: "COMPATIBLES (cat\xE1logo, estimado)",
|
|
1007
|
+
recommended: "RECOMENDADOS PARA ESTE EQUIPO",
|
|
1008
|
+
recommendedHint: "Los valores estimados vienen de CanIRun.ai de midudev (canirun.ai \xB7 midu.dev) o de un fallback local \u2014 no son mediciones",
|
|
1009
|
+
credits: "Medido: llama-bench + telemetr\xEDa del SO. Estimado: CanIRun.ai de midudev (https://canirun.ai, https://github.com/midudev/canirun.ai, https://midu.dev). Se etiquetan por separado; nunca se mezclan.",
|
|
1010
|
+
noModels: "No se detectaron modelos locales",
|
|
1011
|
+
notTested: "Sin probar",
|
|
1012
|
+
detected: "detectado",
|
|
1013
|
+
notDetected: "no detectado",
|
|
1014
|
+
unknownMachine: "Equipo desconocido",
|
|
1015
|
+
cpuNa: "CPU N/D",
|
|
1016
|
+
selectBenchmark: "Selecciona un modelo para medir",
|
|
1017
|
+
enterToRun: "Enter para ejecutar llama-bench \xB7 Esc atr\xE1s",
|
|
1018
|
+
noGguf: "No hay modelos GGUF resolubles para medir.",
|
|
1019
|
+
performance: "RENDIMIENTO",
|
|
1020
|
+
system: "SISTEMA",
|
|
1021
|
+
powerThermals: "ENERG\xCDA / T\xC9RMICAS",
|
|
1022
|
+
elapsed: "Transcurrido",
|
|
1023
|
+
measuredAfter: "Los t/s de prompt y generaci\xF3n aparecen al terminar llama-bench (medidos).",
|
|
1024
|
+
permissionsHint: "El % de GPU y la potencia requieren permisos extra; se muestra N/D si no se puede medir.",
|
|
1025
|
+
cancelHint: "Ctrl+C / Q cancela llama-bench y la telemetr\xEDa",
|
|
1026
|
+
saved: "Guardado",
|
|
1027
|
+
hardwareHint: "Potencia, % de GPU y t\xE9rmicas requieren permisos extra del SO; si no hay datos se muestra N/D.",
|
|
1028
|
+
modelsHint: "FIT y EST. SPEED son estimados. MEASURED sale del historial local de llama-bench.",
|
|
1029
|
+
historyEmpty: "No hay historial de benchmarks en ~/.pudu-ai/benchmarks",
|
|
1030
|
+
compareNeedTwo: "Se necesitan al menos dos benchmarks medidos para comparar.",
|
|
1031
|
+
compareTitle: "BENCHMARKS LOCALES (medidos)",
|
|
1032
|
+
winner: "Ganador",
|
|
1033
|
+
qualityNote: "Calidad (no se deriva de la velocidad; ver metadatos del cat\xE1logo)",
|
|
1034
|
+
doctorTitle: "Pudu-AI Doctor",
|
|
1035
|
+
doctorReady: "Listo para medir {count} modelos instalados.",
|
|
1036
|
+
doctorNoBench: "llama-bench no est\xE1 disponible. Instala llama.cpp, p. ej. `brew install llama.cpp`. Pudu-AI no instala dependencias nativas.",
|
|
1037
|
+
loading: "Inspeccionando hardware, runtimes y modelos locales\u2026",
|
|
1038
|
+
addedPath: "Ruta de modelos a\xF1adida {path}",
|
|
1039
|
+
modelNotFound: "Modelo no encontrado: {id}",
|
|
1040
|
+
noHistory: "No hay historial de benchmarks.",
|
|
1041
|
+
jsonNeedModel: "Pasa un id de modelo en modo JSON, p. ej. npx pudu-ai benchmark qwen3:8b --json",
|
|
1042
|
+
useCaseCoding: "C\xF3digo",
|
|
1043
|
+
useCaseGeneral: "General",
|
|
1044
|
+
useCaseReasoning: "Razonamiento",
|
|
1045
|
+
useCaseLightweight: "Ligero",
|
|
1046
|
+
estimated: "Estimado",
|
|
1047
|
+
measured: "Medido",
|
|
1048
|
+
scoreLabel: "Puntuaci\xF3n Pudu-AI",
|
|
1049
|
+
reportTitle: "Benchmark Pudu-AI",
|
|
1050
|
+
benchmarkTitle: "Benchmark Pudu-AI",
|
|
1051
|
+
tasksTitle: "HARNESSES DE TAREAS",
|
|
1052
|
+
tasksHint: "Tareas peque\xF1as estilo OpenCode en tu idioma. Primero modelos instalados. El cat\xE1logo es Estimado (CanIRun.ai de midudev).",
|
|
1053
|
+
tasksKinds: "Tipos de trabajo",
|
|
1054
|
+
tasksScope: "Alcance",
|
|
1055
|
+
tasksPriority: "Prioridad",
|
|
1056
|
+
tasksInstalled: "instalado",
|
|
1057
|
+
tasksNotInstalled: "no instalado",
|
|
1058
|
+
tasksEmpty: "No hay modelos que coincidan con esos tipos de trabajo en este equipo.",
|
|
1059
|
+
tasksQ1: "\xBFQu\xE9 quieres hacer? (espacio para marcar, enter para seguir)",
|
|
1060
|
+
tasksQ2: "\xBFSolo modelos instalados, o tambi\xE9n estimaciones del cat\xE1logo?",
|
|
1061
|
+
tasksQ3: "\xBFPrioridad?",
|
|
1062
|
+
tasksOptCode: "C\xF3digo",
|
|
1063
|
+
tasksOptVideo: "V\xEDdeo",
|
|
1064
|
+
tasksOptImage: "Imagen",
|
|
1065
|
+
tasksOptTranscription: "Transcripci\xF3n",
|
|
1066
|
+
tasksOptChat: "Tareas / chat",
|
|
1067
|
+
tasksOptInstalled: "Solo instalados",
|
|
1068
|
+
tasksOptAll: "Instalados + cat\xE1logo (estimado)",
|
|
1069
|
+
tasksOptSpeed: "Velocidad",
|
|
1070
|
+
tasksOptBalanced: "Equilibrado",
|
|
1071
|
+
tasksOptQuality: "Calidad",
|
|
1072
|
+
taskCodeReviewTitle: "Revisar un diff local",
|
|
1073
|
+
taskCodeReviewPrompt: "En OpenCode: abre un git diff y pide a este modelo bugs, tests faltantes y un resumen de 5 l\xEDneas. No apliques parches salvo que lo pidas.",
|
|
1074
|
+
taskCodeTestsTitle: "Escribir un test que falle",
|
|
1075
|
+
taskCodeTestsPrompt: "Elige una funci\xF3n. Pide un solo test Vitest/Jest que falle con el bug actual. No generes archivos extra.",
|
|
1076
|
+
taskChatPlanTitle: "Convertir un objetivo en tareas",
|
|
1077
|
+
taskChatPlanPrompt: "Dale un objetivo. Exige un plan numerado de 5 tareas tama\xF1o harness. Sin implementar todav\xEDa.",
|
|
1078
|
+
taskChatAgentTitle: "Bucle de agente en un archivo",
|
|
1079
|
+
taskChatAgentPrompt: "Se\xF1ala un archivo y un criterio de aceptaci\xF3n. Solo puede editar ese archivo y luego parar.",
|
|
1080
|
+
taskImageCaptionTitle: "Describir una imagen local",
|
|
1081
|
+
taskImageCaptionPrompt: "Si es un modelo de visi\xF3n/imagen, describe una imagen local en el idioma de la UI. Si es solo texto, indica N/D.",
|
|
1082
|
+
taskImageBriefTitle: "Brief de generaci\xF3n de imagen",
|
|
1083
|
+
taskImageBriefPrompt: "Pide un brief de 6 l\xEDneas (sujeto, lente, luz, negative prompt) para un still. No inventes que el modelo renderiz\xF3 la imagen.",
|
|
1084
|
+
taskVideoBoardTitle: "Storyboard de 8 planos",
|
|
1085
|
+
taskVideoBoardPrompt: "Pide 8 planos: duraci\xF3n, c\xE1mara, acci\xF3n, texto en pantalla. Local. No afirmes que existe un render.",
|
|
1086
|
+
taskVideoShotTitle: "Lista de planos desde un guion",
|
|
1087
|
+
taskVideoShotPrompt: "Pega un guion corto. Pide lista de planos y segundos estimados. Un modelo de texto puede planear; no puede encodear v\xEDdeo.",
|
|
1088
|
+
taskTranscribeCleanTitle: "Limpiar una transcripci\xF3n",
|
|
1089
|
+
taskTranscribeCleanPrompt: "Pega texto de speech-to-text ruidoso. Pide puntuaci\xF3n, quitar muletillas y mantener hablantes. Esto no es ASR; edita texto.",
|
|
1090
|
+
taskTranscribeActionsTitle: "Acciones desde una transcripci\xF3n",
|
|
1091
|
+
taskTranscribeActionsPrompt: "De una reuni\xF3n transcrita, extrae responsables, fechas y preguntas abiertas como checklist en el idioma de la UI.",
|
|
1092
|
+
launchTitle: "INTEGRACIONES OLLAMA",
|
|
1093
|
+
launchHint: "Por defecto solo explica. Pull/instalaci\xF3n/launch solo con --yes y solo si un modelo recomendado cabe en este hardware (nota S\u2013B; velocidad medida si existe). Docs: OpenCode, OpenClaw, Hermes, Claude Code v\xEDa Ollama.",
|
|
1094
|
+
launchNeedOllama: "Ollama no est\xE1 detectado. Inst\xE1lalo primero. Pudu-AI no lo instala.",
|
|
1095
|
+
launchNoModel: "Ning\xFAn modelo de c\xF3digo/chat en este equipo cumple el umbral de hardware para esta integraci\xF3n.",
|
|
1096
|
+
launchGradeFail: "La nota {grade} est\xE1 por debajo del conjunto permitido ({allowed}).",
|
|
1097
|
+
launchSlowFail: "Los {tps} t/s medidos est\xE1n por debajo del m\xEDnimo de agente ({min} t/s).",
|
|
1098
|
+
launchEstimateWeak: "Solo hay una estimaci\xF3n d\xE9bil; no se har\xE1 pull ni launch.",
|
|
1099
|
+
launchOk: "Elegible en este hardware para las tareas recomendadas.",
|
|
1100
|
+
launchModel: "Modelo",
|
|
1101
|
+
launchRunHint: "Para hacer pull (si hace falta) y lanzar: npx pudu-ai launch {tool} --yes",
|
|
1102
|
+
launchBlocked: "Bloqueado. Sin pull, instalaci\xF3n ni launch.",
|
|
1103
|
+
launchNeedTool: "Indica una herramienta: opencode | openclaw | hermes | claude",
|
|
1104
|
+
launchUnknown: "Integraci\xF3n desconocida.",
|
|
1105
|
+
launchPulling: "Descargando {model} con ollama pull\u2026",
|
|
1106
|
+
launchPullFail: "Fall\xF3 ollama pull.",
|
|
1107
|
+
launchExecFail: "Fall\xF3 ollama launch.",
|
|
1108
|
+
help: `Pudu-AI \u2014 laboratorio local de hardware y benchmarks de IA
|
|
1109
|
+
|
|
1110
|
+
Uso:
|
|
1111
|
+
npx pudu-ai
|
|
1112
|
+
npx pudu-ai hardware
|
|
1113
|
+
npx pudu-ai models
|
|
1114
|
+
npx pudu-ai models add-path ~/Models
|
|
1115
|
+
npx pudu-ai recommend
|
|
1116
|
+
npx pudu-ai tasks
|
|
1117
|
+
npx pudu-ai tasks --for code,image --scope all --priority speed
|
|
1118
|
+
npx pudu-ai benchmark [model]
|
|
1119
|
+
npx pudu-ai compare
|
|
1120
|
+
npx pudu-ai history
|
|
1121
|
+
npx pudu-ai doctor
|
|
1122
|
+
npx pudu-ai report --markdown
|
|
1123
|
+
npx pudu-ai launch
|
|
1124
|
+
npx pudu-ai launch opencode
|
|
1125
|
+
npx pudu-ai launch opencode --yes
|
|
1126
|
+
|
|
1127
|
+
Flags:
|
|
1128
|
+
--json JSON legible por m\xE1quinas (sin TUI)
|
|
1129
|
+
--csv Salida CSV
|
|
1130
|
+
--no-network Omite la API de CanIRun; usa cach\xE9/estimaciones locales
|
|
1131
|
+
--no-color Sin color ANSI
|
|
1132
|
+
--verbose Logs de depuraci\xF3n en stderr
|
|
1133
|
+
--preset quick | standard | stress
|
|
1134
|
+
--lang en | es
|
|
1135
|
+
--for code,video,image,transcription,chat
|
|
1136
|
+
--scope installed | all
|
|
1137
|
+
--priority speed | balanced | quality
|
|
1138
|
+
--yes Ejecuta pull/launch solo si el modelo es elegible
|
|
1139
|
+
|
|
1140
|
+
Cr\xE9ditos:
|
|
1141
|
+
Los valores medidos vienen de llama-bench y de la telemetr\xEDa del SO.
|
|
1142
|
+
Los valores estimados vienen de CanIRun.ai de midudev
|
|
1143
|
+
(https://canirun.ai \xB7 https://github.com/midudev/canirun.ai \xB7 https://midu.dev)
|
|
1144
|
+
o de un fallback local, y se etiquetan como tal.
|
|
1145
|
+
`
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
// src/i18n/index.ts
|
|
1149
|
+
var locales = { en, es };
|
|
1150
|
+
var current = "en";
|
|
1151
|
+
function resolveLocale(raw) {
|
|
1152
|
+
const value = (raw ?? process.env.PUDU_AI_LANG ?? process.env.PUDU_LANG ?? process.env.LANG ?? "en").toLowerCase();
|
|
1153
|
+
if (value.startsWith("es")) return "es";
|
|
1154
|
+
return "en";
|
|
1155
|
+
}
|
|
1156
|
+
function setLocale(locale) {
|
|
1157
|
+
current = locale in locales ? locale : "en";
|
|
1158
|
+
}
|
|
1159
|
+
function t(id, vars) {
|
|
1160
|
+
const table = locales[current] ?? locales.en;
|
|
1161
|
+
let text = table[id] ?? locales.en[id];
|
|
1162
|
+
if (vars) {
|
|
1163
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
1164
|
+
text = text.replaceAll(`{${key}}`, String(value));
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
return text;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// src/compatibility/local.ts
|
|
1171
|
+
var GB_PER_BILLION_Q4 = 0.65;
|
|
1172
|
+
function estimateModelRamGb(model, quant = "Q4_K_M") {
|
|
1173
|
+
const params = model.paramsBillions ?? 8;
|
|
1174
|
+
const quantFactor = {
|
|
1175
|
+
Q2_K: 0.4,
|
|
1176
|
+
Q3_K_M: 0.5,
|
|
1177
|
+
Q4_K_M: 0.65,
|
|
1178
|
+
Q5_K_M: 0.8,
|
|
1179
|
+
Q6_K: 0.9,
|
|
1180
|
+
Q8_0: 1.1,
|
|
1181
|
+
F16: 2
|
|
1182
|
+
};
|
|
1183
|
+
return params * (quantFactor[quant] ?? GB_PER_BILLION_Q4);
|
|
1184
|
+
}
|
|
1185
|
+
function localCompatibility(hardware, model) {
|
|
1186
|
+
const ramGb = bytesToGiB(hardware.memory.totalBytes);
|
|
1187
|
+
const required = estimateModelRamGb(model);
|
|
1188
|
+
const ratio = required / ramGb;
|
|
1189
|
+
let grade;
|
|
1190
|
+
if (ratio <= 0.35) grade = "S";
|
|
1191
|
+
else if (ratio <= 0.5) grade = "A";
|
|
1192
|
+
else if (ratio <= 0.7) grade = "B";
|
|
1193
|
+
else if (ratio <= 0.9) grade = "C";
|
|
1194
|
+
else if (ratio <= 1.15) grade = "D";
|
|
1195
|
+
else grade = "F";
|
|
1196
|
+
const bandwidthGuess = hardware.cpu.appleSilicon ? 100 : 40;
|
|
1197
|
+
const estimatedTokensPerSecond = grade === "F" ? void 0 : Math.max(4, bandwidthGuess / Math.max(required, 1));
|
|
1198
|
+
return {
|
|
1199
|
+
modelId: model.id,
|
|
1200
|
+
source: "estimated",
|
|
1201
|
+
grade,
|
|
1202
|
+
estimatedRamGb: Number(required.toFixed(2)),
|
|
1203
|
+
estimatedTokensPerSecond: estimatedTokensPerSecond ? Number(estimatedTokensPerSecond.toFixed(1)) : void 0,
|
|
1204
|
+
notes: [`Local estimate from ${required.toFixed(1)} GB Q4 vs ${ramGb.toFixed(1)} GB ${hardware.memory.unified ? "unified memory" : "RAM"}`]
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// src/compatibility/service.ts
|
|
1209
|
+
var FALLBACK_CATALOG = [
|
|
1210
|
+
{ id: "gemma3-4b", name: "Gemma 3 4B", provider: "Google", paramsBillions: 4, useCase: ["chat"] },
|
|
1211
|
+
{ id: "qwen3-8b", name: "Qwen 3 8B", provider: "Alibaba", paramsBillions: 8, useCase: ["code", "chat", "reasoning"] },
|
|
1212
|
+
{ id: "qwen3-4b", name: "Qwen 3 4B", provider: "Alibaba", paramsBillions: 4, useCase: ["chat"] },
|
|
1213
|
+
{ id: "qwen3.5-4b", name: "Qwen 3.5 4B", provider: "Alibaba", paramsBillions: 4, useCase: ["chat"] },
|
|
1214
|
+
{ id: "deepseek-r1-8b", name: "DeepSeek R1 8B", provider: "DeepSeek", paramsBillions: 8, useCase: ["reasoning"] },
|
|
1215
|
+
{ id: "qwen3-14b", name: "Qwen 3 14B", provider: "Alibaba", paramsBillions: 14, useCase: ["chat", "reasoning"] },
|
|
1216
|
+
{ id: "llama3.1-8b", name: "Llama 3.1 8B", provider: "Meta", paramsBillions: 8, useCase: ["chat", "code"] }
|
|
1217
|
+
];
|
|
1218
|
+
async function loadCatalog(network) {
|
|
1219
|
+
if (network) {
|
|
1220
|
+
try {
|
|
1221
|
+
const models = await fetchCatalog();
|
|
1222
|
+
await saveCatalogCache(models);
|
|
1223
|
+
return { catalog: models, networkUsed: true };
|
|
1224
|
+
} catch {
|
|
1225
|
+
const cached2 = await loadCatalogCache();
|
|
1226
|
+
if (cached2?.length) return { catalog: cached2, networkUsed: false };
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
const cached = await loadCatalogCache();
|
|
1230
|
+
return { catalog: cached?.length ? cached : FALLBACK_CATALOG, networkUsed: false };
|
|
1231
|
+
}
|
|
1232
|
+
function matchCatalog(catalog, local) {
|
|
1233
|
+
return catalog.find((model) => idsLikelyMatch(model.id, local.id) || idsLikelyMatch(model.name, local.name));
|
|
1234
|
+
}
|
|
1235
|
+
async function compatibilityFor(hardware, catalogModel, network) {
|
|
1236
|
+
if (network) {
|
|
1237
|
+
try {
|
|
1238
|
+
return await fetchCompatibility(hardware, catalogModel.id);
|
|
1239
|
+
} catch {
|
|
1240
|
+
return localCompatibility(hardware, catalogModel);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
return localCompatibility(hardware, catalogModel);
|
|
1244
|
+
}
|
|
1245
|
+
async function recommendForMachine(hardware, catalog, network) {
|
|
1246
|
+
const useCases = ["code", "chat", "reasoning", "edge"];
|
|
1247
|
+
if (network) {
|
|
1248
|
+
try {
|
|
1249
|
+
const rows = [];
|
|
1250
|
+
for (const useCase of useCases) {
|
|
1251
|
+
const found = await fetchRecommendations(hardware, useCase, 1);
|
|
1252
|
+
rows.push(...found.map((item) => ({ ...item, useCase: labelUseCase(useCase) })));
|
|
1253
|
+
}
|
|
1254
|
+
if (rows.length) return rows;
|
|
1255
|
+
} catch {
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
const ranked = catalog.map((model) => ({ model, result: localCompatibility(hardware, model) })).filter((row) => row.result.grade !== "F").sort((a, b) => a.result.grade.localeCompare(b.result.grade));
|
|
1259
|
+
const picks = [];
|
|
1260
|
+
const used = /* @__PURE__ */ new Set();
|
|
1261
|
+
for (const useCase of useCases) {
|
|
1262
|
+
const hit = ranked.find((row) => {
|
|
1263
|
+
if (used.has(row.model.id)) return false;
|
|
1264
|
+
if (useCase === "chat") return row.model.useCase?.includes("chat") ?? true;
|
|
1265
|
+
return row.model.useCase?.some((u) => u.includes(useCase)) ?? false;
|
|
1266
|
+
});
|
|
1267
|
+
if (!hit) continue;
|
|
1268
|
+
used.add(hit.model.id);
|
|
1269
|
+
picks.push({
|
|
1270
|
+
useCase: labelUseCase(useCase),
|
|
1271
|
+
model: hit.model,
|
|
1272
|
+
grade: hit.result.grade,
|
|
1273
|
+
estimatedTokensPerSecond: hit.result.estimatedTokensPerSecond
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
return picks;
|
|
1277
|
+
}
|
|
1278
|
+
function labelUseCase(useCase) {
|
|
1279
|
+
if (useCase === "code") return t("useCaseCoding");
|
|
1280
|
+
if (useCase === "reasoning") return t("useCaseReasoning");
|
|
1281
|
+
if (useCase === "edge") return t("useCaseLightweight");
|
|
1282
|
+
return t("useCaseGeneral");
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// src/storage/benchmarks.ts
|
|
1286
|
+
import { readdir as readdir2, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
|
|
1287
|
+
import path6 from "node:path";
|
|
1288
|
+
import { z as z3 } from "zod";
|
|
1289
|
+
var benchmarkRecordSchema = z3.object({
|
|
1290
|
+
schemaVersion: z3.literal(1),
|
|
1291
|
+
timestamp: z3.string(),
|
|
1292
|
+
machine: z3.record(z3.unknown()),
|
|
1293
|
+
model: z3.object({
|
|
1294
|
+
id: z3.string(),
|
|
1295
|
+
name: z3.string().optional(),
|
|
1296
|
+
source: z3.string().optional(),
|
|
1297
|
+
path: z3.string().optional()
|
|
1298
|
+
}),
|
|
1299
|
+
runtime: z3.object({
|
|
1300
|
+
name: z3.string(),
|
|
1301
|
+
command: z3.array(z3.string()).optional()
|
|
1302
|
+
}),
|
|
1303
|
+
benchmark: z3.object({
|
|
1304
|
+
promptTokens: z3.number(),
|
|
1305
|
+
generationTokens: z3.number(),
|
|
1306
|
+
repetitions: z3.number(),
|
|
1307
|
+
promptTokensPerSecond: z3.number().optional(),
|
|
1308
|
+
generationTokensPerSecond: z3.number().optional(),
|
|
1309
|
+
elapsedSeconds: z3.number().optional()
|
|
1310
|
+
}),
|
|
1311
|
+
resources: z3.object({
|
|
1312
|
+
peakMemoryGb: z3.number().optional(),
|
|
1313
|
+
peakSwapGb: z3.number().optional(),
|
|
1314
|
+
avgGpuPercent: z3.number().optional(),
|
|
1315
|
+
avgCpuPercent: z3.number().optional(),
|
|
1316
|
+
peakGpuPercent: z3.number().optional(),
|
|
1317
|
+
avgPackagePowerWatts: z3.number().optional(),
|
|
1318
|
+
peakPackagePowerWatts: z3.number().optional(),
|
|
1319
|
+
avgTemperatureC: z3.number().optional(),
|
|
1320
|
+
peakTemperatureC: z3.number().optional(),
|
|
1321
|
+
tokensPerSecondPerWatt: z3.number().optional(),
|
|
1322
|
+
peakProcessRssGb: z3.number().optional()
|
|
1323
|
+
}),
|
|
1324
|
+
score: z3.object({
|
|
1325
|
+
total: z3.number(),
|
|
1326
|
+
speed: z3.string().optional(),
|
|
1327
|
+
memory: z3.string().optional(),
|
|
1328
|
+
energy: z3.string().optional(),
|
|
1329
|
+
thermal: z3.string().optional(),
|
|
1330
|
+
swap: z3.string().optional()
|
|
1331
|
+
}).optional(),
|
|
1332
|
+
origin: z3.enum(["measured"]).default("measured")
|
|
1333
|
+
});
|
|
1334
|
+
async function saveBenchmark(record) {
|
|
1335
|
+
await ensureStorage();
|
|
1336
|
+
const safeName = record.model.id.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
|
1337
|
+
const file = path6.join(benchmarksDir(), `${record.timestamp.replace(/[:.]/g, "-")}-${safeName}.json`);
|
|
1338
|
+
await writeFile3(file, `${JSON.stringify(record, null, 2)}
|
|
1339
|
+
`, "utf8");
|
|
1340
|
+
return file;
|
|
1341
|
+
}
|
|
1342
|
+
async function listBenchmarks() {
|
|
1343
|
+
await ensureStorage();
|
|
1344
|
+
let files = [];
|
|
1345
|
+
try {
|
|
1346
|
+
files = (await readdir2(benchmarksDir())).filter((f) => f.endsWith(".json"));
|
|
1347
|
+
} catch {
|
|
1348
|
+
return [];
|
|
1349
|
+
}
|
|
1350
|
+
const records = [];
|
|
1351
|
+
for (const file of files.sort()) {
|
|
1352
|
+
try {
|
|
1353
|
+
const raw = await readFile3(path6.join(benchmarksDir(), file), "utf8");
|
|
1354
|
+
records.push(benchmarkRecordSchema.parse(JSON.parse(raw)));
|
|
1355
|
+
} catch {
|
|
1356
|
+
continue;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return records;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// src/session/load.ts
|
|
1363
|
+
async function loadSession(options) {
|
|
1364
|
+
const hardware = await detectHardware();
|
|
1365
|
+
const [runtimes2, models, catalogState, history, llamaBench] = await Promise.all([
|
|
1366
|
+
detectRuntimes(),
|
|
1367
|
+
discoverLocalModels(),
|
|
1368
|
+
loadCatalog(options.network),
|
|
1369
|
+
listBenchmarks(),
|
|
1370
|
+
commandExists("llama-bench").then(Boolean)
|
|
1371
|
+
]);
|
|
1372
|
+
const rows = [];
|
|
1373
|
+
for (const local of models) {
|
|
1374
|
+
const catalog = matchCatalog(catalogState.catalog, local);
|
|
1375
|
+
const compatibility = catalog ? await compatibilityFor(hardware, catalog, options.network) : void 0;
|
|
1376
|
+
const lastBenchmark = [...history].reverse().find((b) => b.model.id === local.id);
|
|
1377
|
+
rows.push({ local, catalog, compatibility, lastBenchmark });
|
|
1378
|
+
}
|
|
1379
|
+
const recommendations = await recommendForMachine(hardware, catalogState.catalog, options.network);
|
|
1380
|
+
return {
|
|
1381
|
+
hardware,
|
|
1382
|
+
runtimes: runtimes2,
|
|
1383
|
+
models,
|
|
1384
|
+
catalog: catalogState.catalog,
|
|
1385
|
+
rows,
|
|
1386
|
+
recommendations,
|
|
1387
|
+
history,
|
|
1388
|
+
llamaBench,
|
|
1389
|
+
networkUsed: catalogState.networkUsed
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// src/telemetry/macos.ts
|
|
1394
|
+
import os9 from "node:os";
|
|
1395
|
+
|
|
1396
|
+
// src/telemetry/fallback.ts
|
|
1397
|
+
import os8 from "node:os";
|
|
1398
|
+
var previous = os8.cpus();
|
|
1399
|
+
function cpuPercent() {
|
|
1400
|
+
const current2 = os8.cpus();
|
|
1401
|
+
let idle = 0;
|
|
1402
|
+
let total = 0;
|
|
1403
|
+
for (let i = 0; i < current2.length; i += 1) {
|
|
1404
|
+
const c = current2[i];
|
|
1405
|
+
const p = previous[i] ?? c;
|
|
1406
|
+
const idleDelta = c.times.idle - p.times.idle;
|
|
1407
|
+
const totalDelta = c.times.user + c.times.nice + c.times.sys + c.times.idle + c.times.irq - (p.times.user + p.times.nice + p.times.sys + p.times.idle + p.times.irq);
|
|
1408
|
+
idle += idleDelta;
|
|
1409
|
+
total += totalDelta;
|
|
1410
|
+
}
|
|
1411
|
+
previous = current2;
|
|
1412
|
+
if (total <= 0) return 0;
|
|
1413
|
+
return Number((100 * (1 - idle / total)).toFixed(1));
|
|
1414
|
+
}
|
|
1415
|
+
var nodeTelemetry = {
|
|
1416
|
+
async start() {
|
|
1417
|
+
previous = os8.cpus();
|
|
1418
|
+
},
|
|
1419
|
+
async sample() {
|
|
1420
|
+
const total = os8.totalmem();
|
|
1421
|
+
const free = os8.freemem();
|
|
1422
|
+
const sample = {
|
|
1423
|
+
timestamp: Date.now(),
|
|
1424
|
+
cpu: { utilizationPercent: cpuPercent() },
|
|
1425
|
+
memory: {
|
|
1426
|
+
usedBytes: total - free,
|
|
1427
|
+
availableBytes: free,
|
|
1428
|
+
swapUsedBytes: 0
|
|
1429
|
+
}
|
|
1430
|
+
};
|
|
1431
|
+
return sample;
|
|
1432
|
+
},
|
|
1433
|
+
async stop() {
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
// src/telemetry/macos.ts
|
|
1439
|
+
async function sampleProcess(pid) {
|
|
1440
|
+
if (!pid) return void 0;
|
|
1441
|
+
const result = await runCommand("ps", ["-o", "pid=,%cpu=,rss=", "-p", String(pid)], { timeout: 3e3 });
|
|
1442
|
+
const parts = result.stdout.trim().split(/\s+/);
|
|
1443
|
+
if (parts.length < 3) return { pid };
|
|
1444
|
+
return {
|
|
1445
|
+
pid,
|
|
1446
|
+
cpuPercent: Number(parts[1]),
|
|
1447
|
+
rssBytes: Number(parts[2]) * 1024
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
function createMacosTelemetry(pid) {
|
|
1451
|
+
return {
|
|
1452
|
+
async start() {
|
|
1453
|
+
await nodeTelemetry.start();
|
|
1454
|
+
},
|
|
1455
|
+
async sample() {
|
|
1456
|
+
const base = await nodeTelemetry.sample();
|
|
1457
|
+
const [vm, pressure, pagesize, swap] = await Promise.all([
|
|
1458
|
+
runCommand("vm_stat", [], { timeout: 3e3 }),
|
|
1459
|
+
runCommand("memory_pressure", [], { timeout: 3e3 }),
|
|
1460
|
+
runCommand("sysctl", ["-n", "hw.pagesize"], { timeout: 3e3 }),
|
|
1461
|
+
runCommand("sysctl", ["-n", "vm.swapusage"], { timeout: 3e3 })
|
|
1462
|
+
]);
|
|
1463
|
+
const pageSize = Number(pagesize.stdout.trim()) || 16384;
|
|
1464
|
+
const parsed = parseMacosVmStat(vm.stdout, pageSize);
|
|
1465
|
+
const total = os9.totalmem();
|
|
1466
|
+
const available = parsed.availableBytes ?? os9.freemem();
|
|
1467
|
+
const sample = {
|
|
1468
|
+
...base,
|
|
1469
|
+
memory: {
|
|
1470
|
+
usedBytes: Math.max(0, total - available),
|
|
1471
|
+
availableBytes: available,
|
|
1472
|
+
swapUsedBytes: parseMacosSwapUsage(swap.stdout) ?? 0
|
|
1473
|
+
},
|
|
1474
|
+
thermal: {
|
|
1475
|
+
pressure: parseMacosMemoryPressure(pressure.stdout + pressure.stderr)
|
|
1476
|
+
},
|
|
1477
|
+
process: await sampleProcess(pid),
|
|
1478
|
+
gpu: {
|
|
1479
|
+
utilizationPercent: void 0,
|
|
1480
|
+
powerWatts: void 0
|
|
1481
|
+
}
|
|
1482
|
+
};
|
|
1483
|
+
return sample;
|
|
1484
|
+
},
|
|
1485
|
+
async stop() {
|
|
1486
|
+
await nodeTelemetry.stop();
|
|
1487
|
+
}
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// src/telemetry/collector.ts
|
|
1492
|
+
function createTelemetry(pid) {
|
|
1493
|
+
if (detectOs() === "macos") return createMacosTelemetry(pid);
|
|
1494
|
+
return nodeTelemetry;
|
|
1495
|
+
}
|
|
1496
|
+
var TelemetryCollector = class {
|
|
1497
|
+
provider;
|
|
1498
|
+
timer;
|
|
1499
|
+
samples = [];
|
|
1500
|
+
running = false;
|
|
1501
|
+
constructor(pid) {
|
|
1502
|
+
this.provider = createTelemetry(pid);
|
|
1503
|
+
}
|
|
1504
|
+
async start(intervalMs = 750) {
|
|
1505
|
+
await this.provider.start();
|
|
1506
|
+
this.running = true;
|
|
1507
|
+
const tick = async () => {
|
|
1508
|
+
if (!this.running) return;
|
|
1509
|
+
try {
|
|
1510
|
+
const sample = await this.provider.sample();
|
|
1511
|
+
this.samples.push(sample);
|
|
1512
|
+
this.onSample?.(sample);
|
|
1513
|
+
} catch {
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
await tick();
|
|
1517
|
+
this.timer = setInterval(() => {
|
|
1518
|
+
void tick();
|
|
1519
|
+
}, intervalMs);
|
|
1520
|
+
}
|
|
1521
|
+
onSample;
|
|
1522
|
+
latest() {
|
|
1523
|
+
return this.samples.at(-1);
|
|
1524
|
+
}
|
|
1525
|
+
history() {
|
|
1526
|
+
return this.samples;
|
|
1527
|
+
}
|
|
1528
|
+
async stop() {
|
|
1529
|
+
this.running = false;
|
|
1530
|
+
if (this.timer) clearInterval(this.timer);
|
|
1531
|
+
await this.provider.stop();
|
|
1532
|
+
return summarize(this.samples);
|
|
1533
|
+
}
|
|
1534
|
+
};
|
|
1535
|
+
function summarize(samples) {
|
|
1536
|
+
if (samples.length === 0) return { samples: 0 };
|
|
1537
|
+
const avg = (values) => {
|
|
1538
|
+
const nums = values.filter((v) => v !== void 0 && !Number.isNaN(v));
|
|
1539
|
+
if (!nums.length) return void 0;
|
|
1540
|
+
return Number((nums.reduce((a, b) => a + b, 0) / nums.length).toFixed(1));
|
|
1541
|
+
};
|
|
1542
|
+
const peak = (values) => {
|
|
1543
|
+
const nums = values.filter((v) => v !== void 0 && !Number.isNaN(v));
|
|
1544
|
+
if (!nums.length) return void 0;
|
|
1545
|
+
return Number(Math.max(...nums).toFixed(2));
|
|
1546
|
+
};
|
|
1547
|
+
const GiB2 = 1024 ** 3;
|
|
1548
|
+
return {
|
|
1549
|
+
samples: samples.length,
|
|
1550
|
+
avgCpuPercent: avg(samples.map((s) => s.cpu?.utilizationPercent)),
|
|
1551
|
+
avgGpuPercent: avg(samples.map((s) => s.gpu?.utilizationPercent)),
|
|
1552
|
+
peakGpuPercent: peak(samples.map((s) => s.gpu?.utilizationPercent)),
|
|
1553
|
+
peakMemoryGb: peak(samples.map((s) => s.memory.usedBytes / GiB2)),
|
|
1554
|
+
peakSwapGb: peak(samples.map((s) => s.memory.swapUsedBytes / GiB2)),
|
|
1555
|
+
avgPackagePowerWatts: avg(samples.map((s) => s.packagePowerWatts ?? s.cpu?.powerWatts)),
|
|
1556
|
+
peakPackagePowerWatts: peak(samples.map((s) => s.packagePowerWatts ?? s.cpu?.powerWatts)),
|
|
1557
|
+
avgTemperatureC: avg(samples.map((s) => s.thermal?.temperatureC)),
|
|
1558
|
+
peakTemperatureC: peak(samples.map((s) => s.thermal?.temperatureC)),
|
|
1559
|
+
peakProcessRssGb: peak(samples.map((s) => (s.process?.rssBytes ?? 0) / GiB2))
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// src/benchmark/parse-llama-bench.ts
|
|
1564
|
+
import { z as z4 } from "zod";
|
|
1565
|
+
var jsonRowSchema = z4.object({
|
|
1566
|
+
n_prompt: z4.number().optional(),
|
|
1567
|
+
n_gen: z4.number().optional(),
|
|
1568
|
+
avg_ts: z4.number().optional(),
|
|
1569
|
+
model_type: z4.string().optional(),
|
|
1570
|
+
model_size: z4.number().optional(),
|
|
1571
|
+
backends: z4.string().optional(),
|
|
1572
|
+
test: z4.string().optional()
|
|
1573
|
+
}).passthrough();
|
|
1574
|
+
function parseLlamaBenchJson(text) {
|
|
1575
|
+
const parsed = JSON.parse(text);
|
|
1576
|
+
const rows = Array.isArray(parsed) ? parsed.map((row) => jsonRowSchema.parse(row)) : [jsonRowSchema.parse(parsed)];
|
|
1577
|
+
return metricsFromRows(rows, parsed);
|
|
1578
|
+
}
|
|
1579
|
+
function metricsFromRows(rows, raw) {
|
|
1580
|
+
let promptTokensPerSecond;
|
|
1581
|
+
let generationTokensPerSecond;
|
|
1582
|
+
for (const row of rows) {
|
|
1583
|
+
const test = row.test ?? "";
|
|
1584
|
+
const avg = row.avg_ts;
|
|
1585
|
+
if (avg === void 0) continue;
|
|
1586
|
+
if (/^pp/i.test(test) || (row.n_prompt ?? 0) > 0 && (row.n_gen ?? 0) === 0) {
|
|
1587
|
+
promptTokensPerSecond = avg;
|
|
1588
|
+
} else if (/^tg/i.test(test) || (row.n_gen ?? 0) > 0 && (row.n_prompt ?? 0) === 0) {
|
|
1589
|
+
generationTokensPerSecond = avg;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
const first = rows[0];
|
|
1593
|
+
return {
|
|
1594
|
+
promptTokensPerSecond,
|
|
1595
|
+
generationTokensPerSecond,
|
|
1596
|
+
modelType: first?.model_type,
|
|
1597
|
+
modelSizeBytes: first?.model_size,
|
|
1598
|
+
backend: first?.backends,
|
|
1599
|
+
raw
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
function parseLlamaBenchMarkdown(text) {
|
|
1603
|
+
const rows = [];
|
|
1604
|
+
for (const line of text.split(/\r?\n/)) {
|
|
1605
|
+
if (!line.includes("|")) continue;
|
|
1606
|
+
const cells = line.split("|").map((c) => c.trim()).filter(Boolean);
|
|
1607
|
+
if (cells.length < 6) continue;
|
|
1608
|
+
if (/^model$/i.test(cells[0] ?? "") || /^-+$/.test(cells[0] ?? "")) continue;
|
|
1609
|
+
const test = cells[cells.length - 2] ?? "";
|
|
1610
|
+
const tsCell = cells[cells.length - 1] ?? "";
|
|
1611
|
+
const tsMatch = tsCell.match(/([\d.]+)/);
|
|
1612
|
+
if (!tsMatch) continue;
|
|
1613
|
+
rows.push({ test, ts: Number(tsMatch[1]), modelType: cells[0] });
|
|
1614
|
+
}
|
|
1615
|
+
let promptTokensPerSecond;
|
|
1616
|
+
let generationTokensPerSecond;
|
|
1617
|
+
for (const row of rows) {
|
|
1618
|
+
if (/^pp/i.test(row.test)) promptTokensPerSecond = row.ts;
|
|
1619
|
+
if (/^tg/i.test(row.test)) generationTokensPerSecond = row.ts;
|
|
1620
|
+
}
|
|
1621
|
+
return {
|
|
1622
|
+
promptTokensPerSecond,
|
|
1623
|
+
generationTokensPerSecond,
|
|
1624
|
+
modelType: rows[0]?.modelType,
|
|
1625
|
+
raw: { format: "markdown", rows }
|
|
1626
|
+
};
|
|
1627
|
+
}
|
|
1628
|
+
function parseLlamaBenchOutput(text) {
|
|
1629
|
+
const trimmed = text.trim();
|
|
1630
|
+
const jsonStart = trimmed.indexOf("[");
|
|
1631
|
+
const jsonObjStart = trimmed.indexOf("{");
|
|
1632
|
+
const start = jsonStart >= 0 && (jsonObjStart < 0 || jsonStart < jsonObjStart) ? jsonStart : jsonObjStart;
|
|
1633
|
+
if (start >= 0) {
|
|
1634
|
+
const slice = trimmed.slice(start);
|
|
1635
|
+
try {
|
|
1636
|
+
return parseLlamaBenchJson(slice);
|
|
1637
|
+
} catch {
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
return parseLlamaBenchMarkdown(text);
|
|
1641
|
+
}
|
|
1642
|
+
function tokensPerSecondPerWatt(tokensPerSecond, watts) {
|
|
1643
|
+
if (!tokensPerSecond || !watts || watts <= 0) return void 0;
|
|
1644
|
+
return Number((tokensPerSecond / watts).toFixed(2));
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// src/benchmark/presets.ts
|
|
1648
|
+
var PRESETS = {
|
|
1649
|
+
quick: { name: "quick", promptTokens: 512, generationTokens: 128, repetitions: 3 },
|
|
1650
|
+
standard: { name: "standard", promptTokens: 2048, generationTokens: 256, repetitions: 5 },
|
|
1651
|
+
stress: { name: "stress", promptTokens: 4096, generationTokens: 512, repetitions: 10 }
|
|
1652
|
+
};
|
|
1653
|
+
function resolvePreset(name) {
|
|
1654
|
+
if (name === "standard" || name === "stress" || name === "quick") return PRESETS[name];
|
|
1655
|
+
return PRESETS.quick;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// src/compatibility/grades.ts
|
|
1659
|
+
function gradeFromThresholds(score, thresholds) {
|
|
1660
|
+
if (score >= thresholds.S) return "S";
|
|
1661
|
+
if (score >= thresholds.A) return "A";
|
|
1662
|
+
if (score >= thresholds.B) return "B";
|
|
1663
|
+
if (score >= thresholds.C) return "C";
|
|
1664
|
+
if (score >= thresholds.D) return "D";
|
|
1665
|
+
return "F";
|
|
1666
|
+
}
|
|
1667
|
+
function gradeToScore(grade) {
|
|
1668
|
+
return { S: 100, A: 85, B: 70, C: 55, D: 40, F: 20 }[grade];
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// src/benchmark/score.ts
|
|
1672
|
+
function computeLocalMeterScore(generationTokensPerSecond, resources, totalMemoryGb) {
|
|
1673
|
+
const dimensions = [
|
|
1674
|
+
{
|
|
1675
|
+
key: "speed",
|
|
1676
|
+
measured: generationTokensPerSecond !== void 0,
|
|
1677
|
+
grade: generationTokensPerSecond === void 0 ? void 0 : gradeFromThresholds(generationTokensPerSecond, { S: 60, A: 40, B: 25, C: 12, D: 5, F: 0 }),
|
|
1678
|
+
weight: 35
|
|
1679
|
+
},
|
|
1680
|
+
{
|
|
1681
|
+
key: "memory",
|
|
1682
|
+
measured: resources.peakMemoryGb !== void 0 && totalMemoryGb > 0,
|
|
1683
|
+
grade: resources.peakMemoryGb === void 0 || totalMemoryGb <= 0 ? void 0 : gradeFromThresholds(100 - resources.peakMemoryGb / totalMemoryGb * 100, {
|
|
1684
|
+
S: 50,
|
|
1685
|
+
A: 30,
|
|
1686
|
+
B: 15,
|
|
1687
|
+
C: 5,
|
|
1688
|
+
D: 0,
|
|
1689
|
+
F: -100
|
|
1690
|
+
}),
|
|
1691
|
+
weight: 25
|
|
1692
|
+
},
|
|
1693
|
+
{
|
|
1694
|
+
key: "energy",
|
|
1695
|
+
measured: resources.avgPackagePowerWatts !== void 0 && generationTokensPerSecond !== void 0,
|
|
1696
|
+
grade: energyGrade(generationTokensPerSecond, resources.avgPackagePowerWatts),
|
|
1697
|
+
weight: 20
|
|
1698
|
+
},
|
|
1699
|
+
{
|
|
1700
|
+
key: "thermal",
|
|
1701
|
+
measured: resources.peakTemperatureC !== void 0,
|
|
1702
|
+
grade: resources.peakTemperatureC === void 0 ? void 0 : gradeFromThresholds(100 - resources.peakTemperatureC, { S: 30, A: 20, B: 10, C: 0, D: -10, F: -100 }),
|
|
1703
|
+
weight: 10
|
|
1704
|
+
},
|
|
1705
|
+
{
|
|
1706
|
+
key: "swap",
|
|
1707
|
+
measured: resources.peakSwapGb !== void 0,
|
|
1708
|
+
grade: resources.peakSwapGb === void 0 ? void 0 : gradeFromThresholds(4 - resources.peakSwapGb, { S: 4, A: 3.75, B: 3, C: 2, D: 0, F: -100 }),
|
|
1709
|
+
weight: 10
|
|
1710
|
+
}
|
|
1711
|
+
];
|
|
1712
|
+
const measured = dimensions.filter((d) => d.measured && d.grade);
|
|
1713
|
+
const weightSum = measured.reduce((sum, d) => sum + d.weight, 0);
|
|
1714
|
+
if (!measured.length || weightSum === 0) return { dimensions };
|
|
1715
|
+
const total = measured.reduce((sum, d) => sum + gradeToScore(d.grade) * (d.weight / weightSum), 0);
|
|
1716
|
+
return { total: Math.round(total), dimensions };
|
|
1717
|
+
}
|
|
1718
|
+
function energyGrade(tps, watts) {
|
|
1719
|
+
if (!tps || !watts || watts <= 0) return void 0;
|
|
1720
|
+
const efficiency = tps / watts;
|
|
1721
|
+
return gradeFromThresholds(efficiency, { S: 3, A: 2, B: 1.2, C: 0.6, D: 0.3, F: 0 });
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// src/benchmark/assess.ts
|
|
1725
|
+
function assessRun(input) {
|
|
1726
|
+
const lines = [];
|
|
1727
|
+
const { generationTokensPerSecond, resources, catalog } = input;
|
|
1728
|
+
if (generationTokensPerSecond !== void 0 && generationTokensPerSecond >= 25) {
|
|
1729
|
+
lines.push("Runs comfortably on this machine");
|
|
1730
|
+
} else if (generationTokensPerSecond !== void 0 && generationTokensPerSecond >= 10) {
|
|
1731
|
+
lines.push("Runs, but generation is constrained");
|
|
1732
|
+
} else if (generationTokensPerSecond !== void 0) {
|
|
1733
|
+
lines.push("Generation is too slow for interactive use");
|
|
1734
|
+
}
|
|
1735
|
+
if ((resources.peakSwapGb ?? 0) < 0.05) lines.push("No meaningful swap detected");
|
|
1736
|
+
else lines.push("Swap activity detected \u2014 memory is tight");
|
|
1737
|
+
if (resources.avgGpuPercent !== void 0 && resources.avgGpuPercent >= 80) {
|
|
1738
|
+
lines.push("GPU remains highly utilized (system-wide)");
|
|
1739
|
+
}
|
|
1740
|
+
if (generationTokensPerSecond !== void 0 && generationTokensPerSecond >= 35) {
|
|
1741
|
+
lines.push("Good sustained generation performance");
|
|
1742
|
+
}
|
|
1743
|
+
if (resources.avgPackagePowerWatts !== void 0 && generationTokensPerSecond) {
|
|
1744
|
+
const eff = generationTokensPerSecond / resources.avgPackagePowerWatts;
|
|
1745
|
+
if (eff >= 2) lines.push("Excellent energy efficiency");
|
|
1746
|
+
}
|
|
1747
|
+
if (catalog?.useCase?.length) {
|
|
1748
|
+
lines.push(`This model is suitable for: ${catalog.useCase.join(", ")}`);
|
|
1749
|
+
}
|
|
1750
|
+
return lines;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
// src/benchmark/engine.ts
|
|
1754
|
+
async function runBenchmark(input) {
|
|
1755
|
+
const bench = await commandExists("llama-bench");
|
|
1756
|
+
if (!bench) {
|
|
1757
|
+
throw new Error("llama-bench not found. Install llama.cpp (e.g. brew install llama.cpp).");
|
|
1758
|
+
}
|
|
1759
|
+
const artifact = input.model.artifactPath;
|
|
1760
|
+
if (!artifact) {
|
|
1761
|
+
throw new Error(`No GGUF path for ${input.model.id}. Cannot run llama-bench.`);
|
|
1762
|
+
}
|
|
1763
|
+
const preset = resolvePreset(input.preset);
|
|
1764
|
+
const started = Date.now();
|
|
1765
|
+
const args2 = [
|
|
1766
|
+
"-m",
|
|
1767
|
+
artifact,
|
|
1768
|
+
"-p",
|
|
1769
|
+
String(preset.promptTokens),
|
|
1770
|
+
"-n",
|
|
1771
|
+
String(preset.generationTokens),
|
|
1772
|
+
"-r",
|
|
1773
|
+
String(preset.repetitions),
|
|
1774
|
+
"-o",
|
|
1775
|
+
"json"
|
|
1776
|
+
];
|
|
1777
|
+
const subprocess = spawnTracked(bench, args2, { timeout: 30 * 6e4 });
|
|
1778
|
+
const collector = new TelemetryCollector(subprocess.pid);
|
|
1779
|
+
collector.onSample = (sample) => {
|
|
1780
|
+
input.onProgress?.({ sample, elapsedSeconds: (Date.now() - started) / 1e3, status: "running" });
|
|
1781
|
+
};
|
|
1782
|
+
await collector.start(750);
|
|
1783
|
+
const abort = () => {
|
|
1784
|
+
subprocess.kill("SIGTERM");
|
|
1785
|
+
};
|
|
1786
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
1787
|
+
const result = await subprocess;
|
|
1788
|
+
input.signal?.removeEventListener("abort", abort);
|
|
1789
|
+
const resources = await collector.stop();
|
|
1790
|
+
if (input.signal?.aborted) {
|
|
1791
|
+
throw new Error("Benchmark cancelled");
|
|
1792
|
+
}
|
|
1793
|
+
const stdout = toText(result.stdout);
|
|
1794
|
+
const stderr = toText(result.stderr);
|
|
1795
|
+
if (result.exitCode !== 0) {
|
|
1796
|
+
throw new Error(stderr || `llama-bench exited with ${result.exitCode}`);
|
|
1797
|
+
}
|
|
1798
|
+
const metrics = parseLlamaBenchOutput(stdout || stderr);
|
|
1799
|
+
const score = computeLocalMeterScore(
|
|
1800
|
+
metrics.generationTokensPerSecond,
|
|
1801
|
+
resources,
|
|
1802
|
+
bytesToGiB(input.hardware.memory.totalBytes)
|
|
1803
|
+
);
|
|
1804
|
+
const efficiency = tokensPerSecondPerWatt(
|
|
1805
|
+
metrics.generationTokensPerSecond,
|
|
1806
|
+
resources.avgPackagePowerWatts
|
|
1807
|
+
);
|
|
1808
|
+
const record = {
|
|
1809
|
+
schemaVersion: 1,
|
|
1810
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1811
|
+
machine: {
|
|
1812
|
+
os: input.hardware.os,
|
|
1813
|
+
arch: input.hardware.arch,
|
|
1814
|
+
model: input.hardware.machineModel,
|
|
1815
|
+
cpu: input.hardware.cpu.name,
|
|
1816
|
+
memoryGb: Number(bytesToGiB(input.hardware.memory.totalBytes).toFixed(1)),
|
|
1817
|
+
unified: input.hardware.memory.unified
|
|
1818
|
+
},
|
|
1819
|
+
model: {
|
|
1820
|
+
id: input.model.id,
|
|
1821
|
+
name: input.model.name,
|
|
1822
|
+
source: input.model.source,
|
|
1823
|
+
path: input.model.artifactPath
|
|
1824
|
+
},
|
|
1825
|
+
runtime: { name: "llama-bench", command: [bench, ...args2] },
|
|
1826
|
+
benchmark: {
|
|
1827
|
+
promptTokens: preset.promptTokens,
|
|
1828
|
+
generationTokens: preset.generationTokens,
|
|
1829
|
+
repetitions: preset.repetitions,
|
|
1830
|
+
promptTokensPerSecond: metrics.promptTokensPerSecond,
|
|
1831
|
+
generationTokensPerSecond: metrics.generationTokensPerSecond,
|
|
1832
|
+
elapsedSeconds: (Date.now() - started) / 1e3
|
|
1833
|
+
},
|
|
1834
|
+
resources: {
|
|
1835
|
+
...resourceFields(resources),
|
|
1836
|
+
tokensPerSecondPerWatt: efficiency
|
|
1837
|
+
},
|
|
1838
|
+
score: {
|
|
1839
|
+
total: score.total ?? 0,
|
|
1840
|
+
speed: score.dimensions.find((d) => d.key === "speed")?.grade,
|
|
1841
|
+
memory: score.dimensions.find((d) => d.key === "memory")?.grade,
|
|
1842
|
+
energy: score.dimensions.find((d) => d.key === "energy")?.grade,
|
|
1843
|
+
thermal: score.dimensions.find((d) => d.key === "thermal")?.grade,
|
|
1844
|
+
swap: score.dimensions.find((d) => d.key === "swap")?.grade
|
|
1845
|
+
},
|
|
1846
|
+
origin: "measured"
|
|
1847
|
+
};
|
|
1848
|
+
const path7 = await saveBenchmark(record);
|
|
1849
|
+
input.onProgress?.({
|
|
1850
|
+
elapsedSeconds: record.benchmark.elapsedSeconds ?? 0,
|
|
1851
|
+
status: "completed"
|
|
1852
|
+
});
|
|
1853
|
+
return {
|
|
1854
|
+
record,
|
|
1855
|
+
assessment: assessRun({
|
|
1856
|
+
generationTokensPerSecond: metrics.generationTokensPerSecond,
|
|
1857
|
+
resources,
|
|
1858
|
+
catalog: input.catalog
|
|
1859
|
+
}),
|
|
1860
|
+
path: path7,
|
|
1861
|
+
stdout
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1864
|
+
function resourceFields(resources) {
|
|
1865
|
+
return {
|
|
1866
|
+
peakMemoryGb: resources.peakMemoryGb,
|
|
1867
|
+
peakSwapGb: resources.peakSwapGb,
|
|
1868
|
+
avgGpuPercent: resources.avgGpuPercent,
|
|
1869
|
+
avgCpuPercent: resources.avgCpuPercent,
|
|
1870
|
+
peakGpuPercent: resources.peakGpuPercent,
|
|
1871
|
+
avgPackagePowerWatts: resources.avgPackagePowerWatts,
|
|
1872
|
+
peakPackagePowerWatts: resources.peakPackagePowerWatts,
|
|
1873
|
+
avgTemperatureC: resources.avgTemperatureC,
|
|
1874
|
+
peakTemperatureC: resources.peakTemperatureC,
|
|
1875
|
+
peakProcessRssGb: resources.peakProcessRssGb
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
// src/shared/logger.ts
|
|
1880
|
+
var level = "info";
|
|
1881
|
+
function setLogLevel(next) {
|
|
1882
|
+
level = next;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// src/tui/render.tsx
|
|
1886
|
+
import { render } from "ink";
|
|
1887
|
+
|
|
1888
|
+
// src/tui/App.tsx
|
|
1889
|
+
import { Box as Box9, Text as Text9, useApp, useInput as useInput3 } from "ink";
|
|
1890
|
+
import { useMemo as useMemo2, useState as useState3 } from "react";
|
|
1891
|
+
|
|
1892
|
+
// src/tui/views/Dashboard.tsx
|
|
1893
|
+
import { Box, Text } from "ink";
|
|
1894
|
+
|
|
1895
|
+
// src/shared/format.ts
|
|
1896
|
+
function formatNumber(value, digits = 1) {
|
|
1897
|
+
if (value === void 0 || Number.isNaN(value)) return "N/A";
|
|
1898
|
+
return value.toFixed(digits);
|
|
1899
|
+
}
|
|
1900
|
+
function formatPercent(value, digits = 0) {
|
|
1901
|
+
if (value === void 0 || Number.isNaN(value)) return "N/A";
|
|
1902
|
+
return `${value.toFixed(digits)} %`;
|
|
1903
|
+
}
|
|
1904
|
+
function formatTokensPerSec(value, digits = 1) {
|
|
1905
|
+
if (value === void 0 || Number.isNaN(value)) return "N/A";
|
|
1906
|
+
return `${value.toFixed(digits)} t/s`;
|
|
1907
|
+
}
|
|
1908
|
+
function na(value) {
|
|
1909
|
+
if (value === void 0 || value === null || value === "") return "N/A";
|
|
1910
|
+
return String(value);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// src/hardware/types.ts
|
|
1914
|
+
function memoryLabel(profile) {
|
|
1915
|
+
return profile.memory.unified ? "Unified Memory" : "RAM";
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
// src/compatibility/types.ts
|
|
1919
|
+
var GRADE_MEANING = {
|
|
1920
|
+
S: "Excellent",
|
|
1921
|
+
A: "Recommended",
|
|
1922
|
+
B: "Good",
|
|
1923
|
+
C: "Tight",
|
|
1924
|
+
D: "CPU/offload",
|
|
1925
|
+
F: "Not recommended"
|
|
1926
|
+
};
|
|
1927
|
+
|
|
1928
|
+
// src/tui/views/Dashboard.tsx
|
|
1929
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
1930
|
+
function Dashboard({ session }) {
|
|
1931
|
+
const h = session.hardware;
|
|
1932
|
+
return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
|
|
1933
|
+
/* @__PURE__ */ jsxs(Box, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, marginBottom: 1, children: [
|
|
1934
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: t("appTitle") }),
|
|
1935
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: t("appSubtitle") }),
|
|
1936
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: t("aboutDashboard") })
|
|
1937
|
+
] }),
|
|
1938
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: t("machine") }),
|
|
1939
|
+
/* @__PURE__ */ jsx(Text, { children: h.machineModel ?? t("unknownMachine") }),
|
|
1940
|
+
/* @__PURE__ */ jsx(Text, { children: h.cpu.name ?? t("cpuNa") }),
|
|
1941
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1942
|
+
"CPU ",
|
|
1943
|
+
h.cpu.physicalCores ?? "N/A",
|
|
1944
|
+
" cores",
|
|
1945
|
+
h.cpu.performanceCores ? ` (${h.cpu.performanceCores}P/${h.cpu.efficiencyCores ?? "?"}E)` : ""
|
|
1946
|
+
] }),
|
|
1947
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1948
|
+
"GPU ",
|
|
1949
|
+
h.gpu.name ?? "N/A"
|
|
1950
|
+
] }),
|
|
1951
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1952
|
+
memoryLabel(h).padEnd(13),
|
|
1953
|
+
" ",
|
|
1954
|
+
formatBytes(h.memory.totalBytes, 0)
|
|
1955
|
+
] }),
|
|
1956
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1957
|
+
"Available ",
|
|
1958
|
+
h.memory.availableBytes ? formatBytes(h.memory.availableBytes) : "N/A"
|
|
1959
|
+
] }),
|
|
1960
|
+
/* @__PURE__ */ jsxs(Text, { children: [
|
|
1961
|
+
h.os,
|
|
1962
|
+
" ",
|
|
1963
|
+
h.osVersion ?? h.arch
|
|
1964
|
+
] }),
|
|
1965
|
+
/* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
|
|
1966
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: t("runtimes") }),
|
|
1967
|
+
session.runtimes.map((r) => /* @__PURE__ */ jsxs(Text, { children: [
|
|
1968
|
+
r.detected ? "\u2713" : "\u25CB",
|
|
1969
|
+
" ",
|
|
1970
|
+
r.label.padEnd(14),
|
|
1971
|
+
" ",
|
|
1972
|
+
r.detected ? r.version ?? t("detected") : t("notDetected")
|
|
1973
|
+
] }, r.id))
|
|
1974
|
+
] }),
|
|
1975
|
+
/* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
|
|
1976
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: t("installedModels") }),
|
|
1977
|
+
session.rows.length === 0 && /* @__PURE__ */ jsx(Text, { dimColor: true, children: t("noModels") }),
|
|
1978
|
+
session.rows.map((row) => /* @__PURE__ */ jsxs(Text, { children: [
|
|
1979
|
+
row.local.name.padEnd(20),
|
|
1980
|
+
" ",
|
|
1981
|
+
row.local.sizeBytes ? formatBytes(row.local.sizeBytes) : "N/A",
|
|
1982
|
+
" ",
|
|
1983
|
+
row.compatibility?.grade ?? "\u2014",
|
|
1984
|
+
" ",
|
|
1985
|
+
row.lastBenchmark?.benchmark.generationTokensPerSecond ? formatTokensPerSec(row.lastBenchmark.benchmark.generationTokensPerSecond) : t("notTested")
|
|
1986
|
+
] }, row.local.id))
|
|
1987
|
+
] }),
|
|
1988
|
+
/* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
|
|
1989
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: t("recommended") }),
|
|
1990
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: t("recommendedHint") }),
|
|
1991
|
+
session.recommendations.map((rec) => /* @__PURE__ */ jsxs(Text, { children: [
|
|
1992
|
+
rec.useCase.padEnd(12),
|
|
1993
|
+
" ",
|
|
1994
|
+
rec.model.name.padEnd(22),
|
|
1995
|
+
" ",
|
|
1996
|
+
rec.grade,
|
|
1997
|
+
" ",
|
|
1998
|
+
GRADE_MEANING[rec.grade]
|
|
1999
|
+
] }, `${rec.useCase}-${rec.model.id}`)),
|
|
2000
|
+
/* @__PURE__ */ jsx(Text, { dimColor: true, children: t("credits") })
|
|
2001
|
+
] })
|
|
2002
|
+
] });
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
// src/tui/views/Models.tsx
|
|
2006
|
+
import { Box as Box2, Text as Text2 } from "ink";
|
|
2007
|
+
|
|
2008
|
+
// src/cli/text.ts
|
|
2009
|
+
function hardwareText(session) {
|
|
2010
|
+
const h = session.hardware;
|
|
2011
|
+
const ram = formatBytes(h.memory.totalBytes, 0);
|
|
2012
|
+
const avail = h.memory.availableBytes ? formatBytes(h.memory.availableBytes) : "N/A";
|
|
2013
|
+
return [
|
|
2014
|
+
t("machine"),
|
|
2015
|
+
` ${h.machineModel ?? t("unknownMachine")}`,
|
|
2016
|
+
` ${h.cpu.name ?? t("cpuNa")}`,
|
|
2017
|
+
` CPU ${h.cpu.physicalCores ?? "N/A"} cores (${h.cpu.performanceCores ?? "?"}P / ${h.cpu.efficiencyCores ?? "?"}E)`,
|
|
2018
|
+
` GPU ${h.gpu.name ?? "N/A"}`,
|
|
2019
|
+
` ${memoryLabel(h).padEnd(13)} ${ram}`,
|
|
2020
|
+
` Available ${avail}`,
|
|
2021
|
+
` ${h.os} ${h.osVersion ?? h.arch}`
|
|
2022
|
+
].join("\n");
|
|
2023
|
+
}
|
|
2024
|
+
function runtimesText(session) {
|
|
2025
|
+
return [
|
|
2026
|
+
t("runtimes"),
|
|
2027
|
+
...session.runtimes.map((r) => ` ${r.detected ? "\u2713" : "\u25CB"} ${r.label.padEnd(14)} ${r.detected ? r.version ?? t("detected") : t("notDetected")}`)
|
|
2028
|
+
].join("\n");
|
|
2029
|
+
}
|
|
2030
|
+
function modelsText(session) {
|
|
2031
|
+
const lines = [t("installedModels"), "MODEL INSTALLED FIT EST. SPEED MEASURED"];
|
|
2032
|
+
for (const row of session.rows) {
|
|
2033
|
+
const fit = row.compatibility?.grade ?? "\u2014";
|
|
2034
|
+
const est = row.compatibility?.estimatedTokensPerSecond ? `~${formatNumber(row.compatibility.estimatedTokensPerSecond)} t/s` : "\u2014";
|
|
2035
|
+
const measured = row.lastBenchmark?.benchmark.generationTokensPerSecond ? formatTokensPerSec(row.lastBenchmark.benchmark.generationTokensPerSecond) : t("notTested");
|
|
2036
|
+
lines.push(
|
|
2037
|
+
`${row.local.name.padEnd(22)} \u2713 ${fit.padEnd(9)} ${est.padEnd(14)} ${measured}`
|
|
2038
|
+
);
|
|
2039
|
+
}
|
|
2040
|
+
lines.push("", t("compatible"));
|
|
2041
|
+
const extras = session.catalog.filter((model) => !session.rows.some((row) => idsLikelyMatch(row.local.id, model.id))).map((model) => ({ model, fit: localCompatibility(session.hardware, model) })).sort((a, b) => a.fit.grade.localeCompare(b.fit.grade)).slice(0, 12);
|
|
2042
|
+
for (const extra of extras) {
|
|
2043
|
+
const est = extra.fit.estimatedTokensPerSecond ? `~${formatNumber(extra.fit.estimatedTokensPerSecond)} t/s` : "\u2014";
|
|
2044
|
+
lines.push(`${extra.model.name.padEnd(22)} \u2014 ${extra.fit.grade.padEnd(9)} ${est.padEnd(14)} ${t("estimated")}`);
|
|
2045
|
+
}
|
|
2046
|
+
return lines.join("\n");
|
|
2047
|
+
}
|
|
2048
|
+
function recommendText(session) {
|
|
2049
|
+
const lines = [t("recommended"), t("recommendedHint"), "", t("credits"), ""];
|
|
2050
|
+
for (const rec of session.recommendations) {
|
|
2051
|
+
lines.push(
|
|
2052
|
+
`${rec.useCase.padEnd(12)} ${rec.model.name.padEnd(22)} ${rec.grade} ${GRADE_MEANING[rec.grade]} ~${na(rec.estimatedTokensPerSecond)} t/s est.`
|
|
2053
|
+
);
|
|
2054
|
+
}
|
|
2055
|
+
return lines.join("\n");
|
|
2056
|
+
}
|
|
2057
|
+
function historyText(records) {
|
|
2058
|
+
if (!records.length) return t("historyEmpty");
|
|
2059
|
+
return records.map((r) => {
|
|
2060
|
+
const gen = r.benchmark.generationTokensPerSecond;
|
|
2061
|
+
return `${r.timestamp} ${r.model.id.padEnd(18)} ${gen ? formatTokensPerSec(gen) : "N/A"} score ${r.score?.total ?? "N/A"}`;
|
|
2062
|
+
}).join("\n");
|
|
2063
|
+
}
|
|
2064
|
+
function historyCsv(records) {
|
|
2065
|
+
const header = "timestamp,model,prompt_tps,generation_tps,peak_memory_gb,avg_cpu,score";
|
|
2066
|
+
const rows = records.map(
|
|
2067
|
+
(r) => [
|
|
2068
|
+
r.timestamp,
|
|
2069
|
+
r.model.id,
|
|
2070
|
+
r.benchmark.promptTokensPerSecond ?? "",
|
|
2071
|
+
r.benchmark.generationTokensPerSecond ?? "",
|
|
2072
|
+
r.resources.peakMemoryGb ?? "",
|
|
2073
|
+
r.resources.avgCpuPercent ?? "",
|
|
2074
|
+
r.score?.total ?? ""
|
|
2075
|
+
].join(",")
|
|
2076
|
+
);
|
|
2077
|
+
return [header, ...rows].join("\n");
|
|
2078
|
+
}
|
|
2079
|
+
function doctorText(session) {
|
|
2080
|
+
const node = process.version.replace(/^v/, "");
|
|
2081
|
+
const apple = session.hardware.cpu.appleSilicon ? `${session.hardware.cpu.appleSilicon.generation} ${session.hardware.cpu.appleSilicon.variant}` : "no";
|
|
2082
|
+
const lines = [
|
|
2083
|
+
t("doctorTitle"),
|
|
2084
|
+
"",
|
|
2085
|
+
`\u2713 Node.js ${node}`,
|
|
2086
|
+
`${session.hardware.cpu.appleSilicon ? "\u2713" : "\u25CB"} Apple Silicon ${apple}`,
|
|
2087
|
+
`${session.hardware.gpu.metal ? "\u2713" : "\u25CB"} Metal`,
|
|
2088
|
+
...session.runtimes.map((r) => `${r.detected ? "\u2713" : "\u25CB"} ${r.label.padEnd(14)} ${r.version ?? ""}`.trimEnd()),
|
|
2089
|
+
`${session.networkUsed ? "\u2713" : "\u25CB"} CanIRun.ai (midudev)`,
|
|
2090
|
+
"",
|
|
2091
|
+
session.llamaBench ? t("doctorReady", { count: session.models.filter((m) => m.artifactPath).length }) : t("doctorNoBench")
|
|
2092
|
+
];
|
|
2093
|
+
return lines.join("\n");
|
|
2094
|
+
}
|
|
2095
|
+
function reportMarkdown(record) {
|
|
2096
|
+
const machine = `${record.machine.cpu ?? "unknown"} / ${record.machine.memoryGb ?? "?"} GB`;
|
|
2097
|
+
return `## ${t("reportTitle")}
|
|
2098
|
+
|
|
2099
|
+
**Machine:** ${machine}
|
|
2100
|
+
**Model:** ${record.model.id}
|
|
2101
|
+
|
|
2102
|
+
| Metric | Result |
|
|
2103
|
+
|---|---:|
|
|
2104
|
+
| Prompt processing | ${record.benchmark.promptTokensPerSecond ?? "N/A"} t/s |
|
|
2105
|
+
| Generation | ${record.benchmark.generationTokensPerSecond ?? "N/A"} t/s |
|
|
2106
|
+
| Peak memory | ${record.resources.peakMemoryGb ?? "N/A"} GB |
|
|
2107
|
+
| Average GPU | ${record.resources.avgGpuPercent ?? "N/A"}% |
|
|
2108
|
+
| Average power | ${record.resources.avgPackagePowerWatts ?? "N/A"} W |
|
|
2109
|
+
| Efficiency | ${record.resources.tokensPerSecondPerWatt ?? "N/A"} t/s/W |
|
|
2110
|
+
| ${t("scoreLabel")} | ${record.score?.total ?? "N/A"} / 100 |
|
|
2111
|
+
`;
|
|
2112
|
+
}
|
|
2113
|
+
function compareText(records) {
|
|
2114
|
+
const latestByModel = /* @__PURE__ */ new Map();
|
|
2115
|
+
for (const record of records) latestByModel.set(record.model.id, record);
|
|
2116
|
+
const list = [...latestByModel.values()].slice(0, 4);
|
|
2117
|
+
if (list.length < 2) return t("compareNeedTwo");
|
|
2118
|
+
const names = list.map((r) => r.model.id);
|
|
2119
|
+
const row = (label, pick) => `${label.padEnd(20)}${list.map((r) => pick(r).padStart(14)).join("")}`;
|
|
2120
|
+
const winner = (metric, higher = true) => {
|
|
2121
|
+
let best;
|
|
2122
|
+
for (const r of list) {
|
|
2123
|
+
const v = metric(r);
|
|
2124
|
+
if (v === void 0) continue;
|
|
2125
|
+
if (!best) best = r;
|
|
2126
|
+
else {
|
|
2127
|
+
const b = metric(best);
|
|
2128
|
+
if (b === void 0) best = r;
|
|
2129
|
+
else if (higher ? v > b : v < b) best = r;
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
return best?.model.id ?? "N/A";
|
|
2133
|
+
};
|
|
2134
|
+
return [
|
|
2135
|
+
t("compareTitle"),
|
|
2136
|
+
names.join(" vs "),
|
|
2137
|
+
row("Generation t/s", (r) => formatNumber(r.benchmark.generationTokensPerSecond)),
|
|
2138
|
+
row("Prompt t/s", (r) => formatNumber(r.benchmark.promptTokensPerSecond)),
|
|
2139
|
+
row("Peak RAM", (r) => r.resources.peakMemoryGb ? `${r.resources.peakMemoryGb} GB` : "N/A"),
|
|
2140
|
+
row("Avg CPU", (r) => r.resources.avgCpuPercent ? `${r.resources.avgCpuPercent}%` : "N/A"),
|
|
2141
|
+
row("Power", (r) => r.resources.avgPackagePowerWatts ? `${r.resources.avgPackagePowerWatts} W` : "N/A"),
|
|
2142
|
+
row("t/s/W", (r) => formatNumber(r.resources.tokensPerSecondPerWatt)),
|
|
2143
|
+
"",
|
|
2144
|
+
t("winner"),
|
|
2145
|
+
`Speed ${winner((r) => r.benchmark.generationTokensPerSecond)}`,
|
|
2146
|
+
`Memory ${winner((r) => r.resources.peakMemoryGb, false)}`,
|
|
2147
|
+
`Efficiency ${winner((r) => r.resources.tokensPerSecondPerWatt)}`,
|
|
2148
|
+
t("qualityNote")
|
|
2149
|
+
].join("\n");
|
|
2150
|
+
}
|
|
2151
|
+
function resultText(record, assessment) {
|
|
2152
|
+
return [
|
|
2153
|
+
record.model.id,
|
|
2154
|
+
"",
|
|
2155
|
+
`Prompt processing ${record.benchmark.promptTokensPerSecond ?? "N/A"} t/s`,
|
|
2156
|
+
`Generation ${record.benchmark.generationTokensPerSecond ?? "N/A"} t/s`,
|
|
2157
|
+
`Peak memory ${record.resources.peakMemoryGb ?? "N/A"} GB`,
|
|
2158
|
+
`Average GPU ${record.resources.avgGpuPercent ?? "N/A"} %`,
|
|
2159
|
+
`Average CPU ${record.resources.avgCpuPercent ?? "N/A"} %`,
|
|
2160
|
+
`Average power ${record.resources.avgPackagePowerWatts ?? "N/A"} W`,
|
|
2161
|
+
`Efficiency ${record.resources.tokensPerSecondPerWatt ?? "N/A"} t/s/W`,
|
|
2162
|
+
"",
|
|
2163
|
+
`${t("scoreLabel")} ${record.score?.total ?? "N/A"}/100`,
|
|
2164
|
+
"",
|
|
2165
|
+
...assessment.map((line) => `\u2713 ${line}`)
|
|
2166
|
+
].join("\n");
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
// src/tui/views/Models.tsx
|
|
2170
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
2171
|
+
function ModelsView({ session }) {
|
|
2172
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
|
|
2173
|
+
/* @__PURE__ */ jsx2(Text2, { children: modelsText(session) }),
|
|
2174
|
+
/* @__PURE__ */ jsx2(Text2, { dimColor: true, children: t("modelsHint") })
|
|
2175
|
+
] });
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// src/tui/views/Hardware.tsx
|
|
2179
|
+
import { Box as Box3, Text as Text3 } from "ink";
|
|
2180
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
2181
|
+
function HardwareView({ session }) {
|
|
2182
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
|
|
2183
|
+
/* @__PURE__ */ jsx3(Text3, { children: hardwareText(session) }),
|
|
2184
|
+
/* @__PURE__ */ jsx3(Text3, { children: runtimesText(session) }),
|
|
2185
|
+
/* @__PURE__ */ jsx3(Text3, { dimColor: true, children: t("hardwareHint") })
|
|
2186
|
+
] });
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
// src/tui/views/Recommend.tsx
|
|
2190
|
+
import { Box as Box4, Text as Text4 } from "ink";
|
|
2191
|
+
import { jsx as jsx4 } from "react/jsx-runtime";
|
|
2192
|
+
function RecommendView({ session }) {
|
|
2193
|
+
return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", children: /* @__PURE__ */ jsx4(Text4, { children: recommendText(session) }) });
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
// src/tui/views/Compare.tsx
|
|
2197
|
+
import { Box as Box5, Text as Text5 } from "ink";
|
|
2198
|
+
import { jsx as jsx5 } from "react/jsx-runtime";
|
|
2199
|
+
function CompareView({ session }) {
|
|
2200
|
+
return /* @__PURE__ */ jsx5(Box5, { flexDirection: "column", children: /* @__PURE__ */ jsx5(Text5, { children: compareText(session.history) }) });
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
// src/tui/views/Benchmark.tsx
|
|
2204
|
+
import { Box as Box6, Text as Text6, useInput } from "ink";
|
|
2205
|
+
import { useEffect, useRef, useState } from "react";
|
|
2206
|
+
|
|
2207
|
+
// src/tui/theme.ts
|
|
2208
|
+
function bar(percent, width = 10) {
|
|
2209
|
+
if (percent === void 0 || Number.isNaN(percent)) return `${"\u2591".repeat(width)} N/A`;
|
|
2210
|
+
const filled = Math.max(0, Math.min(width, Math.round(percent / 100 * width)));
|
|
2211
|
+
return `${"\u2588".repeat(filled)}${"\u2591".repeat(width - filled)}`;
|
|
2212
|
+
}
|
|
2213
|
+
function sparkline(values, width = 24) {
|
|
2214
|
+
const glyphs = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
|
|
2215
|
+
if (!values.length) return glyphs[0].repeat(width);
|
|
2216
|
+
const slice = values.slice(-width);
|
|
2217
|
+
const max = Math.max(...slice, 1);
|
|
2218
|
+
return slice.map((v) => glyphs[Math.min(glyphs.length - 1, Math.floor(v / max * (glyphs.length - 1)))]).join("").padStart(width, glyphs[0]);
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
// src/tui/views/Benchmark.tsx
|
|
2222
|
+
import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
2223
|
+
function BenchmarkView(props) {
|
|
2224
|
+
const [phase, setPhase] = useState(props.models.length ? "select" : "error");
|
|
2225
|
+
const [sample, setSample] = useState();
|
|
2226
|
+
const [elapsed, setElapsed] = useState(0);
|
|
2227
|
+
const [result, setResult] = useState();
|
|
2228
|
+
const [error, setError] = useState(props.models.length ? "" : t("noGguf"));
|
|
2229
|
+
const cpuHist = useRef([]);
|
|
2230
|
+
const memHist = useRef([]);
|
|
2231
|
+
const abortRef = useRef(void 0);
|
|
2232
|
+
useInput((input, key) => {
|
|
2233
|
+
if (phase === "select") {
|
|
2234
|
+
if (key.upArrow) props.setSelected(Math.max(0, props.selected - 1));
|
|
2235
|
+
if (key.downArrow) props.setSelected(Math.min(props.models.length - 1, props.selected + 1));
|
|
2236
|
+
if (key.return && props.models[props.selected]) setPhase("running");
|
|
2237
|
+
if (input.toLowerCase() === "q" || key.escape) props.onBack();
|
|
2238
|
+
} else if (phase === "running" && (input.toLowerCase() === "q" || key.escape)) {
|
|
2239
|
+
abortRef.current?.abort();
|
|
2240
|
+
} else if ((phase === "done" || phase === "error") && (input.toLowerCase() === "q" || key.escape || key.return)) {
|
|
2241
|
+
props.onBack();
|
|
2242
|
+
}
|
|
2243
|
+
});
|
|
2244
|
+
useEffect(() => {
|
|
2245
|
+
if (phase !== "running") return;
|
|
2246
|
+
const model2 = props.models[props.selected];
|
|
2247
|
+
if (!model2) return;
|
|
2248
|
+
const controller = new AbortController();
|
|
2249
|
+
abortRef.current = controller;
|
|
2250
|
+
void (async () => {
|
|
2251
|
+
try {
|
|
2252
|
+
const catalog = props.session.rows.find((r) => r.local.id === model2.id)?.catalog;
|
|
2253
|
+
const out = await runBenchmark({
|
|
2254
|
+
model: model2,
|
|
2255
|
+
hardware: props.session.hardware,
|
|
2256
|
+
preset: props.preset,
|
|
2257
|
+
catalog,
|
|
2258
|
+
signal: controller.signal,
|
|
2259
|
+
onProgress: (p) => {
|
|
2260
|
+
setElapsed(p.elapsedSeconds);
|
|
2261
|
+
if (p.sample) {
|
|
2262
|
+
setSample(p.sample);
|
|
2263
|
+
if (p.sample.cpu?.utilizationPercent !== void 0) {
|
|
2264
|
+
cpuHist.current = [...cpuHist.current, p.sample.cpu.utilizationPercent].slice(-32);
|
|
2265
|
+
}
|
|
2266
|
+
memHist.current = [...memHist.current, p.sample.memory.usedBytes].slice(-32);
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
});
|
|
2270
|
+
setResult(out);
|
|
2271
|
+
setPhase("done");
|
|
2272
|
+
} catch (err) {
|
|
2273
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
2274
|
+
setPhase("error");
|
|
2275
|
+
}
|
|
2276
|
+
})();
|
|
2277
|
+
return () => controller.abort();
|
|
2278
|
+
}, [phase, props.models, props.preset, props.selected, props.session]);
|
|
2279
|
+
const h = props.session.hardware;
|
|
2280
|
+
const model = props.models[props.selected];
|
|
2281
|
+
if (phase === "select") {
|
|
2282
|
+
return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
|
|
2283
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, children: t("selectBenchmark") }),
|
|
2284
|
+
props.models.map((m, i) => /* @__PURE__ */ jsxs4(Text6, { color: i === props.selected ? "cyan" : void 0, children: [
|
|
2285
|
+
i === props.selected ? "\u276F " : " ",
|
|
2286
|
+
m.name
|
|
2287
|
+
] }, m.id)),
|
|
2288
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: t("enterToRun") })
|
|
2289
|
+
] });
|
|
2290
|
+
}
|
|
2291
|
+
if (phase === "done" && result) {
|
|
2292
|
+
return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
|
|
2293
|
+
/* @__PURE__ */ jsx6(Text6, { children: resultText(result.record, result.assessment) }),
|
|
2294
|
+
/* @__PURE__ */ jsxs4(Text6, { dimColor: true, children: [
|
|
2295
|
+
t("saved"),
|
|
2296
|
+
" ",
|
|
2297
|
+
result.path
|
|
2298
|
+
] })
|
|
2299
|
+
] });
|
|
2300
|
+
}
|
|
2301
|
+
if (phase === "error") {
|
|
2302
|
+
return /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", children: /* @__PURE__ */ jsx6(Text6, { color: "red", children: error }) });
|
|
2303
|
+
}
|
|
2304
|
+
const cpu = sample?.cpu?.utilizationPercent;
|
|
2305
|
+
const gpu = sample?.gpu?.utilizationPercent;
|
|
2306
|
+
const memUsed = sample?.memory.usedBytes;
|
|
2307
|
+
const memTotal = h.memory.totalBytes;
|
|
2308
|
+
return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
|
|
2309
|
+
/* @__PURE__ */ jsxs4(Box6, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, children: [
|
|
2310
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, children: t("benchmarkTitle") }),
|
|
2311
|
+
/* @__PURE__ */ jsx6(Text6, { children: model?.name }),
|
|
2312
|
+
/* @__PURE__ */ jsxs4(Text6, { dimColor: true, children: [
|
|
2313
|
+
h.cpu.name,
|
|
2314
|
+
" \xB7 ",
|
|
2315
|
+
formatBytes(h.memory.totalBytes, 0),
|
|
2316
|
+
" ",
|
|
2317
|
+
memoryLabel(h)
|
|
2318
|
+
] })
|
|
2319
|
+
] }),
|
|
2320
|
+
/* @__PURE__ */ jsxs4(Box6, { marginTop: 1, flexDirection: "column", children: [
|
|
2321
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, color: "cyan", children: t("performance") }),
|
|
2322
|
+
/* @__PURE__ */ jsxs4(Text6, { children: [
|
|
2323
|
+
t("elapsed"),
|
|
2324
|
+
" ",
|
|
2325
|
+
elapsed.toFixed(1),
|
|
2326
|
+
" s"
|
|
2327
|
+
] }),
|
|
2328
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: t("measuredAfter") })
|
|
2329
|
+
] }),
|
|
2330
|
+
/* @__PURE__ */ jsxs4(Box6, { marginTop: 1, flexDirection: "column", children: [
|
|
2331
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, color: "cyan", children: t("system") }),
|
|
2332
|
+
/* @__PURE__ */ jsxs4(Text6, { children: [
|
|
2333
|
+
"CPU ",
|
|
2334
|
+
bar(cpu),
|
|
2335
|
+
" ",
|
|
2336
|
+
formatPercent(cpu)
|
|
2337
|
+
] }),
|
|
2338
|
+
/* @__PURE__ */ jsxs4(Text6, { children: [
|
|
2339
|
+
"GPU ",
|
|
2340
|
+
bar(gpu),
|
|
2341
|
+
" ",
|
|
2342
|
+
formatPercent(gpu)
|
|
2343
|
+
] }),
|
|
2344
|
+
/* @__PURE__ */ jsxs4(Text6, { children: [
|
|
2345
|
+
"Memory ",
|
|
2346
|
+
bar(memUsed && memTotal ? memUsed / memTotal * 100 : void 0),
|
|
2347
|
+
" ",
|
|
2348
|
+
memUsed ? formatBytes(memUsed) : "N/A",
|
|
2349
|
+
" / ",
|
|
2350
|
+
formatBytes(memTotal, 0)
|
|
2351
|
+
] }),
|
|
2352
|
+
/* @__PURE__ */ jsxs4(Text6, { children: [
|
|
2353
|
+
"Swap ",
|
|
2354
|
+
bar(0),
|
|
2355
|
+
" ",
|
|
2356
|
+
sample ? formatBytes(sample.memory.swapUsedBytes) : "N/A"
|
|
2357
|
+
] })
|
|
2358
|
+
] }),
|
|
2359
|
+
/* @__PURE__ */ jsxs4(Box6, { marginTop: 1, flexDirection: "column", children: [
|
|
2360
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, color: "cyan", children: t("powerThermals") }),
|
|
2361
|
+
/* @__PURE__ */ jsxs4(Text6, { children: [
|
|
2362
|
+
"Package ",
|
|
2363
|
+
sample?.packagePowerWatts ?? "N/A",
|
|
2364
|
+
" W"
|
|
2365
|
+
] }),
|
|
2366
|
+
/* @__PURE__ */ jsxs4(Text6, { children: [
|
|
2367
|
+
"Temperature ",
|
|
2368
|
+
sample?.thermal?.temperatureC ?? "N/A",
|
|
2369
|
+
" \xB0C"
|
|
2370
|
+
] }),
|
|
2371
|
+
/* @__PURE__ */ jsxs4(Text6, { children: [
|
|
2372
|
+
"Thermal Pressure ",
|
|
2373
|
+
sample?.thermal?.pressure ?? "N/A"
|
|
2374
|
+
] }),
|
|
2375
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: t("permissionsHint") })
|
|
2376
|
+
] }),
|
|
2377
|
+
/* @__PURE__ */ jsxs4(Text6, { children: [
|
|
2378
|
+
sparkline(cpuHist.current),
|
|
2379
|
+
" CPU"
|
|
2380
|
+
] }),
|
|
2381
|
+
/* @__PURE__ */ jsxs4(Text6, { children: [
|
|
2382
|
+
sparkline(memHist.current.map((v) => v / 1024 ** 3)),
|
|
2383
|
+
" Memory"
|
|
2384
|
+
] }),
|
|
2385
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: t("cancelHint") })
|
|
2386
|
+
] });
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
// src/tui/views/History.tsx
|
|
2390
|
+
import { Box as Box7, Text as Text7 } from "ink";
|
|
2391
|
+
import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
2392
|
+
function HistoryView({ session }) {
|
|
2393
|
+
return /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", children: [
|
|
2394
|
+
/* @__PURE__ */ jsx7(Text7, { bold: true, color: "cyan", children: t("measured") }),
|
|
2395
|
+
/* @__PURE__ */ jsx7(Text7, { children: historyText(session.history) })
|
|
2396
|
+
] });
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
// src/tui/views/Tasks.tsx
|
|
2400
|
+
import { Box as Box8, Text as Text8, useInput as useInput2 } from "ink";
|
|
2401
|
+
import { useMemo, useState as useState2 } from "react";
|
|
2402
|
+
|
|
2403
|
+
// src/tasks/catalog.ts
|
|
2404
|
+
var TASK_CATALOG = [
|
|
2405
|
+
{
|
|
2406
|
+
id: "code-review",
|
|
2407
|
+
kind: "code",
|
|
2408
|
+
useCases: ["code"],
|
|
2409
|
+
titleKey: "taskCodeReviewTitle",
|
|
2410
|
+
promptKey: "taskCodeReviewPrompt",
|
|
2411
|
+
harnessId: "H-CODE-REVIEW"
|
|
2412
|
+
},
|
|
2413
|
+
{
|
|
2414
|
+
id: "code-tests",
|
|
2415
|
+
kind: "code",
|
|
2416
|
+
useCases: ["code"],
|
|
2417
|
+
titleKey: "taskCodeTestsTitle",
|
|
2418
|
+
promptKey: "taskCodeTestsPrompt",
|
|
2419
|
+
harnessId: "H-CODE-TESTS"
|
|
2420
|
+
},
|
|
2421
|
+
{
|
|
2422
|
+
id: "chat-plan",
|
|
2423
|
+
kind: "chat",
|
|
2424
|
+
useCases: ["chat"],
|
|
2425
|
+
titleKey: "taskChatPlanTitle",
|
|
2426
|
+
promptKey: "taskChatPlanPrompt",
|
|
2427
|
+
harnessId: "H-CHAT-PLAN"
|
|
2428
|
+
},
|
|
2429
|
+
{
|
|
2430
|
+
id: "chat-agent",
|
|
2431
|
+
kind: "chat",
|
|
2432
|
+
useCases: ["chat", "code"],
|
|
2433
|
+
titleKey: "taskChatAgentTitle",
|
|
2434
|
+
promptKey: "taskChatAgentPrompt",
|
|
2435
|
+
harnessId: "H-CHAT-AGENT"
|
|
2436
|
+
},
|
|
2437
|
+
{
|
|
2438
|
+
id: "image-caption",
|
|
2439
|
+
kind: "image",
|
|
2440
|
+
useCases: ["image", "vision"],
|
|
2441
|
+
titleKey: "taskImageCaptionTitle",
|
|
2442
|
+
promptKey: "taskImageCaptionPrompt",
|
|
2443
|
+
harnessId: "H-IMAGE-CAPTION"
|
|
2444
|
+
},
|
|
2445
|
+
{
|
|
2446
|
+
id: "image-edit-brief",
|
|
2447
|
+
kind: "image",
|
|
2448
|
+
useCases: ["image", "vision"],
|
|
2449
|
+
titleKey: "taskImageBriefTitle",
|
|
2450
|
+
promptKey: "taskImageBriefPrompt",
|
|
2451
|
+
harnessId: "H-IMAGE-BRIEF"
|
|
2452
|
+
},
|
|
2453
|
+
{
|
|
2454
|
+
id: "video-storyboard",
|
|
2455
|
+
kind: "video",
|
|
2456
|
+
useCases: ["video"],
|
|
2457
|
+
titleKey: "taskVideoBoardTitle",
|
|
2458
|
+
promptKey: "taskVideoBoardPrompt",
|
|
2459
|
+
harnessId: "H-VIDEO-BOARD"
|
|
2460
|
+
},
|
|
2461
|
+
{
|
|
2462
|
+
id: "video-shotlist",
|
|
2463
|
+
kind: "video",
|
|
2464
|
+
useCases: ["video"],
|
|
2465
|
+
titleKey: "taskVideoShotTitle",
|
|
2466
|
+
promptKey: "taskVideoShotPrompt",
|
|
2467
|
+
harnessId: "H-VIDEO-SHOTS"
|
|
2468
|
+
},
|
|
2469
|
+
{
|
|
2470
|
+
id: "transcribe-clean",
|
|
2471
|
+
kind: "transcription",
|
|
2472
|
+
useCases: ["chat", "multilingual"],
|
|
2473
|
+
titleKey: "taskTranscribeCleanTitle",
|
|
2474
|
+
promptKey: "taskTranscribeCleanPrompt",
|
|
2475
|
+
harnessId: "H-ASR-CLEAN"
|
|
2476
|
+
},
|
|
2477
|
+
{
|
|
2478
|
+
id: "transcribe-actions",
|
|
2479
|
+
kind: "transcription",
|
|
2480
|
+
useCases: ["chat", "code"],
|
|
2481
|
+
titleKey: "taskTranscribeActionsTitle",
|
|
2482
|
+
promptKey: "taskTranscribeActionsPrompt",
|
|
2483
|
+
harnessId: "H-ASR-ACTIONS"
|
|
2484
|
+
}
|
|
2485
|
+
];
|
|
2486
|
+
|
|
2487
|
+
// src/tasks/types.ts
|
|
2488
|
+
var WORK_KINDS = ["code", "video", "image", "transcription", "chat"];
|
|
2489
|
+
|
|
2490
|
+
// src/tasks/plan.ts
|
|
2491
|
+
function parseKinds(raw) {
|
|
2492
|
+
if (!raw) return [...WORK_KINDS];
|
|
2493
|
+
const parts = raw.split(",").map((p) => p.trim().toLowerCase());
|
|
2494
|
+
const kinds = WORK_KINDS.filter((k) => parts.includes(k));
|
|
2495
|
+
return kinds.length ? kinds : [...WORK_KINDS];
|
|
2496
|
+
}
|
|
2497
|
+
function parseAnswers(input) {
|
|
2498
|
+
return {
|
|
2499
|
+
kinds: parseKinds(input.for),
|
|
2500
|
+
scope: input.scope === "all" ? "all" : "installed",
|
|
2501
|
+
priority: input.priority === "quality" || input.priority === "speed" ? input.priority : "balanced"
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
function kindMatches(kind, useCases) {
|
|
2505
|
+
if (kind === "code") return useCases.some((u) => u.includes("code"));
|
|
2506
|
+
if (kind === "image") return useCases.some((u) => u.includes("image") || u.includes("vision"));
|
|
2507
|
+
if (kind === "video") return useCases.some((u) => u.includes("video"));
|
|
2508
|
+
if (kind === "transcription") return useCases.some((u) => u.includes("chat") || u.includes("multilingual"));
|
|
2509
|
+
return useCases.some((u) => u.includes("chat") || u.includes("reasoning") || u.includes("code"));
|
|
2510
|
+
}
|
|
2511
|
+
function planTasks(session, answers) {
|
|
2512
|
+
const wanted = new Set(answers.kinds);
|
|
2513
|
+
const defs = TASK_CATALOG.filter((task) => wanted.has(task.kind));
|
|
2514
|
+
const plans = [];
|
|
2515
|
+
const installed2 = session.rows.map((row) => {
|
|
2516
|
+
const useCases = row.catalog?.useCase ?? ["chat", "code"];
|
|
2517
|
+
const kinds = answers.kinds.filter((kind) => kindMatches(kind, useCases));
|
|
2518
|
+
return {
|
|
2519
|
+
modelId: row.local.id,
|
|
2520
|
+
modelName: row.local.name,
|
|
2521
|
+
installed: true,
|
|
2522
|
+
origin: row.lastBenchmark ? "measured" : "estimated",
|
|
2523
|
+
grade: row.compatibility?.grade,
|
|
2524
|
+
useCases,
|
|
2525
|
+
kinds
|
|
2526
|
+
};
|
|
2527
|
+
});
|
|
2528
|
+
const catalogExtras = answers.scope === "all" ? session.catalog.filter((model) => !session.rows.some((row) => idsLikelyMatch(row.local.id, model.id))).map((model) => {
|
|
2529
|
+
const useCases = model.useCase ?? [];
|
|
2530
|
+
const kinds = answers.kinds.filter((kind) => kindMatches(kind, useCases));
|
|
2531
|
+
const fit = localCompatibility(session.hardware, model);
|
|
2532
|
+
return {
|
|
2533
|
+
modelId: model.id,
|
|
2534
|
+
modelName: model.name,
|
|
2535
|
+
installed: false,
|
|
2536
|
+
origin: "estimated",
|
|
2537
|
+
grade: fit.grade,
|
|
2538
|
+
useCases,
|
|
2539
|
+
kinds
|
|
2540
|
+
};
|
|
2541
|
+
}).filter((row) => row.kinds.length && row.grade !== "F") : [];
|
|
2542
|
+
const ranked = [...installed2, ...catalogExtras].filter((row) => row.kinds.length);
|
|
2543
|
+
ranked.sort((a, b) => {
|
|
2544
|
+
if (a.installed !== b.installed) return a.installed ? -1 : 1;
|
|
2545
|
+
if (answers.priority === "speed") return (a.grade ?? "C").localeCompare(b.grade ?? "C");
|
|
2546
|
+
if (answers.priority === "quality") return (b.grade ?? "C").localeCompare(a.grade ?? "C");
|
|
2547
|
+
return 0;
|
|
2548
|
+
});
|
|
2549
|
+
for (const row of ranked.slice(0, 8)) {
|
|
2550
|
+
const tasks = defs.filter((def) => row.kinds.includes(def.kind) && def.useCases.some((u) => row.useCases.includes(u) || kindMatches(def.kind, row.useCases))).slice(0, 3).map((def) => ({
|
|
2551
|
+
id: def.id,
|
|
2552
|
+
harnessId: def.harnessId,
|
|
2553
|
+
kind: def.kind,
|
|
2554
|
+
title: t(def.titleKey),
|
|
2555
|
+
prompt: t(def.promptKey)
|
|
2556
|
+
}));
|
|
2557
|
+
if (!tasks.length) continue;
|
|
2558
|
+
plans.push({
|
|
2559
|
+
modelId: row.modelId,
|
|
2560
|
+
modelName: row.modelName,
|
|
2561
|
+
installed: row.installed,
|
|
2562
|
+
origin: row.origin,
|
|
2563
|
+
grade: row.grade,
|
|
2564
|
+
kinds: row.kinds,
|
|
2565
|
+
tasks
|
|
2566
|
+
});
|
|
2567
|
+
}
|
|
2568
|
+
return plans;
|
|
2569
|
+
}
|
|
2570
|
+
function tasksText(plans, answers) {
|
|
2571
|
+
const header = [
|
|
2572
|
+
t("tasksTitle"),
|
|
2573
|
+
t("tasksHint"),
|
|
2574
|
+
`${t("tasksKinds")}: ${answers.kinds.join(", ")}`,
|
|
2575
|
+
`${t("tasksScope")}: ${answers.scope}`,
|
|
2576
|
+
`${t("tasksPriority")}: ${answers.priority}`,
|
|
2577
|
+
t("credits"),
|
|
2578
|
+
""
|
|
2579
|
+
];
|
|
2580
|
+
if (!plans.length) return [...header, t("tasksEmpty")].join("\n");
|
|
2581
|
+
const blocks = plans.map((plan) => {
|
|
2582
|
+
const inst = plan.installed ? t("tasksInstalled") : t("tasksNotInstalled");
|
|
2583
|
+
const origin = plan.origin === "measured" ? t("measured") : t("estimated");
|
|
2584
|
+
const lines = [
|
|
2585
|
+
`${plan.modelName} [${inst}] ${plan.grade ?? "\u2014"} ${origin}`,
|
|
2586
|
+
` ${plan.kinds.join(", ")}`,
|
|
2587
|
+
...plan.tasks.map((task) => ` ${task.harnessId} ${task.title}
|
|
2588
|
+
${task.prompt}`)
|
|
2589
|
+
];
|
|
2590
|
+
return lines.join("\n");
|
|
2591
|
+
});
|
|
2592
|
+
return [...header, ...blocks].join("\n\n");
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
// src/tui/views/Tasks.tsx
|
|
2596
|
+
import { Fragment, jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
2597
|
+
var KIND_LABEL = {
|
|
2598
|
+
code: "tasksOptCode",
|
|
2599
|
+
video: "tasksOptVideo",
|
|
2600
|
+
image: "tasksOptImage",
|
|
2601
|
+
transcription: "tasksOptTranscription",
|
|
2602
|
+
chat: "tasksOptChat"
|
|
2603
|
+
};
|
|
2604
|
+
function TasksView({ session }) {
|
|
2605
|
+
const [step, setStep] = useState2(0);
|
|
2606
|
+
const [cursor, setCursor] = useState2(0);
|
|
2607
|
+
const [kinds, setKinds] = useState2(["code", "chat"]);
|
|
2608
|
+
const [scope, setScope] = useState2("installed");
|
|
2609
|
+
const [priority, setPriority] = useState2("balanced");
|
|
2610
|
+
const plans = useMemo(
|
|
2611
|
+
() => planTasks(session, { kinds, scope, priority }),
|
|
2612
|
+
[session, kinds, scope, priority]
|
|
2613
|
+
);
|
|
2614
|
+
useInput2((input, key) => {
|
|
2615
|
+
if (step === 0) {
|
|
2616
|
+
if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
|
|
2617
|
+
if (key.downArrow) setCursor((c) => Math.min(WORK_KINDS.length - 1, c + 1));
|
|
2618
|
+
if (input === " " || input === "x") {
|
|
2619
|
+
const kind = WORK_KINDS[cursor];
|
|
2620
|
+
if (!kind) return;
|
|
2621
|
+
setKinds(
|
|
2622
|
+
(current2) => current2.includes(kind) ? current2.filter((k) => k !== kind) : [...current2, kind]
|
|
2623
|
+
);
|
|
2624
|
+
}
|
|
2625
|
+
if (key.return && kinds.length) setStep(1);
|
|
2626
|
+
} else if (step === 1) {
|
|
2627
|
+
if (key.upArrow || key.downArrow) setScope((s) => s === "installed" ? "all" : "installed");
|
|
2628
|
+
if (key.return) setStep(2);
|
|
2629
|
+
} else if (step === 2) {
|
|
2630
|
+
const order = ["speed", "balanced", "quality"];
|
|
2631
|
+
if (key.upArrow) setPriority(order[Math.max(0, order.indexOf(priority) - 1)] ?? "speed");
|
|
2632
|
+
if (key.downArrow) setPriority(order[Math.min(2, order.indexOf(priority) + 1)] ?? "quality");
|
|
2633
|
+
if (key.return) setStep(3);
|
|
2634
|
+
}
|
|
2635
|
+
});
|
|
2636
|
+
if (step === 3) {
|
|
2637
|
+
return /* @__PURE__ */ jsx8(Box8, { flexDirection: "column", children: /* @__PURE__ */ jsx8(Text8, { children: tasksText(plans, { kinds, scope, priority }) }) });
|
|
2638
|
+
}
|
|
2639
|
+
return /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", children: [
|
|
2640
|
+
/* @__PURE__ */ jsx8(Text8, { bold: true, color: "cyan", children: t("tasksTitle") }),
|
|
2641
|
+
step === 0 && /* @__PURE__ */ jsxs6(Fragment, { children: [
|
|
2642
|
+
/* @__PURE__ */ jsx8(Text8, { children: t("tasksQ1") }),
|
|
2643
|
+
WORK_KINDS.map((kind, i) => /* @__PURE__ */ jsxs6(Text8, { color: i === cursor ? "cyan" : void 0, children: [
|
|
2644
|
+
i === cursor ? "\u276F " : " ",
|
|
2645
|
+
"[",
|
|
2646
|
+
kinds.includes(kind) ? "x" : " ",
|
|
2647
|
+
"] ",
|
|
2648
|
+
t(KIND_LABEL[kind])
|
|
2649
|
+
] }, kind))
|
|
2650
|
+
] }),
|
|
2651
|
+
step === 1 && /* @__PURE__ */ jsxs6(Fragment, { children: [
|
|
2652
|
+
/* @__PURE__ */ jsx8(Text8, { children: t("tasksQ2") }),
|
|
2653
|
+
/* @__PURE__ */ jsxs6(Text8, { color: scope === "installed" ? "cyan" : void 0, children: [
|
|
2654
|
+
scope === "installed" ? "\u276F " : " ",
|
|
2655
|
+
t("tasksOptInstalled")
|
|
2656
|
+
] }),
|
|
2657
|
+
/* @__PURE__ */ jsxs6(Text8, { color: scope === "all" ? "cyan" : void 0, children: [
|
|
2658
|
+
scope === "all" ? "\u276F " : " ",
|
|
2659
|
+
t("tasksOptAll")
|
|
2660
|
+
] })
|
|
2661
|
+
] }),
|
|
2662
|
+
step === 2 && /* @__PURE__ */ jsxs6(Fragment, { children: [
|
|
2663
|
+
/* @__PURE__ */ jsx8(Text8, { children: t("tasksQ3") }),
|
|
2664
|
+
["speed", "balanced", "quality"].map((item) => /* @__PURE__ */ jsxs6(Text8, { color: priority === item ? "cyan" : void 0, children: [
|
|
2665
|
+
priority === item ? "\u276F " : " ",
|
|
2666
|
+
item === "speed" ? t("tasksOptSpeed") : item === "quality" ? t("tasksOptQuality") : t("tasksOptBalanced")
|
|
2667
|
+
] }, item))
|
|
2668
|
+
] })
|
|
2669
|
+
] });
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
// src/tui/App.tsx
|
|
2673
|
+
import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
2674
|
+
function App(props) {
|
|
2675
|
+
const { exit } = useApp();
|
|
2676
|
+
const [screen, setScreen] = useState3("home");
|
|
2677
|
+
const [selected, setSelected] = useState3(0);
|
|
2678
|
+
const benchable = useMemo2(
|
|
2679
|
+
() => props.session.models.filter((m) => Boolean(m.artifactPath)),
|
|
2680
|
+
[props.session.models]
|
|
2681
|
+
);
|
|
2682
|
+
useInput3((input, key) => {
|
|
2683
|
+
if (screen === "benchmark") return;
|
|
2684
|
+
const letter = input.toLowerCase();
|
|
2685
|
+
if (letter === "q" || key.escape) exit();
|
|
2686
|
+
if (letter === "b") setScreen("benchmark");
|
|
2687
|
+
if (letter === "m") setScreen("models");
|
|
2688
|
+
if (letter === "r") setScreen("recommend");
|
|
2689
|
+
if (letter === "h") setScreen("hardware");
|
|
2690
|
+
if (letter === "c") setScreen("compare");
|
|
2691
|
+
if (letter === "l") setScreen("history");
|
|
2692
|
+
if (letter === "t") setScreen("tasks");
|
|
2693
|
+
if (key.return && screen === "home") setScreen("benchmark");
|
|
2694
|
+
});
|
|
2695
|
+
return /* @__PURE__ */ jsxs7(Box9, { flexDirection: "column", padding: 1, children: [
|
|
2696
|
+
screen === "home" && /* @__PURE__ */ jsx9(Dashboard, { session: props.session }),
|
|
2697
|
+
screen === "models" && /* @__PURE__ */ jsx9(ModelsView, { session: props.session }),
|
|
2698
|
+
screen === "hardware" && /* @__PURE__ */ jsx9(HardwareView, { session: props.session }),
|
|
2699
|
+
screen === "recommend" && /* @__PURE__ */ jsx9(RecommendView, { session: props.session }),
|
|
2700
|
+
screen === "compare" && /* @__PURE__ */ jsx9(CompareView, { session: props.session }),
|
|
2701
|
+
screen === "history" && /* @__PURE__ */ jsx9(HistoryView, { session: props.session }),
|
|
2702
|
+
screen === "tasks" && /* @__PURE__ */ jsx9(TasksView, { session: props.session }),
|
|
2703
|
+
screen === "benchmark" && /* @__PURE__ */ jsx9(
|
|
2704
|
+
BenchmarkView,
|
|
2705
|
+
{
|
|
2706
|
+
session: props.session,
|
|
2707
|
+
models: benchable,
|
|
2708
|
+
selected,
|
|
2709
|
+
setSelected,
|
|
2710
|
+
preset: props.preset,
|
|
2711
|
+
onBack: () => setScreen("home")
|
|
2712
|
+
}
|
|
2713
|
+
),
|
|
2714
|
+
screen !== "benchmark" && /* @__PURE__ */ jsx9(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: t("nav") }) })
|
|
2715
|
+
] });
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
// src/tui/render.tsx
|
|
2719
|
+
import { jsx as jsx10 } from "react/jsx-runtime";
|
|
2720
|
+
async function renderDashboard(session, preset) {
|
|
2721
|
+
const instance = render(/* @__PURE__ */ jsx10(App, { session, preset }));
|
|
2722
|
+
await instance.waitUntilExit();
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
// src/integrations/catalog.ts
|
|
2726
|
+
var INTEGRATIONS = {
|
|
2727
|
+
opencode: {
|
|
2728
|
+
id: "opencode",
|
|
2729
|
+
docsUrl: "https://docs.ollama.com/integrations/opencode",
|
|
2730
|
+
ollamaLaunch: "opencode",
|
|
2731
|
+
minGenerationTps: 12,
|
|
2732
|
+
allowedGrades: ["S", "A", "B"],
|
|
2733
|
+
useCases: ["code", "chat", "reasoning"]
|
|
2734
|
+
},
|
|
2735
|
+
openclaw: {
|
|
2736
|
+
id: "openclaw",
|
|
2737
|
+
docsUrl: "https://docs.ollama.com/integrations/openclaw",
|
|
2738
|
+
ollamaLaunch: "openclaw",
|
|
2739
|
+
minGenerationTps: 12,
|
|
2740
|
+
allowedGrades: ["S", "A", "B"],
|
|
2741
|
+
useCases: ["code", "chat", "reasoning"]
|
|
2742
|
+
},
|
|
2743
|
+
hermes: {
|
|
2744
|
+
id: "hermes",
|
|
2745
|
+
docsUrl: "https://docs.ollama.com/integrations/hermes",
|
|
2746
|
+
ollamaLaunch: "hermes",
|
|
2747
|
+
minGenerationTps: 12,
|
|
2748
|
+
allowedGrades: ["S", "A"],
|
|
2749
|
+
useCases: ["code", "chat", "reasoning"]
|
|
2750
|
+
},
|
|
2751
|
+
claude: {
|
|
2752
|
+
id: "claude",
|
|
2753
|
+
docsUrl: "https://docs.ollama.com/integrations/claude-code",
|
|
2754
|
+
ollamaLaunch: "claude",
|
|
2755
|
+
minGenerationTps: 12,
|
|
2756
|
+
allowedGrades: ["S", "A", "B"],
|
|
2757
|
+
useCases: ["code", "chat", "reasoning"]
|
|
2758
|
+
}
|
|
2759
|
+
};
|
|
2760
|
+
|
|
2761
|
+
// src/integrations/types.ts
|
|
2762
|
+
var INTEGRATION_IDS = ["opencode", "openclaw", "hermes", "claude"];
|
|
2763
|
+
|
|
2764
|
+
// src/integrations/decide.ts
|
|
2765
|
+
var GRADE_RANK = { S: 5, A: 4, B: 3, C: 2, D: 1, F: 0 };
|
|
2766
|
+
function toOllamaTag(id) {
|
|
2767
|
+
const compact = id.toLowerCase().replace(/_/g, "-");
|
|
2768
|
+
const match = compact.match(/^([a-z0-9.]+(?:-[a-z0-9.]+)*)-(\d+(?:\.\d+)?b)$/i);
|
|
2769
|
+
if (match) return `${match[1]}:${match[2]}`;
|
|
2770
|
+
if (compact.includes(":")) return compact;
|
|
2771
|
+
return compact;
|
|
2772
|
+
}
|
|
2773
|
+
function useCaseOk(useCases, needed) {
|
|
2774
|
+
if (!useCases?.length) return needed.includes("chat");
|
|
2775
|
+
return useCases.some((u) => needed.some((n) => u.includes(n)));
|
|
2776
|
+
}
|
|
2777
|
+
function decideLaunch(session, id) {
|
|
2778
|
+
const def = INTEGRATIONS[id];
|
|
2779
|
+
const reasons = [];
|
|
2780
|
+
const ollama = session.runtimes.find((r) => r.id === "ollama")?.detected;
|
|
2781
|
+
if (!ollama) reasons.push(t("launchNeedOllama"));
|
|
2782
|
+
const candidates = [];
|
|
2783
|
+
for (const row of session.rows) {
|
|
2784
|
+
const useCases = row.catalog?.useCase ?? ["chat", "code"];
|
|
2785
|
+
if (!useCaseOk(useCases, def.useCases)) continue;
|
|
2786
|
+
const tps = row.lastBenchmark?.benchmark.generationTokensPerSecond;
|
|
2787
|
+
candidates.push({
|
|
2788
|
+
modelId: row.local.id,
|
|
2789
|
+
ollamaTag: row.local.id.includes(":") ? row.local.id : toOllamaTag(row.local.id),
|
|
2790
|
+
installed: true,
|
|
2791
|
+
origin: row.lastBenchmark ? "measured" : "estimated",
|
|
2792
|
+
grade: row.compatibility?.grade,
|
|
2793
|
+
tps
|
|
2794
|
+
});
|
|
2795
|
+
}
|
|
2796
|
+
if (!candidates.length) {
|
|
2797
|
+
for (const model of session.catalog) {
|
|
2798
|
+
if (!useCaseOk(model.useCase, def.useCases)) continue;
|
|
2799
|
+
const fit = localCompatibility(session.hardware, model);
|
|
2800
|
+
if (!def.allowedGrades.includes(fit.grade)) continue;
|
|
2801
|
+
candidates.push({
|
|
2802
|
+
modelId: model.id,
|
|
2803
|
+
ollamaTag: toOllamaTag(model.id),
|
|
2804
|
+
installed: false,
|
|
2805
|
+
origin: "estimated",
|
|
2806
|
+
grade: fit.grade
|
|
2807
|
+
});
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
candidates.sort((a, b) => {
|
|
2811
|
+
if (a.installed !== b.installed) return a.installed ? -1 : 1;
|
|
2812
|
+
return (GRADE_RANK[b.grade ?? "F"] ?? 0) - (GRADE_RANK[a.grade ?? "F"] ?? 0);
|
|
2813
|
+
});
|
|
2814
|
+
const pick = candidates[0];
|
|
2815
|
+
if (!pick) reasons.push(t("launchNoModel"));
|
|
2816
|
+
if (pick?.grade && !def.allowedGrades.includes(pick.grade)) {
|
|
2817
|
+
reasons.push(t("launchGradeFail", { grade: pick.grade, allowed: def.allowedGrades.join(",") }));
|
|
2818
|
+
}
|
|
2819
|
+
if (pick?.origin === "measured" && pick.tps !== void 0 && pick.tps < def.minGenerationTps) {
|
|
2820
|
+
reasons.push(t("launchSlowFail", { tps: pick.tps.toFixed(1), min: def.minGenerationTps }));
|
|
2821
|
+
}
|
|
2822
|
+
if (pick && pick.origin === "estimated" && (!pick.grade || GRADE_RANK[pick.grade] < GRADE_RANK.B)) {
|
|
2823
|
+
reasons.push(t("launchEstimateWeak"));
|
|
2824
|
+
}
|
|
2825
|
+
const eligible = Boolean(ollama && pick && reasons.length === 0);
|
|
2826
|
+
const command = pick ? `ollama launch ${def.ollamaLaunch} --model ${pick.ollamaTag}` : `ollama launch ${def.ollamaLaunch}`;
|
|
2827
|
+
return {
|
|
2828
|
+
integration: id,
|
|
2829
|
+
docsUrl: def.docsUrl,
|
|
2830
|
+
eligible,
|
|
2831
|
+
reasons: eligible ? [t("launchOk")] : reasons,
|
|
2832
|
+
modelId: pick?.modelId,
|
|
2833
|
+
ollamaTag: pick?.ollamaTag,
|
|
2834
|
+
installed: pick?.installed ?? false,
|
|
2835
|
+
origin: pick?.origin,
|
|
2836
|
+
grade: pick?.grade,
|
|
2837
|
+
command
|
|
2838
|
+
};
|
|
2839
|
+
}
|
|
2840
|
+
function decideAll(session) {
|
|
2841
|
+
return INTEGRATION_IDS.map((id) => decideLaunch(session, id));
|
|
2842
|
+
}
|
|
2843
|
+
function launchText(decisions) {
|
|
2844
|
+
const lines = [t("launchTitle"), t("launchHint"), ""];
|
|
2845
|
+
for (const d of decisions) {
|
|
2846
|
+
lines.push(`${d.integration} ${d.docsUrl}`);
|
|
2847
|
+
lines.push(` ${d.eligible ? "\u2713" : "\u25CB"} ${d.reasons.join(" ")}`);
|
|
2848
|
+
if (d.modelId) {
|
|
2849
|
+
lines.push(
|
|
2850
|
+
` ${t("launchModel")}: ${d.modelId} ${d.grade ?? "\u2014"} ${d.origin === "measured" ? t("measured") : t("estimated")} ${d.installed ? t("tasksInstalled") : t("tasksNotInstalled")}`
|
|
2851
|
+
);
|
|
2852
|
+
}
|
|
2853
|
+
lines.push(` ${d.command}`);
|
|
2854
|
+
if (d.eligible) lines.push(` ${t("launchRunHint", { tool: d.integration })}`);
|
|
2855
|
+
else lines.push(` ${t("launchBlocked")}`);
|
|
2856
|
+
lines.push("");
|
|
2857
|
+
}
|
|
2858
|
+
return lines.join("\n");
|
|
2859
|
+
}
|
|
2860
|
+
function parseIntegration(raw) {
|
|
2861
|
+
if (!raw) return void 0;
|
|
2862
|
+
const id = raw.toLowerCase();
|
|
2863
|
+
if (id === "claude-code") return "claude";
|
|
2864
|
+
return INTEGRATION_IDS.find((item) => item === id);
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
// src/integrations/execute.ts
|
|
2868
|
+
async function executeLaunch(decision) {
|
|
2869
|
+
if (!decision.eligible || !decision.ollamaTag) {
|
|
2870
|
+
return { ok: false, log: t("launchBlocked") };
|
|
2871
|
+
}
|
|
2872
|
+
const def = INTEGRATIONS[decision.integration];
|
|
2873
|
+
if (!def) return { ok: false, log: t("launchUnknown") };
|
|
2874
|
+
const lines = [];
|
|
2875
|
+
if (!decision.installed) {
|
|
2876
|
+
lines.push(t("launchPulling", { model: decision.ollamaTag }));
|
|
2877
|
+
const pull = await runCommand("ollama", ["pull", decision.ollamaTag], { timeout: 30 * 6e4 });
|
|
2878
|
+
if (pull.exitCode !== 0) {
|
|
2879
|
+
return { ok: false, log: `${lines.join("\n")}
|
|
2880
|
+
${pull.stderr || t("launchPullFail")}` };
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
const args2 = ["launch", def.ollamaLaunch, "--model", decision.ollamaTag];
|
|
2884
|
+
if (def.id === "openclaw" || def.id === "claude") args2.push("--yes");
|
|
2885
|
+
lines.push(`ollama ${args2.join(" ")}`);
|
|
2886
|
+
const launched = await runCommand("ollama", args2, { timeout: 12e4 });
|
|
2887
|
+
if (launched.exitCode !== 0) {
|
|
2888
|
+
return {
|
|
2889
|
+
ok: false,
|
|
2890
|
+
log: `${lines.join("\n")}
|
|
2891
|
+
${launched.stderr || launched.stdout || t("launchExecFail")}`
|
|
2892
|
+
};
|
|
2893
|
+
}
|
|
2894
|
+
return { ok: true, log: `${lines.join("\n")}
|
|
2895
|
+
${launched.stdout}`.trim() };
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
// src/cli/help.ts
|
|
2899
|
+
function helpText() {
|
|
2900
|
+
return t("help");
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
// src/cli/run.ts
|
|
2904
|
+
function print(value, json) {
|
|
2905
|
+
if (json) {
|
|
2906
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}
|
|
2907
|
+
`);
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
process.stdout.write(`${typeof value === "string" ? value : JSON.stringify(value, null, 2)}
|
|
2911
|
+
`);
|
|
2912
|
+
}
|
|
2913
|
+
function explained(aboutKey, body) {
|
|
2914
|
+
return `${t(aboutKey)}
|
|
2915
|
+
|
|
2916
|
+
${body}`;
|
|
2917
|
+
}
|
|
2918
|
+
async function run(args2) {
|
|
2919
|
+
setLocale(resolveLocale(args2.lang));
|
|
2920
|
+
if (args2.help) {
|
|
2921
|
+
process.stdout.write(`${helpText()}
|
|
2922
|
+
`);
|
|
2923
|
+
return 0;
|
|
2924
|
+
}
|
|
2925
|
+
if (args2.verbose) setLogLevel("verbose");
|
|
2926
|
+
if (!args2.color) process.env.NO_COLOR = "1";
|
|
2927
|
+
const cleanup = () => {
|
|
2928
|
+
void killAll("SIGTERM");
|
|
2929
|
+
};
|
|
2930
|
+
process.on("SIGINT", () => {
|
|
2931
|
+
cleanup();
|
|
2932
|
+
process.exit(130);
|
|
2933
|
+
});
|
|
2934
|
+
if (args2.addPath) {
|
|
2935
|
+
await addModelPath(args2.addPath);
|
|
2936
|
+
print(args2.json ? { path: args2.addPath } : explained("aboutAddPath", t("addedPath", { path: args2.addPath })), args2.json);
|
|
2937
|
+
return 0;
|
|
2938
|
+
}
|
|
2939
|
+
if (!args2.json && !args2.csv && process.stderr.isTTY) {
|
|
2940
|
+
process.stderr.write(`${t("loading")}
|
|
2941
|
+
`);
|
|
2942
|
+
}
|
|
2943
|
+
const session = await loadSession({ network: args2.network });
|
|
2944
|
+
switch (args2.command) {
|
|
2945
|
+
case "hardware":
|
|
2946
|
+
print(
|
|
2947
|
+
args2.json ? session.hardware : explained("aboutHardware", `${hardwareText(session)}
|
|
2948
|
+
|
|
2949
|
+
${runtimesText(session)}`),
|
|
2950
|
+
args2.json
|
|
2951
|
+
);
|
|
2952
|
+
return 0;
|
|
2953
|
+
case "models":
|
|
2954
|
+
print(args2.json ? session.rows : explained("aboutModels", modelsText(session)), args2.json);
|
|
2955
|
+
return 0;
|
|
2956
|
+
case "recommend":
|
|
2957
|
+
print(args2.json ? session.recommendations : explained("aboutRecommend", recommendText(session)), args2.json);
|
|
2958
|
+
return 0;
|
|
2959
|
+
case "launch": {
|
|
2960
|
+
const tool = parseIntegration(args2.positional[0]);
|
|
2961
|
+
const decisions = tool ? [decideLaunch(session, tool)] : decideAll(session);
|
|
2962
|
+
if (!args2.yes) {
|
|
2963
|
+
print(args2.json ? decisions : explained("aboutLaunch", launchText(decisions)), args2.json);
|
|
2964
|
+
return 0;
|
|
2965
|
+
}
|
|
2966
|
+
if (!tool) {
|
|
2967
|
+
process.stderr.write(`${t("launchNeedTool")}
|
|
2968
|
+
`);
|
|
2969
|
+
return 1;
|
|
2970
|
+
}
|
|
2971
|
+
const decision = decisions[0];
|
|
2972
|
+
if (!decision.eligible) {
|
|
2973
|
+
print(args2.json ? decision : explained("aboutLaunch", launchText([decision])), args2.json);
|
|
2974
|
+
return 1;
|
|
2975
|
+
}
|
|
2976
|
+
const result = await executeLaunch(decision);
|
|
2977
|
+
print(args2.json ? { decision, result } : explained("aboutLaunch", result.log), args2.json);
|
|
2978
|
+
return result.ok ? 0 : 1;
|
|
2979
|
+
}
|
|
2980
|
+
case "tasks": {
|
|
2981
|
+
const answers = parseAnswers({
|
|
2982
|
+
for: args2.forKinds,
|
|
2983
|
+
scope: args2.scope,
|
|
2984
|
+
priority: args2.priority
|
|
2985
|
+
});
|
|
2986
|
+
const plans = planTasks(session, answers);
|
|
2987
|
+
print(args2.json ? { answers, plans } : explained("aboutTasks", tasksText(plans, answers)), args2.json);
|
|
2988
|
+
return 0;
|
|
2989
|
+
}
|
|
2990
|
+
case "history": {
|
|
2991
|
+
const records = session.history;
|
|
2992
|
+
if (args2.csv) {
|
|
2993
|
+
process.stdout.write(`${historyCsv(records)}
|
|
2994
|
+
`);
|
|
2995
|
+
return 0;
|
|
2996
|
+
}
|
|
2997
|
+
print(args2.json ? records : explained("aboutHistory", historyText(records)), args2.json);
|
|
2998
|
+
return 0;
|
|
2999
|
+
}
|
|
3000
|
+
case "doctor":
|
|
3001
|
+
print(args2.json ? session : explained("aboutDoctor", doctorText(session)), args2.json);
|
|
3002
|
+
return 0;
|
|
3003
|
+
case "compare":
|
|
3004
|
+
print(args2.json ? session.history : explained("aboutCompare", compareText(session.history)), args2.json);
|
|
3005
|
+
return 0;
|
|
3006
|
+
case "report": {
|
|
3007
|
+
const last = session.history.at(-1);
|
|
3008
|
+
if (!last) {
|
|
3009
|
+
process.stderr.write(`${t("noHistory")}
|
|
3010
|
+
`);
|
|
3011
|
+
return 1;
|
|
3012
|
+
}
|
|
3013
|
+
process.stdout.write(`${explained("aboutReport", reportMarkdown(last))}
|
|
3014
|
+
`);
|
|
3015
|
+
return 0;
|
|
3016
|
+
}
|
|
3017
|
+
case "benchmark": {
|
|
3018
|
+
const id = args2.positional[0];
|
|
3019
|
+
if (!id) {
|
|
3020
|
+
if (args2.json) {
|
|
3021
|
+
print({ error: t("jsonNeedModel") }, true);
|
|
3022
|
+
return 1;
|
|
3023
|
+
}
|
|
3024
|
+
await renderDashboard(session, args2.preset);
|
|
3025
|
+
return 0;
|
|
3026
|
+
}
|
|
3027
|
+
const model = session.models.find((m) => m.id === id || m.name === id);
|
|
3028
|
+
if (!model) {
|
|
3029
|
+
process.stderr.write(`${t("modelNotFound", { id })}
|
|
3030
|
+
`);
|
|
3031
|
+
return 1;
|
|
3032
|
+
}
|
|
3033
|
+
const catalog = session.rows.find((r) => r.local.id === model.id)?.catalog;
|
|
3034
|
+
const result = await runBenchmark({
|
|
3035
|
+
model,
|
|
3036
|
+
hardware: session.hardware,
|
|
3037
|
+
preset: args2.preset,
|
|
3038
|
+
catalog
|
|
3039
|
+
});
|
|
3040
|
+
if (args2.json) {
|
|
3041
|
+
print(result.record, true);
|
|
3042
|
+
return 0;
|
|
3043
|
+
}
|
|
3044
|
+
process.stdout.write(`${explained("aboutBenchmark", resultText(result.record, result.assessment))}
|
|
3045
|
+
`);
|
|
3046
|
+
return 0;
|
|
3047
|
+
}
|
|
3048
|
+
case "dashboard":
|
|
3049
|
+
default:
|
|
3050
|
+
if (args2.json) {
|
|
3051
|
+
print(session, true);
|
|
3052
|
+
return 0;
|
|
3053
|
+
}
|
|
3054
|
+
await renderDashboard(session, args2.preset);
|
|
3055
|
+
return 0;
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
// src/cli/index.ts
|
|
3060
|
+
var args = parseArgs(process.argv);
|
|
3061
|
+
run(args).then((code) => {
|
|
3062
|
+
process.exitCode = code;
|
|
3063
|
+
}).catch((error) => {
|
|
3064
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3065
|
+
process.stderr.write(`${message}
|
|
3066
|
+
`);
|
|
3067
|
+
process.exitCode = 1;
|
|
3068
|
+
});
|