@iola_adm/iola-cli 0.2.5 → 0.2.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 +23 -0
- package/package.json +1 -1
- package/skills/geo/SKILL.md +20 -0
- package/skills/personal-docs/SKILL.md +27 -0
- package/src/cli.js +1009 -13
- package/wiki/Home.md +2 -0
- package/wiki/Yandex-Geocoder-API-key.md +1 -1
- package/wiki//320/232/320/276/320/274/320/260/320/275/320/264/321/213.md +23 -0
- package/wiki//320/234/320/260/321/201/321/202/320/265/321/200-/320/275/320/260/321/201/321/202/321/200/320/276/320/271/320/272/320/270.md +11 -0
- package/wiki//320/236/320/261/320/273/320/260/321/207/320/275/321/213/320/265-/320/264/320/270/321/201/320/272/320/270.md +90 -0
- package/wiki//320/241/320/272/320/270/320/273/320/273/321/213-/320/264/320/273/321/217-/320/266/320/270/321/202/320/265/320/273/320/265/320/271.md +54 -12
package/src/cli.js
CHANGED
|
@@ -39,6 +39,7 @@ const PROJECT_CONFIG_FILE = path.join(PROJECT_IOLA_DIR, "config.json");
|
|
|
39
39
|
const LOCAL_CONFIG_FILE = path.join(PROJECT_IOLA_DIR, "local.json");
|
|
40
40
|
const BROWSER_RUNTIME_DIR = path.join(CONFIG_DIR, "browser-runtime");
|
|
41
41
|
const BROWSER_RUNTIME_PACKAGE = path.join(BROWSER_RUNTIME_DIR, "node_modules", "playwright", "package.json");
|
|
42
|
+
const CLOUD_DEFAULT_REMOTE_DIR = "/IOLA";
|
|
42
43
|
const INDEXABLE_EXTENSIONS = /\.(md|txt|csv|json|html|docx|xlsx|pptx|pdf)$/i;
|
|
43
44
|
const LOCAL_TOOLS = ["search_data", "search_entities", "resolve_entity_field", "get_card", "export_report", "file_read", "browser_open", "get_current_date"];
|
|
44
45
|
const LEGACY_LOCAL_TOOLS = ["search_local", "export_data", "run_report", "save_view"];
|
|
@@ -228,7 +229,14 @@ const DEFAULT_AI_CONFIG = {
|
|
|
228
229
|
suggestions: true,
|
|
229
230
|
},
|
|
230
231
|
skills: {
|
|
231
|
-
enabled: ["education", "open-data", "reports", "local-model", "local-files", "browser-agent"],
|
|
232
|
+
enabled: ["education", "open-data", "geo", "personal-docs", "reports", "local-model", "local-files", "browser-agent"],
|
|
233
|
+
},
|
|
234
|
+
cloud: {
|
|
235
|
+
activeProvider: "",
|
|
236
|
+
providers: {
|
|
237
|
+
"yandex-disk": { root: CLOUD_DEFAULT_REMOTE_DIR },
|
|
238
|
+
"mailru-cloud": { root: CLOUD_DEFAULT_REMOTE_DIR },
|
|
239
|
+
},
|
|
232
240
|
},
|
|
233
241
|
daemon: {
|
|
234
242
|
host: "127.0.0.1",
|
|
@@ -314,6 +322,7 @@ const SLASH_COMMANDS = [
|
|
|
314
322
|
{ command: "/permissions", description: "разрешения" },
|
|
315
323
|
{ command: "/tools", description: "tools и toolsets" },
|
|
316
324
|
{ command: "/files status", description: "локальные файловые операции" },
|
|
325
|
+
{ command: "/cloud status", description: "облачные диски" },
|
|
317
326
|
{ command: "/archive doctor", description: "архиватор" },
|
|
318
327
|
{ command: "/changes list", description: "подготовленные изменения" },
|
|
319
328
|
{ command: "/index status", description: "индекс документов" },
|
|
@@ -389,6 +398,7 @@ const COMMANDS = new Map([
|
|
|
389
398
|
["skills", handleSkills],
|
|
390
399
|
["tools", handleTools],
|
|
391
400
|
["files", handleFiles],
|
|
401
|
+
["cloud", handleCloud],
|
|
392
402
|
["archive", handleArchive],
|
|
393
403
|
["changes", handleChanges],
|
|
394
404
|
["import", handleImport],
|
|
@@ -537,6 +547,7 @@ async function showHelp() {
|
|
|
537
547
|
iola agent интерактивный режим
|
|
538
548
|
iola ai setup настройка AI-профиля
|
|
539
549
|
iola browser status браузерный runtime
|
|
550
|
+
iola cloud status облачные диски
|
|
540
551
|
iola mcp status MCP-подключение
|
|
541
552
|
iola doctor диагностика
|
|
542
553
|
iola wiki документация
|
|
@@ -576,6 +587,7 @@ Usage:
|
|
|
576
587
|
iola skills list|show|paths|enable|disable|bundles|bundle|doctor
|
|
577
588
|
iola tools list|toolsets|enable|disable|profile
|
|
578
589
|
iola files status|mode|approvals|tree|read|search|write|patch
|
|
590
|
+
iola cloud setup|status|ls|find|upload|download|share|save|backup
|
|
579
591
|
iola archive doctor|list|test|extract|create|index
|
|
580
592
|
iola changes list|show|apply|discard
|
|
581
593
|
iola import file|folder
|
|
@@ -2879,6 +2891,467 @@ async function handleFiles(args) {
|
|
|
2879
2891
|
throw new Error("Команды files: status, mode MODE, approvals POLICY, tree [PATH], read FILE, search TEXT, write FILE --text TEXT, patch FILE --search OLD --replace NEW.");
|
|
2880
2892
|
}
|
|
2881
2893
|
|
|
2894
|
+
async function handleCloud(args) {
|
|
2895
|
+
const [action = "status", target, maybeRemote, ...rest] = args;
|
|
2896
|
+
const options = parseOptions(rest);
|
|
2897
|
+
|
|
2898
|
+
if (action === "setup") {
|
|
2899
|
+
await setupCloudProvider(target, options);
|
|
2900
|
+
return;
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
if (action === "use") {
|
|
2904
|
+
const provider = normalizeCloudProvider(target);
|
|
2905
|
+
await requireCloudCredentials(provider);
|
|
2906
|
+
const config = await loadConfig();
|
|
2907
|
+
await saveConfig({ cloud: { ...(config.cloud || {}), activeProvider: provider } });
|
|
2908
|
+
console.log(`Активный облачный диск: ${provider}`);
|
|
2909
|
+
return;
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
if (action === "status") {
|
|
2913
|
+
await printCloudStatus({ check: Boolean(options.check) });
|
|
2914
|
+
return;
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
if (action === "doctor") {
|
|
2918
|
+
await printCloudStatus({ check: true });
|
|
2919
|
+
return;
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
if (action === "delete") {
|
|
2923
|
+
const provider = normalizeCloudProvider(target);
|
|
2924
|
+
const secrets = await loadSecrets();
|
|
2925
|
+
delete secrets.cloud?.[provider];
|
|
2926
|
+
if (secrets.cloud && Object.keys(secrets.cloud).length === 0) delete secrets.cloud;
|
|
2927
|
+
await saveSecrets(secrets);
|
|
2928
|
+
const config = await loadConfig();
|
|
2929
|
+
if (config.cloud?.activeProvider === provider) {
|
|
2930
|
+
await saveConfig({ cloud: { ...(config.cloud || {}), activeProvider: "" } });
|
|
2931
|
+
}
|
|
2932
|
+
console.log(`Локальные секреты облака удалены: ${provider}`);
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
if (action === "ls" || action === "list") {
|
|
2937
|
+
const provider = await getCloudProvider(options.provider);
|
|
2938
|
+
const rows = await cloudList(provider, target || cloudRootForProvider(provider));
|
|
2939
|
+
printTable(rows, [["type", "Тип"], ["name", "Имя"], ["path", "Путь"], ["size", "Размер"]]);
|
|
2940
|
+
return;
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
if (action === "find" || action === "search") {
|
|
2944
|
+
if (!target) throw new Error('Пример: iola cloud find "справка" --path /IOLA');
|
|
2945
|
+
const provider = await getCloudProvider(options.provider);
|
|
2946
|
+
const rows = await cloudFind(provider, target, { path: options.path || cloudRootForProvider(provider), limit: Number(options.limit || 50) });
|
|
2947
|
+
printTable(rows, [["type", "Тип"], ["name", "Имя"], ["path", "Путь"], ["size", "Размер"]]);
|
|
2948
|
+
return;
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
if (action === "upload") {
|
|
2952
|
+
if (!target) throw new Error('Пример: iola cloud upload report.md /IOLA/reports/report.md');
|
|
2953
|
+
const provider = await getCloudProvider(options.provider);
|
|
2954
|
+
const remotePath = maybeRemote || `${cloudRootForProvider(provider)}/${path.basename(target)}`;
|
|
2955
|
+
const result = await cloudUpload(provider, target, remotePath, { overwrite: options.overwrite !== false });
|
|
2956
|
+
printKeyValue(result);
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
if (action === "download") {
|
|
2961
|
+
if (!target) throw new Error('Пример: iola cloud download /IOLA/report.md ./report.md');
|
|
2962
|
+
const provider = await getCloudProvider(options.provider);
|
|
2963
|
+
const outputPath = maybeRemote || path.basename(target);
|
|
2964
|
+
const result = await cloudDownload(provider, target, outputPath);
|
|
2965
|
+
printKeyValue(result);
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
if (action === "share") {
|
|
2970
|
+
if (!target) throw new Error('Пример: iola cloud share /IOLA/report.md');
|
|
2971
|
+
const provider = await getCloudProvider(options.provider);
|
|
2972
|
+
const result = await cloudShare(provider, target);
|
|
2973
|
+
printKeyValue(result);
|
|
2974
|
+
return;
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
if (action === "save") {
|
|
2978
|
+
const provider = await getCloudProvider(options.provider);
|
|
2979
|
+
const text = options.text ?? [target, maybeRemote, ...rest].filter(Boolean).join(" ");
|
|
2980
|
+
if (!text) throw new Error('Пример: iola cloud save --text "Текст" --path /IOLA/notes/note.txt');
|
|
2981
|
+
const remotePath = options.path || `${cloudRootForProvider(provider)}/notes/iola-${timestampForFile()}.txt`;
|
|
2982
|
+
const tempPath = path.join(CONFIG_DIR, `cloud-save-${Date.now()}.txt`);
|
|
2983
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
2984
|
+
await writeFile(tempPath, text, "utf8");
|
|
2985
|
+
try {
|
|
2986
|
+
const result = await cloudUpload(provider, tempPath, remotePath, { overwrite: true });
|
|
2987
|
+
printKeyValue(result);
|
|
2988
|
+
} finally {
|
|
2989
|
+
await rm(tempPath, { force: true }).catch(() => {});
|
|
2990
|
+
}
|
|
2991
|
+
return;
|
|
2992
|
+
}
|
|
2993
|
+
|
|
2994
|
+
if (action === "backup") {
|
|
2995
|
+
const provider = await getCloudProvider(options.provider);
|
|
2996
|
+
const result = await cloudBackup(provider);
|
|
2997
|
+
printKeyValue(result);
|
|
2998
|
+
return;
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
throw new Error(`Команды cloud:
|
|
3002
|
+
iola cloud setup yandex-disk
|
|
3003
|
+
iola cloud setup mailru-cloud
|
|
3004
|
+
iola cloud status|doctor
|
|
3005
|
+
iola cloud use yandex-disk
|
|
3006
|
+
iola cloud ls /IOLA
|
|
3007
|
+
iola cloud find "справка" --path /IOLA
|
|
3008
|
+
iola cloud upload local.txt /IOLA/local.txt
|
|
3009
|
+
iola cloud download /IOLA/local.txt ./local.txt
|
|
3010
|
+
iola cloud share /IOLA/local.txt
|
|
3011
|
+
iola cloud save --text "Текст" --path /IOLA/notes/note.txt
|
|
3012
|
+
iola cloud backup`);
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
async function setupCloudProvider(providerValue, options = {}) {
|
|
3016
|
+
const provider = normalizeCloudProvider(providerValue);
|
|
3017
|
+
if (!process.stdin.isTTY) throw new Error("Для настройки облака запустите команду в интерактивном терминале.");
|
|
3018
|
+
const secrets = await loadSecrets();
|
|
3019
|
+
secrets.cloud = secrets.cloud || {};
|
|
3020
|
+
|
|
3021
|
+
if (provider === "yandex-disk") {
|
|
3022
|
+
console.log("Яндекс Диск использует OAuth-токен пользователя с доступом к Диску.");
|
|
3023
|
+
console.log("Инструкция: https://github.com/adm-iola/iola-cli/wiki/Облачные-диски");
|
|
3024
|
+
const token = (await askText("Введите OAuth-токен Яндекс Диска: ")).trim();
|
|
3025
|
+
if (!token) throw new Error("Токен пустой, сохранение отменено.");
|
|
3026
|
+
secrets.cloud[provider] = { token };
|
|
3027
|
+
} else if (provider === "mailru-cloud") {
|
|
3028
|
+
console.log("Облако Mail.ru подключается через WebDAV и пароль внешнего приложения.");
|
|
3029
|
+
console.log("Инструкция: https://github.com/adm-iola/iola-cli/wiki/Облачные-диски");
|
|
3030
|
+
const username = (await askText("Введите email Mail.ru: ")).trim();
|
|
3031
|
+
const password = (await askText("Введите пароль внешнего приложения/WebDAV: ")).trim();
|
|
3032
|
+
if (!username || !password) throw new Error("Email или пароль пустой, сохранение отменено.");
|
|
3033
|
+
secrets.cloud[provider] = { username, password, baseUrl: options.baseUrl || "https://webdav.cloud.mail.ru" };
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
await saveSecrets(secrets);
|
|
3037
|
+
const config = await loadConfig();
|
|
3038
|
+
await saveConfig({ cloud: { ...(config.cloud || {}), activeProvider: provider } });
|
|
3039
|
+
console.log(`Облачный диск сохранен и выбран: ${provider}`);
|
|
3040
|
+
await printCloudStatus({ check: true });
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
async function printCloudStatus(options = {}) {
|
|
3044
|
+
const config = await loadConfig();
|
|
3045
|
+
const secrets = await loadSecrets();
|
|
3046
|
+
const rows = ["yandex-disk", "mailru-cloud"].map((provider) => ({
|
|
3047
|
+
provider,
|
|
3048
|
+
active: config.cloud?.activeProvider === provider ? "yes" : "no",
|
|
3049
|
+
configured: secrets.cloud?.[provider] ? "yes" : "no",
|
|
3050
|
+
root: cloudRootForProvider(provider, config),
|
|
3051
|
+
}));
|
|
3052
|
+
printTable(rows, [["provider", "Провайдер"], ["active", "Активен"], ["configured", "Настроен"], ["root", "Папка"]]);
|
|
3053
|
+
if (options.check) {
|
|
3054
|
+
for (const row of rows.filter((item) => item.configured === "yes")) {
|
|
3055
|
+
try {
|
|
3056
|
+
const listed = await cloudList(row.provider, cloudRootForProvider(row.provider), { allowMissingRoot: true });
|
|
3057
|
+
console.log(`${row.provider}: ok (${listed.length} объектов в корневой папке IOLA или папка доступна)`);
|
|
3058
|
+
} catch (error) {
|
|
3059
|
+
console.log(`${row.provider}: error - ${error instanceof Error ? error.message : String(error)}`);
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
|
|
3065
|
+
function normalizeCloudProvider(value) {
|
|
3066
|
+
const text = String(value || "").toLocaleLowerCase("ru-RU").trim();
|
|
3067
|
+
if (!text || text === "yandex" || text === "yandex-disk" || text === "яндекс" || text === "яндекс-диск") return "yandex-disk";
|
|
3068
|
+
if (text === "mailru" || text === "mailru-cloud" || text === "mail" || text === "mail.ru" || text === "облако-mail") return "mailru-cloud";
|
|
3069
|
+
throw new Error("Поддерживаются облака: yandex-disk, mailru-cloud.");
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
async function getCloudProvider(value) {
|
|
3073
|
+
if (value) return normalizeCloudProvider(value);
|
|
3074
|
+
const config = await loadConfig();
|
|
3075
|
+
const provider = normalizeCloudProvider(config.cloud?.activeProvider || "yandex-disk");
|
|
3076
|
+
await requireCloudCredentials(provider);
|
|
3077
|
+
return provider;
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
async function requireCloudCredentials(provider) {
|
|
3081
|
+
const secrets = await loadSecrets();
|
|
3082
|
+
const credentials = secrets.cloud?.[provider];
|
|
3083
|
+
if (!credentials) throw new Error(`Облачный диск не настроен: ${provider}. Запустите: iola cloud setup ${provider}`);
|
|
3084
|
+
return credentials;
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
function cloudRootForProvider(provider, config = null) {
|
|
3088
|
+
const loaded = config || readConfigLayerSync(CONFIG_FILE) || {};
|
|
3089
|
+
return loaded.cloud?.providers?.[provider]?.root || DEFAULT_AI_CONFIG.cloud.providers[provider]?.root || CLOUD_DEFAULT_REMOTE_DIR;
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
async function cloudList(provider, remotePath, options = {}) {
|
|
3093
|
+
if (provider === "yandex-disk") return yandexDiskList(remotePath, options);
|
|
3094
|
+
if (provider === "mailru-cloud") return mailruCloudList(remotePath, options);
|
|
3095
|
+
throw new Error(`Провайдер не поддерживается: ${provider}`);
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
async function cloudUpload(provider, localPath, remotePath, options = {}) {
|
|
3099
|
+
if (provider === "yandex-disk") return yandexDiskUpload(localPath, remotePath, options);
|
|
3100
|
+
if (provider === "mailru-cloud") return mailruCloudUpload(localPath, remotePath, options);
|
|
3101
|
+
throw new Error(`Провайдер не поддерживается: ${provider}`);
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
async function cloudDownload(provider, remotePath, outputPath) {
|
|
3105
|
+
if (provider === "yandex-disk") return yandexDiskDownload(remotePath, outputPath);
|
|
3106
|
+
if (provider === "mailru-cloud") return mailruCloudDownload(remotePath, outputPath);
|
|
3107
|
+
throw new Error(`Провайдер не поддерживается: ${provider}`);
|
|
3108
|
+
}
|
|
3109
|
+
|
|
3110
|
+
async function cloudFind(provider, query, options = {}) {
|
|
3111
|
+
if (provider === "yandex-disk") return yandexDiskFind(query, options);
|
|
3112
|
+
if (provider === "mailru-cloud") {
|
|
3113
|
+
const rows = await mailruCloudList(options.path || CLOUD_DEFAULT_REMOTE_DIR);
|
|
3114
|
+
return rows.filter((row) => normalizeGeoText(`${row.name} ${row.path}`).includes(normalizeGeoText(query))).slice(0, Number(options.limit || 50));
|
|
3115
|
+
}
|
|
3116
|
+
throw new Error(`Провайдер не поддерживается: ${provider}`);
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
async function cloudShare(provider, remotePath) {
|
|
3120
|
+
if (provider === "yandex-disk") return yandexDiskShare(remotePath);
|
|
3121
|
+
throw new Error("Публичные ссылки через CLI сейчас поддерживаются только для Яндекс Диска.");
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
async function yandexDiskRequest(method, apiPath, options = {}) {
|
|
3125
|
+
const credentials = await requireCloudCredentials("yandex-disk");
|
|
3126
|
+
const url = new URL(`https://cloud-api.yandex.net/v1/disk${apiPath}`);
|
|
3127
|
+
for (const [key, value] of Object.entries(options.query || {})) {
|
|
3128
|
+
if (value !== undefined && value !== "") url.searchParams.set(key, String(value));
|
|
3129
|
+
}
|
|
3130
|
+
const response = await fetch(url, {
|
|
3131
|
+
method,
|
|
3132
|
+
headers: {
|
|
3133
|
+
Authorization: `OAuth ${credentials.token}`,
|
|
3134
|
+
...(options.headers || {}),
|
|
3135
|
+
},
|
|
3136
|
+
body: options.body,
|
|
3137
|
+
signal: AbortSignal.timeout(Number(options.timeout || 30000)),
|
|
3138
|
+
});
|
|
3139
|
+
if (!response.ok) {
|
|
3140
|
+
const text = await response.text();
|
|
3141
|
+
throw new Error(`Yandex Disk request failed: ${response.status} ${response.statusText}\n${sanitizeSecretFromText(text, credentials.token)}`);
|
|
3142
|
+
}
|
|
3143
|
+
if (response.status === 204) return {};
|
|
3144
|
+
const text = await response.text();
|
|
3145
|
+
if (!text.trim()) return {};
|
|
3146
|
+
return JSON.parse(text);
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
async function yandexDiskList(remotePath, options = {}) {
|
|
3150
|
+
await ensureYandexDiskDir(remotePath, { allowExisting: true, allowMissingRoot: Boolean(options.allowMissingRoot) });
|
|
3151
|
+
const payload = await yandexDiskRequest("GET", "/resources", { query: { path: normalizeYandexDiskPath(remotePath), limit: 100 } });
|
|
3152
|
+
const items = payload._embedded?.items || [];
|
|
3153
|
+
return items.map((item) => ({
|
|
3154
|
+
type: item.type === "dir" ? "dir" : "file",
|
|
3155
|
+
name: item.name || path.basename(item.path || ""),
|
|
3156
|
+
path: denormalizeYandexDiskPath(item.path || ""),
|
|
3157
|
+
size: item.size || "-",
|
|
3158
|
+
}));
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
async function yandexDiskFind(query, options = {}) {
|
|
3162
|
+
const payload = await yandexDiskRequest("GET", "/resources/files", { query: { limit: Math.max(100, Number(options.limit || 50) * 5), fields: "items.name,items.path,items.size,items.type" } });
|
|
3163
|
+
const needle = normalizeGeoText(query);
|
|
3164
|
+
return (payload.items || [])
|
|
3165
|
+
.map((item) => ({
|
|
3166
|
+
type: item.type === "dir" ? "dir" : "file",
|
|
3167
|
+
name: item.name || path.basename(item.path || ""),
|
|
3168
|
+
path: denormalizeYandexDiskPath(item.path || ""),
|
|
3169
|
+
size: item.size || "-",
|
|
3170
|
+
}))
|
|
3171
|
+
.filter((item) => normalizeGeoText(`${item.name} ${item.path}`).includes(needle))
|
|
3172
|
+
.slice(0, Number(options.limit || 50));
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
async function yandexDiskUpload(localPath, remotePath, options = {}) {
|
|
3176
|
+
const resolved = path.resolve(localPath);
|
|
3177
|
+
const info = await stat(resolved);
|
|
3178
|
+
if (!info.isFile()) throw new Error(`Это не файл: ${localPath}`);
|
|
3179
|
+
await ensureYandexDiskDir(path.dirname(remotePath), { allowExisting: true });
|
|
3180
|
+
const upload = await yandexDiskRequest("GET", "/resources/upload", { query: { path: normalizeYandexDiskPath(remotePath), overwrite: options.overwrite !== false } });
|
|
3181
|
+
const bytes = await readFile(resolved);
|
|
3182
|
+
const response = await fetch(upload.href, { method: upload.method || "PUT", body: bytes, signal: AbortSignal.timeout(120000) });
|
|
3183
|
+
if (!response.ok) throw new Error(`Yandex Disk upload failed: ${response.status} ${response.statusText}`);
|
|
3184
|
+
return { provider: "yandex-disk", local: resolved, remote: remotePath, size: info.size };
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
async function yandexDiskDownload(remotePath, outputPath) {
|
|
3188
|
+
const download = await yandexDiskRequest("GET", "/resources/download", { query: { path: normalizeYandexDiskPath(remotePath) } });
|
|
3189
|
+
const response = await fetch(download.href, { signal: AbortSignal.timeout(120000) });
|
|
3190
|
+
if (!response.ok) throw new Error(`Yandex Disk download failed: ${response.status} ${response.statusText}`);
|
|
3191
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
3192
|
+
const resolved = path.resolve(outputPath);
|
|
3193
|
+
await mkdir(path.dirname(resolved), { recursive: true });
|
|
3194
|
+
await writeFile(resolved, buffer);
|
|
3195
|
+
return { provider: "yandex-disk", remote: remotePath, local: resolved, size: buffer.length };
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
async function yandexDiskShare(remotePath) {
|
|
3199
|
+
await yandexDiskRequest("PUT", "/resources/publish", { query: { path: normalizeYandexDiskPath(remotePath) } });
|
|
3200
|
+
const payload = await yandexDiskRequest("GET", "/resources", { query: { path: normalizeYandexDiskPath(remotePath), fields: "name,path,public_url" } });
|
|
3201
|
+
return { provider: "yandex-disk", remote: remotePath, publicUrl: payload.public_url || "-" };
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
async function ensureYandexDiskDir(remotePath, options = {}) {
|
|
3205
|
+
const normalized = normalizeYandexDiskPath(remotePath || CLOUD_DEFAULT_REMOTE_DIR);
|
|
3206
|
+
const plain = denormalizeYandexDiskPath(normalized);
|
|
3207
|
+
const parts = plain.split("/").filter(Boolean);
|
|
3208
|
+
let current = "";
|
|
3209
|
+
for (const part of parts) {
|
|
3210
|
+
current += `/${part}`;
|
|
3211
|
+
try {
|
|
3212
|
+
await yandexDiskRequest("PUT", "/resources", { query: { path: current } });
|
|
3213
|
+
} catch (error) {
|
|
3214
|
+
const message = String(error?.message || "");
|
|
3215
|
+
if (/409|DiskPathPointsToExistentDirectoryError|уже существует/iu.test(message)) continue;
|
|
3216
|
+
if (options.allowMissingRoot && /404/iu.test(message)) continue;
|
|
3217
|
+
throw error;
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
function normalizeYandexDiskPath(remotePath) {
|
|
3223
|
+
const text = String(remotePath || CLOUD_DEFAULT_REMOTE_DIR).trim().replace(/\\/g, "/");
|
|
3224
|
+
if (text.startsWith("disk:") || text.startsWith("app:")) return text;
|
|
3225
|
+
return text.startsWith("/") ? text : `/${text}`;
|
|
3226
|
+
}
|
|
3227
|
+
|
|
3228
|
+
function denormalizeYandexDiskPath(remotePath) {
|
|
3229
|
+
return String(remotePath || "").replace(/^disk:/u, "").replace(/^app:/u, "") || "/";
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
async function mailruCloudRequest(method, remotePath, options = {}) {
|
|
3233
|
+
const credentials = await requireCloudCredentials("mailru-cloud");
|
|
3234
|
+
const baseUrl = String(credentials.baseUrl || "https://webdav.cloud.mail.ru").replace(/\/+$/u, "");
|
|
3235
|
+
const url = `${baseUrl}${encodeWebDavPath(remotePath)}`;
|
|
3236
|
+
const response = await fetch(url, {
|
|
3237
|
+
method,
|
|
3238
|
+
headers: {
|
|
3239
|
+
Authorization: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString("base64")}`,
|
|
3240
|
+
...(options.headers || {}),
|
|
3241
|
+
},
|
|
3242
|
+
body: options.body,
|
|
3243
|
+
signal: AbortSignal.timeout(Number(options.timeout || 30000)),
|
|
3244
|
+
});
|
|
3245
|
+
if (!response.ok && !(method === "MKCOL" && response.status === 405)) {
|
|
3246
|
+
const text = await response.text().catch(() => "");
|
|
3247
|
+
throw new Error(`Mail.ru Cloud WebDAV request failed: ${response.status} ${response.statusText}\n${sanitizeSecretFromText(text, credentials.password)}`);
|
|
3248
|
+
}
|
|
3249
|
+
return response;
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
async function mailruCloudList(remotePath, options = {}) {
|
|
3253
|
+
await ensureMailruCloudDir(remotePath, { allowMissingRoot: Boolean(options.allowMissingRoot) });
|
|
3254
|
+
const response = await mailruCloudRequest("PROPFIND", remotePath, { headers: { Depth: "1" } });
|
|
3255
|
+
const text = await response.text();
|
|
3256
|
+
return parseWebDavList(text, remotePath);
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
async function mailruCloudUpload(localPath, remotePath) {
|
|
3260
|
+
const resolved = path.resolve(localPath);
|
|
3261
|
+
const info = await stat(resolved);
|
|
3262
|
+
if (!info.isFile()) throw new Error(`Это не файл: ${localPath}`);
|
|
3263
|
+
await ensureMailruCloudDir(path.dirname(remotePath));
|
|
3264
|
+
const bytes = await readFile(resolved);
|
|
3265
|
+
await mailruCloudRequest("PUT", remotePath, { body: bytes, timeout: 120000 });
|
|
3266
|
+
return { provider: "mailru-cloud", local: resolved, remote: remotePath, size: info.size };
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
async function mailruCloudDownload(remotePath, outputPath) {
|
|
3270
|
+
const response = await mailruCloudRequest("GET", remotePath, { timeout: 120000 });
|
|
3271
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
3272
|
+
const resolved = path.resolve(outputPath);
|
|
3273
|
+
await mkdir(path.dirname(resolved), { recursive: true });
|
|
3274
|
+
await writeFile(resolved, buffer);
|
|
3275
|
+
return { provider: "mailru-cloud", remote: remotePath, local: resolved, size: buffer.length };
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
async function ensureMailruCloudDir(remotePath, options = {}) {
|
|
3279
|
+
const parts = String(remotePath || CLOUD_DEFAULT_REMOTE_DIR).replace(/\\/g, "/").split("/").filter(Boolean);
|
|
3280
|
+
let current = "";
|
|
3281
|
+
for (const part of parts) {
|
|
3282
|
+
current += `/${part}`;
|
|
3283
|
+
try {
|
|
3284
|
+
await mailruCloudRequest("MKCOL", current);
|
|
3285
|
+
} catch (error) {
|
|
3286
|
+
const message = String(error?.message || "");
|
|
3287
|
+
if (/405|409/iu.test(message)) continue;
|
|
3288
|
+
if (options.allowMissingRoot && /404/iu.test(message)) continue;
|
|
3289
|
+
throw error;
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
function encodeWebDavPath(remotePath) {
|
|
3295
|
+
const normalized = String(remotePath || "/").replace(/\\/g, "/");
|
|
3296
|
+
const segments = normalized.split("/").filter(Boolean).map(encodeURIComponent);
|
|
3297
|
+
return `/${segments.join("/")}${normalized.endsWith("/") ? "/" : ""}`;
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
function parseWebDavList(xml, basePath) {
|
|
3301
|
+
const responses = String(xml || "").split(/<[^:>]*:?response[^>]*>/iu).slice(1);
|
|
3302
|
+
const rows = [];
|
|
3303
|
+
for (const response of responses) {
|
|
3304
|
+
const href = decodeXml(stripXmlTags(response.match(/<[^:>]*:?href[^>]*>([\s\S]*?)<\/[^:>]*:?href>/iu)?.[1] || ""));
|
|
3305
|
+
const name = decodeXml(stripXmlTags(response.match(/<[^:>]*:?displayname[^>]*>([\s\S]*?)<\/[^:>]*:?displayname>/iu)?.[1] || "")) || path.basename(href);
|
|
3306
|
+
const size = decodeXml(stripXmlTags(response.match(/<[^:>]*:?getcontentlength[^>]*>([\s\S]*?)<\/[^:>]*:?getcontentlength>/iu)?.[1] || ""));
|
|
3307
|
+
const isDir = /<[^:>]*:?collection\s*\/?>/iu.test(response);
|
|
3308
|
+
const normalizedPath = decodeURIComponent(href || "");
|
|
3309
|
+
if (normalizeGeoText(normalizedPath).replace(/\s+/g, "") === normalizeGeoText(basePath).replace(/\s+/g, "")) continue;
|
|
3310
|
+
rows.push({ type: isDir ? "dir" : "file", name, path: normalizedPath, size: size || "-" });
|
|
3311
|
+
}
|
|
3312
|
+
return rows;
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3315
|
+
function stripXmlTags(value) {
|
|
3316
|
+
return String(value || "").replace(/<[^>]+>/g, "").trim();
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
function decodeXml(value) {
|
|
3320
|
+
return String(value || "")
|
|
3321
|
+
.replace(/&/g, "&")
|
|
3322
|
+
.replace(/</g, "<")
|
|
3323
|
+
.replace(/>/g, ">")
|
|
3324
|
+
.replace(/"/g, "\"")
|
|
3325
|
+
.replace(/'/g, "'");
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
async function cloudBackup(provider) {
|
|
3329
|
+
const config = await loadConfig();
|
|
3330
|
+
const payload = {
|
|
3331
|
+
createdAt: new Date().toISOString(),
|
|
3332
|
+
version: getPackageVersion(),
|
|
3333
|
+
note: "Секреты, API-ключи и токены не включены в резервную копию.",
|
|
3334
|
+
config: {
|
|
3335
|
+
api: config.api,
|
|
3336
|
+
ai: { activeProfile: config.ai?.activeProfile, provider: config.ai?.provider, model: config.ai?.model, profiles: config.ai?.profiles },
|
|
3337
|
+
files: config.files,
|
|
3338
|
+
skills: config.skills,
|
|
3339
|
+
cloud: config.cloud,
|
|
3340
|
+
},
|
|
3341
|
+
};
|
|
3342
|
+
const tempPath = path.join(CONFIG_DIR, `iola-backup-${timestampForFile()}.json`);
|
|
3343
|
+
await writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
3344
|
+
try {
|
|
3345
|
+
return cloudUpload(provider, tempPath, `${cloudRootForProvider(provider)}/backup/${path.basename(tempPath)}`, { overwrite: true });
|
|
3346
|
+
} finally {
|
|
3347
|
+
await rm(tempPath, { force: true }).catch(() => {});
|
|
3348
|
+
}
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
function timestampForFile() {
|
|
3352
|
+
return new Date().toISOString().replace(/[:.]/g, "-");
|
|
3353
|
+
}
|
|
3354
|
+
|
|
2882
3355
|
async function handleArchive(args) {
|
|
2883
3356
|
const [action = "doctor", target, ...rest] = args;
|
|
2884
3357
|
const options = parseOptions(rest);
|
|
@@ -5343,6 +5816,36 @@ async function handleGeo(args) {
|
|
|
5343
5816
|
return;
|
|
5344
5817
|
}
|
|
5345
5818
|
|
|
5819
|
+
if (command === "nearby") {
|
|
5820
|
+
await geoNearby([subcommand, ...rest].filter(Boolean));
|
|
5821
|
+
return;
|
|
5822
|
+
}
|
|
5823
|
+
|
|
5824
|
+
if (command === "distance") {
|
|
5825
|
+
await geoDistance([subcommand, ...rest].filter(Boolean));
|
|
5826
|
+
return;
|
|
5827
|
+
}
|
|
5828
|
+
|
|
5829
|
+
if (command === "map-link") {
|
|
5830
|
+
await geoMapLink([subcommand, ...rest].filter(Boolean));
|
|
5831
|
+
return;
|
|
5832
|
+
}
|
|
5833
|
+
|
|
5834
|
+
if (command === "resolve") {
|
|
5835
|
+
await geoResolve([subcommand, ...rest].filter(Boolean));
|
|
5836
|
+
return;
|
|
5837
|
+
}
|
|
5838
|
+
|
|
5839
|
+
if (command === "route-context") {
|
|
5840
|
+
await geoRouteContext([subcommand, ...rest].filter(Boolean));
|
|
5841
|
+
return;
|
|
5842
|
+
}
|
|
5843
|
+
|
|
5844
|
+
if (command === "services") {
|
|
5845
|
+
await geoServices([subcommand, ...rest].filter(Boolean));
|
|
5846
|
+
return;
|
|
5847
|
+
}
|
|
5848
|
+
|
|
5346
5849
|
if (command === "doctor" || command === "status") {
|
|
5347
5850
|
await printGeoKeyStatus({ check: command === "doctor" });
|
|
5348
5851
|
return;
|
|
@@ -5353,7 +5856,13 @@ async function handleGeo(args) {
|
|
|
5353
5856
|
iola geo key status
|
|
5354
5857
|
iola geo key doctor
|
|
5355
5858
|
iola geo key delete yandex
|
|
5356
|
-
iola geo geocode "Йошкар-Ола, ул. Петрова, 15"
|
|
5859
|
+
iola geo geocode "Йошкар-Ола, ул. Петрова, 15"
|
|
5860
|
+
iola geo nearby "Йошкар-Ола, ул. Петрова, 15" --dataset all --limit 5
|
|
5861
|
+
iola geo distance --from "Йошкар-Ола, ул. Петрова, 15" --to "школа 7"
|
|
5862
|
+
iola geo map-link "школа 7"
|
|
5863
|
+
iola geo resolve "садик золотой петушок"
|
|
5864
|
+
iola geo route-context "школа 7"
|
|
5865
|
+
iola geo services "Йошкар-Ола, ул. Петрова, 15"`);
|
|
5357
5866
|
}
|
|
5358
5867
|
|
|
5359
5868
|
async function handleGeoKey(args) {
|
|
@@ -5433,6 +5942,56 @@ async function geoGeocode(args) {
|
|
|
5433
5942
|
printKeyValue(result);
|
|
5434
5943
|
}
|
|
5435
5944
|
|
|
5945
|
+
async function geoNearby(args) {
|
|
5946
|
+
const options = parseOptions(args);
|
|
5947
|
+
const query = options._.join(" ").trim();
|
|
5948
|
+
if (!query) throw new Error('Адрес обязателен. Пример: iola geo nearby "Йошкар-Ола, ул. Петрова, 15" --dataset schools');
|
|
5949
|
+
const answer = await buildNearbyAnswer(query, {
|
|
5950
|
+
dataset: normalizeGeoDataset(options.dataset || inferGeoDataset(query)),
|
|
5951
|
+
limit: Number(options.limit || 5),
|
|
5952
|
+
radius: Number(options.radius || 0),
|
|
5953
|
+
});
|
|
5954
|
+
console.log(answer);
|
|
5955
|
+
}
|
|
5956
|
+
|
|
5957
|
+
async function geoDistance(args) {
|
|
5958
|
+
const options = parseOptions(args);
|
|
5959
|
+
const from = options.from || options.address || options._.join(" ").split(/\s+(?:до|и|->)\s+/iu)[0]?.trim();
|
|
5960
|
+
const to = options.to || options._.join(" ").split(/\s+(?:до|и|->)\s+/iu)[1]?.trim();
|
|
5961
|
+
if (!from || !to) throw new Error('Нужны две точки. Пример: iola geo distance --from "Петрова 15" --to "школа 7"');
|
|
5962
|
+
const answer = await buildDistanceAnswer(from, to);
|
|
5963
|
+
console.log(answer);
|
|
5964
|
+
}
|
|
5965
|
+
|
|
5966
|
+
async function geoMapLink(args) {
|
|
5967
|
+
const query = args.join(" ").trim();
|
|
5968
|
+
if (!query) throw new Error('Объект или адрес обязателен. Пример: iola geo map-link "школа 7"');
|
|
5969
|
+
const answer = await buildMapLinkAnswer(query);
|
|
5970
|
+
console.log(answer);
|
|
5971
|
+
}
|
|
5972
|
+
|
|
5973
|
+
async function geoResolve(args) {
|
|
5974
|
+
const query = args.join(" ").trim();
|
|
5975
|
+
if (!query) throw new Error('Место или объект обязателен. Пример: iola geo resolve "садик золотой петушок"');
|
|
5976
|
+
const answer = await buildPlaceResolverAnswer(query);
|
|
5977
|
+
console.log(answer);
|
|
5978
|
+
}
|
|
5979
|
+
|
|
5980
|
+
async function geoRouteContext(args) {
|
|
5981
|
+
const query = args.join(" ").trim();
|
|
5982
|
+
if (!query) throw new Error('Объект или адрес обязателен. Пример: iola geo route-context "школа 7"');
|
|
5983
|
+
const answer = await buildRouteContextAnswer(query);
|
|
5984
|
+
console.log(answer);
|
|
5985
|
+
}
|
|
5986
|
+
|
|
5987
|
+
async function geoServices(args) {
|
|
5988
|
+
const options = parseOptions(args);
|
|
5989
|
+
const query = options._.join(" ").trim();
|
|
5990
|
+
if (!query) throw new Error('Адрес обязателен. Пример: iola geo services "Йошкар-Ола, ул. Петрова, 15"');
|
|
5991
|
+
const answer = await buildAddressToServicesAnswer(query, { limit: Number(options.limit || 3) });
|
|
5992
|
+
console.log(answer);
|
|
5993
|
+
}
|
|
5994
|
+
|
|
5436
5995
|
async function checkYandexGeocoderKey(options = {}) {
|
|
5437
5996
|
try {
|
|
5438
5997
|
const result = await callYandexGeocoder("Йошкар-Ола");
|
|
@@ -5462,12 +6021,7 @@ async function callYandexGeocoder(query) {
|
|
|
5462
6021
|
url.searchParams.set("lang", "ru_RU");
|
|
5463
6022
|
url.searchParams.set("results", "1");
|
|
5464
6023
|
|
|
5465
|
-
|
|
5466
|
-
try {
|
|
5467
|
-
response = await fetch(url, { signal: AbortSignal.timeout(15000) });
|
|
5468
|
-
} catch (error) {
|
|
5469
|
-
throw new Error(formatProviderFetchError("Yandex Geocoder", error));
|
|
5470
|
-
}
|
|
6024
|
+
const response = await fetchYandexGeocoderWithRetry(url);
|
|
5471
6025
|
|
|
5472
6026
|
if (!response.ok) {
|
|
5473
6027
|
const text = await response.text();
|
|
@@ -5492,6 +6046,401 @@ async function callYandexGeocoder(query) {
|
|
|
5492
6046
|
};
|
|
5493
6047
|
}
|
|
5494
6048
|
|
|
6049
|
+
async function fetchYandexGeocoderWithRetry(url) {
|
|
6050
|
+
let lastError = null;
|
|
6051
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
6052
|
+
try {
|
|
6053
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(15000) });
|
|
6054
|
+
if (response.status !== 429 || attempt === 2) return response;
|
|
6055
|
+
await sleep(700 * (attempt + 1));
|
|
6056
|
+
} catch (error) {
|
|
6057
|
+
lastError = error;
|
|
6058
|
+
if (attempt === 2) break;
|
|
6059
|
+
await sleep(500 * (attempt + 1));
|
|
6060
|
+
}
|
|
6061
|
+
}
|
|
6062
|
+
throw new Error(formatProviderFetchError("Yandex Geocoder", lastError));
|
|
6063
|
+
}
|
|
6064
|
+
|
|
6065
|
+
const geoMemoryCache = new Map();
|
|
6066
|
+
|
|
6067
|
+
async function buildGeoDirectAnswer(question) {
|
|
6068
|
+
const normalized = String(question || "").toLocaleLowerCase("ru-RU");
|
|
6069
|
+
if (!isGeoQuestion(normalized)) return "";
|
|
6070
|
+
const place = detectEducationPlace(question);
|
|
6071
|
+
if (place && !place.supported && /(школ|сош|лице|гимнази|сад|детсад|детск\w*\s+сад|садик)/iu.test(normalized)) {
|
|
6072
|
+
return formatUnsupportedEducationGeoPlace(place);
|
|
6073
|
+
}
|
|
6074
|
+
|
|
6075
|
+
try {
|
|
6076
|
+
if (/(расстояни|как далеко|далеко ли|ближе)/iu.test(normalized)) {
|
|
6077
|
+
const pair = extractDistancePair(question);
|
|
6078
|
+
if (pair.from && pair.to) return buildDistanceAnswer(pair.from, pair.to);
|
|
6079
|
+
}
|
|
6080
|
+
|
|
6081
|
+
if (/(карт[аеу]|ссылк.*карт|открыть.*карт)/iu.test(normalized)) {
|
|
6082
|
+
const query = cleanupGeoObjectQuery(question);
|
|
6083
|
+
if (query) return buildMapLinkAnswer(query);
|
|
6084
|
+
}
|
|
6085
|
+
|
|
6086
|
+
if (/(где находится|как пройти|как добраться|что рядом|ориентир)/iu.test(normalized)) {
|
|
6087
|
+
const query = cleanupGeoObjectQuery(question);
|
|
6088
|
+
if (query) return buildRouteContextAnswer(query);
|
|
6089
|
+
}
|
|
6090
|
+
|
|
6091
|
+
if (/(мой адрес|живу|по адресу)/iu.test(normalized)) {
|
|
6092
|
+
const address = extractAddressForNearby(question);
|
|
6093
|
+
if (address) return buildAddressToServicesAnswer(address, { limit: 3 });
|
|
6094
|
+
}
|
|
6095
|
+
|
|
6096
|
+
if (/(рядом|поблизости|ближайш|ближе|около|возле)/iu.test(normalized)) {
|
|
6097
|
+
const address = extractAddressForNearby(question);
|
|
6098
|
+
if (address) return buildNearbyAnswer(address, { dataset: normalizeGeoDataset(inferGeoDataset(question)), limit: 5 });
|
|
6099
|
+
}
|
|
6100
|
+
|
|
6101
|
+
if (/(уточни место|какой населенный пункт|какой район|фильтр.*район|фильтр.*населен|не путай.*населен)/iu.test(normalized)) {
|
|
6102
|
+
const query = cleanupGeoObjectQuery(question);
|
|
6103
|
+
if (query) return buildPlaceResolverAnswer(query);
|
|
6104
|
+
}
|
|
6105
|
+
} catch (error) {
|
|
6106
|
+
if (/Yandex Geocoder API key не найден/iu.test(String(error?.message || ""))) return "";
|
|
6107
|
+
return `Не смог выполнить geo-запрос: ${error instanceof Error ? error.message : String(error)}`;
|
|
6108
|
+
}
|
|
6109
|
+
|
|
6110
|
+
return "";
|
|
6111
|
+
}
|
|
6112
|
+
|
|
6113
|
+
function formatUnsupportedEducationGeoPlace(place) {
|
|
6114
|
+
return `В текущих открытых данных iola-cli есть данные городского округа Йошкар-Ола. Данных по ${place.locative || place.label} в подключенных слоях нет, поэтому не могу надежно ответить по этому объекту.`;
|
|
6115
|
+
}
|
|
6116
|
+
|
|
6117
|
+
function isGeoQuestion(normalized) {
|
|
6118
|
+
return /(рядом|поблизости|ближайш|ближе|расстояни|как далеко|карт[аеу]|где находится|как пройти|как добраться|мой адрес|живу|по адресу|ориентир|уточни место|какой населенный пункт|какой район|фильтр.*район|фильтр.*населен)/iu.test(normalized);
|
|
6119
|
+
}
|
|
6120
|
+
|
|
6121
|
+
async function buildNearbyAnswer(address, options = {}) {
|
|
6122
|
+
const origin = await resolveGeoPoint(address);
|
|
6123
|
+
const dataset = normalizeGeoDataset(options.dataset || "all");
|
|
6124
|
+
const limit = Math.max(1, Number(options.limit || 5));
|
|
6125
|
+
const radius = Number(options.radius || 0);
|
|
6126
|
+
const candidates = await getGeoCandidates(dataset);
|
|
6127
|
+
const ranked = (await asyncMapLimit(candidates, 6, async (item) => {
|
|
6128
|
+
const point = item.address
|
|
6129
|
+
? await geocodeCached(normalizeAddressForGeocoder(item.address)).catch(() => null)
|
|
6130
|
+
: await resolveGeoPoint(item.name).catch(() => null);
|
|
6131
|
+
if (!point?.lat || !point?.lon) return null;
|
|
6132
|
+
const distanceMeters = haversineMeters(origin, point);
|
|
6133
|
+
return { ...item, point, distanceMeters };
|
|
6134
|
+
})).filter(Boolean)
|
|
6135
|
+
.filter((item) => !radius || item.distanceMeters <= radius)
|
|
6136
|
+
.sort((a, b) => a.distanceMeters - b.distanceMeters)
|
|
6137
|
+
.slice(0, limit);
|
|
6138
|
+
|
|
6139
|
+
if (ranked.length === 0) return `Рядом с адресом "${address}" не нашел объектов в подключенных слоях.`;
|
|
6140
|
+
|
|
6141
|
+
return [
|
|
6142
|
+
`Ближайшие объекты к адресу: ${origin.address || address}`,
|
|
6143
|
+
...ranked.map((item, index) => `${index + 1}. ${item.name} — ${formatDistance(item.distanceMeters)}${item.address ? `\n Адрес: ${item.address}` : ""}${item.inn ? `\n ИНН: ${item.inn}` : ""}${item.point.map ? `\n Карта: ${item.point.map}` : ""}`),
|
|
6144
|
+
`Источник: Yandex Geocoder + слои ${dataset === "all" ? "schools, kindergartens" : dataset}.`,
|
|
6145
|
+
].join("\n");
|
|
6146
|
+
}
|
|
6147
|
+
|
|
6148
|
+
async function buildDistanceAnswer(fromQuery, toQuery) {
|
|
6149
|
+
const from = await resolveGeoPoint(fromQuery);
|
|
6150
|
+
const to = await resolveGeoPoint(toQuery);
|
|
6151
|
+
const distance = haversineMeters(from, to);
|
|
6152
|
+
return [
|
|
6153
|
+
`Расстояние по прямой: ${formatDistance(distance)}.`,
|
|
6154
|
+
`От: ${from.name || from.address || fromQuery}${from.address && from.address !== from.name ? ` (${from.address})` : ""}`,
|
|
6155
|
+
`До: ${to.name || to.address || toQuery}${to.address && to.address !== to.name ? ` (${to.address})` : ""}`,
|
|
6156
|
+
to.map ? `Карта точки назначения: ${to.map}` : "",
|
|
6157
|
+
"Это расстояние по координатам, не маршрут по дорогам.",
|
|
6158
|
+
].filter(Boolean).join("\n");
|
|
6159
|
+
}
|
|
6160
|
+
|
|
6161
|
+
async function buildMapLinkAnswer(query) {
|
|
6162
|
+
const point = await resolveGeoPoint(query);
|
|
6163
|
+
return [
|
|
6164
|
+
point.name || query,
|
|
6165
|
+
point.address ? `Адрес: ${point.address}` : "",
|
|
6166
|
+
point.coordinates ? `Координаты: ${point.coordinates}` : "",
|
|
6167
|
+
point.map ? `Карта: ${point.map}` : "",
|
|
6168
|
+
].filter(Boolean).join("\n");
|
|
6169
|
+
}
|
|
6170
|
+
|
|
6171
|
+
async function buildPlaceResolverAnswer(query) {
|
|
6172
|
+
const match = await resolveGeoEntity(query).catch(() => null);
|
|
6173
|
+
if (match) {
|
|
6174
|
+
return [
|
|
6175
|
+
`Нашел объект: ${match.name}`,
|
|
6176
|
+
match.address ? `Адрес: ${match.address}` : "",
|
|
6177
|
+
match.inn ? `ИНН: ${match.inn}` : "",
|
|
6178
|
+
match.layer ? `Слой: ${match.layer}` : "",
|
|
6179
|
+
match.map ? `Карта: ${match.map}` : "",
|
|
6180
|
+
].filter(Boolean).join("\n");
|
|
6181
|
+
}
|
|
6182
|
+
const point = await callYandexGeocoder(query);
|
|
6183
|
+
if (!point) return `Не смог уточнить место: ${query}`;
|
|
6184
|
+
return [
|
|
6185
|
+
`Уточнил место через геокодер: ${point.name || query}`,
|
|
6186
|
+
point.address ? `Адрес: ${point.address}` : "",
|
|
6187
|
+
point.precision ? `Точность: ${point.precision}` : "",
|
|
6188
|
+
point.coordinates ? `Координаты: ${point.coordinates}` : "",
|
|
6189
|
+
point.map ? `Карта: ${point.map}` : "",
|
|
6190
|
+
].filter(Boolean).join("\n");
|
|
6191
|
+
}
|
|
6192
|
+
|
|
6193
|
+
async function buildRouteContextAnswer(query) {
|
|
6194
|
+
const point = await resolveGeoPoint(query);
|
|
6195
|
+
const nearby = await buildNearbyAnswer(point.address || query, { dataset: "all", limit: 3 });
|
|
6196
|
+
return [
|
|
6197
|
+
`${point.name || query}`,
|
|
6198
|
+
point.address ? `Адрес: ${point.address}` : "",
|
|
6199
|
+
point.coordinates ? `Координаты: ${point.coordinates}` : "",
|
|
6200
|
+
point.map ? `Карта: ${point.map}` : "",
|
|
6201
|
+
"",
|
|
6202
|
+
nearby,
|
|
6203
|
+
].filter(Boolean).join("\n");
|
|
6204
|
+
}
|
|
6205
|
+
|
|
6206
|
+
async function buildAddressToServicesAnswer(address, options = {}) {
|
|
6207
|
+
const limit = Math.max(1, Number(options.limit || 3));
|
|
6208
|
+
const schools = await buildNearbyAnswer(address, { dataset: "schools", limit });
|
|
6209
|
+
const kindergartens = await buildNearbyAnswer(address, { dataset: "kindergartens", limit });
|
|
6210
|
+
return [
|
|
6211
|
+
`По адресу "${address}" ближайшие подключенные городские объекты:`,
|
|
6212
|
+
"",
|
|
6213
|
+
"Школы:",
|
|
6214
|
+
stripNearbyHeader(schools),
|
|
6215
|
+
"",
|
|
6216
|
+
"Детские сады:",
|
|
6217
|
+
stripNearbyHeader(kindergartens),
|
|
6218
|
+
].join("\n");
|
|
6219
|
+
}
|
|
6220
|
+
|
|
6221
|
+
function stripNearbyHeader(text) {
|
|
6222
|
+
return String(text || "").split("\n").filter((line) => !line.startsWith("Ближайшие объекты к адресу:")).join("\n");
|
|
6223
|
+
}
|
|
6224
|
+
|
|
6225
|
+
async function resolveGeoPoint(query) {
|
|
6226
|
+
const entity = await resolveGeoEntity(query).catch(() => null);
|
|
6227
|
+
if (entity?.address) {
|
|
6228
|
+
const point = await geocodeCached(normalizeAddressForGeocoder(entity.address));
|
|
6229
|
+
return mergeGeoEntityPoint(entity, point);
|
|
6230
|
+
}
|
|
6231
|
+
return geocodeCached(query);
|
|
6232
|
+
}
|
|
6233
|
+
|
|
6234
|
+
async function resolveGeoEntity(query) {
|
|
6235
|
+
const dataset = normalizeGeoDataset(inferGeoDataset(query));
|
|
6236
|
+
let candidates = await getGeoCandidates(dataset);
|
|
6237
|
+
const normalized = normalizeGeoText(query);
|
|
6238
|
+
const number = extractEntityNumberFromQuestion(query, dataset === "kindergartens" ? "kindergartens" : "schools");
|
|
6239
|
+
const place = detectEducationPlace(query);
|
|
6240
|
+
if (place && !place.supported) return null;
|
|
6241
|
+
if (place?.supported) {
|
|
6242
|
+
const placeMatches = candidates.filter((item) => geoItemMatchesPlace(item, place));
|
|
6243
|
+
if (placeMatches.length > 0) candidates = placeMatches;
|
|
6244
|
+
}
|
|
6245
|
+
if (number) {
|
|
6246
|
+
const exactNumberMatches = candidates.filter((item) => itemNameHasNumber(item, number));
|
|
6247
|
+
if (exactNumberMatches.length === 1) {
|
|
6248
|
+
const point = exactNumberMatches[0].address ? await geocodeCached(normalizeAddressForGeocoder(exactNumberMatches[0].address)).catch(() => null) : null;
|
|
6249
|
+
return mergeGeoEntityPoint(exactNumberMatches[0], point);
|
|
6250
|
+
}
|
|
6251
|
+
if (exactNumberMatches.length > 1) {
|
|
6252
|
+
const scoped = place?.supported
|
|
6253
|
+
? exactNumberMatches.filter((item) => geoItemMatchesPlace(item, place))
|
|
6254
|
+
: exactNumberMatches;
|
|
6255
|
+
const bestExact = scoped[0] || exactNumberMatches[0];
|
|
6256
|
+
const point = bestExact.address ? await geocodeCached(normalizeAddressForGeocoder(bestExact.address)).catch(() => null) : null;
|
|
6257
|
+
return mergeGeoEntityPoint(bestExact, point);
|
|
6258
|
+
}
|
|
6259
|
+
}
|
|
6260
|
+
const scored = candidates.map((item) => ({ ...item, score: scoreGeoCandidate(item, normalized, number, place) }))
|
|
6261
|
+
.filter((item) => item.score > 0)
|
|
6262
|
+
.sort((a, b) => b.score - a.score);
|
|
6263
|
+
const best = scored[0];
|
|
6264
|
+
if (!best) return null;
|
|
6265
|
+
const point = best.address ? await geocodeCached(normalizeAddressForGeocoder(best.address)).catch(() => null) : null;
|
|
6266
|
+
return mergeGeoEntityPoint(best, point);
|
|
6267
|
+
}
|
|
6268
|
+
|
|
6269
|
+
function mergeGeoEntityPoint(entity, point) {
|
|
6270
|
+
if (!point) return entity;
|
|
6271
|
+
return {
|
|
6272
|
+
...entity,
|
|
6273
|
+
geoName: point.name || "",
|
|
6274
|
+
geocodedAddress: point.address || "",
|
|
6275
|
+
precision: point.precision || "",
|
|
6276
|
+
coordinates: point.coordinates || "",
|
|
6277
|
+
map: point.map || "",
|
|
6278
|
+
lat: point.lat,
|
|
6279
|
+
lon: point.lon,
|
|
6280
|
+
};
|
|
6281
|
+
}
|
|
6282
|
+
|
|
6283
|
+
function scoreGeoCandidate(item, normalizedQuery, number, place) {
|
|
6284
|
+
const name = normalizeGeoText(item.name);
|
|
6285
|
+
const address = normalizeGeoText(item.address);
|
|
6286
|
+
let score = 0;
|
|
6287
|
+
if (number && itemNameHasNumber(item, number)) score += 20;
|
|
6288
|
+
for (const token of normalizeGeoText(normalizedQuery).split(/\s+/).filter((part) => part.length >= 3)) {
|
|
6289
|
+
if (name.includes(token)) score += 3;
|
|
6290
|
+
if (address.includes(token)) score += 1;
|
|
6291
|
+
}
|
|
6292
|
+
if (place?.supported && geoItemMatchesPlace(item, place)) score += 8;
|
|
6293
|
+
if (/(сад|детсад|садик)/iu.test(normalizedQuery) && item.layer === "kindergartens") score += 5;
|
|
6294
|
+
if (/(школ|лице|гимнази)/iu.test(normalizedQuery) && item.layer === "schools") score += 5;
|
|
6295
|
+
return score;
|
|
6296
|
+
}
|
|
6297
|
+
|
|
6298
|
+
function geoItemMatchesPlace(item, place) {
|
|
6299
|
+
if (!place?.supported) return false;
|
|
6300
|
+
const haystack = normalizeGeoText(`${item.name || ""} ${item.address || ""}`);
|
|
6301
|
+
const aliases = [place.label, ...(place.aliases || [])].map(normalizeGeoText).filter(Boolean);
|
|
6302
|
+
return aliases.some((alias) => haystack.includes(alias));
|
|
6303
|
+
}
|
|
6304
|
+
|
|
6305
|
+
async function getGeoCandidates(dataset = "all") {
|
|
6306
|
+
const layers = dataset === "all" ? ["schools", "kindergartens"] : [dataset];
|
|
6307
|
+
const result = [];
|
|
6308
|
+
for (const layer of layers) {
|
|
6309
|
+
const items = normalizeItems(await fetchAllApiItems(`${await getApiBaseUrl()}/${DATASETS[layer].endpoint}`))
|
|
6310
|
+
.map(selectPublicSummary)
|
|
6311
|
+
.filter((item) => item.name || item.address)
|
|
6312
|
+
.map((item) => ({ ...item, layer }));
|
|
6313
|
+
result.push(...items);
|
|
6314
|
+
}
|
|
6315
|
+
return result;
|
|
6316
|
+
}
|
|
6317
|
+
|
|
6318
|
+
async function geocodeCached(query) {
|
|
6319
|
+
const key = normalizeGeoText(query);
|
|
6320
|
+
if (geoMemoryCache.has(key)) return geoMemoryCache.get(key);
|
|
6321
|
+
const value = await callYandexGeocoder(query);
|
|
6322
|
+
if (!value) throw new Error(`Yandex Geocoder не вернул результат для: ${query}`);
|
|
6323
|
+
const parsed = parseCoordinates(value.coordinates);
|
|
6324
|
+
const point = { ...value, ...parsed };
|
|
6325
|
+
geoMemoryCache.set(key, point);
|
|
6326
|
+
return point;
|
|
6327
|
+
}
|
|
6328
|
+
|
|
6329
|
+
function parseCoordinates(coordinates) {
|
|
6330
|
+
const [lat, lon] = String(coordinates || "").split(",").map((part) => Number(part.trim()));
|
|
6331
|
+
return { lat, lon };
|
|
6332
|
+
}
|
|
6333
|
+
|
|
6334
|
+
async function asyncMapLimit(items, limit, mapper) {
|
|
6335
|
+
const source = Array.from(items || []);
|
|
6336
|
+
if (source.length === 0) return [];
|
|
6337
|
+
const results = new Array(source.length);
|
|
6338
|
+
let next = 0;
|
|
6339
|
+
const workers = Array.from({ length: Math.max(1, Math.min(limit, source.length)) }, async () => {
|
|
6340
|
+
while (next < source.length) {
|
|
6341
|
+
const index = next;
|
|
6342
|
+
next += 1;
|
|
6343
|
+
results[index] = await mapper(source[index], index);
|
|
6344
|
+
}
|
|
6345
|
+
});
|
|
6346
|
+
await Promise.all(workers);
|
|
6347
|
+
return results;
|
|
6348
|
+
}
|
|
6349
|
+
|
|
6350
|
+
function haversineMeters(a, b) {
|
|
6351
|
+
const radius = 6371000;
|
|
6352
|
+
const lat1 = toRadians(Number(a.lat));
|
|
6353
|
+
const lat2 = toRadians(Number(b.lat));
|
|
6354
|
+
const deltaLat = toRadians(Number(b.lat) - Number(a.lat));
|
|
6355
|
+
const deltaLon = toRadians(Number(b.lon) - Number(a.lon));
|
|
6356
|
+
const x = Math.sin(deltaLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
|
|
6357
|
+
return 2 * radius * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x));
|
|
6358
|
+
}
|
|
6359
|
+
|
|
6360
|
+
function toRadians(value) {
|
|
6361
|
+
return value * Math.PI / 180;
|
|
6362
|
+
}
|
|
6363
|
+
|
|
6364
|
+
function formatDistance(meters) {
|
|
6365
|
+
if (!Number.isFinite(meters)) return "-";
|
|
6366
|
+
if (meters < 1000) return `${Math.round(meters)} м`;
|
|
6367
|
+
return `${(meters / 1000).toFixed(meters < 10000 ? 1 : 0)} км`;
|
|
6368
|
+
}
|
|
6369
|
+
|
|
6370
|
+
function normalizeGeoDataset(value) {
|
|
6371
|
+
const text = String(value || "").toLocaleLowerCase("ru-RU");
|
|
6372
|
+
if (text === "schools" || /школ|лице|гимнази/.test(text)) return "schools";
|
|
6373
|
+
if (text === "kindergartens" || /сад|детсад|садик/.test(text)) return "kindergartens";
|
|
6374
|
+
return "all";
|
|
6375
|
+
}
|
|
6376
|
+
|
|
6377
|
+
function inferGeoDataset(text) {
|
|
6378
|
+
const normalized = String(text || "").toLocaleLowerCase("ru-RU");
|
|
6379
|
+
if (/(сад|детсад|садик)/iu.test(normalized)) return "kindergartens";
|
|
6380
|
+
if (/(школ|лице|гимнази)/iu.test(normalized)) return "schools";
|
|
6381
|
+
return "all";
|
|
6382
|
+
}
|
|
6383
|
+
|
|
6384
|
+
function extractAddressForNearby(question) {
|
|
6385
|
+
const text = String(question || "").trim();
|
|
6386
|
+
const match = text.match(/(?:рядом с|рядом|около|возле|поблизости от|живу на|мой адрес|по адресу)\s+(.+)/iu);
|
|
6387
|
+
if (match?.[1]) return cleanupGeoAddress(match[1].split(/[,;]\s*(?:какие|что|где|кто|дай|покажи)/iu)[0]);
|
|
6388
|
+
return cleanupGeoAddress(text);
|
|
6389
|
+
}
|
|
6390
|
+
|
|
6391
|
+
function extractDistancePair(question) {
|
|
6392
|
+
const text = String(question || "").trim();
|
|
6393
|
+
const explicit = {
|
|
6394
|
+
from: text.match(/(?:от|from)\s+(.+?)\s+(?:до|к|to)\s+(.+)/iu)?.[1],
|
|
6395
|
+
to: text.match(/(?:от|from)\s+(.+?)\s+(?:до|к|to)\s+(.+)/iu)?.[2],
|
|
6396
|
+
};
|
|
6397
|
+
if (explicit.from && explicit.to) return { from: cleanupGeoAddress(explicit.from), to: cleanupGeoAddress(explicit.to) };
|
|
6398
|
+
const parts = text.split(/\s+(?:и|до|->)\s+/iu).map(cleanupGeoAddress).filter(Boolean);
|
|
6399
|
+
return { from: parts[0] || "", to: parts[1] || "" };
|
|
6400
|
+
}
|
|
6401
|
+
|
|
6402
|
+
function cleanupGeoObjectQuery(text) {
|
|
6403
|
+
return cleanupGeoAddress(String(text || "")
|
|
6404
|
+
.replace(/^(?:где находится|как пройти|как добраться|покажи|дай|открой|найди|ссылку на карту|ссылка на карту)\s+/iu, "")
|
|
6405
|
+
.replace(/\b(?:на карте|карту|карта|рядом|что рядом|ориентиры?)\b/giu, ""));
|
|
6406
|
+
}
|
|
6407
|
+
|
|
6408
|
+
function cleanupGeoAddress(text) {
|
|
6409
|
+
return String(text || "")
|
|
6410
|
+
.replace(/[?.!]+$/u, "")
|
|
6411
|
+
.replace(/(^|\s)улице(?=\s|$)/giu, "$1улица")
|
|
6412
|
+
.replace(/\b(?:какие|какой|какая|есть|ближайшие|ближайший|школы|школа|детские сады|детский сад|садики|садик|объекты|городские|учреждения|рядом|поблизости)\b/giu, " ")
|
|
6413
|
+
.replace(/\s+/g, " ")
|
|
6414
|
+
.trim();
|
|
6415
|
+
}
|
|
6416
|
+
|
|
6417
|
+
function normalizeAddressForGeocoder(address) {
|
|
6418
|
+
const source = String(address || "").trim();
|
|
6419
|
+
if (!source) return source;
|
|
6420
|
+
let text = source
|
|
6421
|
+
.replace(/^\s*\d{6},?\s*/u, "")
|
|
6422
|
+
.replace(/\bРоссия,\s*/iu, "")
|
|
6423
|
+
.replace(/\bРоссийская Федерация,\s*/iu, "")
|
|
6424
|
+
.replace(/\bРеспублика Марий Эл,\s*/iu, "")
|
|
6425
|
+
.replace(/\bгородской округ\s+город\s+Йошкар-Ола,\s*/iu, "")
|
|
6426
|
+
.replace(/\bГОРОД ЙОШКАР-ОЛА,\s*/iu, "")
|
|
6427
|
+
.replace(/\bЙошкар-Ола\s+г,\s*/iu, "")
|
|
6428
|
+
.replace(/\bгород\s+Йошкар-Ола,\s*/iu, "Йошкар-Ола, ")
|
|
6429
|
+
.replace(/\bсело\s+Сем[её]новка,\s*/iu, "Йошкар-Ола, Семёновка, ")
|
|
6430
|
+
.replace(/\bдом\s+/giu, "д. ")
|
|
6431
|
+
.replace(/\bулица\s+/giu, "ул. ")
|
|
6432
|
+
.replace(/([А-ЯЁ])\.(?=[А-ЯЁ])/gu, "$1. ")
|
|
6433
|
+
.replace(/\s+/g, " ")
|
|
6434
|
+
.replace(/,\s*,/g, ",")
|
|
6435
|
+
.trim();
|
|
6436
|
+
if (!/(йошкар|сем[её]новк)/iu.test(text)) text = `Йошкар-Ола, ${text}`;
|
|
6437
|
+
return text;
|
|
6438
|
+
}
|
|
6439
|
+
|
|
6440
|
+
function normalizeGeoText(text) {
|
|
6441
|
+
return String(text || "").toLocaleLowerCase("ru-RU").replace(/ё/g, "е").replace(/[^\p{L}\p{N}]+/gu, " ").trim();
|
|
6442
|
+
}
|
|
6443
|
+
|
|
5495
6444
|
async function getYandexGeocoderKey() {
|
|
5496
6445
|
if (process.env.YANDEX_GEOCODER_API_KEY || process.env.YANDEX_MAPS_API_KEY) {
|
|
5497
6446
|
return process.env.YANDEX_GEOCODER_API_KEY || process.env.YANDEX_MAPS_API_KEY;
|
|
@@ -6912,6 +7861,20 @@ async function aiAsk(args, context = {}) {
|
|
|
6912
7861
|
const historyEnabled = !options.bare && !options["no-history"] && isFeatureEnabled("sqlite-history");
|
|
6913
7862
|
const sessionId = historyEnabled && isFeatureEnabled("sessions") ? ensureSessionForAsk(options, providerConfig, question) : null;
|
|
6914
7863
|
const history = context.history || (sessionId ? getSessionAiHistory(sessionId) : []);
|
|
7864
|
+
const geoAnswer = await buildGeoDirectAnswer(question);
|
|
7865
|
+
if (geoAnswer) {
|
|
7866
|
+
if (historyEnabled) {
|
|
7867
|
+
recordAskHistory({ question, answer: geoAnswer, providerConfig, dataContext, error: "", sessionId });
|
|
7868
|
+
appendSessionExchange(sessionId, question, geoAnswer, dataContext, "");
|
|
7869
|
+
}
|
|
7870
|
+
emitEvent(options, "answer", { length: geoAnswer.length, sessionId, direct: true, geo: true });
|
|
7871
|
+
if (options.output) {
|
|
7872
|
+
await assertPermission("writeFiles");
|
|
7873
|
+
await writeFile(options.output, geoAnswer, "utf8");
|
|
7874
|
+
}
|
|
7875
|
+
if (!options.quiet) console.log(geoAnswer);
|
|
7876
|
+
return geoAnswer;
|
|
7877
|
+
}
|
|
6915
7878
|
const directAnswer = await buildDirectDataAnswer(question, dataContext);
|
|
6916
7879
|
if (directAnswer) {
|
|
6917
7880
|
if (historyEnabled) {
|
|
@@ -7281,6 +8244,11 @@ async function localToolAsk(question, providerConfig, options) {
|
|
|
7281
8244
|
if (!options.quiet) console.log(casualAnswer);
|
|
7282
8245
|
return casualAnswer;
|
|
7283
8246
|
}
|
|
8247
|
+
const geoAnswer = await buildGeoDirectAnswer(question);
|
|
8248
|
+
if (geoAnswer) {
|
|
8249
|
+
if (!options.quiet) console.log(geoAnswer);
|
|
8250
|
+
return geoAnswer;
|
|
8251
|
+
}
|
|
7284
8252
|
await ensureLocalData();
|
|
7285
8253
|
const personRoleAnswer = buildPersonRoleDirectAnswer(question);
|
|
7286
8254
|
if (personRoleAnswer) {
|
|
@@ -7754,9 +8722,9 @@ function extractEntityNumberFromQuestion(question, layer) {
|
|
|
7754
8722
|
const isKindergarten = layer === "kindergartens";
|
|
7755
8723
|
const isSchool = layer === "schools";
|
|
7756
8724
|
const patterns = isKindergarten
|
|
7757
|
-
? [/(
|
|
8725
|
+
? [/(?:детск[\p{L}\p{N}_-]*\s+сад[\p{L}\p{N}_-]*|детсад[\p{L}\p{N}_-]*|сад[\p{L}\p{N}_-]*)\s*(?:№|номер|n)?\s*(\d{1,4})/iu, /№\s*(\d{1,4})/iu]
|
|
7758
8726
|
: isSchool
|
|
7759
|
-
? [/(
|
|
8727
|
+
? [/(?:школ[\p{L}\p{N}_-]*|сош|гимнази[\p{L}\p{N}_-]*|лице[\p{L}\p{N}_-]*)\s*(?:№|номер|n)?\s*(\d{1,4})/iu, /№\s*(\d{1,4})/iu]
|
|
7760
8728
|
: [/№\s*(\d{1,4})/iu];
|
|
7761
8729
|
for (const pattern of patterns) {
|
|
7762
8730
|
const match = text.match(pattern);
|
|
@@ -9433,6 +10401,14 @@ async function onboard(args = []) {
|
|
|
9433
10401
|
await setYandexGeocoderKey();
|
|
9434
10402
|
}
|
|
9435
10403
|
}
|
|
10404
|
+
if (components.includes("cloud")) {
|
|
10405
|
+
if (process.stdin.isTTY) {
|
|
10406
|
+
const providerAnswer = (await askText("Облачный диск: 1 - Яндекс Диск, 2 - Облако Mail.ru, 0 - пропустить: ")).trim();
|
|
10407
|
+
if (providerAnswer === "1") await setupCloudProvider("yandex-disk");
|
|
10408
|
+
else if (providerAnswer === "2") await setupCloudProvider("mailru-cloud");
|
|
10409
|
+
else console.log("Настройка облачного диска пропущена.");
|
|
10410
|
+
}
|
|
10411
|
+
}
|
|
9436
10412
|
if (components.includes("codex")) {
|
|
9437
10413
|
await installCodexIfMissing();
|
|
9438
10414
|
await aiSetup(["codex"]);
|
|
@@ -9482,6 +10458,7 @@ async function chooseOnboardComponents(status = null) {
|
|
|
9482
10458
|
12: "browser",
|
|
9483
10459
|
13: "ollama",
|
|
9484
10460
|
14: "yandex-geocoder",
|
|
10461
|
+
15: "cloud",
|
|
9485
10462
|
};
|
|
9486
10463
|
return [...selected].map((item) => map[item] || item).filter(Boolean);
|
|
9487
10464
|
} finally {
|
|
@@ -9494,7 +10471,7 @@ function isOnboardExitAnswer(answer) {
|
|
|
9494
10471
|
}
|
|
9495
10472
|
|
|
9496
10473
|
async function getOnboardComponentStatus() {
|
|
9497
|
-
const [config, readiness, browser, archive, codexVersion, ollamaVersion, yandexGeocoderKey] = await Promise.all([
|
|
10474
|
+
const [config, readiness, browser, archive, codexVersion, ollamaVersion, yandexGeocoderKey, cloudSecrets] = await Promise.all([
|
|
9498
10475
|
loadConfig(),
|
|
9499
10476
|
getAiReadiness(),
|
|
9500
10477
|
getBrowserStatus(),
|
|
@@ -9502,6 +10479,7 @@ async function getOnboardComponentStatus() {
|
|
|
9502
10479
|
getCommandVersion("codex", ["--version"]),
|
|
9503
10480
|
getOllamaVersion(),
|
|
9504
10481
|
getYandexGeocoderKey(),
|
|
10482
|
+
loadSecrets().then((secrets) => secrets.cloud || {}),
|
|
9505
10483
|
]);
|
|
9506
10484
|
const workspaceReady = existsSync(PROJECT_CONTEXT_FILE) || existsSync(PROJECT_CONTEXT_DIR_FILE) || existsSync(PROJECT_IOLA_DIR);
|
|
9507
10485
|
const policyReady = (config.toolsets?.enabled || []).includes("analyst");
|
|
@@ -9520,6 +10498,7 @@ async function getOnboardComponentStatus() {
|
|
|
9520
10498
|
index: false,
|
|
9521
10499
|
browser: browser.installed === "yes",
|
|
9522
10500
|
"yandex-geocoder": Boolean(yandexGeocoderKey),
|
|
10501
|
+
cloud: Object.keys(cloudSecrets).length > 0,
|
|
9523
10502
|
};
|
|
9524
10503
|
}
|
|
9525
10504
|
|
|
@@ -9539,6 +10518,7 @@ function onboardComponentRows(status) {
|
|
|
9539
10518
|
["12", "browser", "Browser runtime", "Playwright/Chromium установлен"],
|
|
9540
10519
|
["13", "ollama", "Ollama", "опциональный локальный runtime"],
|
|
9541
10520
|
["14", "yandex-geocoder", "Yandex Geocoder API", "ключ геокодера сохранен или есть в env"],
|
|
10521
|
+
["15", "cloud", "Облачный диск", "Яндекс Диск или Облако Mail.ru"],
|
|
9542
10522
|
];
|
|
9543
10523
|
return rows.map(([number, key, title, hint]) => ({ number, key, title, hint, status: status[key] ? "готово" : "не настроено" }));
|
|
9544
10524
|
}
|
|
@@ -9553,7 +10533,7 @@ function defaultOnboardSelection(status) {
|
|
|
9553
10533
|
}
|
|
9554
10534
|
|
|
9555
10535
|
function defaultOnboardComponents(status) {
|
|
9556
|
-
const map = { 1: "workspace", 2: "policy", 3: "iola", 4: "yandexgpt", 5: "gigachat", 6: "openai", 7: "openrouter", 8: "codex", 9: "codex-mcp", 10: "archive", 11: "index", 12: "browser", 13: "ollama", 14: "yandex-geocoder" };
|
|
10536
|
+
const map = { 1: "workspace", 2: "policy", 3: "iola", 4: "yandexgpt", 5: "gigachat", 6: "openai", 7: "openrouter", 8: "codex", 9: "codex-mcp", 10: "archive", 11: "index", 12: "browser", 13: "ollama", 14: "yandex-geocoder", 15: "cloud" };
|
|
9557
10537
|
return defaultOnboardSelection(status).map((item) => map[item]).filter(Boolean);
|
|
9558
10538
|
}
|
|
9559
10539
|
|
|
@@ -9567,7 +10547,7 @@ function parseOptions(args) {
|
|
|
9567
10547
|
} else if (arg === "--check" || arg === "--upgrade-node") {
|
|
9568
10548
|
result.check = true;
|
|
9569
10549
|
result[arg.slice(2)] = true;
|
|
9570
|
-
} else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--replace" || arg === "--text" || arg === "--path" || arg === "--depth" || arg === "--max-bytes" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--source" || arg === "--command" || arg === "--prompt" || arg === "--description" || arg === "--base-url" || arg === "--repo" || arg === "--model-dir" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config" || arg === "--dataset" || arg === "--save" || arg === "--reasoning" || arg === "--agent" || arg === "--scope" || arg === "--selector" || arg === "--url" || arg === "--timeout" || arg === "--wait" || arg === "--viewport" || arg === "--press" || arg === "--script" || arg === "--auth-url" || arg === "--token-url" || arg === "--userinfo-url" || arg === "--client-id" || arg === "--client-secret" || arg === "--redirect-host" || arg === "--redirect-port" || arg === "--redirect-path" || arg === "--debug-file") {
|
|
10550
|
+
} else if (arg === "--limit" || arg === "--offset" || arg === "--search" || arg === "--replace" || arg === "--text" || arg === "--path" || arg === "--depth" || arg === "--max-bytes" || arg === "--query" || arg === "--where" || arg === "--columns" || arg === "--inn" || arg === "--model" || arg === "--provider" || arg === "--profile" || arg === "--name" || arg === "--source" || arg === "--command" || arg === "--prompt" || arg === "--description" || arg === "--base-url" || arg === "--repo" || arg === "--model-dir" || arg === "--sandbox" || arg === "--approval" || arg === "--cwd" || arg === "--codex-profile" || arg === "--format" || arg === "--output" || arg === "--schema" || arg === "--session" || arg === "--temperature" || arg === "--config" || arg === "--dataset" || arg === "--save" || arg === "--reasoning" || arg === "--agent" || arg === "--scope" || arg === "--selector" || arg === "--url" || arg === "--timeout" || arg === "--wait" || arg === "--viewport" || arg === "--press" || arg === "--script" || arg === "--auth-url" || arg === "--token-url" || arg === "--userinfo-url" || arg === "--client-id" || arg === "--client-secret" || arg === "--redirect-host" || arg === "--redirect-port" || arg === "--redirect-path" || arg === "--debug-file" || arg === "--from" || arg === "--to" || arg === "--radius" || arg === "--address") {
|
|
9571
10551
|
result[arg.slice(2)] = args[index + 1];
|
|
9572
10552
|
index += 1;
|
|
9573
10553
|
} else {
|
|
@@ -9816,6 +10796,7 @@ function selectSkillsForPrompt(config, question = "", options = {}) {
|
|
|
9816
10796
|
const normalized = String(question || "").toLocaleLowerCase("ru-RU");
|
|
9817
10797
|
if (enabled.has("local-model")) selected.add("local-model");
|
|
9818
10798
|
if (enabled.has("open-data") && shouldUseDataContext(question, options)) selected.add("open-data");
|
|
10799
|
+
if (enabled.has("geo") && isGeoQuestion(normalized)) selected.add("geo");
|
|
9819
10800
|
if (enabled.has("reports") && /(отчет|отчёт|выгруз|csv|xlsx|качество|провер)/iu.test(normalized)) selected.add("reports");
|
|
9820
10801
|
if (enabled.has("local-files") && (options.files || /(файл|папк|readme|документ|архив)/iu.test(normalized))) selected.add("local-files");
|
|
9821
10802
|
if (enabled.has("browser-agent") && /(браузер|сайт|страниц|url|https?:\/\/)/iu.test(normalized)) selected.add("browser-agent");
|
|
@@ -11451,6 +12432,14 @@ function mergeConfig(base, override) {
|
|
|
11451
12432
|
...base.files,
|
|
11452
12433
|
...(override.files || {}),
|
|
11453
12434
|
},
|
|
12435
|
+
cloud: {
|
|
12436
|
+
...base.cloud,
|
|
12437
|
+
...(override.cloud || {}),
|
|
12438
|
+
providers: {
|
|
12439
|
+
...(base.cloud?.providers || {}),
|
|
12440
|
+
...(override.cloud?.providers || {}),
|
|
12441
|
+
},
|
|
12442
|
+
},
|
|
11454
12443
|
memory: {
|
|
11455
12444
|
...base.memory,
|
|
11456
12445
|
...(override.memory || {}),
|
|
@@ -11510,6 +12499,9 @@ function sanitizeConfig(config) {
|
|
|
11510
12499
|
if (Array.isArray(next.skills?.enabled) && next.skills.enabled.includes("open-data") && !next.skills.enabled.includes("education")) {
|
|
11511
12500
|
next.skills.enabled = ["education", ...next.skills.enabled];
|
|
11512
12501
|
}
|
|
12502
|
+
if (Array.isArray(next.skills?.enabled) && next.skills.enabled.includes("local-files") && !next.skills.enabled.includes("personal-docs")) {
|
|
12503
|
+
next.skills.enabled = [...next.skills.enabled, "personal-docs"];
|
|
12504
|
+
}
|
|
11513
12505
|
const localProfile = next.ai?.profiles?.local;
|
|
11514
12506
|
if (localProfile?.provider === "iola") {
|
|
11515
12507
|
if (!localProfile.runtime || localProfile.model === "iola-router-1b") {
|
|
@@ -11554,6 +12546,9 @@ function validateConfig(config) {
|
|
|
11554
12546
|
for (const toolset of config.toolsets?.enabled || []) {
|
|
11555
12547
|
if (!TOOLSETS[toolset]) errors.push(`toolsets.enabled содержит неизвестный toolset: ${toolset}`);
|
|
11556
12548
|
}
|
|
12549
|
+
if (config.cloud?.activeProvider && !["yandex-disk", "mailru-cloud"].includes(config.cloud.activeProvider)) {
|
|
12550
|
+
errors.push(`cloud.activeProvider неизвестен: ${config.cloud.activeProvider}`);
|
|
12551
|
+
}
|
|
11557
12552
|
return errors;
|
|
11558
12553
|
}
|
|
11559
12554
|
|
|
@@ -11567,6 +12562,7 @@ function configSchema() {
|
|
|
11567
12562
|
permissions: { localTools: ALL_LOCAL_TOOLS, runtime: ["readFiles", "writeFiles", "editFiles", "deleteFiles", "sync", "externalApi", "externalAi", "codex"] },
|
|
11568
12563
|
toolsets: { available: Object.keys(TOOLSETS) },
|
|
11569
12564
|
files: { modes: ["locked", "read-only", "workspace-write", "full-access"], approvals: ["never", "on-write", "on-danger", "always"] },
|
|
12565
|
+
cloud: { providers: ["yandex-disk", "mailru-cloud"], root: CLOUD_DEFAULT_REMOTE_DIR },
|
|
11570
12566
|
skills: { enabled: "array of skill names" },
|
|
11571
12567
|
daemon: { host: "127.0.0.1", port: DAEMON_PORT },
|
|
11572
12568
|
},
|