Files
Learn_System/frontend/js/assistant.js
T
Maxim Dolgolyov c49077abbc feat(assistant): живость питомца — лицо реагирует на диалог (фича 6/6)
Лицо Квантика в шапке чата (PetSprite) меняет настроение по состоянию:
- думает (нейтральное + лёгкая анимация-покачивание asstThink) пока ждём/стримим
- радуется (happy) на готовый ответ; грустит (sad) на ошибку/лимит/«не нашёл»
- ликует (ecstatic) на сгенерированный тест и нарисованную картинку
Вплетено в send/sendNonStream/makeQuiz/drawInChat через setNameFace().
Анимация уважает prefers-reduced-motion. Только frontend.

Серия из 6 фич доработки Квантика завершена (стриминг, контекст урока,
сократический режим, авто-здоровье провайдеров, генерация тестов, живость).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:12:49 +03:00

1039 lines
76 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]; }); }
// Безопасный href: только внутренний путь /… или https://… — блокирует javascript:/data:.
function safeUrl(u) { u = String(u == null ? '' : u).trim(); return (/^\//.test(u) || /^https?:\/\//i.test(u)) ? u : '#'; }
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-name-face{display:inline-block;transition:transform .2s;}',
reduceMotion ? '' : '.asst-name-face.asst-think{animation:asstThink 1.3s ease-in-out infinite;transform-origin:60% 70%;}',
'@keyframes asstThink{0%,100%{transform:scale(1) rotate(0);}50%{transform:scale(1.08) rotate(-4deg);}}',
'.asst-bubble{position:absolute;left:0;bottom:66px;width:380px;max-width:92vw;background:#fff;border-radius:18px;',
' box-shadow:0 20px 56px rgba(15,23,42,.24);padding:15px 17px;border:1px solid rgba(15,23,42,.07);',
' opacity:0;transform:translateY(8px) scale(.98);pointer-events:none;transition:opacity .18s,transform .18s;transform-origin:bottom left;}',
'.asst-name-face{display:inline-block;width:20px;height:20px;vertical-align:-4px;margin-right:7px;}',
'.asst-name-face svg{width:100%;height:100%;display:block;}',
'.asst-memnote{font-size:.66rem;color:#9aa5b4;margin-top:9px;line-height:1.45;border-top:1px solid rgba(15,23,42,.05);padding-top:8px;}',
'.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-rich{overflow-wrap:anywhere;}',
'.asst-rich .katex-display{margin:6px 0;overflow-x:auto;overflow-y:hidden;padding-bottom:4px;max-width:100%;}',
'.asst-rich .katex-display::-webkit-scrollbar{height:6px;}',
'.asst-rich .katex-display::-webkit-scrollbar-thumb{background:rgba(15,23,42,.18);border-radius:99px;}',
'.asst-rich .katex{max-width:100%;}',
// мигающий курсор во время стриминга ответа (CSS-каретка, без глифа)
'.asst-streaming{white-space:pre-wrap;}',
'.asst-streaming::after{content:"";display:inline-block;width:2px;height:1em;vertical-align:-2px;margin-left:2px;background:#9B5DE5;animation:asst-blink 1s steps(2) infinite;}',
'@keyframes asst-blink{50%{opacity:0;}}',
'.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);max-width:100%;}',
'.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;}',
'html.asst-exam-on .tc-asst-btn{display:inline-flex !important;}',
'.asst-empty{font-size:.82rem;color:#8a94a6;padding:6px 0;}',
'.asst-mem-body{font-size:.82rem;color:#28324a;max-height:46vh;overflow:auto;}',
'.asst-mem-prof{background:rgba(155,93,229,.07);border:1px solid rgba(155,93,229,.18);border-radius:10px;padding:9px 12px;line-height:1.75;margin-bottom:10px;}',
'.asst-mem-notes-h{font-size:.66rem;font-weight:800;color:#8a94a6;text-transform:uppercase;letter-spacing:.03em;margin:6px 0 4px;}',
'.asst-mem-note{display:flex;align-items:center;gap:8px;justify-content:space-between;padding:6px 0;border-bottom:1px solid rgba(15,23,42,.06);}',
'.asst-mem-note:last-of-type{border-bottom:none;}',
'.asst-mem-x{border:none;background:none;color:#b4bcc8;cursor:pointer;font-size:1.15rem;line-height:1;padding:0 4px;}',
'.asst-mem-x:hover{color:#e0335e;}',
'.asst-mem-off{font-size:.82rem;color:#8a94a6;padding:10px 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 setNameFace(mood) { var f = bubble && bubble.querySelector && bubble.querySelector('.asst-name-face'); if (f) { f.innerHTML = faceSVG(mood === 'thinking' ? 'neutral' : mood); f.classList.toggle('asst-think', mood === 'thinking'); } }
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(safeUrl(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 src = String(text || '');
// ответ мог оборваться по лимиту токенов посреди $$…$$ — не показываем сырой LaTeX, обрезаем хвост
var dd = src.match(/\$\$/g);
if (dd && dd.length % 2 === 1) { var li = src.lastIndexOf('$$'); src = src.slice(0, li).replace(/[\s\\]+$/, '') + ' …'; }
var math = [];
var protectedText = src.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, kind: 'textbook' };
}
}
if (PAGE === 'theory') {
var c = document.querySelector('.lesson-content, .lsn-content, .lesson-body, #lesson-content, article.lesson, .course-lesson, .lesson-view');
if (c) {
var lt = document.querySelector('h1, .lsn-title, .lesson-title');
var ltitle = (lt && lt.textContent.trim()) || (document.title || 'Урок').split('·')[0].trim();
var ltext = (c.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 3500);
if (ltext.length > 60) return { title: ltitle, text: ltext, kind: 'lesson' };
}
}
} catch (e) {}
return null;
}
// Лёгкий ситуативный контекст для ЛЮБОГО вопроса — где сейчас ученик (заголовок+раздел).
var PAGE_LABEL = { textbook: 'учебник', theory: 'урок/курс', exam: 'экзамен или тест', flashcards: 'флешкарты',
lab: 'лаборатория', homework: 'домашние задания', dashboard: 'главная', knowledge: 'карта знаний',
library: 'библиотека', analytics: 'аналитика', gradebook: 'журнал', qbank: 'банк вопросов' };
function pageHint() {
try {
var label = PAGE_LABEL[PAGE] || '';
var hEl = document.querySelector('.sec.active .sec-h, h1, .lsn-title, .lesson-title, .page-title');
var title = (hEl && hEl.textContent.trim()) || (document.title || '').split('·')[0].split('|')[0].trim();
title = title.replace(/\s+/g, ' ').slice(0, 120);
if (!title && !label) return '';
return 'Ученик сейчас на странице платформы' + (label ? ' («' + label + '»)' : '') + (title ? ': «' + title + '»' : '') + '. Учитывай это, если вопрос относится к материалу страницы.';
} catch (e) { return ''; }
}
/* ── «Спроси Квантика» ───────────────────────────────────────────────── */
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.img) d.innerHTML = '<img src="' + m.img + '" alt="" style="width:100%;border-radius:10px;display:block">';
else 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: 'Вставь своё решение — проверю…', draw: 'Опиши картинку: «кот-учёный, плоская иллюстрация»', quiz: 'Тема или текст — сгенерирую вопросы для банка' };
function openAsk(prefill) {
var sel = _lastSel, pc = getPageContext();
var noun = pc && pc.kind === 'lesson' ? 'этот урок' : 'этот параграф';
var noun2 = pc && pc.kind === 'lesson' ? 'урока' : 'параграфа';
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">Объяснить ' + noun + '</button>' +
'<button class="asst-chip asst-chip-ctx" data-ctx="sum" type="button">Конспект ' + noun2 + '</button>' +
'<button class="asst-chip asst-chip-ctx" data-ctx="cards" type="button">Флешкарты из ' + noun2 + '</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 isTch = (_role === 'teacher' || _role === 'admin');
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>' +
(isTch ? '<button class="asst-mode" data-m="quiz">Тест в банк</button>' : '') +
'<button class="asst-mode" data-m="draw">Нарисовать</button></div>';
openBubble(
'<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Спроси Квантика' +
'<button class="asst-link" data-a="mem" style="float:right;font-weight:600;margin-right:24px">Память</button>' +
(_chat.length ? '<button class="asst-link" data-a="clear" style="float:right;font-weight:600;margin-right:8px">Очистить</button>' : '') + '</div>' +
'<div class="asst-chat"></div>' + chips + modes +
'<input class="asst-ask-in" type="text" placeholder="' + MODE_PH.answer + '" maxlength="500" />' +
'<div class="asst-memnote">Я помню последние ~6 сообщений этого разговора — как рабочая память: что было раньше, понимаю; старое постепенно забывается. «Очистить» — начать с чистого листа.</div>', {});
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';
// свободный вопрос (context не задан явно) → подмешиваем лёгкий ситуативный контекст страницы
function go(q, context, m) { if (chipsEl) chipsEl.style.display = 'none'; inp.value = ''; if (context == null) context = pageHint() || undefined; 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(); };
var memBtn = bubble.querySelector('[data-a="mem"]');
if (memBtn) memBtn.onclick = openMemory;
if (prefill && prefill.q) go(prefill.q, prefill.context, prefill.mode);
else inp.focus();
}
/* ── «Что я о тебе помню» ── */
function openMemory() {
LS.assistantMemory().then(function (m) {
if (!m) return;
var p = m.profile || {}, prof = [];
if (p.exam) prof.push('Готовишься к экзамену' + (p.exam.date ? ' (до ' + esc(p.exam.date) + ')' : ''));
if (p.weakSubjects && p.weakSubjects.length) prof.push('Слабые предметы: ' + p.weakSubjects.map(function (s) { return esc(s.name) + ' ' + s.avg + '%'; }).join(', '));
if (p.weakTopics && p.weakTopics.length) prof.push('Трудные темы: ' + p.weakTopics.map(function (t) { return esc(t.topic) + ' ' + t.rate + '%'; }).join(', '));
if (p.streak >= 3) prof.push('Серия занятий: ' + p.streak + ' дн.');
var notes = (m.notes || []).map(function (n) { return '<div class="asst-mem-note"><span>' + esc(n.text) + '</span><button class="asst-mem-x" data-id="' + n.id + '" title="Забыть">&times;</button></div>'; }).join('');
var body = m.enabled === false
? '<div class="asst-mem-off">Персональная память выключена администратором.</div>'
: '<div class="asst-mem-body">' +
(prof.length ? '<div class="asst-mem-prof">' + prof.map(function (x) { return '<div>• ' + x + '</div>'; }).join('') + '</div>' : '') +
(notes ? '<div class="asst-mem-notes-h">Заметки</div>' + notes : (prof.length ? '' : '<div class="asst-empty">Пока я ничего не запомнил — позанимайся, и здесь появятся слабые темы и заметки.</div>')) +
((notes || prof.length) ? '<button class="asst-link" data-a="forget" style="margin-top:12px;color:#e0335e">Забыть всё</button>' : '') +
'</div>';
openBubble(
'<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Что я о тебе помню' +
'<button class="asst-link" data-a="back" style="float:right;font-weight:600;margin-right:24px">← Назад</button></div>' +
body +
'<div class="asst-memnote">Память помогает объяснять под тебя. Видна только тебе; учитель видит лишь общие слабые темы.</div>', {});
var bk = bubble.querySelector('[data-a="back"]'); if (bk) bk.onclick = function () { openAsk(); };
var fg = bubble.querySelector('[data-a="forget"]'); if (fg) fg.onclick = function () { LS.assistantMemoryClear().then(openMemory); };
bubble.querySelectorAll('.asst-mem-x').forEach(function (b) { b.onclick = function () { LS.assistantMemoryClear(b.getAttribute('data-id')).then(openMemory); }; });
});
}
function srcUrl(s) { return '/textbook/' + s.slug + (s.ref ? '#sec-' + s.ref : ''); }
function drawInChat(prompt, chatEl) {
prompt = (prompt || '').trim();
if (prompt.length < 3) return;
_chat.push({ role: 'user', content: 'Нарисуй: ' + prompt });
var u = msgEl('user'); u.textContent = 'Нарисуй: ' + prompt; chatEl.appendChild(u);
var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.textContent = 'Рисую картинку…'; chatEl.appendChild(ph);
chatEl.scrollTop = chatEl.scrollHeight; setNameFace('thinking');
LS.imageGen(prompt).then(function (r) {
ph.remove();
var d = msgEl('assistant');
if (r && r.url) { d.innerHTML = '<img src="' + r.url + '" alt="" style="width:100%;border-radius:10px;display:block">'; _chat.push({ role: 'assistant', content: '[картинка]', img: r.url }); setNameFace('ecstatic'); }
else { d.textContent = 'Не получилось нарисовать.'; setNameFace('sad'); }
chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight;
}).catch(function (err) {
ph.remove(); var d = msgEl('assistant'); setNameFace('sad');
d.textContent = (err && err.data && err.data.error) || 'Не получилось нарисовать.';
chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight;
});
}
function send(q, context, chatEl, mode) {
q = (q || '').trim();
if (q.length < 2) return;
if (mode === 'draw') return drawInChat(q, chatEl);
if (mode === 'quiz') return makeQuiz(q, chatEl);
// стриминг недоступен (старый кэш api.js / нет ReadableStream) — обычный путь
if (!LS.assistantAskStream || typeof ReadableStream === 'undefined') return sendNonStream(q, context, chatEl, mode);
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;
setNameFace('thinking');
var searchP = (LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; });
var meta = { answers: [], sources: [] }, full = '', msgD = null, richEl = null, streamed = false, finalized = false;
function ensureMsg() {
if (msgD) return;
if (ph.parentNode) ph.remove();
msgD = msgEl('assistant'); msgD.innerHTML = '<div class="asst-rich asst-streaming"></div>';
richEl = msgD.querySelector('.asst-rich'); chatEl.appendChild(msgD);
}
function finalize(done) {
if (finalized) return; finalized = true;
done = done || {};
var src = done.source;
if ((src === 'limit' || src === 'error') && !full) {
_chat.pop();
if (msgD) msgD.remove(); if (ph.parentNode) ph.remove();
var em = msgEl('assistant'); em.className += ' asst-msg-ph'; em.textContent = done.answer || 'Сейчас не получилось. Попробуй ещё раз.';
chatEl.appendChild(em); chatEl.scrollTop = chatEl.scrollHeight; setNameFace('sad'); return;
}
var isModel = src === 'model' && (full || done.answer);
setNameFace(isModel ? 'happy' : 'neutral');
searchP.then(function (sres) {
var found = (sres && sres.results) || [];
var ansArr = (done.answers && done.answers.length ? done.answers : meta.answers) || [];
var sources = done.sources || meta.sources || [];
var content = isModel ? (full || done.answer) : ((ansArr[0] && (ansArr[0].q + '\n' + ansArr[0].a)) || 'Не нашёл точного ответа. Попробуй переформулировать или поищи (Ctrl+K).');
ensureMsg(); richEl.classList.remove('asst-streaming');
_chat.push({ role: 'assistant', content: content });
renderRich(richEl, content);
if (isModel && sources.length) {
var sc = document.createElement('div'); sc.className = 'asst-src';
sc.innerHTML = 'Источник: ' + sources.map(function (s) { return '<a href="' + esc(safeUrl(srcUrl(s))) + '">' + esc(s.title) + (s.section ? ', ' + esc(s.section) : '') + '</a>'; }).join('; ');
chatEl.appendChild(sc);
}
var links = '';
if (!isModel && ansArr.length) links += ansArr.slice(0, 2).filter(function (a) { return a.url; }).map(function (a) { return '<a class="asst-ans-link" href="' + esc(safeUrl(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(safeUrl(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 (isModel) {
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;
});
}
LS.assistantAskStream(q, context, history, mode, {
onMeta: function (m) { if (m.answers) meta.answers = m.answers; if (m.sources) meta.sources = m.sources; },
onDelta: function (t) { streamed = true; ensureMsg(); full += t; richEl.textContent = full; chatEl.scrollTop = chatEl.scrollHeight; },
onDone: function (o) { finalize(o); },
}).then(function () { if (!finalized) finalize({ source: full ? 'model' : 'faq' }); })
.catch(function () {
if (finalized) return;
if (!streamed) { if (ph.parentNode) ph.remove(); _chat.pop(); sendNonStream(q, context, chatEl, mode); }
else finalize({ source: 'model' });
});
}
function sendNonStream(q, context, chatEl, mode) {
q = (q || '').trim();
if (q.length < 2) return;
if (mode === 'draw') return drawInChat(q, chatEl);
if (mode === 'quiz') return makeQuiz(q, chatEl);
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;
setNameFace('thinking');
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 r0 = res[0] || {};
// лимит/ошибка ИИ — не ломаем память диалога: убираем последний вопрос, показываем сообщение
if (r0.source === 'limit' || r0.source === 'error') {
_chat.pop();
var em = msgEl('assistant'); em.className += ' asst-msg-ph'; em.textContent = r0.answer || 'Сейчас не получилось. Попробуй ещё раз.';
chatEl.appendChild(em); chatEl.scrollTop = chatEl.scrollHeight; setNameFace('sad'); return;
}
var model = r0.source === 'model' ? r0.answer : null;
setNameFace(model ? 'happy' : 'neutral');
var ans = r0.answers || [];
var sources = r0.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(safeUrl(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(safeUrl(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(safeUrl(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>'; });
}
/* ── «Тест в банк» (учитель): модель → вопросы → банк вопросов ─────────── */
function makeQuiz(topic, chatEl) {
topic = (topic || '').trim();
var note = msgEl('assistant');
note.innerHTML = '<div class="asst-rich">Составляю тестовые вопросы…</div>';
chatEl.appendChild(note); chatEl.scrollTop = chatEl.scrollHeight; setNameFace('thinking');
Promise.all([
LS.assistantQuestions(topic, 5),
(LS.getSubjects ? LS.getSubjects() : Promise.resolve([])).catch(function () { return []; }),
]).then(function (res) {
var qs = (res[0] && res[0].questions) || [];
var subjects = Array.isArray(res[1]) ? res[1] : ((res[1] && res[1].subjects) || []);
if (!qs.length) { note.innerHTML = '<div class="asst-rich">Не получилось сгенерировать вопросы. Уточни тему и попробуй ещё.</div>'; setNameFace('sad'); return; }
note.remove();
var wrap = msgEl('assistant'); wrap.style.maxWidth = '100%';
var box = document.createElement('div'); box.className = 'asst-rich'; wrap.appendChild(box);
var head = document.createElement('div'); head.style.cssText = 'font-weight:800;margin-bottom:6px'; head.textContent = 'Вопросы (' + qs.length + ') — проверь и сохрани:'; box.appendChild(head);
qs.forEach(function (it, i) {
var qd = document.createElement('div'); qd.style.cssText = 'margin:8px 0;padding:8px 10px;border:1px solid var(--border,#e2e8f0);border-radius:10px';
var qt = document.createElement('div'); qt.style.cssText = 'font-weight:700;margin-bottom:4px'; qt.appendChild(document.createTextNode((i + 1) + '. '));
var qr = document.createElement('span'); qt.appendChild(qr); renderRich(qr, it.q); qd.appendChild(qt);
(it.options || []).forEach(function (op, oi) {
var li = document.createElement('div'); li.style.cssText = 'padding:2px 0 2px 14px;font-size:.84rem' + (oi === it.correct ? ';color:#059652;font-weight:700' : '');
var os = document.createElement('span'); renderRich(os, op); li.appendChild(os);
if (oi === it.correct) { var okm = document.createElement('span'); okm.textContent = ' — верно'; okm.style.color = '#059652'; li.appendChild(okm); }
qd.appendChild(li);
});
if (it.explanation) { var ex = document.createElement('div'); ex.style.cssText = 'margin-top:4px;font-size:.8rem;color:#8a94a6'; ex.appendChild(document.createTextNode('Пояснение: ')); var exs = document.createElement('span'); renderRich(exs, it.explanation); ex.appendChild(exs); qd.appendChild(ex); }
box.appendChild(qd);
});
var bar = document.createElement('div'); bar.style.cssText = 'display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:8px';
var sel = document.createElement('select'); sel.style.cssText = 'padding:6px 8px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.82rem';
sel.innerHTML = '<option value="">Предмет…</option>' + subjects.map(function (s) { return '<option value="' + esc(s.slug) + '">' + esc(s.name || s.slug) + '</option>'; }).join('');
var topicIn = document.createElement('input'); topicIn.type = 'text'; topicIn.placeholder = 'Тема (необязательно)'; topicIn.style.cssText = 'padding:6px 8px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.82rem;flex:1;min-width:110px';
var saveB = document.createElement('button'); saveB.className = 'asst-chip'; saveB.type = 'button'; saveB.textContent = 'Сохранить в банк';
var st = document.createElement('span'); st.style.cssText = 'font-size:.8rem;color:#8a94a6';
bar.appendChild(sel); bar.appendChild(topicIn); bar.appendChild(saveB); bar.appendChild(st); box.appendChild(bar);
saveB.addEventListener('click', function () {
var slug = sel.value; if (!slug) { st.textContent = 'Выбери предмет'; return; }
saveB.disabled = true; st.textContent = 'Сохраняю…';
var topicName = topicIn.value.trim() || (topic.length <= 60 ? topic : '');
var done = 0;
qs.reduce(function (p, it) {
return p.then(function () {
return LS.createQuestion({ subject_slug: slug, topic_name: topicName || undefined, type: 'single', text: it.q, explanation: it.explanation || undefined, difficulty: 1, options: (it.options || []).map(function (t, i) { return { text: t, is_correct: i === it.correct }; }) }).then(function () { done++; }).catch(function () {});
});
}, Promise.resolve()).then(function () {
st.innerHTML = 'Сохранено ' + done + ' из ' + qs.length + '. <a class="asst-ans-link" href="/question-bank">Открыть банк вопросов</a>';
saveB.style.display = 'none'; sel.disabled = true; topicIn.disabled = true;
});
});
chatEl.appendChild(wrap); chatEl.scrollTop = chatEl.scrollHeight; setNameFace('ecstatic');
}).catch(function (e) {
note.innerHTML = '<div class="asst-rich">' + ((e && e.data && e.data.error) ? esc(e.data.error) : 'Не удалось сгенерировать вопросы.') + '</div>'; setNameFace('sad');
});
}
/* ── Ф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();
if (SRV && SRV.examButtons) document.documentElement.classList.add('asst-exam-on'); // показать кнопки помощника на карточках экзамена
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); },
};
})();