diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js
index f94f6c4..f3539ec 100644
--- a/backend/src/controllers/adminController.js
+++ b/backend/src/controllers/adminController.js
@@ -877,6 +877,51 @@ function broadcast(req, res) {
res.json({ ok: true, sent: users.length });
}
+/* ── Ассистент «Квантик»: конфиг LLM из админки ──────────────────────── */
+const ASSISTANT_PRESETS = [
+ { name: 'Google Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', model: 'gemini-2.5-flash' },
+ { name: 'Groq', url: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.3-70b-versatile' },
+ { name: 'OpenRouter', url: 'https://openrouter.ai/api/v1/chat/completions', model: 'meta-llama/llama-3.3-70b-instruct:free' },
+ { name: 'Ollama (локально)', url: 'http://localhost:11434/v1/chat/completions', model: 'qwen2.5:3b' },
+];
+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 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);
+ res.json({ url, model, hasKey, keyFromEnv: !dbKey && !!process.env.ASSISTANT_LLM_KEY, active: !!(hasKey || local), presets: ASSISTANT_PRESETS });
+}
+
+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 (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 ? 'ключ очищен' : 'обновлено');
+ res.json({ ok: true });
+}
+
+async function testAssistant(req, res) {
+ const a = require('./assistantController');
+ const cfg = a.llmConfig();
+ const b = req.body || {};
+ const 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.on = !!(override.key || override.local);
+ const r = await a.pingLLM(override);
+ res.json(r);
+}
+
module.exports = {
getStats, getOverview, globalSearch,
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
@@ -886,4 +931,5 @@ module.exports = {
getSecurityLog, clearSecurityLog,
getTopics, createTopic, updateTopic, deleteTopic,
broadcast,
+ getAssistant, saveAssistant, testAssistant,
};
diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js
index d660630..a529a9e 100644
--- a/backend/src/controllers/assistantController.js
+++ b/backend/src/controllers/assistantController.js
@@ -225,23 +225,28 @@ function searchFaq(q, n) {
* ASSISTANT_LLM_KEY (Bearer-ключ; для localhost/Ollama не нужен)
* ASSISTANT_LLM_MODEL (по умолч. llama-3.3-70b-versatile)
* Если ключ не задан и URL не локальный — тихо работаем как раньше (FAQ). */
-const LLM_URL = process.env.ASSISTANT_LLM_URL || 'https://api.groq.com/openai/v1/chat/completions';
-const LLM_KEY = process.env.ASSISTANT_LLM_KEY || '';
-const LLM_MODEL = process.env.ASSISTANT_LLM_MODEL || 'llama-3.3-70b-versatile';
-const LLM_LOCAL = /\/\/(localhost|127\.0\.0\.1)/.test(LLM_URL);
-
-const LLM_ON = !!(LLM_KEY || LLM_LOCAL);
+/* Конфиг берём из 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() {
+ 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) };
+}
/* Низкоуровневый вызов OpenAI-совместимого chat/completions. */
-async function callLLM(messages, maxTokens) {
- if (typeof fetch !== 'function' || !LLM_ON) return null;
+async function callLLM(messages, maxTokens, override) {
+ const cfg = override || llmConfig();
+ if (typeof fetch !== 'function' || !cfg.on) return null;
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15000);
try {
- const r = await fetch(LLM_URL, {
+ const r = await fetch(cfg.url, {
method: 'POST',
- headers: Object.assign({ 'Content-Type': 'application/json' }, LLM_KEY ? { Authorization: `Bearer ${LLM_KEY}` } : {}),
- body: JSON.stringify({ model: LLM_MODEL, temperature: 0.3, max_tokens: maxTokens || 320, messages }),
+ headers: Object.assign({ 'Content-Type': 'application/json' }, cfg.key ? { Authorization: `Bearer ${cfg.key}` } : {}),
+ body: JSON.stringify({ model: cfg.model, temperature: 0.3, max_tokens: maxTokens || 320, messages }),
signal: ctrl.signal,
});
if (!r.ok) return null;
@@ -251,6 +256,33 @@ async function callLLM(messages, maxTokens) {
} catch (e) { return null; } finally { clearTimeout(timer); }
}
+/* Тест-пинг для админки: подробный статус (status/ошибка/пример ответа). */
+async function pingLLM(override) {
+ const cfg = override || llmConfig();
+ if (!cfg.url) return { ok: false, error: 'URL не задан' };
+ if (!cfg.key && !/\/\/(localhost|127\.0\.0\.1)/.test(cfg.url)) return { ok: false, error: 'Ключ не задан' };
+ if (typeof fetch !== 'function') return { ok: false, error: 'fetch недоступен' };
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), 15000);
+ try {
+ const r = await fetch(cfg.url, {
+ method: 'POST',
+ headers: Object.assign({ 'Content-Type': 'application/json' }, cfg.key ? { Authorization: `Bearer ${cfg.key}` } : {}),
+ body: JSON.stringify({ model: cfg.model, max_tokens: 16, messages: [{ role: 'user', content: 'Ответь одним словом: привет' }] }),
+ signal: ctrl.signal,
+ });
+ const txt = await r.text();
+ if (!r.ok) {
+ let msg = txt.slice(0, 300);
+ try { const j = JSON.parse(txt); if (j && j.error) msg = String(j.error.message || JSON.stringify(j.error)).slice(0, 300); } catch (e) {}
+ return { ok: false, status: r.status, error: msg };
+ }
+ let sample = '';
+ try { const j = JSON.parse(txt); sample = String((j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || '').slice(0, 120); } catch (e) {}
+ return { ok: true, status: r.status, sample, model: cfg.model };
+ } catch (e) { return { ok: false, error: e.name === 'AbortError' ? 'Таймаут (15с)' : (e.message || 'Ошибка сети') }; } finally { clearTimeout(timer); }
+}
+
const ASSISTANT_SYS = 'Ты — Квантик, дружелюбный помощник учебной платформы LearnSpace. ' +
'Отвечай по-русски, кратко и понятно, на «ты», как для школьника. ' +
'Если вопрос о работе платформы — опирайся на справку ниже и не выдумывай разделы/кнопки, которых в ней нет ' +
@@ -258,23 +290,28 @@ const ASSISTANT_SYS = 'Ты — Квантик, дружелюбный помо
'Если это учебный или общий вопрос (математика, физика, объяснить понятие, решить пример) — отвечай по существу и помоги разобраться. ' +
'Формулы и математику оформляй в LaTeX между знаками доллара, например $a^2+b^2=c^2$. Не используй эмодзи.';
-async function askModel(q, hits, context) {
+async function askModel(q, hits, context, history) {
const ref = hits.map((h, i) => `${i + 1}. ${h.q}\n${h.a}${h.url ? ` (раздел: ${h.url})` : ''}`).join('\n') || '(пусто)';
const user = (context ? `Контекст со страницы (на него опирайся, если вопрос про него):\n${context}\n\n` : '') +
`Справка по платформе:\n${ref}\n\nВопрос: ${q}`;
- return callLLM([{ role: 'system', content: ASSISTANT_SYS }, { role: 'user', content: user }], 380);
+ const msgs = [{ role: 'system', content: ASSISTANT_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);
}
-/* ── POST /api/assistant/ask { q, context? } ── «Спроси Квантика» ─────────
- * Грунтуем ответ топ-FAQ (+ опц. контекстом страницы/выделенного). Если LLM
- * настроена — даём её ответ (source:'model'), иначе FAQ (source:'faq'). */
+/* ── POST /api/assistant/ask { q, context?, history? } ── «Спроси Квантика» ─
+ * Грунтуем ответ топ-FAQ (+ опц. контекст страницы + история диалога). Если
+ * LLM настроена — её ответ (source:'model'), иначе FAQ (source:'faq'). */
async function ask(req, res) {
const q = String((req.body && req.body.q) || '').trim().slice(0, 500);
if (!q || q.length < 2) return res.json({ source: 'faq', answer: null, answers: [] });
const context = String((req.body && req.body.context) || '').slice(0, 4000);
+ let history = (req.body && req.body.history);
+ history = Array.isArray(history) ? history.slice(-6) : [];
const hits = searchFaq(q, 3);
let answer = null;
- if (LLM_ON) { try { answer = await askModel(q, hits, context); } catch (e) { answer = null; } }
+ if (llmConfig().on) { try { answer = await askModel(q, hits, context, history); } catch (e) { answer = null; } }
res.json({
source: answer ? 'model' : 'faq',
answer: answer || null,
@@ -286,7 +323,7 @@ async function ask(req, res) {
* Генерирует учебные карточки из текста (модель → JSON). Карточки фронт
* создаёт сам через существующий API флешкарт. */
async function flashcardsFromText(req, res) {
- if (!LLM_ON) return res.status(503).json({ error: 'LLM не настроена' });
+ if (!llmConfig().on) 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: 'Слишком мало текста' });
@@ -316,4 +353,4 @@ async function flashcardsFromText(req, res) {
res.json({ title, cards });
}
-module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText };
+module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText, llmConfig, pingLLM };
diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js
index 4fd525f..27b597e 100644
--- a/backend/src/routes/admin.js
+++ b/backend/src/routes/admin.js
@@ -13,6 +13,9 @@ router.patch('/free-student-features', requireRole('admin'), ctrl.updateF
/* Everything below is admin-only */
router.use(requireRole('admin'));
+router.get('/assistant', ctrl.getAssistant);
+router.put('/assistant', ctrl.saveAssistant);
+router.post('/assistant/test', ctrl.testAssistant);
router.get('/stats', ctrl.getStats);
router.get('/overview', ctrl.getOverview);
router.get('/search', ctrl.globalSearch);
diff --git a/frontend/js/admin/sections/games.js b/frontend/js/admin/sections/games.js
index 8b403e1..568094f 100644
--- a/frontend/js/admin/sections/games.js
+++ b/frontend/js/admin/sections/games.js
@@ -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 =
+ '
Помощник «Квантик» — модель (ИИ)
' +
+ 'Подключение LLM к «Спроси Квантика». OpenAI-совместимый эндпоинт. Без ключа — режим обычного FAQ.
' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '
' +
+ '';
+ 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
+ ? '● Подключено — «Спроси» отвечает через ИИ'
+ : '○ Ключ не задан — работает обычный FAQ-режим';
+ }
+ 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
+ ? '✓ Работает (' + (r.model || '') + '): ' + (r.sample || 'ответ получен').replace(/'
+ : '✗ ' + ((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').toString().slice(0, 200).replace(/';
+ } catch (e) { res.innerHTML = '✗ ' + (e.message || 'ошибка') + ''; }
+ });
+ 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 = '';
diff --git a/frontend/js/assistant.js b/frontend/js/assistant.js
index 93becd3..16a14b2 100644
--- a/frontend/js/assistant.js
+++ b/frontend/js/assistant.js
@@ -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 = ''; 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 = '' + ctxBtns +
SUGGESTIONS.map(function (q) { return ''; }).join('') + '
';
openBubble(
- 'Спроси Квантика
' +
- '' +
- chips + '', {});
+ 'Спроси Квантика' + (_chat.length ? '' : '') + '
' +
+ '' + chips +
+ '', {});
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 = 'Думаю…
';
+ 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 = 'Квантик
';
- box.appendChild(a);
- renderRich(a.querySelector('.asst-rich'), modelAnswer);
- if (ans.length) box.insertAdjacentHTML('beforeend', 'Из справки
');
- }
- var rest = '';
- if (ans.length) rest += ans.map(function (a2) {
- return '' + esc(a2.q) + '
' + esc(a2.a) +
- (a2.url ? '
Открыть' : '') + '
';
- }).join('');
- if (found.length) {
- rest += 'На платформе
';
- rest += found.slice(0, 4).map(function (f) {
- return '';
- }).join('');
- }
- if (rest) box.insertAdjacentHTML('beforeend', rest);
- if (!box.innerHTML) box.innerHTML = 'Ничего не нашёл. Попробуй переформулировать.
';
- }).catch(function () { box.innerHTML = 'Не удалось получить ответ.
'; });
+ 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 = ''; 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 '' + esc(a.q) + ''; }).join(' · ');
+ if (found.length) links += (links ? '
' : '') + 'На платформе: ' + found.slice(0, 3).map(function (f) { return '' + esc(f.title || '…') + ''; }).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 = 'Открой параграф учебника, чтобы сделать карточки.
'; return; }
- box.innerHTML = 'Готовлю карточки…
';
+ function makeFlashcards(pc, chatEl) {
+ var note = msgEl('assistant');
+ if (!pc || !pc.text) { note.innerHTML = 'Открой параграф учебника, чтобы сделать карточки.
'; chatEl.appendChild(note); return; }
+ note.innerHTML = 'Готовлю карточки…
'; 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 = 'Готово: создано ' + n + ' ' + plural(n, 'карточка', 'карточки', 'карточек') +
+ note.innerHTML = '
Готово: создано ' + n + ' ' + plural(n, 'карточка', 'карточки', 'карточек') +
'.
Открыть флешкарты';
- }).catch(function () { box.innerHTML = '
Не удалось сделать карточки. Попробуй позже.
'; });
+ }).catch(function () { note.innerHTML = '
Не удалось сделать карточки. Попробуй позже.
'; });
}
/* ── Ф2: онбординг-тур по разделам ───────────────────────────────────── */
diff --git a/js/api.js b/js/api.js
index bf6bf0b..94afa68 100644
--- a/js/api.js
+++ b/js/api.js
@@ -1051,6 +1051,7 @@ window.LS = {
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards,
+ adminGetAssistant, adminSaveAssistant, adminTestAssistant,
fcListDecks, fcCreateDeck, fcAddCard,
escapeHtml, esc,
parseDate, fmtRelTime, safeHref,
@@ -1272,8 +1273,11 @@ async function assistantContext() { return req('GET', '/assistant/context'
async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); }
async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); }
async function assistantSettings(d) { return req('PATCH', '/assistant/settings', d); }
-async function assistantAsk(q, context) { return req('POST', '/assistant/ask', { q, context: context || undefined }); }
+async function assistantAsk(q, context, history) { return req('POST', '/assistant/ask', { q, context: context || undefined, history: history || undefined }); }
async function assistantFlashcards(text, title) { return req('POST', '/assistant/flashcards', { text, title }); }
+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 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); }