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:
@@ -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 = {
|
||||
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 = /\/\/(localhost|127\.0\.0\.1)/.test(override.url);
|
||||
}
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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 llmConfig() {
|
||||
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';
|
||||
const local = /\/\/(localhost|127\.0\.0\.1)/.test(url);
|
||||
return { url, key, model, local, on: !!(key || local) };
|
||||
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 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';
|
||||
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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
'<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; } });
|
||||
q('#asst-save').addEventListener('click', async function () {
|
||||
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 { 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'); }
|
||||
// активный провайдер
|
||||
pc.querySelectorAll('input[name="asst-active"]').forEach(function (r) {
|
||||
r.addEventListener('change', function () { LS.adminSetActiveProvider(this.value).then(function () { LS.toast('Активный провайдер обновлён', 'success'); }).catch(function () {}); });
|
||||
});
|
||||
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;
|
||||
// действия по провайдеру
|
||||
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(body);
|
||||
res.innerHTML = r && r.ok
|
||||
? '<span style="color:#059652">✓ Работает (' + (r.model || '') + '): ' + String(r.sample || 'ответ получен').replace(/</g, '<') + '</span>'
|
||||
: '<span style="color:#e0335e">✗ ' + String((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').slice(0, 200).replace(/</g, '<') + '</span>';
|
||||
} catch (e) { res.innerHTML = '<span style="color:#e0335e">✗ ' + (e.message || 'ошибка') + '</span>'; }
|
||||
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>'; }
|
||||
}
|
||||
});
|
||||
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'); }
|
||||
});
|
||||
// пресет → заполнить 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 };
|
||||
if (editingId) body.id = editingId;
|
||||
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(); 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 = 'Переиндексировать учебники'; }
|
||||
});
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user