From 3f8009c59dee1537af9520fc59ebd250bbd322b9 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 4 Jun 2026 16:17:37 +0300 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20=D0=9A=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D0=B8=D0=BA-=D0=B0=D1=81=D1=81=D0=B8=D1=81=D1=82=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=20=E2=80=94=20=D0=A40/=D0=A41=20+=20=C2=AB=D0=A1?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=B8=C2=BB=20(=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB=D0=BE=D0=B2=D1=8B=D0=B9=20=D0=B4=D0=B2=D0=B8?= =?UTF-8?q?=D0=B6=D0=BE=D0=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Плавающий помощник на всех страницах (через 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) --- .../src/controllers/assistantController.js | 166 +++++++++ backend/src/db/migrations/062_assistant.sql | 20 + backend/src/routes/assistant.js | 16 + backend/src/server.js | 2 + frontend/js/assistant.js | 346 ++++++++++++++++++ frontend/profile.html | 34 ++ js/api.js | 6 + js/sidebar.js | 9 + 8 files changed, 599 insertions(+) create mode 100644 backend/src/controllers/assistantController.js create mode 100644 backend/src/db/migrations/062_assistant.sql create mode 100644 backend/src/routes/assistant.js create mode 100644 frontend/js/assistant.js diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js new file mode 100644 index 0000000..c7e1a48 --- /dev/null +++ b/backend/src/controllers/assistantController.js @@ -0,0 +1,166 @@ +'use strict'; +/* assistantController — данные и состояние «Квантик-ассистента». + * GET /api/assistant/context — бандл для движка подсказок (1 запрос) + * POST /api/assistant/seen — отметить показ правила (++count) + * POST /api/assistant/dismiss — «не показывать это правило» + * PATCH /api/assistant/settings — вкл/выкл ассистента + * POST /api/assistant/ask — «Спроси Квантика» (поиск по FAQ; точка под LLM) + * Гейт фичи — общий с питомцем ('pet'), см. server.js. */ +const db = require('../db/db'); + +/* Корпус справки для «Спроси Квантика» (поиск по ключевым словам в ask()). + * Это же место — контекст для будущей локальной модели (см. ask). Правьте свободно. */ +const FAQ = [ + { id: 'clip-textbook', q: 'Как сохранить кусок учебника?', + a: 'На странице учебника нажми «Вырезать область» (внизу), выдели фрагмент — он сохранится картинкой в «Мои материалы».', + url: '/my-materials', keywords: ['учебник', 'вырезать', 'область', 'скриншот', 'фрагмент', 'сохранить', 'картинк', 'материал'] }, + { id: 'materials-folders', q: 'Как разложить материалы по папкам?', + a: 'В «Мои материалы» нажми «+ папка», затем у карточки выбери папку. Можно фильтровать по папкам и типам.', + url: '/my-materials', keywords: ['папк', 'материал', 'коллекци', 'разложить', 'сортиров', 'фильтр'] }, + { id: 'materials-annotate', q: 'Как рисовать поверх фото?', + a: 'Открой материал-картинку и нажми кнопку с карандашом-линейкой — откроется редактор. Сохранение обновит ту же карточку.', + url: '/my-materials', keywords: ['рисовать', 'аннотир', 'поверх', 'фото', 'карандаш', 'разметк', 'редактир'] }, + { id: 'flashcards', q: 'Как работают флешкарты?', + a: 'Создай колоду, добавь карточки (вопрос/ответ, можно картинку и формулы KaTeX). Система сама напомнит, что пора повторить.', + url: '/flashcards', keywords: ['флешкарт', 'карточк', 'колод', 'повтор', 'память', 'katex', 'формул'] }, + { id: 'exam-modes', q: 'Чем отличаются режимы экзамена?', + a: 'Экзамен — как на ЦТ/ЦЭ, на время. Тренировка — с разбором после каждого ответа. Случайный — быстрый набор вопросов.', + url: '/exam-prep', keywords: ['экзамен', 'режим', 'тренировк', 'случайн', 'цт', 'цэ', 'тест'] }, + { id: 'board-tools', q: 'Что умеет доска?', + a: 'Карандаш, маркер, лазер, фигуры, соединители, стикеры, текст, формулы KaTeX, таблицы, линейка и транспортир. «Выделение» двигает и поворачивает объекты.', + url: '/board', keywords: ['доск', 'инструмент', 'рисов', 'фигур', 'линейк', 'маркер', 'whiteboard'] }, + { id: 'pet', q: 'Зачем нужен питомец и XP?', + a: 'Квантик растёт от твоей активности: за тесты, уроки и карточки идут XP и монеты. Серия за ежедневные занятия поднимает настроение и даёт бонусы.', + url: '/pet', keywords: ['питомец', 'квантик', 'xp', 'опыт', 'монет', 'серия', 'streak', 'настроение', 'уровень'] }, + { id: 'homework', q: 'Где мои домашние задания?', + a: 'Все задания и дедлайны — в разделе «Домашние задания». Там же можно загрузить выполненную работу.', + url: '/homework', keywords: ['домашк', 'домашн', 'задани', 'дедлайн', 'сдать', 'загрузить', 'работ'] }, + { id: 'quick-lesson', q: 'Как создать один урок без курса?', + a: 'В «Теории» нажми «Быстрый урок» — урок создастся в скрытом личном контейнере, его не видно в общем каталоге.', + url: '/theory', keywords: ['урок', 'быстрый', 'без курса', 'создать', 'теори'] }, + { id: 'lab', q: 'Как открыть симуляции?', + a: 'В «Лаборатории» симуляции запускаются прямо в браузере — установка не нужна. Выбери предмет и опыт.', + url: '/lab', keywords: ['лаборатори', 'симуляци', 'опыт', 'эксперимент', 'физик', 'хими'] }, +]; + +/* ── Источники проактивных подсказок (всё уже есть в БД) ──────────────── */ +function dueCardsCount(uid) { + try { + return db.prepare(` + SELECT COUNT(*) AS n + FROM flashcard_cards c + JOIN flashcard_decks d ON d.id = c.deck_id AND d.user_id = ? + LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ? + WHERE r.id IS NULL OR r.due_at <= datetime('now') + `).get(uid, uid)?.n || 0; + } catch (e) { return 0; } +} + +function pendingHomework(uid) { + // Несданные задания класса с дедлайном: нет завершённой попытки и нет отметки выполнения. + try { + const rows = db.prepare(` + SELECT a.id, a.title, a.deadline + FROM class_members cm + JOIN assignments a ON a.class_id = cm.class_id AND a.user_id IS NULL + WHERE cm.user_id = ? AND a.deadline IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM assignment_completion ac + WHERE ac.assignment_id = a.id AND ac.user_id = cm.user_id) + AND NOT EXISTS (SELECT 1 FROM assignment_sessions ax + JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed' + WHERE ax.assignment_id = a.id AND ax.user_id = cm.user_id) + ORDER BY a.deadline ASC + LIMIT 20 + `).all(uid); + const now = Date.now(); + const soonMs = 48 * 3600 * 1000; + let overdue = null, dueSoon = null; + for (const r of rows) { + const t = Date.parse(r.deadline); + if (isNaN(t)) continue; + if (t < now) { if (!overdue) overdue = { title: r.title, deadline: r.deadline }; } + else if (t - now <= soonMs) { if (!dueSoon) dueSoon = { title: r.title, deadline: r.deadline }; } + } + return { overdue, dueSoon }; + } catch (e) { return { overdue: null, dueSoon: null }; } +} + +/* ── GET /api/assistant/context ───────────────────────────────────────── */ +function getContext(req, res) { + const uid = req.user.id; + const u = db.prepare('SELECT assistant_enabled FROM users WHERE id = ?').get(uid); + const seen = {}; + try { + db.prepare('SELECT rule_id, count, dismissed, last_at FROM assistant_seen WHERE user_id = ?') + .all(uid) + .forEach(r => { seen[r.rule_id] = { count: r.count, dismissed: r.dismissed === 1, lastAt: r.last_at }; }); + } catch (e) { /* table may be missing on a legacy instance */ } + + res.json({ + enabled: u ? u.assistant_enabled !== 0 : true, + seen, + dueCards: dueCardsCount(uid), + homework: pendingHomework(uid), + }); +} + +/* ── POST /api/assistant/seen { ruleId } ──────────────────────────────── */ +function markSeen(req, res) { + const ruleId = String((req.body && req.body.ruleId) || '').slice(0, 60); + if (!ruleId) return res.status(400).json({ error: 'ruleId required' }); + db.prepare(` + INSERT INTO assistant_seen (user_id, rule_id, count, last_at) + VALUES (?, ?, 1, datetime('now')) + ON CONFLICT(user_id, rule_id) DO UPDATE SET count = count + 1, last_at = datetime('now') + `).run(req.user.id, ruleId); + res.json({ ok: true }); +} + +/* ── POST /api/assistant/dismiss { ruleId } ───────────────────────────── */ +function dismiss(req, res) { + const ruleId = String((req.body && req.body.ruleId) || '').slice(0, 60); + if (!ruleId) return res.status(400).json({ error: 'ruleId required' }); + db.prepare(` + INSERT INTO assistant_seen (user_id, rule_id, count, dismissed, last_at) + VALUES (?, ?, 0, 1, datetime('now')) + ON CONFLICT(user_id, rule_id) DO UPDATE SET dismissed = 1, last_at = datetime('now') + `).run(req.user.id, ruleId); + res.json({ ok: true }); +} + +/* ── PATCH /api/assistant/settings { enabled } ────────────────────────── */ +function setSettings(req, res) { + const enabled = (req.body && req.body.enabled) ? 1 : 0; + db.prepare('UPDATE users SET assistant_enabled = ? WHERE id = ?').run(enabled, req.user.id); + res.json({ ok: true, enabled: enabled === 1 }); +} + +/* ── POST /api/assistant/ask { q } ── «Спроси Квантика» ─────────────────── + * Сейчас: поиск по FAQ (пересечение ключевых слов). Возвращает топ-совпадения. + * + * ТОЧКА РАСШИРЕНИЯ ПОД ЛОКАЛЬНУЮ МОДЕЛЬ: + * когда подключим локальную/облачную LLM — здесь вызываем askModel(q, hits), + * передав найденные FAQ как контекст, и возвращаем { source:'model', answer }. + * Сигнатуру ответа фронт уже понимает (поле source). */ +function ask(req, res) { + const q = String((req.body && req.body.q) || '').trim().toLowerCase().slice(0, 300); + if (!q) return res.json({ source: 'faq', answers: [] }); + + const tokens = q.split(/[^a-zа-яё0-9]+/i).filter(t => t.length >= 3); + const scored = FAQ.map(item => { + let score = 0; + for (const t of tokens) { + if (item.keywords.some(k => k.indexOf(t) === 0 || t.indexOf(k) === 0)) score += 2; + if (item.q.toLowerCase().includes(t)) score += 1; + } + return { item, score }; + }).filter(x => x.score > 0).sort((a, b) => b.score - a.score).slice(0, 3); + + // const answer = await askModel(q, scored.map(s => s.item)); // TODO: локальная модель + res.json({ + source: 'faq', + answers: scored.map(s => ({ id: s.item.id, q: s.item.q, a: s.item.a, url: s.item.url || null })), + }); +} + +module.exports = { getContext, markSeen, dismiss, setSettings, ask }; diff --git a/backend/src/db/migrations/062_assistant.sql b/backend/src/db/migrations/062_assistant.sql new file mode 100644 index 0000000..bf27114 --- /dev/null +++ b/backend/src/db/migrations/062_assistant.sql @@ -0,0 +1,20 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 062: Квантик-ассистент — per-user toggle + cross-device "seen" tracking +-- +-- Питомец «Квантик» становится сквозным ассистентом (подсказки/напоминания). +-- assistant_enabled — личный выключатель (по умолчанию включён всем). +-- assistant_seen — сколько раз показана подсказка правила и не закрыта ли она +-- «навсегда» (dismissed). Хранится на сервере, чтобы не повторять на других +-- устройствах. Фича-гейт переиспользует существующий feature 'pet'. +-- ═══════════════════════════════════════════════════════════════ + +ALTER TABLE users ADD COLUMN assistant_enabled INTEGER NOT NULL DEFAULT 1; + +CREATE TABLE assistant_seen ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rule_id TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + dismissed INTEGER NOT NULL DEFAULT 0, + last_at TEXT, + PRIMARY KEY (user_id, rule_id) +); diff --git a/backend/src/routes/assistant.js b/backend/src/routes/assistant.js new file mode 100644 index 0000000..88b3c12 --- /dev/null +++ b/backend/src/routes/assistant.js @@ -0,0 +1,16 @@ +'use strict'; +/* Квантик-ассистент. Все маршруты — под авторизацией (router-level), фича-гейт + * 'pet' навешивается при монтировании в server.js. */ +const router = require('express').Router(); +const { authMiddleware } = require('../middleware/auth'); +const ctrl = require('../controllers/assistantController'); + +router.use(authMiddleware); + +router.get('/context', ctrl.getContext); +router.post('/seen', ctrl.markSeen); +router.post('/dismiss', ctrl.dismiss); +router.patch('/settings', ctrl.setSettings); +router.post('/ask', ctrl.ask); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index 5da9978..ec45f0c 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -183,6 +183,7 @@ app.use('/api/classroom', classroomRoutes); app.use('/api/games', gamesRoutes); app.use('/api/knowledge-map', requireFeature('knowledge_map'), knowledgeMapRoutes); app.use('/api/pet', requireFeature('pet'), petRoutes); +app.use('/api/assistant', requireFeature('pet'), require('./routes/assistant')); app.use('/api/collection', requireFeature('collection'), collectionRoutes); app.use('/api/red-book', requireFeature('red_book'), redBookRoutes); app.use('/api/biochem', requireFeature('biochem'), require('./routes/biochem')); @@ -430,6 +431,7 @@ const DEEPLINK_INJECT = ` + `; function _renderTextbook(filePath, slug, embed) { diff --git a/frontend/js/assistant.js b/frontend/js/assistant.js new file mode 100644 index 0000000..bb094fb --- /dev/null +++ b/frontend/js/assistant.js @@ -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 ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 ''; + } + + /* ── 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 = '' + 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 ? '' + esc(act.label || 'Открыть') + '' : ''; + var dismiss = (rule.scope !== 'celebration') ? '' : ''; + return '
' + esc(PET && PET.petName ? PET.petName : 'Квантик') + '
' + + '
' + esc(rule.text ? rule.text() : rule.text) + '
' + + '
' + actHtml + + '' + dismiss + + '
'; + } + + 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 '
' + esc(PET && PET.petName ? PET.petName : 'Квантик') + '
' + + '
Привет! Я помогу разобраться в системе. Спроси, как что-то сделать.
' + + '
' + + '
'; + } + function showGreet() { + openBubble(greetHtml(), {}); + bubble.querySelector('[data-a="ok"]').onclick = closeBubble; + bubble.querySelector('[data-a="ask"]').onclick = openAsk; + } + + /* ── «Спроси Квантика» ───────────────────────────────────────────────── */ + function openAsk() { + openBubble( + '
Спроси Квантика
' + + '' + + '
', {}); + 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 = '
Ищу…
'; + LS.assistantAsk(q).then(function (r) { + var ans = (r && r.answers) || []; + if (!ans.length) { box.innerHTML = '
Не нашёл точного ответа. Попробуй переформулировать.
'; return; } + box.innerHTML = ans.map(function (a) { + return '
' + esc(a.q) + '
' + esc(a.a) + + (a.url ? '
Открыть' : '') + '
'; + }).join(''); + }).catch(function () { box.innerHTML = '
Не удалось получить ответ.
'; }); + } + + /* ── монтирование ────────────────────────────────────────────────────── */ + function mount() { + ensureStyles(); + root = document.createElement('div'); + root.className = 'asst-root'; + root.setAttribute('data-h2c-ignore', ''); // не попадать в скриншоты учебника + root.innerHTML = + '' + + ''; + 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(); } }; +})(); diff --git a/frontend/profile.html b/frontend/profile.html index 039b36a..4d0e103 100644 --- a/frontend/profile.html +++ b/frontend/profile.html @@ -1285,6 +1285,27 @@ + +
+
+
+
+
Помощник Квантик
+
Подсказки и напоминания по системе
+
+
+
+
+
Показывать помощника
+
Плавающий Квантик с подсказками на страницах
+
+ +
+
+
@@ -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); diff --git a/js/api.js b/js/api.js index a9ea8ca..8429796 100644 --- a/js/api.js +++ b/js/api.js @@ -1050,6 +1050,7 @@ window.LS = { crAdminGetAllHistory, crAdminGetTeachersList, listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity, createMaterialCollection, updateMaterialCollection, deleteMaterialCollection, + assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, fcListDecks, fcCreateDeck, fcAddCard, escapeHtml, esc, parseDate, fmtRelTime, safeHref, @@ -1267,6 +1268,11 @@ async function getActivity() { return req('GET', '/dashboard/activit async function createMaterialCollection(d) { return req('POST', '/materials/collections', d); } async function updateMaterialCollection(id,d){ return req('PATCH', `/materials/collections/${id}`, d); } async function deleteMaterialCollection(id) { return req('DELETE', `/materials/collections/${id}`); } +async function assistantContext() { return req('GET', '/assistant/context'); } +async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); } +async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); } +async function assistantSettings(d) { return req('PATCH', '/assistant/settings', d); } +async function assistantAsk(q) { return req('POST', '/assistant/ask', { q }); } async function fcListDecks() { return req('GET', '/flashcards/decks'); } async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); } async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); } diff --git a/js/sidebar.js b/js/sidebar.js index d0e69e7..50eacb0 100644 --- a/js/sidebar.js +++ b/js/sidebar.js @@ -235,4 +235,13 @@ s.defer = true; document.body.appendChild(s); } + + // Квантик-ассистент — плавающий помощник на всех страницах с шапкой + if (typeof LS !== 'undefined' && LS.isLoggedIn?.() && !document.getElementById('asst-loader')) { + const s = document.createElement('script'); + s.id = 'asst-loader'; + s.src = '/js/assistant.js'; + s.defer = true; + document.body.appendChild(s); + } })();