'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 ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 = ''; 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 = 'контекст ' + fmtTok(L.ctx) + ' · ответ до ' + fmtTok(L.out) + ' токенов'; if (L.free === true) s += ' · бесплатно'; 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 = '
Помощник включён для всей системы
' + '
Выключатель «Квантика» для всех пользователей. Выключено — помощник не загружается нигде.
' + ''; 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 ? 'Переключение провайдера. «' + esc(fo.failedName || '?') + '» недоступен (' + (rmap[fo.reason] || 'ошибка') + ') — работаю на «' + esc(fo.servedName) + '». ' + (when ? '' + esc(when) + '' : '') + '
Снимется автоматически, когда активный снова заработает. Запись могла устареть — нажмите «Тест» на активном или снимите вручную.' : 'Все провайдеры ИИ недоступны (' + (rmap[fo.reason] || 'ошибка') + '). Сейчас «Спроси» в FAQ-режиме. ' + (when ? '' + esc(when) + '' : '') + '
Если провайдер уже работает (тест проходит) — запись устарела, снимите её.'; ban.innerHTML = '
' + bantxt + '
'; 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 = '
Провайдеры ИИ для «Спроси Квантика»
' + '
Активный (фиолетовый) используется первым. При лимите/ошибке Квантик автоматически пробует следующего с ключом. Без ключей — режим FAQ.
' + '
' + '
' + '
' + '' + ''; 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 = '
Каталог бесплатных моделей Kilo
' + '
Сканирует шлюз, находит бесплатные модели и тестирует каждую тест-запросом на русском. Этот список показывается в выпадашке моделей у Kilo-провайдеров. ' + (cfg.kiloModelsCustom ? 'Сейчас: обновлён сканированием.' : 'Сейчас: встроенный список.') + '
' + '
' + '' + (cfg.kiloModelsCustom ? '' : '') + '
' + '
'; 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 = '
Настройки и статистика
' + '' + '' + '' + '' + '' + '
' + (cfg.chunks || 0) + ' фрагментов
' + '
Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.
' + '
Оценки (30 дн): ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' + ((f.recent || []).length ? '. Не помогло: ' + f.recent.map(function (x) { return '«' + esc(String(x.q || '').slice(0, 40)) + '»'; }).join(', ') : '') + '
'; 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 = '
Пока нет провайдеров — добавьте ниже.
'; } 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 = ''; } // лимиты: сохранённые на провайдере → для 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 ? '
' + fmtLimits(L) + '
' : '
лимиты: загрузка…
'; var h = health[p.id]; var hdot = h ? '' : ''; return '
' + '
' + SPARK + '
' + '
' + esc(p.name || 'Провайдер') + hdot + (act ? 'активен' : '') + (p.hasKey ? 'ключ есть' : p.noKey ? 'без ключа' : 'нет ключа') + '
' + '
' + esc(p.model || '') + '
' + ksel + lim + '
' + '
' + (act ? '' : '') + '' + '' + '' + '
'; }).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 ''; }).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 ? '✓ Работает (' + esc(r.model || '') + '): ' + esc(String(r.sample || 'ответ получен')) + '' : '✗ ' + esc(String((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').slice(0, 200)) + ''; } catch (e) { res.innerHTML = '✗ ' + esc(e.message || 'ошибка') + ''; } } }); }); // форма 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 = '' + models.map(function (m) { return ''; }).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 '' + '' + '' + esc(m.id) + '' + '' + fmtTok(m.ctx) + '/' + fmtTok(m.out) + '' + '' + (m.status === 'current' ? 'в списке' : 'новая') + '' + '…'; }).join(''); var goneRows = (r.gone || []).map(function (g) { return '—' + '' + esc(g.id) + '' + '—исчезла' + 'будет убрана'; }).join(''); res.innerHTML = '
' + '' + '' + '' + rows + goneRows + '
модельctx/outстатусрусский
' + '
' + 'отмечены: текущие + новые с чистым русским
'; 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 = '' + pr.ratio + '% · ' + esc(pr.verdict) + ' · ' + (pr.ms / 1000).toFixed(1) + 'с'; if (!good) { var cb = tr.querySelector('.asst-scan-cb'); if (cb) cb.checked = false; } } else { cell.innerHTML = '' + esc(String((pr && (pr.error || ('HTTP ' + pr.status))) || 'ошибка').slice(0, 60)) + ''; 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 }; })();