Files
Learn_System/frontend/js/admin/sections/assistant.js
T
Maxim Dolgolyov bc0ed1892f feat(assistant): авто-здоровье провайдеров + ручная проверка (фича 4/6)
Новый модуль assistant-health.js (по образцу classroom-cleanup): каждые 15 мин
пингует каждого провайдера (pingLLM) → app_settings.assistant_health
{ id:{ok,at,error,ms,fails} }. Авто-понижение: если активный провайдер
не отвечает 2+ раза подряд, а есть здоровый рабочий запасной — автоматически
переключает assistant_active и пишет assistant_failover (баннер «health»).
schedule() из server.js (unref).

Админка: тумблер «Авто-проверка провайдеров», кнопка «Проверить сейчас»
(POST /admin/assistant/health → runHealth), цветной индикатор здоровья на
каждой карточке провайдера (зелёный/красный + время/ошибка в title).
keyless-шлюзы и провайдеры без ключа учтены.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:02:37 +03:00

359 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* admin → «Помощник Квантик»: системный вкл/выкл + провайдеры ИИ (карточки,
* активный, авто-перехват, переключение модели Kilo) + RAG/экзамен/статистика. */
(function () {
'use strict';
let inited = false, editingId = null;
var esc = (window.LS && LS.escapeHtml) ? LS.escapeHtml : function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]; }); };
var IN = 'padding:8px 11px;border:1px solid var(--border,#e2e8f0);border-radius:9px;font:inherit;font-size:.85rem;width:100%;box-sizing:border-box;background:var(--surface,#fff);color:var(--text,#0f172a)';
var SPARK = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/></svg>';
function fmtTok(n) { if (!n) return '—'; if (n >= 1000000) { var m = n / 1000000; return (m >= 10 ? Math.round(m) : m.toFixed(1).replace(/\.0$/, '')) + 'M'; } if (n >= 1000) return Math.round(n / 1000) + 'K'; return String(n); }
function fmtLimits(L) { var s = 'контекст <b>' + fmtTok(L.ctx) + '</b> · ответ до <b>' + fmtTok(L.out) + '</b> токенов'; if (L.free === true) s += ' · <b>бесплатно</b>'; else if (L.free === false) s += ' · платно'; return s; }
function ensureStyle() {
if (document.getElementById('asst-adm-style')) return;
var s = document.createElement('style'); s.id = 'asst-adm-style';
s.textContent = [
'.asst-prov{display:flex;flex-direction:column;gap:9px;}',
'.asst-pcard{display:flex;align-items:center;gap:12px;padding:11px 13px;border:1.5px solid var(--border,#e2e8f0);border-radius:13px;background:var(--surface,#fff);transition:border-color .15s,box-shadow .15s,transform .12s;}',
'.asst-pcard:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(15,23,42,.07);}',
'.asst-pcard.active{border-color:#9B5DE5;background:rgba(155,93,229,.06);box-shadow:0 2px 12px rgba(155,93,229,.14);}',
'.asst-pcic{width:38px;height:38px;border-radius:11px;display:flex;align-items:center;justify-content:center;background:rgba(155,93,229,.12);color:#9B5DE5;flex-shrink:0;}',
'.asst-pcb{flex:1;min-width:0;}',
'.asst-pcn{font-weight:800;font-size:.92rem;color:var(--text,#0f172a);display:flex;align-items:center;gap:7px;flex-wrap:wrap;}',
'.asst-pcs{font-size:.76rem;color:#8a94a6;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}',
'.asst-pclim{font-size:.7rem;color:#8a94a6;margin-top:5px;display:flex;align-items:center;gap:5px;}',
'.asst-pclim b{color:var(--text-2,#475569);font-weight:700;}',
'.asst-bdg{font-size:.6rem;font-weight:800;text-transform:uppercase;letter-spacing:.03em;padding:2px 8px;border-radius:99px;}',
'.asst-bdg.act{background:#9B5DE5;color:#fff;}',
'.asst-bdg.key{background:rgba(5,150,82,.12);color:#059652;}',
'.asst-bdg.nokey{background:rgba(224,51,94,.12);color:#e0335e;}',
'.asst-pca{display:flex;gap:6px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end;}',
'.asst-ib{border:1px solid var(--border,#e2e8f0);background:var(--surface,#fff);border-radius:8px;padding:5px 9px;font:inherit;font-size:.74rem;font-weight:700;cursor:pointer;color:var(--text-2,#475569);}',
'.asst-ib:hover{border-color:#9B5DE5;color:#9B5DE5;}',
'.asst-ib.primary{background:#9B5DE5;border-color:#9B5DE5;color:#fff;}',
'.asst-ib.primary:hover{background:#7e3eca;color:#fff;}',
'.asst-ksel{margin-top:7px;font:inherit;font-size:.78rem;padding:5px 9px;border:1px solid rgba(155,93,229,.3);border-radius:8px;background:var(--surface,#fff);color:var(--text,#0f172a);max-width:100%;}',
'.asst-flabel{font-size:.74rem;font-weight:700;color:var(--text-2,#475569);margin:2px 0 -3px;}',
'.asst-ptest{font-size:.8rem;line-height:1.5;padding:2px 0;}',
].join('');
document.head.appendChild(s);
}
async function render() {
var host = document.getElementById('assistant-admin');
if (!host) return;
ensureStyle();
host.innerHTML = '';
// ── Системный выключатель ──
var feats = {}; try { feats = await LS.api('/api/admin/features'); } catch (e) {}
var on = feats.assistant !== false;
var master = document.createElement('div');
master.className = 'perm-card' + (on ? ' enabled' : '');
master.innerHTML =
'<div class="perm-info"><div class="perm-label"><i data-lucide="power" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>Помощник включён для всей системы</div>' +
'<div class="perm-desc">Выключатель «Квантика» для всех пользователей. Выключено — помощник не загружается нигде.</div></div>' +
'<label class="perm-toggle"><input type="checkbox" id="asst-master" ' + (on ? 'checked' : '') + '><span class="perm-track"></span><span class="perm-thumb"></span></label>';
host.appendChild(master);
master.querySelector('#asst-master').addEventListener('change', function () {
var v = this.checked;
LS.api('/api/admin/features', { method: 'PATCH', body: JSON.stringify({ assistant: v }) })
.then(function () { master.classList.toggle('enabled', v); LS.toast(v ? 'Помощник включён' : 'Помощник выключен для всех', 'success'); })
.catch(function () { master.querySelector('#asst-master').checked = !v; LS.toast('Ошибка', 'error'); });
});
var cfg = {}; try { cfg = await LS.adminGetAssistant(); } catch (e) {}
var providers = cfg.providers || [], activeId = cfg.activeId, presets = cfg.presets || [], kiloModels = cfg.kiloModels || [];
var health = cfg.health || {};
// ── Баннер failover ──
if (cfg.failover) {
var fo = cfg.failover, rmap = { rate_limit: 'исчерпан лимит', http: 'ошибка API', timeout: 'таймаут', network: 'нет связи', error: 'ошибка', health: 'не прошёл авто-проверку' };
var when = ''; try { when = new Date(fo.at).toLocaleString('ru'); } catch (e) {}
var ban = document.createElement('div');
ban.style.cssText = 'margin-top:14px;padding:11px 14px;border-radius:11px;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.4);color:#92400e;font-size:.84rem;line-height:1.5;display:flex;align-items:flex-start;gap:12px';
var bantxt = fo.servedName
? '<b>Переключение провайдера.</b> «' + esc(fo.failedName || '?') + '» недоступен (' + (rmap[fo.reason] || 'ошибка') + ') — работаю на «' + esc(fo.servedName) + '». ' + (when ? '<span style="opacity:.7">' + esc(when) + '</span>' : '') + '<br><span style="opacity:.8">Снимется автоматически, когда активный снова заработает. Запись могла устареть — нажмите «Тест» на активном или снимите вручную.</span>'
: '<b>Все провайдеры ИИ недоступны</b> (' + (rmap[fo.reason] || 'ошибка') + '). Сейчас «Спроси» в FAQ-режиме. ' + (when ? '<span style="opacity:.7">' + esc(when) + '</span>' : '') + '<br><span style="opacity:.8">Если провайдер уже работает (тест проходит) — запись устарела, снимите её.</span>';
ban.innerHTML = '<div style="flex:1">' + bantxt + '</div><button id="asst-fo-dismiss" class="asst-ib" style="flex-shrink:0;background:#92400e;border-color:#92400e;color:#fff">Снять</button>';
host.appendChild(ban);
ban.querySelector('#asst-fo-dismiss').addEventListener('click', function () {
LS.adminSaveAssistant({ dismissFailover: true }).then(function () { LS.toast('Уведомление снято', 'success'); render(); }).catch(function () { LS.toast('Ошибка', 'error'); });
});
}
// ── Провайдеры (карточки) ──
var pc = document.createElement('div');
pc.className = 'perm-card';
pc.style.cssText = 'flex-direction:column;align-items:stretch;gap:11px;margin-top:14px';
pc.innerHTML =
'<div class="perm-label"><i data-lucide="sparkles" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>Провайдеры ИИ для «Спроси Квантика»</div>' +
'<div class="perm-desc">Активный (фиолетовый) используется первым. При лимите/ошибке Квантик автоматически пробует следующего с ключом. Без ключей — режим FAQ.</div>' +
'<div class="asst-prov" id="asst-prov"></div>' +
'<div id="asst-ptest" class="asst-ptest"></div>' +
'<hr style="border:none;border-top:1px solid var(--border,#e2e8f0);margin:6px 0 2px">' +
'<button id="asst-ftoggle" class="asst-ib" style="align-self:flex-start;padding:7px 13px;display:inline-flex;align-items:center;gap:6px"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>Добавить провайдера</button>' +
'<div id="asst-form" style="display:none;flex-direction:column;gap:9px;margin-top:2px;padding:13px;border:1px dashed var(--border,#e2e8f0);border-radius:12px">' +
'<div style="font-weight:800;font-size:.86rem" id="asst-fhead">Новый провайдер</div>' +
'<div class="asst-flabel">Пресет</div>' +
'<select id="asst-preset" style="' + IN + '"><option value="">— выбрать провайдера —</option>' + presets.map(function (p, i) { return '<option value="' + i + '">' + esc(p.name) + '</option>'; }).join('') + '</select>' +
'<div class="asst-flabel">Название</div><input id="asst-name" placeholder="напр. Kilo основной" style="' + IN + '" />' +
'<div class="asst-flabel">URL (chat/completions)</div><input id="asst-url" placeholder="https://…/chat/completions" style="' + IN + '" />' +
'<div id="asst-kbox" style="display:none"><div class="asst-flabel">Модель Kilo (бесплатные)</div><select id="asst-kmodels" style="' + IN + '"></select></div>' +
'<div class="asst-flabel">Модель</div><input id="asst-model" placeholder="model-id" style="' + IN + '" />' +
'<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"><button id="asst-fetch" type="button" class="asst-ib">Загрузить модели провайдера</button><span id="asst-fetch-st" style="font-size:.74rem;color:#8a94a6"></span></div>' +
'<select id="asst-fetched" style="display:none;' + IN + '"></select>' +
'<div class="asst-flabel">API-ключ</div><input id="asst-key" type="password" autocomplete="off" placeholder="ключ" style="' + IN + '" />' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:4px"><button id="asst-save" class="asst-ib primary" style="padding:8px 16px">Сохранить провайдера</button><button id="asst-cancel" class="asst-ib" style="padding:8px 16px">Отмена</button></div>' +
'</div>';
host.appendChild(pc);
// ── Сканер бесплатных моделей шлюза Kilo ──
var sk = document.createElement('div');
sk.className = 'perm-card'; sk.style.cssText = 'flex-direction:column;align-items:stretch;gap:10px;margin-top:14px';
sk.innerHTML =
'<div class="perm-label"><i data-lucide="radar" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>Каталог бесплатных моделей Kilo</div>' +
'<div class="perm-desc">Сканирует шлюз, находит бесплатные модели и тестирует каждую тест-запросом на русском. Этот список показывается в выпадашке моделей у Kilo-провайдеров. ' +
(cfg.kiloModelsCustom ? '<b>Сейчас: обновлён сканированием.</b>' : 'Сейчас: встроенный список.') + '</div>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
'<button id="asst-scan" class="asst-ib primary" style="padding:8px 14px;display:inline-flex;align-items:center;gap:6px">' + SPARK + 'Сканировать модели</button>' +
(cfg.kiloModelsCustom ? '<button id="asst-scan-reset" class="asst-ib">Вернуть встроенный список</button>' : '') +
'<span id="asst-scan-st" style="font-size:.78rem;color:#8a94a6"></span></div>' +
'<div id="asst-scan-res"></div>';
host.appendChild(sk);
// ── Настройки/статистика ──
var u = cfg.usage || {}, u30 = cfg.usage30 || {}, f = cfg.feedback || {};
var sc = document.createElement('div');
sc.className = 'perm-card'; sc.style.cssText = 'flex-direction:column;align-items:stretch;gap:9px;margin-top:14px';
sc.innerHTML =
'<div class="perm-label"><i data-lucide="settings-2" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>Настройки и статистика</div>' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-rag" ' + (cfg.rag !== false ? 'checked' : '') + '> Искать ответы по учебникам (RAG)</label>' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-exambtn" ' + (cfg.examButtons ? 'checked' : '') + '> Кнопки помощника на карточках экзамена</label>' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-memory" ' + (cfg.memory !== false ? 'checked' : '') + '> Персональная память об ученике (слабые темы, заметки)</label>' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-socratic" ' + (cfg.socratic ? 'checked' : '') + '> Сократический режим: не решать задачи за ученика (теорию объясняет, задачи — наводит)</label>' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-health" ' + (cfg.healthEnabled !== false ? 'checked' : '') + '> Авто-проверка провайдеров (каждые 15 мин): упавший активный автоматически уступает место здоровому</label>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><button id="asst-reindex" class="asst-ib">Переиндексировать учебники</button><span id="asst-chunks" style="font-size:.78rem;color:#8a94a6">' + (cfg.chunks || 0) + ' фрагментов</span><button id="asst-healthrun" class="asst-ib">Проверить провайдеров сейчас</button></div>' +
'<div style="font-size:.78rem;color:#8a94a6">Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.</div>' +
'<div style="font-size:.78rem;color:#8a94a6">Оценки (30 дн): ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' + ((f.recent || []).length ? '. Не помогло: ' + f.recent.map(function (x) { return '«' + esc(String(x.q || '').slice(0, 40)) + '»'; }).join(', ') : '') + '</div>';
host.appendChild(sc);
if (window.lucide) lucide.createIcons();
var Q = function (s) { return host.querySelector(s); };
// ── рендер карточек провайдеров ──
var listEl = Q('#asst-prov');
if (!providers.length) { listEl.innerHTML = '<div style="color:#8a94a6;font-size:.84rem">Пока нет провайдеров — добавьте ниже.</div>'; }
else listEl.innerHTML = providers.map(function (p) {
var isKilo = /kilocode\.ai/.test(p.url || '');
var act = p.id === activeId;
var ksel = '';
if (isKilo) {
var opts = kiloModels.slice();
if (!opts.some(function (m) { return m.id === p.model; })) opts = [{ id: p.model, label: p.model }].concat(opts);
ksel = '<select class="asst-ksel" data-ksel="' + p.id + '">' + opts.map(function (m) { return '<option value="' + esc(m.id) + '"' + (m.id === p.model ? ' selected' : '') + '>' + esc(m.label) + '</option>'; }).join('') + '</select>';
}
// лимиты: сохранённые на провайдере → для Kilo из списка → иначе авто-подгрузка
var L = p.ctx ? { ctx: p.ctx, out: p.out, free: p.free } : null;
if (!L && isKilo) { var km = kiloModels.find(function (m) { return m.id === p.model; }); if (km) L = { ctx: km.ctx, out: km.out, free: true }; }
var lim = L
? '<div class="asst-pclim" data-lim="' + p.id + '">' + fmtLimits(L) + '</div>'
: '<div class="asst-pclim" data-lim="' + p.id + '" style="opacity:.6">лимиты: загрузка…</div>';
var h = health[p.id];
var hdot = h ? '<span title="' + esc((h.ok ? 'отвечает' : (h.error || 'не отвечает')) + (h.at ? ' · ' + (function () { try { return new Date(h.at).toLocaleString('ru'); } catch (e) { return ''; } })() : '')) + '" style="display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0;background:' + (h.ok ? '#059652' : '#e0335e') + ';margin-left:2px;align-self:center"></span>' : '';
return '<div class="asst-pcard' + (act ? ' active' : '') + '">' +
'<div class="asst-pcic">' + SPARK + '</div>' +
'<div class="asst-pcb"><div class="asst-pcn">' + esc(p.name || 'Провайдер') + hdot +
(act ? '<span class="asst-bdg act">активен</span>' : '') +
(p.hasKey ? '<span class="asst-bdg key">ключ есть</span>' : p.noKey ? '<span class="asst-bdg key">без ключа</span>' : '<span class="asst-bdg nokey">нет ключа</span>') + '</div>' +
'<div class="asst-pcs">' + esc(p.model || '') + '</div>' + ksel + lim + '</div>' +
'<div class="asst-pca">' +
(act ? '' : '<button class="asst-ib primary" data-act="activate" data-id="' + p.id + '">Сделать активным</button>') +
'<button class="asst-ib" data-act="test" data-id="' + p.id + '">Тест</button>' +
'<button class="asst-ib" data-act="edit" data-id="' + p.id + '">Изм.</button>' +
'<button class="asst-ib" data-act="del" data-id="' + p.id + '" style="color:#e0335e">Удалить</button>' +
'</div></div>';
}).join('');
// авто-подгрузка лимитов для провайдеров без сохранённых (Gemini, новые модели) — фоном, сервер кэширует
var _pickedLimits = null;
providers.forEach(function (p) {
if (p.ctx) return;
if (/kilocode\.ai/.test(p.url || '') && kiloModels.some(function (m) { return m.id === p.model; })) return;
LS.adminAssistantModels({ id: p.id }).then(function (r) {
var el = host.querySelector('[data-lim="' + p.id + '"]'); if (!el) return;
if (r && !r.error && r.current) { el.style.opacity = ''; el.innerHTML = fmtLimits(r.current); }
else { el.style.display = 'none'; }
}).catch(function () { var el = host.querySelector('[data-lim="' + p.id + '"]'); if (el) el.style.display = 'none'; });
});
function openForm(show) { Q('#asst-form').style.display = show ? 'flex' : 'none'; }
function clearForm() { editingId = null; _pickedLimits = null; Q('#asst-fhead').textContent = 'Новый провайдер'; Q('#asst-preset').value = ''; Q('#asst-name').value = ''; Q('#asst-url').value = ''; Q('#asst-model').value = ''; Q('#asst-key').value = ''; Q('#asst-key').placeholder = 'ключ'; Q('#asst-fetched').style.display = 'none'; Q('#asst-fetched').innerHTML = ''; Q('#asst-fetch-st').textContent = ''; toggleKilo(); openForm(false); }
function toggleKilo() {
var isKilo = /kilocode\.ai/.test(Q('#asst-url').value || '');
Q('#asst-kbox').style.display = isKilo ? '' : 'none';
if (isKilo) {
var sel = Q('#asst-kmodels');
if (!sel.options.length) sel.innerHTML = kiloModels.map(function (m) { return '<option value="' + esc(m.id) + '">' + esc(m.label) + '</option>'; }).join('');
}
}
// карточные действия
listEl.querySelectorAll('[data-ksel]').forEach(function (sel) {
sel.addEventListener('change', function () {
var km = kiloModels.find(function (m) { return m.id === sel.value; });
var body = { id: sel.getAttribute('data-ksel'), model: sel.value };
if (km) { body.ctx = km.ctx; body.out = km.out; body.free = true; }
LS.adminSaveProvider(body).then(function () { LS.toast('Модель обновлена', 'success'); render(); }).catch(function () { LS.toast('Ошибка', 'error'); });
});
});
listEl.querySelectorAll('[data-act]').forEach(function (b) {
b.addEventListener('click', async function () {
var id = b.getAttribute('data-id'), act = b.getAttribute('data-act');
if (act === 'activate') { try { await LS.adminSetActiveProvider(id); LS.toast('Активный провайдер обновлён', 'success'); render(); } catch (e) { LS.toast('Ошибка', 'error'); } }
else if (act === 'del') { if (!await LS.confirm('Удалить провайдера?', { title: 'Удалить?', confirmText: 'Удалить' })) return; try { await LS.adminDeleteProvider(id); LS.toast('Удалён', 'success'); render(); } catch (e) { LS.toast('Ошибка', 'error'); } }
else if (act === 'edit') {
var p = providers.find(function (x) { return x.id === id; }); if (!p) return;
editingId = id; Q('#asst-fhead').textContent = 'Изменить: ' + (p.name || '');
Q('#asst-name').value = p.name || ''; Q('#asst-url').value = p.url || ''; Q('#asst-model').value = p.model || '';
Q('#asst-key').value = ''; Q('#asst-key').placeholder = p.hasKey ? 'ключ сохранён — введите новый, чтобы заменить' : 'ключ';
toggleKilo(); openForm(true); Q('#asst-form').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); Q('#asst-name').focus();
} else if (act === 'test') {
var res = Q('#asst-ptest'); res.innerHTML = 'Проверяю…';
try { var r = await LS.adminTestAssistant({ id: id }); res.innerHTML = r && r.ok ? '<span style="color:#059652">✓ Работает (' + esc(r.model || '') + '): ' + esc(String(r.sample || 'ответ получен')) + '</span>' : '<span style="color:#e0335e">✗ ' + esc(String((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').slice(0, 200)) + '</span>'; } catch (e) { res.innerHTML = '<span style="color:#e0335e">✗ ' + esc(e.message || 'ошибка') + '</span>'; }
}
});
});
// форма
Q('#asst-ftoggle').addEventListener('click', function () { var open = Q('#asst-form').style.display !== 'none'; if (open) { clearForm(); } else { clearForm(); openForm(true); Q('#asst-name').focus(); } });
Q('#asst-preset').addEventListener('change', function () { var p = presets[Number(this.value)]; if (p) { Q('#asst-url').value = p.url; Q('#asst-model').value = p.model; if (!Q('#asst-name').value) Q('#asst-name').value = p.name; _pickedLimits = null; Q('#asst-fetched').style.display = 'none'; Q('#asst-fetch-st').textContent = ''; toggleKilo(); } });
Q('#asst-url').addEventListener('input', function () { toggleKilo(); _pickedLimits = null; Q('#asst-fetched').style.display = 'none'; Q('#asst-fetch-st').textContent = ''; });
Q('#asst-kmodels').addEventListener('change', function () { Q('#asst-model').value = this.value; var km = kiloModels.find(function (m) { return m.id === Q('#asst-model').value; }); _pickedLimits = km ? { ctx: km.ctx, out: km.out, free: true } : null; });
Q('#asst-cancel').addEventListener('click', clearForm);
Q('#asst-fetch').addEventListener('click', async function () {
var st = Q('#asst-fetch-st'), btn = this;
var params = editingId ? { id: editingId } : {};
if (!editingId) { params.url = Q('#asst-url').value.trim(); params.key = Q('#asst-key').value.trim(); if (!params.url) { st.textContent = 'Сначала введите URL'; return; } }
btn.disabled = true; st.textContent = 'Загружаю…';
try {
var r = await LS.adminAssistantModels(params);
if (!r || r.error) { st.textContent = 'Ошибка: ' + ((r && (r.error || ('HTTP ' + r.status))) || 'нет данных'); return; }
var models = r.models || [];
if (!models.length) { st.textContent = 'Моделей не найдено'; return; }
var sel = Q('#asst-fetched');
sel.innerHTML = '<option value="">— выбрать из ' + models.length + ' моделей —</option>' + models.map(function (m) {
return '<option value="' + esc(m.id) + '" data-ctx="' + (m.ctx || '') + '" data-out="' + (m.out || '') + '" data-free="' + (m.free === null || m.free === undefined ? '' : m.free) + '">' + esc(m.id) + ' (' + fmtTok(m.ctx) + '/' + fmtTok(m.out) + (m.free === true ? ', free' : '') + ')</option>';
}).join('');
sel.style.display = ''; st.textContent = 'Загружено: ' + models.length + ' — выберите модель';
} catch (e) { st.textContent = 'Ошибка'; } finally { btn.disabled = false; }
});
Q('#asst-fetched').addEventListener('change', function () {
var o = this.options[this.selectedIndex]; if (!o || !o.value) { _pickedLimits = null; return; }
Q('#asst-model').value = o.value;
var fr = o.getAttribute('data-free');
_pickedLimits = { ctx: Number(o.getAttribute('data-ctx')) || null, out: Number(o.getAttribute('data-out')) || null, free: fr === '' ? null : fr === 'true' };
});
Q('#asst-save').addEventListener('click', async function () {
var body = { name: Q('#asst-name').value, url: Q('#asst-url').value, model: Q('#asst-model').value };
if (editingId) body.id = editingId;
var k = Q('#asst-key').value.trim(); if (k) body.key = k;
if (_pickedLimits) { body.ctx = _pickedLimits.ctx; body.out = _pickedLimits.out; body.free = _pickedLimits.free; }
if (!body.url || !body.model) { LS.toast('Заполни URL и модель', 'warn'); return; }
try { await LS.adminSaveProvider(body); LS.toast('Сохранено', 'success'); clearForm(); render(); } catch (e) { LS.toast('Ошибка: ' + (e.message || ''), 'error'); }
});
// настройки
Q('#asst-rag').addEventListener('change', function () { LS.adminSaveAssistant({ rag: Q('#asst-rag').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
Q('#asst-exambtn').addEventListener('change', function () { LS.adminSaveAssistant({ examButtons: Q('#asst-exambtn').checked }).then(function () { LS.toast('Сохранено (обновите страницу экзамена)', 'success'); }).catch(function () {}); });
Q('#asst-memory').addEventListener('change', function () { LS.adminSaveAssistant({ memory: Q('#asst-memory').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
Q('#asst-socratic').addEventListener('change', function () { LS.adminSaveAssistant({ socratic: Q('#asst-socratic').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
Q('#asst-health').addEventListener('change', function () { LS.adminSaveAssistant({ healthEnabled: Q('#asst-health').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
Q('#asst-healthrun').addEventListener('click', async function () {
var btn = Q('#asst-healthrun'); btn.disabled = true; btn.textContent = 'Проверяю…';
try { await LS.adminAssistantHealth(); LS.toast('Проверка завершена', 'success'); render(); }
catch (e) { LS.toast('Ошибка проверки', 'error'); btn.disabled = false; btn.textContent = 'Проверить провайдеров сейчас'; }
});
Q('#asst-reindex').addEventListener('click', async function () {
var btn = Q('#asst-reindex'); btn.disabled = true; btn.textContent = 'Индексирую…';
try { var r = await LS.adminReindexTextbooks(); Q('#asst-chunks').textContent = ((r && r.chunks) || 0) + ' фрагментов'; LS.toast('Готово', 'success'); }
catch (e) { LS.toast('Ошибка индексации', 'error'); } finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; }
});
// ── Сканер моделей ──
var scanProvId = null;
function applyScan() {
var trs = Q('#asst-scan-res').querySelectorAll('tbody tr[data-mid]');
var models = [];
trs.forEach(function (tr) {
var cb = tr.querySelector('.asst-scan-cb'); if (!cb || !cb.checked) return;
var id = tr.getAttribute('data-mid');
var ctx = Number(tr.getAttribute('data-ctx')) || null, out = Number(tr.getAttribute('data-out')) || null;
var ex = (cfg.kiloModels || []).find(function (x) { return x.id === id; });
var label = ex ? ex.label : (id.split('/').pop().replace(/:free$/, '') + (ctx ? ' (' + fmtTok(ctx) + ')' : ''));
models.push({ id: id, label: label, ctx: ctx, out: out });
});
if (!models.length) { LS.toast('Отметьте хотя бы одну модель', 'warn'); return; }
LS.adminAssistantApplyModels(models).then(function () { LS.toast('Список моделей обновлён (' + models.length + ')', 'success'); render(); }).catch(function () { LS.toast('Ошибка', 'error'); });
}
async function runScan() {
var st = Q('#asst-scan-st'), res = Q('#asst-scan-res'), btn = Q('#asst-scan');
btn.disabled = true; st.textContent = 'Сканирую шлюз…'; res.innerHTML = '';
var r; try { r = await LS.adminAssistantScan(); } catch (e) { st.textContent = 'Ошибка запроса'; btn.disabled = false; return; }
if (!r || r.error) { st.textContent = 'Ошибка: ' + esc((r && r.error) || '—'); btn.disabled = false; return; }
scanProvId = r.providerId;
st.textContent = 'Найдено бесплатных: ' + r.models.length + ' (провайдер «' + esc(r.providerName) + '»). Тестирую русский…';
var rows = r.models.map(function (m) {
return '<tr data-mid="' + esc(m.id) + '" data-ctx="' + (m.ctx || '') + '" data-out="' + (m.out || '') + '">' +
'<td style="padding:5px 6px"><input type="checkbox" class="asst-scan-cb"' + (m.status === 'current' ? ' checked' : '') + '></td>' +
'<td style="padding:5px 6px;font-family:ui-monospace,monospace;font-size:.74rem">' + esc(m.id) + '</td>' +
'<td style="padding:5px 6px;white-space:nowrap;color:#8a94a6">' + fmtTok(m.ctx) + '/' + fmtTok(m.out) + '</td>' +
'<td style="padding:5px 6px"><span class="asst-bdg ' + (m.status === 'current' ? 'key' : 'act') + '">' + (m.status === 'current' ? 'в списке' : 'новая') + '</span></td>' +
'<td class="asst-ru" style="padding:5px 6px;font-size:.75rem;color:#8a94a6">…</td></tr>';
}).join('');
var goneRows = (r.gone || []).map(function (g) {
return '<tr style="opacity:.55"><td style="padding:5px 6px">—</td>' +
'<td style="padding:5px 6px;font-family:ui-monospace,monospace;font-size:.74rem;text-decoration:line-through">' + esc(g.id) + '</td>' +
'<td style="padding:5px 6px">—</td><td style="padding:5px 6px"><span class="asst-bdg nokey">исчезла</span></td>' +
'<td style="padding:5px 6px;font-size:.75rem;color:#e0335e">будет убрана</td></tr>';
}).join('');
res.innerHTML = '<div style="overflow-x:auto;margin-top:4px"><table style="width:100%;border-collapse:collapse">' +
'<thead><tr style="text-align:left;color:#8a94a6;font-size:.72rem;border-bottom:1px solid var(--border,#e2e8f0)">' +
'<th style="padding:4px 6px"></th><th style="padding:4px 6px">модель</th><th style="padding:4px 6px">ctx/out</th><th style="padding:4px 6px">статус</th><th style="padding:4px 6px">русский</th></tr></thead>' +
'<tbody>' + rows + goneRows + '</tbody></table></div>' +
'<div style="margin-top:10px"><button id="asst-scan-apply" class="asst-ib primary" style="padding:8px 16px">Применить выбранные</button> ' +
'<span style="font-size:.74rem;color:#8a94a6">отмечены: текущие + новые с чистым русским</span></div>';
Q('#asst-scan-apply').addEventListener('click', applyScan);
// последовательный прогон тест-запросов
var trs = res.querySelectorAll('tbody tr[data-mid]');
for (var i = 0; i < trs.length; i++) {
var tr = trs[i], mid = tr.getAttribute('data-mid'), cell = tr.querySelector('.asst-ru');
cell.textContent = 'тест…';
try {
var pr = await LS.adminAssistantProbe(scanProvId, mid);
if (pr && pr.ok) {
var good = pr.cjk === 0 && pr.ratio > 55;
var col = good ? '#059652' : (pr.ratio > 20 && pr.cjk === 0) ? '#b45309' : '#e0335e';
cell.innerHTML = '<span style="color:' + col + '" title="' + esc(pr.sample || '') + '">' + pr.ratio + '% · ' + esc(pr.verdict) + ' · ' + (pr.ms / 1000).toFixed(1) + 'с</span>';
if (!good) { var cb = tr.querySelector('.asst-scan-cb'); if (cb) cb.checked = false; }
} else {
cell.innerHTML = '<span style="color:#e0335e">' + esc(String((pr && (pr.error || ('HTTP ' + pr.status))) || 'ошибка').slice(0, 60)) + '</span>';
var cb2 = tr.querySelector('.asst-scan-cb'); if (cb2) cb2.checked = false;
}
} catch (e) { cell.textContent = 'ошибка'; }
}
st.textContent = 'Готово. Отметьте нужные модели и нажмите «Применить выбранные».';
btn.disabled = false;
}
Q('#asst-scan').addEventListener('click', runScan);
if (Q('#asst-scan-reset')) Q('#asst-scan-reset').addEventListener('click', async function () {
if (!await LS.confirm('Вернуть встроенный список бесплатных моделей?', { title: 'Сброс списка', confirmText: 'Вернуть' })) return;
try { await LS.adminAssistantApplyModels(null, true); LS.toast('Возвращён встроенный список', 'success'); render(); } catch (e) { LS.toast('Ошибка', 'error'); }
});
}
window.AdminSections = window.AdminSections || {};
window.AdminSections.assistant = { init: async () => { if (inited) return; inited = true; await render(); }, reload: render };
})();