From 479c621e2ecadeac84f9a19e5c4dc069c592e1d1 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 4 Jun 2026 17:53:45 +0300 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20markdown+KaTeX,=20=C2=AB?= =?UTF-8?q?=D0=9E=D0=B1=D1=8A=D1=8F=D1=81=D0=BD=D0=B8=20=D1=8D=D1=82=D0=BE?= =?UTF-8?q?=C2=BB,=20=D1=80=D0=B5=D0=BF=D0=B5=D1=82=D0=B8=D1=82=D0=BE?= =?UTF-8?q?=D1=80=20=D0=BD=D0=B0=20=D1=8D=D0=BA=D0=B7=D0=B0=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5,=20=D1=84=D0=BB=D0=B5=D1=88=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ответы модели рендерятся как 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) --- .../src/controllers/assistantController.js | 82 ++++++-- backend/src/routes/assistant.js | 1 + frontend/js/assistant.js | 183 ++++++++++++++---- frontend/js/exam-prep/task-card.js | 18 +- js/api.js | 5 +- 5 files changed, 231 insertions(+), 58 deletions(-) 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 = 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 += '