From dc073e21140f26090086844601122edd22bea54a Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 4 Jun 2026 18:04:42 +0300 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20=D0=B0=D0=B4=D0=BC=D0=B8?= =?UTF-8?q?=D0=BD-=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB=D1=8C=20LLM=20(=D0=BA?= =?UTF-8?q?=D0=BB=D1=8E=D1=87/URL/=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C/?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82)=20+=20=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=BE=D0=B2=D0=BE=D0=B9=20=D1=87=D0=B0=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Админка (Управление → игры/фичи): карточка «Помощник Квантик — модель» — пресеты провайдеров, URL/модель, поле ключа, кнопки Сохранить/Проверить/ Очистить ключ, индикатор статуса. Конфиг в app_settings (без рестарта), откат на ENV/дефолты; нет ключа → автоматически FAQ-режим. Эндпоинты GET/PUT/POST /api/admin/assistant(/test), admin-only. «Спроси Квантика» теперь многоходовой чат: история диалога (последние 6 реплик) уходит модели, ответы рендерятся как чат-лента, кнопка «Очистить». Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/controllers/adminController.js | 46 ++++++++ .../src/controllers/assistantController.js | 75 +++++++++--- backend/src/routes/admin.js | 3 + frontend/js/admin/sections/games.js | 70 +++++++++++ frontend/js/assistant.js | 111 ++++++++++-------- js/api.js | 6 +- 6 files changed, 244 insertions(+), 67 deletions(-) diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index f94f6c4..f3539ec 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -877,6 +877,51 @@ function broadcast(req, res) { res.json({ ok: true, sent: users.length }); } +/* ── Ассистент «Квантик»: конфиг LLM из админки ──────────────────────── */ +const ASSISTANT_PRESETS = [ + { name: 'Google Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', model: 'gemini-2.5-flash' }, + { name: 'Groq', url: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.3-70b-versatile' }, + { name: 'OpenRouter', url: 'https://openrouter.ai/api/v1/chat/completions', model: 'meta-llama/llama-3.3-70b-instruct:free' }, + { name: 'Ollama (локально)', url: 'http://localhost:11434/v1/chat/completions', model: 'qwen2.5:3b' }, +]; +function _aset(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; } + +function getAssistant(_req, res) { + const url = _aset('assistant_llm_url') || process.env.ASSISTANT_LLM_URL || ASSISTANT_PRESETS[1].url; + const model = _aset('assistant_llm_model') || process.env.ASSISTANT_LLM_MODEL || ASSISTANT_PRESETS[1].model; + const dbKey = _aset('assistant_llm_key'); + const hasKey = !!(dbKey || process.env.ASSISTANT_LLM_KEY); + const local = /\/\/(localhost|127\.0\.0\.1)/.test(url); + res.json({ url, model, hasKey, keyFromEnv: !dbKey && !!process.env.ASSISTANT_LLM_KEY, active: !!(hasKey || local), presets: ASSISTANT_PRESETS }); +} + +function saveAssistant(req, res) { + const set = (k, v) => db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(k, v); + const del = (k) => db.prepare('DELETE FROM app_settings WHERE key = ?').run(k); + const b = req.body || {}; + if (typeof b.url === 'string') set('assistant_llm_url', b.url.trim().slice(0, 300)); + if (typeof b.model === 'string') set('assistant_llm_model', b.model.trim().slice(0, 120)); + if (b.clearKey) del('assistant_llm_key'); + else if (typeof b.key === 'string' && b.key.trim()) set('assistant_llm_key', b.key.trim().slice(0, 400)); + audit(req, 'assistant.config', 'assistant', b.clearKey ? 'ключ очищен' : 'обновлено'); + res.json({ ok: true }); +} + +async function testAssistant(req, res) { + const a = require('./assistantController'); + const cfg = a.llmConfig(); + const b = req.body || {}; + const override = { + url: (typeof b.url === 'string' && b.url.trim()) ? b.url.trim() : cfg.url, + model: (typeof b.model === 'string' && b.model.trim()) ? b.model.trim() : cfg.model, + key: (typeof b.key === 'string' && b.key.trim()) ? b.key.trim() : cfg.key, + }; + override.local = /\/\/(localhost|127\.0\.0\.1)/.test(override.url); + override.on = !!(override.key || override.local); + const r = await a.pingLLM(override); + res.json(r); +} + module.exports = { getStats, getOverview, globalSearch, getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail, @@ -886,4 +931,5 @@ module.exports = { getSecurityLog, clearSecurityLog, getTopics, createTopic, updateTopic, deleteTopic, broadcast, + getAssistant, saveAssistant, testAssistant, }; diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js index d660630..a529a9e 100644 --- a/backend/src/controllers/assistantController.js +++ b/backend/src/controllers/assistantController.js @@ -225,23 +225,28 @@ function searchFaq(q, n) { * ASSISTANT_LLM_KEY (Bearer-ключ; для localhost/Ollama не нужен) * ASSISTANT_LLM_MODEL (по умолч. llama-3.3-70b-versatile) * Если ключ не задан и URL не локальный — тихо работаем как раньше (FAQ). */ -const LLM_URL = process.env.ASSISTANT_LLM_URL || 'https://api.groq.com/openai/v1/chat/completions'; -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); - -const LLM_ON = !!(LLM_KEY || LLM_LOCAL); +/* Конфиг берём из app_settings (правится из админки без рестарта), с откатом + * на ENV и дефолты. Если ключа нет и URL не локальный — работаем как FAQ. */ +function _setting(k) { try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; } catch (e) { return null; } } +function llmConfig() { + const url = _setting('assistant_llm_url') || process.env.ASSISTANT_LLM_URL || 'https://api.groq.com/openai/v1/chat/completions'; + const key = _setting('assistant_llm_key') || process.env.ASSISTANT_LLM_KEY || ''; + const model = _setting('assistant_llm_model') || process.env.ASSISTANT_LLM_MODEL || 'llama-3.3-70b-versatile'; + const local = /\/\/(localhost|127\.0\.0\.1)/.test(url); + return { url, key, model, local, on: !!(key || local) }; +} /* Низкоуровневый вызов OpenAI-совместимого chat/completions. */ -async function callLLM(messages, maxTokens) { - if (typeof fetch !== 'function' || !LLM_ON) return null; +async function callLLM(messages, maxTokens, override) { + const cfg = override || llmConfig(); + if (typeof fetch !== 'function' || !cfg.on) return null; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 15000); try { - const r = await fetch(LLM_URL, { + const r = await fetch(cfg.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: maxTokens || 320, messages }), + headers: Object.assign({ 'Content-Type': 'application/json' }, cfg.key ? { Authorization: `Bearer ${cfg.key}` } : {}), + body: JSON.stringify({ model: cfg.model, temperature: 0.3, max_tokens: maxTokens || 320, messages }), signal: ctrl.signal, }); if (!r.ok) return null; @@ -251,6 +256,33 @@ async function callLLM(messages, maxTokens) { } catch (e) { return null; } finally { clearTimeout(timer); } } +/* Тест-пинг для админки: подробный статус (status/ошибка/пример ответа). */ +async function pingLLM(override) { + const cfg = override || llmConfig(); + if (!cfg.url) return { ok: false, error: 'URL не задан' }; + if (!cfg.key && !/\/\/(localhost|127\.0\.0\.1)/.test(cfg.url)) return { ok: false, error: 'Ключ не задан' }; + if (typeof fetch !== 'function') return { ok: false, error: 'fetch недоступен' }; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(cfg.url, { + method: 'POST', + headers: Object.assign({ 'Content-Type': 'application/json' }, cfg.key ? { Authorization: `Bearer ${cfg.key}` } : {}), + body: JSON.stringify({ model: cfg.model, max_tokens: 16, messages: [{ role: 'user', content: 'Ответь одним словом: привет' }] }), + signal: ctrl.signal, + }); + const txt = await r.text(); + if (!r.ok) { + let msg = txt.slice(0, 300); + try { const j = JSON.parse(txt); if (j && j.error) msg = String(j.error.message || JSON.stringify(j.error)).slice(0, 300); } catch (e) {} + return { ok: false, status: r.status, error: msg }; + } + let sample = ''; + try { const j = JSON.parse(txt); sample = String((j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || '').slice(0, 120); } catch (e) {} + return { ok: true, status: r.status, sample, model: cfg.model }; + } catch (e) { return { ok: false, error: e.name === 'AbortError' ? 'Таймаут (15с)' : (e.message || 'Ошибка сети') }; } finally { clearTimeout(timer); } +} + const ASSISTANT_SYS = 'Ты — Квантик, дружелюбный помощник учебной платформы LearnSpace. ' + 'Отвечай по-русски, кратко и понятно, на «ты», как для школьника. ' + 'Если вопрос о работе платформы — опирайся на справку ниже и не выдумывай разделы/кнопки, которых в ней нет ' + @@ -258,23 +290,28 @@ const ASSISTANT_SYS = 'Ты — Квантик, дружелюбный помо 'Если это учебный или общий вопрос (математика, физика, объяснить понятие, решить пример) — отвечай по существу и помоги разобраться. ' + 'Формулы и математику оформляй в LaTeX между знаками доллара, например $a^2+b^2=c^2$. Не используй эмодзи.'; -async function askModel(q, hits, context) { +async function askModel(q, hits, context, history) { 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); + const msgs = [{ role: 'system', content: ASSISTANT_SYS }]; + (history || []).forEach(m => { if (m && (m.role === 'user' || m.role === 'assistant') && m.content) msgs.push({ role: m.role, content: String(m.content).slice(0, 1500) }); }); + msgs.push({ role: 'user', content: user }); + return callLLM(msgs, 420); } -/* ── POST /api/assistant/ask { q, context? } ── «Спроси Квантика» ───────── - * Грунтуем ответ топ-FAQ (+ опц. контекстом страницы/выделенного). Если LLM - * настроена — даём её ответ (source:'model'), иначе FAQ (source:'faq'). */ +/* ── POST /api/assistant/ask { q, context?, history? } ── «Спроси Квантика» ─ + * Грунтуем ответ топ-FAQ (+ опц. контекст страницы + история диалога). Если + * LLM настроена — её ответ (source:'model'), иначе FAQ (source:'faq'). */ async function ask(req, res) { 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); + let history = (req.body && req.body.history); + history = Array.isArray(history) ? history.slice(-6) : []; const hits = searchFaq(q, 3); let answer = null; - if (LLM_ON) { try { answer = await askModel(q, hits, context); } catch (e) { answer = null; } } + if (llmConfig().on) { try { answer = await askModel(q, hits, context, history); } catch (e) { answer = null; } } res.json({ source: answer ? 'model' : 'faq', answer: answer || null, @@ -286,7 +323,7 @@ async function ask(req, res) { * Генерирует учебные карточки из текста (модель → JSON). Карточки фронт * создаёт сам через существующий API флешкарт. */ async function flashcardsFromText(req, res) { - if (!LLM_ON) return res.status(503).json({ error: 'LLM не настроена' }); + if (!llmConfig().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: 'Слишком мало текста' }); @@ -316,4 +353,4 @@ async function flashcardsFromText(req, res) { res.json({ title, cards }); } -module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText }; +module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText, llmConfig, pingLLM }; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 4fd525f..27b597e 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -13,6 +13,9 @@ router.patch('/free-student-features', requireRole('admin'), ctrl.updateF /* Everything below is admin-only */ router.use(requireRole('admin')); +router.get('/assistant', ctrl.getAssistant); +router.put('/assistant', ctrl.saveAssistant); +router.post('/assistant/test', ctrl.testAssistant); router.get('/stats', ctrl.getStats); router.get('/overview', ctrl.getOverview); router.get('/search', ctrl.globalSearch); diff --git a/frontend/js/admin/sections/games.js b/frontend/js/admin/sections/games.js index 8b403e1..568094f 100644 --- a/frontend/js/admin/sections/games.js +++ b/frontend/js/admin/sections/games.js @@ -34,8 +34,78 @@ { key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' }, ]; + /* ── Конфиг LLM для помощника «Квантик» ── */ + var IN_STYLE = 'padding:8px 10px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.85rem;width:100%;box-sizing:border-box;background:var(--surface,#fff);color:var(--text,#0f172a)'; + var BTN_STYLE = 'padding:8px 14px;border-radius:9px;border:1px solid var(--border,#e2e8f0);background:var(--surface,#fff);font:inherit;font-size:.82rem;font-weight:700;cursor:pointer;color:var(--text-2,#475569)'; + async function renderAssistantLlmCard(grid) { + if (!grid || document.getElementById('asst-llm-card')) return; + var wrap = document.createElement('div'); + wrap.id = 'asst-llm-card'; + wrap.className = 'perm-card'; + wrap.style.cssText = 'flex-direction:column;align-items:stretch;gap:9px;margin-bottom:14px'; + wrap.innerHTML = + '
Помощник «Квантик» — модель (ИИ)
' + + '
Подключение LLM к «Спроси Квантика». OpenAI-совместимый эндпоинт. Без ключа — режим обычного FAQ.
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
'; + grid.parentNode.insertBefore(wrap, grid); + if (window.lucide) lucide.createIcons(); + + var q = function (s) { return wrap.querySelector(s); }; + var cfg = {}; + try { cfg = await LS.adminGetAssistant(); } catch (e) {} + var presetSel = q('#asst-preset'); + (cfg.presets || []).forEach(function (p, i) { var o = document.createElement('option'); o.value = String(i); o.textContent = p.name; presetSel.appendChild(o); }); + q('#asst-url').value = cfg.url || ''; + q('#asst-model').value = cfg.model || ''; + q('#asst-key').placeholder = cfg.hasKey ? 'Ключ сохранён — введите новый, чтобы заменить' : 'API-ключ'; + function setStatus() { + q('#asst-llm-status').innerHTML = cfg.active + ? '● Подключено — «Спроси» отвечает через ИИ' + : '○ Ключ не задан — работает обычный FAQ-режим'; + } + setStatus(); + presetSel.addEventListener('change', function () { + var p = (cfg.presets || [])[Number(presetSel.value)]; + if (p) { q('#asst-url').value = p.url; q('#asst-model').value = p.model; } + }); + q('#asst-save').addEventListener('click', async function () { + var body = { url: q('#asst-url').value, model: q('#asst-model').value }; + var k = q('#asst-key').value.trim(); if (k) body.key = k; + try { await LS.adminSaveAssistant(body); q('#asst-key').value = ''; LS.toast('Сохранено', 'success'); + cfg = await LS.adminGetAssistant(); cfg.hasKey && (q('#asst-key').placeholder = 'Ключ сохранён — введите новый, чтобы заменить'); setStatus(); + } catch (e) { LS.toast('Ошибка: ' + (e.message || ''), 'error'); } + }); + q('#asst-test').addEventListener('click', async function () { + var res = q('#asst-test-res'); res.innerHTML = 'Проверяю…'; + var body = { url: q('#asst-url').value, model: q('#asst-model').value }; + var k = q('#asst-key').value.trim(); if (k) body.key = k; + try { + var r = await LS.adminTestAssistant(body); + res.innerHTML = r && r.ok + ? '✓ Работает (' + (r.model || '') + '): ' + (r.sample || 'ответ получен').replace(/' + : '✗ ' + ((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').toString().slice(0, 200).replace(/'; + } catch (e) { res.innerHTML = '✗ ' + (e.message || 'ошибка') + ''; } + }); + q('#asst-clearkey').addEventListener('click', async function () { + if (!await LS.confirm('Очистить сохранённый ключ? «Спроси» вернётся в FAQ-режим.', { title: 'Очистить ключ?', confirmText: 'Очистить' })) return; + try { await LS.adminSaveAssistant({ clearKey: true }); LS.toast('Ключ очищен', 'success'); cfg = await LS.adminGetAssistant(); q('#asst-key').placeholder = 'API-ключ'; setStatus(); } + catch (e) { LS.toast('Ошибка', 'error'); } + }); + } + async function loadGamesAdmin() { const grid = document.getElementById('games-features-grid'); + renderAssistantLlmCard(grid); try { const features = await LS.api('/api/admin/features'); grid.innerHTML = ''; diff --git a/frontend/js/assistant.js b/frontend/js/assistant.js index 93becd3..16a14b2 100644 --- a/frontend/js/assistant.js +++ b/frontend/js/assistant.js @@ -303,6 +303,14 @@ '.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-chat{max-height:46vh;overflow:auto;display:flex;flex-direction:column;gap:8px;margin-bottom:8px;}', + '.asst-chat:empty{display:none;}', + '.asst-msg{font-size:.84rem;line-height:1.5;border-radius:12px;padding:8px 11px;max-width:92%;word-break:break-word;}', + '.asst-msg-user{align-self:flex-end;background:#9B5DE5;color:#fff;}', + '.asst-msg-assistant{align-self:flex-start;background:rgba(15,23,42,.05);}', + '.asst-msg-assistant .asst-rich{color:#28324a;}', + '.asst-msg-ph{opacity:.6;}', + '.asst-msg-links{align-self:flex-start;font-size:.74rem;}', '.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;}}', @@ -433,6 +441,18 @@ /* ── «Спроси Квантика» ───────────────────────────────────────────────── */ var SUGGESTIONS = ['Как вырезать кусок учебника?', 'Как создать карточки?', 'Как начать тест?', 'Объясни теорему Пифагора', 'Где мои домашние задания?', 'Как включить тёмную тему?']; + var _chat = []; // многоходовой диалог: [{role:'user'|'assistant', content}] + function msgEl(role) { var d = document.createElement('div'); d.className = 'asst-msg asst-msg-' + role; return d; } + function renderChat(chatEl) { + chatEl.innerHTML = ''; + _chat.forEach(function (m) { + var d = msgEl(m.role); + if (m.role === 'assistant') { d.innerHTML = '
'; renderRich(d.querySelector('.asst-rich'), m.content); } + else d.textContent = m.content; + chatEl.appendChild(d); + }); + chatEl.scrollTop = chatEl.scrollHeight; + } function openAsk(prefill) { var sel = _lastSel, pc = getPageContext(); var ctxBtns = ''; @@ -443,67 +463,64 @@ var chips = '
' + ctxBtns + SUGGESTIONS.map(function (q) { return ''; }).join('') + '
'; openBubble( - '
Спроси Квантика
' + - '' + - chips + '
', {}); + '
Спроси Квантика' + (_chat.length ? '' : '') + '
' + + '
' + chips + + '', {}); var inp = bubble.querySelector('.asst-ask-in'); - var box = bubble.querySelector('.asst-ans-box'); - 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); } }); + var chatEl = bubble.querySelector('.asst-chat'); + var chipsEl = bubble.querySelector('.asst-chips'); + renderChat(chatEl); + if (_chat.length) chipsEl.style.display = 'none'; + function go(q, context) { if (chipsEl) chipsEl.style.display = 'none'; inp.value = ''; send(q, context, chatEl); } + inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') go(inp.value); }); bubble.querySelectorAll('.asst-chip').forEach(function (c) { 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 (ctx === 'sel') return go('Объясни простыми словами и приведи пример.', sel); + if (ctx === 'sec') return go('Объясни простыми словами, о чём этот параграф, и выдели главное.', pc && pc.text); + if (ctx === 'sum') return go('Сделай краткий конспект этого материала: 4–6 главных пунктов.', pc && pc.text); + if (ctx === 'cards') { if (chipsEl) chipsEl.style.display = 'none'; return makeFlashcards(pc, chatEl); } + go(c.textContent); }); }); - if (prefill) { inp.value = prefill.q || ''; runAsk(prefill.q, box, prefill.context); } + var clr = bubble.querySelector('[data-a="clear"]'); + if (clr) clr.onclick = function () { _chat = []; openAsk(); }; + if (prefill && prefill.q) go(prefill.q, prefill.context); else inp.focus(); } - function runAsk(q, box, context) { + function send(q, context, chatEl) { q = (q || '').trim(); - if (q.length < 3) { box.innerHTML = ''; return; } - box.innerHTML = '
Думаю…
'; + if (q.length < 2) return; + var history = _chat.slice(-6); + _chat.push({ role: 'user', content: q }); + var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u); + var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.textContent = 'Думаю…'; chatEl.appendChild(ph); + chatEl.scrollTop = chatEl.scrollHeight; Promise.all([ - LS.assistantAsk(q, context).catch(function () { return { answers: [] }; }), - (LS.globalSearch ? LS.globalSearch(q, 'all', 4) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }), + LS.assistantAsk(q, context, history).catch(function () { return { answers: [] }; }), + (LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }), ]).then(function (res) { - var modelAnswer = res[0] && res[0].answer; + ph.remove(); + var model = res[0] && res[0].answer; var ans = (res[0] && res[0].answers) || []; var found = (res[1] && res[1].results) || []; - box.innerHTML = ''; - if (modelAnswer) { - var a = document.createElement('div'); a.className = 'asst-ans'; - a.innerHTML = '
Квантик
'; - box.appendChild(a); - renderRich(a.querySelector('.asst-rich'), modelAnswer); - if (ans.length) box.insertAdjacentHTML('beforeend', '
Из справки
'); - } - var rest = ''; - if (ans.length) rest += ans.map(function (a2) { - return '
' + esc(a2.q) + '
' + esc(a2.a) + - (a2.url ? '
Открыть' : '') + '
'; - }).join(''); - if (found.length) { - rest += '
На платформе
'; - rest += found.slice(0, 4).map(function (f) { - return '
' + esc(f.title || 'Без названия') + '' + - (f.subtitle ? ' — ' + esc(f.subtitle) + '' : '') + '
'; - }).join(''); - } - if (rest) box.insertAdjacentHTML('beforeend', rest); - if (!box.innerHTML) box.innerHTML = '
Ничего не нашёл. Попробуй переформулировать.
'; - }).catch(function () { box.innerHTML = '
Не удалось получить ответ.
'; }); + var content = model || (ans[0] && (ans[0].q + '\n' + ans[0].a)) || 'Не нашёл точного ответа. Попробуй переформулировать или поищи (Ctrl+K).'; + _chat.push({ role: 'assistant', content: content }); + var d = msgEl('assistant'); d.innerHTML = '
'; chatEl.appendChild(d); + renderRich(d.querySelector('.asst-rich'), content); + var links = ''; + if (!model && ans.length) links += ans.slice(0, 2).filter(function (a) { return a.url; }).map(function (a) { return '' + esc(a.q) + ''; }).join(' · '); + if (found.length) links += (links ? '
' : '') + 'На платформе: ' + found.slice(0, 3).map(function (f) { return '' + esc(f.title || '…') + ''; }).join(' · '); + if (links) { var l = document.createElement('div'); l.className = 'asst-msg-links'; l.innerHTML = links; chatEl.appendChild(l); } + chatEl.scrollTop = chatEl.scrollHeight; + }).catch(function () { ph.textContent = 'Не удалось получить ответ.'; }); } /* ── «Флешкарты из параграфа» — модель → колода через API флешкарт ─────── */ - function makeFlashcards(pc, box) { - if (!pc || !pc.text) { box.innerHTML = '
Открой параграф учебника, чтобы сделать карточки.
'; return; } - box.innerHTML = '
Готовлю карточки…
'; + function makeFlashcards(pc, chatEl) { + var note = msgEl('assistant'); + if (!pc || !pc.text) { note.innerHTML = '
Открой параграф учебника, чтобы сделать карточки.
'; chatEl.appendChild(note); return; } + note.innerHTML = '
Готовлю карточки…
'; chatEl.appendChild(note); chatEl.scrollTop = chatEl.scrollHeight; LS.assistantFlashcards(pc.text, pc.title || 'Карточки').then(function (r) { var cards = (r && r.cards) || []; if (!cards.length) throw new Error('empty'); @@ -514,9 +531,9 @@ }, Promise.resolve()).then(function () { return cards.length; }); }); }).then(function (n) { - box.innerHTML = '
Готово: создано ' + n + ' ' + plural(n, 'карточка', 'карточки', 'карточек') + + note.innerHTML = '
Готово: создано ' + n + ' ' + plural(n, 'карточка', 'карточки', 'карточек') + '. Открыть флешкарты
'; - }).catch(function () { box.innerHTML = '
Не удалось сделать карточки. Попробуй позже.
'; }); + }).catch(function () { note.innerHTML = '
Не удалось сделать карточки. Попробуй позже.
'; }); } /* ── Ф2: онбординг-тур по разделам ───────────────────────────────────── */ diff --git a/js/api.js b/js/api.js index bf6bf0b..94afa68 100644 --- a/js/api.js +++ b/js/api.js @@ -1051,6 +1051,7 @@ window.LS = { listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity, createMaterialCollection, updateMaterialCollection, deleteMaterialCollection, assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, + adminGetAssistant, adminSaveAssistant, adminTestAssistant, fcListDecks, fcCreateDeck, fcAddCard, escapeHtml, esc, parseDate, fmtRelTime, safeHref, @@ -1272,8 +1273,11 @@ 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, context) { return req('POST', '/assistant/ask', { q, context: context || undefined }); } +async function assistantAsk(q, context, history) { return req('POST', '/assistant/ask', { q, context: context || undefined, history: history || undefined }); } async function assistantFlashcards(text, title) { return req('POST', '/assistant/flashcards', { text, title }); } +async function adminGetAssistant() { return req('GET', '/admin/assistant'); } +async function adminSaveAssistant(d) { return req('PUT', '/admin/assistant', d); } +async function adminTestAssistant(d) { return req('POST', '/admin/assistant/test', d || {}); } 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); }