feat(assistant): диалог сохраняется между заходами (localStorage, per-user)

_chat теперь персистится в localStorage (ключ asst_chat_<uid>, последние 30
сообщений) при каждом ответе и восстанавливается при загрузке виджета. Живёт,
пока ученик сам не нажмёт «Очистить» (чистит и хранилище). Сноска обновлена.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-24 23:04:43 +03:00
parent c045ea02b0
commit 7c0ccc7282
+11 -5
View File
@@ -546,6 +546,11 @@
var SUGGESTIONS = ['Как вырезать кусок учебника?', 'Как создать карточки?', 'Как начать тест?', 'Объясни теорему Пифагора', 'Где мои домашние задания?', 'Как включить тёмную тему?'];
var TEACHER_SUGGESTIONS = ['Как создать класс и выдать задание?', 'Идеи заданий по теме…', 'Составь план урока по теме…', 'Как работает журнал и аналитика?', 'Как провести онлайн-урок?'];
var _chat = []; // многоходовой диалог: [{role:'user'|'assistant', content}]
// Диалог переживает обновление/переходы (localStorage, per-user), пока ученик сам не нажмёт «Очистить».
function _chatKey() { try { var u = LS.getUser && LS.getUser(); return u && u.id ? 'asst_chat_' + u.id : null; } catch (e) { return null; } }
function saveChat() { var k = _chatKey(); if (k) lsSet(k, JSON.stringify(_chat.slice(-30))); }
function loadChat() { var k = _chatKey(); if (!k) return; try { var a = JSON.parse(lsGet(k) || '[]'); if (Array.isArray(a)) _chat = a.filter(function (m) { return m && (m.role === 'user' || m.role === 'assistant') && typeof m.content === 'string'; }); } catch (e) {} }
function clearChatStore() { var k = _chatKey(); if (k) try { localStorage.removeItem(k); } catch (e) {} }
function msgEl(role) { var d = document.createElement('div'); d.className = 'asst-msg asst-msg-' + role; return d; }
function renderChat(chatEl) {
chatEl.innerHTML = '';
@@ -593,7 +598,7 @@
'<div class="asst-ask-row"><input class="asst-ask-in" type="text" placeholder="' + MODE_PH.answer + '" maxlength="500" />' +
'<button class="asst-send" type="button" title="Отправить" aria-label="Отправить"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4 20-7z"/></svg></button></div>' +
'<div class="asst-memnote">' + _svg('<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>') +
'<span>Держу в голове ход беседы — последние <b>~14 сообщений</b>. Что обсуждали раньше — учту, давнее плавно забывается. «Очистить» — начать с чистого листа.</span></div>', {});
'<span>Держу в голове ход беседы — последние <b>~14 сообщений</b>. Диалог сохраняется между заходами и обновлениями страницы, пока ты сам не нажмёшь «Очистить».</span></div>', {});
var inp = bubble.querySelector('.asst-ask-in');
var chatEl = bubble.querySelector('.asst-chat');
var chipsEl = bubble.querySelector('.asst-chips');
@@ -622,7 +627,7 @@
});
});
var clr = bubble.querySelector('[data-a="clear"]');
if (clr) clr.onclick = function () { _chat = []; openAsk(); };
if (clr) clr.onclick = function () { _chat = []; clearChatStore(); openAsk(); };
var memBtn = bubble.querySelector('[data-a="mem"]');
if (memBtn) memBtn.onclick = openMemory;
if (prefill && prefill.q) go(prefill.q, prefill.context, prefill.mode);
@@ -671,7 +676,7 @@
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'); }
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 }); saveChat(); setNameFace('ecstatic'); }
else { d.textContent = 'Не получилось нарисовать.'; setNameFace('sad'); }
chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight;
}).catch(function (err) {
@@ -722,7 +727,7 @@
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 });
_chat.push({ role: 'assistant', content: content }); saveChat();
renderRich(richEl, content);
if (isModel && sources.length) {
var sc = document.createElement('div'); sc.className = 'asst-src';
@@ -786,7 +791,7 @@
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 });
_chat.push({ role: 'assistant', content: content }); saveChat();
var d = msgEl('assistant'); d.innerHTML = '<div class="asst-rich"></div>'; chatEl.appendChild(d);
renderRich(d.querySelector('.asst-rich'), content);
// источники (RAG)
@@ -1058,6 +1063,7 @@
SRV = ctx || {};
_role = (SRV && SRV.role) || 'student';
if (SRV.enabled === false) return; // выключено пользователем
loadChat(); // восстановить диалог прошлой сессии (per-user)
return (LS.api ? LS.api('/api/pet') : Promise.resolve(null)).then(function (pet) {
PET = pet || null;
ensurePet(mount);