e2bff24b5b
Конфиг стал списком провайдеров (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>
152 lines
13 KiB
JavaScript
152 lines
13 KiB
JavaScript
'use strict';
|
|
/* 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 = '';
|
|
|
|
// ── Системный выключатель ──
|
|
var feats = {};
|
|
try { feats = await LS.api('/api/admin/features'); } catch (e) {}
|
|
var on = feats.assistant !== false;
|
|
var master = document.createElement('div');
|
|
master.className = 'perm-card' + (on ? ' enabled' : '');
|
|
master.innerHTML =
|
|
'<div class="perm-info"><div class="perm-label"><i data-lucide="power" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>Помощник включён для всей системы</div>' +
|
|
'<div class="perm-desc">Выключатель «Квантика» для всех пользователей. Выключено — помощник не загружается нигде.</div></div>' +
|
|
'<label class="perm-toggle"><input type="checkbox" id="asst-master" ' + (on ? 'checked' : '') + '><span class="perm-track"></span><span class="perm-thumb"></span></label>';
|
|
host.appendChild(master);
|
|
master.querySelector('#asst-master').addEventListener('change', function () {
|
|
var v = this.checked;
|
|
LS.api('/api/admin/features', { method: 'PATCH', body: JSON.stringify({ assistant: v }) })
|
|
.then(function () { master.classList.toggle('enabled', v); LS.toast(v ? 'Помощник включён' : 'Помощник выключен для всех', 'success'); })
|
|
.catch(function () { master.querySelector('#asst-master').checked = !v; LS.toast('Ошибка', 'error'); });
|
|
});
|
|
|
|
var cfg = {};
|
|
try { cfg = await LS.adminGetAssistant(); } catch (e) {}
|
|
var providers = cfg.providers || [];
|
|
var activeId = cfg.activeId;
|
|
var presets = cfg.presets || [];
|
|
|
|
// ── Провайдеры ИИ ──
|
|
var 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-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 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'; }
|
|
|
|
// активный провайдер
|
|
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 = { 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(); q('#asst-chunks').textContent = ((r && r.chunks) || 0) + ' фрагментов'; LS.toast('Готово', 'success'); }
|
|
catch (e) { LS.toast('Ошибка индексации', 'error'); }
|
|
finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; }
|
|
});
|
|
}
|
|
|
|
window.AdminSections = window.AdminSections || {};
|
|
window.AdminSections.assistant = {
|
|
init: async () => { if (inited) return; inited = true; await render(); },
|
|
reload: render,
|
|
};
|
|
})();
|