llm-tuner 0.1.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.
@@ -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
@@ -92,7 +92,7 @@ export default {
92
92
  // Shared by CLI and web
93
93
  'common.error': 'Error: {message}',
94
94
  'common.na': 'n/a',
95
- 'common.longPrompt': 'long prompt (~{tokens} tokens)…',
95
+ 'common.longPrompt': 'long prompt (~{tokens} tokens, 50% context)…',
96
96
  'common.presetSaved': 'Preset saved: {file}',
97
97
  'common.backups': 'Backups: {list}',
98
98
 
@@ -154,7 +154,8 @@ Presets are saved in {presetsDir}`,
154
154
  'cli.confirmApply': 'Apply and load the model?',
155
155
  'cli.dryRun': '--dry-run: nothing was applied and no preset was saved.',
156
156
  'cli.pending': 'To finish, run (asks for the administrator password):',
157
- 'cli.loaded': 'Model loaded in {engine}: {tps}',
157
+ 'cli.tuningModel': 'Tuning {model} with {ctx} context ({profile})…',
158
+ 'cli.loaded': 'Model loaded in {engine} with {ctx} context: {tps}',
158
159
  'cli.usesModel': ' (uses the model {model})',
159
160
  'cli.loadFailed': 'Could not load: {error}',
160
161
  'cli.installMethod': 'Install method:',
@@ -239,5 +240,14 @@ Presets are saved in {presetsDir}`,
239
240
  'web.themeDark': 'Dark',
240
241
  'web.settings': 'Settings',
241
242
  'web.close': 'Close',
243
+ 'web.telemetryCpu': 'CPU',
244
+ 'web.telemetryRam': 'RAM',
245
+ 'web.telemetryVram': 'VRAM',
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}',
242
252
  };
243
253
 
package/src/i18n/es.js CHANGED
@@ -92,7 +92,7 @@ export default {
92
92
  // Shared by CLI and web
93
93
  'common.error': 'Error: {message}',
94
94
  'common.na': 'n/d',
95
- 'common.longPrompt': 'prompt largo (~{tokens} tokens)…',
95
+ 'common.longPrompt': 'prompt largo (~{tokens} tokens, 50% del contexto)…',
96
96
  'common.presetSaved': 'Preset guardado: {file}',
97
97
  'common.backups': 'Copias de seguridad: {list}',
98
98
 
@@ -154,7 +154,8 @@ Los presets se guardan en {presetsDir}`,
154
154
  'cli.confirmApply': '¿Aplicar y cargar el modelo?',
155
155
  'cli.dryRun': '--dry-run: no se aplicó nada ni se guardó preset.',
156
156
  'cli.pending': 'Para terminar ejecuta (pide contraseña de administrador):',
157
- 'cli.loaded': 'Modelo cargado en {engine}: {tps}',
157
+ 'cli.tuningModel': 'Ajustando {model} con {ctx} de contexto ({profile})…',
158
+ 'cli.loaded': 'Modelo cargado en {engine} con {ctx} de contexto: {tps}',
158
159
  'cli.usesModel': ' (usa el modelo {model})',
159
160
  'cli.loadFailed': 'No se pudo cargar: {error}',
160
161
  'cli.installMethod': 'Método de instalación:',
@@ -239,5 +240,14 @@ Los presets se guardan en {presetsDir}`,
239
240
  'web.themeDark': 'Oscuro',
240
241
  'web.settings': 'Configuración',
241
242
  'web.close': 'Cerrar',
243
+ 'web.telemetryCpu': 'CPU',
244
+ 'web.telemetryRam': 'RAM',
245
+ 'web.telemetryVram': 'VRAM',
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}',
242
252
  };
243
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();
@@ -202,6 +218,7 @@ async function chooseEngine(e) {
202
218
  step('model');
203
219
  $('#model').innerHTML = `<option>${esc(t('web.readingModels'))}</option>`;
204
220
  const { models } = await api(`/api/models?engine=${e.id}`);
221
+ models.sort((a, b) => (a.sizeBytes ?? 0) - (b.sizeBytes ?? 0) || (a.name || '').localeCompare(b.name || ''));
205
222
  $('#model').innerHTML = models.length
206
223
  ? models.map((m) => `<option value="${esc(m.key)}">${esc(m.name)} · ${gb(m.sizeBytes)}${m.quant ? ' · ' + esc(m.quant) : ''}</option>`).join('')
207
224
  : `<option value="">${esc(t('web.noModels'))}</option>`;
@@ -386,6 +403,59 @@ function onCtxChange() {
386
403
  }
387
404
  $('#ctx').oninput = onCtxChange;
388
405
 
406
+ function updateTelemetry(e) {
407
+ const panel = $('#telemetry-panel');
408
+ if (!panel || !e) return;
409
+ panel.hidden = false;
410
+
411
+ if (e.cpu) {
412
+ const cpuVal = $('#telemetry-cpu-val');
413
+ const cpuFill = $('#telemetry-cpu-fill');
414
+ if (cpuVal) cpuVal.textContent = `${e.cpu.percent}%`;
415
+ if (cpuFill) cpuFill.style.width = `${Math.min(100, Math.max(0, e.cpu.percent))}%`;
416
+ }
417
+
418
+ if (e.ram) {
419
+ const ramVal = $('#telemetry-ram-val');
420
+ const ramFill = $('#telemetry-ram-fill');
421
+ if (ramVal) ramVal.textContent = `${gb(e.ram.usedBytes)} / ${gb(e.ram.totalBytes)} (${e.ram.percent}%)`;
422
+ if (ramFill) ramFill.style.width = `${Math.min(100, Math.max(0, e.ram.percent))}%`;
423
+ }
424
+
425
+ if (e.gpus) {
426
+ const box = $('#telemetry-gpus');
427
+ if (box) {
428
+ box.innerHTML = e.gpus.map((g) => {
429
+ const name = g.name.replace(/^NVIDIA GeForce /, '');
430
+ return `<div class="telemetry-card">
431
+ <div class="telemetry-header"><span>GPU ${g.index} (${esc(name)})</span> <strong>${gb(g.usedBytes)} / ${gb(g.totalBytes)} (${g.percent}%)</strong></div>
432
+ <div class="meter-bar"><div class="meter-fill" style="width: ${Math.min(100, Math.max(0, g.percent))}%"></div></div>
433
+ </div>`;
434
+ }).join('');
435
+ }
436
+ }
437
+ }
438
+
439
+ function updateProgress(p, label) {
440
+ const wrap = $('#load-progress-wrap');
441
+ if (!wrap || !p) return;
442
+ wrap.hidden = false;
443
+
444
+ const bar = $('#load-progress-bar');
445
+ const pct = $('#load-progress-pct');
446
+ const lbl = $('#load-progress-label');
447
+
448
+ if (pct) pct.textContent = `${p.pct}%`;
449
+ if (bar) bar.style.width = `${p.pct}%`;
450
+ if (lbl && label !== undefined) {
451
+ let text = label || '';
452
+ if (p.phase === 'bench' && p.current && p.total) {
453
+ text = `[${p.current}/${p.total}] ${text}`.trim();
454
+ }
455
+ lbl.textContent = text;
456
+ }
457
+ }
458
+
389
459
  // ---------- Load ----------
390
460
  $('#load-btn').onclick = async () => {
391
461
  const model = $('#model').value;
@@ -407,6 +477,17 @@ $('#load-btn').onclick = async () => {
407
477
  $('#load-result').innerHTML = '';
408
478
  const log = $('#load-log');
409
479
  log.textContent = '';
480
+ // Reset progress and telemetry
481
+ $('#load-progress-wrap').hidden = false;
482
+ $('#load-progress-bar').style.width = '0%';
483
+ $('#load-progress-pct').textContent = '0%';
484
+ $('#load-progress-label').textContent = '';
485
+ $('#telemetry-panel').hidden = false;
486
+ $('#telemetry-cpu-val').textContent = '0%';
487
+ $('#telemetry-cpu-fill').style.width = '0%';
488
+ $('#telemetry-ram-val').textContent = '0 / 0 GB (0%)';
489
+ $('#telemetry-ram-fill').style.width = '0%';
490
+ $('#telemetry-gpus').innerHTML = '';
410
491
  // One element per line, so a warning can be styled on its own.
411
492
  const addLog = (s, level = 'note') => {
412
493
  const line = document.createElement('span');
@@ -424,6 +505,17 @@ $('#load-btn').onclick = async () => {
424
505
  // Progress event before the current one: a fallback that repeats its reason is not logged twice.
425
506
  let previous = null;
426
507
  const done = await follow(jobId, (e) => {
508
+ if (e.type === 'telemetry') {
509
+ updateTelemetry(e);
510
+ return;
511
+ }
512
+ if (e.progress) {
513
+ let label = '';
514
+ if (e.type === 'status' && e.code) label = t(e.code, e.params);
515
+ else if (e.type === 'candidate-start') label = describe(e.candidate);
516
+ else if (e.type === 'bench-progress' && e.phase) label = e.phase === 'deep' ? t('common.longPrompt', { tokens: e.promptTokens }) : '';
517
+ updateProgress(e.progress, label);
518
+ }
427
519
  if (e.type === 'variant-picked' || e.type === 'profile-fallback') {
428
520
  for (const line of profileEventLines(e, t, { engineName: state.engine.name, previous })) addLog(line.text, line.level);
429
521
  }
@@ -456,6 +548,8 @@ $('#load-btn').onclick = async () => {
456
548
  const loaded = done.result?.loaded;
457
549
  throw new Error(done.code || done.error ? errText(done) : loaded && !loaded.ok ? benchErr(loaded) : t('bench.loadFailed'));
458
550
  }
551
+ updateProgress({ pct: 100, phase: 'done' });
552
+
459
553
  const r = done.result;
460
554
  if (r.report?.best) {
461
555
  const idx = r.report.results.findIndex((x) => x.candidate.id === r.report.best.candidate.id);
@@ -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>
@@ -75,6 +76,26 @@
75
76
 
76
77
  <section id="sec-load" class="panel" hidden>
77
78
  <header><h2 id="load-title" data-i18n="web.loading">Loading</h2><span class="pill" id="load-status" data-i18n="web.inProgress">in progress</span></header>
79
+ <div id="load-progress-wrap" class="progress-wrap" hidden>
80
+ <div class="progress-bar-track">
81
+ <div id="load-progress-bar" class="progress-bar-fill" style="width: 0%"></div>
82
+ </div>
83
+ <div class="progress-meta">
84
+ <span id="load-progress-label"></span>
85
+ <span id="load-progress-pct">0%</span>
86
+ </div>
87
+ </div>
88
+ <div id="telemetry-panel" class="telemetry-panel" hidden>
89
+ <div class="telemetry-card">
90
+ <div class="telemetry-header"><span data-i18n="web.telemetryCpu">CPU</span> <strong id="telemetry-cpu-val">0%</strong></div>
91
+ <div class="meter-bar"><div id="telemetry-cpu-fill" class="meter-fill" style="width: 0%"></div></div>
92
+ </div>
93
+ <div class="telemetry-card">
94
+ <div class="telemetry-header"><span data-i18n="web.telemetryRam">RAM</span> <strong id="telemetry-ram-val">0 / 0 GB (0%)</strong></div>
95
+ <div class="meter-bar"><div id="telemetry-ram-fill" class="meter-fill" style="width: 0%"></div></div>
96
+ </div>
97
+ <div id="telemetry-gpus" class="telemetry-gpus"></div>
98
+ </div>
78
99
  <div id="bench" hidden>
79
100
  <p class="muted small" data-i18n="web.benchIntro">There is no preset for this context and profile: several configurations are tested on your hardware. It takes a few minutes and only happens once.</p>
80
101
  <div class="table-wrap">
@@ -124,6 +145,12 @@
124
145
  <option value="dark" data-i18n="web.themeDark">Dark</option>
125
146
  </select>
126
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>
127
154
  </div>
128
155
  </aside>
129
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
  }
@@ -328,3 +332,41 @@ td .err { color: var(--bad); white-space: normal; }
328
332
  .chat-form input { flex: 1; min-width: 0; }
329
333
  .chat-form button { flex-shrink: 0; }
330
334
 
335
+ /* Progress bar and real-time telemetry */
336
+ .progress-wrap { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
337
+ .progress-bar-track { width: 100%; height: 8px; background: var(--code-bg); border: 1px solid var(--line); border-radius: 4px; overflow: hidden; }
338
+ .progress-bar-fill { height: 100%; background: linear-gradient(90deg, var(--accent), var(--ok)); transition: width .2s ease; }
339
+ .progress-meta { display: flex; justify-content: space-between; align-items: center; font: 500 .8rem var(--mono); color: var(--muted); }
340
+ .progress-meta strong { color: var(--ink); }
341
+
342
+ .telemetry-panel { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin-top: 10px; }
343
+ .telemetry-card { background: var(--bg); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; display: grid; gap: 6px; }
344
+ .telemetry-header { display: flex; justify-content: space-between; align-items: center; font: 500 .8rem var(--mono); color: var(--muted); }
345
+ .telemetry-header strong { color: var(--ink); }
346
+ .telemetry-gpus { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
347
+ .meter-bar { width: 100%; height: 8px; background: var(--code-bg); border: 1px solid var(--line); border-radius: 4px; overflow: hidden; }
348
+ .meter-fill { height: 100%; background: var(--gpu); transition: width .2s ease; }
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
+ }
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(', ') });