diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js
index ce7018e..d660630 100644
--- a/backend/src/controllers/assistantController.js
+++ b/backend/src/controllers/assistantController.js
@@ -230,25 +230,18 @@ 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);
-async function askModel(q, hits) {
- if (typeof fetch !== 'function') return null;
- const ctx = hits.map((h, i) => `${i + 1}. ${h.q}\n${h.a}${h.url ? ` (раздел: ${h.url})` : ''}`).join('\n') || '(пусто)';
- const sys = 'Ты — Квантик, дружелюбный помощник учебной платформы LearnSpace. ' +
- 'Отвечай по-русски, кратко и понятно, на «ты», как для школьника. ' +
- 'Если вопрос о работе платформы — опирайся на справку ниже и не выдумывай разделы/кнопки, которых в ней нет ' +
- '(если не знаешь — предложи поиск Ctrl+K). ' +
- 'Если это учебный или общий вопрос (математика, физика, объяснить понятие, решить пример) — отвечай по существу и помоги разобраться. ' +
- 'Не используй эмодзи.';
+const LLM_ON = !!(LLM_KEY || LLM_LOCAL);
+
+/* Низкоуровневый вызов OpenAI-совместимого chat/completions. */
+async function callLLM(messages, maxTokens) {
+ if (typeof fetch !== 'function' || !LLM_ON) return null;
const ctrl = new AbortController();
- const timer = setTimeout(() => ctrl.abort(), 12000);
+ const timer = setTimeout(() => ctrl.abort(), 15000);
try {
const r = await fetch(LLM_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: 320,
- messages: [{ role: 'system', content: sys }, { role: 'user', content: `Справка:\n${ctx}\n\nВопрос: ${q}` }],
- }),
+ body: JSON.stringify({ model: LLM_MODEL, temperature: 0.3, max_tokens: maxTokens || 320, messages }),
signal: ctrl.signal,
});
if (!r.ok) return null;
@@ -258,15 +251,30 @@ async function askModel(q, hits) {
} catch (e) { return null; } finally { clearTimeout(timer); }
}
-/* ── POST /api/assistant/ask { q } ── «Спроси Квантика» ───────────────────
- * Грунтуем ответ топ-FAQ. Если LLM настроена — даём её ответ (source:'model'),
- * иначе отдаём найденные FAQ (source:'faq'). Поиск по платформе фронт делает сам. */
+const ASSISTANT_SYS = 'Ты — Квантик, дружелюбный помощник учебной платформы LearnSpace. ' +
+ 'Отвечай по-русски, кратко и понятно, на «ты», как для школьника. ' +
+ 'Если вопрос о работе платформы — опирайся на справку ниже и не выдумывай разделы/кнопки, которых в ней нет ' +
+ '(если не знаешь — предложи поиск Ctrl+K). ' +
+ 'Если это учебный или общий вопрос (математика, физика, объяснить понятие, решить пример) — отвечай по существу и помоги разобраться. ' +
+ 'Формулы и математику оформляй в LaTeX между знаками доллара, например $a^2+b^2=c^2$. Не используй эмодзи.';
+
+async function askModel(q, hits, context) {
+ 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);
+}
+
+/* ── POST /api/assistant/ask { q, context? } ── «Спроси Квантика» ─────────
+ * Грунтуем ответ топ-FAQ (+ опц. контекстом страницы/выделенного). Если LLM
+ * настроена — даём её ответ (source:'model'), иначе FAQ (source:'faq'). */
async function ask(req, res) {
- const q = String((req.body && req.body.q) || '').trim().slice(0, 300);
+ 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);
const hits = searchFaq(q, 3);
let answer = null;
- if (LLM_KEY || LLM_LOCAL) { try { answer = await askModel(q, hits); } catch (e) { answer = null; } }
+ if (LLM_ON) { try { answer = await askModel(q, hits, context); } catch (e) { answer = null; } }
res.json({
source: answer ? 'model' : 'faq',
answer: answer || null,
@@ -274,4 +282,38 @@ async function ask(req, res) {
});
}
-module.exports = { getContext, markSeen, dismiss, setSettings, ask };
+/* ── POST /api/assistant/flashcards { text, title? } ─────────────────────
+ * Генерирует учебные карточки из текста (модель → JSON). Карточки фронт
+ * создаёт сам через существующий API флешкарт. */
+async function flashcardsFromText(req, res) {
+ if (!LLM_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: 'Слишком мало текста' });
+ 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);
+ let cards = [];
+ if (raw) {
+ let s = raw.replace(/```(?:json)?/gi, '').trim();
+ const a = s.indexOf('[');
+ if (a >= 0) {
+ const b = s.lastIndexOf(']');
+ if (b > a) s = s.slice(a, b + 1);
+ else { const last = s.lastIndexOf('}'); s = last > a ? s.slice(a, last + 1) + ']' : ''; } // починка обрезанного JSON
+ }
+ try {
+ const arr = JSON.parse(s);
+ if (Array.isArray(arr)) {
+ cards = arr.filter(c => c && c.front && c.back)
+ .slice(0, 8)
+ .map(c => ({ front: String(c.front).slice(0, 500), back: String(c.back).slice(0, 1000) }));
+ }
+ } catch (e) { /* модель вернула не-JSON */ }
+ }
+ if (!cards.length) return res.status(502).json({ error: 'Не удалось сгенерировать карточки' });
+ res.json({ title, cards });
+}
+
+module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText };
diff --git a/backend/src/routes/assistant.js b/backend/src/routes/assistant.js
index 88b3c12..302a437 100644
--- a/backend/src/routes/assistant.js
+++ b/backend/src/routes/assistant.js
@@ -12,5 +12,6 @@ router.post('/seen', ctrl.markSeen);
router.post('/dismiss', ctrl.dismiss);
router.patch('/settings', ctrl.setSettings);
router.post('/ask', ctrl.ask);
+router.post('/flashcards', ctrl.flashcardsFromText);
module.exports = router;
diff --git a/frontend/js/assistant.js b/frontend/js/assistant.js
index 63930a0..93becd3 100644
--- a/frontend/js/assistant.js
+++ b/frontend/js/assistant.js
@@ -296,6 +296,13 @@
'.asst-chips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px;}',
'.asst-chip{border:1px solid #e2e8f0;background:#f8fafc;border-radius:99px;padding:5px 10px;font:600 .72rem Manrope,sans-serif;color:#475569;cursor:pointer;text-align:left;}',
'.asst-chip:hover{border-color:#9B5DE5;color:#9B5DE5;}',
+ '.asst-chip-ctx{background:rgba(155,93,229,.1);border-color:rgba(155,93,229,.35);color:#7e3eca;}',
+ '.asst-rich{font-size:.84rem;line-height:1.55;color:#28324a;}',
+ '.asst-rich>div{margin:3px 0;}',
+ '.asst-rich ul,.asst-rich ol{margin:4px 0 4px 18px;padding:0;}',
+ '.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-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;}}',
@@ -353,68 +360,165 @@
bubble.querySelector('[data-a="tour"]').onclick = function () { startTour(); };
}
+ /* ── рендер markdown + KaTeX в ответах модели ────────────────────────── */
+ var _katexP = null;
+ function ensureKatex() {
+ if (window.renderMathInElement) return Promise.resolve();
+ if (_katexP) return _katexP;
+ _katexP = new Promise(function (resolve) {
+ var base = 'https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/';
+ var css = document.createElement('link'); css.rel = 'stylesheet'; css.href = base + 'katex.min.css'; document.head.appendChild(css);
+ var s1 = document.createElement('script'); s1.src = base + 'katex.min.js';
+ s1.onload = function () {
+ var s2 = document.createElement('script'); s2.src = base + 'contrib/auto-render.min.js';
+ s2.onload = function () { resolve(); }; s2.onerror = function () { resolve(); };
+ document.head.appendChild(s2);
+ };
+ s1.onerror = function () { resolve(); };
+ document.head.appendChild(s1);
+ });
+ return _katexP;
+ }
+ function mdInline(s) {
+ return s.replace(/\*\*([^*]+)\*\*/g, '$1')
+ .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2')
+ .replace(/`([^`]+)`/g, '$1');
+ }
+ function mdToHtml(src) {
+ var lines = esc(src).split(/\r?\n/), html = '', list = null;
+ function closeList() { if (list) { html += '' + list + '>'; list = null; } }
+ for (var i = 0; i < lines.length; i++) {
+ var ln = lines[i];
+ var mUl = ln.match(/^\s*[-*]\s+(.*)$/), mOl = ln.match(/^\s*\d+\.\s+(.*)$/), mH = ln.match(/^\s*#{1,6}\s+(.*)$/);
+ if (mUl) { if (list !== 'ul') { closeList(); html += '
'; list = 'ul'; } html += '- ' + mdInline(mUl[1]) + '
'; continue; }
+ if (mOl) { if (list !== 'ol') { closeList(); html += ''; list = 'ol'; } html += '- ' + mdInline(mOl[1]) + '
'; continue; }
+ closeList();
+ if (mH) { html += '' + mdInline(mH[1]) + '
'; continue; }
+ if (ln.trim() !== '') html += '' + mdInline(ln) + '
';
+ }
+ closeList();
+ return html;
+ }
+ function renderRich(container, text) {
+ var math = [];
+ var protectedText = String(text || '').replace(/(\$\$[\s\S]+?\$\$|\$[^\n$]+?\$)/g, function (m) { math.push(m); return '@@M' + (math.length - 1) + '@@'; });
+ var html = mdToHtml(protectedText).replace(/@@M(\d+)@@/g, function (_, i) { return esc(math[+i] || ''); });
+ container.innerHTML = html;
+ ensureKatex().then(function () {
+ try {
+ if (window.renderMathInElement) renderMathInElement(container, {
+ delimiters: [{ left: '$$', right: '$$', display: true }, { left: '$', right: '$', display: false }],
+ throwOnError: false,
+ });
+ } catch (e) {}
+ });
+ }
+
+ /* ── контекст: выделенный текст / текущий параграф ───────────────────── */
+ var _lastSel = '';
+ function getPageContext() {
+ try {
+ if (PAGE === 'textbook') {
+ var sec = document.querySelector('.sec.active') || document.querySelector('.sec');
+ if (sec) {
+ var h = sec.querySelector('.sec-h');
+ var title = (h && h.textContent.trim()) || (document.title || 'Параграф').split('·')[0].trim();
+ var text = (sec.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 3500);
+ if (text.length > 40) return { title: title, text: text };
+ }
+ }
+ } catch (e) {}
+ return null;
+ }
+
/* ── «Спроси Квантика» ───────────────────────────────────────────────── */
- var SUGGESTIONS = [
- 'Как вырезать кусок учебника?',
- 'Как создать карточки?',
- 'Как начать тест?',
- 'Как сохранить доску себе?',
- 'Где мои домашние задания?',
- 'Как включить тёмную тему?',
- ];
- function openAsk() {
- var chips = '' +
- SUGGESTIONS.map(function (q) { return ''; }).join('') +
- '
';
+ var SUGGESTIONS = ['Как вырезать кусок учебника?', 'Как создать карточки?', 'Как начать тест?', 'Объясни теорему Пифагора', 'Где мои домашние задания?', 'Как включить тёмную тему?'];
+ function openAsk(prefill) {
+ var sel = _lastSel, pc = getPageContext();
+ var ctxBtns = '';
+ if (sel) ctxBtns += '';
+ if (pc) ctxBtns += '' +
+ '' +
+ '';
+ var chips = '' + ctxBtns +
+ SUGGESTIONS.map(function (q) { return ''; }).join('') + '
';
openBubble(
'Спроси Квантика
' +
- '' +
- chips +
- '', {});
+ '' +
+ chips + '', {});
var inp = bubble.querySelector('.asst-ask-in');
var box = bubble.querySelector('.asst-ans-box');
- inp.focus();
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); } });
bubble.querySelectorAll('.asst-chip').forEach(function (c) {
- c.addEventListener('click', function () { inp.value = c.textContent; runAsk(c.textContent, box); inp.focus(); });
+ 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 (prefill) { inp.value = prefill.q || ''; runAsk(prefill.q, box, prefill.context); }
+ else inp.focus();
}
- function runAsk(q, box) {
+ function runAsk(q, box, context) {
q = (q || '').trim();
if (q.length < 3) { box.innerHTML = ''; return; }
- box.innerHTML = 'Ищу…
';
+ box.innerHTML = 'Думаю…
';
Promise.all([
- LS.assistantAsk(q).catch(function () { return { answers: [] }; }),
+ LS.assistantAsk(q, context).catch(function () { return { answers: [] }; }),
(LS.globalSearch ? LS.globalSearch(q, 'all', 4) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }),
]).then(function (res) {
var modelAnswer = res[0] && res[0].answer;
var ans = (res[0] && res[0].answers) || [];
var found = (res[1] && res[1].results) || [];
- var html = '';
+ box.innerHTML = '';
if (modelAnswer) {
- html += 'Квантик
' +
- '
' + esc(modelAnswer) + '
';
- if (ans.length) html += 'Из справки
';
- }
- if (ans.length) {
- html += ans.map(function (a) {
- return '' + esc(a.q) + '
' + esc(a.a) +
- (a.url ? '
Открыть' : '') + '
';
- }).join('');
+ 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) {
- html += 'На платформе
';
- html += found.slice(0, 4).map(function (f) {
+ rest += 'На платформе
';
+ rest += found.slice(0, 4).map(function (f) {
return '';
}).join('');
}
- box.innerHTML = html || 'Ничего не нашёл. Попробуй переформулировать.
';
+ if (rest) box.insertAdjacentHTML('beforeend', rest);
+ if (!box.innerHTML) box.innerHTML = 'Ничего не нашёл. Попробуй переформулировать.
';
}).catch(function () { box.innerHTML = 'Не удалось получить ответ.
'; });
}
+ /* ── «Флешкарты из параграфа» — модель → колода через API флешкарт ─────── */
+ function makeFlashcards(pc, box) {
+ if (!pc || !pc.text) { box.innerHTML = 'Открой параграф учебника, чтобы сделать карточки.
'; return; }
+ box.innerHTML = 'Готовлю карточки…
';
+ LS.assistantFlashcards(pc.text, pc.title || 'Карточки').then(function (r) {
+ var cards = (r && r.cards) || [];
+ if (!cards.length) throw new Error('empty');
+ return LS.fcCreateDeck({ title: (r.title || pc.title || 'Карточки').slice(0, 80) }).then(function (d) {
+ var deckId = d && d.id;
+ return cards.reduce(function (p, c) {
+ return p.then(function () { return LS.fcAddCard(deckId, { front: c.front, back: c.back }).catch(function () {}); });
+ }, Promise.resolve()).then(function () { return cards.length; });
+ });
+ }).then(function (n) {
+ box.innerHTML = 'Готово: создано ' + n + ' ' + plural(n, 'карточка', 'карточки', 'карточек') +
+ '.
Открыть флешкарты ';
+ }).catch(function () { box.innerHTML = 'Не удалось сделать карточки. Попробуй позже.
'; });
+ }
+
/* ── Ф2: онбординг-тур по разделам ───────────────────────────────────── */
var TOUR = [
{ sel: '#app-sidebar a[href="/dashboard"]', title: 'Дашборд', text: 'Главная: твой прогресс, активность и питомец.' },
@@ -535,6 +639,16 @@
else showGreet();
};
+ // Запоминаем выделенный пользователем текст (для «Объяснить выделенное»)
+ document.addEventListener('mouseup', function () {
+ try {
+ var s = (window.getSelection && window.getSelection().toString() || '').trim();
+ if (s.length >= 8 && !(root && window.getSelection().anchorNode && root.contains(window.getSelection().anchorNode))) {
+ _lastSel = s.slice(0, 3000);
+ }
+ } catch (e) {}
+ });
+
// Онбординг новичка — приоритетно на дашборде, пока не пройден/не закрыт
var ob = (SRV.seen && SRV.seen['onboarding']) || {};
if (PAGE === 'dashboard' && !ob.dismissed && (ob.count || 0) < 3) {
@@ -580,5 +694,8 @@
window.Assistant = {
open: function () { if (root) root.querySelector('.asst-fab').click(); },
tour: function () { startTour(); },
+ // открыть «Спроси» с готовым вопросом и контекстом (для интеграций, напр. экзамен)
+ ask: function (q, context) { if (root && bubble) openAsk({ q: q, context: context }); },
+ explainSelection: function () { if (_lastSel) window.Assistant.ask('Объясни простыми словами и приведи пример.', _lastSel); },
};
})();
diff --git a/frontend/js/exam-prep/task-card.js b/frontend/js/exam-prep/task-card.js
index 7555d5b..36b5739 100644
--- a/frontend/js/exam-prep/task-card.js
+++ b/frontend/js/exam-prep/task-card.js
@@ -133,9 +133,9 @@
? `` : '';
const solPanelHtml = (showSol && task.solution)
? `${task.solution}
` : '';
- const solBlock = (solToggle || refLink || saveMatBtn)
- ? `${solToggle}${refLink}${saveMatBtn}
${solPanelHtml}
`
- : '';
+ const askBtn = ``;
+ const solBlock = `${solToggle}${refLink}${saveMatBtn}${askBtn}
${solPanelHtml}
`;
card.innerHTML = `
@@ -169,6 +169,18 @@
});
}
+ // ── Спросить Квантика по этой задаче (репетитор)
+ const askEl = card.querySelector('[data-tc-ask]');
+ if (askEl) {
+ askEl.addEventListener('click', () => {
+ if (!window.Assistant || !window.Assistant.ask) { if (window.LS && LS.toast) LS.toast('Помощник недоступен на этой странице', 'warn'); return; }
+ const parts = ['Задание: ' + stripHtml(task.text)];
+ if (task.answer) parts.push('Правильный ответ: ' + task.answer);
+ if (task.solution) parts.push('Решение: ' + stripHtml(task.solution));
+ window.Assistant.ask('Объясни решение этой задачи по шагам, понятным языком. Если решение дано — опирайся на него, но изложи понятно.', parts.join('\n'));
+ });
+ }
+
// ── State
let startedAt = Date.now();
let solutionLogged = false;
diff --git a/js/api.js b/js/api.js
index 8429796..bf6bf0b 100644
--- a/js/api.js
+++ b/js/api.js
@@ -1050,7 +1050,7 @@ window.LS = {
crAdminGetAllHistory, crAdminGetTeachersList,
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
- assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk,
+ assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards,
fcListDecks, fcCreateDeck, fcAddCard,
escapeHtml, esc,
parseDate, fmtRelTime, safeHref,
@@ -1272,7 +1272,8 @@ 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) { return req('POST', '/assistant/ask', { q }); }
+async function assistantAsk(q, context) { return req('POST', '/assistant/ask', { q, context: context || undefined }); }
+async function assistantFlashcards(text, title) { return req('POST', '/assistant/flashcards', { text, title }); }
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); }