feat(assistant): админ-панель LLM (ключ/URL/модель/тест) + многоходовой чат
Админка (Управление → игры/фичи): карточка «Помощник Квантик — модель» — пресеты провайдеров, URL/модель, поле ключа, кнопки Сохранить/Проверить/ Очистить ключ, индикатор статуса. Конфиг в app_settings (без рестарта), откат на ENV/дефолты; нет ключа → автоматически FAQ-режим. Эндпоинты GET/PUT/POST /api/admin/assistant(/test), admin-only. «Спроси Квантика» теперь многоходовой чат: история диалога (последние 6 реплик) уходит модели, ответы рендерятся как чат-лента, кнопка «Очистить». Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -34,8 +34,78 @@
|
||||
{ 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>';
|
||||
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>';
|
||||
}
|
||||
setStatus();
|
||||
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 = '';
|
||||
|
||||
+64
-47
@@ -303,6 +303,14 @@
|
||||
'.asst-rich li{margin:2px 0;}',
|
||||
'.asst-rich code{background:rgba(15,23,42,.06);border-radius:4px;padding:1px 4px;}',
|
||||
'.asst-md-h{font-weight:800;color:#0F172A;margin:6px 0 2px;}',
|
||||
'.asst-chat{max-height:46vh;overflow:auto;display:flex;flex-direction:column;gap:8px;margin-bottom:8px;}',
|
||||
'.asst-chat:empty{display:none;}',
|
||||
'.asst-msg{font-size:.84rem;line-height:1.5;border-radius:12px;padding:8px 11px;max-width:92%;word-break:break-word;}',
|
||||
'.asst-msg-user{align-self:flex-end;background:#9B5DE5;color:#fff;}',
|
||||
'.asst-msg-assistant{align-self:flex-start;background:rgba(15,23,42,.05);}',
|
||||
'.asst-msg-assistant .asst-rich{color:#28324a;}',
|
||||
'.asst-msg-ph{opacity:.6;}',
|
||||
'.asst-msg-links{align-self:flex-start;font-size:.74rem;}',
|
||||
'.asst-empty{font-size:.82rem;color:#8a94a6;padding:6px 0;}',
|
||||
// на мобиле сайдбар — выезжающая шторка, контент во всю ширину → к левому краю
|
||||
'@media(max-width:768px){.asst-root,.app-layout ~ .asst-root,.app-layout.sb-collapsed ~ .asst-root{left:12px;bottom:18px;}.asst-fab{width:48px;height:48px;}}',
|
||||
@@ -433,6 +441,18 @@
|
||||
|
||||
/* ── «Спроси Квантика» ───────────────────────────────────────────────── */
|
||||
var SUGGESTIONS = ['Как вырезать кусок учебника?', 'Как создать карточки?', 'Как начать тест?', 'Объясни теорему Пифагора', 'Где мои домашние задания?', 'Как включить тёмную тему?'];
|
||||
var _chat = []; // многоходовой диалог: [{role:'user'|'assistant', content}]
|
||||
function msgEl(role) { var d = document.createElement('div'); d.className = 'asst-msg asst-msg-' + role; return d; }
|
||||
function renderChat(chatEl) {
|
||||
chatEl.innerHTML = '';
|
||||
_chat.forEach(function (m) {
|
||||
var d = msgEl(m.role);
|
||||
if (m.role === 'assistant') { d.innerHTML = '<div class="asst-rich"></div>'; renderRich(d.querySelector('.asst-rich'), m.content); }
|
||||
else d.textContent = m.content;
|
||||
chatEl.appendChild(d);
|
||||
});
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
}
|
||||
function openAsk(prefill) {
|
||||
var sel = _lastSel, pc = getPageContext();
|
||||
var ctxBtns = '';
|
||||
@@ -443,67 +463,64 @@
|
||||
var chips = '<div class="asst-chips">' + ctxBtns +
|
||||
SUGGESTIONS.map(function (q) { return '<button class="asst-chip" type="button">' + esc(q) + '</button>'; }).join('') + '</div>';
|
||||
openBubble(
|
||||
'<div class="asst-name">Спроси Квантика</div>' +
|
||||
'<input class="asst-ask-in" type="text" placeholder="Спроси что угодно: «объясни…», «как…»" maxlength="300" />' +
|
||||
chips + '<div class="asst-ans-box"></div>', {});
|
||||
'<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-chat"></div>' + chips +
|
||||
'<input class="asst-ask-in" type="text" placeholder="Спроси что угодно: «объясни…», «как…»" maxlength="300" />', {});
|
||||
var inp = bubble.querySelector('.asst-ask-in');
|
||||
var box = bubble.querySelector('.asst-ans-box');
|
||||
var t = null;
|
||||
inp.addEventListener('input', function () { clearTimeout(t); t = setTimeout(function () { runAsk(inp.value, box); }, 350); });
|
||||
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') { clearTimeout(t); runAsk(inp.value, box); } });
|
||||
var chatEl = bubble.querySelector('.asst-chat');
|
||||
var chipsEl = bubble.querySelector('.asst-chips');
|
||||
renderChat(chatEl);
|
||||
if (_chat.length) chipsEl.style.display = 'none';
|
||||
function go(q, context) { if (chipsEl) chipsEl.style.display = 'none'; inp.value = ''; send(q, context, chatEl); }
|
||||
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') go(inp.value); });
|
||||
bubble.querySelectorAll('.asst-chip').forEach(function (c) {
|
||||
c.addEventListener('click', function () {
|
||||
var ctx = c.getAttribute('data-ctx');
|
||||
if (ctx === 'sel') return runAsk('Объясни простыми словами и приведи пример.', box, sel);
|
||||
if (ctx === 'sec') return runAsk('Объясни простыми словами, о чём этот параграф, и выдели главное.', box, pc && pc.text);
|
||||
if (ctx === 'sum') return runAsk('Сделай краткий конспект этого материала: 4–6 главных пунктов.', box, pc && pc.text);
|
||||
if (ctx === 'cards') return makeFlashcards(pc, box);
|
||||
inp.value = c.textContent; runAsk(c.textContent, box); inp.focus();
|
||||
if (ctx === 'sel') return go('Объясни простыми словами и приведи пример.', sel);
|
||||
if (ctx === 'sec') return go('Объясни простыми словами, о чём этот параграф, и выдели главное.', pc && pc.text);
|
||||
if (ctx === 'sum') return go('Сделай краткий конспект этого материала: 4–6 главных пунктов.', pc && pc.text);
|
||||
if (ctx === 'cards') { if (chipsEl) chipsEl.style.display = 'none'; return makeFlashcards(pc, chatEl); }
|
||||
go(c.textContent);
|
||||
});
|
||||
});
|
||||
if (prefill) { inp.value = prefill.q || ''; runAsk(prefill.q, box, prefill.context); }
|
||||
var clr = bubble.querySelector('[data-a="clear"]');
|
||||
if (clr) clr.onclick = function () { _chat = []; openAsk(); };
|
||||
if (prefill && prefill.q) go(prefill.q, prefill.context);
|
||||
else inp.focus();
|
||||
}
|
||||
function runAsk(q, box, context) {
|
||||
function send(q, context, chatEl) {
|
||||
q = (q || '').trim();
|
||||
if (q.length < 3) { box.innerHTML = ''; return; }
|
||||
box.innerHTML = '<div class="asst-empty">Думаю…</div>';
|
||||
if (q.length < 2) return;
|
||||
var history = _chat.slice(-6);
|
||||
_chat.push({ role: 'user', content: q });
|
||||
var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u);
|
||||
var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.textContent = 'Думаю…'; chatEl.appendChild(ph);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
Promise.all([
|
||||
LS.assistantAsk(q, context).catch(function () { return { answers: [] }; }),
|
||||
(LS.globalSearch ? LS.globalSearch(q, 'all', 4) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }),
|
||||
LS.assistantAsk(q, context, history).catch(function () { return { answers: [] }; }),
|
||||
(LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }),
|
||||
]).then(function (res) {
|
||||
var modelAnswer = res[0] && res[0].answer;
|
||||
ph.remove();
|
||||
var model = res[0] && res[0].answer;
|
||||
var ans = (res[0] && res[0].answers) || [];
|
||||
var found = (res[1] && res[1].results) || [];
|
||||
box.innerHTML = '';
|
||||
if (modelAnswer) {
|
||||
var a = document.createElement('div'); a.className = 'asst-ans';
|
||||
a.innerHTML = '<div class="asst-ans-q">Квантик</div><div class="asst-rich"></div>';
|
||||
box.appendChild(a);
|
||||
renderRich(a.querySelector('.asst-rich'), modelAnswer);
|
||||
if (ans.length) box.insertAdjacentHTML('beforeend', '<div class="asst-ans-sec">Из справки</div>');
|
||||
}
|
||||
var rest = '';
|
||||
if (ans.length) rest += ans.map(function (a2) {
|
||||
return '<div class="asst-ans"><div class="asst-ans-q">' + esc(a2.q) + '</div>' + esc(a2.a) +
|
||||
(a2.url ? '<br><a class="asst-ans-link" href="' + esc(a2.url) + '">Открыть</a>' : '') + '</div>';
|
||||
}).join('');
|
||||
if (found.length) {
|
||||
rest += '<div class="asst-ans-sec">На платформе</div>';
|
||||
rest += found.slice(0, 4).map(function (f) {
|
||||
return '<div class="asst-ans"><a class="asst-ans-link" style="margin-top:0" href="' + esc(f.url || '#') + '">' + esc(f.title || 'Без названия') + '</a>' +
|
||||
(f.subtitle ? ' <span style="color:#8a94a6">— ' + esc(f.subtitle) + '</span>' : '') + '</div>';
|
||||
}).join('');
|
||||
}
|
||||
if (rest) box.insertAdjacentHTML('beforeend', rest);
|
||||
if (!box.innerHTML) box.innerHTML = '<div class="asst-empty">Ничего не нашёл. Попробуй переформулировать.</div>';
|
||||
}).catch(function () { box.innerHTML = '<div class="asst-empty">Не удалось получить ответ.</div>'; });
|
||||
var content = model || (ans[0] && (ans[0].q + '\n' + ans[0].a)) || 'Не нашёл точного ответа. Попробуй переформулировать или поищи (Ctrl+K).';
|
||||
_chat.push({ role: 'assistant', content: content });
|
||||
var d = msgEl('assistant'); d.innerHTML = '<div class="asst-rich"></div>'; chatEl.appendChild(d);
|
||||
renderRich(d.querySelector('.asst-rich'), content);
|
||||
var links = '';
|
||||
if (!model && ans.length) links += ans.slice(0, 2).filter(function (a) { return a.url; }).map(function (a) { return '<a class="asst-ans-link" href="' + esc(a.url) + '">' + esc(a.q) + '</a>'; }).join(' · ');
|
||||
if (found.length) links += (links ? '<br>' : '') + '<span style="color:#8a94a6">На платформе: </span>' + found.slice(0, 3).map(function (f) { return '<a class="asst-ans-link" href="' + esc(f.url || '#') + '">' + esc(f.title || '…') + '</a>'; }).join(' · ');
|
||||
if (links) { var l = document.createElement('div'); l.className = 'asst-msg-links'; l.innerHTML = links; chatEl.appendChild(l); }
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
}).catch(function () { ph.textContent = 'Не удалось получить ответ.'; });
|
||||
}
|
||||
|
||||
/* ── «Флешкарты из параграфа» — модель → колода через API флешкарт ─────── */
|
||||
function makeFlashcards(pc, box) {
|
||||
if (!pc || !pc.text) { box.innerHTML = '<div class="asst-empty">Открой параграф учебника, чтобы сделать карточки.</div>'; return; }
|
||||
box.innerHTML = '<div class="asst-empty">Готовлю карточки…</div>';
|
||||
function makeFlashcards(pc, chatEl) {
|
||||
var note = msgEl('assistant');
|
||||
if (!pc || !pc.text) { note.innerHTML = '<div class="asst-rich">Открой параграф учебника, чтобы сделать карточки.</div>'; chatEl.appendChild(note); return; }
|
||||
note.innerHTML = '<div class="asst-rich">Готовлю карточки…</div>'; chatEl.appendChild(note); chatEl.scrollTop = chatEl.scrollHeight;
|
||||
LS.assistantFlashcards(pc.text, pc.title || 'Карточки').then(function (r) {
|
||||
var cards = (r && r.cards) || [];
|
||||
if (!cards.length) throw new Error('empty');
|
||||
@@ -514,9 +531,9 @@
|
||||
}, Promise.resolve()).then(function () { return cards.length; });
|
||||
});
|
||||
}).then(function (n) {
|
||||
box.innerHTML = '<div class="asst-ans">Готово: создано ' + n + ' ' + plural(n, 'карточка', 'карточки', 'карточек') +
|
||||
note.innerHTML = '<div class="asst-rich">Готово: создано ' + n + ' ' + plural(n, 'карточка', 'карточки', 'карточек') +
|
||||
'. <a class="asst-ans-link" href="/flashcards">Открыть флешкарты</a></div>';
|
||||
}).catch(function () { box.innerHTML = '<div class="asst-empty">Не удалось сделать карточки. Попробуй позже.</div>'; });
|
||||
}).catch(function () { note.innerHTML = '<div class="asst-rich">Не удалось сделать карточки. Попробуй позже.</div>'; });
|
||||
}
|
||||
|
||||
/* ── Ф2: онбординг-тур по разделам ───────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user