llm-tuner 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-tuner",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Detects your hardware and finds the fastest configuration to run local LLMs with LM Studio or Ollama.",
5
5
  "keywords": [
6
6
  "llm",
package/src/cli/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { select, input, confirm, search } from '@inquirer/prompts';
3
- import { detectHardware, detectEngines, Tuner, installPlans, runInstall, listPresets, presetsDir, fmtBytes, readSettings, writeSettings, THEMES, PROFILES, DEFAULT_PROFILE, TunerError } from '../core/index.js';
3
+ import { detectHardware, detectEngines, Tuner, installPlans, runInstall, listPresets, presetsDir, fmtBytes, readSettings, writeSettings, THEMES, PROFILES, DEFAULT_PROFILE, TunerError, checkUpdate, CURRENT_VERSION } from '../core/index.js';
4
4
  import { t as translate, LOCALES, errorText } from '../i18n/index.js';
5
5
  import { profileChoices, profileNotes, profileSwitchNote, profileEventLines, shouldAskProfile, parseContext } from './profile.js';
6
6
 
@@ -34,12 +34,18 @@ const isLight = () => theme === 'light';
34
34
  const c = {
35
35
  b: (s) => `\x1b[1m${s}\x1b[0m`,
36
36
  dim: (s) => isLight() ? `\x1b[90m${s}\x1b[0m` : `\x1b[2m${s}\x1b[0m`,
37
- g: (s) => isLight() ? `\x1b[32m${s}\x1b[0m` : `\x1b[92m${s}\x1b[0m`,
37
+ g: (s) => isLight() ? `\x1b[38;2;40;138;0m${s}\x1b[0m` : `\x1b[38;2;101;255;0m${s}\x1b[0m`,
38
38
  y: (s) => isLight() ? `\x1b[33m${s}\x1b[0m` : `\x1b[93m${s}\x1b[0m`,
39
- r: (s) => isLight() ? `\x1b[31m${s}\x1b[0m` : `\x1b[91m${s}\x1b[0m`,
40
- cyan: (s) => isLight() ? `\x1b[36m${s}\x1b[0m` : `\x1b[96m${s}\x1b[0m`,
39
+ r: (s) => isLight() ? `\x1b[38;2;214;21;0m${s}\x1b[0m` : `\x1b[38;2;255;26;0m${s}\x1b[0m`,
40
+ cyan: (s) => isLight() ? `\x1b[38;2;0;124;153m${s}\x1b[0m` : `\x1b[38;2;0;229;255m${s}\x1b[0m`,
41
+ purple: (s) => isLight() ? `\x1b[38;2;120;0;199m${s}\x1b[0m` : `\x1b[38;2;154;0;255m${s}\x1b[0m`,
41
42
  };
42
43
 
44
+ if (args.version) {
45
+ console.log(CURRENT_VERSION);
46
+ process.exit(0);
47
+ }
48
+
43
49
  if (args.help) {
44
50
  console.log(t('cli.help', { presetsDir: presetsDir() }));
45
51
  process.exit(0);
@@ -114,6 +120,7 @@ async function settingsFlow() {
114
120
  }
115
121
 
116
122
  async function main() {
123
+ const updatePromise = checkUpdate().catch(() => null);
117
124
  const isTTY = Boolean(process.stdout.isTTY && !args.json);
118
125
  let lastProgress = null;
119
126
  let lastTelemetry = null;
@@ -163,6 +170,12 @@ async function main() {
163
170
  '',
164
171
  ].join('\n'));
165
172
 
173
+ const updateInfo = await updatePromise;
174
+ if (updateInfo?.updateAvailable) {
175
+ out(c.y(` ▲ ${t('cli.updateAvailable', { current: updateInfo.currentVersion, latest: updateInfo.latestVersion })}`));
176
+ out(c.dim(` ${t('cli.updateCommand', { cmd: 'npm install -g llm-tuner' })}\n`));
177
+ }
178
+
166
179
  const hw = await detectHardware();
167
180
  out(c.dim(` ${hw.cpu.brand} · ${fmtBytes(hw.ram.totalBytes)} RAM`));
168
181
  for (const g of hw.gpus) out(c.dim(` GPU${g.index} ${g.name} · ${fmtBytes(g.totalBytes)}${g.pcieGen ? ` · PCIe ${g.pcieGen}.0 x${g.pcieWidth}` : ''}${g.displayAttached ? t('cli.display') : ''}`));
@@ -343,11 +356,12 @@ function vramStr(peaks = {}) {
343
356
  }
344
357
 
345
358
  function parseArgs(argv) {
346
- const flags = new Set(['help', 'yes', 'dry-run', 'json', 'force', 'presets', 'settings']);
359
+ const flags = new Set(['help', 'version', 'yes', 'dry-run', 'json', 'force', 'presets', 'settings']);
347
360
  const o = {};
348
361
  for (let i = 0; i < argv.length; i++) {
349
362
  const a = argv[i];
350
363
  if (a === '-h') o.help = true;
364
+ if (a === '-v') o.version = true;
351
365
  if (a === '/settings' || a === '/setting' || a === 'settings') o.settings = true;
352
366
  if (!a.startsWith('--')) continue;
353
367
  const k = a.slice(2);
package/src/core/index.js CHANGED
@@ -9,4 +9,4 @@ export { findPreset, savePreset, listPresets, presetsDir, presetPath, variantsSi
9
9
  export { readSettings, writeSettings, settingsPath, THEMES, DEFAULT_THEME, normalizeTheme } from './settings.js';
10
10
  export { TunerError, benchError } from './errors.js';
11
11
  export { sampleRam, sampleGpus, startTelemetrySampler } from './telemetry.js';
12
-
12
+ export { checkUpdate, isNewerVersion, CURRENT_VERSION } from './updater.js';
@@ -0,0 +1,99 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { configDir, readJson, fetchJson } from './util.js';
4
+
5
+ const pkgUrl = new URL('../../package.json', import.meta.url);
6
+ const pkgContent = await fs.readFile(pkgUrl, 'utf8');
7
+ export const CURRENT_VERSION = JSON.parse(pkgContent).version;
8
+
9
+ export const DEFAULT_REGISTRY_URL = 'https://registry.npmjs.org/llm-tuner/latest';
10
+ export const DEFAULT_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
11
+
12
+ export const updateCachePath = () => path.join(configDir(), 'update-check.json');
13
+
14
+ /**
15
+ * Parse a semver string into [major, minor, patch] numbers.
16
+ * Strips any leading 'v' and ignores pre-release/build identifiers.
17
+ */
18
+ export function parseSemver(v) {
19
+ if (typeof v !== 'string') return null;
20
+ const clean = v.trim().replace(/^v/, '');
21
+ const [core] = clean.split('-');
22
+ const parts = core.split('.').map((p) => Number(p));
23
+ if (parts.length < 3 || parts.some((n) => Number.isNaN(n) || n < 0)) return null;
24
+ return parts.slice(0, 3);
25
+ }
26
+
27
+ /**
28
+ * Returns true if `latest` is strictly newer than `current`.
29
+ */
30
+ export function isNewerVersion(latest, current) {
31
+ const l = parseSemver(latest);
32
+ const c = parseSemver(current);
33
+ if (!l || !c) return false;
34
+ for (let i = 0; i < 3; i++) {
35
+ if (l[i] > c[i]) return true;
36
+ if (l[i] < c[i]) return false;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ /**
42
+ * Checks npm registry for updates with a 24h cache.
43
+ * Never throws; returns safe fallback on network or file errors.
44
+ */
45
+ export async function checkUpdate({
46
+ force = false,
47
+ timeout = 2000,
48
+ cacheTtlMs = DEFAULT_CACHE_TTL_MS,
49
+ registryUrl = DEFAULT_REGISTRY_URL,
50
+ currentVersion = CURRENT_VERSION,
51
+ } = {}) {
52
+ try {
53
+ const cacheFile = updateCachePath();
54
+ if (!force) {
55
+ const cached = await readJson(cacheFile, null);
56
+ if (cached && typeof cached === 'object' && cached.lastChecked && cached.latestVersion) {
57
+ if (Date.now() - cached.lastChecked < cacheTtlMs) {
58
+ return {
59
+ updateAvailable: isNewerVersion(cached.latestVersion, currentVersion),
60
+ currentVersion,
61
+ latestVersion: cached.latestVersion,
62
+ cached: true,
63
+ };
64
+ }
65
+ }
66
+ }
67
+
68
+ const res = await fetchJson(registryUrl, { timeout });
69
+ if (res?.ok && res?.json?.version) {
70
+ const latest = res.json.version;
71
+ try {
72
+ await fs.mkdir(configDir(), { recursive: true });
73
+ await fs.writeFile(cacheFile, JSON.stringify({ lastChecked: Date.now(), latestVersion: latest }, null, 2));
74
+ } catch {
75
+ // Ignore cache write errors
76
+ }
77
+ return {
78
+ updateAvailable: isNewerVersion(latest, currentVersion),
79
+ currentVersion,
80
+ latestVersion: latest,
81
+ cached: false,
82
+ };
83
+ }
84
+
85
+ return {
86
+ updateAvailable: false,
87
+ currentVersion,
88
+ latestVersion: null,
89
+ cached: false,
90
+ };
91
+ } catch {
92
+ return {
93
+ updateAvailable: false,
94
+ currentVersion,
95
+ latestVersion: null,
96
+ cached: false,
97
+ };
98
+ }
99
+ }
package/src/i18n/en.js CHANGED
@@ -244,5 +244,10 @@ Presets are saved in {presetsDir}`,
244
244
  'web.telemetryRam': 'RAM',
245
245
  'web.telemetryVram': 'VRAM',
246
246
  'web.telemetryGpu': 'GPU {index}',
247
+ 'cli.updateAvailable': 'Update available: {current} → {latest}',
248
+ 'cli.updateCommand': 'Run {cmd} to update',
249
+ 'web.updateAvailable': 'Update available: v{latest}',
250
+ 'web.updateLink': 'View release notes on GitHub',
251
+ 'web.version': 'Version {version}',
247
252
  };
248
253
 
package/src/i18n/es.js CHANGED
@@ -244,5 +244,10 @@ Los presets se guardan en {presetsDir}`,
244
244
  'web.telemetryRam': 'RAM',
245
245
  'web.telemetryVram': 'VRAM',
246
246
  'web.telemetryGpu': 'GPU {index}',
247
+ 'cli.updateAvailable': 'Actualización disponible: {current} → {latest}',
248
+ 'cli.updateCommand': 'Ejecutá {cmd} para actualizar',
249
+ 'web.updateAvailable': 'Actualización disponible: v{latest}',
250
+ 'web.updateLink': 'Ver notas de la versión en GitHub',
251
+ 'web.version': 'Versión {version}',
247
252
  };
248
253
 
@@ -136,6 +136,22 @@ async function boot() {
136
136
  document.documentElement.lang = s.lang;
137
137
  applyStatic();
138
138
  setupDrawer();
139
+ api('/api/update').then((up) => {
140
+ if (up?.currentVersion) {
141
+ const vDisp = $('#version-display');
142
+ if (vDisp) vDisp.textContent = t('web.version', { version: up.currentVersion });
143
+ }
144
+ if (up?.updateAvailable) {
145
+ const dot = $('#update-dot');
146
+ if (dot) dot.hidden = false;
147
+ const badge = $('#update-badge');
148
+ const link = $('#update-link');
149
+ if (badge && link) {
150
+ link.textContent = t('web.updateAvailable', { latest: up.latestVersion });
151
+ badge.hidden = false;
152
+ }
153
+ }
154
+ }).catch(() => {});
139
155
  state.storedProfile = s.profile ?? null;
140
156
  state.profileIds = s.profiles;
141
157
  renderProfile();
@@ -22,6 +22,7 @@
22
22
  <button id="settings-open" class="settings-trigger" type="button" aria-haspopup="dialog" aria-expanded="false">
23
23
  <span class="settings-trigger-icon">⚙</span>
24
24
  <span data-i18n="web.settings">Settings</span>
25
+ <span id="update-dot" class="update-dot" hidden></span>
25
26
  </button>
26
27
  <ol class="steps" id="steps">
27
28
  <li data-step="engine" class="active" data-i18n="web.stepEngine">Engine</li>
@@ -144,6 +145,12 @@
144
145
  <option value="dark" data-i18n="web.themeDark">Dark</option>
145
146
  </select>
146
147
  </div>
148
+ <div class="drawer-field version-info">
149
+ <span class="muted" id="version-display"></span>
150
+ <div id="update-badge" hidden>
151
+ <a href="https://github.com/ronald32acunadev/llmtuner/releases" target="_blank" rel="noopener noreferrer" class="tag prio" id="update-link"></a>
152
+ </div>
153
+ </div>
147
154
  </div>
148
155
  </aside>
149
156
  <script src="app.js" type="module"></script>
@@ -4,13 +4,14 @@
4
4
  --ink: #0f172a;
5
5
  --muted: #475569;
6
6
  --line: #cbd5e1;
7
- --accent: #0284c7;
7
+ --accent: #007c99; /* accessible electric cyan for light theme */
8
8
  --accent-ink: #ffffff;
9
- --gpu: #0891b2;
9
+ --gpu: #007c99;
10
+ --purple: #7800c7; /* accessible electric purple */
10
11
  --cpu: #94a3b8;
11
- --ok: #16a34a;
12
+ --ok: #288a00; /* accessible neon lime */
12
13
  --warn: #d97706;
13
- --bad: #dc2626;
14
+ --bad: #d61500; /* accessible electric red */
14
15
  --code-bg: #f8fafc;
15
16
  --font: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
16
17
  --mono: 'IBM Plex Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
@@ -23,13 +24,14 @@
23
24
  --ink: #e2e8f0;
24
25
  --muted: #8b9bb0;
25
26
  --line: #1e2c3d;
26
- --accent: #00e5ff; /* electric cyan from logo */
27
+ --accent: #00e5ff; /* electric cyan from brand */
27
28
  --accent-ink: #05131d;
28
29
  --gpu: #00e5ff;
30
+ --purple: #9a00ff; /* electric purple from brand */
29
31
  --cpu: #3d4f63;
30
- --ok: #34d399;
32
+ --ok: #65ff00; /* neon lime from brand */
31
33
  --warn: #fbbf24;
32
- --bad: #f87171;
34
+ --bad: #ff1a00; /* electric red from brand */
33
35
  --code-bg: #0b121b;
34
36
  color-scheme: dark;
35
37
  }
@@ -40,13 +42,14 @@
40
42
  --ink: #e2e8f0;
41
43
  --muted: #8b9bb0;
42
44
  --line: #1e2c3d;
43
- --accent: #00e5ff; /* electric cyan from logo */
45
+ --accent: #00e5ff; /* electric cyan from brand */
44
46
  --accent-ink: #05131d;
45
47
  --gpu: #00e5ff;
48
+ --purple: #9a00ff; /* electric purple from brand */
46
49
  --cpu: #3d4f63;
47
- --ok: #34d399;
50
+ --ok: #65ff00; /* neon lime from brand */
48
51
  --warn: #fbbf24;
49
- --bad: #f87171;
52
+ --bad: #ff1a00; /* electric red from brand */
50
53
  --code-bg: #0b121b;
51
54
  color-scheme: dark;
52
55
  }
@@ -56,13 +59,14 @@
56
59
  --ink: #0f172a;
57
60
  --muted: #475569;
58
61
  --line: #cbd5e1;
59
- --accent: #0284c7;
62
+ --accent: #007c99; /* accessible electric cyan for light theme */
60
63
  --accent-ink: #ffffff;
61
- --gpu: #0891b2;
64
+ --gpu: #007c99;
65
+ --purple: #7800c7; /* accessible electric purple */
62
66
  --cpu: #94a3b8;
63
- --ok: #16a34a;
67
+ --ok: #288a00; /* accessible neon lime */
64
68
  --warn: #d97706;
65
- --bad: #dc2626;
69
+ --bad: #d61500; /* accessible electric red */
66
70
  --code-bg: #f8fafc;
67
71
  color-scheme: light;
68
72
  }
@@ -331,7 +335,7 @@ td .err { color: var(--bad); white-space: normal; }
331
335
  /* Progress bar and real-time telemetry */
332
336
  .progress-wrap { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
333
337
  .progress-bar-track { width: 100%; height: 8px; background: var(--code-bg); border: 1px solid var(--line); border-radius: 4px; overflow: hidden; }
334
- .progress-bar-fill { height: 100%; background: var(--accent); transition: width .2s ease; }
338
+ .progress-bar-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--ok)); transition: width .2s ease; }
335
339
  .progress-meta { display: flex; justify-content: space-between; align-items: center; font: 500 .8rem var(--mono); color: var(--muted); }
336
340
  .progress-meta strong { color: var(--ink); }
337
341
 
@@ -342,5 +346,27 @@ td .err { color: var(--bad); white-space: normal; }
342
346
  .telemetry-gpus { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
343
347
  .meter-bar { width: 100%; height: 8px; background: var(--code-bg); border: 1px solid var(--line); border-radius: 4px; overflow: hidden; }
344
348
  .meter-fill { height: 100%; background: var(--gpu); transition: width .2s ease; }
345
- #telemetry-cpu-fill { background: var(--accent); }
349
+ #telemetry-cpu-fill { background: var(--purple); }
350
+
351
+ /* Update notifications */
352
+ .update-dot {
353
+ width: 8px;
354
+ height: 8px;
355
+ border-radius: 50%;
356
+ background: var(--warn, #f59e0b);
357
+ display: inline-block;
358
+ margin-left: auto;
359
+ }
360
+ .version-info {
361
+ display: flex;
362
+ align-items: center;
363
+ justify-content: space-between;
364
+ padding-top: 1rem;
365
+ border-top: 1px solid var(--line);
366
+ font-size: .85rem;
367
+ }
368
+ .version-info #update-link {
369
+ text-decoration: none;
370
+ font-size: .75rem;
371
+ }
346
372
 
package/src/web/server.js CHANGED
@@ -3,7 +3,7 @@ import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath, pathToFileURL } from 'node:url';
5
5
  import { randomUUID } from 'node:crypto';
6
- import { detectHardware, detectEngines, Tuner, ENGINES, installPlans, runInstall, slim, listPresets, readSettings, writeSettings, TunerError, PROFILES } from '../core/index.js';
6
+ import { detectHardware, detectEngines, Tuner, ENGINES, installPlans, runInstall, slim, listPresets, readSettings, writeSettings, TunerError, PROFILES, checkUpdate } from '../core/index.js';
7
7
  import { THEMES } from '../core/settings.js';
8
8
  import { LOCALES, messagesFor } from '../i18n/index.js';
9
9
  import { openUrl } from '../core/installer.js';
@@ -52,6 +52,8 @@ function send(res, status, obj) {
52
52
  const routes = {
53
53
  'GET /api/settings': async () => ({ ...(await readSettings()), locales: LOCALES, themes: THEMES, profiles: PROFILES }),
54
54
 
55
+ 'GET /api/update': async () => checkUpdate(),
56
+
55
57
  'POST /api/settings': async (req) => {
56
58
  const payload = await body(req);
57
59
  if (payload?.lang === undefined && payload?.theme === undefined && payload?.profile === undefined) throw new TunerError('errors.unknownLocale', { lang: '', list: LOCALES.join(', ') });