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.
- package/README.es.md +187 -64
- package/README.md +190 -67
- package/package.json +1 -1
- package/src/cli/index.js +97 -28
- package/src/cli/profile.js +14 -1
- package/src/core/engines/lmstudio.js +2 -1
- package/src/core/engines/ollama.js +2 -1
- package/src/core/index.js +2 -0
- package/src/core/telemetry.js +73 -0
- package/src/core/tuner.js +113 -78
- package/src/core/updater.js +99 -0
- package/src/i18n/en.js +12 -2
- package/src/i18n/es.js +12 -2
- package/src/web/public/app.js +94 -0
- package/src/web/public/index.html +27 -0
- package/src/web/public/style.css +56 -14
- package/src/web/server.js +3 -1
|
@@ -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.
|
|
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.
|
|
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
|
|
package/src/web/public/app.js
CHANGED
|
@@ -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>
|
package/src/web/public/style.css
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
--ink: #0f172a;
|
|
5
5
|
--muted: #475569;
|
|
6
6
|
--line: #cbd5e1;
|
|
7
|
-
--accent: #
|
|
7
|
+
--accent: #007c99; /* accessible electric cyan for light theme */
|
|
8
8
|
--accent-ink: #ffffff;
|
|
9
|
-
--gpu: #
|
|
9
|
+
--gpu: #007c99;
|
|
10
|
+
--purple: #7800c7; /* accessible electric purple */
|
|
10
11
|
--cpu: #94a3b8;
|
|
11
|
-
--ok: #
|
|
12
|
+
--ok: #288a00; /* accessible neon lime */
|
|
12
13
|
--warn: #d97706;
|
|
13
|
-
--bad: #
|
|
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
|
|
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: #
|
|
32
|
+
--ok: #65ff00; /* neon lime from brand */
|
|
31
33
|
--warn: #fbbf24;
|
|
32
|
-
--bad: #
|
|
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
|
|
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: #
|
|
50
|
+
--ok: #65ff00; /* neon lime from brand */
|
|
48
51
|
--warn: #fbbf24;
|
|
49
|
-
--bad: #
|
|
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: #
|
|
62
|
+
--accent: #007c99; /* accessible electric cyan for light theme */
|
|
60
63
|
--accent-ink: #ffffff;
|
|
61
|
-
--gpu: #
|
|
64
|
+
--gpu: #007c99;
|
|
65
|
+
--purple: #7800c7; /* accessible electric purple */
|
|
62
66
|
--cpu: #94a3b8;
|
|
63
|
-
--ok: #
|
|
67
|
+
--ok: #288a00; /* accessible neon lime */
|
|
64
68
|
--warn: #d97706;
|
|
65
|
-
--bad: #
|
|
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(', ') });
|