feat(assistant): несколько провайдеров ИИ + выбор активного + авто-перехват при лимите

Конфиг стал списком провайдеров (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) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 20:21:06 +03:00
parent 78300845ed
commit e2bff24b5b
5 changed files with 227 additions and 98 deletions
+75 -20
View File
@@ -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,
};
+48 -8
View File
@@ -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) {
+3
View File
@@ -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);
+97 -70
View File
@@ -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 ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 =
'<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">OpenAI-совместимый эндпоинт. Без ключа — режим обычного FAQ.</div>' +
'<div id="asst-llm-status" style="font-size:.82rem;font-weight:600"></div>' +
'<select id="asst-preset" style="' + IN + '"><option value="">— провайдер (пресет) —</option></select>' +
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 '<label class="asst-prov-row" style="display:flex;align-items:center;gap:9px;padding:8px 10px;border:1px solid var(--border,#e2e8f0);border-radius:9px">' +
'<input type="radio" name="asst-active" value="' + p.id + '"' + (p.id === activeId ? ' checked' : '') + '>' +
'<span style="font-weight:700">' + esc(p.name || 'Провайдер') + '</span>' +
'<span style="color:#8a94a6;font-size:.78rem">' + esc(p.model || '') + ' · ' + (p.hasKey ? '<span style="color:#059652">ключ есть</span>' : '<span style="color:#e0335e">нет ключа</span>') + '</span>' +
'<span style="margin-left:auto;display:flex;gap:5px">' +
'<button type="button" style="' + SBTN + '" data-act="test" data-id="' + p.id + '">Тест</button>' +
'<button type="button" style="' + SBTN + '" data-act="edit" data-id="' + p.id + '">Изм.</button>' +
'<button type="button" style="' + SBTN + ';color:#e0335e" data-act="del" data-id="' + p.id + '">Удалить</button>' +
'</span></label>';
}).join('') : '<div style="color:#8a94a6;font-size:.84rem">Пока нет провайдеров — добавьте ниже.</div>';
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 id="asst-prov-list" style="display:flex;flex-direction:column;gap:7px">' + rows + '</div>' +
'<div id="asst-prov-test" style="font-size:.82rem;line-height:1.5"></div>' +
'<hr style="border:none;border-top:1px solid var(--border,#e2e8f0);margin:4px 0">' +
'<div style="font-weight:700;font-size:.85rem" id="asst-form-title">Добавить провайдера</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>' +
'<input id="asst-name" placeholder="Название (напр. Groq основной)" style="' + IN + '" />' +
'<input id="asst-url" placeholder="URL chat/completions" style="' + IN + '" />' +
'<input id="asst-model" placeholder="Модель" style="' + IN + '" />' +
'<input id="asst-key" type="password" autocomplete="off" placeholder="API-ключ" style="' + IN + '" />' +
'<div style="display:flex;gap:8px;flex-wrap:wrap">' +
'<button id="asst-save" style="' + BTN + ';background:#9B5DE5;border-color:#9B5DE5;color:#fff">Сохранить</button>' +
'<button id="asst-test" style="' + BTN + '">Проверить</button>' +
'<button id="asst-clearkey" style="' + BTN + ';color:#e0335e">Очистить ключ</button>' +
'</div>' +
'<div id="asst-test-res" style="font-size:.82rem;line-height:1.5"></div>' +
'<hr style="border:none;border-top:1px solid var(--border,#e2e8f0);margin:4px 0">' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-rag"> Искать ответы по учебникам (RAG)</label>' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-exambtn"> Кнопки помощника на карточках экзамена («Подсказка», «Спросить Квантика»)</label>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
'<button id="asst-reindex" style="' + BTN + '">Переиндексировать учебники</button>' +
'<span id="asst-chunks" style="font-size:.78rem;color:#8a94a6"></span>' +
'</div>' +
'<div id="asst-usage" style="font-size:.78rem;color:#8a94a6"></div>' +
'<div id="asst-quality" style="font-size:.78rem;color:#8a94a6"></div>';
host.appendChild(card);
'<button id="asst-save" style="' + BTN + ';background:#9B5DE5;border-color:#9B5DE5;color:#fff">Сохранить провайдера</button>' +
'<button id="asst-cancel" style="' + BTN + ';display:none">Отмена</button>' +
'</div>';
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 =
'<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>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><button id="asst-reindex" style="' + BTN + '">Переиндексировать учебники</button><span id="asst-chunks" style="font-size:.78rem;color:#8a94a6">' + (cfg.chunks || 0) + ' фрагментов</span></div>' +
'<div style="font-size:.78rem;color:#8a94a6">Сегодня: ' + ((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) + '.</div>' +
'<div style="font-size:.78rem;color:#8a94a6">Оценки (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(', ') : '') + '</div>';
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
? '<span style="color:#059652">● Подключено — «Спроси» отвечает через ИИ</span>'
: '<span style="color:#94a3b8">○ Ключ не задан — работает обычный FAQ-режим</span>';
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 ? '<span style="color:#059652">✓ Работает (' + (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>'; }
}
});
});
// пресет → заполнить 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
? '<span style="color:#059652">✓ Работает (' + (r.model || '') + '): ' + String(r.sample || 'ответ получен').replace(/</g, '&lt;') + '</span>'
: '<span style="color:#e0335e">✗ ' + String((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').slice(0, 200).replace(/</g, '&lt;') + '</span>';
} catch (e) { res.innerHTML = '<span style="color:#e0335e">✗ ' + (e.message || 'ошибка') + '</span>'; }
});
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 = 'Переиндексировать учебники'; }
});
+4
View File
@@ -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); }