From e2bff24b5b2985ba8a18e8eae1419d1b576ff5a8 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 4 Jun 2026 20:21:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20=D0=BD=D0=B5=D1=81=D0=BA?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=B9=D0=B4=D0=B5=D1=80=D0=BE=D0=B2=20=D0=98=D0=98=20+=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20+=20=D0=B0=D0=B2=D1=82=D0=BE-=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D1=85=D0=B2=D0=B0=D1=82=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D0=BB=D0=B8=D0=BC=D0=B8=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Конфиг стал списком провайдеров (assistant_providers) + активный (assistant_active). llmConfig берёт активного; providersOrdered — активный первым, затем остальные с ключом; callLLMFailover перебирает их при 429/сетевой ошибке (второй ключ подхватывает при исчерпании квоты). Legacy мигрируется в список. Админ-раздел: список провайдеров (радио-активный, Тест/Изменить/Удалить) + форма с пресетами. Эндпоинты POST/DELETE /admin/assistant/provider(/:id), POST /admin/assistant/active. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/controllers/adminController.js | 95 +++++++--- .../src/controllers/assistantController.js | 56 +++++- backend/src/routes/admin.js | 3 + frontend/js/admin/sections/assistant.js | 167 ++++++++++-------- js/api.js | 4 + 5 files changed, 227 insertions(+), 98 deletions(-) diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index 00063dc..a306e99 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -886,12 +886,27 @@ const ASSISTANT_PRESETS = [ ]; 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 []; } } +function _aSetProviders(arr) { db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_providers', ?)").run(JSON.stringify(arr)); } +function _aIsLocal(u) { return /\/\/(localhost|127\.0\.0\.1)/.test(u || ''); } + function getAssistant(_req, res) { - const url = _aset('assistant_llm_url') || process.env.ASSISTANT_LLM_URL || ASSISTANT_PRESETS[1].url; - const model = _aset('assistant_llm_model') || process.env.ASSISTANT_LLM_MODEL || ASSISTANT_PRESETS[1].model; - const dbKey = _aset('assistant_llm_key'); - const hasKey = !!(dbKey || process.env.ASSISTANT_LLM_KEY); - const local = /\/\/(localhost|127\.0\.0\.1)/.test(url); + // Миграция legacy-настроек в список провайдеров (один раз) + if (!_aset('assistant_providers')) { + const lurl = _aset('assistant_llm_url') || process.env.ASSISTANT_LLM_URL; + const lkey = _aset('assistant_llm_key') || process.env.ASSISTANT_LLM_KEY; + const lmodel = _aset('assistant_llm_model') || process.env.ASSISTANT_LLM_MODEL; + if (lurl || lkey || lmodel) { + _aSetProviders([{ id: 'p1', name: 'Провайдер 1', url: lurl || ASSISTANT_PRESETS[1].url, model: lmodel || ASSISTANT_PRESETS[1].model, key: lkey || '' }]); + if (!_aset('assistant_active')) db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', 'p1')").run(); + } + } + const list = _aProviders(); + const providers = list.map(p => ({ id: p.id, name: p.name, url: p.url, model: p.model, hasKey: !!p.key })); + const activeId = _aset('assistant_active') || (providers[0] && providers[0].id) || null; + const ap = list.find(p => p.id === activeId); + const active = !!(ap && (ap.key || _aIsLocal(ap.url))); + let chunks = 0, usage = { model_calls: 0, cache_hits: 0, faq: 0 }, usage30 = { model_calls: 0, cache_hits: 0, faq: 0 }; try { chunks = db.prepare('SELECT COUNT(*) n FROM textbook_chunks').get().n; } catch (e) {} try { @@ -907,23 +922,55 @@ function getAssistant(_req, res) { feedback.recent = db.prepare("SELECT q, created_at FROM assistant_feedback WHERE rating=-1 AND q IS NOT NULL AND q <> '' ORDER BY id DESC LIMIT 5").all(); } catch (e) {} res.json({ - url, model, hasKey, keyFromEnv: !dbKey && !!process.env.ASSISTANT_LLM_KEY, active: !!(hasKey || local), + providers, activeId, active, rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1', chunks, usage, usage30, feedback, presets: ASSISTANT_PRESETS, }); } +/* PATCH /api/admin/assistant — только тумблеры (RAG, кнопки экзамена) */ function saveAssistant(req, res) { const set = (k, v) => db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(k, v); - const del = (k) => db.prepare('DELETE FROM app_settings WHERE key = ?').run(k); const b = req.body || {}; - if (typeof b.url === 'string') set('assistant_llm_url', b.url.trim().slice(0, 300)); - if (typeof b.model === 'string') set('assistant_llm_model', b.model.trim().slice(0, 120)); if (typeof b.rag === 'boolean') set('assistant_rag', b.rag ? '1' : '0'); if (typeof b.examButtons === 'boolean') set('assistant_exam_buttons', b.examButtons ? '1' : '0'); - if (b.clearKey) del('assistant_llm_key'); - else if (typeof b.key === 'string' && b.key.trim()) set('assistant_llm_key', b.key.trim().slice(0, 400)); - audit(req, 'assistant.config', 'assistant', b.clearKey ? 'ключ очищен' : 'обновлено'); + audit(req, 'assistant.config', 'assistant', 'настройки'); + res.json({ ok: true }); +} + +/* POST /api/admin/assistant/provider — добавить/обновить провайдера */ +function saveProvider(req, res) { + const arr = _aProviders(); + const b = req.body || {}; + let p; + if (b.id) { p = arr.find(x => x.id === b.id); if (!p) { p = { id: b.id }; arr.push(p); } } + else { p = { id: 'p' + Date.now().toString(36) + Math.floor(Math.random() * 1000) }; arr.push(p); } + if (typeof b.name === 'string') p.name = b.name.trim().slice(0, 40) || p.name || 'Провайдер'; + if (typeof b.url === 'string') p.url = b.url.trim().slice(0, 300); + if (typeof b.model === 'string') p.model = b.model.trim().slice(0, 120); + if (typeof b.key === 'string' && b.key.trim()) p.key = b.key.trim().slice(0, 400); + if (!p.name) p.name = 'Провайдер'; + _aSetProviders(arr); + if (b.makeActive || arr.length === 1) db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', ?)").run(p.id); + audit(req, 'assistant.provider', p.id, 'сохранён'); + res.json({ ok: true, id: p.id }); +} + +/* DELETE /api/admin/assistant/provider/:id */ +function deleteProvider(req, res) { + let arr = _aProviders(); + arr = arr.filter(x => x.id !== req.params.id); + _aSetProviders(arr); + if (_aset('assistant_active') === req.params.id && arr[0]) db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', ?)").run(arr[0].id); + audit(req, 'assistant.provider.del', req.params.id, 'удалён'); + res.json({ ok: true }); +} + +/* POST /api/admin/assistant/active { id } — выбрать активного провайдера */ +function setActiveProvider(req, res) { + const id = String((req.body && req.body.id) || ''); + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', ?)").run(id); + audit(req, 'assistant.active', id, 'активный провайдер'); res.json({ ok: true }); } @@ -939,14 +986,22 @@ function reindexTextbooks(req, res) { async function testAssistant(req, res) { const a = require('./assistantController'); - const cfg = a.llmConfig(); const b = req.body || {}; - const override = { - url: (typeof b.url === 'string' && b.url.trim()) ? b.url.trim() : cfg.url, - model: (typeof b.model === 'string' && b.model.trim()) ? b.model.trim() : cfg.model, - key: (typeof b.key === 'string' && b.key.trim()) ? b.key.trim() : cfg.key, - }; - override.local = /\/\/(localhost|127\.0\.0\.1)/.test(override.url); + let override; + if (b.id) { + const p = _aProviders().find(x => x.id === b.id); + if (!p) return res.json({ ok: false, error: 'провайдер не найден' }); + // если ключ пуст (не вводили) — берём сохранённый у этого провайдера + override = { url: (b.url && b.url.trim()) || p.url, model: (b.model && b.model.trim()) || p.model, key: (b.key && b.key.trim()) || p.key }; + } else { + const cfg = a.llmConfig(); + override = { + url: (typeof b.url === 'string' && b.url.trim()) ? b.url.trim() : cfg.url, + model: (typeof b.model === 'string' && b.model.trim()) ? b.model.trim() : cfg.model, + key: (typeof b.key === 'string' && b.key.trim()) ? b.key.trim() : cfg.key, + }; + } + override.local = _aIsLocal(override.url); override.on = !!(override.key || override.local); const r = await a.pingLLM(override); res.json(r); @@ -961,5 +1016,5 @@ module.exports = { getSecurityLog, clearSecurityLog, getTopics, createTopic, updateTopic, deleteTopic, broadcast, - getAssistant, saveAssistant, testAssistant, reindexTextbooks, + getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider, }; diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js index 347452a..d902d24 100644 --- a/backend/src/controllers/assistantController.js +++ b/backend/src/controllers/assistantController.js @@ -230,12 +230,37 @@ function searchFaq(q, n) { /* Конфиг берём из app_settings (правится из админки без рестарта), с откатом * на ENV и дефолты. Если ключа нет и URL не локальный — работаем как FAQ. */ function _setting(k) { try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; } catch (e) { return null; } } +function _isLocal(url) { return /\/\/(localhost|127\.0\.0\.1)/.test(url || ''); } + +/* Список провайдеров (несколько ключей/моделей). Хранится JSON в app_settings. + * Если списка нет — синтезируем из legacy-настроек/ENV, чтобы ничего не сломать. */ +function _providers() { + let arr = []; + try { arr = JSON.parse(_setting('assistant_providers') || '[]'); } catch (e) {} + if (!Array.isArray(arr)) arr = []; + if (!arr.length) { + const url = _setting('assistant_llm_url') || process.env.ASSISTANT_LLM_URL || 'https://api.groq.com/openai/v1/chat/completions'; + const key = _setting('assistant_llm_key') || process.env.ASSISTANT_LLM_KEY || ''; + const model = _setting('assistant_llm_model') || process.env.ASSISTANT_LLM_MODEL || 'llama-3.3-70b-versatile'; + arr = [{ id: 'p1', name: 'Провайдер 1', url, model, key }]; + } + return arr; +} +/* Конфиги в порядке использования: активный первым, затем остальные с ключом + * (для авто-перехвата при лимите/ошибке). */ +function providersOrdered() { + const arr = _providers().filter(p => p && (p.key || _isLocal(p.url))); + const activeId = _setting('assistant_active'); + const active = arr.filter(p => p.id === activeId); + const rest = arr.filter(p => p.id !== activeId); + return active.concat(rest).map(p => ({ id: p.id, name: p.name, url: p.url, key: p.key, model: p.model, local: _isLocal(p.url), on: true })); +} function llmConfig() { - const url = _setting('assistant_llm_url') || process.env.ASSISTANT_LLM_URL || 'https://api.groq.com/openai/v1/chat/completions'; - const key = _setting('assistant_llm_key') || process.env.ASSISTANT_LLM_KEY || ''; + const ordered = providersOrdered(); + if (ordered.length) return ordered[0]; + const url = _setting('assistant_llm_url') || process.env.ASSISTANT_LLM_URL || 'https://api.groq.com/openai/v1/chat/completions'; const model = _setting('assistant_llm_model') || process.env.ASSISTANT_LLM_MODEL || 'llama-3.3-70b-versatile'; - const local = /\/\/(localhost|127\.0\.0\.1)/.test(url); - return { url, key, model, local, on: !!(key || local) }; + return { url, key: '', model, local: _isLocal(url), on: false }; } /* RAG: релевантные куски учебников (textbook_chunks) под вопрос. @@ -289,6 +314,21 @@ async function callLLM(messages, maxTokens, override) { } catch (e) { return { text: null, error: e.name === 'AbortError' ? 'timeout' : 'network' }; } finally { clearTimeout(timer); } } +/* Перебор провайдеров: активный, затем остальные — при лимите/сетевой ошибке. + * Останавливаемся на успехе или на «контентной» неудаче (пустой ответ). */ +const _RETRYABLE = { rate_limit: 1, http: 1, timeout: 1, network: 1 }; +async function callLLMFailover(messages, maxTokens) { + const cfgs = providersOrdered(); + if (!cfgs.length) return { text: null, error: 'off' }; + let last = { text: null, error: 'off' }; + for (const c of cfgs) { + last = await callLLM(messages, maxTokens, c); + if (last.text) return last; + if (!_RETRYABLE[last.error]) break; // не лимит/сеть — нет смысла пробовать другие + } + return last; +} + /* Тест-пинг для админки: подробный статус (status/ошибка/пример ответа). */ async function pingLLM(override) { const cfg = override || llmConfig(); @@ -350,7 +390,7 @@ async function askModel(q, hits, context, history, role, mode) { const msgs = [{ role: 'system', content: sys }]; (history || []).forEach(m => { if (m && (m.role === 'user' || m.role === 'assistant') && m.content) msgs.push({ role: m.role, content: String(m.content).slice(0, 1500) }); }); msgs.push({ role: 'user', content: user }); - return callLLM(msgs, 420); + return callLLMFailover(msgs, 420); } /* ── POST /api/assistant/ask { q, context?, history? } ── «Спроси Квантика» ─ @@ -367,7 +407,7 @@ async function ask(req, res) { const hits = searchFaq(q, 3); const faqJson = hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null })); - if (!llmConfig().on) { bumpUsage('faq'); return res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] }); } + if (!providersOrdered().length) { bumpUsage('faq'); return res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] }); } const rag = ragContext(q); @@ -416,14 +456,14 @@ function feedback(req, res) { * Генерирует учебные карточки из текста (модель → JSON). Карточки фронт * создаёт сам через существующий API флешкарт. */ async function flashcardsFromText(req, res) { - if (!llmConfig().on) return res.status(503).json({ error: 'LLM не настроена' }); + if (!providersOrdered().length) return res.status(503).json({ error: 'LLM не настроена' }); const text = String((req.body && req.body.text) || '').trim().slice(0, 6000); const title = String((req.body && req.body.title) || 'Карточки').trim().slice(0, 80) || 'Карточки'; if (text.length < 20) return res.status(400).json({ error: 'Слишком мало текста' }); const sys = 'Ты составляешь учебные флешкарты по тексту. Верни СТРОГО JSON-массив из 5–6 объектов ' + 'вида {"front":"...","back":"..."} без markdown и пояснений. front — короткий вопрос, back — краткий ответ (1–2 предложения). ' + 'По-русски. Формулы в LaTeX между $...$. Никакого текста вне JSON.'; - const rr = await callLLM([{ role: 'system', content: sys }, { role: 'user', content: text }], 1400); + const rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 1400); const raw = rr && rr.text; let cards = []; if (raw) { diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 19d6f8b..561e26d 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -17,6 +17,9 @@ router.get('/assistant', ctrl.getAssistant); router.put('/assistant', ctrl.saveAssistant); router.post('/assistant/test', ctrl.testAssistant); router.post('/assistant/reindex', ctrl.reindexTextbooks); +router.post('/assistant/provider', ctrl.saveProvider); +router.delete('/assistant/provider/:id', requireRole('admin'), ctrl.deleteProvider); +router.post('/assistant/active', ctrl.setActiveProvider); router.get('/stats', ctrl.getStats); router.get('/overview', ctrl.getOverview); router.get('/search', ctrl.globalSearch); diff --git a/frontend/js/admin/sections/assistant.js b/frontend/js/admin/sections/assistant.js index 46edd6c..f211336 100644 --- a/frontend/js/admin/sections/assistant.js +++ b/frontend/js/admin/sections/assistant.js @@ -1,18 +1,22 @@ 'use strict'; -/* admin → «Помощник Квантик»: системный вкл/выкл + конфиг LLM (ключ/модель/тест), - * RAG, кнопки на экзамене, статистика использования и качество. */ +/* admin → «Помощник Квантик»: системный вкл/выкл + несколько провайдеров ИИ + * (ключи/модели) с выбором активного и авто-перехватом при лимите + 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)'; + var esc = (window.LS && LS.escapeHtml) ? LS.escapeHtml : function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]; }); }; async function render() { var host = document.getElementById('assistant-admin'); if (!host) return; host.innerHTML = ''; - // ── Системный выключатель (feature 'assistant') ── + // ── Системный выключатель ── var feats = {}; try { feats = await LS.api('/api/admin/features'); } catch (e) {} var on = feats.assistant !== false; @@ -30,87 +34,110 @@ .catch(function () { master.querySelector('#asst-master').checked = !v; LS.toast('Ошибка', 'error'); }); }); - // ── Конфиг модели ── - var card = document.createElement('div'); - card.id = 'asst-llm-card'; - card.className = 'perm-card'; - card.style.cssText = 'flex-direction:column;align-items:stretch;gap:9px;margin-top:14px'; - card.innerHTML = - '
Модель (ИИ) для «Спроси Квантика»
' + - '
OpenAI-совместимый эндпоинт. Без ключа — режим обычного FAQ.
' + - '
' + - '' + + var cfg = {}; + try { cfg = await LS.adminGetAssistant(); } catch (e) {} + var providers = cfg.providers || []; + var activeId = cfg.activeId; + var presets = cfg.presets || []; + + // ── Провайдеры ИИ ── + 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.innerHTML = + '
Провайдеры ИИ для «Спроси Квантика»
' + + '
Активный (отмечен точкой) используется первым. При лимите или ошибке Квантик автоматически пробует следующего с ключом. Без ключей — режим FAQ.
' + + '
' + rows + '
' + + '
' + + '
' + + '
Добавить провайдера
' + + '' + + '' + '' + '' + '' + '
' + - '' + - '' + - '' + - '
' + - '
' + - '
' + - '' + - '' + - '
' + - '' + - '' + - '
' + - '
' + - '
'; - host.appendChild(card); + '' + + '' + + ''; + host.appendChild(pc); + + // ── Настройки и статистика ── + 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) + ' фрагментов
' + + '
Сегодня: ' + ((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(', ') : '') + '
'; + host.appendChild(sc); + if (window.lucide) lucide.createIcons(); - var q = function (s) { return card.querySelector(s); }; - var cfg = {}; - try { cfg = await LS.adminGetAssistant(); } catch (e) {} - (cfg.presets || []).forEach(function (p, i) { var o = document.createElement('option'); o.value = String(i); o.textContent = p.name; q('#asst-preset').appendChild(o); }); - q('#asst-url').value = cfg.url || ''; - q('#asst-model').value = cfg.model || ''; - q('#asst-key').placeholder = cfg.hasKey ? 'Ключ сохранён — введите новый, чтобы заменить' : 'API-ключ'; + 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'; } - function setStatus() { - q('#asst-llm-status').innerHTML = cfg.active - ? '● Подключено — «Спроси» отвечает через ИИ' - : '○ Ключ не задан — работает обычный FAQ-режим'; - q('#asst-rag').checked = cfg.rag !== false; - q('#asst-exambtn').checked = !!cfg.examButtons; - q('#asst-chunks').textContent = (cfg.chunks || 0) + ' фрагментов учебников в индексе'; - var u = cfg.usage || {}, u30 = cfg.usage30 || {}; - q('#asst-usage').innerHTML = 'Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.'; - var f = cfg.feedback || {}; - q('#asst-quality').innerHTML = 'Оценки за 30 дней: ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' + - (f.recent && f.recent.length ? '. Недавно не помогло: ' + f.recent.map(function (x) { return '«' + String(x.q || '').slice(0, 40) + '»'; }).join(', ') : ''); - } - setStatus(); - - q('#asst-preset').addEventListener('change', function () { var p = (cfg.presets || [])[Number(this.value)]; if (p) { q('#asst-url').value = p.url; q('#asst-model').value = p.model; } }); + // активный провайдер + pc.querySelectorAll('input[name="asst-active"]').forEach(function (r) { + r.addEventListener('change', function () { LS.adminSetActiveProvider(this.value).then(function () { LS.toast('Активный провайдер обновлён', 'success'); }).catch(function () {}); }); + }); + // действия по провайдеру + pc.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') { + 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(); + } 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 || 'ошибка') + ''; } + } + }); + }); + // пресет → заполнить 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 = { url: q('#asst-url').value, model: q('#asst-model').value }; + 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; - try { await LS.adminSaveAssistant(body); q('#asst-key').value = ''; LS.toast('Сохранено', 'success'); cfg = await LS.adminGetAssistant(); q('#asst-key').placeholder = cfg.hasKey ? 'Ключ сохранён — введите новый, чтобы заменить' : 'API-ключ'; setStatus(); } - catch (e) { LS.toast('Ошибка: ' + (e.message || ''), 'error'); } - }); - q('#asst-test').addEventListener('click', async function () { - var res = q('#asst-test-res'); res.innerHTML = 'Проверяю…'; - var body = { url: q('#asst-url').value, model: q('#asst-model').value }; - var k = q('#asst-key').value.trim(); if (k) body.key = k; - try { - var r = await LS.adminTestAssistant(body); - res.innerHTML = r && r.ok - ? '✓ Работает (' + (r.model || '') + '): ' + String(r.sample || 'ответ получен').replace(/' - : '✗ ' + String((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').slice(0, 200).replace(/'; - } catch (e) { res.innerHTML = '✗ ' + (e.message || 'ошибка') + ''; } - }); - q('#asst-clearkey').addEventListener('click', async function () { - if (!await LS.confirm('Очистить сохранённый ключ? «Спроси» вернётся в FAQ-режим.', { title: 'Очистить ключ?', confirmText: 'Очистить' })) return; - try { await LS.adminSaveAssistant({ clearKey: true }); LS.toast('Ключ очищен', 'success'); cfg = await LS.adminGetAssistant(); q('#asst-key').placeholder = 'API-ключ'; setStatus(); } catch (e) { LS.toast('Ошибка', 'error'); } + 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(); cfg.chunks = (r && r.chunks) || 0; setStatus(); LS.toast('Готово: ' + cfg.chunks + ' фрагментов', 'success'); } + 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 = 'Переиндексировать учебники'; } }); diff --git a/js/api.js b/js/api.js index eb44903..444a211 100644 --- a/js/api.js +++ b/js/api.js @@ -1052,6 +1052,7 @@ window.LS = { createMaterialCollection, updateMaterialCollection, deleteMaterialCollection, assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, + adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, fcListDecks, fcCreateDeck, fcAddCard, escapeHtml, esc, parseDate, fmtRelTime, safeHref, @@ -1280,6 +1281,9 @@ async function adminGetAssistant() { return req('GET', '/admin/assistant'); } async function adminSaveAssistant(d) { return req('PUT', '/admin/assistant', d); } async function adminTestAssistant(d) { return req('POST', '/admin/assistant/test', d || {}); } async function adminReindexTextbooks() { return req('POST', '/admin/assistant/reindex', {}); } +async function adminSaveProvider(d) { return req('POST', '/admin/assistant/provider', d); } +async function adminDeleteProvider(id) { return req('DELETE', `/admin/assistant/provider/${id}`); } +async function adminSetActiveProvider(id) { return req('POST', '/admin/assistant/active', { id }); } async function fcListDecks() { return req('GET', '/flashcards/decks'); } async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); } async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }