Files
Learn_System/frontend/js/assistant.js
T
Maxim Dolgolyov e1cde834d0 feat(assistant): админ-тумблер, расширенный FAQ, подсказки «что спросить»
- Отдельный фича-флаг 'assistant' (вместо reuse 'pet'): админ может включать/
  выключать помощника в Управление → фичи, независимо от питомца. Дефолт ON.
- FAQ расширен (~50 -> ~60): профиль/пароль, колоды/массовый импорт/SRS,
  прогресс по предмету, поиск, экзамен9, питомец, «без класса», «о чём спросить».
- В «Спроси Квантика» — чипы с примерами вопросов (что можно спросить).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:13:52 +03:00

579 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* assistant.js — «Квантик-ассистент»: плавающий компаньон на всех страницах.
* Подсказки по контексту + проактивные напоминания + поздравления + «Спроси».
* Правиловый движок (без модели). Состояние «видел» — на сервере (assistant_seen),
* дневной лимит/детект событий — в localStorage. Лицо = pet-sprite.js, данные —
* /api/assistant/context и /api/pet. Гейт фичи 'pet' проверяется на сервере.
* Грузится через sidebar.js (app-страницы) и серверный inject (учебник). */
(function () {
if (window.__assistantBooted) return;
window.__assistantBooted = true;
if (window.parent !== window) return; // не в iframe/embed
if (!window.LS || !LS.getToken || !LS.getToken()) return; // только залогиненным
var DAILY_CAP = 2; // консервативно: не больше 2 авто-подсказок в день
var AUTO_DELAY = 7000; // показать подсказку через 7с на странице
var reduceMotion = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;
var SRV = null, PET = null, picked = null, root = null, bubble = null, openState = false;
/* ── helpers ─────────────────────────────────────────────────────────── */
function esc(s) { return (window.LS && LS.escapeHtml) ? LS.escapeHtml(String(s == null ? '' : s)) : String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]; }); }
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) {} }
function todayKey() { return new Date().toISOString().slice(0, 10); }
function pageId() {
var p = location.pathname.replace(/\/+$/, '') || '/';
if (p === '/' || p === '/dashboard') return 'dashboard';
if (p.indexOf('/textbook') === 0) return 'textbook';
if (p === '/classroom') return 'classroom';
if (p === '/board') return 'board';
if (p.indexOf('/exam') === 0) return 'exam';
if (p === '/flashcards') return 'flashcards';
if (p === '/my-materials') return 'materials';
if (p === '/lab') return 'lab';
if (p === '/theory' || p.indexOf('/course') === 0 || p.indexOf('/lesson') === 0) return 'theory';
if (p === '/textbooks') return 'textbooks';
if (p === '/library') return 'library';
if (p === '/knowledge-map') return 'knowledge';
if (p === '/biochem') return 'biochem';
if (p === '/red-book') return 'redbook';
if (p === '/crossword') return 'crossword';
if (p === '/hangman') return 'hangman';
if (p === '/collection') return 'collection';
if (p === '/analytics') return 'analytics';
if (p === '/gradebook') return 'gradebook';
if (p === '/lesson-history') return 'lessonhistory';
if (p === '/question-bank') return 'qbank';
if (p === '/classes') return 'classes';
if (p === '/homework') return 'homework';
if (p === '/pet') return 'pet';
return 'other';
}
var PAGE = pageId();
var SUPPRESS_PAGE = (PAGE === 'classroom'); // не мешаем на живом уроке
function quest(undoneOnly) {
var qs = (PET && PET.quests) || [];
for (var i = 0; i < qs.length; i++) if (!undoneOnly || !qs[i].done) return qs[i];
return null;
}
function activeToday() {
if (!PET) return true;
if (PET.daysSinceLogin === 0) {
var q = (PET.quests || []).find ? (PET.quests || []).find(function (x) { return x.id === 'xp30'; }) : null;
return q ? (q.progress || 0) > 0 : true; // есть прогресс сегодня
}
return false;
}
/* ── каталог правил ──────────────────────────────────────────────────── */
// scope: page | proactive | celebration. when(C) → bool. action(C) → {label,url}|null.
var RULES = [
// — контекстные подсказки генерируются из PAGE_HINTS ниже —
// — проактивные (из реальных данных) —
{ id: 'hw-overdue', scope: 'proactive', cooldownDays: 1, maxShows: 30,
when: function () { return !!(SRV && SRV.homework && SRV.homework.overdue); },
text: function () { return 'Просрочена домашка: «' + (SRV.homework.overdue.title || 'задание') + '». Загляни в раздел.'; },
action: function () { return { label: 'К домашке', url: '/homework' }; } },
{ id: 'hw-soon', scope: 'proactive', cooldownDays: 1, maxShows: 30,
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, 'карточка', 'карточки', 'карточек') + ' — освежим память?'; },
action: function () { return { label: 'Повторить', url: '/flashcards' }; } },
{ id: 'streak-risk', scope: 'proactive', cooldownDays: 1, maxShows: 60,
when: function () { return !!(PET && PET.streakCurrent >= 1 && !activeToday() && new Date().getHours() >= 18); },
text: function () { return 'Серия ' + PET.streakCurrent + ' ' + plural(PET.streakCurrent, 'день', 'дня', 'дней') + ' под угрозой — позанимайся сегодня, чтобы не потерять.'; },
action: function () { return { label: 'Заниматься', url: '/exam-prep/math9' }; } },
{ id: 'quest', scope: 'proactive', cooldownDays: 1, maxShows: 90,
when: function () { return !!(quest(true) && new Date().getHours() >= 16); },
text: function () { var q = quest(true); return 'Остался квест дня: «' + (q.label || 'задание') + '».'; },
action: function () { return null; } },
{ id: 'weak-subject', scope: 'proactive', cooldownDays: 2, maxShows: 40,
when: function () { return !!(SRV && SRV.weakSubject); },
text: function () { var w = SRV.weakSubject; return 'Слабее всего идёт ' + (w.name || 'предмет') + ' (' + w.avg + '%). Потренируемся?'; },
action: function () { return { label: 'Потренироваться', url: '/exam-prep/math9' }; } },
{ id: 'daily-plan', scope: 'proactive', cooldownDays: 1, maxShows: 120,
when: function () { return PAGE === 'dashboard' && dailyPlan().length > 0; },
text: function () { return 'План на сегодня: ' + dailyPlan().join(', ') + '. Начнём?'; },
action: function () { var p = dailyPlanAction(); return p; } },
];
/* План на сегодня — из дневных квестов и карточек к повторению */
function dailyPlan() {
var out = [];
var qs = (PET && PET.quests) || [];
qs.forEach(function (q) { if (!q.done && q.label) out.push(q.label.toLowerCase()); });
if (SRV && SRV.dueCards > 0) out.push('повторить ' + SRV.dueCards + ' ' + plural(SRV.dueCards, 'карточку', 'карточки', 'карточек'));
return out.slice(0, 3);
}
function dailyPlanAction() {
if (SRV && SRV.dueCards > 0) return { label: 'Повторить карточки', url: '/flashcards' };
return { label: 'К занятиям', url: '/exam-prep/math9' };
}
function plural(n, one, few, many) {
var m10 = n % 10, m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) return few;
return many;
}
/* ── контентные подсказки по страницам (генерируются в RULES) ────────── */
var PAGE_HINTS = {
textbook: ['Любой кусок страницы можно вырезать картинкой в «Мои материалы» — кнопка «Вырезать область» внизу.',
'Ссылка из адресной строки ведёт прямо на этот параграф — удобно делиться.'],
exam: ['Три режима: экзамен (как на ЦТ/ЦЭ), тренировка (с разбором) и случайный.',
'После теста доступен разбор: правильные ответы и решения.'],
flashcards: ['Формулы вводятся через KaTeX-палитру, можно добавить и картинку.',
'Система сама напоминает, какие карточки пора повторить.'],
materials: ['Раскладывай материалы по папкам, а поверх фото можно рисовать — кнопка с карандашом.',
'Клик по картинке открывает её в просмотрщике прямо на странице.'],
lab: ['Симуляции запускаются прямо в браузере — ничего ставить не нужно.',
'У оптической скамьи есть режим «Конструктор» — собирай системы из линз и зеркал.'],
dashboard: ['Виджеты на дашборде можно включать и переставлять под себя.',
'Блок «Активность» показывает серию и занятия за недели.'],
textbooks: ['Внутри учебника — главы и параграфы; прочитанное отмечается и идёт в прогресс.'],
library: ['В библиотеке — файлы и материалы от учителя; ищи по предмету.'],
knowledge: ['Карта знаний показывает связи тем — видно, что уже освоено.'],
biochem: ['Интерактивные модели молекул и реакций — крути и разбирай.'],
redbook: ['Красная книга — карточки видов; листай и запоминай.'],
crossword: ['Кроссворд по терминам предмета — отгадывай и получай XP.'],
hangman: ['«Виселица» по терминам — угадывай слово по буквам.'],
collection: ['Коллекция — твои собранные награды и предметы.'],
board: ['Инструмент «выделение» двигает и поворачивает объекты; есть фигуры, формулы и линейка.',
'Страницу доски можно сохранить в «Мои материалы».'],
lessonhistory: ['Архив онлайн-уроков — доска и заметки сохраняются после занятия.'],
qbank: ['Банк вопросов — основа для тестов и заданий.'],
theory: ['Можно создать одиночный «Быстрый урок» без курса.',
'Прогресс по урокам отслеживается — продолжай с того же места.'],
classes: ['Создавай классы и выдавай задания; ученики входят по коду.'],
analytics: ['Аналитика: динамика результатов и слабые темы.'],
gradebook: ['Журнал — оценки и сдачи по заданиям класса.'],
homework: ['Здесь все задания и дедлайны; работу можно загрузить прямо тут.'],
pet: ['Гладь и корми Квантика, закрывай квесты — за это XP и монеты.'],
};
Object.keys(PAGE_HINTS).forEach(function (pg) {
PAGE_HINTS[pg].forEach(function (text, idx) {
RULES.push({
id: 'p-' + pg + '-' + idx, scope: 'page', cooldownDays: 12, maxShows: 2,
when: (function (p) { return function () { return PAGE === p; }; })(pg),
text: (function (t) { return function () { return t; }; })(text),
action: function () { return null; },
});
});
});
/* ── «Совет дня» — ротация фич-открытий (дашборд) ────────────────────── */
var TIPS = [
'фрагмент учебника можно сохранить картинкой в «Мои материалы».',
'в «Теории» есть «Быстрый урок» — один урок без создания курса.',
'поверх сохранённого фото можно рисовать пометки.',
'формулы в карточках и на доске вводятся через KaTeX.',
'серия за ежедневные занятия делает Квантика счастливее.',
'поиск по платформе — Ctrl+K: уроки, курсы, файлы и вопросы.',
'заметку из «Мои материалы» можно превратить во флешкарту.',
'тёмную тему и звуки включишь в Профиль → Настройки.',
];
RULES.push({
id: 'tip-daily', scope: 'page', cooldownDays: 1, maxShows: 200,
when: function () { return PAGE === 'dashboard'; },
text: function () { return 'А ты знал: ' + TIPS[(new Date().getDate()) % TIPS.length]; },
action: function () { return null; },
});
/* ── выбор подсказки ─────────────────────────────────────────────────── */
function eligible(rule) {
if (SUPPRESS_PAGE && rule.scope !== 'celebration') return false;
var s = (SRV && SRV.seen && SRV.seen[rule.id]) || null;
if (s && s.dismissed) return false;
if (s && rule.maxShows && s.count >= rule.maxShows) return false;
if (s && s.lastAt && rule.cooldownDays) {
var days = (Date.now() - Date.parse(s.lastAt + 'Z')) / 86400000;
if (days < rule.cooldownDays) return false;
}
try { return !!rule.when(); } catch (e) { return false; }
}
function pickRule() {
var order = { celebration: 3, proactive: 2, page: 1 };
var cands = RULES.filter(eligible).sort(function (a, b) { return (order[b.scope] || 0) - (order[a.scope] || 0); });
return cands[0] || null;
}
/* ── поздравления (детект по дельте, localStorage) ───────────────────── */
function celebration() {
if (!PET) return null;
var lvl = PET.petLevel || 1;
var prevLvl = parseInt(lsGet('asst_lvl') || '', 10);
if (!isNaN(prevLvl) && lvl > prevLvl) {
lsSet('asst_lvl', String(lvl));
return { id: 'cel-level', mood: 'ecstatic', text: 'Ура! ' + (PET.petName || 'Квантик') + ' дорос до уровня ' + lvl + '! Так держать.' };
}
if (isNaN(prevLvl)) lsSet('asst_lvl', String(lvl));
var ms = [3, 7, 14, 30, 60, 100];
var cur = PET.streakCurrent || 0;
var prevMs = parseInt(lsGet('asst_streak_ms') || '0', 10) || 0;
var hit = 0;
for (var i = 0; i < ms.length; i++) if (cur >= ms[i]) hit = ms[i];
if (hit > prevMs) {
lsSet('asst_streak_ms', String(hit));
return { id: 'cel-streak', mood: 'ecstatic', text: 'Серия ' + hit + ' ' + plural(hit, 'день', 'дня', 'дней') + ' подряд! Огонь.' };
}
if (hit > 0 && prevMs === 0) lsSet('asst_streak_ms', String(hit));
return null;
}
/* ── дневной лимит авто-показов ──────────────────────────────────────── */
function dayCount() {
if (lsGet('asst_day') !== todayKey()) { lsSet('asst_day', todayKey()); lsSet('asst_day_n', '0'); }
return parseInt(lsGet('asst_day_n') || '0', 10) || 0;
}
function bumpDay() { lsSet('asst_day', todayKey()); lsSet('asst_day_n', String(dayCount() + 1)); }
/* ── PetSprite ───────────────────────────────────────────────────────── */
function ensurePet(cb) {
if (window.PetSprite && PetSprite.render) return cb();
var s = document.createElement('script'); s.src = '/js/pet-sprite.js';
s.onload = cb; s.onerror = cb; document.head.appendChild(s);
}
function faceSVG(mood) {
try {
if (window.PetSprite && PetSprite.render) {
return PetSprite.render(PET ? (PET.petLevel || 1) : 1, mood || (PET && PET.mood) || 'happy',
(PET && PET.accessories) || [], (PET && PET.petColor) || 'purple', (PET && PET.streakCurrent) || 0);
}
} catch (e) {}
return '<svg viewBox="0 0 100 100"><circle cx="55" cy="58" r="34" fill="#9B5DE5"/></svg>';
}
/* ── styles ──────────────────────────────────────────────────────────── */
function ensureStyles() {
if (document.getElementById('asst-style')) return;
var s = document.createElement('style'); s.id = 'asst-style';
s.textContent = [
'.asst-root{position:fixed;left:18px;bottom:18px;z-index:8500;font-family:Manrope,system-ui,sans-serif;transition:left .28s cubic-bezier(.4,0,.2,1);}',
// в приложении сдвигаем правее сайдбара (230/62px), чтобы не перекрывать профиль в сайдбаре
'.app-layout ~ .asst-root{left:248px;}',
'.app-layout.sb-collapsed ~ .asst-root{left:80px;}',
'.asst-fab{width:54px;height:54px;border-radius:50%;border:none;background:#fff;cursor:pointer;padding:4px;',
' box-shadow:0 8px 24px rgba(139,92,246,.32);transition:transform .15s;position:relative;display:block;}',
'.asst-fab:hover{transform:translateY(-2px) scale(1.04);}',
'.asst-fab svg{width:100%;height:100%;display:block;}',
'.asst-dot{position:absolute;top:0;right:0;width:13px;height:13px;border-radius:50%;background:#F15BB5;border:2px solid #fff;}',
reduceMotion ? '' : '.asst-fab.pulse{animation:asstPulse 2.2s ease-in-out infinite;}',
'@keyframes asstPulse{0%,100%{box-shadow:0 8px 24px rgba(139,92,246,.32);}50%{box-shadow:0 8px 30px rgba(241,91,181,.5);}}',
'.asst-bubble{position:absolute;left:0;bottom:64px;width:300px;max-width:78vw;background:#fff;border-radius:16px;',
' box-shadow:0 18px 50px rgba(15,23,42,.22);padding:14px 16px;border:1px solid rgba(15,23,42,.07);',
' opacity:0;transform:translateY(8px);pointer-events:none;transition:opacity .18s,transform .18s;}',
'.asst-bubble.open{opacity:1;transform:translateY(0);pointer-events:auto;}',
'.asst-x{position:absolute;top:8px;right:8px;width:26px;height:26px;border:none;background:transparent;color:#8a94a6;',
' cursor:pointer;border-radius:7px;font-size:18px;line-height:1;}',
'.asst-x:hover{background:rgba(15,23,42,.06);color:#0F172A;}',
'.asst-name{font-size:.7rem;font-weight:800;color:#9B5DE5;text-transform:uppercase;letter-spacing:.03em;margin-bottom:6px;}',
'.asst-text{font-size:.86rem;line-height:1.5;color:#28324a;margin-bottom:12px;white-space:pre-line;}',
'.asst-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}',
'.asst-btn{display:inline-flex;align-items:center;gap:6px;padding:7px 13px;border-radius:99px;border:none;cursor:pointer;',
' font:700 .78rem Manrope,sans-serif;background:#9B5DE5;color:#fff;text-decoration:none;}',
'.asst-btn:hover{background:#7e3eca;}',
'.asst-link{background:none;border:none;color:#8a94a6;cursor:pointer;font:600 .76rem Manrope,sans-serif;padding:4px 2px;text-decoration:none;}',
'.asst-link:hover{color:#9B5DE5;}',
'.asst-ask-in{width:100%;box-sizing:border-box;padding:9px 12px;border:1px solid #e2e8f0;border-radius:10px;font:inherit;font-size:.84rem;margin-bottom:10px;}',
'.asst-ans{font-size:.82rem;line-height:1.5;color:#28324a;border-top:1px solid rgba(15,23,42,.06);padding:9px 0;}',
'.asst-ans:first-of-type{border-top:none;}',
'.asst-ans-q{font-weight:700;color:#0F172A;margin-bottom:2px;}',
'.asst-ans-link{display:inline-block;margin-top:4px;color:#9B5DE5;font-weight:700;font-size:.78rem;text-decoration:none;}',
'.asst-ans-sec{font-size:.66rem;font-weight:800;color:#8a94a6;text-transform:uppercase;letter-spacing:.03em;margin:12px 0 2px;}',
'.asst-ans-box{max-height:46vh;overflow:auto;}',
'.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-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;}}',
].join('');
document.head.appendChild(s);
}
/* ── рендер ──────────────────────────────────────────────────────────── */
function setFace(mood) { var f = root.querySelector('.asst-face'); if (f) f.innerHTML = faceSVG(mood); }
function openBubble(html, opts) {
opts = opts || {};
bubble.innerHTML = '<button class="asst-x" aria-label="Закрыть">&times;</button>' + html;
bubble.querySelector('.asst-x').onclick = closeBubble;
bubble.classList.add('open');
openState = true;
root.querySelector('.asst-fab').classList.remove('pulse');
root.querySelector('.asst-dot') && root.querySelector('.asst-dot').remove();
if (opts.mood) setFace(opts.mood);
}
function closeBubble() { bubble.classList.remove('open'); openState = false; setFace(); }
function hintHtml(rule) {
var act = null; try { act = rule.action(); } catch (e) {}
var actHtml = act && act.url ? '<a class="asst-btn" href="' + esc(act.url) + '">' + esc(act.label || 'Открыть') + '</a>' : '';
var dismiss = (rule.scope !== 'celebration') ? '<button class="asst-link" data-a="dismiss">Не показывать</button>' : '';
return '<div class="asst-name">' + esc(PET && PET.petName ? PET.petName : 'Квантик') + '</div>' +
'<div class="asst-text">' + esc(rule.text ? rule.text() : rule.text) + '</div>' +
'<div class="asst-actions">' + actHtml +
'<button class="asst-link" data-a="ok">Понятно</button>' + dismiss +
'<button class="asst-link" data-a="ask" style="margin-left:auto">Спросить</button></div>';
}
function showRule(rule, isCelebration) {
openBubble(hintHtml(rule), { mood: rule.mood });
// отметить показ (для page/proactive — на сервере; celebration — локально через дельту)
if (!isCelebration) { try { LS.assistantSeen(rule.id); } catch (e) {} bumpDay(); }
bubble.querySelector('[data-a="ok"]').onclick = closeBubble;
var dz = bubble.querySelector('[data-a="dismiss"]');
if (dz) dz.onclick = function () { try { LS.assistantDismiss(rule.id); } catch (e) {} closeBubble(); };
bubble.querySelector('[data-a="ask"]').onclick = openAsk;
}
function greetHtml() {
return '<div class="asst-name">' + esc(PET && PET.petName ? PET.petName : 'Квантик') + '</div>' +
'<div class="asst-text">Привет! Я помогу разобраться в системе. Спроси, как что-то сделать, или пройди короткий тур.</div>' +
'<div class="asst-actions"><button class="asst-btn" data-a="ask">Спросить</button>' +
'<button class="asst-link" data-a="tour">Тур по системе</button>' +
'<button class="asst-link" data-a="ok" style="margin-left:auto">Закрыть</button></div>';
}
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(); };
}
/* ── «Спроси Квантика» ───────────────────────────────────────────────── */
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>';
openBubble(
'<div class="asst-name">Спроси Квантика</div>' +
'<input class="asst-ask-in" type="text" placeholder="Например: как сохранить кусок учебника" maxlength="200" />' +
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(); });
});
}
function runAsk(q, box) {
q = (q || '').trim();
if (q.length < 3) { box.innerHTML = ''; return; }
box.innerHTML = '<div class="asst-empty">Ищу…</div>';
Promise.all([
LS.assistantAsk(q).catch(function () { return { answers: [] }; }),
(LS.globalSearch ? LS.globalSearch(q, 'all', 4) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }),
]).then(function (res) {
var ans = (res[0] && res[0].answers) || [];
var found = (res[1] && res[1].results) || [];
var html = '';
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('');
}
if (found.length) {
html += '<div class="asst-ans-sec">На платформе</div>';
html += 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>';
}).catch(function () { box.innerHTML = '<div class="asst-empty">Не удалось получить ответ.</div>'; });
}
/* ── Ф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 = '<div class="asst-tour-ring"></div><div class="asst-tour-tip"></div>';
document.body.appendChild(ov);
var ring = ov.querySelector('.asst-tour-ring');
var tip = ov.querySelector('.asst-tour-tip');
ov.addEventListener('click', function (e) { if (e.target === ov) finish(); }); // клик по фону — выход
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;
// сперва проматываем элемент в зону видимости, ПОТОМ меряем (иначе позиция стухшая)
if (el && vis(el) && el.scrollIntoView) { try { el.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (e) {} }
var r = el && vis(el) ? el.getBoundingClientRect() : null;
// контент сначала — чтобы измерить реальный размер подсказки и привязать кнопки
tip.innerHTML =
'<div class="asst-tour-h">' + esc(st.title) + '</div>' +
'<div class="asst-tour-t">' + esc(st.text) + '</div>' +
'<div class="asst-tour-nav"><span class="asst-tour-pg">' + (i + 1) + ' / ' + steps.length + '</span>' +
'<span style="margin-left:auto"></span>' +
(i > 0 ? '<button class="asst-link" data-a="back">Назад</button>' : '') +
'<button class="asst-link" data-a="skip">Пропустить</button>' +
'<button class="asst-btn" data-a="next">' + (i === steps.length - 1 ? 'Готово' : 'Далее') + '</button></div>';
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;
// позиционирование: подсказка ВСЕГДА целиком в пределах экрана
var W = window.innerWidth, H = window.innerHeight;
var tw = tip.offsetWidth || 280, th = tip.offsetHeight || 150;
tip.style.transform = '';
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;
if (tx + tw > W - 12) tx = r.left - tw - 14; // не влезает справа — показать слева
tx = Math.max(12, Math.min(tx, W - tw - 12));
var ty = Math.max(12, Math.min(r.top, H - th - 12));
tip.style.left = tx + 'px'; tip.style.top = ty + 'px';
} else {
ring.style.display = 'none';
ov.style.background = 'rgba(15,12,30,.55)';
tip.style.left = Math.max(12, (W - tw) / 2) + 'px';
tip.style.top = Math.max(12, (H - th) / 2) + 'px';
}
}
window.addEventListener('resize', render);
document.addEventListener('keydown', onKey);
render();
}
function onboardingOffer() {
openBubble(
'<div class="asst-name">' + esc(PET && PET.petName ? PET.petName : 'Квантик') + '</div>' +
'<div class="asst-text">Привет! Я ' + esc(PET && PET.petName ? PET.petName : 'Квантик') + '. Показать за минуту, что где в системе?</div>' +
'<div class="asst-actions"><button class="asst-btn" data-a="tour">Показать тур</button>' +
'<button class="asst-link" data-a="later">Позже</button></div>', {});
try { LS.assistantSeen('onboarding'); } catch (e) {}
bubble.querySelector('[data-a="tour"]').onclick = function () { startTour(); };
bubble.querySelector('[data-a="later"]').onclick = closeBubble;
}
/* ── монтирование ────────────────────────────────────────────────────── */
function mount() {
ensureStyles();
root = document.createElement('div');
root.className = 'asst-root';
root.setAttribute('data-h2c-ignore', ''); // не попадать в скриншоты учебника
root.innerHTML =
'<div class="asst-bubble" role="dialog" aria-live="polite"></div>' +
'<button class="asst-fab" aria-label="Помощник Квантик"><span class="asst-face">' + faceSVG() + '</span></button>';
document.body.appendChild(root);
bubble = root.querySelector('.asst-bubble');
var fab = root.querySelector('.asst-fab');
fab.onclick = function () {
if (openState) return closeBubble();
if (picked) showRule(picked, picked.scope === 'celebration');
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) {
picked = { id: cel.id, scope: 'celebration', text: cel.text, mood: cel.mood, action: function () { return null; } };
var dot = document.createElement('span'); dot.className = 'asst-dot'; fab.appendChild(dot);
fab.classList.add('pulse');
setTimeout(function () { if (!openState) showRule(picked, true); }, 1200);
return;
}
picked = pickRule();
if (picked && dayCount() < DAILY_CAP) {
var d = document.createElement('span'); d.className = 'asst-dot'; fab.appendChild(d);
fab.classList.add('pulse');
setTimeout(function () { if (!openState && picked) showRule(picked, false); }, AUTO_DELAY);
}
}
/* ── boot ────────────────────────────────────────────────────────────── */
function boot() {
if (!document.body) { return setTimeout(boot, 200); }
LS.assistantContext().then(function (ctx) {
SRV = ctx || {};
if (SRV.enabled === false) return; // выключено пользователем
return (LS.api ? LS.api('/api/pet') : Promise.resolve(null)).then(function (pet) {
PET = pet || null;
ensurePet(mount);
});
}).catch(function () { /* фича выключена / нет доступа — тихо выходим */ });
}
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(); },
tour: function () { startTour(); },
};
})();