Files
Learn_System/frontend/js/assistant.js
T
Maxim Dolgolyov 4224a22092 feat(assistant): источники в ответах, режим-наставник, оценки, утренний бриф
- Источники: RAG возвращает sources (slug/§/ref), под ответом ссылка «по учебнику
  X, §N» на параграф (миграция 064: section_ref в textbook_chunks; headless-индексатор
  его заполняет). Статический индексатор теперь не затирает headless-данные.
- Режим-наставник: переключатель Ответ/Подсказка/Проверить решение в «Спроси»
  (mode в ask + промпт); на карточке экзамена кнопка «Подсказка» (mode hint).
- Оценка ответов: лайк/дизлайк под ответом (assistant_feedback) + сводка в админке.
- Утренний бриф на дашборде: «занимался N из 5 дн + план на сегодня».

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

779 lines
54 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: 'brief', scope: 'proactive', cooldownDays: 1, maxShows: 300,
when: function () { return PAGE === 'dashboard' && new Date().getHours() < 12; },
text: function () {
var plan = dailyPlan(), days = activeDaysThisWeek();
var s = 'Доброе утро! ' + (days != null ? 'На этой неделе ты занимался ' + days + ' из 5 дн. ' : '');
return s + (plan.length ? 'Сегодня: ' + plan.join(', ') + '.' : 'Сегодня можно начать с короткого теста.');
},
action: function () { return dailyPlanAction(); } },
{ id: 'daily-plan', scope: 'proactive', cooldownDays: 1, maxShows: 120,
when: function () { return PAGE === 'dashboard' && new Date().getHours() >= 12 && 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 activeDaysThisWeek() { try { var w = (PET && PET.weeklyXP) || []; return w.length ? w.filter(function (d) { return (d.xp || 0) > 0; }).length : null; } catch (e) { return null; } }
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-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-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-modes{display:flex;gap:6px;margin:2px 0 8px;}',
'.asst-mode{flex:1;border:1px solid #e2e8f0;background:#f8fafc;border-radius:8px;padding:5px 6px;font:700 .68rem Manrope,sans-serif;color:#475569;cursor:pointer;}',
'.asst-mode.on{background:#9B5DE5;border-color:#9B5DE5;color:#fff;}',
'.asst-src{align-self:flex-start;font-size:.72rem;color:#8a94a6;}',
'.asst-src a{color:#7e3eca;font-weight:700;text-decoration:none;}',
'.asst-fb{align-self:flex-start;display:flex;gap:6px;}',
'.asst-fb button{border:1px solid #e2e8f0;background:#fff;border-radius:7px;width:30px;height:24px;cursor:pointer;color:#8a94a6;display:inline-flex;align-items:center;justify-content:center;}',
'.asst-fb button:hover{border-color:#9B5DE5;color:#9B5DE5;}',
'.asst-fb button.on{border-color:#9B5DE5;color:#9B5DE5;background:rgba(155,93,229,.1);}',
'.asst-fb svg{width:13px;height:13px;}',
'.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(); };
}
/* ── рендер 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 = '';
var _role = 'student';
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 TEACHER_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 = '<div class="asst-rich"></div>'; renderRich(d.querySelector('.asst-rich'), m.content); }
else d.textContent = m.content;
chatEl.appendChild(d);
});
chatEl.scrollTop = chatEl.scrollHeight;
}
var FB_UP = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v11"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>';
var FB_DOWN = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(180deg)"><path d="M7 10v11"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>';
var MODE_PH = { answer: 'Спроси что угодно: «объясни…», «как…»', hint: 'Задача, по которой нужна подсказка…', check: 'Вставь своё решение — проверю…' };
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>';
var sug = (_role === 'teacher' || _role === 'admin') ? TEACHER_SUGGESTIONS : SUGGESTIONS;
var chips = '<div class="asst-chips">' + ctxBtns +
sug.map(function (q) { return '<button class="asst-chip" type="button">' + esc(q) + '</button>'; }).join('') + '</div>';
var modes = '<div class="asst-modes">' +
'<button class="asst-mode on" data-m="answer">Ответ</button>' +
'<button class="asst-mode" data-m="hint">Подсказка</button>' +
'<button class="asst-mode" data-m="check">Проверить решение</button></div>';
openBubble(
'<div class="asst-name">Спроси Квантика' + (_chat.length ? '<button class="asst-link" data-a="clear" style="float:right;font-weight:600;margin-right:24px">Очистить</button>' : '') + '</div>' +
'<div class="asst-chat"></div>' + chips + modes +
'<input class="asst-ask-in" type="text" placeholder="' + MODE_PH.answer + '" maxlength="500" />', {});
var inp = bubble.querySelector('.asst-ask-in');
var chatEl = bubble.querySelector('.asst-chat');
var chipsEl = bubble.querySelector('.asst-chips');
var mode = 'answer';
renderChat(chatEl);
if (_chat.length) chipsEl.style.display = 'none';
function go(q, context, m) { if (chipsEl) chipsEl.style.display = 'none'; inp.value = ''; send(q, context, chatEl, m || mode); }
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') go(inp.value); });
bubble.querySelectorAll('.asst-mode').forEach(function (b) {
b.addEventListener('click', function () {
mode = b.getAttribute('data-m');
bubble.querySelectorAll('.asst-mode').forEach(function (x) { x.classList.toggle('on', x === b); });
inp.placeholder = MODE_PH[mode] || MODE_PH.answer; inp.focus();
});
});
bubble.querySelectorAll('.asst-chip').forEach(function (c) {
c.addEventListener('click', function () {
var ctx = c.getAttribute('data-ctx');
if (ctx === 'sel') return go('Объясни простыми словами и приведи пример.', sel, 'answer');
if (ctx === 'sec') return go('Объясни простыми словами, о чём этот параграф, и выдели главное.', pc && pc.text, 'answer');
if (ctx === 'sum') return go('Сделай краткий конспект этого материала: 4–6 главных пунктов.', pc && pc.text, 'answer');
if (ctx === 'cards') { if (chipsEl) chipsEl.style.display = 'none'; return makeFlashcards(pc, chatEl); }
go(c.textContent, null, 'answer');
});
});
var clr = bubble.querySelector('[data-a="clear"]');
if (clr) clr.onclick = function () { _chat = []; openAsk(); };
if (prefill && prefill.q) go(prefill.q, prefill.context, prefill.mode);
else inp.focus();
}
function srcUrl(s) { return '/textbook/' + s.slug + (s.ref ? '#sec-' + s.ref : ''); }
function send(q, context, chatEl, mode) {
q = (q || '').trim();
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 = mode === 'check' ? 'Проверяю…' : 'Думаю…'; chatEl.appendChild(ph);
chatEl.scrollTop = chatEl.scrollHeight;
Promise.all([
LS.assistantAsk(q, context, history, mode).catch(function () { return { answers: [] }; }),
(LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }),
]).then(function (res) {
ph.remove();
var model = res[0] && res[0].answer;
var ans = (res[0] && res[0].answers) || [];
var sources = (res[0] && res[0].sources) || [];
var found = (res[1] && res[1].results) || [];
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 = '<div class="asst-rich"></div>'; chatEl.appendChild(d);
renderRich(d.querySelector('.asst-rich'), content);
// источники (RAG)
if (model && sources.length) {
var sc = document.createElement('div'); sc.className = 'asst-src';
sc.innerHTML = 'Источник: ' + sources.map(function (s) { return '<a href="' + esc(srcUrl(s)) + '">' + esc(s.title) + (s.section ? ', ' + esc(s.section) : '') + '</a>'; }).join('; ');
chatEl.appendChild(sc);
}
// ссылки FAQ/платформа
var links = '';
if (!model && ans.length) links += ans.slice(0, 2).filter(function (a) { return a.url; }).map(function (a) { return '<a class="asst-ans-link" href="' + esc(a.url) + '">' + esc(a.q) + '</a>'; }).join(' · ');
if (found.length) links += (links ? '<br>' : '') + '<span style="color:#8a94a6">На платформе: </span>' + found.slice(0, 3).map(function (f) { return '<a class="asst-ans-link" href="' + esc(f.url || '#') + '">' + esc(f.title || '…') + '</a>'; }).join(' · ');
if (links) { var l = document.createElement('div'); l.className = 'asst-msg-links'; l.innerHTML = links; chatEl.appendChild(l); }
// оценка ответа
if (model) {
var fb = document.createElement('div'); fb.className = 'asst-fb';
fb.innerHTML = '<button data-r="1" title="Полезно">' + FB_UP + '</button><button data-r="-1" title="Не помогло">' + FB_DOWN + '</button>';
fb.querySelectorAll('button').forEach(function (b) {
b.addEventListener('click', function () {
if (fb.dataset.done) return; fb.dataset.done = '1';
b.classList.add('on');
try { LS.assistantFeedback(Number(b.getAttribute('data-r')), q); } catch (e) {}
});
});
chatEl.appendChild(fb);
}
chatEl.scrollTop = chatEl.scrollHeight;
}).catch(function () { ph.textContent = 'Не удалось получить ответ.'; });
}
/* ── «Флешкарты из параграфа» — модель → колода через API флешкарт ─────── */
function makeFlashcards(pc, chatEl) {
var note = msgEl('assistant');
if (!pc || !pc.text) { note.innerHTML = '<div class="asst-rich">Открой параграф учебника, чтобы сделать карточки.</div>'; chatEl.appendChild(note); return; }
note.innerHTML = '<div class="asst-rich">Готовлю карточки…</div>'; 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');
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) {
note.innerHTML = '<div class="asst-rich">Готово: создано ' + n + ' ' + plural(n, 'карточка', 'карточки', 'карточек') +
'. <a class="asst-ans-link" href="/flashcards">Открыть флешкарты</a></div>';
}).catch(function () { note.innerHTML = '<div class="asst-rich">Не удалось сделать карточки. Попробуй позже.</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();
};
// Запоминаем выделенный пользователем текст (для «Объяснить выделенное»)
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']) || {};
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 || {};
_role = (SRV && SRV.role) || 'student';
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(); },
// открыть «Спроси» с готовым вопросом и контекстом (для интеграций, напр. экзамен)
ask: function (q, context, opts) { if (root && bubble) openAsk({ q: q, context: context, mode: opts && opts.mode }); },
explainSelection: function () { if (_lastSel) window.Assistant.ask('Объясни простыми словами и приведи пример.', _lastSel); },
};
})();