llm-tuner 0.3.0 → 0.4.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 +19 -3
- package/README.md +19 -3
- package/package.json +1 -1
- package/src/cli/animation.js +85 -0
- package/src/cli/colors.js +12 -0
- package/src/cli/commands/help.js +7 -0
- package/src/cli/commands/load.js +338 -0
- package/src/cli/commands/mcp.js +118 -0
- package/src/cli/commands/presets.js +21 -0
- package/src/cli/commands/settings.js +65 -0
- package/src/cli/index.js +10 -367
- package/src/cli/nav.js +82 -0
- package/src/cli/router.js +243 -0
- package/src/core/index.js +1 -0
- package/src/core/mcp/adapters/json-adapter.js +37 -0
- package/src/core/mcp/adapters/toml-adapter.js +82 -0
- package/src/core/mcp/index.js +82 -0
- package/src/core/mcp/registry.js +121 -0
- package/src/i18n/en.js +46 -4
- package/src/i18n/es.js +46 -4
- package/src/web/public/app.js +172 -1
- package/src/web/public/index.html +33 -0
- package/src/web/public/style.css +29 -0
- package/src/web/server.js +20 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { select } from '@inquirer/prompts';
|
|
2
|
+
import { writeSettings } from '../../core/index.js';
|
|
3
|
+
import { t as translate } from '../../i18n/index.js';
|
|
4
|
+
import { getColors } from '../colors.js';
|
|
5
|
+
import { withNav, NAV_BACK, backChoice } from '../nav.js';
|
|
6
|
+
|
|
7
|
+
export async function settingsCommand(args, { lang, theme }) {
|
|
8
|
+
const c = getColors(theme);
|
|
9
|
+
const t = (key, params) => translate(lang, key, params);
|
|
10
|
+
|
|
11
|
+
while (true) {
|
|
12
|
+
const action = await withNav(() => select({
|
|
13
|
+
message: t('cli.settingsMenu'),
|
|
14
|
+
choices: [
|
|
15
|
+
{ name: `${t('cli.settingsLanguage')}: ${lang === 'es' ? 'Español' : 'English'} (${lang})`, value: 'lang' },
|
|
16
|
+
{ name: `${t('cli.settingsTheme')}: ${theme.charAt(0).toUpperCase() + theme.slice(1)}`, value: 'theme' },
|
|
17
|
+
{ name: t('cli.settingsExit'), value: 'exit' },
|
|
18
|
+
],
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
if (action === NAV_BACK || action === 'exit') {
|
|
22
|
+
console.log(c.g(t('cli.settingsSaved')));
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (action === 'lang') {
|
|
27
|
+
const res = await withNav(() => select({
|
|
28
|
+
message: t('cli.settingsLanguage'),
|
|
29
|
+
default: lang,
|
|
30
|
+
choices: [
|
|
31
|
+
{ name: 'English', value: 'en' },
|
|
32
|
+
{ name: 'Español', value: 'es' },
|
|
33
|
+
backChoice(t, c),
|
|
34
|
+
],
|
|
35
|
+
}));
|
|
36
|
+
if (res !== NAV_BACK) {
|
|
37
|
+
lang = res;
|
|
38
|
+
try {
|
|
39
|
+
await writeSettings({ lang });
|
|
40
|
+
} catch (e) {
|
|
41
|
+
console.error(c.y(t('cli.langNotSaved', { error: e.message })));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} else if (action === 'theme') {
|
|
45
|
+
const res = await withNav(() => select({
|
|
46
|
+
message: t('cli.settingsTheme'),
|
|
47
|
+
default: theme,
|
|
48
|
+
choices: [
|
|
49
|
+
{ name: 'System', value: 'system' },
|
|
50
|
+
{ name: 'Light', value: 'light' },
|
|
51
|
+
{ name: 'Dark', value: 'dark' },
|
|
52
|
+
backChoice(t, c),
|
|
53
|
+
],
|
|
54
|
+
}));
|
|
55
|
+
if (res !== NAV_BACK) {
|
|
56
|
+
theme = res;
|
|
57
|
+
try {
|
|
58
|
+
await writeSettings({ theme });
|
|
59
|
+
} catch (e) {
|
|
60
|
+
console.error(c.y(t('cli.themeNotSaved', { error: e.message })));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -1,372 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { detectHardware, detectEngines, Tuner, installPlans, runInstall, listPresets, presetsDir, fmtBytes, readSettings, writeSettings, THEMES, PROFILES, DEFAULT_PROFILE, TunerError, checkUpdate, CURRENT_VERSION } from '../core/index.js';
|
|
4
|
-
import { t as translate, LOCALES, errorText } from '../i18n/index.js';
|
|
5
|
-
import { profileChoices, profileNotes, profileSwitchNote, profileEventLines, shouldAskProfile, parseContext } from './profile.js';
|
|
2
|
+
import { routeCli } from './router.js';
|
|
6
3
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
if (!LOCALES.includes(args.lang)) {
|
|
12
|
-
console.error(translate('en', 'errors.unknownLocale', { lang: args.lang ?? '', list: LOCALES.join(', ') }));
|
|
13
|
-
process.exit(1);
|
|
4
|
+
try {
|
|
5
|
+
const code = await routeCli(process.argv.slice(2));
|
|
6
|
+
if (typeof code === 'number' && code !== 0) {
|
|
7
|
+
process.exit(code);
|
|
14
8
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if ('theme' in args) {
|
|
19
|
-
if (!THEMES.includes(args.theme)) {
|
|
20
|
-
console.error(translate(lang, 'errors.unknownTheme', { theme: args.theme ?? '', list: THEMES.join(', ') }));
|
|
21
|
-
process.exit(1);
|
|
22
|
-
}
|
|
23
|
-
theme = args.theme;
|
|
24
|
-
try { await writeSettings({ theme }); } catch (e) { console.error(c.y(t('cli.themeNotSaved', { error: e.message }))); }
|
|
25
|
-
}
|
|
26
|
-
if ('profile' in args) {
|
|
27
|
-
if (!PROFILES.includes(args.profile)) {
|
|
28
|
-
console.error(translate(lang, 'errors.unknownProfile', { profile: args.profile ?? '', list: PROFILES.join(', ') }));
|
|
29
|
-
process.exit(1);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const isLight = () => theme === 'light';
|
|
34
|
-
const c = {
|
|
35
|
-
b: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
36
|
-
dim: (s) => isLight() ? `\x1b[90m${s}\x1b[0m` : `\x1b[2m${s}\x1b[0m`,
|
|
37
|
-
g: (s) => isLight() ? `\x1b[38;2;40;138;0m${s}\x1b[0m` : `\x1b[38;2;101;255;0m${s}\x1b[0m`,
|
|
38
|
-
y: (s) => isLight() ? `\x1b[33m${s}\x1b[0m` : `\x1b[93m${s}\x1b[0m`,
|
|
39
|
-
r: (s) => isLight() ? `\x1b[38;2;214;21;0m${s}\x1b[0m` : `\x1b[38;2;255;26;0m${s}\x1b[0m`,
|
|
40
|
-
cyan: (s) => isLight() ? `\x1b[38;2;0;124;153m${s}\x1b[0m` : `\x1b[38;2;0;229;255m${s}\x1b[0m`,
|
|
41
|
-
purple: (s) => isLight() ? `\x1b[38;2;120;0;199m${s}\x1b[0m` : `\x1b[38;2;154;0;255m${s}\x1b[0m`,
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
if (args.version) {
|
|
45
|
-
console.log(CURRENT_VERSION);
|
|
46
|
-
process.exit(0);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (args.help) {
|
|
50
|
-
console.log(t('cli.help', { presetsDir: presetsDir() }));
|
|
51
|
-
process.exit(0);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (args.settings) {
|
|
55
|
-
try {
|
|
56
|
-
await settingsFlow();
|
|
57
|
-
process.exit(0);
|
|
58
|
-
} catch (e) {
|
|
59
|
-
if (e?.name === 'ExitPromptError') process.exit(130);
|
|
60
|
-
console.error(c.r(`\n${t('common.error', { message: errorText(lang, e) })}`));
|
|
61
|
-
process.exit(1);
|
|
62
|
-
}
|
|
63
|
-
} else if (args.presets) {
|
|
64
|
-
const list = await listPresets({ engine: args.engine, modelKey: args.model });
|
|
65
|
-
if (!list.length) console.log(t('cli.noPresets'));
|
|
66
|
-
for (const p of list) console.log(`${p.engine.padEnd(9)} ${p.model} ${p.ctx / 1024}K${p.profile ? ` ${p.profile}${p.variant && p.variant !== p.model ? ` (${p.variant})` : ''}` : ''} KV ${p.kvType} ${p.fullOffload ? t('candidate.allGpu') : t('cli.partial')} ${p.shortTps} t/s ${c.dim(p.createdAt.slice(0, 16).replace('T', ' '))}`);
|
|
67
|
-
} else {
|
|
68
|
-
main().catch((e) => {
|
|
69
|
-
if (e?.name === 'ExitPromptError') process.exit(130);
|
|
70
|
-
console.error(c.r(`\n${t('common.error', { message: errorText(lang, e) })}`));
|
|
71
|
-
process.exit(1);
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function settingsFlow() {
|
|
76
|
-
while (true) {
|
|
77
|
-
const action = await select({
|
|
78
|
-
message: t('cli.settingsMenu'),
|
|
79
|
-
choices: [
|
|
80
|
-
{ name: `${t('cli.settingsLanguage')}: ${lang === 'es' ? 'Español' : 'English'} (${lang})`, value: 'lang' },
|
|
81
|
-
{ name: `${t('cli.settingsTheme')}: ${theme.charAt(0).toUpperCase() + theme.slice(1)}`, value: 'theme' },
|
|
82
|
-
{ name: t('cli.settingsExit'), value: 'exit' },
|
|
83
|
-
],
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
if (action === 'lang') {
|
|
87
|
-
lang = await select({
|
|
88
|
-
message: t('cli.settingsLanguage'),
|
|
89
|
-
default: lang,
|
|
90
|
-
choices: [
|
|
91
|
-
{ name: 'English', value: 'en' },
|
|
92
|
-
{ name: 'Español', value: 'es' },
|
|
93
|
-
],
|
|
94
|
-
});
|
|
95
|
-
try {
|
|
96
|
-
await writeSettings({ lang });
|
|
97
|
-
} catch (e) {
|
|
98
|
-
console.error(c.y(t('cli.langNotSaved', { error: e.message })));
|
|
99
|
-
}
|
|
100
|
-
} else if (action === 'theme') {
|
|
101
|
-
theme = await select({
|
|
102
|
-
message: t('cli.settingsTheme'),
|
|
103
|
-
default: theme,
|
|
104
|
-
choices: [
|
|
105
|
-
{ name: 'System', value: 'system' },
|
|
106
|
-
{ name: 'Light', value: 'light' },
|
|
107
|
-
{ name: 'Dark', value: 'dark' },
|
|
108
|
-
],
|
|
109
|
-
});
|
|
110
|
-
try {
|
|
111
|
-
await writeSettings({ theme });
|
|
112
|
-
} catch (e) {
|
|
113
|
-
console.error(c.y(t('cli.themeNotSaved', { error: e.message })));
|
|
114
|
-
}
|
|
115
|
-
} else if (action === 'exit') {
|
|
116
|
-
console.log(c.g(t('cli.settingsSaved')));
|
|
117
|
-
break;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async function main() {
|
|
123
|
-
const updatePromise = checkUpdate().catch(() => null);
|
|
124
|
-
const isTTY = Boolean(process.stdout.isTTY && !args.json);
|
|
125
|
-
let lastProgress = null;
|
|
126
|
-
let lastTelemetry = null;
|
|
127
|
-
let liveActive = false;
|
|
128
|
-
|
|
129
|
-
const clearLive = () => {
|
|
130
|
-
if (isTTY && liveActive) {
|
|
131
|
-
process.stdout.write('\r\x1b[K');
|
|
132
|
-
liveActive = false;
|
|
133
|
-
}
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
const renderLive = () => {
|
|
137
|
-
if (!isTTY || !lastProgress) return;
|
|
138
|
-
const pct = Math.min(100, Math.max(0, lastProgress.pct ?? 0));
|
|
139
|
-
const barLen = 16;
|
|
140
|
-
const filled = Math.round((pct / 100) * barLen);
|
|
141
|
-
const bar = c.cyan('━'.repeat(filled)) + c.dim('─'.repeat(barLen - filled));
|
|
142
|
-
|
|
143
|
-
let telem = '';
|
|
144
|
-
if (lastTelemetry) {
|
|
145
|
-
if (lastTelemetry.cpu) telem += ` · CPU ${lastTelemetry.cpu.percent}%`;
|
|
146
|
-
if (lastTelemetry.ram) {
|
|
147
|
-
telem += ` · RAM ${(lastTelemetry.ram.usedBytes / 1024 ** 3).toFixed(1)}/${(lastTelemetry.ram.totalBytes / 1024 ** 3).toFixed(1)} GB`;
|
|
148
|
-
}
|
|
149
|
-
if (lastTelemetry.gpus?.length) {
|
|
150
|
-
telem += ' · ' + lastTelemetry.gpus.map((g) => `GPU${g.index} ${(g.usedBytes / 1024 ** 3).toFixed(1)}/${(g.totalBytes / 1024 ** 3).toFixed(1)} GB`).join(' ');
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
process.stdout.write(`\r\x1b[K [${bar}] ${String(pct).padStart(3)}%${telem}`);
|
|
155
|
-
liveActive = true;
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
const out = (...a) => {
|
|
159
|
-
if (args.json) return;
|
|
160
|
-
clearLive();
|
|
161
|
-
console.log(...a);
|
|
162
|
-
};
|
|
163
|
-
out([
|
|
164
|
-
'',
|
|
165
|
-
` ${c.cyan(' ╭───')}${c.dim('╷ ╷ ╷')}${c.cyan('───╮')}`,
|
|
166
|
-
` ${c.cyan('╭─┤ ')}${c.cyan('┌───┐')}${c.cyan(' ├─╮ ')}${c.b('LLM Tuner')}`,
|
|
167
|
-
` ${c.cyan('│ ')}${c.dim('╶┤')}${c.b(' │ █ │ ')}${c.dim('├╴')}${c.cyan('▲ │ ')}${c.dim(t('web.tagline'))}`,
|
|
168
|
-
` ${c.cyan('╰─┤ ')}${c.cyan('└───┘')}${c.cyan(' ├──╯')}`,
|
|
169
|
-
` ${c.cyan(' ╰───')}${c.dim('╵ ╵ ╵')}${c.cyan('───╯')}`,
|
|
170
|
-
'',
|
|
171
|
-
].join('\n'));
|
|
172
|
-
|
|
173
|
-
const updateInfo = await updatePromise;
|
|
174
|
-
if (updateInfo?.updateAvailable) {
|
|
175
|
-
out(c.y(` ▲ ${t('cli.updateAvailable', { current: updateInfo.currentVersion, latest: updateInfo.latestVersion })}`));
|
|
176
|
-
out(c.dim(` ${t('cli.updateCommand', { cmd: 'npm install -g llm-tuner' })}\n`));
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const hw = await detectHardware();
|
|
180
|
-
out(c.dim(` ${hw.cpu.brand} · ${fmtBytes(hw.ram.totalBytes)} RAM`));
|
|
181
|
-
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') : ''}`));
|
|
182
|
-
if (!hw.gpus.length) out(c.y(` ${t('cli.noGpu')}`));
|
|
183
|
-
|
|
184
|
-
// 1. Engine
|
|
185
|
-
let det = await detectEngines(hw);
|
|
186
|
-
let engineId = args.engine;
|
|
187
|
-
if (!engineId) {
|
|
188
|
-
engineId = await select({
|
|
189
|
-
message: t('cli.whichEngine'),
|
|
190
|
-
default: det.recommended,
|
|
191
|
-
choices: det.engines.map((e) => ({
|
|
192
|
-
name: `${e.name} ${e.detection.installed ? c.g(t('cli.installed')) + (e.detection.version ? c.dim(' v' + e.detection.version) : '') : c.dim(t('cli.notInstalled'))}${e.id === det.recommended ? c.y(` ${t('cli.recommended')}`) : ''}`,
|
|
193
|
-
value: e.id,
|
|
194
|
-
})),
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
if (!det.engines.find((e) => e.id === engineId)?.detection.installed) {
|
|
198
|
-
await installFlow(engineId);
|
|
199
|
-
det = await detectEngines(hw);
|
|
200
|
-
if (!det.engines.find((e) => e.id === engineId).detection.installed) throw new TunerError('errors.installNotFinished');
|
|
201
|
-
}
|
|
202
|
-
const tuner = await Tuner.create(engineId);
|
|
203
|
-
|
|
204
|
-
// 2. Model
|
|
205
|
-
let models = await tuner.listModels();
|
|
206
|
-
if (!models.length) {
|
|
207
|
-
out(c.y(`\n${t('cli.noModels', { engine: tuner.engine.name })}`));
|
|
208
|
-
const name = await input({ message: engineId === 'ollama' ? t('cli.downloadOllama') : t('cli.downloadLms') });
|
|
209
|
-
const bin = tuner.ctx.detection.bin;
|
|
210
|
-
const r = await runInstall(engineId === 'ollama' ? { cmd: bin, args: ['pull', name] } : { cmd: bin, args: ['get', name, '-y'] }, (l) => out(c.dim(' ' + l)));
|
|
211
|
-
if (r.code !== 0) throw new TunerError('errors.modelDownloadFailed');
|
|
212
|
-
models = await tuner.listModels();
|
|
213
|
-
}
|
|
214
|
-
const modelKey = args.model || await search({
|
|
215
|
-
message: t('cli.model'),
|
|
216
|
-
source: (term) => models
|
|
217
|
-
.filter((m) => !term || `${m.key} ${m.name}`.toLowerCase().includes(term.toLowerCase()))
|
|
218
|
-
.map((m) => ({ name: `${m.name} ${c.dim(`${fmtBytes(m.sizeBytes)}${m.quant ? ' · ' + m.quant : ''}`)}`, value: m.key })),
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
// 3. Context
|
|
222
|
-
let ctx = parseContext(args.ctx);
|
|
223
|
-
if (!ctx) {
|
|
224
|
-
const presets = await listPresets({ engine: engineId, modelKey });
|
|
225
|
-
const preview = await tuner.plan(modelKey, 8192);
|
|
226
|
-
const limit = preview.model.meta.trainContext || 131072;
|
|
227
|
-
const list = Object.entries(preview.maxContext).map(([k, v]) => `KV ${k} ${v ? Math.floor(v / 1024) + 'K' : '—'}`).join(' · ');
|
|
228
|
-
out(c.dim(` ${t('cli.maxFullGpu', { list, train: Math.floor(limit / 1024) })}`));
|
|
229
|
-
if (presets.length) out(c.dim(` ${t('cli.tunedContexts', { list: presets.map((p) => `${p.ctx / 1024}K (${p.shortTps} t/s)`).join(', ') })}`));
|
|
230
|
-
const answer = await input({
|
|
231
|
-
message: t('cli.context'),
|
|
232
|
-
default: String(presets.at(-1)?.ctx || Math.min(16384, limit)),
|
|
233
|
-
validate: (v) => parseContext(v, limit) !== null || t('cli.contextRange', { limit }),
|
|
234
|
-
});
|
|
235
|
-
ctx = parseContext(answer, limit);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// 4. Profile
|
|
239
|
-
let profile = args.profile;
|
|
240
|
-
if (!profile && !shouldAskProfile(args)) {
|
|
241
|
-
profile = DEFAULT_PROFILE; // Non-interactive and fully flagged runs keep the behaviour scripts already rely on.
|
|
242
|
-
}
|
|
243
|
-
// The wizard gives the switch advice with its prompt; a profile that comes from a flag gets it with the load events.
|
|
244
|
-
const advisedByWizard = !profile;
|
|
245
|
-
if (!profile) {
|
|
246
|
-
const plan = await tuner.profilePlan(modelKey, ctx);
|
|
247
|
-
const { profile: stored } = await readSettings();
|
|
248
|
-
for (const line of profileNotes(plan, t)) {
|
|
249
|
-
out((line.level === 'warn' ? c.y : c.dim)(` ${line.text}`));
|
|
250
|
-
}
|
|
251
|
-
profile = await select({
|
|
252
|
-
message: t('cli.profile'),
|
|
253
|
-
...profileChoices(plan, t, { stored, mark: c.y, dim: c.dim }),
|
|
254
|
-
});
|
|
255
|
-
try {
|
|
256
|
-
await writeSettings({ profile });
|
|
257
|
-
} catch (e) {
|
|
258
|
-
console.error(c.y(t('cli.profileNotSaved', { error: e.message })));
|
|
259
|
-
}
|
|
260
|
-
const switchNote = profileSwitchNote(plan, profile, t, tuner.engine.name);
|
|
261
|
-
if (switchNote) out(c.dim(` ${switchNote}`));
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// 5. Load (preset or benchmark)
|
|
265
|
-
// Progress event before the current one: a fallback that repeats its reason is not printed twice.
|
|
266
|
-
let previous = null;
|
|
267
|
-
tuner.on('progress', (e) => {
|
|
268
|
-
if (args.json) return;
|
|
269
|
-
if (e.type === 'telemetry') {
|
|
270
|
-
lastTelemetry = e;
|
|
271
|
-
renderLive();
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
if (e.progress) {
|
|
275
|
-
lastProgress = e.progress;
|
|
276
|
-
}
|
|
277
|
-
if (e.type === 'variant-picked' || e.type === 'profile-fallback') {
|
|
278
|
-
for (const line of profileEventLines(e, previous, t, advisedByWizard ? null : tuner.engine.name)) {
|
|
279
|
-
out((line.level === 'warn' ? c.y : c.dim)(` ${line.text}`));
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
previous = e;
|
|
283
|
-
if (e.type === 'status') out(c.dim(`\n${t(e.code, e.params)}`));
|
|
284
|
-
if (e.type === 'preset-hit') out(c.g(`\n✓ ${t('cli.presetHit', { date: e.preset.createdAt.slice(0, 10), config: describe(e.preset.candidate), tps: e.preset.bench.short.genTps })}`));
|
|
285
|
-
if (e.type === 'candidate-start') out(` [${e.index + 1}/${e.total}] ${describe(e.candidate)}`);
|
|
286
|
-
if (e.type === 'bench-progress' && e.phase === 'deep') out(c.dim(` ${t('common.longPrompt', { tokens: e.promptTokens })}`));
|
|
287
|
-
if (e.type === 'candidate-done') {
|
|
288
|
-
const b = e.bench;
|
|
289
|
-
out(b.ok ? ` ${c.g('✓')} ${t('cli.benchShort', { tps: c.b(b.short.genTps + ' t/s') })}${b.deep ? t('cli.benchDeep', { tps: c.b(b.deep.genTps + ' t/s'), tokens: b.deep.promptTokens }) : ''} · CPU ${b.cpu.avg}% · VRAM ${vramStr(b.vramPeakBytes)}` : c.r(` ✗ ${benchErrorText(b)}`));
|
|
290
|
-
}
|
|
291
|
-
if (e.type === 'preset-saved') out(c.g(`\n✓ ${t('common.presetSaved', { file: e.file })}`));
|
|
292
|
-
|
|
293
|
-
renderLive();
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
let result;
|
|
297
|
-
try {
|
|
298
|
-
result = await tuner.load(modelKey, ctx, {
|
|
299
|
-
profile,
|
|
300
|
-
force: !!args.force,
|
|
301
|
-
dryRun: !!args['dry-run'],
|
|
302
|
-
maxCandidates: Number(args.candidates) || 3,
|
|
303
|
-
confirmApply: async (preview) => {
|
|
304
|
-
clearLive();
|
|
305
|
-
out(c.b(`\n${t('cli.changes')}`));
|
|
306
|
-
if (engineId === 'lmstudio') {
|
|
307
|
-
preview.files.forEach((f) => out(` ${f}`));
|
|
308
|
-
if (preview.restartsApp) out(c.y(` ${t('cli.lmsRestart')}`));
|
|
309
|
-
} else {
|
|
310
|
-
out(` ${t('cli.ollamaNewModel', { name: c.b(preview.tunedModel) })}`);
|
|
311
|
-
out(` ${t('cli.ollamaServer', { env: Object.entries(preview.env).map(([k, v]) => `${k}=${v}`).join(' ') })}`);
|
|
312
|
-
}
|
|
313
|
-
return args.yes || confirm({ message: t('cli.confirmApply'), default: true });
|
|
314
|
-
},
|
|
315
|
-
});
|
|
316
|
-
} finally {
|
|
317
|
-
clearLive();
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
if (args['dry-run']) out(c.y(`\n${t('cli.dryRun')}`));
|
|
321
|
-
if (result.applied?.backups?.length) out(c.dim(` ${t('common.backups', { list: result.applied.backups.join(', ') })}`));
|
|
322
|
-
if (result.applied?.pendingCommands?.length) {
|
|
323
|
-
out(c.y(`\n${t('cli.pending')}`));
|
|
324
|
-
result.applied.pendingCommands.forEach((cmd) => out(` ${cmd}`));
|
|
325
|
-
}
|
|
326
|
-
const l = result.loaded;
|
|
327
|
-
if (l) out(l.ok
|
|
328
|
-
? c.g(`\n✓ ${t('cli.loaded', { engine: tuner.engine.name, tps: c.b(l.short.genTps + ' t/s') })}${l.model ? t('cli.usesModel', { model: l.model }) : ''}`)
|
|
329
|
-
: c.r(`\n✗ ${t('cli.loadFailed', { error: benchErrorText(l) })}`));
|
|
330
|
-
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
async function installFlow(engineId) {
|
|
334
|
-
const plans = await installPlans(engineId);
|
|
335
|
-
if (!plans.length) throw new TunerError('errors.noInstaller');
|
|
336
|
-
const plan = plans.length === 1 ? plans[0] : await select({ message: t('cli.installMethod'), choices: plans.map((p) => ({ name: t(p.labelCode), value: p })) });
|
|
337
|
-
console.log(c.dim(` ${plan.shell || (plan.cmd ? [plan.cmd, ...plan.args].join(' ') : plan.url)}`));
|
|
338
|
-
if (!args.yes && !(await confirm({ message: t('cli.installNow'), default: true }))) process.exit(0);
|
|
339
|
-
const r = await runInstall(plan, (l) => console.log(c.dim(' ' + l)));
|
|
340
|
-
if (r.manual) await input({ message: t('cli.installManual') });
|
|
341
|
-
else if (r.code !== 0) throw new TunerError('errors.installerExit', { code: r.code });
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function describe(cand) {
|
|
345
|
-
const where = cand.fullOffload ? t('candidate.allGpu') : t('candidate.layersGpu', { gpu: cand.gpuLayers, total: cand.totalLayers });
|
|
346
|
-
return `KV ${cand.kvType} · ${where}${cand.cpuMoeLayers ? t('candidate.expertsRam', { layers: cand.cpuMoeLayers }) : ''}${t('candidate.threads', { n: cand.threads })}${cand.probe ? t('candidate.probe') : ''}`;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function benchErrorText(b) {
|
|
350
|
-
return b.errorCode ? t(b.errorCode, b.errorParams) : b.error;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
function vramStr(peaks = {}) {
|
|
354
|
-
const e = Object.entries(peaks || {});
|
|
355
|
-
return e.length ? e.map(([i, b]) => `GPU${i} ${(b / 1024 ** 3).toFixed(1)} GB`).join(' / ') : t('common.na');
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function parseArgs(argv) {
|
|
359
|
-
const flags = new Set(['help', 'version', 'yes', 'dry-run', 'json', 'force', 'presets', 'settings']);
|
|
360
|
-
const o = {};
|
|
361
|
-
for (let i = 0; i < argv.length; i++) {
|
|
362
|
-
const a = argv[i];
|
|
363
|
-
if (a === '-h') o.help = true;
|
|
364
|
-
if (a === '-v') o.version = true;
|
|
365
|
-
if (a === '/settings' || a === '/setting' || a === 'settings') o.settings = true;
|
|
366
|
-
if (!a.startsWith('--')) continue;
|
|
367
|
-
const k = a.slice(2);
|
|
368
|
-
if (k === 'setting') { o.settings = true; continue; }
|
|
369
|
-
o[k] = flags.has(k) ? true : argv[++i];
|
|
9
|
+
} catch (e) {
|
|
10
|
+
if (e?.name === 'ExitPromptError') {
|
|
11
|
+
process.exit(130);
|
|
370
12
|
}
|
|
371
|
-
|
|
13
|
+
console.error(`\nError: ${e.message || e}`);
|
|
14
|
+
process.exit(1);
|
|
372
15
|
}
|
package/src/cli/nav.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { CancelPromptError } from '@inquirer/core';
|
|
2
|
+
|
|
3
|
+
export const NAV_BACK = Symbol('NAV_BACK');
|
|
4
|
+
|
|
5
|
+
export function isNavBack(val) {
|
|
6
|
+
return val === NAV_BACK;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function backChoice(t, c) {
|
|
10
|
+
const label = t ? t('common.back') : 'Back';
|
|
11
|
+
const text = `← ${label} (Backspace)`;
|
|
12
|
+
return {
|
|
13
|
+
name: c ? c.dim(text) : text,
|
|
14
|
+
value: NAV_BACK,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Runs an Inquirer prompt with keyboard navigation support (Backspace / Escape to go back).
|
|
20
|
+
* If the user presses Backspace or Escape, or if the prompt is cancelled, returns NAV_BACK.
|
|
21
|
+
*
|
|
22
|
+
* @param {Function} promptFactory - Function that creates and returns an Inquirer prompt promise.
|
|
23
|
+
* @param {Object} [options]
|
|
24
|
+
* @param {boolean} [options.allowBack=true] - Whether Backspace/Escape triggers NAV_BACK.
|
|
25
|
+
* @param {boolean} [options.isTextInput=false] - For text inputs, only empty input triggers back on Backspace.
|
|
26
|
+
* @param {Function} [options.getInputValue] - Getter returning current input text buffer.
|
|
27
|
+
*/
|
|
28
|
+
export async function withNav(promptFactory, options = {}) {
|
|
29
|
+
const { allowBack = true, isTextInput = false, getInputValue } = options;
|
|
30
|
+
|
|
31
|
+
if (!allowBack || !process.stdin.isTTY) {
|
|
32
|
+
try {
|
|
33
|
+
const res = await promptFactory();
|
|
34
|
+
return res;
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (e?.name === 'CancelPromptError') return NAV_BACK;
|
|
37
|
+
throw e;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let promptPromise = null;
|
|
42
|
+
let triggeredBack = false;
|
|
43
|
+
|
|
44
|
+
const onKeypress = (ch, key) => {
|
|
45
|
+
if (!key) return;
|
|
46
|
+
const isBackKey = key.name === 'backspace' || ch === '\x08' || ch === '\x7f';
|
|
47
|
+
const isEscKey = key.name === 'escape';
|
|
48
|
+
|
|
49
|
+
if (isEscKey) {
|
|
50
|
+
triggeredBack = true;
|
|
51
|
+
promptPromise?.cancel?.();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (isBackKey) {
|
|
56
|
+
if (isTextInput && typeof getInputValue === 'function') {
|
|
57
|
+
const currentVal = getInputValue();
|
|
58
|
+
if (currentVal && currentVal.length > 0) {
|
|
59
|
+
// User is deleting characters in text input
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
triggeredBack = true;
|
|
64
|
+
promptPromise?.cancel?.();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
process.stdin.on('keypress', onKeypress);
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
promptPromise = promptFactory();
|
|
72
|
+
const result = await promptPromise;
|
|
73
|
+
return result;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (triggeredBack || err?.name === 'CancelPromptError') {
|
|
76
|
+
return NAV_BACK;
|
|
77
|
+
}
|
|
78
|
+
throw err;
|
|
79
|
+
} finally {
|
|
80
|
+
process.stdin.removeListener('keypress', onKeypress);
|
|
81
|
+
}
|
|
82
|
+
}
|