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>
This commit is contained in:
Maxim Dolgolyov
2026-06-24 15:12:49 +03:00
parent 78aea47619
commit c49077abbc
+19 -10
View File
@@ -282,6 +282,9 @@
'.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;}',
@@ -363,6 +366,8 @@
/* ── рендер ──────────────────────────────────────────────────────────── */
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 || {};
@@ -622,15 +627,15 @@
_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;
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 }); }
else d.textContent = 'Не получилось нарисовать.';
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');
ph.remove(); var d = msgEl('assistant'); setNameFace('sad');
d.textContent = (err && err.data && err.data.error) || 'Не получилось нарисовать.';
chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight;
});
@@ -648,6 +653,7 @@
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;
@@ -666,9 +672,10 @@
_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; return;
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) || [];
@@ -720,6 +727,7 @@
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: [] }; }),
@@ -730,9 +738,10 @@
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; return;
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) || [];
@@ -793,14 +802,14 @@
topic = (topic || '').trim();
var note = msgEl('assistant');
note.innerHTML = '<div class="asst-rich">Составляю тестовые вопросы…</div>';
chatEl.appendChild(note); chatEl.scrollTop = chatEl.scrollHeight;
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>'; return; }
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);
@@ -839,9 +848,9 @@
saveB.style.display = 'none'; sel.disabled = true; topicIn.disabled = true;
});
});
chatEl.appendChild(wrap); chatEl.scrollTop = chatEl.scrollHeight;
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>';
note.innerHTML = '<div class="asst-rich">' + ((e && e.data && e.data.error) ? esc(e.data.error) : 'Не удалось сгенерировать вопросы.') + '</div>'; setNameFace('sad');
});
}