@iola_adm/iola-cli 0.1.5 → 0.1.7
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.md +59 -0
- package/package.json +1 -1
- package/src/cli.js +396 -8
package/README.md
CHANGED
|
@@ -78,6 +78,11 @@ iola banner
|
|
|
78
78
|
iola agent
|
|
79
79
|
iola ai doctor
|
|
80
80
|
iola ai setup ollama
|
|
81
|
+
iola ai ask "Какие школы есть на улице Петрова?"
|
|
82
|
+
iola ai key set openai
|
|
83
|
+
iola ai key status
|
|
84
|
+
iola ai setup openai --model gpt-4.1-mini
|
|
85
|
+
iola ai setup openrouter --model openai/gpt-4.1-mini
|
|
81
86
|
iola health
|
|
82
87
|
iola layers
|
|
83
88
|
iola schools --limit 10
|
|
@@ -110,9 +115,63 @@ iola agent
|
|
|
110
115
|
/search лицей --limit 3
|
|
111
116
|
/mcp-info
|
|
112
117
|
/ai doctor
|
|
118
|
+
/model
|
|
119
|
+
/provider
|
|
120
|
+
/config
|
|
121
|
+
/history
|
|
122
|
+
/clear
|
|
113
123
|
/exit
|
|
114
124
|
```
|
|
115
125
|
|
|
126
|
+
Обычный текст без `/` в `iola agent` отправляется в настроенный AI-провайдер.
|
|
127
|
+
|
|
128
|
+
## AI-запросы
|
|
129
|
+
|
|
130
|
+
Локальная модель через Ollama:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
iola ai setup ollama
|
|
134
|
+
iola ai ask "Какие школы есть на улице Петрова?"
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
OpenAI:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
iola ai key set openai
|
|
141
|
+
iola ai setup openai --model gpt-4.1-mini
|
|
142
|
+
iola ai ask "Найди школу 29"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
OpenRouter:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
iola ai key set openrouter
|
|
149
|
+
iola ai setup openrouter --model openai/gpt-4.1-mini
|
|
150
|
+
iola ai ask "Покажи контакты лицея"
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Ключи OpenAI/OpenRouter сохраняются локально на компьютере пользователя:
|
|
154
|
+
|
|
155
|
+
```text
|
|
156
|
+
%USERPROFILE%\.iola\secrets.json
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Управление ключами:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
iola ai key set openai
|
|
163
|
+
iola ai key set openrouter
|
|
164
|
+
iola ai key status
|
|
165
|
+
iola ai key delete openai
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Если одновременно задана переменная окружения (`OPENAI_API_KEY` или
|
|
169
|
+
`OPENROUTER_API_KEY`) и сохранен локальный ключ, CLI использует переменную
|
|
170
|
+
окружения как более приоритетную.
|
|
171
|
+
|
|
172
|
+
AI-ответ строится по контексту из публичного API. Если данных в контексте
|
|
173
|
+
недостаточно, ассистент должен сообщить об этом, а не выдумывать сведения.
|
|
174
|
+
|
|
116
175
|
## Назначение
|
|
117
176
|
|
|
118
177
|
Первый релиз CLI дает прямой терминальный доступ к открытым данным и командам
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import readline from "node:readline/promises";
|
|
@@ -9,6 +9,14 @@ const API_BASE_URL = process.env.IOLA_API_BASE_URL || "https://apiiola.yasg.ru/a
|
|
|
9
9
|
const MCP_BASE_URL = process.env.IOLA_MCP_BASE_URL || "https://apiiola.yasg.ru";
|
|
10
10
|
const CONFIG_DIR = path.join(os.homedir(), ".iola");
|
|
11
11
|
const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
12
|
+
const SECRETS_FILE = path.join(CONFIG_DIR, "secrets.json");
|
|
13
|
+
const DEFAULT_AI_CONFIG = {
|
|
14
|
+
ai: {
|
|
15
|
+
provider: "ollama",
|
|
16
|
+
model: "llama3.2:1b",
|
|
17
|
+
baseUrl: "http://127.0.0.1:11434",
|
|
18
|
+
},
|
|
19
|
+
};
|
|
12
20
|
const BANNER = `\x1b[38;5;45m┌────────────────────────────────────────────────────────────────────────────┐
|
|
13
21
|
│\x1b[38;5;51m ____ _ ___ ____ ____ ___ _____ _ _______ \x1b[38;5;45m│
|
|
14
22
|
│\x1b[38;5;51m / ___| | |_ _| | _ \\| _ \\ / _ \\| ____| |/ /_ _| \x1b[38;5;45m│
|
|
@@ -56,6 +64,11 @@ async function showHelp() {
|
|
|
56
64
|
Usage:
|
|
57
65
|
iola banner
|
|
58
66
|
iola agent
|
|
67
|
+
iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
|
|
68
|
+
iola ai key set openai
|
|
69
|
+
iola ai key set openrouter
|
|
70
|
+
iola ai key status
|
|
71
|
+
iola ai key delete openai|openrouter
|
|
59
72
|
iola ai doctor [--json]
|
|
60
73
|
iola ai setup
|
|
61
74
|
iola ai setup ollama [--yes] [--model MODEL]
|
|
@@ -81,6 +94,9 @@ async function startAgent() {
|
|
|
81
94
|
console.log("Интерактивный режим. Введите /help для списка команд, /exit для выхода.");
|
|
82
95
|
|
|
83
96
|
const rl = readline.createInterface({ input, output, prompt: "iola> " });
|
|
97
|
+
const state = {
|
|
98
|
+
history: [],
|
|
99
|
+
};
|
|
84
100
|
let closed = false;
|
|
85
101
|
rl.on("close", () => {
|
|
86
102
|
closed = true;
|
|
@@ -96,7 +112,7 @@ async function startAgent() {
|
|
|
96
112
|
}
|
|
97
113
|
|
|
98
114
|
try {
|
|
99
|
-
const shouldExit = await handleAgentLine(line);
|
|
115
|
+
const shouldExit = await handleAgentLine(line, state);
|
|
100
116
|
if (shouldExit) {
|
|
101
117
|
break;
|
|
102
118
|
}
|
|
@@ -112,9 +128,11 @@ async function startAgent() {
|
|
|
112
128
|
}
|
|
113
129
|
}
|
|
114
130
|
|
|
115
|
-
async function handleAgentLine(line) {
|
|
131
|
+
async function handleAgentLine(line, state) {
|
|
116
132
|
if (!line.startsWith("/")) {
|
|
117
|
-
|
|
133
|
+
const answer = await aiAsk([line], { history: state.history });
|
|
134
|
+
state.history.push({ role: "user", content: line });
|
|
135
|
+
state.history.push({ role: "assistant", content: answer });
|
|
118
136
|
return false;
|
|
119
137
|
}
|
|
120
138
|
|
|
@@ -129,6 +147,32 @@ async function handleAgentLine(line) {
|
|
|
129
147
|
return false;
|
|
130
148
|
}
|
|
131
149
|
|
|
150
|
+
if (command === "clear") {
|
|
151
|
+
state.history = [];
|
|
152
|
+
console.log("История agent-сессии очищена.");
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (command === "history") {
|
|
157
|
+
printAgentHistory(state.history);
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (command === "config") {
|
|
162
|
+
await printAiConfig();
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (command === "provider") {
|
|
167
|
+
await printAiConfigField("provider");
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (command === "model") {
|
|
172
|
+
await printAiConfigField("model");
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
132
176
|
if (command === "banner") {
|
|
133
177
|
showBanner();
|
|
134
178
|
return false;
|
|
@@ -173,8 +217,26 @@ function printAgentHelp() {
|
|
|
173
217
|
/mcp-info
|
|
174
218
|
/ai doctor
|
|
175
219
|
/ai setup ollama
|
|
220
|
+
/config
|
|
221
|
+
/provider
|
|
222
|
+
/model
|
|
223
|
+
/history
|
|
224
|
+
/clear
|
|
176
225
|
/banner
|
|
177
|
-
/exit
|
|
226
|
+
/exit
|
|
227
|
+
|
|
228
|
+
Обычный текст без slash-команды отправляется в настроенный AI-провайдер.`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function printAgentHistory(history) {
|
|
232
|
+
if (history.length === 0) {
|
|
233
|
+
console.log("История пуста.");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const item of history.slice(-10)) {
|
|
238
|
+
console.log(`${item.role}: ${item.content}`);
|
|
239
|
+
}
|
|
178
240
|
}
|
|
179
241
|
|
|
180
242
|
function safePrompt(rl, closed = false) {
|
|
@@ -227,14 +289,31 @@ async function handleAi(args) {
|
|
|
227
289
|
if (subcommand === "help") {
|
|
228
290
|
showBanner();
|
|
229
291
|
console.log(`AI-команды:
|
|
292
|
+
iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
|
|
293
|
+
iola ai key set openai
|
|
294
|
+
iola ai key set openrouter
|
|
295
|
+
iola ai key status
|
|
296
|
+
iola ai key delete openai|openrouter
|
|
230
297
|
iola ai doctor [--json]
|
|
231
298
|
iola ai setup
|
|
232
299
|
iola ai setup ollama [--yes] [--model MODEL]
|
|
300
|
+
iola ai setup openai [--model MODEL]
|
|
301
|
+
iola ai setup openrouter [--model MODEL]
|
|
233
302
|
|
|
234
303
|
Локальная настройка сохраняется в ${CONFIG_FILE}`);
|
|
235
304
|
return;
|
|
236
305
|
}
|
|
237
306
|
|
|
307
|
+
if (subcommand === "ask") {
|
|
308
|
+
await aiAsk(rest);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (subcommand === "key") {
|
|
313
|
+
await handleAiKey(rest);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
238
317
|
if (subcommand === "doctor") {
|
|
239
318
|
await aiDoctor(rest);
|
|
240
319
|
return;
|
|
@@ -277,8 +356,17 @@ async function aiSetup(args) {
|
|
|
277
356
|
}
|
|
278
357
|
|
|
279
358
|
if (provider === "openai" || provider === "openrouter") {
|
|
280
|
-
|
|
281
|
-
|
|
359
|
+
const options = parseOptions(args.slice(1));
|
|
360
|
+
const model = options.model || (provider === "openai" ? "gpt-4.1-mini" : "openai/gpt-4.1-mini");
|
|
361
|
+
await saveConfig({
|
|
362
|
+
ai: {
|
|
363
|
+
provider,
|
|
364
|
+
model,
|
|
365
|
+
baseUrl: provider === "openai" ? "https://api.openai.com/v1" : "https://openrouter.ai/api/v1",
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
console.log(`AI-профиль ${provider} сохранен в ${CONFIG_FILE}`);
|
|
369
|
+
console.log(`Ключ задайте через переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
|
|
282
370
|
return;
|
|
283
371
|
}
|
|
284
372
|
|
|
@@ -290,6 +378,86 @@ async function aiSetup(args) {
|
|
|
290
378
|
throw new Error(`Unknown AI provider: ${provider}`);
|
|
291
379
|
}
|
|
292
380
|
|
|
381
|
+
async function handleAiKey(args) {
|
|
382
|
+
const [action, provider] = args;
|
|
383
|
+
|
|
384
|
+
if (action === "set") {
|
|
385
|
+
await setAiKey(provider);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (action === "status") {
|
|
390
|
+
await printAiKeyStatus();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (action === "delete") {
|
|
395
|
+
await deleteAiKey(provider);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
throw new Error(`Unknown key command. Use:
|
|
400
|
+
iola ai key set openai
|
|
401
|
+
iola ai key set openrouter
|
|
402
|
+
iola ai key status
|
|
403
|
+
iola ai key delete openai|openrouter`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function setAiKey(provider) {
|
|
407
|
+
assertKeyProvider(provider);
|
|
408
|
+
|
|
409
|
+
if (!process.stdin.isTTY) {
|
|
410
|
+
throw new Error("Для сохранения ключа запустите команду в интерактивном терминале.");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const envName = provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY";
|
|
414
|
+
const rl = readline.createInterface({ input, output });
|
|
415
|
+
|
|
416
|
+
try {
|
|
417
|
+
const key = (await rl.question(`Введите ${envName}: `)).trim();
|
|
418
|
+
|
|
419
|
+
if (!key) {
|
|
420
|
+
throw new Error("Ключ пустой, сохранение отменено.");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const secrets = await loadSecrets();
|
|
424
|
+
secrets[provider] = { apiKey: key };
|
|
425
|
+
await saveSecrets(secrets);
|
|
426
|
+
console.log(`Ключ ${provider} сохранен локально: ${SECRETS_FILE}`);
|
|
427
|
+
} finally {
|
|
428
|
+
rl.close();
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function printAiKeyStatus() {
|
|
433
|
+
const secrets = await loadSecrets();
|
|
434
|
+
const rows = ["openai", "openrouter"].map((provider) => ({
|
|
435
|
+
provider,
|
|
436
|
+
env: provider === "openai" ? (process.env.OPENAI_API_KEY ? "yes" : "no") : (process.env.OPENROUTER_API_KEY ? "yes" : "no"),
|
|
437
|
+
local: secrets[provider]?.apiKey ? "yes" : "no",
|
|
438
|
+
}));
|
|
439
|
+
|
|
440
|
+
printTable(rows, [
|
|
441
|
+
["provider", "Провайдер"],
|
|
442
|
+
["env", "Env"],
|
|
443
|
+
["local", "Локально"],
|
|
444
|
+
]);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function deleteAiKey(provider) {
|
|
448
|
+
assertKeyProvider(provider);
|
|
449
|
+
const secrets = await loadSecrets();
|
|
450
|
+
delete secrets[provider];
|
|
451
|
+
await saveSecrets(secrets);
|
|
452
|
+
console.log(`Локальный ключ ${provider} удален.`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function assertKeyProvider(provider) {
|
|
456
|
+
if (provider !== "openai" && provider !== "openrouter") {
|
|
457
|
+
throw new Error("Провайдер должен быть openai или openrouter.");
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
293
461
|
async function chooseAiProvider() {
|
|
294
462
|
console.log("Выберите режим AI:");
|
|
295
463
|
console.log("1. Локальная модель через Ollama");
|
|
@@ -351,6 +519,180 @@ async function setupOllama(args) {
|
|
|
351
519
|
console.log(`Готово. Локальный AI-профиль сохранен в ${CONFIG_FILE}`);
|
|
352
520
|
}
|
|
353
521
|
|
|
522
|
+
async function aiAsk(args, context = {}) {
|
|
523
|
+
const options = parseOptions(args);
|
|
524
|
+
const question = options._.join(" ").trim();
|
|
525
|
+
|
|
526
|
+
if (!question) {
|
|
527
|
+
throw new Error('Текст вопроса обязателен. Пример: iola ai ask "Какие школы есть на улице Петрова?"');
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const config = await loadConfig();
|
|
531
|
+
const provider = options.provider || config.ai.provider;
|
|
532
|
+
const model = options.model || config.ai.model;
|
|
533
|
+
const providerConfig = {
|
|
534
|
+
...config.ai,
|
|
535
|
+
provider,
|
|
536
|
+
model,
|
|
537
|
+
};
|
|
538
|
+
const dataContext = await buildDataContext(question);
|
|
539
|
+
const messages = buildAiMessages(question, dataContext, context.history || []);
|
|
540
|
+
const answer = await callAiProvider(providerConfig, messages);
|
|
541
|
+
|
|
542
|
+
console.log(answer);
|
|
543
|
+
return answer;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function buildDataContext(question) {
|
|
547
|
+
const [layers, schools, kindergartens] = await Promise.all([
|
|
548
|
+
fetchJson(`${MCP_BASE_URL}/mcp-version`),
|
|
549
|
+
fetchJson(`${API_BASE_URL}/schools?limit=100&offset=0`),
|
|
550
|
+
fetchJson(`${API_BASE_URL}/kindergartens?limit=100&offset=0`),
|
|
551
|
+
]);
|
|
552
|
+
const queryTerms = extractSearchTerms(question);
|
|
553
|
+
const schoolItems = findRelevantItems(normalizeItems(schools), queryTerms).slice(0, 8);
|
|
554
|
+
const kindergartenItems = findRelevantItems(normalizeItems(kindergartens), queryTerms).slice(0, 8);
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
layers: layers.data_layers || [],
|
|
558
|
+
schools: schoolItems.map(selectPublicSummary),
|
|
559
|
+
kindergartens: kindergartenItems.map(selectPublicSummary),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function extractSearchTerms(question) {
|
|
564
|
+
const normalized = question
|
|
565
|
+
.toLocaleLowerCase("ru-RU")
|
|
566
|
+
.replace(/[^\p{L}\p{N}\s.-]/gu, " ")
|
|
567
|
+
.split(/\s+/)
|
|
568
|
+
.map((term) => term.trim())
|
|
569
|
+
.filter(Boolean)
|
|
570
|
+
.filter((term) => !["какие", "какая", "какой", "есть", "найди", "покажи", "контакты", "адрес", "телефон", "школы", "школа", "сад", "детский", "детские", "сады", "улица", "ул"].includes(term));
|
|
571
|
+
|
|
572
|
+
return normalized.length > 0 ? normalized : [question];
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function findRelevantItems(items, terms) {
|
|
576
|
+
return items
|
|
577
|
+
.map((item) => ({
|
|
578
|
+
item,
|
|
579
|
+
score: scoreItem(item, terms),
|
|
580
|
+
}))
|
|
581
|
+
.filter((entry) => entry.score > 0)
|
|
582
|
+
.sort((left, right) => right.score - left.score)
|
|
583
|
+
.map((entry) => entry.item);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function scoreItem(item, terms) {
|
|
587
|
+
const text = JSON.stringify(selectPublicSummary(item)).toLocaleLowerCase("ru-RU");
|
|
588
|
+
return terms.reduce((score, term) => score + (text.includes(term.toLocaleLowerCase("ru-RU")) ? 1 : 0), 0);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function buildAiMessages(question, dataContext, history) {
|
|
592
|
+
const system = [
|
|
593
|
+
"Ты терминальный AI-ассистент CLI-проекта Йошкар-Олы.",
|
|
594
|
+
"Отвечай на русском языке.",
|
|
595
|
+
"Используй только данные из переданного контекста.",
|
|
596
|
+
"Если в контексте нет нужных сведений, прямо напиши, что данных недостаточно.",
|
|
597
|
+
"Не выдумывай адреса, телефоны, лицензии и руководителей.",
|
|
598
|
+
"Отвечай кратко и по делу.",
|
|
599
|
+
].join(" ");
|
|
600
|
+
const contextText = JSON.stringify(dataContext, null, 2);
|
|
601
|
+
const recentHistory = history.slice(-6);
|
|
602
|
+
|
|
603
|
+
return [
|
|
604
|
+
{ role: "system", content: system },
|
|
605
|
+
...recentHistory,
|
|
606
|
+
{
|
|
607
|
+
role: "user",
|
|
608
|
+
content: `Контекст открытых данных городского округа "Город Йошкар-Ола":\n${contextText}\n\nВопрос пользователя: ${question}`,
|
|
609
|
+
},
|
|
610
|
+
];
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function callAiProvider(config, messages) {
|
|
614
|
+
if (config.provider === "ollama") {
|
|
615
|
+
return callOllama(config, messages);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (config.provider === "openai") {
|
|
619
|
+
return callOpenAiCompatible(config, messages, await getApiKey("openai"), "OpenAI");
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (config.provider === "openrouter") {
|
|
623
|
+
return callOpenAiCompatible(config, messages, await getApiKey("openrouter"), "OpenRouter");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
throw new Error(`Неизвестный AI-провайдер: ${config.provider}`);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async function callOllama(config, messages) {
|
|
630
|
+
let response;
|
|
631
|
+
|
|
632
|
+
try {
|
|
633
|
+
response = await fetch(`${config.baseUrl || "http://127.0.0.1:11434"}/api/chat`, {
|
|
634
|
+
method: "POST",
|
|
635
|
+
headers: { "content-type": "application/json" },
|
|
636
|
+
body: JSON.stringify({
|
|
637
|
+
model: config.model || "llama3.2:1b",
|
|
638
|
+
messages,
|
|
639
|
+
stream: false,
|
|
640
|
+
}),
|
|
641
|
+
});
|
|
642
|
+
} catch {
|
|
643
|
+
throw new Error("Ollama недоступен. Запустите Ollama и проверьте: ollama --version");
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (!response.ok) {
|
|
647
|
+
throw new Error(`Ollama request failed: ${response.status} ${response.statusText}. Проверьте "ollama serve" и модель.`);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const payload = await response.json();
|
|
651
|
+
return payload.message?.content || "";
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function callOpenAiCompatible(config, messages, apiKey, providerName) {
|
|
655
|
+
if (!apiKey) {
|
|
656
|
+
throw new Error(`${providerName} API key не найден. Задайте ${providerName === "OpenAI" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const response = await fetch(`${config.baseUrl}/chat/completions`, {
|
|
660
|
+
method: "POST",
|
|
661
|
+
headers: {
|
|
662
|
+
authorization: `Bearer ${apiKey}`,
|
|
663
|
+
"content-type": "application/json",
|
|
664
|
+
"http-referer": "https://github.com/adm-iola/iola-cli",
|
|
665
|
+
"x-title": "iola-cli",
|
|
666
|
+
},
|
|
667
|
+
body: JSON.stringify({
|
|
668
|
+
model: config.model,
|
|
669
|
+
messages,
|
|
670
|
+
temperature: 0.2,
|
|
671
|
+
}),
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
if (!response.ok) {
|
|
675
|
+
const text = await response.text();
|
|
676
|
+
throw new Error(`${providerName} request failed: ${response.status} ${response.statusText}\n${text}`);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const payload = await response.json();
|
|
680
|
+
return payload.choices?.[0]?.message?.content || "";
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function getApiKey(provider) {
|
|
684
|
+
if (provider === "openai" && process.env.OPENAI_API_KEY) {
|
|
685
|
+
return process.env.OPENAI_API_KEY;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (provider === "openrouter" && process.env.OPENROUTER_API_KEY) {
|
|
689
|
+
return process.env.OPENROUTER_API_KEY;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const secrets = await loadSecrets();
|
|
693
|
+
return secrets[provider]?.apiKey || "";
|
|
694
|
+
}
|
|
695
|
+
|
|
354
696
|
async function listLayers(args) {
|
|
355
697
|
const options = parseOptions(args);
|
|
356
698
|
const info = await fetchJson(`${MCP_BASE_URL}/mcp-version`);
|
|
@@ -490,7 +832,7 @@ function parseOptions(args) {
|
|
|
490
832
|
const arg = args[index];
|
|
491
833
|
if (arg === "--json" || arg === "--yes") {
|
|
492
834
|
result[arg.slice(2)] = true;
|
|
493
|
-
} else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn") {
|
|
835
|
+
} else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn" || arg === "--model" || arg === "--provider") {
|
|
494
836
|
result[arg.slice(2)] = args[index + 1];
|
|
495
837
|
index += 1;
|
|
496
838
|
} else {
|
|
@@ -717,6 +1059,52 @@ async function saveConfig(value) {
|
|
|
717
1059
|
await writeFile(CONFIG_FILE, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
718
1060
|
}
|
|
719
1061
|
|
|
1062
|
+
async function loadConfig() {
|
|
1063
|
+
try {
|
|
1064
|
+
const text = await readFile(CONFIG_FILE, "utf8");
|
|
1065
|
+
return mergeConfig(DEFAULT_AI_CONFIG, JSON.parse(text));
|
|
1066
|
+
} catch {
|
|
1067
|
+
return DEFAULT_AI_CONFIG;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function mergeConfig(base, override) {
|
|
1072
|
+
return {
|
|
1073
|
+
...base,
|
|
1074
|
+
...override,
|
|
1075
|
+
ai: {
|
|
1076
|
+
...base.ai,
|
|
1077
|
+
...(override.ai || {}),
|
|
1078
|
+
},
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
async function loadSecrets() {
|
|
1083
|
+
try {
|
|
1084
|
+
return JSON.parse(await readFile(SECRETS_FILE, "utf8"));
|
|
1085
|
+
} catch {
|
|
1086
|
+
return {};
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
async function saveSecrets(value) {
|
|
1091
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
1092
|
+
await writeFile(SECRETS_FILE, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
async function printAiConfig() {
|
|
1096
|
+
const config = await loadConfig();
|
|
1097
|
+
printJson({
|
|
1098
|
+
file: CONFIG_FILE,
|
|
1099
|
+
ai: config.ai,
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
async function printAiConfigField(field) {
|
|
1104
|
+
const config = await loadConfig();
|
|
1105
|
+
console.log(config.ai[field] || "-");
|
|
1106
|
+
}
|
|
1107
|
+
|
|
720
1108
|
function runCommand(command, args, options = {}) {
|
|
721
1109
|
return new Promise((resolve, reject) => {
|
|
722
1110
|
const child = execFile(command, args, {
|