feat(assistant): Квантик-ассистент — Ф0/Ф1 + «Спроси» (правиловый движок)

Плавающий помощник на всех страницах (через sidebar.js + inject в учебник):
контекстные подсказки по странице, проактивные напоминания из реальных данных
(домашка с дедлайном, карточки к повторению, серия под угрозой, квест дня),
поздравления (левелап/серия) и панель «Спроси Квантика» (поиск по FAQ + точка
расширения под локальную модель). Консервативно: дневной лимит, кулдауны,
«не показывать», выключатель в профиле. Лицо — pet-sprite, данные — /api/pet.

Бэкенд: миграция 062 (assistant_enabled + assistant_seen, cross-device «видел»),
GET /api/assistant/context, POST seen/dismiss/ask, PATCH settings — гейт фичи 'pet'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 16:17:37 +03:00
parent 26c0ac0e58
commit 3f8009c59d
8 changed files with 599 additions and 0 deletions
+346
View File
@@ -0,0 +1,346 @@
'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';
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 = [
// — контекстные —
{ id: 'p-textbook', scope: 'page', cooldownDays: 14, maxShows: 2,
when: function () { return PAGE === 'textbook'; },
text: function () { return 'Любой кусок страницы можно вырезать картинкой в «Мои материалы» — кнопка «Вырезать область» внизу.'; },
action: function () { return null; } },
{ id: 'p-exam', scope: 'page', cooldownDays: 14, maxShows: 2,
when: function () { return PAGE === 'exam'; },
text: function () { return 'Три режима: экзамен (как на ЦТ/ЦЭ), тренировка (с разбором) и случайный. Выбирай под свою цель.'; },
action: function () { return null; } },
{ id: 'p-flashcards', scope: 'page', cooldownDays: 14, maxShows: 2,
when: function () { return PAGE === 'flashcards'; },
text: function () { return 'Формулы в карточках вводятся через KaTeX-палитру, а ещё можно добавить картинку.'; },
action: function () { return null; } },
{ id: 'p-materials', scope: 'page', cooldownDays: 14, maxShows: 2,
when: function () { return PAGE === 'materials'; },
text: function () { return 'Раскладывай материалы по папкам, а поверх фото можно рисовать — кнопка с карандашом.'; },
action: function () { return null; } },
{ id: 'p-lab', scope: 'page', cooldownDays: 14, maxShows: 2,
when: function () { return PAGE === 'lab'; },
text: function () { return 'Симуляции запускаются прямо в браузере — ничего ставить не нужно.'; },
action: function () { return null; } },
{ id: 'p-dashboard', scope: 'page', cooldownDays: 30, maxShows: 1,
when: function () { return PAGE === 'dashboard'; },
text: function () { return 'Виджеты на дашборде можно включать и переставлять под себя.'; },
action: function () { return null; } },
// — проактивные (из реальных данных) —
{ 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: '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' }; } },
{ 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; } },
];
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;
}
/* ── выбор подсказки ─────────────────────────────────────────────────── */
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;}',
'.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-empty{font-size:.82rem;color:#8a94a6;padding:6px 0;}',
'@media(max-width:640px){.asst-root{left:12px;bottom:72px;}.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="ok">Закрыть</button></div>';
}
function showGreet() {
openBubble(greetHtml(), {});
bubble.querySelector('[data-a="ok"]').onclick = closeBubble;
bubble.querySelector('[data-a="ask"]').onclick = openAsk;
}
/* ── «Спроси Квантика» ───────────────────────────────────────────────── */
function openAsk() {
openBubble(
'<div class="asst-name">Спроси Квантика</div>' +
'<input class="asst-ask-in" type="text" placeholder="Например: как сохранить кусок учебника" maxlength="200" />' +
'<div class="asst-ans-box"></div>', {});
var inp = bubble.querySelector('.asst-ask-in');
var box = bubble.querySelector('.asst-ans-box');
inp.focus();
var t = null;
inp.addEventListener('input', function () { clearTimeout(t); t = setTimeout(function () { runAsk(inp.value, box); }, 350); });
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') { clearTimeout(t); runAsk(inp.value, box); } });
}
function runAsk(q, box) {
q = (q || '').trim();
if (q.length < 3) { box.innerHTML = ''; return; }
box.innerHTML = '<div class="asst-empty">Ищу…</div>';
LS.assistantAsk(q).then(function (r) {
var ans = (r && r.answers) || [];
if (!ans.length) { box.innerHTML = '<div class="asst-empty">Не нашёл точного ответа. Попробуй переформулировать.</div>'; return; }
box.innerHTML = ans.map(function (a) {
return '<div class="asst-ans"><div class="asst-ans-q">' + esc(a.q) + '</div>' + esc(a.a) +
(a.url ? '<br><a class="asst-ans-link" href="' + esc(a.url) + '">Открыть</a>' : '') + '</div>';
}).join('');
}).catch(function () { box.innerHTML = '<div class="asst-empty">Не удалось получить ответ.</div>'; });
}
/* ── монтирование ────────────────────────────────────────────────────── */
function mount() {
ensureStyles();
root = document.createElement('div');
root.className = 'asst-root';
root.setAttribute('data-h2c-ignore', ''); // не попадать в скриншоты учебника
root.innerHTML =
'<div class="asst-bubble" role="dialog" aria-live="polite"></div>' +
'<button class="asst-fab" aria-label="Помощник Квантик"><span class="asst-face">' + faceSVG() + '</span></button>';
document.body.appendChild(root);
bubble = root.querySelector('.asst-bubble');
var fab = root.querySelector('.asst-fab');
fab.onclick = function () {
if (openState) return closeBubble();
if (picked) showRule(picked, picked.scope === 'celebration');
else showGreet();
};
// поздравление — сразу; иначе подсказка — через паузу (с учётом дневного лимита)
var cel = celebration();
if (cel) {
picked = { id: cel.id, scope: 'celebration', text: cel.text, mood: cel.mood, action: function () { return null; } };
var dot = document.createElement('span'); dot.className = 'asst-dot'; fab.appendChild(dot);
fab.classList.add('pulse');
setTimeout(function () { if (!openState) showRule(picked, true); }, 1200);
return;
}
picked = pickRule();
if (picked && dayCount() < DAILY_CAP) {
var d = document.createElement('span'); d.className = 'asst-dot'; fab.appendChild(d);
fab.classList.add('pulse');
setTimeout(function () { if (!openState && picked) showRule(picked, false); }, AUTO_DELAY);
}
}
/* ── boot ────────────────────────────────────────────────────────────── */
function boot() {
if (!document.body) { return setTimeout(boot, 200); }
LS.assistantContext().then(function (ctx) {
SRV = ctx || {};
if (SRV.enabled === false) return; // выключено пользователем
return (LS.api ? LS.api('/api/pet') : Promise.resolve(null)).then(function (pet) {
PET = pet || null;
ensurePet(mount);
});
}).catch(function () { /* фича выключена / нет доступа — тихо выходим */ });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { setTimeout(boot, 400); });
else setTimeout(boot, 400);
window.Assistant = { open: function () { if (root) root.querySelector('.asst-fab').click(); } };
})();
+34
View File
@@ -1285,6 +1285,27 @@
</div>
</div>
<!-- Помощник Квантик -->
<div class="p-card">
<div class="p-card-header">
<div class="p-card-icon"><i data-lucide="sparkles" style="width:15px;height:15px"></i></div>
<div>
<div class="p-card-title">Помощник Квантик</div>
<div class="p-card-sub">Подсказки и напоминания по системе</div>
</div>
</div>
<div class="pref-row">
<div class="pref-row-info">
<div class="pref-row-label">Показывать помощника</div>
<div class="pref-row-desc">Плавающий Квантик с подсказками на страницах</div>
</div>
<label class="pref-toggle">
<input type="checkbox" id="pref-assistant" onchange="prefAssistant(this.checked)">
<span class="pref-toggle-track"></span>
</label>
</div>
</div>
<!-- Внешний вид -->
<div class="p-card">
<div class="p-card-header">
@@ -2075,6 +2096,11 @@
/* ── Настройки (prefs tab) ── */
function loadPrefs() {
// Ассистент Квантик (независимо от наличия LS.sfx)
const asstEl = document.getElementById('pref-assistant');
if (asstEl && window.LS && LS.assistantContext) {
LS.assistantContext().then(c => { asstEl.checked = !(c && c.enabled === false); }).catch(() => {});
}
if (!window.LS || !LS.sfx) return;
const sfx = LS.sfx;
const setChk = (id, v) => { const el = document.getElementById(id); if (el) el.checked = v; };
@@ -2108,6 +2134,14 @@
if (v) setTimeout(() => LS.sfx.play('success'), 100);
}
function prefAssistant(v) {
if (!window.LS || !LS.assistantSettings) return;
LS.assistantSettings({ enabled: !!v })
.then(() => { if (LS.toast) LS.toast(v ? 'Помощник включён' : 'Помощник отключён', 'success'); })
.catch(() => { if (LS.toast) LS.toast('Не удалось сохранить', 'error'); });
}
window.prefAssistant = prefAssistant;
function prefSfxVolume(v) {
if (!window.LS || !LS.sfx) return;
LS.sfx.setVolume(v / 100);