llm-tuner 0.1.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/LICENSE +21 -0
- package/README.es.md +123 -0
- package/README.md +123 -0
- package/electron/icon.ico +0 -0
- package/electron/icon.png +0 -0
- package/electron/launch.js +85 -0
- package/electron/main.js +63 -0
- package/package.json +48 -0
- package/src/cli/index.js +303 -0
- package/src/cli/profile.js +74 -0
- package/src/core/benchmark.js +101 -0
- package/src/core/engines/lmstudio.js +358 -0
- package/src/core/engines/ollama.js +335 -0
- package/src/core/errors.js +16 -0
- package/src/core/estimator.js +237 -0
- package/src/core/gguf.js +187 -0
- package/src/core/hardware.js +162 -0
- package/src/core/index.js +10 -0
- package/src/core/installer.js +59 -0
- package/src/core/llama-server.js +107 -0
- package/src/core/presets.js +112 -0
- package/src/core/profiles.js +137 -0
- package/src/core/settings.js +44 -0
- package/src/core/tuner.js +305 -0
- package/src/core/util.js +148 -0
- package/src/i18n/core.js +11 -0
- package/src/i18n/en.js +243 -0
- package/src/i18n/es.js +243 -0
- package/src/i18n/index.js +23 -0
- package/src/web/public/app.js +551 -0
- package/src/web/public/favicon.ico +0 -0
- package/src/web/public/icon.png +0 -0
- package/src/web/public/index.html +131 -0
- package/src/web/public/profile.js +100 -0
- package/src/web/public/style.css +330 -0
- package/src/web/server.js +205 -0
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import { format } from '/i18n/core.js';
|
|
2
|
+
import { initialProfile, profileOptions, profileDetailLines, profileEventLines, presetsFor, presetFor, isCurrentModel, isCurrentPlan } from './profile.js';
|
|
3
|
+
|
|
4
|
+
const $ = (s) => document.querySelector(s);
|
|
5
|
+
const GiB = 1024 ** 3;
|
|
6
|
+
const gb = (b) => (b / GiB).toFixed(1) + ' GB';
|
|
7
|
+
const k = (n) => (n >= 1024 ? `${Math.round(n / 1024)}K` : String(n));
|
|
8
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
9
|
+
|
|
10
|
+
// Messages for the chosen language (English fills any gap), loaded in boot().
|
|
11
|
+
let messages = {};
|
|
12
|
+
const t = (key, params) => format(messages[key] ?? key, params);
|
|
13
|
+
const errText = (o, fallbackKey = 'bench.loadFailed') => (o?.code ? t(o.code, o.params) : o?.error || t(fallbackKey));
|
|
14
|
+
const benchErr = (b) => (b.errorCode ? t(b.errorCode, b.errorParams) : b.error);
|
|
15
|
+
|
|
16
|
+
const state = { hw: null, engines: [], engine: null, presets: [], meta: null, loadedModel: null, chatMessages: [], chatBusy: false };
|
|
17
|
+
// Load profile: the stored choice (null until the user picks one), the ids the server accepts, the per-profile
|
|
18
|
+
// preview of the last plan (null while there is none), what the chips were last drawn with and the detail markup last written.
|
|
19
|
+
Object.assign(state, { storedProfile: null, profileIds: undefined, profilePlan: null, profileSaveError: null, maxContext: null, chipsProfile: null, profileDetailHtml: null });
|
|
20
|
+
|
|
21
|
+
async function api(path, body) {
|
|
22
|
+
const res = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {});
|
|
23
|
+
const data = await res.json();
|
|
24
|
+
if (!res.ok) throw new Error(data.code ? t(data.code, data.params) : (data.error || res.statusText));
|
|
25
|
+
return data;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function follow(jobId, onEvent) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const es = new EventSource(`/api/jobs/${jobId}/events`);
|
|
31
|
+
es.onmessage = (m) => {
|
|
32
|
+
const e = JSON.parse(m.data);
|
|
33
|
+
onEvent(e);
|
|
34
|
+
if (e.type === 'finished') { es.close(); resolve(e); }
|
|
35
|
+
};
|
|
36
|
+
es.onerror = () => { es.close(); resolve({ type: 'finished', ok: false, code: 'web.connectionLost' }); };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function step(name) {
|
|
41
|
+
const order = ['engine', 'model', 'load'];
|
|
42
|
+
const i = order.indexOf(name);
|
|
43
|
+
document.querySelectorAll('#steps li').forEach((li) => {
|
|
44
|
+
const j = order.indexOf(li.dataset.step);
|
|
45
|
+
li.classList.toggle('done', j < i);
|
|
46
|
+
li.classList.toggle('active', j === i);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function describe(c) {
|
|
51
|
+
const where = c.fullOffload ? t('candidate.allGpu') : t('candidate.layersGpu', { gpu: c.gpuLayers, total: c.totalLayers });
|
|
52
|
+
return `KV ${c.kvType} · ${where}${c.cpuMoeLayers ? t('candidate.expertsRam', { layers: c.cpuMoeLayers }) : ''}${c.probe ? t('candidate.probe') : ''}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ---------- Language ----------
|
|
56
|
+
function applyStatic() {
|
|
57
|
+
document.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = t(el.dataset.i18n); });
|
|
58
|
+
document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => { el.placeholder = t(el.dataset.i18nPlaceholder); });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
function applyTheme(theme) {
|
|
63
|
+
if (theme === 'system') {
|
|
64
|
+
delete document.documentElement.dataset.theme;
|
|
65
|
+
} else {
|
|
66
|
+
document.documentElement.dataset.theme = theme;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function setupDrawer() {
|
|
71
|
+
const openBtn = $('#settings-open');
|
|
72
|
+
const closeBtn = $('#settings-close');
|
|
73
|
+
const drawer = $('#settings-drawer');
|
|
74
|
+
const backdrop = $('#settings-backdrop');
|
|
75
|
+
if (!openBtn || !closeBtn || !drawer || !backdrop) return;
|
|
76
|
+
|
|
77
|
+
let closing = false;
|
|
78
|
+
|
|
79
|
+
function open() {
|
|
80
|
+
backdrop.hidden = false;
|
|
81
|
+
drawer.hidden = false;
|
|
82
|
+
void drawer.offsetWidth;
|
|
83
|
+
backdrop.classList.add('open');
|
|
84
|
+
drawer.classList.add('open');
|
|
85
|
+
openBtn.setAttribute('aria-expanded', 'true');
|
|
86
|
+
closeBtn.focus();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function close() {
|
|
90
|
+
if (drawer.hidden || closing) return;
|
|
91
|
+
closing = true;
|
|
92
|
+
backdrop.classList.remove('open');
|
|
93
|
+
drawer.classList.remove('open');
|
|
94
|
+
openBtn.setAttribute('aria-expanded', 'false');
|
|
95
|
+
|
|
96
|
+
let done = false;
|
|
97
|
+
const finish = () => {
|
|
98
|
+
if (done) return;
|
|
99
|
+
done = true;
|
|
100
|
+
backdrop.hidden = true;
|
|
101
|
+
drawer.hidden = true;
|
|
102
|
+
closing = false;
|
|
103
|
+
openBtn.focus();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
drawer.addEventListener('transitionend', finish, { once: true });
|
|
107
|
+
setTimeout(finish, 300);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
openBtn.onclick = open;
|
|
111
|
+
closeBtn.onclick = close;
|
|
112
|
+
backdrop.onclick = close;
|
|
113
|
+
|
|
114
|
+
document.addEventListener('keydown', (e) => {
|
|
115
|
+
if (e.key === 'Escape' && !drawer.hidden && !closing) {
|
|
116
|
+
close();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (location.hash === '#settings') open();
|
|
121
|
+
window.addEventListener('hashchange', () => {
|
|
122
|
+
if (location.hash === '#settings') open();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Changing the language reloads the page, so it is locked while a job runs.
|
|
127
|
+
function setBusy(busy) {
|
|
128
|
+
$('#lang').disabled = busy;
|
|
129
|
+
$('#theme').disabled = busy;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function boot() {
|
|
133
|
+
const s = await api('/api/settings');
|
|
134
|
+
applyTheme(s.theme);
|
|
135
|
+
({ messages } = await api(`/api/i18n/${s.lang}`));
|
|
136
|
+
document.documentElement.lang = s.lang;
|
|
137
|
+
applyStatic();
|
|
138
|
+
setupDrawer();
|
|
139
|
+
state.storedProfile = s.profile ?? null;
|
|
140
|
+
state.profileIds = s.profiles;
|
|
141
|
+
renderProfile();
|
|
142
|
+
$('#lang').value = s.lang;
|
|
143
|
+
$('#lang').onchange = async () => {
|
|
144
|
+
const prev = s.lang;
|
|
145
|
+
setBusy(true);
|
|
146
|
+
try {
|
|
147
|
+
await api('/api/settings', { lang: $('#lang').value });
|
|
148
|
+
location.reload();
|
|
149
|
+
} catch (err) {
|
|
150
|
+
$('#lang').value = prev;
|
|
151
|
+
setBusy(false);
|
|
152
|
+
alert(t('common.error', { message: err.message }));
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
$('#theme').value = s.theme;
|
|
156
|
+
$('#theme').onchange = async () => {
|
|
157
|
+
const prev = s.theme;
|
|
158
|
+
const next = $('#theme').value;
|
|
159
|
+
applyTheme(next);
|
|
160
|
+
try {
|
|
161
|
+
await api('/api/settings', { theme: next });
|
|
162
|
+
s.theme = next;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
$('#theme').value = prev;
|
|
165
|
+
applyTheme(prev);
|
|
166
|
+
alert(t('common.error', { message: err.message }));
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
await init();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------- Engine ----------
|
|
173
|
+
async function init() {
|
|
174
|
+
const s = await api('/api/state');
|
|
175
|
+
state.hw = s.hw;
|
|
176
|
+
state.engines = s.engines;
|
|
177
|
+
setBusy(s.busy);
|
|
178
|
+
$('#hw-mini').innerHTML = `<div>${esc(s.hw.cpu.brand)}</div><div>${gb(s.hw.ram.totalBytes)} RAM</div>` +
|
|
179
|
+
(s.hw.gpus.length ? s.hw.gpus.map((g) => `<div>GPU${g.index} ${esc(g.name.replace(/^NVIDIA GeForce /, ''))} · ${gb(g.totalBytes)}${g.pcieGen ? ` · PCIe ${g.pcieGen}.0 x${g.pcieWidth}` : ''}</div>`).join('') : `<div>${esc(t('web.noGpu'))}</div>`);
|
|
180
|
+
$('#engine-reason').textContent = t(s.reasonCode, s.reasonParams);
|
|
181
|
+
const box = $('#engines');
|
|
182
|
+
box.innerHTML = '';
|
|
183
|
+
for (const e of s.engines) {
|
|
184
|
+
const d = e.detection;
|
|
185
|
+
const btn = document.createElement('button');
|
|
186
|
+
btn.className = 'engine';
|
|
187
|
+
btn.type = 'button';
|
|
188
|
+
btn.dataset.id = e.id;
|
|
189
|
+
btn.innerHTML = `<b>${esc(e.name)}${e.id === s.recommended ? ` <span class="tag prio">${esc(t('web.recommended'))}</span>` : ''}</b>
|
|
190
|
+
<small>${d.installed ? `${esc(t('web.installed'))}${d.version ? ' · v' + esc(d.version) : ''}` : esc(t('web.notInstalled'))}</small>`;
|
|
191
|
+
btn.onclick = () => chooseEngine(e);
|
|
192
|
+
box.appendChild(btn);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function chooseEngine(e) {
|
|
197
|
+
state.engine = e;
|
|
198
|
+
document.querySelectorAll('.engine').forEach((b) => b.classList.toggle('selected', b.dataset.id === e.id));
|
|
199
|
+
if (!e.detection.installed) return showInstall(e);
|
|
200
|
+
$('#install').hidden = true;
|
|
201
|
+
$('#sec-model').hidden = false;
|
|
202
|
+
step('model');
|
|
203
|
+
$('#model').innerHTML = `<option>${esc(t('web.readingModels'))}</option>`;
|
|
204
|
+
const { models } = await api(`/api/models?engine=${e.id}`);
|
|
205
|
+
$('#model').innerHTML = models.length
|
|
206
|
+
? models.map((m) => `<option value="${esc(m.key)}">${esc(m.name)} · ${gb(m.sizeBytes)}${m.quant ? ' · ' + esc(m.quant) : ''}</option>`).join('')
|
|
207
|
+
: `<option value="">${esc(t('web.noModels'))}</option>`;
|
|
208
|
+
if (models.length) onModelChange();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function showInstall(e) {
|
|
212
|
+
$('#install').hidden = false;
|
|
213
|
+
$('#sec-model').hidden = true;
|
|
214
|
+
const { plans } = await api(`/api/install-plans?engine=${e.id}`);
|
|
215
|
+
$('#install-plan').innerHTML = plans.map((p) => `<option value="${p.id}">${esc(t(p.labelCode))}</option>`).join('');
|
|
216
|
+
$('#install-btn').onclick = async () => {
|
|
217
|
+
const log = $('#install-log');
|
|
218
|
+
log.hidden = false;
|
|
219
|
+
log.textContent = '';
|
|
220
|
+
setBusy(true);
|
|
221
|
+
try {
|
|
222
|
+
const r = await api('/api/install', { engine: e.id, planId: $('#install-plan').value });
|
|
223
|
+
if (r.manualCommand) {
|
|
224
|
+
log.textContent = t('web.needsAdmin', { command: r.manualCommand });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const done = await follow(r.jobId, (ev) => { if (ev.line) { log.textContent += ev.line + '\n'; log.scrollTop = log.scrollHeight; } });
|
|
228
|
+
log.textContent += done.ok
|
|
229
|
+
? `\n${done.manual ? t('web.downloadOpened') : t('web.installDone')}`
|
|
230
|
+
: `\n${t('common.error', { message: errText(done, 'web.installerFailed') })}`;
|
|
231
|
+
if (done.ok && !done.manual) init().then(() => chooseEngine(state.engines.find((x) => x.id === e.id)));
|
|
232
|
+
} catch (err) {
|
|
233
|
+
log.textContent += `\n${t('common.error', { message: err.message })}`;
|
|
234
|
+
} finally {
|
|
235
|
+
setBusy(false);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------- Model + context ----------
|
|
241
|
+
/** The engine, model and context selected right now: what an answer must still match to be shown. */
|
|
242
|
+
const selection = () => ({ engine: state.engine?.id, model: $('#model').value, ctx: Number($('#ctx').value) });
|
|
243
|
+
|
|
244
|
+
async function onModelChange() {
|
|
245
|
+
const requested = selection();
|
|
246
|
+
const { model } = requested;
|
|
247
|
+
if (!model) return;
|
|
248
|
+
$('#load-btn').disabled = true;
|
|
249
|
+
$('#model-meta').textContent = t('web.readingModel');
|
|
250
|
+
// The preview of the previous model says nothing about this one.
|
|
251
|
+
state.profilePlan = null;
|
|
252
|
+
renderProfile();
|
|
253
|
+
const [{ presets }, plan] = await Promise.all([
|
|
254
|
+
api(`/api/presets?engine=${requested.engine}&model=${encodeURIComponent(model)}`),
|
|
255
|
+
api('/api/plan', requested).catch((err) => ({ error: err.message })),
|
|
256
|
+
]);
|
|
257
|
+
// An answer for a model that is no longer the chosen one is stale: the change that followed owns the view.
|
|
258
|
+
const current = selection();
|
|
259
|
+
if (!isCurrentModel(requested, current)) return;
|
|
260
|
+
// Only the context changed meanwhile: the model data still holds, and onCtxChange asks for the plan of that context.
|
|
261
|
+
const planIsCurrent = isCurrentPlan(requested, current);
|
|
262
|
+
state.presets = presets;
|
|
263
|
+
if (plan.error) { $('#model-meta').textContent = t('web.modelReadFailed', { error: plan.error }); return; }
|
|
264
|
+
state.meta = plan.meta;
|
|
265
|
+
state.maxContext = plan.maxContext;
|
|
266
|
+
if (planIsCurrent) state.profilePlan = plan.profiles ?? null;
|
|
267
|
+
const m = plan.meta;
|
|
268
|
+
$('#ctx').max = m.trainContext || 131072;
|
|
269
|
+
$('#model-meta').textContent = [
|
|
270
|
+
m.arch,
|
|
271
|
+
t('web.layers', { n: m.nLayers }) + (m.isMoE ? ` · MoE ${m.nExpertsUsed}/${m.nExperts}` : ''),
|
|
272
|
+
t('web.trainedUpTo', { ctx: m.trainContext ? k(m.trainContext) : '?' }),
|
|
273
|
+
t('web.maxAllGpu', { list: Object.entries(plan.maxContext).map(([kv, v]) => `${v ? k(v) : '—'} (KV ${kv})`).join(', ') }),
|
|
274
|
+
].join(' · ');
|
|
275
|
+
renderProfile();
|
|
276
|
+
renderChips(plan.maxContext);
|
|
277
|
+
if (planIsCurrent) renderGpus(plan.candidates[0]);
|
|
278
|
+
updatePresetPill();
|
|
279
|
+
$('#load-btn').disabled = false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------- Load profile ----------
|
|
283
|
+
const selectedProfile = () => initialProfile(state.storedProfile, state.profilePlan);
|
|
284
|
+
|
|
285
|
+
function renderProfile() {
|
|
286
|
+
const box = $('#profile-options');
|
|
287
|
+
const options = profileOptions(state.profilePlan, t, state.profileIds);
|
|
288
|
+
// The radios are built once and only updated afterwards, so keyboard focus survives a refresh.
|
|
289
|
+
if (!box.children.length) {
|
|
290
|
+
box.innerHTML = options.map((o) => `<label class="profile-option" data-id="${esc(o.id)}">
|
|
291
|
+
<span class="profile-head"><input type="radio" name="profile" value="${esc(o.id)}"><b>${esc(o.name)}</b><span class="tag prio" hidden>${esc(t('web.recommended'))}</span></span>
|
|
292
|
+
<small>${esc(o.description)}</small></label>`).join('');
|
|
293
|
+
box.querySelectorAll('input').forEach((input) => { input.onchange = () => chooseProfile(input.value); });
|
|
294
|
+
}
|
|
295
|
+
const current = selectedProfile();
|
|
296
|
+
for (const label of box.children) {
|
|
297
|
+
const o = options.find((x) => x.id === label.dataset.id);
|
|
298
|
+
label.classList.toggle('selected', o.id === current);
|
|
299
|
+
label.querySelector('input').checked = o.id === current;
|
|
300
|
+
label.querySelector('.tag').hidden = !o.recommended;
|
|
301
|
+
}
|
|
302
|
+
const lines = profileDetailLines(state.profilePlan, current, t, { engineName: state.engine?.name, formatBytes: gb });
|
|
303
|
+
if (state.profileSaveError) lines.push({ level: 'warn', text: t('web.profileNotSaved', { error: state.profileSaveError }) });
|
|
304
|
+
const html = lines.map((l) => `<div${l.level === 'warn' ? ' class="warn"' : ''}>${esc(l.text)}</div>`).join('');
|
|
305
|
+
// The detail is a polite live region: writing the same markup again would make a screen reader repeat it.
|
|
306
|
+
if (html === state.profileDetailHtml) return;
|
|
307
|
+
state.profileDetailHtml = html;
|
|
308
|
+
$('#profile-detail').innerHTML = html;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// The profile in use changed (a click, or a new recommendation while none is stored): redraw what depends on it.
|
|
312
|
+
function refreshProfileViews() {
|
|
313
|
+
renderProfile();
|
|
314
|
+
if (state.maxContext && state.chipsProfile !== selectedProfile()) renderChips(state.maxContext);
|
|
315
|
+
updatePresetPill();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function chooseProfile(profile) {
|
|
319
|
+
state.storedProfile = profile;
|
|
320
|
+
state.profileSaveError = null;
|
|
321
|
+
refreshProfileViews();
|
|
322
|
+
// Remembering the choice is a convenience: a failed save is reported and never blocks loading.
|
|
323
|
+
try {
|
|
324
|
+
await api('/api/settings', { profile });
|
|
325
|
+
} catch (err) {
|
|
326
|
+
state.profileSaveError = err.message;
|
|
327
|
+
renderProfile();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function renderChips(maxContext) {
|
|
332
|
+
const limit = state.meta.trainContext || 131072;
|
|
333
|
+
const q8 = maxContext.q8_0 || 0;
|
|
334
|
+
// Presets are saved per context and profile: the chips show the ones of the selected profile.
|
|
335
|
+
const profile = selectedProfile();
|
|
336
|
+
state.chipsProfile = profile;
|
|
337
|
+
const values = [...new Set([4096, 8192, 16384, 32768, 65536, 131072, ...presetsFor(state.presets, profile).map((p) => p.ctx)])].filter((v) => v <= limit).sort((a, b) => a - b);
|
|
338
|
+
$('#ctx-chips').innerHTML = values.map((v) => {
|
|
339
|
+
const p = presetFor(state.presets, v, profile);
|
|
340
|
+
const cls = p ? 'chip saved' : v <= q8 ? 'chip fits' : 'chip';
|
|
341
|
+
const title = p ? t('web.presetSavedTitle', { tps: p.shortTps }) : v <= q8 ? t('web.fitsGpu') : t('web.mayNeedKv');
|
|
342
|
+
return `<button type="button" class="${cls}" data-v="${v}" title="${esc(title)}">${k(v)}${p ? ' ✓' : ''}</button>`;
|
|
343
|
+
}).join('');
|
|
344
|
+
$('#ctx-chips').querySelectorAll('button').forEach((b) => { b.onclick = () => { $('#ctx').value = b.dataset.v; onCtxChange(); }; });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function updatePresetPill() {
|
|
348
|
+
const ctx = Number($('#ctx').value);
|
|
349
|
+
const p = presetFor(state.presets, ctx, selectedProfile());
|
|
350
|
+
const pill = $('#preset-pill');
|
|
351
|
+
pill.hidden = false;
|
|
352
|
+
pill.className = p ? 'pill ok' : 'pill warn';
|
|
353
|
+
pill.textContent = p ? t('web.presetPillSaved', { tps: p.shortTps }) : t('web.presetPillNone');
|
|
354
|
+
document.querySelectorAll('#ctx-chips .chip').forEach((b) => b.classList.toggle('current', Number(b.dataset.v) === ctx));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function renderGpus(candidate) {
|
|
358
|
+
const box = $('#gpus');
|
|
359
|
+
if (!state.hw.gpus.length || !candidate) { box.innerHTML = ''; return; }
|
|
360
|
+
box.innerHTML = state.hw.gpus.map((g) => {
|
|
361
|
+
const lp = candidate.layersPerGpu?.find((l) => l.index === g.index);
|
|
362
|
+
const usedPct = (g.usedBytes / g.totalBytes) * 100;
|
|
363
|
+
const planPct = lp ? Math.min(100 - usedPct, (lp.bytes / g.totalBytes) * 100) : 0;
|
|
364
|
+
return `<div class="gpu">
|
|
365
|
+
<div class="gpu-head"><span>GPU ${g.index}</span><span class="muted">${lp ? esc(t('web.gpuLayers', { layers: lp.layers, used: gb(lp.bytes), free: gb(g.freeBytes) })) : ''}</span></div>
|
|
366
|
+
<div class="meter" role="img" aria-label="${esc(t('web.vramAria', { index: g.index }))}"><i class="used" style="width:${usedPct}%"></i><i class="model" style="width:${planPct}%"></i></div>
|
|
367
|
+
</div>`;
|
|
368
|
+
}).join('') + `<div class="legend"><span style="--c:var(--cpu)">${esc(t('web.legendOther'))}</span><span style="--c:var(--gpu)">${esc(t('web.legendModel'))}</span></div>`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
$('#model').onchange = onModelChange;
|
|
372
|
+
let timer;
|
|
373
|
+
// A new context changes the plan: the GPU meters and what each profile would do are refreshed once typing settles.
|
|
374
|
+
function onCtxChange() {
|
|
375
|
+
updatePresetPill();
|
|
376
|
+
clearTimeout(timer);
|
|
377
|
+
timer = setTimeout(async () => {
|
|
378
|
+
const requested = selection();
|
|
379
|
+
const plan = await api('/api/plan', requested).catch(() => null);
|
|
380
|
+
// An answer for an engine, model or context that is no longer the chosen one is stale.
|
|
381
|
+
if (!plan || !isCurrentPlan(requested, selection())) return;
|
|
382
|
+
renderGpus(plan.candidates[0]);
|
|
383
|
+
state.profilePlan = plan.profiles ?? null;
|
|
384
|
+
refreshProfileViews();
|
|
385
|
+
}, 400);
|
|
386
|
+
}
|
|
387
|
+
$('#ctx').oninput = onCtxChange;
|
|
388
|
+
|
|
389
|
+
// ---------- Load ----------
|
|
390
|
+
$('#load-btn').onclick = async () => {
|
|
391
|
+
const model = $('#model').value;
|
|
392
|
+
const ctx = Number($('#ctx').value);
|
|
393
|
+
const profile = selectedProfile();
|
|
394
|
+
$('#load-btn').disabled = true;
|
|
395
|
+
setBusy(true);
|
|
396
|
+
$('#sec-load').hidden = false;
|
|
397
|
+
step('load');
|
|
398
|
+
$('#load-title').textContent = t('web.loadingTitle', { model, ctx: k(ctx) });
|
|
399
|
+
const status = $('#load-status');
|
|
400
|
+
status.className = 'pill';
|
|
401
|
+
status.textContent = t('web.inProgress');
|
|
402
|
+
$('#bench').hidden = true;
|
|
403
|
+
$('#chat-box').hidden = true;
|
|
404
|
+
$('#chat-messages').innerHTML = '';
|
|
405
|
+
state.loadedModel = null;
|
|
406
|
+
state.chatMessages = [];
|
|
407
|
+
$('#load-result').innerHTML = '';
|
|
408
|
+
const log = $('#load-log');
|
|
409
|
+
log.textContent = '';
|
|
410
|
+
// One element per line, so a warning can be styled on its own.
|
|
411
|
+
const addLog = (s, level = 'note') => {
|
|
412
|
+
const line = document.createElement('span');
|
|
413
|
+
if (level === 'warn') line.className = 'warn';
|
|
414
|
+
line.textContent = s + '\n';
|
|
415
|
+
log.appendChild(line);
|
|
416
|
+
log.scrollTop = log.scrollHeight;
|
|
417
|
+
};
|
|
418
|
+
const tbody = $('#results tbody');
|
|
419
|
+
tbody.innerHTML = '';
|
|
420
|
+
const rows = [];
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
const { jobId } = await api('/api/load', { engine: state.engine.id, model, ctx, profile, force: $('#force').checked });
|
|
424
|
+
// Progress event before the current one: a fallback that repeats its reason is not logged twice.
|
|
425
|
+
let previous = null;
|
|
426
|
+
const done = await follow(jobId, (e) => {
|
|
427
|
+
if (e.type === 'variant-picked' || e.type === 'profile-fallback') {
|
|
428
|
+
for (const line of profileEventLines(e, t, { engineName: state.engine.name, previous })) addLog(line.text, line.level);
|
|
429
|
+
}
|
|
430
|
+
previous = e;
|
|
431
|
+
if (e.type === 'status') addLog(t(e.code, e.params));
|
|
432
|
+
if (e.type === 'preset-hit') addLog(t('web.presetHit', { date: e.preset.createdAt.slice(0, 10), config: describe(e.preset.candidate) }));
|
|
433
|
+
if (e.type === 'plan') {
|
|
434
|
+
$('#bench').hidden = false;
|
|
435
|
+
e.candidates.forEach((c, i) => {
|
|
436
|
+
const tr = document.createElement('tr');
|
|
437
|
+
tr.innerHTML = `<td>${esc(describe(c))}</td><td>${c.gpuLayers}/${c.totalLayers}</td><td class="num">${c.predicted.tpsShort}</td><td class="num">…</td><td class="num">…</td><td class="num">…</td><td>${esc(t('web.pending'))}</td>`;
|
|
438
|
+
tbody.appendChild(tr);
|
|
439
|
+
rows[i] = tr;
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
if (e.type === 'candidate-start') addLog(`[${e.index + 1}/${e.total}] ${describe(e.candidate)}`);
|
|
443
|
+
if (e.type === 'bench-progress' && e.phase === 'deep') addLog(` ${t('common.longPrompt', { tokens: e.promptTokens })}`);
|
|
444
|
+
if (e.type === 'candidate-done') {
|
|
445
|
+
const b = e.bench, tr = rows[e.index];
|
|
446
|
+
if (!tr) return;
|
|
447
|
+
if (!b.ok) { tr.className = 'fail'; tr.cells[3].textContent = '✗'; tr.cells[6].innerHTML = `<span class="err">${esc(benchErr(b))}</span>`; return; }
|
|
448
|
+
tr.cells[3].textContent = b.short.genTps;
|
|
449
|
+
tr.cells[4].textContent = b.deep ? `${b.deep.genTps} @${k(b.deep.promptTokens)}` : '—';
|
|
450
|
+
tr.cells[5].textContent = b.cpu?.avg != null ? `${b.cpu.avg}%` : '—';
|
|
451
|
+
tr.cells[6].textContent = Object.entries(b.vramPeakBytes || {}).map(([i, v]) => `GPU${i} ${gb(v)}`).join(' · ') || t('common.na');
|
|
452
|
+
}
|
|
453
|
+
if (e.type === 'preset-saved') addLog(t('common.presetSaved', { file: e.file }));
|
|
454
|
+
});
|
|
455
|
+
if (!done.ok) {
|
|
456
|
+
const loaded = done.result?.loaded;
|
|
457
|
+
throw new Error(done.code || done.error ? errText(done) : loaded && !loaded.ok ? benchErr(loaded) : t('bench.loadFailed'));
|
|
458
|
+
}
|
|
459
|
+
const r = done.result;
|
|
460
|
+
if (r.report?.best) {
|
|
461
|
+
const idx = r.report.results.findIndex((x) => x.candidate.id === r.report.best.candidate.id);
|
|
462
|
+
rows[idx]?.classList.add('best');
|
|
463
|
+
}
|
|
464
|
+
status.className = 'pill ok';
|
|
465
|
+
status.textContent = r.source === 'preset' ? t('web.fromPreset') : t('web.measuredLoaded');
|
|
466
|
+
let html = '';
|
|
467
|
+
if (r.loaded?.ok) {
|
|
468
|
+
html += `<div class="best"><div><span class="big">${r.loaded.short.genTps}</span> <span class="muted">tokens/s</span></div>
|
|
469
|
+
<div><b>${esc(describe(r.candidate))}</b> · ${esc(t('web.threadsCtx', { threads: r.candidate.threads, ctx: k(r.candidate.ctx) }))}${r.loaded.model ? `<br><span class="muted small">${esc(t('web.usesModel'))} <code>${esc(r.loaded.model)}</code></span>` : ''}</div></div>`;
|
|
470
|
+
state.loadedModel = r.loaded.model || model;
|
|
471
|
+
initChat();
|
|
472
|
+
}
|
|
473
|
+
if (r.applied?.changes?.length) html += `<div class="muted small">${r.applied.changes.map((ch) => esc(t(ch.code, ch.params))).join('<br>')}</div>`;
|
|
474
|
+
if (r.applied?.backups?.length) html += `<div class="muted small">${esc(t('common.backups', { list: r.applied.backups.join(', ') }))}</div>`;
|
|
475
|
+
if (r.applied?.pendingCommands?.length) html += `<div class="notice warn">${esc(t('web.pendingCommands'))}<pre>${esc(r.applied.pendingCommands.join('\n'))}</pre></div>`;
|
|
476
|
+
$('#load-result').innerHTML = html;
|
|
477
|
+
const { presets } = await api(`/api/presets?engine=${state.engine.id}&model=${encodeURIComponent(model)}`);
|
|
478
|
+
state.presets = presets;
|
|
479
|
+
updatePresetPill();
|
|
480
|
+
} catch (err) {
|
|
481
|
+
status.className = 'pill bad';
|
|
482
|
+
status.textContent = t('web.error');
|
|
483
|
+
$('#chat-box').hidden = true;
|
|
484
|
+
$('#load-result').innerHTML = `<div class="notice warn">${esc(err.message)}</div>`;
|
|
485
|
+
} finally {
|
|
486
|
+
$('#load-btn').disabled = false;
|
|
487
|
+
setBusy(false);
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// ---------- Chat tester ----------
|
|
492
|
+
function appendMessage(role, text) {
|
|
493
|
+
const container = $('#chat-messages');
|
|
494
|
+
const div = document.createElement('div');
|
|
495
|
+
div.className = `chat-msg ${role}`;
|
|
496
|
+
div.textContent = text;
|
|
497
|
+
container.appendChild(div);
|
|
498
|
+
container.scrollTop = container.scrollHeight;
|
|
499
|
+
return div;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function initChat() {
|
|
503
|
+
$('#chat-box').hidden = false;
|
|
504
|
+
$('#chat-messages').innerHTML = '';
|
|
505
|
+
state.chatMessages = [];
|
|
506
|
+
const input = $('#chat-input');
|
|
507
|
+
input.value = '';
|
|
508
|
+
input.disabled = false;
|
|
509
|
+
$('#chat-send').disabled = false;
|
|
510
|
+
input.focus();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
$('#chat-form').onsubmit = async (e) => {
|
|
514
|
+
e.preventDefault();
|
|
515
|
+
const input = $('#chat-input');
|
|
516
|
+
const text = input.value.trim();
|
|
517
|
+
if (!text || !state.loadedModel || state.chatBusy) return;
|
|
518
|
+
|
|
519
|
+
state.chatBusy = true;
|
|
520
|
+
input.value = '';
|
|
521
|
+
input.disabled = true;
|
|
522
|
+
$('#chat-send').disabled = true;
|
|
523
|
+
|
|
524
|
+
appendMessage('user', text);
|
|
525
|
+
state.chatMessages.push({ role: 'user', content: text });
|
|
526
|
+
|
|
527
|
+
const loadingDiv = appendMessage('loading', t('web.chatThinking'));
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
const res = await api('/api/chat', {
|
|
531
|
+
engine: state.engine.id,
|
|
532
|
+
model: state.loadedModel,
|
|
533
|
+
messages: state.chatMessages,
|
|
534
|
+
});
|
|
535
|
+
loadingDiv.remove();
|
|
536
|
+
const reply = res.message?.content || '';
|
|
537
|
+
appendMessage('assistant', reply);
|
|
538
|
+
state.chatMessages.push({ role: 'assistant', content: reply });
|
|
539
|
+
} catch (err) {
|
|
540
|
+
loadingDiv.remove();
|
|
541
|
+
appendMessage('error', err.message);
|
|
542
|
+
} finally {
|
|
543
|
+
state.chatBusy = false;
|
|
544
|
+
input.disabled = false;
|
|
545
|
+
$('#chat-send').disabled = false;
|
|
546
|
+
input.focus();
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
boot().catch((err) => { $('#hw-mini').textContent = `Error: ${err.message}`; });
|
|
551
|
+
|
|
Binary file
|
|
Binary file
|