From 9baaca7f68257e8e5a8654326c0160de7067de49 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 4 Jun 2026 16:30:34 +0300 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20=D0=A42=20=D0=BE=D0=BD=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=D0=B4=D0=B8=D0=BD=D0=B3-=D1=82=D1=83=D1=80=20+=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=20=C2=AB=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D0=BE=D0=BB=D0=B6=D0=B8=20=D1=83=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ф2: коачмарк-тур новичка по разделам (сайдбар + сам помощник), офер на дашборде пока не пройден/не закрыт, повтор из приветствия и Assistant.tour(). activeLesson: контекст-эндпоинт отдаёт начатый незавершённый урок (как «продолжить чтение»), добавлено проактивное правило «Продолжи …» → /course. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/controllers/assistantController.js | 23 +++- frontend/js/assistant.js | 116 +++++++++++++++++- 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js index c7e1a48..ad849cc 100644 --- a/backend/src/controllers/assistantController.js +++ b/backend/src/controllers/assistantController.js @@ -85,6 +85,22 @@ function pendingHomework(uid) { } catch (e) { return { overdue: null, dueSoon: null }; } } +function activeLesson(uid, role) { + // Начатый, но не завершённый урок (как «продолжить чтение» на дашборде). + try { + const pub = role === 'student' ? 'AND l.is_published = 1 AND c.is_published = 1' : ''; + const row = db.prepare(` + SELECT l.id AS lessonId, l.title AS lessonTitle, l.course_id AS courseId, c.title AS courseTitle + FROM lesson_progress lp + JOIN lessons l ON lp.lesson_id = l.id + JOIN courses c ON l.course_id = c.id + WHERE lp.user_id = ? AND lp.completed = 0 ${pub} + ORDER BY lp.updated_at DESC LIMIT 1 + `).get(uid); + return row || null; + } catch (e) { return null; } +} + /* ── GET /api/assistant/context ───────────────────────────────────────── */ function getContext(req, res) { const uid = req.user.id; @@ -97,10 +113,11 @@ function getContext(req, res) { } catch (e) { /* table may be missing on a legacy instance */ } res.json({ - enabled: u ? u.assistant_enabled !== 0 : true, + enabled: u ? u.assistant_enabled !== 0 : true, seen, - dueCards: dueCardsCount(uid), - homework: pendingHomework(uid), + dueCards: dueCardsCount(uid), + homework: pendingHomework(uid), + activeLesson: activeLesson(uid, req.user.role), }); } diff --git a/frontend/js/assistant.js b/frontend/js/assistant.js index bb094fb..49be345 100644 --- a/frontend/js/assistant.js +++ b/frontend/js/assistant.js @@ -91,6 +91,10 @@ when: function () { return !!(SRV && SRV.homework && SRV.homework.dueSoon); }, text: function () { return 'Скоро дедлайн: «' + (SRV.homework.dueSoon.title || 'задание') + '».'; }, action: function () { return { label: 'К домашке', url: '/homework' }; } }, + { id: 'lesson-continue', scope: 'proactive', cooldownDays: 1, maxShows: 60, + when: function () { return !!(SRV && SRV.activeLesson && SRV.activeLesson.courseId); }, + text: function () { return 'Продолжи: «' + (SRV.activeLesson.courseTitle || SRV.activeLesson.lessonTitle || 'урок') + '».'; }, + action: function () { return { label: 'Продолжить', url: '/course?id=' + SRV.activeLesson.courseId }; } }, { id: 'cards-due', scope: 'proactive', cooldownDays: 1, maxShows: 60, when: function () { return !!(SRV && SRV.dueCards > 0); }, text: function () { return 'К повторению ' + SRV.dueCards + ' ' + plural(SRV.dueCards, 'карточка', 'карточки', 'карточек') + ' — освежим память?'; }, @@ -254,14 +258,16 @@ function greetHtml() { return '
' + esc(PET && PET.petName ? PET.petName : 'Квантик') + '
' + - '
Привет! Я помогу разобраться в системе. Спроси, как что-то сделать.
' + + '
Привет! Я помогу разобраться в системе. Спроси, как что-то сделать, или пройди короткий тур.
' + '
' + - '
'; + '' + + ''; } function showGreet() { openBubble(greetHtml(), {}); bubble.querySelector('[data-a="ok"]').onclick = closeBubble; bubble.querySelector('[data-a="ask"]').onclick = openAsk; + bubble.querySelector('[data-a="tour"]').onclick = function () { startTour(); }; } /* ── «Спроси Квантика» ───────────────────────────────────────────────── */ @@ -291,6 +297,98 @@ }).catch(function () { box.innerHTML = '
Не удалось получить ответ.
'; }); } + /* ── Ф2: онбординг-тур по разделам ───────────────────────────────────── */ + var TOUR = [ + { sel: '#app-sidebar a[href="/dashboard"]', title: 'Дашборд', text: 'Главная: твой прогресс, активность и питомец.' }, + { sel: '#app-sidebar a[href="/exam-prep/math9"]', title: 'Подготовка к экзамену', text: 'Тесты по темам, режимы экзамена и тренировки.' }, + { sel: '#app-sidebar a[href="/theory"]', title: 'Теория', text: 'Курсы и уроки. Можно создать и быстрый одиночный урок.' }, + { sel: '#app-sidebar a[href="/flashcards"]', title: 'Флэшкарты', text: 'Карточки для повторения — система сама напомнит, что освежить.' }, + { sel: '#app-sidebar a[href="/my-materials"]', title: 'Мои материалы', text: 'Сюда сохраняются вырезки из учебника, заметки и рисунки.' }, + { sel: '#app-sidebar a[href="/pet"]', title: 'Питомец', text: 'Квантик растёт от занятий и серий — заглядывай к нему.' }, + { sel: '.asst-fab', title: 'Я рядом', text: 'Нажми на меня в любой момент, чтобы спросить «как сделать…».' }, + ]; + function vis(el) { return !!(el && el.getBoundingClientRect && el.getBoundingClientRect().width > 0 && el.offsetParent !== null); } + function ensureTourStyles() { + if (document.getElementById('asst-tour-style')) return; + var s = document.createElement('style'); s.id = 'asst-tour-style'; + s.textContent = [ + '.asst-tour-ov{position:fixed;inset:0;z-index:9600;}', + '.asst-tour-ring{position:absolute;border-radius:10px;border:2px solid #9B5DE5;box-shadow:0 0 0 9999px rgba(15,12,30,.55);transition:all .2s ease;pointer-events:none;}', + '.asst-tour-tip{position:fixed;width:280px;max-width:84vw;background:#fff;border-radius:14px;padding:14px 16px;box-shadow:0 18px 50px rgba(15,23,42,.3);font-family:Manrope,system-ui,sans-serif;}', + '.asst-tour-h{font-size:.78rem;font-weight:800;color:#9B5DE5;text-transform:uppercase;letter-spacing:.03em;margin-bottom:5px;}', + '.asst-tour-t{font-size:.86rem;line-height:1.5;color:#28324a;margin-bottom:12px;}', + '.asst-tour-nav{display:flex;align-items:center;gap:8px;}', + '.asst-tour-pg{font-size:.72rem;color:#8a94a6;font-weight:700;}', + ].join(''); + document.head.appendChild(s); + } + function startTour() { + closeBubble(); + ensureTourStyles(); + var steps = TOUR.filter(function (st) { return !st.sel || vis(document.querySelector(st.sel)); }); + if (!steps.length) return; + var i = 0; + var ov = document.createElement('div'); ov.className = 'asst-tour-ov'; ov.setAttribute('data-h2c-ignore', ''); + ov.innerHTML = '
'; + document.body.appendChild(ov); + var ring = ov.querySelector('.asst-tour-ring'); + var tip = ov.querySelector('.asst-tour-tip'); + function finish() { + ov.remove(); + window.removeEventListener('resize', render); + document.removeEventListener('keydown', onKey); + try { LS.assistantDismiss('onboarding'); } catch (e) {} + } + function onKey(e) { if (e.key === 'Escape') finish(); } + function render() { + var st = steps[i]; + var el = st.sel ? document.querySelector(st.sel) : null; + var r = el && vis(el) ? el.getBoundingClientRect() : null; + if (r) { + var pad = 6; + ring.style.display = 'block'; + ring.style.left = (r.left - pad) + 'px'; ring.style.top = (r.top - pad) + 'px'; + ring.style.width = (r.width + pad * 2) + 'px'; ring.style.height = (r.height + pad * 2) + 'px'; + ov.style.background = ''; + var tx = r.right + 14, ty = Math.max(12, r.top); + if (tx + 290 > window.innerWidth) tx = Math.max(12, r.left - 294); // показать слева, если справа не влезает + tip.style.left = Math.min(tx, window.innerWidth - 290) + 'px'; + tip.style.top = Math.min(ty, window.innerHeight - 160) + 'px'; + tip.style.transform = ''; + } else { + ring.style.display = 'none'; + ov.style.background = 'rgba(15,12,30,.55)'; + tip.style.left = '50%'; tip.style.top = '50%'; tip.style.transform = 'translate(-50%,-50%)'; + } + tip.innerHTML = + '
' + esc(st.title) + '
' + + '
' + esc(st.text) + '
' + + '
' + (i + 1) + ' / ' + steps.length + '' + + '' + + (i > 0 ? '' : '') + + '' + + '
'; + tip.querySelector('[data-a="next"]').onclick = function () { if (i === steps.length - 1) finish(); else { i++; render(); } }; + var b = tip.querySelector('[data-a="back"]'); if (b) b.onclick = function () { i--; render(); }; + tip.querySelector('[data-a="skip"]').onclick = finish; + if (el && el.scrollIntoView) { try { el.scrollIntoView({ block: 'nearest' }); } catch (e) {} } + } + window.addEventListener('resize', render); + document.addEventListener('keydown', onKey); + render(); + } + + function onboardingOffer() { + openBubble( + '
' + esc(PET && PET.petName ? PET.petName : 'Квантик') + '
' + + '
Привет! Я ' + esc(PET && PET.petName ? PET.petName : 'Квантик') + '. Показать за минуту, что где в системе?
' + + '
' + + '
', {}); + try { LS.assistantSeen('onboarding'); } catch (e) {} + bubble.querySelector('[data-a="tour"]').onclick = function () { startTour(); }; + bubble.querySelector('[data-a="later"]').onclick = closeBubble; + } + /* ── монтирование ────────────────────────────────────────────────────── */ function mount() { ensureStyles(); @@ -309,6 +407,15 @@ else showGreet(); }; + // Онбординг новичка — приоритетно на дашборде, пока не пройден/не закрыт + var ob = (SRV.seen && SRV.seen['onboarding']) || {}; + if (PAGE === 'dashboard' && !ob.dismissed && (ob.count || 0) < 3) { + var d0 = document.createElement('span'); d0.className = 'asst-dot'; fab.appendChild(d0); + fab.classList.add('pulse'); + setTimeout(function () { if (!openState) onboardingOffer(); }, 1500); + return; + } + // поздравление — сразу; иначе подсказка — через паузу (с учётом дневного лимита) var cel = celebration(); if (cel) { @@ -342,5 +449,8 @@ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { setTimeout(boot, 400); }); else setTimeout(boot, 400); - window.Assistant = { open: function () { if (root) root.querySelector('.asst-fab').click(); } }; + window.Assistant = { + open: function () { if (root) root.querySelector('.asst-fab').click(); }, + tour: function () { startTour(); }, + }; })();