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:
@@ -269,9 +269,10 @@ function bumpUsage(field) {
|
||||
}
|
||||
|
||||
/* Низкоуровневый вызов OpenAI-совместимого chat/completions. */
|
||||
/* Возвращает { text, error } — error: 'off'|'rate_limit'|'http'|'timeout'|'network'|'empty'|null. */
|
||||
async function callLLM(messages, maxTokens, override) {
|
||||
const cfg = override || llmConfig();
|
||||
if (typeof fetch !== 'function' || !cfg.on) return null;
|
||||
if (typeof fetch !== 'function' || !cfg.on) return { text: null, error: 'off' };
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 15000);
|
||||
try {
|
||||
@@ -281,11 +282,11 @@ async function callLLM(messages, maxTokens, override) {
|
||||
body: JSON.stringify({ model: cfg.model, temperature: 0.3, max_tokens: maxTokens || 320, messages }),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
if (!r.ok) return { text: null, error: r.status === 429 ? 'rate_limit' : 'http', status: r.status };
|
||||
const data = await r.json();
|
||||
const text = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content;
|
||||
return text ? String(text).trim() : null;
|
||||
} catch (e) { return null; } finally { clearTimeout(timer); }
|
||||
return { text: text ? String(text).trim() : null, error: text ? null : 'empty' };
|
||||
} catch (e) { return { text: null, error: e.name === 'AbortError' ? 'timeout' : 'network' }; } finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
/* Тест-пинг для админки: подробный статус (status/ошибка/пример ответа). */
|
||||
@@ -383,15 +384,23 @@ async function ask(req, res) {
|
||||
let context = pageCtx;
|
||||
if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text;
|
||||
|
||||
let answer = null;
|
||||
try { answer = await askModel(q, hits, context, history, req.user && req.user.role, mode); } catch (e) { answer = null; }
|
||||
let r = { text: null, error: 'network' };
|
||||
try { r = await askModel(q, hits, context, history, req.user && req.user.role, mode); } catch (e) { r = { text: null, error: 'network' }; }
|
||||
const answer = r && r.text;
|
||||
|
||||
if (answer) {
|
||||
bumpUsage('model_calls');
|
||||
if (cacheable) { try { db.prepare("INSERT OR REPLACE INTO assistant_cache (qhash, answer, created_at) VALUES (?, ?, datetime('now'))").run(qhash, answer); } catch (e) {} }
|
||||
} else { bumpUsage('faq'); }
|
||||
|
||||
res.json({ source: answer ? 'model' : 'faq', answer: answer || null, answers: faqJson, sources: answer ? rag.sources : [] });
|
||||
return res.json({ source: 'model', answer, answers: faqJson, sources: rag.sources });
|
||||
}
|
||||
bumpUsage('faq');
|
||||
if (r && r.error === 'rate_limit') {
|
||||
return res.json({ source: 'limit', answer: 'Сейчас слишком много запросов к ИИ за короткое время — подожди минутку и спроси снова. Память диалога не потеряется.', answers: faqJson, sources: [] });
|
||||
}
|
||||
if (r && (r.error === 'timeout' || r.error === 'network' || r.error === 'http')) {
|
||||
return res.json({ source: 'error', answer: 'Не получилось обратиться к ИИ. Попробуй ещё раз чуть позже.', answers: faqJson, sources: [] });
|
||||
}
|
||||
res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] });
|
||||
}
|
||||
|
||||
/* ── POST /api/assistant/feedback { rating, q? } ── лайк/дизлайк ответа ── */
|
||||
@@ -414,7 +423,8 @@ async function flashcardsFromText(req, res) {
|
||||
const sys = 'Ты составляешь учебные флешкарты по тексту. Верни СТРОГО JSON-массив из 5–6 объектов ' +
|
||||
'вида {"front":"...","back":"..."} без markdown и пояснений. front — короткий вопрос, back — краткий ответ (1–2 предложения). ' +
|
||||
'По-русски. Формулы в LaTeX между $...$. Никакого текста вне JSON.';
|
||||
const raw = await callLLM([{ role: 'system', content: sys }, { role: 'user', content: text }], 1400);
|
||||
const rr = await callLLM([{ role: 'system', content: sys }, { role: 'user', content: text }], 1400);
|
||||
const raw = rr && rr.text;
|
||||
let cards = [];
|
||||
if (raw) {
|
||||
let s = raw.replace(/```(?:json)?/gi, '').trim();
|
||||
|
||||
@@ -1070,6 +1070,9 @@
|
||||
<button class="admin-nav-item" data-tab="games" onclick="switchTab(this)" id="btn-tab-games" style="display:none">
|
||||
<i data-lucide="gamepad-2" style="width:15px;height:15px"></i> Игры
|
||||
</button>
|
||||
<button class="admin-nav-item" data-tab="assistant" onclick="switchTab(this)" id="btn-tab-assistant" style="display:none">
|
||||
<i data-lucide="sparkles" style="width:15px;height:15px"></i> Помощник Квантик
|
||||
</button>
|
||||
<button class="admin-nav-item" data-tab="audit" onclick="switchTab(this)">
|
||||
<i data-lucide="scroll-text" style="width:15px;height:15px"></i> Аудит-лог
|
||||
</button>
|
||||
@@ -1545,6 +1548,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Помощник Квантик ── -->
|
||||
<div class="tab-pane" id="tab-assistant">
|
||||
<div class="section-title">Помощник «Квантик»</div>
|
||||
<div class="perm-desc" style="margin-bottom:20px">Настройки ИИ-помощника: модель, RAG по учебникам, кнопки на экзамене, статистика и качество ответов.</div>
|
||||
<div id="assistant-admin"><div style="color:var(--muted);font-size:0.84rem">Загрузка…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- ── Игры ── -->
|
||||
<div class="tab-pane" id="tab-games">
|
||||
<div class="section-title">Управление играми</div>
|
||||
@@ -2117,6 +2127,7 @@
|
||||
<script src="/js/admin/sections/sublog.js"></script>
|
||||
<script src="/js/admin/sections/sims.js"></script>
|
||||
<script src="/js/admin/sections/games.js"></script>
|
||||
<script src="/js/admin/sections/assistant.js"></script>
|
||||
<script src="/js/admin/sections/tpl.js"></script>
|
||||
<script src="/js/admin/sections/subjects.js"></script>
|
||||
<script src="/js/admin/sections/permissions.js"></script>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
AdminCtx.isAdmin = isAdmin;
|
||||
|
||||
/* Admin-only tabs: show to everyone for discoverability, but lock for non-admins */
|
||||
const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games'];
|
||||
const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games','btn-tab-assistant'];
|
||||
const lockSvg = '<svg class="adm-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
|
||||
ADMIN_ONLY_TABS.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
@@ -65,6 +65,7 @@
|
||||
tpl: 'tpl',
|
||||
sims: 'sims',
|
||||
games: 'games',
|
||||
assistant: 'assistant',
|
||||
sublog: 'sublog',
|
||||
access: 'access',
|
||||
};
|
||||
|
||||
@@ -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, '<') + '</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>'; }
|
||||
});
|
||||
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,
|
||||
};
|
||||
})();
|
||||
@@ -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, '<') + '</span>'
|
||||
: '<span style="color:#e0335e">✗ ' + ((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').toString().slice(0, 200).replace(/</g, '<') + '</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 = '';
|
||||
|
||||
@@ -280,9 +280,12 @@
|
||||
'.asst-dot{position:absolute;top:0;right:0;width:13px;height:13px;border-radius:50%;background:#F15BB5;border:2px solid #fff;}',
|
||||
reduceMotion ? '' : '.asst-fab.pulse{animation:asstPulse 2.2s ease-in-out infinite;}',
|
||||
'@keyframes asstPulse{0%,100%{box-shadow:0 8px 24px rgba(139,92,246,.32);}50%{box-shadow:0 8px 30px rgba(241,91,181,.5);}}',
|
||||
'.asst-bubble{position:absolute;left:0;bottom:64px;width:300px;max-width:78vw;background:#fff;border-radius:16px;',
|
||||
' box-shadow:0 18px 50px rgba(15,23,42,.22);padding:14px 16px;border:1px solid rgba(15,23,42,.07);',
|
||||
' opacity:0;transform:translateY(8px);pointer-events:none;transition:opacity .18s,transform .18s;}',
|
||||
'.asst-bubble{position:absolute;left:0;bottom:66px;width:330px;max-width:88vw;background:#fff;border-radius:18px;',
|
||||
' box-shadow:0 20px 56px rgba(15,23,42,.24);padding:15px 17px;border:1px solid rgba(15,23,42,.07);',
|
||||
' opacity:0;transform:translateY(8px) scale(.98);pointer-events:none;transition:opacity .18s,transform .18s;transform-origin:bottom left;}',
|
||||
'.asst-name-face{display:inline-block;width:20px;height:20px;vertical-align:-4px;margin-right:7px;}',
|
||||
'.asst-name-face svg{width:100%;height:100%;display:block;}',
|
||||
'.asst-memnote{font-size:.66rem;color:#9aa5b4;margin-top:9px;line-height:1.45;border-top:1px solid rgba(15,23,42,.05);padding-top:8px;}',
|
||||
'.asst-bubble.open{opacity:1;transform:translateY(0);pointer-events:auto;}',
|
||||
'.asst-x{position:absolute;top:8px;right:8px;width:26px;height:26px;border:none;background:transparent;color:#8a94a6;',
|
||||
' cursor:pointer;border-radius:7px;font-size:18px;line-height:1;}',
|
||||
@@ -493,9 +496,10 @@
|
||||
'<button class="asst-mode" data-m="hint">Подсказка</button>' +
|
||||
'<button class="asst-mode" data-m="check">Проверить решение</button></div>';
|
||||
openBubble(
|
||||
'<div class="asst-name">Спроси Квантика' + (_chat.length ? '<button class="asst-link" data-a="clear" style="float:right;font-weight:600;margin-right:24px">Очистить</button>' : '') + '</div>' +
|
||||
'<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Спроси Квантика' + (_chat.length ? '<button class="asst-link" data-a="clear" style="float:right;font-weight:600;margin-right:24px">Очистить</button>' : '') + '</div>' +
|
||||
'<div class="asst-chat"></div>' + chips + modes +
|
||||
'<input class="asst-ask-in" type="text" placeholder="' + MODE_PH.answer + '" maxlength="500" />', {});
|
||||
'<input class="asst-ask-in" type="text" placeholder="' + MODE_PH.answer + '" maxlength="500" />' +
|
||||
'<div class="asst-memnote">Я помню последние ~6 сообщений этого разговора — как рабочая память: что было раньше, понимаю; старое постепенно забывается. «Очистить» — начать с чистого листа.</div>', {});
|
||||
var inp = bubble.querySelector('.asst-ask-in');
|
||||
var chatEl = bubble.querySelector('.asst-chat');
|
||||
var chipsEl = bubble.querySelector('.asst-chips');
|
||||
@@ -540,9 +544,16 @@
|
||||
(LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }),
|
||||
]).then(function (res) {
|
||||
ph.remove();
|
||||
var model = res[0] && res[0].answer;
|
||||
var ans = (res[0] && res[0].answers) || [];
|
||||
var sources = (res[0] && res[0].sources) || [];
|
||||
var r0 = res[0] || {};
|
||||
// лимит/ошибка ИИ — не ломаем память диалога: убираем последний вопрос, показываем сообщение
|
||||
if (r0.source === 'limit' || r0.source === 'error') {
|
||||
_chat.pop();
|
||||
var em = msgEl('assistant'); em.className += ' asst-msg-ph'; em.textContent = r0.answer || 'Сейчас не получилось. Попробуй ещё раз.';
|
||||
chatEl.appendChild(em); chatEl.scrollTop = chatEl.scrollHeight; return;
|
||||
}
|
||||
var model = r0.source === 'model' ? r0.answer : null;
|
||||
var ans = r0.answers || [];
|
||||
var sources = r0.sources || [];
|
||||
var found = (res[1] && res[1].results) || [];
|
||||
var content = model || (ans[0] && (ans[0].q + '\n' + ans[0].a)) || 'Не нашёл точного ответа. Попробуй переформулировать или поищи (Ctrl+K).';
|
||||
_chat.push({ role: 'assistant', content: content });
|
||||
|
||||
Reference in New Issue
Block a user