feat(assistant): markdown+KaTeX, «Объясни это», репетитор на экзамене, флешкарты

- Ответы модели рендерятся как markdown + формулы KaTeX (ленивая загрузка),
  модель просим оформлять формулы в LaTeX $...$.
- «Объясни это»: ask принимает context; кнопки «Объяснить выделенное» (запоминаем
  выделение) и «Объяснить/Конспект параграфа» на учебнике.
- Репетитор на экзамене: кнопка «Спросить Квантика» на карточке задания →
  Assistant.ask с условием/ответом/решением как контекстом.
- Быстрые действия: «Флешкарты из параграфа» → POST /api/assistant/flashcards
  (модель → JSON, починка обрезанного) → колода через существующий API флешкарт.
- Экспорт Assistant.ask(q,context) / explainSelection().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 17:53:45 +03:00
parent 638b684f77
commit 479c621e2e
5 changed files with 231 additions and 58 deletions
+62 -20
View File
@@ -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 };
+1
View File
@@ -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;
+150 -33
View File
@@ -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, '<strong>$1</strong>')
.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
}
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 += '<ul>'; list = 'ul'; } html += '<li>' + mdInline(mUl[1]) + '</li>'; continue; }
if (mOl) { if (list !== 'ol') { closeList(); html += '<ol>'; list = 'ol'; } html += '<li>' + mdInline(mOl[1]) + '</li>'; continue; }
closeList();
if (mH) { html += '<div class="asst-md-h">' + mdInline(mH[1]) + '</div>'; continue; }
if (ln.trim() !== '') html += '<div>' + mdInline(ln) + '</div>';
}
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 = '<div class="asst-chips">' +
SUGGESTIONS.map(function (q) { return '<button class="asst-chip" type="button">' + esc(q) + '</button>'; }).join('') +
'</div>';
var SUGGESTIONS = ['Как вырезать кусок учебника?', 'Как создать карточки?', 'Как начать тест?', 'Объясни теорему Пифагора', 'Где мои домашние задания?', 'Как включить тёмную тему?'];
function openAsk(prefill) {
var sel = _lastSel, pc = getPageContext();
var ctxBtns = '';
if (sel) ctxBtns += '<button class="asst-chip asst-chip-ctx" data-ctx="sel" type="button">Объяснить выделенное</button>';
if (pc) ctxBtns += '<button class="asst-chip asst-chip-ctx" data-ctx="sec" type="button">Объяснить этот параграф</button>' +
'<button class="asst-chip asst-chip-ctx" data-ctx="sum" type="button">Конспект параграфа</button>' +
'<button class="asst-chip asst-chip-ctx" data-ctx="cards" type="button">Флешкарты из параграфа</button>';
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="200" />' +
chips +
'<div class="asst-ans-box"></div>', {});
'<input class="asst-ask-in" type="text" placeholder="Спроси что угодно: «объясни…», «как…»" maxlength="300" />' +
chips + '<div class="asst-ans-box"></div>', {});
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 = '<div class="asst-empty">Ищу…</div>';
box.innerHTML = '<div class="asst-empty">Думаю…</div>';
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 += '<div class="asst-ans"><div class="asst-ans-q">Квантик</div>' +
'<div style="white-space:pre-line">' + esc(modelAnswer) + '</div></div>';
if (ans.length) html += '<div class="asst-ans-sec">Из справки</div>';
}
if (ans.length) {
html += ans.map(function (a) {
return '<div class="asst-ans"><div class="asst-ans-q">' + esc(a.q) + '</div>' + esc(a.a) +
(a.url ? '<br><a class="asst-ans-link" href="' + esc(a.url) + '">Открыть</a>' : '') + '</div>';
}).join('');
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) {
html += '<div class="asst-ans-sec">На платформе</div>';
html += found.slice(0, 4).map(function (f) {
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('');
}
box.innerHTML = html || '<div class="asst-empty">Ничего не нашёл. Попробуй переформулировать.</div>';
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>'; });
}
/* ── «Флешкарты из параграфа» — модель → колода через API флешкарт ─────── */
function makeFlashcards(pc, box) {
if (!pc || !pc.text) { box.innerHTML = '<div class="asst-empty">Открой параграф учебника, чтобы сделать карточки.</div>'; return; }
box.innerHTML = '<div class="asst-empty">Готовлю карточки…</div>';
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 = '<div class="asst-ans">Готово: создано ' + n + ' ' + plural(n, 'карточка', 'карточки', 'карточек') +
'. <a class="asst-ans-link" href="/flashcards">Открыть флешкарты</a></div>';
}).catch(function () { box.innerHTML = '<div class="asst-empty">Не удалось сделать карточки. Попробуй позже.</div>'; });
}
/* ── Ф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); },
};
})();
+15 -3
View File
@@ -133,9 +133,9 @@
? `<button class="tc-sol-btn" data-tc-sol>${ICONS.chev}<span>Показать решение</span></button>` : '';
const solPanelHtml = (showSol && task.solution)
? `<div class="tc-sol-panel" data-tc-sol-panel>${task.solution}</div>` : '';
const solBlock = (solToggle || refLink || saveMatBtn)
? `<div class="tc-sol-wrap"><div class="tc-sol-row">${solToggle}${refLink}${saveMatBtn}</div>${solPanelHtml}</div>`
: '';
const askBtn = `<button class="tc-ask-btn" data-tc-ask title="Спросить Квантика по этой задаче" style="background:none;border:1px solid rgba(155,93,229,.35);border-radius:8px;height:30px;padding:0 10px;cursor:pointer;color:#7e3eca;display:inline-flex;align-items:center;gap:5px;font:inherit;font-size:.78rem;font-weight:600">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 2.5-3 4"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>Спросить Квантика</button>`;
const solBlock = `<div class="tc-sol-wrap"><div class="tc-sol-row">${solToggle}${refLink}${saveMatBtn}${askBtn}</div>${solPanelHtml}</div>`;
card.innerHTML = `
<div class="tc-head">
@@ -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;
+3 -2
View File
@@ -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); }