feat(assistant): чёткий ответ при лимите ИИ (память не теряется), напоминание о памяти, отдельный раздел в админке

- Баг «не помнит»: на самом деле free-лимит Gemini (429). callLLM теперь
  возвращает ошибку; при 429 показываем «много запросов, подожди минутку —
  память не потеряется» и НЕ ломаем историю (убираем неудачный вопрос); при
  сбое — «не получилось, попробуй позже». Раньше показывалось «не нашёл ответ».
- В окне «Спроси» — пояснение, сколько помнит Квантик (≈6 реплик, рабочая память).
- Окна красивее: шире, аватар Квантика в шапке, мягкая анимация.
- Управление помощником вынесено в отдельный раздел админки «Помощник Квантик»
  (системный вкл/выкл + модель/ключ/тест/RAG/кнопки экзамена/статистика/качество);
  из раздела «Игры» конфиг убран.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 20:03:02 +03:00
parent 961504b256
commit 78300845ed
6 changed files with 176 additions and 116 deletions
+124
View File
@@ -0,0 +1,124 @@
'use strict';
/* admin → «Помощник Квантик»: системный вкл/выкл + конфиг LLM (ключ/модель/тест),
* RAG, кнопки на экзамене, статистика использования и качество. */
(function () {
'use strict';
let inited = false;
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)';
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;
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 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>' +
'<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);
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-ключ';
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'); }
});
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'); }
});
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'); }
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,
};
})();
-97
View File
@@ -16,7 +16,6 @@
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
{ key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' },
{ key: 'assistant', label: 'Помощник «Квантик»', desc: 'Плавающий помощник: подсказки по разделам, напоминания и «Спроси Квантика»', icon: 'sparkles' },
];
const FS_FEATURES = [
@@ -34,104 +33,8 @@
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
];
/* ── Конфиг LLM для помощника «Квантик» ── */
var IN_STYLE = '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_STYLE = '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)';
async function renderAssistantLlmCard(grid) {
if (!grid || document.getElementById('asst-llm-card')) return;
var wrap = document.createElement('div');
wrap.id = 'asst-llm-card';
wrap.className = 'perm-card';
wrap.style.cssText = 'flex-direction:column;align-items:stretch;gap:9px;margin-bottom:14px';
wrap.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">Подключение LLM к «Спроси Квантика». OpenAI-совместимый эндпоинт. Без ключа — режим обычного FAQ.</div>' +
'<div id="asst-llm-status" style="font-size:.82rem;font-weight:600"></div>' +
'<select id="asst-preset" style="' + IN_STYLE + '"><option value="">— провайдер (пресет) —</option></select>' +
'<input id="asst-url" placeholder="URL chat/completions" style="' + IN_STYLE + '" />' +
'<input id="asst-model" placeholder="Модель" style="' + IN_STYLE + '" />' +
'<input id="asst-key" type="password" autocomplete="off" placeholder="API-ключ" style="' + IN_STYLE + '" />' +
'<div style="display:flex;gap:8px;flex-wrap:wrap">' +
'<button id="asst-save" style="' + BTN_STYLE + ';background:#9B5DE5;border-color:#9B5DE5;color:#fff">Сохранить</button>' +
'<button id="asst-test" style="' + BTN_STYLE + '">Проверить</button>' +
'<button id="asst-clearkey" style="' + BTN_STYLE + ';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_STYLE + '">Переиндексировать учебники</button>' +
'<span id="asst-chunks" style="font-size:.78rem;color:#8a94a6"></span>' +
'</div>' +
'<div id="asst-usage" style="font-size:.78rem;color:#8a94a6"></div>';
grid.parentNode.insertBefore(wrap, grid);
if (window.lucide) lucide.createIcons();
var q = function (s) { return wrap.querySelector(s); };
var cfg = {};
try { cfg = await LS.adminGetAssistant(); } catch (e) {}
var presetSel = q('#asst-preset');
(cfg.presets || []).forEach(function (p, i) { var o = document.createElement('option'); o.value = String(i); o.textContent = p.name; presetSel.appendChild(o); });
q('#asst-url').value = cfg.url || '';
q('#asst-model').value = cfg.model || '';
q('#asst-key').placeholder = cfg.hasKey ? 'Ключ сохранён — введите новый, чтобы заменить' : 'API-ключ';
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) + '.';
}
setStatus();
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'); }
catch (e) { LS.toast('Ошибка индексации', 'error'); }
finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; }
});
presetSel.addEventListener('change', function () {
var p = (cfg.presets || [])[Number(presetSel.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(); cfg.hasKey && (q('#asst-key').placeholder = 'Ключ сохранён — введите новый, чтобы заменить'); 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 || '') + '): ' + (r.sample || 'ответ получен').replace(/</g, '&lt;') + '</span>'
: '<span style="color:#e0335e">✗ ' + ((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').toString().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'); }
});
}
async function loadGamesAdmin() {
const grid = document.getElementById('games-features-grid');
renderAssistantLlmCard(grid);
try {
const features = await LS.api('/api/admin/features');
grid.innerHTML = '';