llm-tuner 0.3.0 → 0.4.1
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/engines/lmstudio.js +69 -16
- 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
package/README.es.md
CHANGED
|
@@ -41,6 +41,7 @@ Ningún LLM decide la configuración. LLM Tuner lee la topología de tu hardware
|
|
|
41
41
|
- **Soporte de motores locales**: Compatibilidad directa con LM Studio (a través de `llama-server`) y Ollama (instancias privadas aisladas).
|
|
42
42
|
- **Presets persistentes**: Mide una única vez por combinación de hardware, modelo, contexto y perfil. Invalida presets automáticamente si el hardware o el archivo del modelo cambian.
|
|
43
43
|
- **Doble interfaz**: Asistente en consola (`llm-tuner`) y aplicación gráfica de escritorio (`llm-tuner-desktop`).
|
|
44
|
+
- **Integración MCP para agentes de IA**: Conecta tus motores locales (LM Studio, Ollama) como servidores Model Context Protocol (MCP) en Claude Desktop, Claude Code, OpenCode, Antigravity, VS Code y ChatGPT/Codex con nombres personalizados y seguridad multiplataforma.
|
|
44
45
|
- **Bilingüe**: Localización completa en español e inglés tanto en terminal como en entorno gráfico.
|
|
45
46
|
|
|
46
47
|
---
|
|
@@ -93,15 +94,30 @@ Requisitos previos:
|
|
|
93
94
|
|
|
94
95
|
## Uso
|
|
95
96
|
|
|
96
|
-
###
|
|
97
|
+
### CLI interactiva y comandos slash
|
|
97
98
|
|
|
98
|
-
Ejecuta
|
|
99
|
+
Ejecuta la interfaz en la terminal:
|
|
99
100
|
|
|
100
101
|
```bash
|
|
101
102
|
llm-tuner
|
|
102
103
|
```
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
Ejecutar `llm-tuner` sin argumentos abre un menú interactivo para seleccionar la acción deseada:
|
|
106
|
+
- `/load`: Ajustar y cargar un modelo con la configuración óptima para tu hardware
|
|
107
|
+
- `/mcp`: Conectar tu motor local como servidor MCP con agentes de IA (Claude, OpenCode, VS Code, Codex)
|
|
108
|
+
- `/presets`: Listar e inspeccionar los presets guardados
|
|
109
|
+
- `/settings`: Configurar idioma (`en` / `es`) y tema visual (`system` / `light` / `dark`)
|
|
110
|
+
- `/help`: Mostrar ayuda y opciones de uso
|
|
111
|
+
- `/exit`: Salir del menú
|
|
112
|
+
|
|
113
|
+
También puedes ejecutar cualquier comando slash directamente desde tu terminal:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
llm-tuner /load
|
|
117
|
+
llm-tuner /mcp
|
|
118
|
+
llm-tuner /presets
|
|
119
|
+
llm-tuner /settings
|
|
120
|
+
```
|
|
105
121
|
|
|
106
122
|
### Aplicación de escritorio
|
|
107
123
|
|
package/README.md
CHANGED
|
@@ -41,6 +41,7 @@ No LLM decides the configuration. LLM Tuner reads your hardware topology and mod
|
|
|
41
41
|
- **Engine Support**: First-class support for both LM Studio (direct `llama-server` management) and Ollama (isolated private benchmarks).
|
|
42
42
|
- **Persistent Presets**: Measures once per hardware, model, context, and profile. Automatically invalidates presets if hardware or model weights change.
|
|
43
43
|
- **Dual Interfaces**: Interactive terminal wizard (`llm-tuner`) and cross-platform desktop UI (`llm-tuner-desktop`).
|
|
44
|
+
- **AI Agent MCP Integration**: Connect your local engines (LM Studio, Ollama) as Model Context Protocol (MCP) servers to Claude Desktop, Claude Code, OpenCode, Antigravity, VS Code, and ChatGPT/Codex with custom naming and cross-platform safety.
|
|
44
45
|
- **Bilingual**: Full English and Spanish localization for both terminal and desktop interfaces.
|
|
45
46
|
|
|
46
47
|
---
|
|
@@ -93,15 +94,30 @@ Prerequisites:
|
|
|
93
94
|
|
|
94
95
|
## Usage
|
|
95
96
|
|
|
96
|
-
### Interactive
|
|
97
|
+
### Interactive CLI & Slash Commands
|
|
97
98
|
|
|
98
|
-
Launch the terminal
|
|
99
|
+
Launch the terminal interface:
|
|
99
100
|
|
|
100
101
|
```bash
|
|
101
102
|
llm-tuner
|
|
102
103
|
```
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
Running `llm-tuner` without arguments presents an interactive menu to choose a command:
|
|
106
|
+
- `/load`: Tune and load a model with optimal hardware settings
|
|
107
|
+
- `/mcp`: Connect your local engine as an MCP server to AI agents (Claude, OpenCode, VS Code, Codex)
|
|
108
|
+
- `/presets`: Inspect saved tuning presets
|
|
109
|
+
- `/settings`: Configure language (`en` / `es`) and visual theme (`system` / `light` / `dark`)
|
|
110
|
+
- `/help`: Display help and usage options
|
|
111
|
+
- `/exit`: Exit the interactive menu
|
|
112
|
+
|
|
113
|
+
You can also run any slash command directly from your terminal:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
llm-tuner /load
|
|
117
|
+
llm-tuner /mcp
|
|
118
|
+
llm-tuner /presets
|
|
119
|
+
llm-tuner /settings
|
|
120
|
+
```
|
|
105
121
|
|
|
106
122
|
### Desktop App
|
|
107
123
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export class TerminalAnimation {
|
|
2
|
+
constructor(colors, isTTY = true) {
|
|
3
|
+
this.c = colors;
|
|
4
|
+
this.isTTY = Boolean(isTTY);
|
|
5
|
+
this.frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
6
|
+
this.frameIdx = 0;
|
|
7
|
+
this.timer = null;
|
|
8
|
+
this.statusText = '';
|
|
9
|
+
this.pct = 0;
|
|
10
|
+
this.telemetry = null;
|
|
11
|
+
this.isActive = false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
start(initialStatus = '') {
|
|
15
|
+
if (!this.isTTY) return;
|
|
16
|
+
this.statusText = initialStatus;
|
|
17
|
+
this.isActive = true;
|
|
18
|
+
this.timer = setInterval(() => {
|
|
19
|
+
this.tick();
|
|
20
|
+
}, 80);
|
|
21
|
+
this.timer?.unref?.();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
updateStatus(text) {
|
|
25
|
+
this.statusText = text || '';
|
|
26
|
+
this.tick();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
updateProgress(pct) {
|
|
30
|
+
this.pct = Math.min(100, Math.max(0, pct ?? 0));
|
|
31
|
+
this.tick();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
updateTelemetry(telem) {
|
|
35
|
+
this.telemetry = telem;
|
|
36
|
+
this.tick();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
clear() {
|
|
40
|
+
if (this.isTTY && this.isActive) {
|
|
41
|
+
process.stdout.write('\r\x1b[K');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
tick() {
|
|
46
|
+
if (!this.isTTY || !this.isActive) return;
|
|
47
|
+
const frame = this.frames[(this.frameIdx++) % this.frames.length];
|
|
48
|
+
|
|
49
|
+
const barLen = 16;
|
|
50
|
+
const filled = Math.round((this.pct / 100) * barLen);
|
|
51
|
+
const bar = this.c.cyan('━'.repeat(filled)) + this.c.dim('─'.repeat(barLen - filled));
|
|
52
|
+
|
|
53
|
+
let telemStr = '';
|
|
54
|
+
if (this.telemetry) {
|
|
55
|
+
if (this.telemetry.cpu) {
|
|
56
|
+
telemStr += ` · CPU ${this.telemetry.cpu.percent}%`;
|
|
57
|
+
}
|
|
58
|
+
if (this.telemetry.ram) {
|
|
59
|
+
const used = (this.telemetry.ram.usedBytes / 1024 ** 3).toFixed(1);
|
|
60
|
+
const total = (this.telemetry.ram.totalBytes / 1024 ** 3).toFixed(1);
|
|
61
|
+
telemStr += ` · RAM ${used}/${total} GB`;
|
|
62
|
+
}
|
|
63
|
+
if (this.telemetry.gpus?.length) {
|
|
64
|
+
telemStr += ' · ' + this.telemetry.gpus.map((g) => {
|
|
65
|
+
const used = (g.usedBytes / 1024 ** 3).toFixed(1);
|
|
66
|
+
const total = (g.totalBytes / 1024 ** 3).toFixed(1);
|
|
67
|
+
return `GPU${g.index} ${used}/${total} GB`;
|
|
68
|
+
}).join(' ');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const statusPart = this.statusText ? `${this.statusText} ` : '';
|
|
73
|
+
const line = ` ${this.c.cyan(frame)} ${statusPart}[${bar}] ${String(this.pct).padStart(3)}%${this.c.dim(telemStr)}`;
|
|
74
|
+
process.stdout.write(`\r\x1b[K${line}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
stop() {
|
|
78
|
+
if (this.timer) {
|
|
79
|
+
clearInterval(this.timer);
|
|
80
|
+
this.timer = null;
|
|
81
|
+
}
|
|
82
|
+
this.clear();
|
|
83
|
+
this.isActive = false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function getColors(theme = 'dark') {
|
|
2
|
+
const isLight = theme === 'light';
|
|
3
|
+
return {
|
|
4
|
+
b: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
5
|
+
dim: (s) => isLight ? `\x1b[90m${s}\x1b[0m` : `\x1b[2m${s}\x1b[0m`,
|
|
6
|
+
g: (s) => isLight ? `\x1b[38;2;40;138;0m${s}\x1b[0m` : `\x1b[38;2;101;255;0m${s}\x1b[0m`,
|
|
7
|
+
y: (s) => isLight ? `\x1b[33m${s}\x1b[0m` : `\x1b[93m${s}\x1b[0m`,
|
|
8
|
+
r: (s) => isLight ? `\x1b[38;2;214;21;0m${s}\x1b[0m` : `\x1b[38;2;255;26;0m${s}\x1b[0m`,
|
|
9
|
+
cyan: (s) => isLight ? `\x1b[38;2;0;124;153m${s}\x1b[0m` : `\x1b[38;2;0;229;255m${s}\x1b[0m`,
|
|
10
|
+
purple: (s) => isLight ? `\x1b[38;2;120;0;199m${s}\x1b[0m` : `\x1b[38;2;154;0;255m${s}\x1b[0m`,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { presetsDir } from '../../core/index.js';
|
|
2
|
+
import { t as translate } from '../../i18n/index.js';
|
|
3
|
+
|
|
4
|
+
export async function helpCommand(args, { lang }) {
|
|
5
|
+
const t = (key, params) => translate(lang, key, params);
|
|
6
|
+
console.log(t('cli.help', { presetsDir: presetsDir() }));
|
|
7
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { select, input, confirm, search, Separator } from '@inquirer/prompts';
|
|
2
|
+
import {
|
|
3
|
+
detectHardware,
|
|
4
|
+
detectEngines,
|
|
5
|
+
Tuner,
|
|
6
|
+
installPlans,
|
|
7
|
+
runInstall,
|
|
8
|
+
listPresets,
|
|
9
|
+
fmtBytes,
|
|
10
|
+
readSettings,
|
|
11
|
+
writeSettings,
|
|
12
|
+
DEFAULT_PROFILE,
|
|
13
|
+
TunerError,
|
|
14
|
+
checkUpdate,
|
|
15
|
+
} from '../../core/index.js';
|
|
16
|
+
import { t as translate } from '../../i18n/index.js';
|
|
17
|
+
import { profileChoices, profileNotes, profileSwitchNote, profileEventLines, shouldAskProfile, parseContext } from '../profile.js';
|
|
18
|
+
import { getColors } from '../colors.js';
|
|
19
|
+
import { withNav, NAV_BACK, backChoice } from '../nav.js';
|
|
20
|
+
import { TerminalAnimation } from '../animation.js';
|
|
21
|
+
|
|
22
|
+
export async function loadCommand(args, { lang, theme }) {
|
|
23
|
+
const c = getColors(theme);
|
|
24
|
+
const t = (key, params) => translate(lang, key, params);
|
|
25
|
+
|
|
26
|
+
const describe = (cand) => {
|
|
27
|
+
const where = cand.fullOffload ? t('candidate.allGpu') : t('candidate.layersGpu', { gpu: cand.gpuLayers, total: cand.totalLayers });
|
|
28
|
+
return `KV ${cand.kvType} · ${where}${cand.cpuMoeLayers ? t('candidate.expertsRam', { layers: cand.cpuMoeLayers }) : ''}${t('candidate.threads', { n: cand.threads })}${cand.probe ? t('candidate.probe') : ''}`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const benchErrorText = (b) => {
|
|
32
|
+
return b.errorCode ? t(b.errorCode, b.errorParams) : b.error;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const vramStr = (peaks = {}) => {
|
|
36
|
+
const e = Object.entries(peaks || {});
|
|
37
|
+
return e.length ? e.map(([i, b]) => `GPU${i} ${(b / 1024 ** 3).toFixed(1)} GB`).join(' / ') : t('common.na');
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const updatePromise = checkUpdate().catch(() => null);
|
|
41
|
+
const isTTY = Boolean(process.stdout.isTTY && !args.json);
|
|
42
|
+
const anim = new TerminalAnimation(c, isTTY);
|
|
43
|
+
|
|
44
|
+
const out = (...a) => {
|
|
45
|
+
if (args.json) return;
|
|
46
|
+
anim.clear();
|
|
47
|
+
console.log(...a);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
out([
|
|
51
|
+
'',
|
|
52
|
+
` ${c.cyan(' ╭───')}${c.dim('╷ ╷ ╷')}${c.cyan('───╮')}`,
|
|
53
|
+
` ${c.cyan('╭─┤ ')}${c.cyan('┌───┐')}${c.cyan(' ├─╮ ')}${c.b('LLM Tuner')}`,
|
|
54
|
+
` ${c.cyan('│ ')}${c.dim('╶┤')}${c.b(' │ █ │ ')}${c.dim('├╴')}${c.cyan('▲ │ ')}${c.dim(t('web.tagline'))}`,
|
|
55
|
+
` ${c.cyan('╰─┤ ')}${c.cyan('└───┘')}${c.cyan(' ├──╯')}`,
|
|
56
|
+
` ${c.cyan(' ╰───')}${c.dim('╵ ╵ ╵')}${c.cyan('───╯')}`,
|
|
57
|
+
'',
|
|
58
|
+
].join('\n'));
|
|
59
|
+
|
|
60
|
+
const updateInfo = await updatePromise;
|
|
61
|
+
if (updateInfo?.updateAvailable) {
|
|
62
|
+
out(c.y(` ▲ ${t('cli.updateAvailable', { current: updateInfo.currentVersion, latest: updateInfo.latestVersion })}`));
|
|
63
|
+
out(c.dim(` ${t('cli.updateCommand', { cmd: 'npm install -g llm-tuner' })}\n`));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const hw = await detectHardware();
|
|
67
|
+
out(c.dim(` ${hw.cpu.brand} · ${fmtBytes(hw.ram.totalBytes)} RAM`));
|
|
68
|
+
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') : ''}`));
|
|
69
|
+
if (!hw.gpus.length) out(c.y(` ${t('cli.noGpu')}`));
|
|
70
|
+
|
|
71
|
+
let det = await detectEngines(hw);
|
|
72
|
+
let engineId = args.engine;
|
|
73
|
+
let tuner = null;
|
|
74
|
+
let models = [];
|
|
75
|
+
let modelKey = args.model;
|
|
76
|
+
let ctx = parseContext(args.ctx);
|
|
77
|
+
let profile = args.profile;
|
|
78
|
+
|
|
79
|
+
let currentStep = 1;
|
|
80
|
+
|
|
81
|
+
while (currentStep >= 1 && currentStep <= 4) {
|
|
82
|
+
if (currentStep === 1) {
|
|
83
|
+
if (args.engine) {
|
|
84
|
+
engineId = args.engine;
|
|
85
|
+
currentStep = 2;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const res = await withNav(() => select({
|
|
90
|
+
message: t('cli.whichEngine'),
|
|
91
|
+
default: det.recommended,
|
|
92
|
+
choices: [
|
|
93
|
+
...det.engines.map((e) => ({
|
|
94
|
+
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')}`) : ''}`,
|
|
95
|
+
value: e.id,
|
|
96
|
+
})),
|
|
97
|
+
new Separator(),
|
|
98
|
+
backChoice(t, c),
|
|
99
|
+
],
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
if (res === NAV_BACK) return;
|
|
103
|
+
engineId = res;
|
|
104
|
+
currentStep = 2;
|
|
105
|
+
}
|
|
106
|
+
else if (currentStep === 2) {
|
|
107
|
+
if (!tuner || tuner.engine.id !== engineId) {
|
|
108
|
+
if (!det.engines.find((e) => e.id === engineId)?.detection.installed) {
|
|
109
|
+
await installFlow(engineId, { args, t, c });
|
|
110
|
+
det = await detectEngines(hw);
|
|
111
|
+
if (!det.engines.find((e) => e.id === engineId).detection.installed) throw new TunerError('errors.installNotFinished');
|
|
112
|
+
}
|
|
113
|
+
tuner = await Tuner.create(engineId);
|
|
114
|
+
models = await tuner.listModels();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (args.model) {
|
|
118
|
+
modelKey = args.model;
|
|
119
|
+
currentStep = 3;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!models.length) {
|
|
124
|
+
out(c.y(`\n${t('cli.noModels', { engine: tuner.engine.name })}`));
|
|
125
|
+
const name = await input({ message: engineId === 'ollama' ? t('cli.downloadOllama') : t('cli.downloadLms') });
|
|
126
|
+
const bin = tuner.ctx.detection.bin;
|
|
127
|
+
const r = await runInstall(engineId === 'ollama' ? { cmd: bin, args: ['pull', name] } : { cmd: bin, args: ['get', name, '-y'] }, (l) => out(c.dim(' ' + l)));
|
|
128
|
+
if (r.code !== 0) throw new TunerError('errors.modelDownloadFailed');
|
|
129
|
+
models = await tuner.listModels();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const res = await withNav(() => search({
|
|
133
|
+
message: t('cli.model'),
|
|
134
|
+
source: (term) => [
|
|
135
|
+
backChoice(t, c),
|
|
136
|
+
new Separator(),
|
|
137
|
+
...models
|
|
138
|
+
.filter((m) => !term || `${m.key} ${m.name}`.toLowerCase().includes(term.toLowerCase()))
|
|
139
|
+
.map((m) => ({ name: `${m.name} ${c.dim(`${fmtBytes(m.sizeBytes)}${m.quant ? ' · ' + m.quant : ''}`)}`, value: m.key })),
|
|
140
|
+
],
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
if (res === NAV_BACK) {
|
|
144
|
+
if (args.engine) return;
|
|
145
|
+
currentStep = 1;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
modelKey = res;
|
|
150
|
+
currentStep = 3;
|
|
151
|
+
}
|
|
152
|
+
else if (currentStep === 3) {
|
|
153
|
+
if (args.ctx) {
|
|
154
|
+
ctx = parseContext(args.ctx);
|
|
155
|
+
currentStep = 4;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const presets = await listPresets({ engine: engineId, modelKey });
|
|
160
|
+
const preview = await tuner.plan(modelKey, 8192);
|
|
161
|
+
const limit = preview.model.meta.trainContext || 131072;
|
|
162
|
+
const list = Object.entries(preview.maxContext).map(([k, v]) => `KV ${k} ${v ? Math.floor(v / 1024) + 'K' : '—'}`).join(' · ');
|
|
163
|
+
out(c.dim(` ${t('cli.maxFullGpu', { list, train: Math.floor(limit / 1024) })}`));
|
|
164
|
+
if (presets.length) out(c.dim(` ${t('cli.tunedContexts', { list: presets.map((p) => `${p.ctx / 1024}K (${p.shortTps} t/s)`).join(', ') })}`));
|
|
165
|
+
|
|
166
|
+
let inputBuffer = '';
|
|
167
|
+
const res = await withNav(() => input({
|
|
168
|
+
message: `${t('cli.context')} ${c.dim(`(Backspace: ${t('common.back')})`)}`,
|
|
169
|
+
default: String(presets.at(-1)?.ctx || Math.min(16384, limit)),
|
|
170
|
+
validate: (v) => {
|
|
171
|
+
inputBuffer = v;
|
|
172
|
+
if (v.toLowerCase() === 'b' || v.toLowerCase() === 'back') return true;
|
|
173
|
+
return parseContext(v, limit) !== null || t('cli.contextRange', { limit });
|
|
174
|
+
},
|
|
175
|
+
}), { isTextInput: true, getInputValue: () => inputBuffer });
|
|
176
|
+
|
|
177
|
+
if (res === NAV_BACK || res?.toLowerCase?.() === 'b' || res?.toLowerCase?.() === 'back') {
|
|
178
|
+
if (args.model) {
|
|
179
|
+
if (args.engine) return;
|
|
180
|
+
currentStep = 1;
|
|
181
|
+
} else {
|
|
182
|
+
currentStep = 2;
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
ctx = parseContext(res, limit);
|
|
188
|
+
currentStep = 4;
|
|
189
|
+
}
|
|
190
|
+
else if (currentStep === 4) {
|
|
191
|
+
if (args.profile || !shouldAskProfile(args)) {
|
|
192
|
+
profile = args.profile || DEFAULT_PROFILE;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const plan = await tuner.profilePlan(modelKey, ctx);
|
|
197
|
+
const { profile: stored } = await readSettings();
|
|
198
|
+
for (const line of profileNotes(plan, t)) {
|
|
199
|
+
out((line.level === 'warn' ? c.y : c.dim)(` ${line.text}`));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const choicesObj = profileChoices(plan, t, { stored, mark: c.y, dim: c.dim });
|
|
203
|
+
const res = await withNav(() => select({
|
|
204
|
+
message: t('cli.profile'),
|
|
205
|
+
...choicesObj,
|
|
206
|
+
choices: [
|
|
207
|
+
...choicesObj.choices,
|
|
208
|
+
new Separator(),
|
|
209
|
+
backChoice(t, c),
|
|
210
|
+
],
|
|
211
|
+
}));
|
|
212
|
+
|
|
213
|
+
if (res === NAV_BACK) {
|
|
214
|
+
if (args.ctx) {
|
|
215
|
+
if (args.model) {
|
|
216
|
+
if (args.engine) return;
|
|
217
|
+
currentStep = 1;
|
|
218
|
+
} else {
|
|
219
|
+
currentStep = 2;
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
currentStep = 3;
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
profile = res;
|
|
228
|
+
try {
|
|
229
|
+
await writeSettings({ profile });
|
|
230
|
+
} catch (e) {
|
|
231
|
+
console.error(c.y(t('cli.profileNotSaved', { error: e.message })));
|
|
232
|
+
}
|
|
233
|
+
const switchNote = profileSwitchNote(plan, profile, t, tuner.engine.name);
|
|
234
|
+
if (switchNote) out(c.dim(` ${switchNote}`));
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 5. Load & Benchmark Execution with Desktop-style live animation
|
|
240
|
+
if (!tuner) {
|
|
241
|
+
tuner = await Tuner.create(engineId);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const advisedByWizard = !args.profile;
|
|
245
|
+
let previous = null;
|
|
246
|
+
|
|
247
|
+
anim.start(t('status.loading'));
|
|
248
|
+
|
|
249
|
+
tuner.on('progress', (e) => {
|
|
250
|
+
if (args.json) return;
|
|
251
|
+
|
|
252
|
+
if (e.type === 'telemetry') {
|
|
253
|
+
anim.updateTelemetry(e);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (e.progress) {
|
|
257
|
+
anim.updateProgress(e.progress.pct);
|
|
258
|
+
}
|
|
259
|
+
if (e.type === 'variant-picked' || e.type === 'profile-fallback') {
|
|
260
|
+
for (const line of profileEventLines(e, previous, t, advisedByWizard ? null : tuner.engine.name)) {
|
|
261
|
+
out((line.level === 'warn' ? c.y : c.dim)(` ${line.text}`));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
previous = e;
|
|
265
|
+
if (e.type === 'status') {
|
|
266
|
+
anim.updateStatus(t(e.code, e.params));
|
|
267
|
+
out(c.dim(`\n${t(e.code, e.params)}`));
|
|
268
|
+
}
|
|
269
|
+
if (e.type === 'preset-hit') {
|
|
270
|
+
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 })}`));
|
|
271
|
+
}
|
|
272
|
+
if (e.type === 'candidate-start') {
|
|
273
|
+
anim.updateStatus(`[${e.index + 1}/${e.total}] ${describe(e.candidate)}`);
|
|
274
|
+
out(` [${e.index + 1}/${e.total}] ${describe(e.candidate)}`);
|
|
275
|
+
}
|
|
276
|
+
if (e.type === 'bench-progress' && e.phase === 'deep') {
|
|
277
|
+
out(c.dim(` ${t('common.longPrompt', { tokens: e.promptTokens })}`));
|
|
278
|
+
}
|
|
279
|
+
if (e.type === 'candidate-done') {
|
|
280
|
+
const b = e.bench;
|
|
281
|
+
out(b.ok
|
|
282
|
+
? ` ${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)}`
|
|
283
|
+
: c.r(` ✗ ${benchErrorText(b)}`));
|
|
284
|
+
}
|
|
285
|
+
if (e.type === 'preset-saved') {
|
|
286
|
+
out(c.g(`\n✓ ${t('common.presetSaved', { file: e.file })}`));
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
let result;
|
|
291
|
+
try {
|
|
292
|
+
result = await tuner.load(modelKey, ctx, {
|
|
293
|
+
profile,
|
|
294
|
+
force: !!args.force,
|
|
295
|
+
dryRun: !!args['dry-run'],
|
|
296
|
+
maxCandidates: Number(args.candidates) || 3,
|
|
297
|
+
confirmApply: async (preview) => {
|
|
298
|
+
anim.stop();
|
|
299
|
+
out(c.b(`\n${t('cli.changes')}`));
|
|
300
|
+
if (engineId === 'lmstudio') {
|
|
301
|
+
preview.files.forEach((f) => out(` ${f}`));
|
|
302
|
+
if (preview.restartsApp) out(c.y(` ${t('cli.lmsRestart')}`));
|
|
303
|
+
} else {
|
|
304
|
+
out(` ${t('cli.ollamaNewModel', { name: c.b(preview.tunedModel) })}`);
|
|
305
|
+
out(` ${t('cli.ollamaServer', { env: Object.entries(preview.env).map(([k, v]) => `${k}=${v}`).join(' ') })}`);
|
|
306
|
+
}
|
|
307
|
+
const confirmed = args.yes || await confirm({ message: t('cli.confirmApply'), default: true });
|
|
308
|
+
if (confirmed) anim.start(t('status.applying'));
|
|
309
|
+
return confirmed;
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
} finally {
|
|
313
|
+
anim.stop();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (args['dry-run']) out(c.y(`\n${t('cli.dryRun')}`));
|
|
317
|
+
if (result.applied?.backups?.length) out(c.dim(` ${t('common.backups', { list: result.applied.backups.join(', ') })}`));
|
|
318
|
+
if (result.applied?.pendingCommands?.length) {
|
|
319
|
+
out(c.y(`\n${t('cli.pending')}`));
|
|
320
|
+
result.applied.pendingCommands.forEach((cmd) => out(` ${cmd}`));
|
|
321
|
+
}
|
|
322
|
+
const l = result.loaded;
|
|
323
|
+
if (l) out(l.ok
|
|
324
|
+
? 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 }) : ''}`)
|
|
325
|
+
: c.r(`\n✗ ${t('cli.loadFailed', { error: benchErrorText(l) })}`));
|
|
326
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function installFlow(engineId, { args, t, c }) {
|
|
330
|
+
const plans = await installPlans(engineId);
|
|
331
|
+
if (!plans.length) throw new TunerError('errors.noInstaller');
|
|
332
|
+
const plan = plans.length === 1 ? plans[0] : await select({ message: t('cli.installMethod'), choices: plans.map((p) => ({ name: t(p.labelCode), value: p })) });
|
|
333
|
+
console.log(c.dim(` ${plan.shell || (plan.cmd ? [plan.cmd, ...plan.args].join(' ') : plan.url)}`));
|
|
334
|
+
if (!args.yes && !(await confirm({ message: t('cli.installNow'), default: true }))) process.exit(0);
|
|
335
|
+
const r = await runInstall(plan, (l) => console.log(c.dim(' ' + l)));
|
|
336
|
+
if (r.manual) await input({ message: t('cli.installManual') });
|
|
337
|
+
else if (r.code !== 0) throw new TunerError('errors.installerExit', { code: r.code });
|
|
338
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { select, checkbox, input, Separator } from '@inquirer/prompts';
|
|
2
|
+
import { detectHardware, detectEngines, detectAgents, installMcpServer } 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 mcpCommand(args, { lang, theme }) {
|
|
8
|
+
const c = getColors(theme);
|
|
9
|
+
const t = (key, params) => translate(lang, key, params);
|
|
10
|
+
|
|
11
|
+
let step = 1;
|
|
12
|
+
let engineId = args.engine;
|
|
13
|
+
let selectedAgents = [];
|
|
14
|
+
let customName = args.name;
|
|
15
|
+
let agents = null;
|
|
16
|
+
|
|
17
|
+
while (step >= 1 && step <= 3) {
|
|
18
|
+
if (step === 1) {
|
|
19
|
+
if (args.engine) {
|
|
20
|
+
step = 2;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const hw = await detectHardware();
|
|
24
|
+
const det = await detectEngines(hw);
|
|
25
|
+
const res = await withNav(() => select({
|
|
26
|
+
message: t('cli.mcpSelectEngine'),
|
|
27
|
+
default: det.recommended || 'lmstudio',
|
|
28
|
+
choices: [
|
|
29
|
+
{ name: 'LM Studio', value: 'lmstudio' },
|
|
30
|
+
{ name: 'Ollama', value: 'ollama' },
|
|
31
|
+
new Separator(),
|
|
32
|
+
backChoice(t, c),
|
|
33
|
+
],
|
|
34
|
+
}));
|
|
35
|
+
if (res === NAV_BACK) return;
|
|
36
|
+
engineId = res;
|
|
37
|
+
step = 2;
|
|
38
|
+
}
|
|
39
|
+
else if (step === 2) {
|
|
40
|
+
if (!agents) {
|
|
41
|
+
agents = await detectAgents();
|
|
42
|
+
}
|
|
43
|
+
const res = await withNav(() => checkbox({
|
|
44
|
+
message: `${t('cli.mcpSelectAgents')} ${c.dim(`(Backspace: ${t('common.back')})`)}`,
|
|
45
|
+
choices: agents.map((agent) => {
|
|
46
|
+
const isConfigured = agent.configuredServers?.includes(engineId);
|
|
47
|
+
let statusStr = agent.detected ? c.g(`[${t('cli.mcpStatusDetected')}]`) : c.dim(`[${t('cli.mcpStatusAvailable')}]`);
|
|
48
|
+
if (isConfigured) {
|
|
49
|
+
statusStr += ` ${c.cyan(`(${t('web.mcpAlreadyConfigured')})`)}`;
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
name: `${agent.name.padEnd(18)} ${statusStr}`,
|
|
53
|
+
value: agent.id,
|
|
54
|
+
checked: agent.detected,
|
|
55
|
+
};
|
|
56
|
+
}),
|
|
57
|
+
}));
|
|
58
|
+
if (res === NAV_BACK) {
|
|
59
|
+
if (args.engine) return;
|
|
60
|
+
step = 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!res.length) {
|
|
64
|
+
console.log(c.y(t('cli.mcpNoAgentsSelected')));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
selectedAgents = res;
|
|
68
|
+
step = 3;
|
|
69
|
+
}
|
|
70
|
+
else if (step === 3) {
|
|
71
|
+
if (args.name) {
|
|
72
|
+
customName = args.name;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
let buffer = '';
|
|
76
|
+
const res = await withNav(() => input({
|
|
77
|
+
message: `${t('cli.mcpServerNamePrompt')} ${c.dim(`(Backspace: ${t('common.back')})`)}`,
|
|
78
|
+
default: engineId,
|
|
79
|
+
validate: (val) => {
|
|
80
|
+
buffer = val;
|
|
81
|
+
if (val.toLowerCase() === 'b' || val.toLowerCase() === 'back') return true;
|
|
82
|
+
return Boolean(val.trim()) || 'Server name cannot be empty';
|
|
83
|
+
},
|
|
84
|
+
}), { isTextInput: true, getInputValue: () => buffer });
|
|
85
|
+
|
|
86
|
+
if (res === NAV_BACK || res?.toLowerCase?.() === 'b' || res?.toLowerCase?.() === 'back') {
|
|
87
|
+
step = 2;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
customName = res.trim();
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Install MCP server configuration
|
|
96
|
+
const result = await installMcpServer({
|
|
97
|
+
engineId,
|
|
98
|
+
customName,
|
|
99
|
+
agentIds: selectedAgents,
|
|
100
|
+
host: args.host,
|
|
101
|
+
apiKey: args.apiKey,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Report results
|
|
105
|
+
if (result.updated.length) {
|
|
106
|
+
const updatedNames = result.updated
|
|
107
|
+
.map((id) => agents?.find((a) => a.id === id)?.name || id)
|
|
108
|
+
.join(', ');
|
|
109
|
+
console.log(c.g(`\n✓ ${t('cli.mcpSuccess', { server: customName, agents: updatedNames })}`));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (result.errors?.length) {
|
|
113
|
+
const errDetails = result.errors
|
|
114
|
+
.map((e) => `${agents?.find((a) => a.id === e.agentId)?.name || e.agentId}: ${e.error}`)
|
|
115
|
+
.join(', ');
|
|
116
|
+
console.error(c.r(`\n✗ ${t('cli.mcpFailed', { agents: errDetails })}`));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { listPresets } from '../../core/index.js';
|
|
2
|
+
import { t as translate } from '../../i18n/index.js';
|
|
3
|
+
import { getColors } from '../colors.js';
|
|
4
|
+
|
|
5
|
+
export async function presetsCommand(args, { lang, theme }) {
|
|
6
|
+
const c = getColors(theme);
|
|
7
|
+
const t = (key, params) => translate(lang, key, params);
|
|
8
|
+
|
|
9
|
+
const list = await listPresets({ engine: args.engine, modelKey: args.model });
|
|
10
|
+
if (!list.length) {
|
|
11
|
+
console.log(t('cli.noPresets'));
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
for (const p of list) {
|
|
16
|
+
const profileStr = p.profile ? ` ${p.profile}${p.variant && p.variant !== p.model ? ` (${p.variant})` : ''}` : '';
|
|
17
|
+
const offloadStr = p.fullOffload ? t('candidate.allGpu') : t('cli.partial');
|
|
18
|
+
const dateStr = c.dim(p.createdAt.slice(0, 16).replace('T', ' '));
|
|
19
|
+
console.log(`${p.engine.padEnd(9)} ${p.model} ${p.ctx / 1024}K${profileStr} KV ${p.kvType} ${offloadStr} ${p.shortTps} t/s ${dateStr}`);
|
|
20
|
+
}
|
|
21
|
+
}
|