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
+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;