@iola_adm/iola-cli 0.1.5 → 0.1.6
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 +38 -0
- package/package.json +1 -1
- package/src/cli.js +276 -8
package/README.md
CHANGED
|
@@ -78,6 +78,9 @@ iola banner
|
|
|
78
78
|
iola agent
|
|
79
79
|
iola ai doctor
|
|
80
80
|
iola ai setup ollama
|
|
81
|
+
iola ai ask "Какие школы есть на улице Петрова?"
|
|
82
|
+
iola ai setup openai --model gpt-4.1-mini
|
|
83
|
+
iola ai setup openrouter --model openai/gpt-4.1-mini
|
|
81
84
|
iola health
|
|
82
85
|
iola layers
|
|
83
86
|
iola schools --limit 10
|
|
@@ -110,9 +113,44 @@ iola agent
|
|
|
110
113
|
/search лицей --limit 3
|
|
111
114
|
/mcp-info
|
|
112
115
|
/ai doctor
|
|
116
|
+
/model
|
|
117
|
+
/provider
|
|
118
|
+
/config
|
|
119
|
+
/history
|
|
120
|
+
/clear
|
|
113
121
|
/exit
|
|
114
122
|
```
|
|
115
123
|
|
|
124
|
+
Обычный текст без `/` в `iola agent` отправляется в настроенный AI-провайдер.
|
|
125
|
+
|
|
126
|
+
## AI-запросы
|
|
127
|
+
|
|
128
|
+
Локальная модель через Ollama:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
iola ai setup ollama
|
|
132
|
+
iola ai ask "Какие школы есть на улице Петрова?"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
OpenAI:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
set OPENAI_API_KEY=...
|
|
139
|
+
iola ai setup openai --model gpt-4.1-mini
|
|
140
|
+
iola ai ask "Найди школу 29"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
OpenRouter:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
set OPENROUTER_API_KEY=...
|
|
147
|
+
iola ai setup openrouter --model openai/gpt-4.1-mini
|
|
148
|
+
iola ai ask "Покажи контакты лицея"
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
AI-ответ строится по контексту из публичного API. Если данных в контексте
|
|
152
|
+
недостаточно, ассистент должен сообщить об этом, а не выдумывать сведения.
|
|
153
|
+
|
|
116
154
|
## Назначение
|
|
117
155
|
|
|
118
156
|
Первый релиз 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,13 @@ 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 DEFAULT_AI_CONFIG = {
|
|
13
|
+
ai: {
|
|
14
|
+
provider: "ollama",
|
|
15
|
+
model: "llama3.2:1b",
|
|
16
|
+
baseUrl: "http://127.0.0.1:11434",
|
|
17
|
+
},
|
|
18
|
+
};
|
|
12
19
|
const BANNER = `\x1b[38;5;45m┌────────────────────────────────────────────────────────────────────────────┐
|
|
13
20
|
│\x1b[38;5;51m ____ _ ___ ____ ____ ___ _____ _ _______ \x1b[38;5;45m│
|
|
14
21
|
│\x1b[38;5;51m / ___| | |_ _| | _ \\| _ \\ / _ \\| ____| |/ /_ _| \x1b[38;5;45m│
|
|
@@ -56,6 +63,7 @@ async function showHelp() {
|
|
|
56
63
|
Usage:
|
|
57
64
|
iola banner
|
|
58
65
|
iola agent
|
|
66
|
+
iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
|
|
59
67
|
iola ai doctor [--json]
|
|
60
68
|
iola ai setup
|
|
61
69
|
iola ai setup ollama [--yes] [--model MODEL]
|
|
@@ -81,6 +89,9 @@ async function startAgent() {
|
|
|
81
89
|
console.log("Интерактивный режим. Введите /help для списка команд, /exit для выхода.");
|
|
82
90
|
|
|
83
91
|
const rl = readline.createInterface({ input, output, prompt: "iola> " });
|
|
92
|
+
const state = {
|
|
93
|
+
history: [],
|
|
94
|
+
};
|
|
84
95
|
let closed = false;
|
|
85
96
|
rl.on("close", () => {
|
|
86
97
|
closed = true;
|
|
@@ -96,7 +107,7 @@ async function startAgent() {
|
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
try {
|
|
99
|
-
const shouldExit = await handleAgentLine(line);
|
|
110
|
+
const shouldExit = await handleAgentLine(line, state);
|
|
100
111
|
if (shouldExit) {
|
|
101
112
|
break;
|
|
102
113
|
}
|
|
@@ -112,9 +123,11 @@ async function startAgent() {
|
|
|
112
123
|
}
|
|
113
124
|
}
|
|
114
125
|
|
|
115
|
-
async function handleAgentLine(line) {
|
|
126
|
+
async function handleAgentLine(line, state) {
|
|
116
127
|
if (!line.startsWith("/")) {
|
|
117
|
-
|
|
128
|
+
const answer = await aiAsk([line], { history: state.history });
|
|
129
|
+
state.history.push({ role: "user", content: line });
|
|
130
|
+
state.history.push({ role: "assistant", content: answer });
|
|
118
131
|
return false;
|
|
119
132
|
}
|
|
120
133
|
|
|
@@ -129,6 +142,32 @@ async function handleAgentLine(line) {
|
|
|
129
142
|
return false;
|
|
130
143
|
}
|
|
131
144
|
|
|
145
|
+
if (command === "clear") {
|
|
146
|
+
state.history = [];
|
|
147
|
+
console.log("История agent-сессии очищена.");
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (command === "history") {
|
|
152
|
+
printAgentHistory(state.history);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (command === "config") {
|
|
157
|
+
await printAiConfig();
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (command === "provider") {
|
|
162
|
+
await printAiConfigField("provider");
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (command === "model") {
|
|
167
|
+
await printAiConfigField("model");
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
132
171
|
if (command === "banner") {
|
|
133
172
|
showBanner();
|
|
134
173
|
return false;
|
|
@@ -173,8 +212,26 @@ function printAgentHelp() {
|
|
|
173
212
|
/mcp-info
|
|
174
213
|
/ai doctor
|
|
175
214
|
/ai setup ollama
|
|
215
|
+
/config
|
|
216
|
+
/provider
|
|
217
|
+
/model
|
|
218
|
+
/history
|
|
219
|
+
/clear
|
|
176
220
|
/banner
|
|
177
|
-
/exit
|
|
221
|
+
/exit
|
|
222
|
+
|
|
223
|
+
Обычный текст без slash-команды отправляется в настроенный AI-провайдер.`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function printAgentHistory(history) {
|
|
227
|
+
if (history.length === 0) {
|
|
228
|
+
console.log("История пуста.");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
for (const item of history.slice(-10)) {
|
|
233
|
+
console.log(`${item.role}: ${item.content}`);
|
|
234
|
+
}
|
|
178
235
|
}
|
|
179
236
|
|
|
180
237
|
function safePrompt(rl, closed = false) {
|
|
@@ -227,14 +284,22 @@ async function handleAi(args) {
|
|
|
227
284
|
if (subcommand === "help") {
|
|
228
285
|
showBanner();
|
|
229
286
|
console.log(`AI-команды:
|
|
287
|
+
iola ai ask TEXT [--provider ollama|openai|openrouter] [--model MODEL]
|
|
230
288
|
iola ai doctor [--json]
|
|
231
289
|
iola ai setup
|
|
232
290
|
iola ai setup ollama [--yes] [--model MODEL]
|
|
291
|
+
iola ai setup openai [--model MODEL]
|
|
292
|
+
iola ai setup openrouter [--model MODEL]
|
|
233
293
|
|
|
234
294
|
Локальная настройка сохраняется в ${CONFIG_FILE}`);
|
|
235
295
|
return;
|
|
236
296
|
}
|
|
237
297
|
|
|
298
|
+
if (subcommand === "ask") {
|
|
299
|
+
await aiAsk(rest);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
238
303
|
if (subcommand === "doctor") {
|
|
239
304
|
await aiDoctor(rest);
|
|
240
305
|
return;
|
|
@@ -277,8 +342,17 @@ async function aiSetup(args) {
|
|
|
277
342
|
}
|
|
278
343
|
|
|
279
344
|
if (provider === "openai" || provider === "openrouter") {
|
|
280
|
-
|
|
281
|
-
|
|
345
|
+
const options = parseOptions(args.slice(1));
|
|
346
|
+
const model = options.model || (provider === "openai" ? "gpt-4.1-mini" : "openai/gpt-4.1-mini");
|
|
347
|
+
await saveConfig({
|
|
348
|
+
ai: {
|
|
349
|
+
provider,
|
|
350
|
+
model,
|
|
351
|
+
baseUrl: provider === "openai" ? "https://api.openai.com/v1" : "https://openrouter.ai/api/v1",
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
console.log(`AI-профиль ${provider} сохранен в ${CONFIG_FILE}`);
|
|
355
|
+
console.log(`Ключ задайте через переменную окружения ${provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
|
|
282
356
|
return;
|
|
283
357
|
}
|
|
284
358
|
|
|
@@ -351,6 +425,167 @@ async function setupOllama(args) {
|
|
|
351
425
|
console.log(`Готово. Локальный AI-профиль сохранен в ${CONFIG_FILE}`);
|
|
352
426
|
}
|
|
353
427
|
|
|
428
|
+
async function aiAsk(args, context = {}) {
|
|
429
|
+
const options = parseOptions(args);
|
|
430
|
+
const question = options._.join(" ").trim();
|
|
431
|
+
|
|
432
|
+
if (!question) {
|
|
433
|
+
throw new Error('Текст вопроса обязателен. Пример: iola ai ask "Какие школы есть на улице Петрова?"');
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const config = await loadConfig();
|
|
437
|
+
const provider = options.provider || config.ai.provider;
|
|
438
|
+
const model = options.model || config.ai.model;
|
|
439
|
+
const providerConfig = {
|
|
440
|
+
...config.ai,
|
|
441
|
+
provider,
|
|
442
|
+
model,
|
|
443
|
+
};
|
|
444
|
+
const dataContext = await buildDataContext(question);
|
|
445
|
+
const messages = buildAiMessages(question, dataContext, context.history || []);
|
|
446
|
+
const answer = await callAiProvider(providerConfig, messages);
|
|
447
|
+
|
|
448
|
+
console.log(answer);
|
|
449
|
+
return answer;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function buildDataContext(question) {
|
|
453
|
+
const [layers, schools, kindergartens] = await Promise.all([
|
|
454
|
+
fetchJson(`${MCP_BASE_URL}/mcp-version`),
|
|
455
|
+
fetchJson(`${API_BASE_URL}/schools?limit=100&offset=0`),
|
|
456
|
+
fetchJson(`${API_BASE_URL}/kindergartens?limit=100&offset=0`),
|
|
457
|
+
]);
|
|
458
|
+
const queryTerms = extractSearchTerms(question);
|
|
459
|
+
const schoolItems = findRelevantItems(normalizeItems(schools), queryTerms).slice(0, 8);
|
|
460
|
+
const kindergartenItems = findRelevantItems(normalizeItems(kindergartens), queryTerms).slice(0, 8);
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
layers: layers.data_layers || [],
|
|
464
|
+
schools: schoolItems.map(selectPublicSummary),
|
|
465
|
+
kindergartens: kindergartenItems.map(selectPublicSummary),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function extractSearchTerms(question) {
|
|
470
|
+
const normalized = question
|
|
471
|
+
.toLocaleLowerCase("ru-RU")
|
|
472
|
+
.replace(/[^\p{L}\p{N}\s.-]/gu, " ")
|
|
473
|
+
.split(/\s+/)
|
|
474
|
+
.map((term) => term.trim())
|
|
475
|
+
.filter(Boolean)
|
|
476
|
+
.filter((term) => !["какие", "какая", "какой", "есть", "найди", "покажи", "контакты", "адрес", "телефон", "школы", "школа", "сад", "детский", "детские", "сады", "улица", "ул"].includes(term));
|
|
477
|
+
|
|
478
|
+
return normalized.length > 0 ? normalized : [question];
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function findRelevantItems(items, terms) {
|
|
482
|
+
return items
|
|
483
|
+
.map((item) => ({
|
|
484
|
+
item,
|
|
485
|
+
score: scoreItem(item, terms),
|
|
486
|
+
}))
|
|
487
|
+
.filter((entry) => entry.score > 0)
|
|
488
|
+
.sort((left, right) => right.score - left.score)
|
|
489
|
+
.map((entry) => entry.item);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function scoreItem(item, terms) {
|
|
493
|
+
const text = JSON.stringify(selectPublicSummary(item)).toLocaleLowerCase("ru-RU");
|
|
494
|
+
return terms.reduce((score, term) => score + (text.includes(term.toLocaleLowerCase("ru-RU")) ? 1 : 0), 0);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function buildAiMessages(question, dataContext, history) {
|
|
498
|
+
const system = [
|
|
499
|
+
"Ты терминальный AI-ассистент CLI-проекта Йошкар-Олы.",
|
|
500
|
+
"Отвечай на русском языке.",
|
|
501
|
+
"Используй только данные из переданного контекста.",
|
|
502
|
+
"Если в контексте нет нужных сведений, прямо напиши, что данных недостаточно.",
|
|
503
|
+
"Не выдумывай адреса, телефоны, лицензии и руководителей.",
|
|
504
|
+
"Отвечай кратко и по делу.",
|
|
505
|
+
].join(" ");
|
|
506
|
+
const contextText = JSON.stringify(dataContext, null, 2);
|
|
507
|
+
const recentHistory = history.slice(-6);
|
|
508
|
+
|
|
509
|
+
return [
|
|
510
|
+
{ role: "system", content: system },
|
|
511
|
+
...recentHistory,
|
|
512
|
+
{
|
|
513
|
+
role: "user",
|
|
514
|
+
content: `Контекст открытых данных городского округа "Город Йошкар-Ола":\n${contextText}\n\nВопрос пользователя: ${question}`,
|
|
515
|
+
},
|
|
516
|
+
];
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function callAiProvider(config, messages) {
|
|
520
|
+
if (config.provider === "ollama") {
|
|
521
|
+
return callOllama(config, messages);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (config.provider === "openai") {
|
|
525
|
+
return callOpenAiCompatible(config, messages, process.env.OPENAI_API_KEY, "OpenAI");
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (config.provider === "openrouter") {
|
|
529
|
+
return callOpenAiCompatible(config, messages, process.env.OPENROUTER_API_KEY, "OpenRouter");
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
throw new Error(`Неизвестный AI-провайдер: ${config.provider}`);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function callOllama(config, messages) {
|
|
536
|
+
let response;
|
|
537
|
+
|
|
538
|
+
try {
|
|
539
|
+
response = await fetch(`${config.baseUrl || "http://127.0.0.1:11434"}/api/chat`, {
|
|
540
|
+
method: "POST",
|
|
541
|
+
headers: { "content-type": "application/json" },
|
|
542
|
+
body: JSON.stringify({
|
|
543
|
+
model: config.model || "llama3.2:1b",
|
|
544
|
+
messages,
|
|
545
|
+
stream: false,
|
|
546
|
+
}),
|
|
547
|
+
});
|
|
548
|
+
} catch {
|
|
549
|
+
throw new Error("Ollama недоступен. Запустите Ollama и проверьте: ollama --version");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (!response.ok) {
|
|
553
|
+
throw new Error(`Ollama request failed: ${response.status} ${response.statusText}. Проверьте "ollama serve" и модель.`);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const payload = await response.json();
|
|
557
|
+
return payload.message?.content || "";
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function callOpenAiCompatible(config, messages, apiKey, providerName) {
|
|
561
|
+
if (!apiKey) {
|
|
562
|
+
throw new Error(`${providerName} API key не найден. Задайте ${providerName === "OpenAI" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const response = await fetch(`${config.baseUrl}/chat/completions`, {
|
|
566
|
+
method: "POST",
|
|
567
|
+
headers: {
|
|
568
|
+
authorization: `Bearer ${apiKey}`,
|
|
569
|
+
"content-type": "application/json",
|
|
570
|
+
"http-referer": "https://github.com/adm-iola/iola-cli",
|
|
571
|
+
"x-title": "iola-cli",
|
|
572
|
+
},
|
|
573
|
+
body: JSON.stringify({
|
|
574
|
+
model: config.model,
|
|
575
|
+
messages,
|
|
576
|
+
temperature: 0.2,
|
|
577
|
+
}),
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
if (!response.ok) {
|
|
581
|
+
const text = await response.text();
|
|
582
|
+
throw new Error(`${providerName} request failed: ${response.status} ${response.statusText}\n${text}`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const payload = await response.json();
|
|
586
|
+
return payload.choices?.[0]?.message?.content || "";
|
|
587
|
+
}
|
|
588
|
+
|
|
354
589
|
async function listLayers(args) {
|
|
355
590
|
const options = parseOptions(args);
|
|
356
591
|
const info = await fetchJson(`${MCP_BASE_URL}/mcp-version`);
|
|
@@ -490,7 +725,7 @@ function parseOptions(args) {
|
|
|
490
725
|
const arg = args[index];
|
|
491
726
|
if (arg === "--json" || arg === "--yes") {
|
|
492
727
|
result[arg.slice(2)] = true;
|
|
493
|
-
} else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn") {
|
|
728
|
+
} else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--inn" || arg === "--model" || arg === "--provider") {
|
|
494
729
|
result[arg.slice(2)] = args[index + 1];
|
|
495
730
|
index += 1;
|
|
496
731
|
} else {
|
|
@@ -717,6 +952,39 @@ async function saveConfig(value) {
|
|
|
717
952
|
await writeFile(CONFIG_FILE, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
718
953
|
}
|
|
719
954
|
|
|
955
|
+
async function loadConfig() {
|
|
956
|
+
try {
|
|
957
|
+
const text = await readFile(CONFIG_FILE, "utf8");
|
|
958
|
+
return mergeConfig(DEFAULT_AI_CONFIG, JSON.parse(text));
|
|
959
|
+
} catch {
|
|
960
|
+
return DEFAULT_AI_CONFIG;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function mergeConfig(base, override) {
|
|
965
|
+
return {
|
|
966
|
+
...base,
|
|
967
|
+
...override,
|
|
968
|
+
ai: {
|
|
969
|
+
...base.ai,
|
|
970
|
+
...(override.ai || {}),
|
|
971
|
+
},
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
async function printAiConfig() {
|
|
976
|
+
const config = await loadConfig();
|
|
977
|
+
printJson({
|
|
978
|
+
file: CONFIG_FILE,
|
|
979
|
+
ai: config.ai,
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function printAiConfigField(field) {
|
|
984
|
+
const config = await loadConfig();
|
|
985
|
+
console.log(config.ai[field] || "-");
|
|
986
|
+
}
|
|
987
|
+
|
|
720
988
|
function runCommand(command, args, options = {}) {
|
|
721
989
|
return new Promise((resolve, reject) => {
|
|
722
990
|
const child = execFile(command, args, {
|