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.
@@ -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
- if (hostInput) hostInput.placeholder = 'http://127.0.0.1:1234/v1';
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
- if (hostInput) hostInput.placeholder = 'http://127.0.0.1:11434';
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
- $('#hw-mini').innerHTML = `<div>${esc(s.hw.cpu.brand)}</div><div>${gb(s.hw.ram.totalBytes)} RAM</div>` +
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, { 'Content-Type': 'application/json; charset=utf-8' });
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) throw new TunerError('errors.unknownLocale', { lang: '', list: LOCALES.join(', ') });
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
- return { hw, ...engines, busy };
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 handler(req, res, options) {
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 UI server (bound to localhost only). */
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, '127.0.0.1', () => {
219
- const url = `http://127.0.0.1:${server.address().port}`;
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
  }