llm-tuner 0.4.4 → 0.5.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 +202 -125
- package/README.md +197 -120
- package/electron/launch.js +1 -0
- package/electron/main.js +48 -2
- package/electron/preload.cjs +8 -0
- package/electron/server-controls.js +69 -0
- package/package.json +1 -1
- package/src/cli/commands/help.js +1 -0
- package/src/cli/commands/load.js +72 -37
- package/src/cli/commands/mcp.js +16 -2
- package/src/cli/commands/presets.js +12 -2
- package/src/cli/commands/serve.js +59 -0
- package/src/cli/commands/settings.js +60 -5
- package/src/cli/router.js +32 -0
- package/src/cli/server-login.js +47 -0
- package/src/core/client.js +127 -0
- package/src/core/index.js +3 -2
- package/src/core/settings.js +50 -2
- package/src/core/util.js +26 -0
- package/src/i18n/en.js +48 -1
- package/src/i18n/es.js +48 -1
- package/src/platform/server-autostart.js +187 -0
- package/src/web/public/app.js +149 -3
- package/src/web/public/index.html +57 -0
- package/src/web/public/server-settings.js +127 -0
- package/src/web/server.js +202 -15
package/src/web/public/app.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { setupServerSettings } from './server-settings.js';
|
|
1
2
|
import { format } from '/i18n/core.js';
|
|
2
3
|
import { initialProfile, profileOptions, profileDetailLines, profileEventLines, presetsFor, presetFor, isCurrentModel, isCurrentPlan } from './profile.js';
|
|
3
4
|
|
|
@@ -235,15 +236,35 @@ async function openMcpModal(engine) {
|
|
|
235
236
|
if (title) title.textContent = t('web.mcpModalTitle', { engine: engine.name });
|
|
236
237
|
if (nameInput) nameInput.value = engine.id === 'lmstudio' ? 'lmstudio' : 'ollama';
|
|
237
238
|
|
|
239
|
+
const activeSrv = (state.settings?.servers || []).find((x) => x.id === state.settings?.activeServerId);
|
|
240
|
+
const isRemote = Boolean(activeSrv && activeSrv.id !== 'local' && activeSrv.url);
|
|
241
|
+
let remoteHost = null;
|
|
242
|
+
if (isRemote) {
|
|
243
|
+
try {
|
|
244
|
+
const hostname = new URL(activeSrv.url).hostname;
|
|
245
|
+
if (hostname && hostname !== '127.0.0.1' && hostname !== 'localhost') {
|
|
246
|
+
remoteHost = engine.id === 'lmstudio' ? `http://${hostname}:1234/v1` : `http://${hostname}:11434`;
|
|
247
|
+
}
|
|
248
|
+
} catch {}
|
|
249
|
+
}
|
|
250
|
+
|
|
238
251
|
if (engine.id === 'lmstudio') {
|
|
239
|
-
|
|
252
|
+
const defaultHost = remoteHost || 'http://127.0.0.1:1234/v1';
|
|
253
|
+
if (hostInput) {
|
|
254
|
+
hostInput.placeholder = defaultHost;
|
|
255
|
+
hostInput.value = remoteHost || '';
|
|
256
|
+
}
|
|
240
257
|
if (apiKeyGroup) apiKeyGroup.hidden = false;
|
|
241
258
|
if (apiKeyInput) {
|
|
242
259
|
apiKeyInput.value = '';
|
|
243
260
|
apiKeyInput.placeholder = 'sk-lm-local';
|
|
244
261
|
}
|
|
245
262
|
} else {
|
|
246
|
-
|
|
263
|
+
const defaultHost = remoteHost || 'http://127.0.0.1:11434';
|
|
264
|
+
if (hostInput) {
|
|
265
|
+
hostInput.placeholder = defaultHost;
|
|
266
|
+
hostInput.value = remoteHost || '';
|
|
267
|
+
}
|
|
247
268
|
if (apiKeyGroup) apiKeyGroup.hidden = true;
|
|
248
269
|
if (apiKeyInput) apiKeyInput.value = '';
|
|
249
270
|
}
|
|
@@ -285,11 +306,13 @@ function updateMcpAgentsList() {
|
|
|
285
306
|
|
|
286
307
|
async function boot() {
|
|
287
308
|
const s = await api('/api/settings');
|
|
309
|
+
state.settings = s;
|
|
288
310
|
applyTheme(s.theme);
|
|
289
311
|
({ messages } = await api(`/api/i18n/${s.lang}`));
|
|
290
312
|
document.documentElement.lang = s.lang;
|
|
291
313
|
applyStatic();
|
|
292
314
|
setupDrawer();
|
|
315
|
+
await setupServerSettings({ document, bridge: window.llmTunerServer, t });
|
|
293
316
|
setupMcpModal();
|
|
294
317
|
api('/api/update').then((up) => {
|
|
295
318
|
if (up?.currentVersion) {
|
|
@@ -337,6 +360,128 @@ async function boot() {
|
|
|
337
360
|
alert(t('common.error', { message: err.message }));
|
|
338
361
|
}
|
|
339
362
|
};
|
|
363
|
+
|
|
364
|
+
function renderServerList() {
|
|
365
|
+
const sel = $('#server-select');
|
|
366
|
+
if (!sel) return;
|
|
367
|
+
sel.innerHTML = '';
|
|
368
|
+
(s.servers || []).forEach((srv) => {
|
|
369
|
+
const opt = document.createElement('option');
|
|
370
|
+
opt.value = srv.id;
|
|
371
|
+
opt.textContent = `${srv.name} (${srv.url})`;
|
|
372
|
+
if (srv.id === s.activeServerId) opt.selected = true;
|
|
373
|
+
sel.appendChild(opt);
|
|
374
|
+
});
|
|
375
|
+
const active = (s.servers || []).find((x) => x.id === s.activeServerId) || s.servers?.[0];
|
|
376
|
+
if (active) {
|
|
377
|
+
const urlSpan = $('#active-server-url');
|
|
378
|
+
if (urlSpan) urlSpan.textContent = active.url;
|
|
379
|
+
const removeBtn = $('#remove-server-btn');
|
|
380
|
+
if (removeBtn) removeBtn.hidden = (active.id === 'local');
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
renderServerList();
|
|
384
|
+
|
|
385
|
+
const serverSel = $('#server-select');
|
|
386
|
+
if (serverSel) {
|
|
387
|
+
serverSel.onchange = async () => {
|
|
388
|
+
const nextId = serverSel.value;
|
|
389
|
+
setBusy(true);
|
|
390
|
+
try {
|
|
391
|
+
await api('/api/settings', { activeServerId: nextId });
|
|
392
|
+
location.reload();
|
|
393
|
+
} catch (err) {
|
|
394
|
+
setBusy(false);
|
|
395
|
+
alert(t('common.error', { message: err.message }));
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const removeServerBtn = $('#remove-server-btn');
|
|
401
|
+
if (removeServerBtn) {
|
|
402
|
+
removeServerBtn.onclick = async () => {
|
|
403
|
+
const activeId = s.activeServerId;
|
|
404
|
+
if (activeId === 'local') return;
|
|
405
|
+
const nextServers = (s.servers || []).filter((x) => x.id !== activeId);
|
|
406
|
+
setBusy(true);
|
|
407
|
+
try {
|
|
408
|
+
await api('/api/settings', { servers: nextServers, activeServerId: 'local' });
|
|
409
|
+
location.reload();
|
|
410
|
+
} catch (err) {
|
|
411
|
+
setBusy(false);
|
|
412
|
+
alert(t('common.error', { message: err.message }));
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const serverModal = $('#server-modal');
|
|
418
|
+
const serverBackdrop = $('#server-backdrop');
|
|
419
|
+
function closeServerModal() {
|
|
420
|
+
if (serverModal) {
|
|
421
|
+
serverModal.hidden = true;
|
|
422
|
+
serverModal.close?.();
|
|
423
|
+
}
|
|
424
|
+
if (serverBackdrop) serverBackdrop.hidden = true;
|
|
425
|
+
$('#server-form')?.reset();
|
|
426
|
+
const fb = $('#server-test-feedback');
|
|
427
|
+
if (fb) fb.hidden = true;
|
|
428
|
+
}
|
|
429
|
+
function openServerModal() {
|
|
430
|
+
if (serverModal) {
|
|
431
|
+
serverModal.hidden = false;
|
|
432
|
+
serverModal.showModal?.();
|
|
433
|
+
}
|
|
434
|
+
if (serverBackdrop) serverBackdrop.hidden = false;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const addServerBtn = $('#add-server-btn');
|
|
438
|
+
if (addServerBtn) addServerBtn.onclick = openServerModal;
|
|
439
|
+
const serverModalClose = $('#server-modal-close');
|
|
440
|
+
if (serverModalClose) serverModalClose.onclick = closeServerModal;
|
|
441
|
+
if (serverBackdrop) serverBackdrop.onclick = closeServerModal;
|
|
442
|
+
|
|
443
|
+
const serverTestBtn = $('#server-test-btn');
|
|
444
|
+
if (serverTestBtn) {
|
|
445
|
+
serverTestBtn.onclick = async () => {
|
|
446
|
+
const url = $('#new-server-url').value.trim().replace(/\/+$/, '');
|
|
447
|
+
const token = $('#new-server-token').value.trim();
|
|
448
|
+
const fb = $('#server-test-feedback');
|
|
449
|
+
if (!fb) return;
|
|
450
|
+
fb.hidden = false;
|
|
451
|
+
fb.textContent = 'Connecting...';
|
|
452
|
+
try {
|
|
453
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
|
454
|
+
const res = await fetch(`${url}/api/state`, { headers });
|
|
455
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
456
|
+
fb.textContent = t('web.serverConnected');
|
|
457
|
+
fb.style.color = 'var(--c-accent, green)';
|
|
458
|
+
} catch (err) {
|
|
459
|
+
fb.textContent = t('web.serverConnectionFailed', { error: err.message });
|
|
460
|
+
fb.style.color = 'var(--c-err, red)';
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const serverForm = $('#server-form');
|
|
466
|
+
if (serverForm) {
|
|
467
|
+
serverForm.onsubmit = async (e) => {
|
|
468
|
+
e.preventDefault();
|
|
469
|
+
const name = $('#new-server-name').value.trim();
|
|
470
|
+
const url = $('#new-server-url').value.trim().replace(/\/+$/, '');
|
|
471
|
+
const token = $('#new-server-token').value.trim() || null;
|
|
472
|
+
const newServer = { id: 'srv_' + Date.now(), name, url, token };
|
|
473
|
+
const nextServers = [...(s.servers || []), newServer];
|
|
474
|
+
setBusy(true);
|
|
475
|
+
try {
|
|
476
|
+
await api('/api/settings', { servers: nextServers, activeServerId: newServer.id });
|
|
477
|
+
location.reload();
|
|
478
|
+
} catch (err) {
|
|
479
|
+
setBusy(false);
|
|
480
|
+
alert(t('common.error', { message: err.message }));
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
340
485
|
await init();
|
|
341
486
|
}
|
|
342
487
|
|
|
@@ -346,7 +491,8 @@ async function init() {
|
|
|
346
491
|
state.hw = s.hw;
|
|
347
492
|
state.engines = s.engines;
|
|
348
493
|
setBusy(s.busy);
|
|
349
|
-
|
|
494
|
+
const cpuTitle = s.hw?.cpu?.brand || (s.hw?.cpu?.physicalCores ? `${s.hw.cpu.physicalCores} cores` : 'CPU');
|
|
495
|
+
$('#hw-mini').innerHTML = `<div>${esc(cpuTitle)}</div><div>${gb(s.hw.ram.totalBytes)} RAM</div>` +
|
|
350
496
|
(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>`);
|
|
351
497
|
$('#engine-reason').textContent = t(s.reasonCode, s.reasonParams);
|
|
352
498
|
const box = $('#engines');
|
|
@@ -145,6 +145,37 @@
|
|
|
145
145
|
<option value="dark" data-i18n="web.themeDark">Dark</option>
|
|
146
146
|
</select>
|
|
147
147
|
</div>
|
|
148
|
+
<div class="drawer-field servers-section">
|
|
149
|
+
<label for="server-select" data-i18n="web.servers">Servers</label>
|
|
150
|
+
<div class="server-control-row" style="display:flex;gap:8px;margin-bottom:8px;">
|
|
151
|
+
<select id="server-select" style="flex:1;"></select>
|
|
152
|
+
<button id="add-server-btn" type="button" class="btn btn-sm" data-i18n="web.addServer">+ Add</button>
|
|
153
|
+
</div>
|
|
154
|
+
<div id="server-item-info" class="small muted" style="display:flex;justify-content:space-between;align-items:center;">
|
|
155
|
+
<span id="active-server-url"></span>
|
|
156
|
+
<button id="remove-server-btn" type="button" class="btn btn-danger-sm" data-i18n="web.removeServer" style="padding:2px 8px;font-size:11px;" hidden>Remove</button>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
<section id="local-server-settings" class="drawer-field" hidden>
|
|
160
|
+
<h3 data-i18n="web.localServerSettings">Local Server Settings</h3>
|
|
161
|
+
|
|
162
|
+
<label for="local-server-autostart" data-i18n="web.serverAutostartLabel">Server Autostart</label>
|
|
163
|
+
<select id="local-server-autostart" disabled>
|
|
164
|
+
<option value="enabled" data-i18n="cli.enabled">Enabled</option>
|
|
165
|
+
<option value="disabled" data-i18n="cli.disabled">Disabled</option>
|
|
166
|
+
</select>
|
|
167
|
+
<p id="local-server-status" class="small muted" aria-live="polite"></p>
|
|
168
|
+
|
|
169
|
+
<label for="server-token" data-i18n="web.serverTokenLabel">Server Token</label>
|
|
170
|
+
<div class="token-container">
|
|
171
|
+
<input id="server-token" type="password" readonly autocomplete="off" spellcheck="false" value="">
|
|
172
|
+
<button type="button" id="server-token-reveal" class="btn btn-sm" data-i18n="web.serverTokenReveal">Reveal</button>
|
|
173
|
+
<button type="button" id="server-token-copy" class="btn btn-sm" data-i18n="web.serverTokenCopy">Copy</button>
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<p id="server-connection-info" class="small muted"></p>
|
|
177
|
+
<p id="local-server-feedback" class="small" aria-live="polite" role="status"></p>
|
|
178
|
+
</section>
|
|
148
179
|
<div class="drawer-field version-info">
|
|
149
180
|
<span class="muted" id="version-display"></span>
|
|
150
181
|
<div id="update-badge" hidden>
|
|
@@ -186,6 +217,32 @@
|
|
|
186
217
|
<p id="mcp-status" class="mcp-status" hidden></p>
|
|
187
218
|
</form>
|
|
188
219
|
</dialog>
|
|
220
|
+
<div id="server-backdrop" class="modal-backdrop" hidden></div>
|
|
221
|
+
<dialog id="server-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="server-modal-title" hidden>
|
|
222
|
+
<div class="modal-header">
|
|
223
|
+
<h3 id="server-modal-title" data-i18n="web.addServerModalTitle">Add Remote Server</h3>
|
|
224
|
+
<button id="server-modal-close" class="modal-close" type="button" aria-label="Close">✕</button>
|
|
225
|
+
</div>
|
|
226
|
+
<form id="server-form" class="modal-body">
|
|
227
|
+
<div class="field">
|
|
228
|
+
<label for="new-server-name" data-i18n="web.serverName">Server Name</label>
|
|
229
|
+
<input id="new-server-name" type="text" required placeholder="e.g. Remote GPU Box">
|
|
230
|
+
</div>
|
|
231
|
+
<div class="field">
|
|
232
|
+
<label for="new-server-url" data-i18n="web.serverUrl">Server URL</label>
|
|
233
|
+
<input id="new-server-url" type="url" required placeholder="http://192.168.1.100:7860">
|
|
234
|
+
</div>
|
|
235
|
+
<div class="field">
|
|
236
|
+
<label for="new-server-token" data-i18n="web.serverToken">Authentication Token</label>
|
|
237
|
+
<input id="new-server-token" type="password" placeholder="Bearer Token">
|
|
238
|
+
</div>
|
|
239
|
+
<div style="display:flex;gap:8px;align-items:center;margin-top:12px;">
|
|
240
|
+
<button id="server-test-btn" type="button" class="btn" data-i18n="web.testConnection">Test Connection</button>
|
|
241
|
+
<button id="server-save-btn" type="submit" class="btn prio" data-i18n="web.saveServer">Save Server</button>
|
|
242
|
+
</div>
|
|
243
|
+
<div id="server-test-feedback" class="small muted" style="margin-top:8px;" hidden></div>
|
|
244
|
+
</form>
|
|
245
|
+
</dialog>
|
|
189
246
|
<script src="app.js" type="module"></script>
|
|
190
247
|
</body>
|
|
191
248
|
</html>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
export async function setupServerSettings({ document, bridge, t }) {
|
|
2
|
+
if (!bridge) return;
|
|
3
|
+
|
|
4
|
+
const root = document.getElementById('local-server-settings');
|
|
5
|
+
const autostart = document.getElementById('local-server-autostart');
|
|
6
|
+
const status = document.getElementById('local-server-status');
|
|
7
|
+
const feedback = document.getElementById('local-server-feedback');
|
|
8
|
+
const token = document.getElementById('server-token');
|
|
9
|
+
const revealBtn = document.getElementById('server-token-reveal');
|
|
10
|
+
const copyBtn = document.getElementById('server-token-copy');
|
|
11
|
+
const info = document.getElementById('server-connection-info');
|
|
12
|
+
const closeBtn = document.getElementById('settings-close');
|
|
13
|
+
const backdrop = document.getElementById('settings-backdrop');
|
|
14
|
+
const openBtn = document.getElementById('settings-open');
|
|
15
|
+
|
|
16
|
+
root.hidden = false;
|
|
17
|
+
token.value = '';
|
|
18
|
+
token.type = 'password';
|
|
19
|
+
|
|
20
|
+
let confirmedState = null;
|
|
21
|
+
let isBusy = false;
|
|
22
|
+
let generation = 0;
|
|
23
|
+
|
|
24
|
+
const updateStatusUI = (enabled, unknown = false) => {
|
|
25
|
+
if (unknown) {
|
|
26
|
+
status.textContent = t('web.serverStartupUnknown');
|
|
27
|
+
} else {
|
|
28
|
+
status.textContent = enabled ? t('web.serverStartupEnabled') : t('web.serverStartupDisabled');
|
|
29
|
+
autostart.value = enabled ? 'enabled' : 'disabled';
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const refreshStatus = async () => {
|
|
34
|
+
if (isBusy) return;
|
|
35
|
+
isBusy = true;
|
|
36
|
+
try {
|
|
37
|
+
const enabled = await bridge.getAutostartStatus();
|
|
38
|
+
confirmedState = enabled;
|
|
39
|
+
updateStatusUI(enabled);
|
|
40
|
+
autostart.disabled = false;
|
|
41
|
+
} catch (e) {
|
|
42
|
+
updateStatusUI(null, true);
|
|
43
|
+
autostart.disabled = true;
|
|
44
|
+
feedback.textContent = e.message;
|
|
45
|
+
} finally {
|
|
46
|
+
isBusy = false;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
autostart.addEventListener('change', async () => {
|
|
51
|
+
if (isBusy) return;
|
|
52
|
+
const wanted = autostart.value === 'enabled';
|
|
53
|
+
isBusy = true;
|
|
54
|
+
autostart.disabled = true;
|
|
55
|
+
try {
|
|
56
|
+
const result = await bridge.setAutostartEnabled(wanted);
|
|
57
|
+
if (result !== wanted) throw new Error(t('web.serverStartupUnconfirmed'));
|
|
58
|
+
confirmedState = wanted;
|
|
59
|
+
updateStatusUI(wanted);
|
|
60
|
+
feedback.textContent = t('web.serverStartupSaved');
|
|
61
|
+
} catch (e) {
|
|
62
|
+
if (confirmedState !== null) {
|
|
63
|
+
updateStatusUI(confirmedState);
|
|
64
|
+
autostart.value = confirmedState ? 'enabled' : 'disabled';
|
|
65
|
+
}
|
|
66
|
+
feedback.textContent = e.message;
|
|
67
|
+
} finally {
|
|
68
|
+
isBusy = false;
|
|
69
|
+
if (confirmedState !== null) autostart.disabled = false;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
revealBtn.addEventListener('click', async () => {
|
|
74
|
+
feedback.textContent = '';
|
|
75
|
+
if (token.value !== '') {
|
|
76
|
+
token.value = '';
|
|
77
|
+
token.type = 'password';
|
|
78
|
+
info.textContent = '';
|
|
79
|
+
revealBtn.textContent = t('web.serverTokenReveal');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const currentGen = generation;
|
|
84
|
+
try {
|
|
85
|
+
const res = await bridge.revealConnection();
|
|
86
|
+
if (currentGen !== generation) return;
|
|
87
|
+
|
|
88
|
+
if (!res.configured) {
|
|
89
|
+
token.value = '';
|
|
90
|
+
feedback.textContent = t('web.serverNoConfig');
|
|
91
|
+
} else {
|
|
92
|
+
token.value = res.token;
|
|
93
|
+
token.type = 'text';
|
|
94
|
+
revealBtn.textContent = t('web.serverTokenHide');
|
|
95
|
+
info.textContent = t('web.serverConnectionInfo', { host: res.host, port: res.port });
|
|
96
|
+
}
|
|
97
|
+
} catch (e) {
|
|
98
|
+
if (currentGen === generation) feedback.textContent = e.message;
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
copyBtn.addEventListener('click', async () => {
|
|
103
|
+
feedback.textContent = '';
|
|
104
|
+
try {
|
|
105
|
+
const res = await bridge.copyToken();
|
|
106
|
+
if (res?.copied !== true) throw new Error(t('web.serverTokenCopyFailed'));
|
|
107
|
+
feedback.textContent = t('web.serverTokenCopied');
|
|
108
|
+
} catch (e) {
|
|
109
|
+
feedback.textContent = e.message;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const close = () => {
|
|
114
|
+
generation++;
|
|
115
|
+
token.value = '';
|
|
116
|
+
token.type = 'password';
|
|
117
|
+
info.textContent = '';
|
|
118
|
+
revealBtn.textContent = t('web.serverTokenReveal');
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
closeBtn.addEventListener('click', close);
|
|
122
|
+
backdrop.addEventListener('click', close);
|
|
123
|
+
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
|
|
124
|
+
openBtn.addEventListener('click', refreshStatus);
|
|
125
|
+
|
|
126
|
+
await refreshStatus().catch(() => {});
|
|
127
|
+
}
|
package/src/web/server.js
CHANGED
|
@@ -2,12 +2,72 @@ import http from 'node:http';
|
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
-
import { randomUUID } from 'node:crypto';
|
|
6
|
-
import { detectHardware, detectEngines, Tuner, ENGINES, installPlans, runInstall, slim, listPresets, readSettings, writeSettings, TunerError, PROFILES, checkUpdate, detectAgents, installMcpServer } from '../core/index.js';
|
|
5
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import { detectHardware, detectEngines, Tuner, ENGINES, installPlans, runInstall, slim, listPresets, readSettings, writeSettings, TunerError, PROFILES, checkUpdate, detectAgents, installMcpServer, resolveConnectableHost } 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';
|
|
10
10
|
|
|
11
|
+
export function sanitizeHardware(hw) {
|
|
12
|
+
if (!hw || typeof hw !== 'object') return hw;
|
|
13
|
+
return {
|
|
14
|
+
os: {
|
|
15
|
+
platform: hw.os?.platform,
|
|
16
|
+
arch: hw.os?.arch,
|
|
17
|
+
},
|
|
18
|
+
cpu: {
|
|
19
|
+
physicalCores: hw.cpu?.physicalCores,
|
|
20
|
+
threads: hw.cpu?.threads,
|
|
21
|
+
avx2: hw.cpu?.avx2,
|
|
22
|
+
},
|
|
23
|
+
gpus: (hw.gpus || []).map((gpu) => ({
|
|
24
|
+
vendor: gpu.vendor,
|
|
25
|
+
backend: gpu.backend,
|
|
26
|
+
index: gpu.index,
|
|
27
|
+
name: gpu.name,
|
|
28
|
+
totalBytes: gpu.totalBytes,
|
|
29
|
+
usedBytes: gpu.usedBytes,
|
|
30
|
+
freeBytes: gpu.freeBytes,
|
|
31
|
+
bandwidthGBps: gpu.bandwidthGBps,
|
|
32
|
+
displayAttached: gpu.displayAttached,
|
|
33
|
+
unifiedMemory: gpu.unifiedMemory,
|
|
34
|
+
})),
|
|
35
|
+
ram: {
|
|
36
|
+
totalBytes: hw.ram?.totalBytes,
|
|
37
|
+
availableBytes: hw.ram?.availableBytes,
|
|
38
|
+
},
|
|
39
|
+
ramBandwidthGBps: hw.ramBandwidthGBps,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isLoopback(ip) {
|
|
44
|
+
return ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(ip);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function verifyToken(req, expectedToken) {
|
|
48
|
+
const ip = req.socket?.remoteAddress;
|
|
49
|
+
const loopback = isLoopback(ip);
|
|
50
|
+
|
|
51
|
+
if (expectedToken === null || expectedToken === undefined) {
|
|
52
|
+
return loopback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const authHeader = req.headers?.['authorization'];
|
|
56
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const token = authHeader.slice(7);
|
|
61
|
+
const tokenBuf = Buffer.from(token);
|
|
62
|
+
const expectedBuf = Buffer.from(expectedToken);
|
|
63
|
+
|
|
64
|
+
if (tokenBuf.length !== expectedBuf.length) {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return timingSafeEqual(tokenBuf, expectedBuf);
|
|
69
|
+
}
|
|
70
|
+
|
|
11
71
|
const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), 'public');
|
|
12
72
|
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml' };
|
|
13
73
|
const I18N_CORE = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'i18n', 'core.js');
|
|
@@ -45,7 +105,12 @@ async function body(req) {
|
|
|
45
105
|
}
|
|
46
106
|
|
|
47
107
|
function send(res, status, obj) {
|
|
48
|
-
res.writeHead(status, {
|
|
108
|
+
res.writeHead(status, {
|
|
109
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
110
|
+
'Access-Control-Allow-Origin': '*',
|
|
111
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
112
|
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
|
|
113
|
+
});
|
|
49
114
|
res.end(JSON.stringify(obj));
|
|
50
115
|
}
|
|
51
116
|
|
|
@@ -56,7 +121,9 @@ const routes = {
|
|
|
56
121
|
|
|
57
122
|
'POST /api/settings': async (req) => {
|
|
58
123
|
const payload = await body(req);
|
|
59
|
-
if (payload?.lang === undefined && payload?.theme === undefined && payload?.profile === undefined
|
|
124
|
+
if (payload?.lang === undefined && payload?.theme === undefined && payload?.profile === undefined && payload?.servers === undefined && payload?.activeServerId === undefined) {
|
|
125
|
+
throw new TunerError('errors.unknownLocale', { lang: '', list: LOCALES.join(', ') });
|
|
126
|
+
}
|
|
60
127
|
const patch = {};
|
|
61
128
|
if (payload.lang !== undefined) {
|
|
62
129
|
if (!LOCALES.includes(payload.lang)) throw new TunerError('errors.unknownLocale', { lang: String(payload.lang ?? ''), list: LOCALES.join(', ') });
|
|
@@ -67,13 +134,16 @@ const routes = {
|
|
|
67
134
|
patch.theme = payload.theme;
|
|
68
135
|
}
|
|
69
136
|
if (payload.profile !== undefined) patch.profile = knownProfile(payload.profile);
|
|
137
|
+
if (payload.servers !== undefined) patch.servers = payload.servers;
|
|
138
|
+
if (payload.activeServerId !== undefined) patch.activeServerId = payload.activeServerId;
|
|
70
139
|
return { ...(await writeSettings(patch)), locales: LOCALES, themes: THEMES, profiles: PROFILES };
|
|
71
140
|
},
|
|
72
141
|
|
|
73
|
-
'GET /api/state': async () => {
|
|
142
|
+
'GET /api/state': async (req, url, options) => {
|
|
74
143
|
const hw = await detectHardware();
|
|
75
144
|
const engines = await detectEngines(hw);
|
|
76
|
-
|
|
145
|
+
const safeHw = (options?.isRemote || options?.serverMode) ? sanitizeHardware(hw) : hw;
|
|
146
|
+
return { hw: safeHw, ...engines, busy };
|
|
77
147
|
},
|
|
78
148
|
|
|
79
149
|
'GET /api/install-plans': async (req, url) => ({ plans: await installPlans(url.searchParams.get('engine')) }),
|
|
@@ -164,17 +234,131 @@ const routes = {
|
|
|
164
234
|
};
|
|
165
235
|
|
|
166
236
|
|
|
167
|
-
async function
|
|
237
|
+
export async function getActiveRemoteServer({ options } = {}) {
|
|
238
|
+
if (options?.serverMode === true) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const settings = await readSettings().catch(() => null);
|
|
243
|
+
if (!settings || !settings.activeServerId || settings.activeServerId === 'local') {
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const server = settings.servers?.find((s) => s.id === settings.activeServerId);
|
|
248
|
+
return (server && server.url) ? server : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function proxyToRemote(remoteServer, req, url, res) {
|
|
252
|
+
const targetUrl = `${remoteServer.url.replace(/\/+$/, '')}${url.pathname}${url.search}`;
|
|
253
|
+
const headers = {
|
|
254
|
+
'Content-Type': req.headers['content-type'] || 'application/json',
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
if (remoteServer.token) {
|
|
258
|
+
headers['Authorization'] = `Bearer ${remoteServer.token}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let body = null;
|
|
262
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
263
|
+
const chunks = [];
|
|
264
|
+
for await (const chunk of req) {
|
|
265
|
+
chunks.push(chunk);
|
|
266
|
+
}
|
|
267
|
+
body = Buffer.concat(chunks);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
const remoteRes = await fetch(targetUrl, {
|
|
272
|
+
method: req.method,
|
|
273
|
+
headers,
|
|
274
|
+
body: body && body.length > 0 ? body : undefined,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const data = await remoteRes.json().catch(() => ({}));
|
|
278
|
+
return send(res, remoteRes.status, data);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
return send(res, 502, { error: err.message, code: 'web.serverConnectionFailed' });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function handler(req, res, options = {}) {
|
|
168
285
|
const url = new URL(req.url, 'http://localhost');
|
|
169
286
|
try {
|
|
287
|
+
if (req.method === 'OPTIONS') {
|
|
288
|
+
res.writeHead(204, {
|
|
289
|
+
'Access-Control-Allow-Origin': '*',
|
|
290
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
291
|
+
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
|
|
292
|
+
'Access-Control-Max-Age': '86400',
|
|
293
|
+
});
|
|
294
|
+
return res.end();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const ip = req.socket?.remoteAddress;
|
|
298
|
+
const loopback = isLoopback(ip);
|
|
299
|
+
const isRemote = !loopback || !!options.serverMode;
|
|
300
|
+
|
|
301
|
+
if (options.token || isRemote) {
|
|
302
|
+
if (!verifyToken(req, options.token)) {
|
|
303
|
+
return send(res, 401, { error: 'Unauthorized', code: 'errors.unauthorized' });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (isRemote && req.method === 'POST' && url.pathname === '/api/install') {
|
|
308
|
+
return send(res, 403, { error: 'Forbidden', code: 'errors.forbiddenRemote' });
|
|
309
|
+
}
|
|
310
|
+
|
|
170
311
|
// A foreign website must not trigger the desktop's privileged authorizer.
|
|
171
312
|
if (options.authorizeOllama && req.method === 'POST' && req.headers.origin && req.headers.origin !== `http://127.0.0.1:${req.socket.localPort}`) return send(res, 403, { error: 'Forbidden origin' });
|
|
313
|
+
|
|
314
|
+
const activeRemote = await getActiveRemoteServer({ options });
|
|
315
|
+
if (activeRemote) {
|
|
316
|
+
const ev = url.pathname.match(/^\/api\/jobs\/([\w-]+)\/events$/);
|
|
317
|
+
if (ev) {
|
|
318
|
+
try {
|
|
319
|
+
const targetUrl = `${activeRemote.url.replace(/\/+$/, '')}${url.pathname}${url.search}`;
|
|
320
|
+
const headers = {};
|
|
321
|
+
if (activeRemote.token) {
|
|
322
|
+
headers['Authorization'] = `Bearer ${activeRemote.token}`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const remoteRes = await fetch(targetUrl, { method: 'GET', headers });
|
|
326
|
+
res.writeHead(remoteRes.status, {
|
|
327
|
+
'Content-Type': 'text/event-stream',
|
|
328
|
+
'Cache-Control': 'no-cache',
|
|
329
|
+
Connection: 'keep-alive',
|
|
330
|
+
'Access-Control-Allow-Origin': '*',
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const reader = remoteRes.body.getReader();
|
|
334
|
+
req.on('close', () => reader.cancel().catch(() => {}));
|
|
335
|
+
try {
|
|
336
|
+
while (true) {
|
|
337
|
+
const { done, value } = await reader.read();
|
|
338
|
+
if (done) break;
|
|
339
|
+
res.write(value);
|
|
340
|
+
}
|
|
341
|
+
} finally {
|
|
342
|
+
res.end();
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
} catch (err) {
|
|
346
|
+
return send(res, 502, { error: err.message, code: 'web.serverConnectionFailed' });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const PROXIED_PATHS = ['/api/state', '/api/models', '/api/plan', '/api/presets', '/api/load', '/api/chat'];
|
|
351
|
+
if (PROXIED_PATHS.includes(url.pathname)) {
|
|
352
|
+
return await proxyToRemote(activeRemote, req, url, res);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
172
356
|
// Server-Sent Events for job progress.
|
|
173
357
|
const ev = url.pathname.match(/^\/api\/jobs\/([\w-]+)\/events$/);
|
|
174
358
|
if (ev) {
|
|
175
359
|
const job = jobs.get(ev[1]);
|
|
176
360
|
if (!job) return send(res, 404, { error: 'job not found' });
|
|
177
|
-
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
361
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*' });
|
|
178
362
|
const write = (e) => res.write(`data: ${JSON.stringify(e)}\n\n`);
|
|
179
363
|
job.events.forEach(write);
|
|
180
364
|
if (job.done) return res.end();
|
|
@@ -184,7 +368,7 @@ async function handler(req, res, options) {
|
|
|
184
368
|
return;
|
|
185
369
|
}
|
|
186
370
|
const route = routes[`${req.method} ${url.pathname}`];
|
|
187
|
-
if (route) return send(res, 200, await route(req, url, options));
|
|
371
|
+
if (route) return send(res, 200, await route(req, url, { ...options, isRemote }));
|
|
188
372
|
|
|
189
373
|
const i18n = url.pathname.match(/^\/api\/i18n\/([\w-]+)$/);
|
|
190
374
|
if (i18n && req.method === 'GET') {
|
|
@@ -210,16 +394,19 @@ async function handler(req, res, options) {
|
|
|
210
394
|
}
|
|
211
395
|
}
|
|
212
396
|
|
|
213
|
-
/** Start the local
|
|
214
|
-
export function startServer({ port = Number(process.env.PORT) || 7860, open = false, authorizeOllama } = {}) {
|
|
397
|
+
/** Start the local or remote server. */
|
|
398
|
+
export function startServer({ port = Number(process.env.PORT) || 7860, host = '127.0.0.1', token = null, serverMode = false, open = false, authorizeOllama } = {}) {
|
|
215
399
|
return new Promise((resolve, reject) => {
|
|
216
|
-
const server = http.createServer((req, res) => handler(req, res, { authorizeOllama }));
|
|
400
|
+
const server = http.createServer((req, res) => handler(req, res, { authorizeOllama, token, serverMode, host }));
|
|
217
401
|
server.on('error', reject);
|
|
218
|
-
server.listen(port,
|
|
219
|
-
const
|
|
402
|
+
server.listen(port, host, () => {
|
|
403
|
+
const addr = server.address();
|
|
404
|
+
const resolvedPort = typeof addr === 'string' ? addr : addr.port;
|
|
405
|
+
const connectableHost = resolveConnectableHost(host);
|
|
406
|
+
const url = `http://${connectableHost}:${resolvedPort}`;
|
|
220
407
|
console.log(`LLM Tuner available at ${url}`);
|
|
221
408
|
if (open) openUrl(url);
|
|
222
|
-
resolve({ server, url });
|
|
409
|
+
resolve({ server, url, port: resolvedPort });
|
|
223
410
|
});
|
|
224
411
|
});
|
|
225
412
|
}
|