llm-tuner 0.4.3 → 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.
@@ -0,0 +1,79 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { serviceInvocation } from '../core/engines/ollama-service.js';
3
+ import { TunerError } from '../core/errors.js';
4
+
5
+ export const authorizationIO = { spawn };
6
+
7
+ function parseResult(stdout) {
8
+ const fields = Object.create(null);
9
+ for (const line of stdout.trimEnd().split('\n')) {
10
+ const index = line.indexOf('=');
11
+ const key = line.slice(0, index);
12
+ if (index < 0 || !['changed', 'restarted', 'backup'].includes(key) || Object.hasOwn(fields, key)) throw new TunerError('errors.ollamaAuthorizationFailed');
13
+ fields[key] = line.slice(index + 1);
14
+ }
15
+ if (!['0', '1'].includes(fields.changed) || !['0', '1'].includes(fields.restarted) || typeof fields.backup !== 'string' || (fields.backup && !/^\/etc\/systemd\/system\/ollama\.service\.d\/llm-tuner\.conf\.bak-llm-tuner-[A-Za-z0-9]+$/.test(fields.backup))) throw new TunerError('errors.ollamaAuthorizationFailed');
16
+ return { changed: fields.changed === '1', restarted: fields.restarted === '1', backups: fields.backup ? [fields.backup] : [] };
17
+ }
18
+
19
+ /** The interface chooses interaction, never the operation or its destinations. */
20
+ export function createOllamaAuthorizer({ mode = 'noninteractive', beforePrompt = () => {}, afterPrompt = () => {} } = {}) {
21
+ if (!['desktop', 'terminal', 'noninteractive'].includes(mode)) throw new TunerError('errors.ollamaServiceConfig');
22
+ return async (request) => {
23
+ if (!request || request.operation !== 'ollama-service-env' || Object.keys(request).some((k) => !['operation', 'env'].includes(k))) throw new TunerError('errors.ollamaServiceConfig');
24
+ const { command, args } = serviceInvocation(request.env);
25
+ const bin = mode === 'desktop' ? '/usr/bin/pkexec' : '/usr/bin/sudo';
26
+ const prefix = mode === 'desktop' ? ['--disable-internal-agent'] : mode === 'noninteractive' ? ['-n', '--'] : ['--'];
27
+ const terminal = mode === 'terminal';
28
+ try {
29
+ if (terminal) await beforePrompt();
30
+ return await new Promise((resolve, reject) => {
31
+ let child, timer, output = '', settled = false, pendingFailure;
32
+ const finish = (error, value) => {
33
+ if (settled) return;
34
+ settled = true;
35
+ clearTimeout(timer);
36
+ if (error) reject(error); else resolve(value);
37
+ };
38
+ const failed = () => new TunerError('errors.ollamaAuthorizationFailed');
39
+ const stop = () => {
40
+ pendingFailure = failed();
41
+ // A signal request is not proof of termination. Retain ownership
42
+ // until close, even if the OS refuses to signal the elevated child.
43
+ try { child.kill('SIGTERM'); } catch { /* wait for the elevated watchdog */ }
44
+ };
45
+ try {
46
+ child = authorizationIO.spawn(bin, [...prefix, command, ...args], {
47
+ env: { PATH: '/usr/sbin:/usr/bin:/sbin:/bin', LANG: 'C' },
48
+ stdio: terminal ? ['inherit', 'pipe', 'inherit'] : ['ignore', 'pipe', 'pipe'],
49
+ });
50
+ } catch (e) {
51
+ finish(new TunerError(e.code === 'ENOENT' ? 'errors.ollamaAuthorizationUnavailable' : 'errors.ollamaAuthorizationFailed'));
52
+ return;
53
+ }
54
+ timer = setTimeout(stop, 60000);
55
+ child.stdout?.on('data', (chunk) => {
56
+ if (pendingFailure) return;
57
+ output += String(chunk);
58
+ if (Buffer.byteLength(output) > 16384) { output = ''; stop(); }
59
+ });
60
+ // Drain diagnostics without collecting passwords or returning raw privileged output.
61
+ child.stderr?.resume();
62
+ child.on('error', (e) => {
63
+ const error = new TunerError(e.code === 'ENOENT' ? 'errors.ollamaAuthorizationUnavailable' : 'errors.ollamaAuthorizationFailed');
64
+ if (!child.pid) finish(error); else pendingFailure = error;
65
+ });
66
+ child.on('close', (code, signal) => {
67
+ if (pendingFailure) { finish(pendingFailure); return; }
68
+ if (code !== 0 || signal) {
69
+ finish(new TunerError(mode === 'desktop' && code === 126 ? 'errors.ollamaAuthorizationCancelled' : mode === 'noninteractive' && code === 1 ? 'errors.ollamaAuthorizationRequired' : 'errors.ollamaAuthorizationFailed'));
70
+ return;
71
+ }
72
+ try { finish(null, parseResult(output)); } catch (e) { finish(e); }
73
+ });
74
+ });
75
+ } finally {
76
+ if (terminal) await afterPrompt();
77
+ }
78
+ };
79
+ }
@@ -0,0 +1,187 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { run as runCommand } from '../core/util.js';
7
+
8
+ const ENTRY_NAME = 'dev.llm-tuner.server';
9
+
10
+ function missing(error) {
11
+ return error?.code === 'ENOENT';
12
+ }
13
+
14
+ function validConfig(config) {
15
+ return config && typeof config === 'object' && !Array.isArray(config)
16
+ && typeof config.host === 'string' && config.host.length > 0
17
+ && Number.isInteger(config.port) && config.port > 0 && config.port <= 65535
18
+ && typeof config.token === 'string' && config.token.length > 0;
19
+ }
20
+
21
+ function desktopArgument(value) {
22
+ const execEscaped = String(value).replace(/([\\"$`])/g, '\\$1').replace(/%/g, '%%');
23
+ return `"${execEscaped.replace(/\\/g, '\\\\')}"`;
24
+ }
25
+
26
+ function assertNoControlCharacters(values) {
27
+ if (values.some((value) => /[\u0000-\u001f\u007f-\u009f]/u.test(String(value)))) {
28
+ throw new Error('Startup command paths cannot contain control characters.');
29
+ }
30
+ }
31
+
32
+ function xml(value) {
33
+ return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
34
+ }
35
+
36
+ function powershellLiteral(value) {
37
+ return `'${String(value).replace(/'/g, "''")}'`;
38
+ }
39
+
40
+ function windowsArgument(value) {
41
+ const text = String(value);
42
+ if (!/[\s"]/.test(text)) return text;
43
+ let quoted = '"';
44
+ let slashes = 0;
45
+ for (const character of text) {
46
+ if (character === '\\') {
47
+ slashes += 1;
48
+ } else if (character === '"') {
49
+ quoted += `${'\\'.repeat(slashes * 2 + 1)}"`;
50
+ slashes = 0;
51
+ } else {
52
+ quoted += `${'\\'.repeat(slashes)}${character}`;
53
+ slashes = 0;
54
+ }
55
+ }
56
+ return `${quoted}${'\\'.repeat(slashes * 2)}"`;
57
+ }
58
+
59
+ function plist(argumentsList) {
60
+ const values = argumentsList.map((value) => ` <string>${xml(value)}</string>`).join('\n');
61
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>Label</key>\n <string>${ENTRY_NAME}</string>\n <key>ProgramArguments</key>\n <array>\n${values}\n </array>\n <key>RunAtLoad</key>\n <true/>\n</dict>\n</plist>\n`;
62
+ }
63
+
64
+ export function createServerAutostart({
65
+ platform = process.platform,
66
+ home = os.homedir(),
67
+ env = process.env,
68
+ execPath = process.execPath,
69
+ entryPath = fileURLToPath(new URL('../cli/server-login.js', import.meta.url)),
70
+ configPath,
71
+ fileSystem = fs,
72
+ run = runCommand,
73
+ } = {}) {
74
+ let artifactPath;
75
+ if (platform === 'linux') {
76
+ artifactPath = path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'autostart', 'llm-tuner-server.desktop');
77
+ } else if (platform === 'darwin') {
78
+ artifactPath = path.join(home, 'Library', 'LaunchAgents', `${ENTRY_NAME}.plist`);
79
+ } else if (platform === 'win32') {
80
+ artifactPath = path.join(env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', 'LLM Tuner Server.lnk');
81
+ } else {
82
+ throw new Error(`Automatic server startup is not supported on ${platform}.`);
83
+ }
84
+ const defaultConfigRoot = platform === 'win32'
85
+ ? (env.APPDATA || path.join(home, 'AppData', 'Roaming'))
86
+ : platform === 'darwin'
87
+ ? path.join(home, 'Library', 'Application Support')
88
+ : (env.XDG_CONFIG_HOME || path.join(home, '.config'));
89
+ const resolvedConfigPath = configPath || path.join(env.LLM_TUNER_CONFIG_DIR || path.join(defaultConfigRoot, 'llm-tuner'), 'server.json');
90
+
91
+ async function ensureConfig() {
92
+ let contents;
93
+ try {
94
+ contents = await fileSystem.readFile(resolvedConfigPath, 'utf8');
95
+ } catch (error) {
96
+ if (!missing(error)) throw error;
97
+ const initial = { host: '0.0.0.0', port: 7860, token: randomBytes(32).toString('hex') };
98
+ await fileSystem.mkdir(path.dirname(resolvedConfigPath), { recursive: true });
99
+ try {
100
+ await fileSystem.writeFile(resolvedConfigPath, JSON.stringify(initial, null, 2), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
101
+ return initial;
102
+ } catch (writeError) {
103
+ if (writeError?.code !== 'EEXIST') throw writeError;
104
+ contents = await fileSystem.readFile(resolvedConfigPath, 'utf8');
105
+ }
106
+ }
107
+ let config;
108
+ try {
109
+ config = JSON.parse(contents);
110
+ } catch {
111
+ throw new Error(`Server configuration at ${resolvedConfigPath} is malformed; automatic startup was not registered.`);
112
+ }
113
+ if (!validConfig(config)) {
114
+ throw new Error(`Server configuration at ${resolvedConfigPath} is invalid; automatic startup was not registered.`);
115
+ }
116
+ return config;
117
+ }
118
+
119
+ async function register() {
120
+ const configDirectory = path.dirname(resolvedConfigPath);
121
+ assertNoControlCharacters([execPath, entryPath, configDirectory, artifactPath]);
122
+ await ensureConfig();
123
+ if (platform === 'linux') {
124
+ const entry = `[Desktop Entry]\nType=Application\nName=LLM Tuner Server\nComment=Start the LLM Tuner server after login\nExec=${desktopArgument(execPath)} ${desktopArgument(entryPath)} --config-dir ${desktopArgument(configDirectory)}\nTerminal=false\n`;
125
+ await fileSystem.mkdir(path.dirname(artifactPath), { recursive: true });
126
+ await fileSystem.writeFile(artifactPath, entry, { encoding: 'utf8', mode: 0o600 });
127
+ if (!(await status())) throw new Error('The Linux startup entry was not created.');
128
+ return;
129
+ }
130
+ if (platform === 'darwin') {
131
+ await fileSystem.mkdir(path.dirname(artifactPath), { recursive: true });
132
+ await fileSystem.writeFile(artifactPath, plist([execPath, entryPath, '--config-dir', configDirectory]), { encoding: 'utf8', mode: 0o600 });
133
+ if (!(await status())) throw new Error('The macOS startup entry was not created.');
134
+ return;
135
+ }
136
+
137
+ await fileSystem.mkdir(path.dirname(artifactPath), { recursive: true });
138
+ const args = `--config-dir ${windowsArgument(configDirectory)}`;
139
+ const script = [
140
+ `$ErrorActionPreference = 'Stop'`,
141
+ `$expectedTarget = ${powershellLiteral(execPath)}`,
142
+ `$expectedArguments = ${powershellLiteral(`${windowsArgument(entryPath)} ${args}`)}`,
143
+ `$expectedWorkingDirectory = ${powershellLiteral(path.dirname(entryPath))}`,
144
+ `try {`,
145
+ `$shell = New-Object -ComObject WScript.Shell`,
146
+ `$shortcut = $shell.CreateShortcut(${powershellLiteral(artifactPath)})`,
147
+ `$shortcut.TargetPath = $expectedTarget`,
148
+ `$shortcut.Arguments = $expectedArguments`,
149
+ `$shortcut.WorkingDirectory = $expectedWorkingDirectory`,
150
+ `$shortcut.Save()`,
151
+ `$saved = $shell.CreateShortcut(${powershellLiteral(artifactPath)})`,
152
+ `if ($saved.TargetPath -ne $expectedTarget -or $saved.Arguments -ne $expectedArguments -or $saved.WorkingDirectory -ne $expectedWorkingDirectory) { throw 'The saved startup shortcut did not match the requested settings.' }`,
153
+ `} catch { [Console]::Error.WriteLine($_.Exception.Message); exit 1 }`,
154
+ ].join('\n');
155
+ const result = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { windowsHide: true });
156
+ if (result.code !== 0) throw new Error(`Could not create the Windows startup shortcut: ${result.stderr || `PowerShell exited with code ${result.code}`}`);
157
+ try {
158
+ await fileSystem.access(artifactPath);
159
+ } catch (error) {
160
+ if (missing(error)) throw new Error('PowerShell reported success but did not create the Windows startup shortcut.');
161
+ throw error;
162
+ }
163
+ }
164
+
165
+ async function status() {
166
+ try {
167
+ await fileSystem.access(artifactPath);
168
+ return true;
169
+ } catch (error) {
170
+ if (missing(error)) return false;
171
+ throw error;
172
+ }
173
+ }
174
+
175
+ return {
176
+ status,
177
+ async setEnabled(enabled) {
178
+ if (typeof enabled !== 'boolean') throw new TypeError('enabled must be a boolean.');
179
+ if (enabled) {
180
+ await register();
181
+ } else {
182
+ await fileSystem.rm(artifactPath, { force: true });
183
+ }
184
+ return status();
185
+ },
186
+ };
187
+ }
@@ -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
+ }