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 = - '