From 0e08e5775ddc55ac16bff4389bfeadab18e7383b Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 4 Jun 2026 20:47:28 +0300 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20=D0=BA=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=B8=D0=B2=D1=8B=D0=B9=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D0=B9=20=D0=BC=D0=BE=D0=B4?= =?UTF-8?q?=D1=83=D0=BB=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B0=D0=B9=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=D0=BE=D0=B2=20+=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D0=B8=20Kilo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Админ-раздел переделан: провайдеры — карточки (активный подсвечен, бейджи ключ/активен, кнопки Сделать активным/Тест/Изменить/Удалить, hover-подъём). Форма с лейблами и пресетами. Для Kilo — выпадающий список проверенных бесплатных моделей (Nemotron 550B / Owl Alpha / Nemotron Nano 30B / Laguna XS) и инлайн- переключатель модели прямо на карточке. Бэкенд: пресет Kilo + kiloModels в /admin/assistant. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/controllers/adminController.js | 10 +- frontend/js/admin/sections/assistant.js | 228 ++++++++++++--------- 2 files changed, 140 insertions(+), 98 deletions(-) diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index afa1627..275579b 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -879,11 +879,19 @@ function broadcast(req, res) { /* ── Ассистент «Квантик»: конфиг LLM из админки ──────────────────────── */ const ASSISTANT_PRESETS = [ + { name: 'Kilo Code (бесплатно)', url: 'https://kilocode.ai/api/openrouter/chat/completions', model: 'nvidia/nemotron-3-ultra-550b-a55b:free' }, { name: 'Google Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', model: 'gemini-2.5-flash' }, { name: 'Groq', url: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.3-70b-versatile' }, { name: 'OpenRouter', url: 'https://openrouter.ai/api/v1/chat/completions', model: 'meta-llama/llama-3.3-70b-instruct:free' }, { name: 'Ollama (локально)', url: 'http://localhost:11434/v1/chat/completions', model: 'qwen2.5:3b' }, ]; +// Проверенные бесплатные модели Kilo (чистый русский) — для выпадающего списка +const KILO_MODELS = [ + { id: 'nvidia/nemotron-3-ultra-550b-a55b:free', label: 'Nemotron 550B (флагман)' }, + { id: 'openrouter/owl-alpha', label: 'Owl Alpha' }, + { id: 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free', label: 'Nemotron Nano 30B (быстрее)' }, + { id: 'poolside/laguna-xs.2:free', label: 'Laguna XS (лёгкая)' }, +]; function _aset(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; } function _aProviders() { try { return JSON.parse(_aset('assistant_providers') || '[]') || []; } catch (e) { return []; } } @@ -926,7 +934,7 @@ function getAssistant(_req, res) { res.json({ providers, activeId, active, rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1', - chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS, + chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS, kiloModels: KILO_MODELS, }); } diff --git a/frontend/js/admin/sections/assistant.js b/frontend/js/admin/sections/assistant.js index 4776a6c..e6e0e8b 100644 --- a/frontend/js/admin/sections/assistant.js +++ b/frontend/js/admin/sections/assistant.js @@ -1,24 +1,49 @@ 'use strict'; -/* admin → «Помощник Квантик»: системный вкл/выкл + несколько провайдеров ИИ - * (ключи/модели) с выбором активного и авто-перехватом при лимите + RAG, кнопки - * экзамена, статистика и качество. */ +/* admin → «Помощник Квантик»: системный вкл/выкл + провайдеры ИИ (карточки, + * активный, авто-перехват, переключение модели Kilo) + RAG/экзамен/статистика. */ (function () { 'use strict'; - let inited = false; - var editingId = null; - var IN = 'padding:8px 10px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.85rem;width:100%;box-sizing:border-box;background:var(--surface,#fff);color:var(--text,#0f172a)'; - var BTN = 'padding:8px 14px;border-radius:9px;border:1px solid var(--border,#e2e8f0);background:var(--surface,#fff);font:inherit;font-size:.82rem;font-weight:700;cursor:pointer;color:var(--text-2,#475569)'; - var SBTN = 'border:1px solid var(--border,#e2e8f0);background:var(--surface,#fff);border-radius:7px;padding:3px 9px;font:inherit;font-size:.74rem;font-weight:700;cursor:pointer;color:var(--text-2,#475569)'; + 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 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-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 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' : ''); @@ -34,132 +59,141 @@ .catch(function () { master.querySelector('#asst-master').checked = !v; LS.toast('Ошибка', 'error'); }); }); - var cfg = {}; - try { cfg = await LS.adminGetAssistant(); } catch (e) {} - var providers = cfg.providers || []; - var activeId = cfg.activeId; - var presets = cfg.presets || []; + var cfg = {}; try { cfg = await LS.adminGetAssistant(); } catch (e) {} + var providers = cfg.providers || [], activeId = cfg.activeId, presets = cfg.presets || [], kiloModels = cfg.kiloModels || []; - // ── Уведомление о failover ── + // ── Баннер failover ── if (cfg.failover) { - var fo = cfg.failover; - var reasonMap = { rate_limit: 'исчерпан лимит', http: 'ошибка API', timeout: 'таймаут', network: 'нет связи', error: 'ошибка' }; - var rsn = reasonMap[fo.reason] || 'ошибка'; + var fo = cfg.failover, rmap = { rate_limit: 'исчерпан лимит', http: 'ошибка API', timeout: 'таймаут', network: 'нет связи', error: 'ошибка' }; 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:10px;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.4);color:#92400e;font-size:.84rem;line-height:1.5'; + 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'; ban.innerHTML = fo.servedName - ? 'Переключение провайдера. «' + esc(fo.failedName || '?') + '» недоступен (' + rsn + ') — работаю на «' + esc(fo.servedName) + '». ' + (when ? '' + esc(when) + '' : '') + '
Снимется автоматически, когда активный снова заработает.' - : 'Все провайдеры ИИ недоступны (' + rsn + '). Сейчас «Спроси» работает в FAQ-режиме. ' + (when ? '' + esc(when) + '' : ''); + ? 'Переключение провайдера. «' + esc(fo.failedName || '?') + '» недоступен (' + (rmap[fo.reason] || 'ошибка') + ') — работаю на «' + esc(fo.servedName) + '». ' + (when ? '' + esc(when) + '' : '') + '
Снимется автоматически, когда активный снова заработает.' + : 'Все провайдеры ИИ недоступны (' + (rmap[fo.reason] || 'ошибка') + '). Сейчас «Спроси» в FAQ-режиме.'; host.appendChild(ban); } - // ── Провайдеры ИИ ── + // ── Провайдеры (карточки) ── var pc = document.createElement('div'); pc.className = 'perm-card'; - pc.style.cssText = 'flex-direction:column;align-items:stretch;gap:10px;margin-top:14px'; - var rows = providers.length ? providers.map(function (p) { - return ''; - }).join('') : '
Пока нет провайдеров — добавьте ниже.
'; - + pc.style.cssText = 'flex-direction:column;align-items:stretch;gap:11px;margin-top:14px'; pc.innerHTML = '
Провайдеры ИИ для «Спроси Квантика»
' + - '
Активный (отмечен точкой) используется первым. При лимите или ошибке Квантик автоматически пробует следующего с ключом. Без ключей — режим FAQ.
' + - '
' + rows + '
' + - '
' + - '
' + - '
Добавить провайдера
' + - '' + - '' + - '' + - '' + - '' + - '
' + - '' + - '' + - '
'; + '
Активный (фиолетовый) используется первым. При лимите/ошибке Квантик автоматически пробует следующего с ключом. Без ключей — режим FAQ.
' + + '
' + + '
' + + '
' + + '
Добавить провайдера
' + + '
Пресет
' + + '' + + '
Название
' + + '
URL (chat/completions)
' + + '' + + '
Модель
' + + '
API-ключ
' + + '
'; host.appendChild(pc); - // ── Настройки и статистика ── + // ── Настройки/статистика ── + 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.className = 'perm-card'; sc.style.cssText = 'flex-direction:column;align-items:stretch;gap:9px;margin-top:14px'; sc.innerHTML = '
Настройки и статистика
' + '' + '' + - '
' + (cfg.chunks || 0) + ' фрагментов
' + - '
Сегодня: ' + ((cfg.usage || {}).model_calls || 0) + ' к ИИ, ' + ((cfg.usage || {}).cache_hits || 0) + ' из кэша, ' + ((cfg.usage || {}).faq || 0) + ' FAQ. За 30 дней: ' + ((cfg.usage30 || {}).model_calls || 0) + ' / ' + ((cfg.usage30 || {}).cache_hits || 0) + ' / ' + ((cfg.usage30 || {}).faq || 0) + '.
' + - '
Оценки (30 дн): ' + ((cfg.feedback || {}).up || 0) + ' лайков, ' + ((cfg.feedback || {}).down || 0) + ' дизлайков' + - (((cfg.feedback || {}).recent || []).length ? '. Не помогло: ' + cfg.feedback.recent.map(function (x) { return '«' + esc(String(x.q || '').slice(0, 40)) + '»'; }).join(', ') : '') + '
'; + '
' + (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 q = function (s) { return host.querySelector(s); }; - function clearForm() { editingId = null; q('#asst-form-title').textContent = 'Добавить провайдера'; q('#asst-name').value = ''; q('#asst-url').value = ''; q('#asst-model').value = ''; q('#asst-key').value = ''; q('#asst-key').placeholder = 'API-ключ'; q('#asst-cancel').style.display = 'none'; } + // ── рендер карточек провайдеров ── + 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 = ''; + } + return '
' + + '
' + SPARK + '
' + + '
' + esc(p.name || 'Провайдер') + + (act ? 'активен' : '') + + '' + (p.hasKey ? 'ключ есть' : 'нет ключа') + '
' + + '
' + esc(p.model || '') + '
' + ksel + '
' + + '
' + + (act ? '' : '') + + '' + + '' + + '' + + '
'; + }).join(''); - // активный провайдер - pc.querySelectorAll('input[name="asst-active"]').forEach(function (r) { - r.addEventListener('change', function () { LS.adminSetActiveProvider(this.value).then(function () { LS.toast('Активный провайдер обновлён', 'success'); }).catch(function () {}); }); + function clearForm() { editingId = null; Q('#asst-fhead').textContent = 'Добавить провайдера'; Q('#asst-name').value = ''; Q('#asst-url').value = ''; Q('#asst-model').value = ''; Q('#asst-key').value = ''; Q('#asst-key').placeholder = 'ключ'; Q('#asst-cancel').style.display = 'none'; toggleKilo(); } + 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 () { + LS.adminSaveProvider({ id: sel.getAttribute('data-ksel'), model: sel.value }).then(function () { LS.toast('Модель обновлена', 'success'); render(); }).catch(function () { LS.toast('Ошибка', 'error'); }); + }); }); - // действия по провайдеру - pc.querySelectorAll('[data-act]').forEach(function (b) { + 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 === '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') { + 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-form-title').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 ? 'Ключ сохранён — введите новый, чтобы заменить' : 'API-ключ'; - q('#asst-cancel').style.display = ''; q('#asst-name').focus(); + 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 ? 'ключ сохранён — введите новый, чтобы заменить' : 'ключ'; + Q('#asst-cancel').style.display = ''; toggleKilo(); Q('#asst-name').focus(); } else if (act === 'test') { - var res = q('#asst-prov-test'); res.innerHTML = 'Проверяю…'; - try { - var r = await LS.adminTestAssistant({ id: id }); - res.innerHTML = r && r.ok ? '✓ Работает (' + (r.model || '') + '): ' + esc(String(r.sample || 'ответ получен')) + '' - : '✗ ' + esc(String((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').slice(0, 200)) + ''; - } catch (e) { res.innerHTML = '✗ ' + esc(e.message || 'ошибка') + ''; } + 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 || 'ошибка') + ''; } } }); }); - // пресет → заполнить url/model/name - 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; } - }); - q('#asst-cancel').addEventListener('click', clearForm); - q('#asst-save').addEventListener('click', async function () { - var body = { name: q('#asst-name').value, url: q('#asst-url').value, model: q('#asst-model').value }; + + // форма + 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; toggleKilo(); } }); + Q('#asst-url').addEventListener('input', toggleKilo); + Q('#asst-kmodels').addEventListener('change', function () { Q('#asst-model').value = this.value; }); + Q('#asst-cancel').addEventListener('click', clearForm); + 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; + var k = Q('#asst-key').value.trim(); if (k) body.key = k; 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-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 = 'Переиндексировать учебники'; } + 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-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 = 'Переиндексировать учебники'; } }); } window.AdminSections = window.AdminSections || {}; - window.AdminSections.assistant = { - init: async () => { if (inited) return; inited = true; await render(); }, - reload: render, - }; + window.AdminSections.assistant = { init: async () => { if (inited) return; inited = true; await render(); }, reload: render }; })();