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:
@@ -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_MODEL = process.env.ASSISTANT_LLM_MODEL || 'llama-3.3-70b-versatile';
|
||||||
const LLM_LOCAL = /\/\/(localhost|127\.0\.0\.1)/.test(LLM_URL);
|
const LLM_LOCAL = /\/\/(localhost|127\.0\.0\.1)/.test(LLM_URL);
|
||||||
|
|
||||||
async function askModel(q, hits) {
|
const LLM_ON = !!(LLM_KEY || LLM_LOCAL);
|
||||||
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') || '(пусто)';
|
/* Низкоуровневый вызов OpenAI-совместимого chat/completions. */
|
||||||
const sys = 'Ты — Квантик, дружелюбный помощник учебной платформы LearnSpace. ' +
|
async function callLLM(messages, maxTokens) {
|
||||||
'Отвечай по-русски, кратко и понятно, на «ты», как для школьника. ' +
|
if (typeof fetch !== 'function' || !LLM_ON) return null;
|
||||||
'Если вопрос о работе платформы — опирайся на справку ниже и не выдумывай разделы/кнопки, которых в ней нет ' +
|
|
||||||
'(если не знаешь — предложи поиск Ctrl+K). ' +
|
|
||||||
'Если это учебный или общий вопрос (математика, физика, объяснить понятие, решить пример) — отвечай по существу и помоги разобраться. ' +
|
|
||||||
'Не используй эмодзи.';
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(), 12000);
|
const timer = setTimeout(() => ctrl.abort(), 15000);
|
||||||
try {
|
try {
|
||||||
const r = await fetch(LLM_URL, {
|
const r = await fetch(LLM_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: Object.assign({ 'Content-Type': 'application/json' }, LLM_KEY ? { Authorization: `Bearer ${LLM_KEY}` } : {}),
|
headers: Object.assign({ 'Content-Type': 'application/json' }, LLM_KEY ? { Authorization: `Bearer ${LLM_KEY}` } : {}),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ model: LLM_MODEL, temperature: 0.3, max_tokens: maxTokens || 320, messages }),
|
||||||
model: LLM_MODEL, temperature: 0.3, max_tokens: 320,
|
|
||||||
messages: [{ role: 'system', content: sys }, { role: 'user', content: `Справка:\n${ctx}\n\nВопрос: ${q}` }],
|
|
||||||
}),
|
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
});
|
});
|
||||||
if (!r.ok) return null;
|
if (!r.ok) return null;
|
||||||
@@ -258,15 +251,30 @@ async function askModel(q, hits) {
|
|||||||
} catch (e) { return null; } finally { clearTimeout(timer); }
|
} catch (e) { return null; } finally { clearTimeout(timer); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── POST /api/assistant/ask { q } ── «Спроси Квантика» ───────────────────
|
const ASSISTANT_SYS = 'Ты — Квантик, дружелюбный помощник учебной платформы LearnSpace. ' +
|
||||||
* Грунтуем ответ топ-FAQ. Если LLM настроена — даём её ответ (source:'model'),
|
'Отвечай по-русски, кратко и понятно, на «ты», как для школьника. ' +
|
||||||
* иначе отдаём найденные FAQ (source:'faq'). Поиск по платформе фронт делает сам. */
|
'Если вопрос о работе платформы — опирайся на справку ниже и не выдумывай разделы/кнопки, которых в ней нет ' +
|
||||||
|
'(если не знаешь — предложи поиск 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) {
|
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: [] });
|
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);
|
const hits = searchFaq(q, 3);
|
||||||
let answer = null;
|
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({
|
res.json({
|
||||||
source: answer ? 'model' : 'faq',
|
source: answer ? 'model' : 'faq',
|
||||||
answer: answer || null,
|
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 };
|
||||||
|
|||||||
@@ -12,5 +12,6 @@ router.post('/seen', ctrl.markSeen);
|
|||||||
router.post('/dismiss', ctrl.dismiss);
|
router.post('/dismiss', ctrl.dismiss);
|
||||||
router.patch('/settings', ctrl.setSettings);
|
router.patch('/settings', ctrl.setSettings);
|
||||||
router.post('/ask', ctrl.ask);
|
router.post('/ask', ctrl.ask);
|
||||||
|
router.post('/flashcards', ctrl.flashcardsFromText);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
+150
-33
@@ -296,6 +296,13 @@
|
|||||||
'.asst-chips{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px;}',
|
'.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{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: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;}',
|
'.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;}}',
|
'@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(); };
|
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 = [
|
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>';
|
||||||
function openAsk() {
|
var chips = '<div class="asst-chips">' + ctxBtns +
|
||||||
var chips = '<div class="asst-chips">' +
|
SUGGESTIONS.map(function (q) { return '<button class="asst-chip" type="button">' + esc(q) + '</button>'; }).join('') + '</div>';
|
||||||
SUGGESTIONS.map(function (q) { return '<button class="asst-chip" type="button">' + esc(q) + '</button>'; }).join('') +
|
|
||||||
'</div>';
|
|
||||||
openBubble(
|
openBubble(
|
||||||
'<div class="asst-name">Спроси Квантика</div>' +
|
'<div class="asst-name">Спроси Квантика</div>' +
|
||||||
'<input class="asst-ask-in" type="text" placeholder="Например: как сохранить кусок учебника" maxlength="200" />' +
|
'<input class="asst-ask-in" type="text" placeholder="Спроси что угодно: «объясни…», «как…»" maxlength="300" />' +
|
||||||
chips +
|
chips + '<div class="asst-ans-box"></div>', {});
|
||||||
'<div class="asst-ans-box"></div>', {});
|
|
||||||
var inp = bubble.querySelector('.asst-ask-in');
|
var inp = bubble.querySelector('.asst-ask-in');
|
||||||
var box = bubble.querySelector('.asst-ans-box');
|
var box = bubble.querySelector('.asst-ans-box');
|
||||||
inp.focus();
|
|
||||||
var t = null;
|
var t = null;
|
||||||
inp.addEventListener('input', function () { clearTimeout(t); t = setTimeout(function () { runAsk(inp.value, box); }, 350); });
|
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); } });
|
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') { clearTimeout(t); runAsk(inp.value, box); } });
|
||||||
bubble.querySelectorAll('.asst-chip').forEach(function (c) {
|
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();
|
q = (q || '').trim();
|
||||||
if (q.length < 3) { box.innerHTML = ''; return; }
|
if (q.length < 3) { box.innerHTML = ''; return; }
|
||||||
box.innerHTML = '<div class="asst-empty">Ищу…</div>';
|
box.innerHTML = '<div class="asst-empty">Думаю…</div>';
|
||||||
Promise.all([
|
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: [] }; }),
|
(LS.globalSearch ? LS.globalSearch(q, 'all', 4) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }),
|
||||||
]).then(function (res) {
|
]).then(function (res) {
|
||||||
var modelAnswer = res[0] && res[0].answer;
|
var modelAnswer = res[0] && res[0].answer;
|
||||||
var ans = (res[0] && res[0].answers) || [];
|
var ans = (res[0] && res[0].answers) || [];
|
||||||
var found = (res[1] && res[1].results) || [];
|
var found = (res[1] && res[1].results) || [];
|
||||||
var html = '';
|
box.innerHTML = '';
|
||||||
if (modelAnswer) {
|
if (modelAnswer) {
|
||||||
html += '<div class="asst-ans"><div class="asst-ans-q">Квантик</div>' +
|
var a = document.createElement('div'); a.className = 'asst-ans';
|
||||||
'<div style="white-space:pre-line">' + esc(modelAnswer) + '</div></div>';
|
a.innerHTML = '<div class="asst-ans-q">Квантик</div><div class="asst-rich"></div>';
|
||||||
if (ans.length) html += '<div class="asst-ans-sec">Из справки</div>';
|
box.appendChild(a);
|
||||||
}
|
renderRich(a.querySelector('.asst-rich'), modelAnswer);
|
||||||
if (ans.length) {
|
if (ans.length) box.insertAdjacentHTML('beforeend', '<div class="asst-ans-sec">Из справки</div>');
|
||||||
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 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) {
|
if (found.length) {
|
||||||
html += '<div class="asst-ans-sec">На платформе</div>';
|
rest += '<div class="asst-ans-sec">На платформе</div>';
|
||||||
html += found.slice(0, 4).map(function (f) {
|
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>' +
|
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>';
|
(f.subtitle ? ' <span style="color:#8a94a6">— ' + esc(f.subtitle) + '</span>' : '') + '</div>';
|
||||||
}).join('');
|
}).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>'; });
|
}).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: онбординг-тур по разделам ───────────────────────────────────── */
|
/* ── Ф2: онбординг-тур по разделам ───────────────────────────────────── */
|
||||||
var TOUR = [
|
var TOUR = [
|
||||||
{ sel: '#app-sidebar a[href="/dashboard"]', title: 'Дашборд', text: 'Главная: твой прогресс, активность и питомец.' },
|
{ sel: '#app-sidebar a[href="/dashboard"]', title: 'Дашборд', text: 'Главная: твой прогресс, активность и питомец.' },
|
||||||
@@ -535,6 +639,16 @@
|
|||||||
else showGreet();
|
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']) || {};
|
var ob = (SRV.seen && SRV.seen['onboarding']) || {};
|
||||||
if (PAGE === 'dashboard' && !ob.dismissed && (ob.count || 0) < 3) {
|
if (PAGE === 'dashboard' && !ob.dismissed && (ob.count || 0) < 3) {
|
||||||
@@ -580,5 +694,8 @@
|
|||||||
window.Assistant = {
|
window.Assistant = {
|
||||||
open: function () { if (root) root.querySelector('.asst-fab').click(); },
|
open: function () { if (root) root.querySelector('.asst-fab').click(); },
|
||||||
tour: function () { startTour(); },
|
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>` : '';
|
? `<button class="tc-sol-btn" data-tc-sol>${ICONS.chev}<span>Показать решение</span></button>` : '';
|
||||||
const solPanelHtml = (showSol && task.solution)
|
const solPanelHtml = (showSol && task.solution)
|
||||||
? `<div class="tc-sol-panel" data-tc-sol-panel>${task.solution}</div>` : '';
|
? `<div class="tc-sol-panel" data-tc-sol-panel>${task.solution}</div>` : '';
|
||||||
const solBlock = (solToggle || refLink || saveMatBtn)
|
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">
|
||||||
? `<div class="tc-sol-wrap"><div class="tc-sol-row">${solToggle}${refLink}${saveMatBtn}</div>${solPanelHtml}</div>`
|
<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 = `
|
card.innerHTML = `
|
||||||
<div class="tc-head">
|
<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
|
// ── State
|
||||||
let startedAt = Date.now();
|
let startedAt = Date.now();
|
||||||
let solutionLogged = false;
|
let solutionLogged = false;
|
||||||
|
|||||||
@@ -1050,7 +1050,7 @@ window.LS = {
|
|||||||
crAdminGetAllHistory, crAdminGetTeachersList,
|
crAdminGetAllHistory, crAdminGetTeachersList,
|
||||||
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
|
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
|
||||||
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
|
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
|
||||||
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk,
|
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards,
|
||||||
fcListDecks, fcCreateDeck, fcAddCard,
|
fcListDecks, fcCreateDeck, fcAddCard,
|
||||||
escapeHtml, esc,
|
escapeHtml, esc,
|
||||||
parseDate, fmtRelTime, safeHref,
|
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 assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); }
|
||||||
async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); }
|
async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); }
|
||||||
async function assistantSettings(d) { return req('PATCH', '/assistant/settings', d); }
|
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 fcListDecks() { return req('GET', '/flashcards/decks'); }
|
||||||
async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); }
|
async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); }
|
||||||
async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }
|
async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }
|
||||||
|
|||||||
Reference in New Issue
Block a user