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.
- package/README.es.md +202 -125
- package/README.md +197 -120
- package/electron/launch.js +1 -0
- package/electron/main.js +50 -3
- 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 +82 -32
- 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/engines/ollama-service.js +158 -0
- package/src/core/engines/ollama.js +16 -23
- package/src/core/index.js +3 -2
- package/src/core/settings.js +50 -2
- package/src/core/tuner.js +2 -2
- package/src/core/util.js +26 -0
- package/src/i18n/en.js +59 -1
- package/src/i18n/es.js +59 -1
- package/src/platform/ollama-authorize.js +79 -0
- 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 +207 -17
package/src/cli/commands/help.js
CHANGED
|
@@ -4,4 +4,5 @@ import { t as translate } from '../../i18n/index.js';
|
|
|
4
4
|
export async function helpCommand(args, { lang }) {
|
|
5
5
|
const t = (key, params) => translate(lang, key, params);
|
|
6
6
|
console.log(t('cli.help', { presetsDir: presetsDir() }));
|
|
7
|
+
console.log(t('cli.serverConnectionHelp'));
|
|
7
8
|
}
|
package/src/cli/commands/load.js
CHANGED
|
@@ -18,6 +18,8 @@ import { profileChoices, profileNotes, profileSwitchNote, profileEventLines, sho
|
|
|
18
18
|
import { getColors } from '../colors.js';
|
|
19
19
|
import { withNav, NAV_BACK, backChoice } from '../nav.js';
|
|
20
20
|
import { TerminalAnimation } from '../animation.js';
|
|
21
|
+
import { createOllamaAuthorizer } from '../../platform/ollama-authorize.js';
|
|
22
|
+
import { TunerApiClient } from '../../core/client.js';
|
|
21
23
|
|
|
22
24
|
export async function loadCommand(args, { lang, theme }) {
|
|
23
25
|
const c = getColors(theme);
|
|
@@ -63,12 +65,26 @@ export async function loadCommand(args, { lang, theme }) {
|
|
|
63
65
|
out(c.dim(` ${t('cli.updateCommand', { cmd: 'npm install -g llm-tuner' })}\n`));
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
const settings = await readSettings();
|
|
69
|
+
const isRemote = settings.activeServerId !== 'local';
|
|
70
|
+
let client = null;
|
|
71
|
+
let hw;
|
|
72
|
+
let det;
|
|
73
|
+
|
|
74
|
+
if (isRemote) {
|
|
75
|
+
client = TunerApiClient.fromSettings(settings);
|
|
76
|
+
const state = await client.getState();
|
|
77
|
+
hw = state.hw;
|
|
78
|
+
det = state;
|
|
79
|
+
} else {
|
|
80
|
+
hw = await detectHardware();
|
|
81
|
+
det = await detectEngines(hw);
|
|
82
|
+
}
|
|
70
83
|
|
|
71
|
-
|
|
84
|
+
const cpuTitle = hw.cpu?.brand || (hw.cpu?.physicalCores ? `${hw.cpu.physicalCores} cores` : 'CPU');
|
|
85
|
+
out(c.dim(` ${cpuTitle} · ${fmtBytes(hw.ram?.totalBytes)} RAM`));
|
|
86
|
+
for (const g of (hw.gpus || [])) out(c.dim(` GPU${g.index} ${g.name} · ${fmtBytes(g.totalBytes)}${g.pcieGen ? ` · PCIe ${g.pcieGen}.0 x${g.pcieWidth}` : ''}${g.displayAttached ? t('cli.display') : ''}`));
|
|
87
|
+
if (!hw.gpus?.length) out(c.y(` ${t('cli.noGpu')}`));
|
|
72
88
|
let engineId = args.engine;
|
|
73
89
|
let tuner = null;
|
|
74
90
|
let models = [];
|
|
@@ -104,14 +120,20 @@ export async function loadCommand(args, { lang, theme }) {
|
|
|
104
120
|
currentStep = 2;
|
|
105
121
|
}
|
|
106
122
|
else if (currentStep === 2) {
|
|
107
|
-
if (!tuner || tuner.engine
|
|
108
|
-
if (
|
|
109
|
-
await
|
|
110
|
-
|
|
111
|
-
|
|
123
|
+
if (!tuner || tuner.engine?.id !== engineId) {
|
|
124
|
+
if (isRemote) {
|
|
125
|
+
const res = await client.listModels(engineId);
|
|
126
|
+
models = res.models || [];
|
|
127
|
+
tuner = { engine: { id: engineId, name: engineId } };
|
|
128
|
+
} else {
|
|
129
|
+
if (!det.engines.find((e) => e.id === engineId)?.detection.installed) {
|
|
130
|
+
await installFlow(engineId, { args, t, c });
|
|
131
|
+
det = await detectEngines(hw);
|
|
132
|
+
if (!det.engines.find((e) => e.id === engineId).detection.installed) throw new TunerError('errors.installNotFinished');
|
|
133
|
+
}
|
|
134
|
+
tuner = await Tuner.create(engineId);
|
|
135
|
+
models = await tuner.listModels();
|
|
112
136
|
}
|
|
113
|
-
tuner = await Tuner.create(engineId);
|
|
114
|
-
models = await tuner.listModels();
|
|
115
137
|
}
|
|
116
138
|
|
|
117
139
|
if (args.model) {
|
|
@@ -289,26 +311,54 @@ export async function loadCommand(args, { lang, theme }) {
|
|
|
289
311
|
|
|
290
312
|
let result;
|
|
291
313
|
try {
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
314
|
+
if (isRemote) {
|
|
315
|
+
result = await client.loadModel({
|
|
316
|
+
engine: engineId,
|
|
317
|
+
model: modelKey,
|
|
318
|
+
ctx,
|
|
319
|
+
profile,
|
|
320
|
+
force: !!args.force,
|
|
321
|
+
candidates: Number(args.candidates) || 3,
|
|
322
|
+
}, (e) => {
|
|
323
|
+
if (e.type === 'progress') anim.update(e);
|
|
324
|
+
if (e.type === 'log') out(c.dim(' ' + e.line));
|
|
325
|
+
});
|
|
326
|
+
} else {
|
|
327
|
+
result = await tuner.load(modelKey, ctx, {
|
|
328
|
+
authorizeOllama: createOllamaAuthorizer({
|
|
329
|
+
mode: process.stdin.isTTY && process.stderr.isTTY && !args.json ? 'terminal' : 'noninteractive',
|
|
330
|
+
beforePrompt: () => anim.stop(),
|
|
331
|
+
afterPrompt: () => anim.start(t('status.applying')),
|
|
332
|
+
}),
|
|
333
|
+
profile,
|
|
334
|
+
force: !!args.force,
|
|
335
|
+
dryRun: !!args['dry-run'],
|
|
336
|
+
maxCandidates: Number(args.candidates) || 3,
|
|
337
|
+
confirmApply: async (preview) => {
|
|
338
|
+
anim.stop();
|
|
339
|
+
out(c.b(`\n${t('cli.changes')}`));
|
|
340
|
+
if (engineId === 'lmstudio') {
|
|
341
|
+
preview.files.forEach((f) => out(` ${f}`));
|
|
342
|
+
if (preview.restartsApp) out(c.y(` ${t('cli.lmsRestart')}`));
|
|
343
|
+
} else {
|
|
344
|
+
out(` ${t('cli.ollamaNewModel', { name: c.b(preview.tunedModel) })}`);
|
|
345
|
+
out(` ${t('cli.ollamaServer', { env: Object.entries(preview.env).map(([k, v]) => `${k}=${v}`).join(' ') })}`);
|
|
346
|
+
}
|
|
347
|
+
const confirmed = args.yes || await confirm({ message: t('cli.confirmApply'), default: true });
|
|
348
|
+
if (confirmed) anim.start(t('status.applying'));
|
|
349
|
+
return confirmed;
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
} catch (e) {
|
|
354
|
+
if (!/^errors\.ollama(?:Service|Authorization)/.test(e.code || '')) throw e;
|
|
355
|
+
if (args.json) {
|
|
356
|
+
console.log(JSON.stringify({ ok: false, code: e.code, params: e.params || {}, error: t(e.code, e.params) }));
|
|
357
|
+
process.exitCode = 1;
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
e.message = t(e.code, e.params);
|
|
361
|
+
throw e;
|
|
312
362
|
} finally {
|
|
313
363
|
anim.stop();
|
|
314
364
|
}
|
package/src/cli/commands/mcp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { select, checkbox, input, Separator } from '@inquirer/prompts';
|
|
2
|
-
import { detectHardware, detectEngines, detectAgents, installMcpServer } from '../../core/index.js';
|
|
2
|
+
import { detectHardware, detectEngines, detectAgents, installMcpServer, readSettings } from '../../core/index.js';
|
|
3
3
|
import { t as translate } from '../../i18n/index.js';
|
|
4
4
|
import { getColors } from '../colors.js';
|
|
5
5
|
import { withNav, NAV_BACK, backChoice } from '../nav.js';
|
|
@@ -93,11 +93,25 @@ export async function mcpCommand(args, { lang, theme }) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
// Install MCP server configuration
|
|
96
|
+
let targetHost = args.host;
|
|
97
|
+
if (!targetHost) {
|
|
98
|
+
const settings = await readSettings().catch(() => null);
|
|
99
|
+
const activeSrv = (settings?.servers || []).find((s) => s.id === settings?.activeServerId);
|
|
100
|
+
if (activeSrv && activeSrv.id !== 'local' && activeSrv.url) {
|
|
101
|
+
try {
|
|
102
|
+
const hostname = new URL(activeSrv.url).hostname;
|
|
103
|
+
if (hostname && hostname !== '127.0.0.1' && hostname !== 'localhost') {
|
|
104
|
+
targetHost = engineId === 'lmstudio' ? `http://${hostname}:1234/v1` : `http://${hostname}:11434`;
|
|
105
|
+
}
|
|
106
|
+
} catch {}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
96
110
|
const result = await installMcpServer({
|
|
97
111
|
engineId,
|
|
98
112
|
customName,
|
|
99
113
|
agentIds: selectedAgents,
|
|
100
|
-
host:
|
|
114
|
+
host: targetHost,
|
|
101
115
|
apiKey: args.apiKey,
|
|
102
116
|
});
|
|
103
117
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { listPresets } from '../../core/index.js';
|
|
1
|
+
import { listPresets, readSettings } from '../../core/index.js';
|
|
2
|
+
import { TunerApiClient } from '../../core/client.js';
|
|
2
3
|
import { t as translate } from '../../i18n/index.js';
|
|
3
4
|
import { getColors } from '../colors.js';
|
|
4
5
|
|
|
@@ -6,7 +7,16 @@ export async function presetsCommand(args, { lang, theme }) {
|
|
|
6
7
|
const c = getColors(theme);
|
|
7
8
|
const t = (key, params) => translate(lang, key, params);
|
|
8
9
|
|
|
9
|
-
const
|
|
10
|
+
const settings = await readSettings();
|
|
11
|
+
let list = [];
|
|
12
|
+
if (settings.activeServerId !== 'local') {
|
|
13
|
+
const client = TunerApiClient.fromSettings(settings);
|
|
14
|
+
const data = await client.listPresets({ engine: args.engine, model: args.model });
|
|
15
|
+
list = data.presets || [];
|
|
16
|
+
} else {
|
|
17
|
+
list = await listPresets({ engine: args.engine, modelKey: args.model });
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
if (!list.length) {
|
|
11
21
|
console.log(t('cli.noPresets'));
|
|
12
22
|
return;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { configDir, readJson, resolveConnectableHost } from '../../core/util.js';
|
|
5
|
+
import { startServer } from '../../web/server.js';
|
|
6
|
+
|
|
7
|
+
export function serverConfigPath() {
|
|
8
|
+
return path.join(configDir(), 'server.json');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function readServerConfig() {
|
|
12
|
+
try {
|
|
13
|
+
return await readJson(serverConfigPath(), null);
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function writeServerConfig(cfg) {
|
|
20
|
+
await fs.mkdir(configDir(), { recursive: true });
|
|
21
|
+
await fs.writeFile(
|
|
22
|
+
serverConfigPath(),
|
|
23
|
+
JSON.stringify(cfg, null, 2),
|
|
24
|
+
'utf8'
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function serveCommand(args = {}) {
|
|
29
|
+
const host = args.host || '0.0.0.0';
|
|
30
|
+
const port = args.port !== undefined ? Number(args.port) : 7860;
|
|
31
|
+
|
|
32
|
+
const existingConfig = await readServerConfig();
|
|
33
|
+
const token = args.token || existingConfig?.token || randomBytes(32).toString('hex');
|
|
34
|
+
|
|
35
|
+
await writeServerConfig({ port, host, token });
|
|
36
|
+
|
|
37
|
+
const serverResult = await startServer({
|
|
38
|
+
port,
|
|
39
|
+
host,
|
|
40
|
+
token,
|
|
41
|
+
serverMode: true,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const displayHost = resolveConnectableHost(host);
|
|
45
|
+
const resolvedPort = serverResult.port || port;
|
|
46
|
+
const connectableUrl = `http://${displayHost}:${resolvedPort}`;
|
|
47
|
+
|
|
48
|
+
console.log(`LLM Tuner server mode running on ${connectableUrl}`);
|
|
49
|
+
console.log(`Authentication Token: ${token}`);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
server: serverResult.server,
|
|
53
|
+
url: connectableUrl,
|
|
54
|
+
port: resolvedPort,
|
|
55
|
+
host,
|
|
56
|
+
connectableHost: displayHost,
|
|
57
|
+
token,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -1,19 +1,35 @@
|
|
|
1
1
|
import { select } from '@inquirer/prompts';
|
|
2
|
-
import { writeSettings } from '../../core/index.js';
|
|
2
|
+
import { writeSettings, resolveConnectableHost } from '../../core/index.js';
|
|
3
3
|
import { t as translate } from '../../i18n/index.js';
|
|
4
|
+
import { createServerAutostart } from '../../platform/server-autostart.js';
|
|
5
|
+
import { serverConfigPath, readServerConfig } from './serve.js';
|
|
4
6
|
import { getColors } from '../colors.js';
|
|
5
7
|
import { withNav, NAV_BACK, backChoice } from '../nav.js';
|
|
6
8
|
|
|
7
|
-
export async function settingsCommand(args, { lang, theme }) {
|
|
9
|
+
export async function settingsCommand(args, { lang, theme }, dependencies = {}) {
|
|
10
|
+
const {
|
|
11
|
+
selectPrompt = select,
|
|
12
|
+
autostart = createServerAutostart({ configPath: serverConfigPath() }),
|
|
13
|
+
readConfig = readServerConfig
|
|
14
|
+
} = dependencies;
|
|
8
15
|
const c = getColors(theme);
|
|
9
16
|
const t = (key, params) => translate(lang, key, params);
|
|
17
|
+
let startupEnabled;
|
|
18
|
+
try {
|
|
19
|
+
startupEnabled = await autostart.status();
|
|
20
|
+
} catch (e) {
|
|
21
|
+
startupEnabled = null;
|
|
22
|
+
console.error(c.y(t('cli.autostartFailed', { error: e.message })));
|
|
23
|
+
}
|
|
10
24
|
|
|
11
25
|
while (true) {
|
|
12
|
-
const action = await withNav(() =>
|
|
26
|
+
const action = await withNav(() => selectPrompt({
|
|
13
27
|
message: t('cli.settingsMenu'),
|
|
14
28
|
choices: [
|
|
15
29
|
{ name: `${t('cli.settingsLanguage')}: ${lang === 'es' ? 'Español' : 'English'} (${lang})`, value: 'lang' },
|
|
16
30
|
{ name: `${t('cli.settingsTheme')}: ${theme.charAt(0).toUpperCase() + theme.slice(1)}`, value: 'theme' },
|
|
31
|
+
{ name: `${t('cli.settingsAutostart')}: ${t(startupEnabled === null ? 'cli.unknown' : startupEnabled ? 'cli.enabled' : 'cli.disabled')}`, value: 'serverAutostart' },
|
|
32
|
+
{ name: t('cli.serverConnection'), value: 'serverConnection' },
|
|
17
33
|
{ name: t('cli.settingsExit'), value: 'exit' },
|
|
18
34
|
],
|
|
19
35
|
}));
|
|
@@ -24,7 +40,7 @@ export async function settingsCommand(args, { lang, theme }) {
|
|
|
24
40
|
}
|
|
25
41
|
|
|
26
42
|
if (action === 'lang') {
|
|
27
|
-
const res = await withNav(() =>
|
|
43
|
+
const res = await withNav(() => selectPrompt({
|
|
28
44
|
message: t('cli.settingsLanguage'),
|
|
29
45
|
default: lang,
|
|
30
46
|
choices: [
|
|
@@ -42,7 +58,7 @@ export async function settingsCommand(args, { lang, theme }) {
|
|
|
42
58
|
}
|
|
43
59
|
}
|
|
44
60
|
} else if (action === 'theme') {
|
|
45
|
-
const res = await withNav(() =>
|
|
61
|
+
const res = await withNav(() => selectPrompt({
|
|
46
62
|
message: t('cli.settingsTheme'),
|
|
47
63
|
default: theme,
|
|
48
64
|
choices: [
|
|
@@ -60,6 +76,45 @@ export async function settingsCommand(args, { lang, theme }) {
|
|
|
60
76
|
console.error(c.y(t('cli.themeNotSaved', { error: e.message })));
|
|
61
77
|
}
|
|
62
78
|
}
|
|
79
|
+
} else if (action === 'serverAutostart') {
|
|
80
|
+
const enabled = startupEnabled === true;
|
|
81
|
+
const res = await withNav(() => selectPrompt({
|
|
82
|
+
message: t('cli.settingsAutostart'),
|
|
83
|
+
default: enabled,
|
|
84
|
+
choices: [
|
|
85
|
+
{ name: t('cli.settingsAutostartEnable'), value: true },
|
|
86
|
+
{ name: t('cli.settingsAutostartDisable'), value: false },
|
|
87
|
+
backChoice(t, c),
|
|
88
|
+
],
|
|
89
|
+
}));
|
|
90
|
+
if (res !== NAV_BACK) {
|
|
91
|
+
try {
|
|
92
|
+
const updated = await autostart.setEnabled(res);
|
|
93
|
+
if (updated !== res) throw new Error('The operating system did not confirm the requested startup state.');
|
|
94
|
+
startupEnabled = res;
|
|
95
|
+
console.log(c.g(t(res ? 'cli.autostartEnabled' : 'cli.autostartDisabled')));
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.error(c.y(t('cli.autostartFailed', { error: e.message })));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} else if (action === 'serverConnection') {
|
|
101
|
+
try {
|
|
102
|
+
const cfg = await readConfig();
|
|
103
|
+
if (!cfg) {
|
|
104
|
+
console.log(c.y(t('cli.serverNoConfig')));
|
|
105
|
+
} else {
|
|
106
|
+
const isValid = typeof cfg.host === 'string' && cfg.host.length > 0 &&
|
|
107
|
+
Number.isInteger(cfg.port) && cfg.port >= 1 && cfg.port <= 65535 &&
|
|
108
|
+
typeof cfg.token === 'string' && cfg.token.length > 0;
|
|
109
|
+
if (!isValid) {
|
|
110
|
+
throw new Error(t('cli.serverConnectionInvalid'));
|
|
111
|
+
}
|
|
112
|
+
const displayHost = resolveConnectableHost(cfg.host);
|
|
113
|
+
console.log(t('cli.serverConnectionDetails', { host: displayHost, port: cfg.port, token: cfg.token }));
|
|
114
|
+
}
|
|
115
|
+
} catch (e) {
|
|
116
|
+
console.error(c.y(t('common.error', { message: e.message })));
|
|
117
|
+
}
|
|
63
118
|
}
|
|
64
119
|
}
|
|
65
120
|
}
|
package/src/cli/router.js
CHANGED
|
@@ -4,6 +4,7 @@ import { t as translate, LOCALES, errorText } from '../i18n/index.js';
|
|
|
4
4
|
import { loadCommand } from './commands/load.js';
|
|
5
5
|
import { mcpCommand } from './commands/mcp.js';
|
|
6
6
|
import { presetsCommand } from './commands/presets.js';
|
|
7
|
+
import { serveCommand } from './commands/serve.js';
|
|
7
8
|
import { settingsCommand } from './commands/settings.js';
|
|
8
9
|
import { helpCommand } from './commands/help.js';
|
|
9
10
|
import { withNav } from './nav.js';
|
|
@@ -30,6 +31,10 @@ export function parseCliArgs(argv) {
|
|
|
30
31
|
command = 'presets';
|
|
31
32
|
continue;
|
|
32
33
|
}
|
|
34
|
+
if (a === '/serve' || a === 'serve') {
|
|
35
|
+
command = 'serve';
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
33
38
|
if (a === '/settings' || a === 'settings' || a === '/setting' || a === 'setting') {
|
|
34
39
|
command = 'settings';
|
|
35
40
|
args.settings = true;
|
|
@@ -62,6 +67,10 @@ export function parseCliArgs(argv) {
|
|
|
62
67
|
command = 'presets';
|
|
63
68
|
continue;
|
|
64
69
|
}
|
|
70
|
+
if (k === 'serve') {
|
|
71
|
+
command = 'serve';
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
65
74
|
if (k === 'help') {
|
|
66
75
|
args.help = true;
|
|
67
76
|
command = 'help';
|
|
@@ -131,6 +140,20 @@ export async function routeCli(argv, env = {}) {
|
|
|
131
140
|
}
|
|
132
141
|
}
|
|
133
142
|
|
|
143
|
+
if ('server' in args) {
|
|
144
|
+
const s = await readSettings();
|
|
145
|
+
const target = s.servers.find((srv) => srv.id === args.server || srv.name === args.server);
|
|
146
|
+
if (!target) {
|
|
147
|
+
console.error(`Unknown server "${args.server}". Valid servers: ${s.servers.map((x) => x.id).join(', ')}`);
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
await writeSettings({ activeServerId: target.id });
|
|
152
|
+
} catch (e) {
|
|
153
|
+
console.error(`Warning: ${e.message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
134
157
|
if (command === 'version' || args.version) {
|
|
135
158
|
console.log(CURRENT_VERSION);
|
|
136
159
|
return 0;
|
|
@@ -141,6 +164,11 @@ export async function routeCli(argv, env = {}) {
|
|
|
141
164
|
return 0;
|
|
142
165
|
}
|
|
143
166
|
|
|
167
|
+
if (command === 'serve') {
|
|
168
|
+
await serveCommand(args);
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
144
172
|
// Interactive menu mode when run bare (persistent loop until /exit)
|
|
145
173
|
if (command === 'menu') {
|
|
146
174
|
const isInteractive = process.stdin.isTTY || env.forceInteractive;
|
|
@@ -163,6 +191,7 @@ export async function routeCli(argv, env = {}) {
|
|
|
163
191
|
{ name: `/load ${activeT('cli.cmdLoadDesc')}`, value: 'load' },
|
|
164
192
|
{ name: `/mcp ${activeT('cli.cmdMcpDesc')}`, value: 'mcp' },
|
|
165
193
|
{ name: `/presets ${activeT('cli.cmdPresetsDesc')}`, value: 'presets' },
|
|
194
|
+
{ name: `/serve ${activeT('cli.cmdServeDesc')}`, value: 'serve' },
|
|
166
195
|
{ name: `/settings ${activeT('cli.cmdSettingsDesc')}`, value: 'settings' },
|
|
167
196
|
{ name: `/help ${activeT('cli.cmdHelpDesc')}`, value: 'help' },
|
|
168
197
|
{ name: `/exit ${activeT('cli.cmdExitDesc')}`, value: 'exit' },
|
|
@@ -190,6 +219,9 @@ export async function routeCli(argv, env = {}) {
|
|
|
190
219
|
case 'presets':
|
|
191
220
|
await presetsCommand(args, { lang: currentLang, theme: currentTheme });
|
|
192
221
|
break;
|
|
222
|
+
case 'serve':
|
|
223
|
+
await serveCommand(args);
|
|
224
|
+
break;
|
|
193
225
|
case 'settings':
|
|
194
226
|
await settingsCommand(args, { lang: currentLang, theme: currentTheme });
|
|
195
227
|
{
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { serveCommand } from './commands/serve.js';
|
|
6
|
+
|
|
7
|
+
function validConfig(config) {
|
|
8
|
+
return config && typeof config === 'object' && !Array.isArray(config)
|
|
9
|
+
&& typeof config.host === 'string' && config.host.length > 0
|
|
10
|
+
&& Number.isInteger(config.port) && config.port > 0 && config.port <= 65535
|
|
11
|
+
&& typeof config.token === 'string' && config.token.length > 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function startSavedServer({ configDir, environment = process.env, readConfig, start = serveCommand } = {}) {
|
|
15
|
+
if (!configDir) throw new Error('A configuration directory is required to start the saved server.');
|
|
16
|
+
environment.LLM_TUNER_CONFIG_DIR = configDir;
|
|
17
|
+
const config = readConfig
|
|
18
|
+
? await readConfig()
|
|
19
|
+
: await fs.readFile(path.join(configDir, 'server.json'), 'utf8').then(JSON.parse).catch((error) => {
|
|
20
|
+
throw new Error(`Could not read the saved server configuration at ${configDir}: ${error.message}`);
|
|
21
|
+
});
|
|
22
|
+
if (!validConfig(config)) throw new Error(`The saved server configuration at ${configDir} is invalid; the server was not started.`);
|
|
23
|
+
const options = { host: config.host, port: config.port, token: config.token };
|
|
24
|
+
if (start !== serveCommand) return start(options);
|
|
25
|
+
// Manual /serve prints the token; a login launcher must not leak it to startup logs.
|
|
26
|
+
const log = console.log;
|
|
27
|
+
console.log = () => {};
|
|
28
|
+
try {
|
|
29
|
+
return await start(options);
|
|
30
|
+
} finally {
|
|
31
|
+
console.log = log;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function configDirectoryFrom(argv) {
|
|
36
|
+
const index = argv.indexOf('--config-dir');
|
|
37
|
+
const value = index >= 0 ? argv[index + 1] : null;
|
|
38
|
+
if (!value) throw new Error('Missing --config-dir for the login-started server.');
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
|
43
|
+
startSavedServer({ configDir: configDirectoryFrom(process.argv.slice(2)) }).catch((error) => {
|
|
44
|
+
console.error(`Could not start the login-configured server: ${error.message}`);
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { getActiveServer } from './settings.js';
|
|
2
|
+
|
|
3
|
+
export class TunerApiClient {
|
|
4
|
+
constructor({ baseUrl = 'http://127.0.0.1:7860', token = null } = {}) {
|
|
5
|
+
this.baseUrl = String(baseUrl || '').replace(/\/+$/, '');
|
|
6
|
+
this.token = token;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async _request(path, options = {}) {
|
|
10
|
+
const url = `${this.baseUrl}${path}`;
|
|
11
|
+
const headers = { ...options.headers };
|
|
12
|
+
|
|
13
|
+
if (this.token) {
|
|
14
|
+
headers['Authorization'] = `Bearer ${this.token}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (options.body && typeof options.body === 'object') {
|
|
18
|
+
headers['Content-Type'] = 'application/json';
|
|
19
|
+
options.body = JSON.stringify(options.body);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const response = await fetch(url, { ...options, headers });
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
let errMessage = `HTTP ${response.status}: ${response.statusText}`;
|
|
25
|
+
let errCode = null;
|
|
26
|
+
try {
|
|
27
|
+
const errJson = await response.json();
|
|
28
|
+
if (errJson?.error) errMessage = errJson.error;
|
|
29
|
+
if (errJson?.code) errCode = errJson.code;
|
|
30
|
+
} catch {}
|
|
31
|
+
const err = new Error(errMessage);
|
|
32
|
+
err.status = response.status;
|
|
33
|
+
if (errCode) err.code = errCode;
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return response.json();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async getState() {
|
|
41
|
+
return this._request('/api/state');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async listModels(engine) {
|
|
45
|
+
const query = engine ? `?engine=${encodeURIComponent(engine)}` : '';
|
|
46
|
+
return this._request(`/api/models${query}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async planModel({ engine, model, ctx }) {
|
|
50
|
+
return this._request('/api/plan', {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
body: { engine, model, ctx },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async listPresets({ engine, model } = {}) {
|
|
57
|
+
const params = new URLSearchParams();
|
|
58
|
+
if (engine) params.set('engine', engine);
|
|
59
|
+
if (model) params.set('model', model);
|
|
60
|
+
const query = params.toString() ? `?${params.toString()}` : '';
|
|
61
|
+
return this._request(`/api/presets${query}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async chat({ engine, model, messages }) {
|
|
65
|
+
return this._request('/api/chat', {
|
|
66
|
+
method: 'POST',
|
|
67
|
+
body: { engine, model, messages },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async loadModel({ engine, model, ctx, profile, force, candidates }, onProgress) {
|
|
72
|
+
const { jobId } = await this._request('/api/load', {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
body: { engine, model, ctx, profile, force, candidates },
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (!onProgress) return { jobId };
|
|
78
|
+
|
|
79
|
+
const response = await fetch(`${this.baseUrl}/api/jobs/${jobId}/events`, {
|
|
80
|
+
headers: this.token ? { 'Authorization': `Bearer ${this.token}` } : {},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const reader = response.body.getReader();
|
|
84
|
+
const decoder = new TextDecoder();
|
|
85
|
+
let buffer = '';
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
while (true) {
|
|
89
|
+
const { value, done } = await reader.read();
|
|
90
|
+
if (done) break;
|
|
91
|
+
|
|
92
|
+
buffer += decoder.decode(value, { stream: true });
|
|
93
|
+
const lines = buffer.split('\n');
|
|
94
|
+
buffer = lines.pop();
|
|
95
|
+
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
const trimmed = line.trim();
|
|
98
|
+
if (!trimmed || !trimmed.startsWith('data: ')) continue;
|
|
99
|
+
|
|
100
|
+
const data = JSON.parse(trimmed.slice(6));
|
|
101
|
+
if (typeof onProgress === 'function') {
|
|
102
|
+
onProgress(data);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (data.type === 'finished') {
|
|
106
|
+
if (data.ok === false) {
|
|
107
|
+
const err = new Error(data.error || 'Job failed');
|
|
108
|
+
if (data.code) err.code = data.code;
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
return data.result;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
await reader.cancel();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
static fromSettings(settings) {
|
|
121
|
+
const active = getActiveServer(settings);
|
|
122
|
+
return new TunerApiClient({
|
|
123
|
+
baseUrl: active.url,
|
|
124
|
+
token: active.token,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|