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:
+150
-33
@@ -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); },
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user