diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js
index 5cfadab..347452a 100644
--- a/backend/src/controllers/assistantController.js
+++ b/backend/src/controllers/assistantController.js
@@ -269,9 +269,10 @@ function bumpUsage(field) {
}
/* Низкоуровневый вызов OpenAI-совместимого chat/completions. */
+/* Возвращает { text, error } — error: 'off'|'rate_limit'|'http'|'timeout'|'network'|'empty'|null. */
async function callLLM(messages, maxTokens, override) {
const cfg = override || llmConfig();
- if (typeof fetch !== 'function' || !cfg.on) return null;
+ if (typeof fetch !== 'function' || !cfg.on) return { text: null, error: 'off' };
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15000);
try {
@@ -281,11 +282,11 @@ async function callLLM(messages, maxTokens, override) {
body: JSON.stringify({ model: cfg.model, temperature: 0.3, max_tokens: maxTokens || 320, messages }),
signal: ctrl.signal,
});
- if (!r.ok) return null;
+ if (!r.ok) return { text: null, error: r.status === 429 ? 'rate_limit' : 'http', status: r.status };
const data = await r.json();
const text = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content;
- return text ? String(text).trim() : null;
- } catch (e) { return null; } finally { clearTimeout(timer); }
+ return { text: text ? String(text).trim() : null, error: text ? null : 'empty' };
+ } catch (e) { return { text: null, error: e.name === 'AbortError' ? 'timeout' : 'network' }; } finally { clearTimeout(timer); }
}
/* Тест-пинг для админки: подробный статус (status/ошибка/пример ответа). */
@@ -383,15 +384,23 @@ async function ask(req, res) {
let context = pageCtx;
if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text;
- let answer = null;
- try { answer = await askModel(q, hits, context, history, req.user && req.user.role, mode); } catch (e) { answer = null; }
+ let r = { text: null, error: 'network' };
+ try { r = await askModel(q, hits, context, history, req.user && req.user.role, mode); } catch (e) { r = { text: null, error: 'network' }; }
+ const answer = r && r.text;
if (answer) {
bumpUsage('model_calls');
if (cacheable) { try { db.prepare("INSERT OR REPLACE INTO assistant_cache (qhash, answer, created_at) VALUES (?, ?, datetime('now'))").run(qhash, answer); } catch (e) {} }
- } else { bumpUsage('faq'); }
-
- res.json({ source: answer ? 'model' : 'faq', answer: answer || null, answers: faqJson, sources: answer ? rag.sources : [] });
+ return res.json({ source: 'model', answer, answers: faqJson, sources: rag.sources });
+ }
+ bumpUsage('faq');
+ if (r && r.error === 'rate_limit') {
+ return res.json({ source: 'limit', answer: 'Сейчас слишком много запросов к ИИ за короткое время — подожди минутку и спроси снова. Память диалога не потеряется.', answers: faqJson, sources: [] });
+ }
+ if (r && (r.error === 'timeout' || r.error === 'network' || r.error === 'http')) {
+ return res.json({ source: 'error', answer: 'Не получилось обратиться к ИИ. Попробуй ещё раз чуть позже.', answers: faqJson, sources: [] });
+ }
+ res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] });
}
/* ── POST /api/assistant/feedback { rating, q? } ── лайк/дизлайк ответа ── */
@@ -414,7 +423,8 @@ async function flashcardsFromText(req, res) {
const sys = 'Ты составляешь учебные флешкарты по тексту. Верни СТРОГО JSON-массив из 5–6 объектов ' +
'вида {"front":"...","back":"..."} без markdown и пояснений. front — короткий вопрос, back — краткий ответ (1–2 предложения). ' +
'По-русски. Формулы в LaTeX между $...$. Никакого текста вне JSON.';
- const raw = await callLLM([{ role: 'system', content: sys }, { role: 'user', content: text }], 1400);
+ const rr = await callLLM([{ role: 'system', content: sys }, { role: 'user', content: text }], 1400);
+ const raw = rr && rr.text;
let cards = [];
if (raw) {
let s = raw.replace(/```(?:json)?/gi, '').trim();
diff --git a/frontend/admin.html b/frontend/admin.html
index 3d85186..c0252ea 100644
--- a/frontend/admin.html
+++ b/frontend/admin.html
@@ -1070,6 +1070,9 @@
Игры
+
+ Помощник Квантик
+
Аудит-лог
@@ -1545,6 +1548,13 @@
+
+
+
Помощник «Квантик»
+
Настройки ИИ-помощника: модель, RAG по учебникам, кнопки на экзамене, статистика и качество ответов.
+
+
+
Управление играми
@@ -2117,6 +2127,7 @@
+
diff --git a/frontend/js/admin/admin.js b/frontend/js/admin/admin.js
index d07fa3f..f2a9eb5 100644
--- a/frontend/js/admin/admin.js
+++ b/frontend/js/admin/admin.js
@@ -15,7 +15,7 @@
AdminCtx.isAdmin = isAdmin;
/* Admin-only tabs: show to everyone for discoverability, but lock for non-admins */
- const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games'];
+ const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games','btn-tab-assistant'];
const lockSvg = '
';
ADMIN_ONLY_TABS.forEach(id => {
const el = document.getElementById(id);
@@ -65,6 +65,7 @@
tpl: 'tpl',
sims: 'sims',
games: 'games',
+ assistant: 'assistant',
sublog: 'sublog',
access: 'access',
};
diff --git a/frontend/js/admin/sections/assistant.js b/frontend/js/admin/sections/assistant.js
new file mode 100644
index 0000000..46edd6c
--- /dev/null
+++ b/frontend/js/admin/sections/assistant.js
@@ -0,0 +1,124 @@
+'use strict';
+/* admin → «Помощник Квантик»: системный вкл/выкл + конфиг LLM (ключ/модель/тест),
+ * RAG, кнопки на экзамене, статистика использования и качество. */
+(function () {
+ 'use strict';
+ let inited = false;
+ var IN = 'padding:8px 10px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.85rem;width:100%;box-sizing:border-box;background:var(--surface,#fff);color:var(--text,#0f172a)';
+ var BTN = 'padding:8px 14px;border-radius:9px;border:1px solid var(--border,#e2e8f0);background:var(--surface,#fff);font:inherit;font-size:.82rem;font-weight:700;cursor:pointer;color:var(--text-2,#475569)';
+
+ async function render() {
+ var host = document.getElementById('assistant-admin');
+ if (!host) return;
+ host.innerHTML = '';
+
+ // ── Системный выключатель (feature 'assistant') ──
+ var feats = {};
+ try { feats = await LS.api('/api/admin/features'); } catch (e) {}
+ var on = feats.assistant !== false;
+ var master = document.createElement('div');
+ master.className = 'perm-card' + (on ? ' enabled' : '');
+ master.innerHTML =
+ '
Помощник включён для всей системы
' +
+ '
Выключатель «Квантика» для всех пользователей. Выключено — помощник не загружается нигде.
' +
+ '
';
+ host.appendChild(master);
+ master.querySelector('#asst-master').addEventListener('change', function () {
+ var v = this.checked;
+ LS.api('/api/admin/features', { method: 'PATCH', body: JSON.stringify({ assistant: v }) })
+ .then(function () { master.classList.toggle('enabled', v); LS.toast(v ? 'Помощник включён' : 'Помощник выключен для всех', 'success'); })
+ .catch(function () { master.querySelector('#asst-master').checked = !v; LS.toast('Ошибка', 'error'); });
+ });
+
+ // ── Конфиг модели ──
+ var card = document.createElement('div');
+ card.id = 'asst-llm-card';
+ card.className = 'perm-card';
+ card.style.cssText = 'flex-direction:column;align-items:stretch;gap:9px;margin-top:14px';
+ card.innerHTML =
+ '
Модель (ИИ) для «Спроси Квантика»
' +
+ '
OpenAI-совместимый эндпоинт. Без ключа — режим обычного FAQ.
' +
+ '
' +
+ '
— провайдер (пресет) — ' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ 'Сохранить ' +
+ 'Проверить ' +
+ 'Очистить ключ ' +
+ '
' +
+ '
' +
+ '
' +
+ '
Искать ответы по учебникам (RAG)' +
+ '
Кнопки помощника на карточках экзамена («Подсказка», «Спросить Квантика»)' +
+ '
' +
+ 'Переиндексировать учебники ' +
+ ' ' +
+ '
' +
+ '
' +
+ '
';
+ host.appendChild(card);
+ if (window.lucide) lucide.createIcons();
+
+ var q = function (s) { return card.querySelector(s); };
+ var cfg = {};
+ try { cfg = await LS.adminGetAssistant(); } catch (e) {}
+ (cfg.presets || []).forEach(function (p, i) { var o = document.createElement('option'); o.value = String(i); o.textContent = p.name; q('#asst-preset').appendChild(o); });
+ q('#asst-url').value = cfg.url || '';
+ q('#asst-model').value = cfg.model || '';
+ q('#asst-key').placeholder = cfg.hasKey ? 'Ключ сохранён — введите новый, чтобы заменить' : 'API-ключ';
+
+ function setStatus() {
+ q('#asst-llm-status').innerHTML = cfg.active
+ ? '
● Подключено — «Спроси» отвечает через ИИ '
+ : '
○ Ключ не задан — работает обычный FAQ-режим ';
+ q('#asst-rag').checked = cfg.rag !== false;
+ q('#asst-exambtn').checked = !!cfg.examButtons;
+ q('#asst-chunks').textContent = (cfg.chunks || 0) + ' фрагментов учебников в индексе';
+ var u = cfg.usage || {}, u30 = cfg.usage30 || {};
+ q('#asst-usage').innerHTML = 'Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.';
+ var f = cfg.feedback || {};
+ q('#asst-quality').innerHTML = 'Оценки за 30 дней: ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' +
+ (f.recent && f.recent.length ? '. Недавно не помогло: ' + f.recent.map(function (x) { return '«' + String(x.q || '').slice(0, 40) + '»'; }).join(', ') : '');
+ }
+ setStatus();
+
+ q('#asst-preset').addEventListener('change', function () { var p = (cfg.presets || [])[Number(this.value)]; if (p) { q('#asst-url').value = p.url; q('#asst-model').value = p.model; } });
+ q('#asst-save').addEventListener('click', async function () {
+ var body = { url: q('#asst-url').value, model: q('#asst-model').value };
+ var k = q('#asst-key').value.trim(); if (k) body.key = k;
+ try { await LS.adminSaveAssistant(body); q('#asst-key').value = ''; LS.toast('Сохранено', 'success'); cfg = await LS.adminGetAssistant(); q('#asst-key').placeholder = cfg.hasKey ? 'Ключ сохранён — введите новый, чтобы заменить' : 'API-ключ'; setStatus(); }
+ catch (e) { LS.toast('Ошибка: ' + (e.message || ''), 'error'); }
+ });
+ q('#asst-test').addEventListener('click', async function () {
+ var res = q('#asst-test-res'); res.innerHTML = 'Проверяю…';
+ var body = { url: q('#asst-url').value, model: q('#asst-model').value };
+ var k = q('#asst-key').value.trim(); if (k) body.key = k;
+ try {
+ var r = await LS.adminTestAssistant(body);
+ res.innerHTML = r && r.ok
+ ? '
✓ Работает (' + (r.model || '') + '): ' + String(r.sample || 'ответ получен').replace(/'
+ : '✗ ' + String((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').slice(0, 200).replace(/';
+ } catch (e) { res.innerHTML = '✗ ' + (e.message || 'ошибка') + ' '; }
+ });
+ q('#asst-clearkey').addEventListener('click', async function () {
+ if (!await LS.confirm('Очистить сохранённый ключ? «Спроси» вернётся в FAQ-режим.', { title: 'Очистить ключ?', confirmText: 'Очистить' })) return;
+ try { await LS.adminSaveAssistant({ clearKey: true }); LS.toast('Ключ очищен', 'success'); cfg = await LS.adminGetAssistant(); q('#asst-key').placeholder = 'API-ключ'; setStatus(); } catch (e) { LS.toast('Ошибка', 'error'); }
+ });
+ q('#asst-rag').addEventListener('change', function () { LS.adminSaveAssistant({ rag: q('#asst-rag').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
+ q('#asst-exambtn').addEventListener('change', function () { LS.adminSaveAssistant({ examButtons: q('#asst-exambtn').checked }).then(function () { LS.toast('Сохранено (обновите страницу экзамена)', 'success'); }).catch(function () {}); });
+ q('#asst-reindex').addEventListener('click', async function () {
+ var btn = q('#asst-reindex'); btn.disabled = true; btn.textContent = 'Индексирую…';
+ try { var r = await LS.adminReindexTextbooks(); cfg.chunks = (r && r.chunks) || 0; setStatus(); LS.toast('Готово: ' + cfg.chunks + ' фрагментов', 'success'); }
+ catch (e) { LS.toast('Ошибка индексации', 'error'); }
+ finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; }
+ });
+ }
+
+ window.AdminSections = window.AdminSections || {};
+ window.AdminSections.assistant = {
+ init: async () => { if (inited) return; inited = true; await render(); },
+ reload: render,
+ };
+})();
diff --git a/frontend/js/admin/sections/games.js b/frontend/js/admin/sections/games.js
index 1a1f904..69cd05f 100644
--- a/frontend/js/admin/sections/games.js
+++ b/frontend/js/admin/sections/games.js
@@ -16,7 +16,6 @@
{ key: 'biochem', label: 'Биохимия', desc: 'Молекулярный редактор, задачи на построение молекул и реакции', icon: 'flask-conical' },
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
{ key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' },
- { key: 'assistant', label: 'Помощник «Квантик»', desc: 'Плавающий помощник: подсказки по разделам, напоминания и «Спроси Квантика»', icon: 'sparkles' },
];
const FS_FEATURES = [
@@ -34,104 +33,8 @@
{ key: 'live_quiz', label: 'Живая викторина', desc: 'Синхронная викторина в реальном времени для всего класса', icon: 'radio' },
];
- /* ── Конфиг LLM для помощника «Квантик» ── */
- var IN_STYLE = 'padding:8px 10px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.85rem;width:100%;box-sizing:border-box;background:var(--surface,#fff);color:var(--text,#0f172a)';
- var BTN_STYLE = 'padding:8px 14px;border-radius:9px;border:1px solid var(--border,#e2e8f0);background:var(--surface,#fff);font:inherit;font-size:.82rem;font-weight:700;cursor:pointer;color:var(--text-2,#475569)';
- async function renderAssistantLlmCard(grid) {
- if (!grid || document.getElementById('asst-llm-card')) return;
- var wrap = document.createElement('div');
- wrap.id = 'asst-llm-card';
- wrap.className = 'perm-card';
- wrap.style.cssText = 'flex-direction:column;align-items:stretch;gap:9px;margin-bottom:14px';
- wrap.innerHTML =
- ' Помощник «Квантик» — модель (ИИ)
' +
- 'Подключение LLM к «Спроси Квантика». OpenAI-совместимый эндпоинт. Без ключа — режим обычного FAQ.
' +
- '
' +
- '— провайдер (пресет) — ' +
- ' ' +
- ' ' +
- ' ' +
- '' +
- 'Сохранить ' +
- 'Проверить ' +
- 'Очистить ключ ' +
- '
' +
- '
' +
- ' ' +
- ' Искать ответы по учебникам (RAG) ' +
- ' Кнопки помощника на карточках экзамена («Подсказка», «Спросить Квантика») ' +
- '' +
- 'Переиндексировать учебники ' +
- ' ' +
- '
' +
- '
';
- grid.parentNode.insertBefore(wrap, grid);
- if (window.lucide) lucide.createIcons();
-
- var q = function (s) { return wrap.querySelector(s); };
- var cfg = {};
- try { cfg = await LS.adminGetAssistant(); } catch (e) {}
- var presetSel = q('#asst-preset');
- (cfg.presets || []).forEach(function (p, i) { var o = document.createElement('option'); o.value = String(i); o.textContent = p.name; presetSel.appendChild(o); });
- q('#asst-url').value = cfg.url || '';
- q('#asst-model').value = cfg.model || '';
- q('#asst-key').placeholder = cfg.hasKey ? 'Ключ сохранён — введите новый, чтобы заменить' : 'API-ключ';
- function setStatus() {
- q('#asst-llm-status').innerHTML = cfg.active
- ? '● Подключено — «Спроси» отвечает через ИИ '
- : '○ Ключ не задан — работает обычный FAQ-режим ';
- q('#asst-rag').checked = cfg.rag !== false;
- q('#asst-exambtn').checked = !!cfg.examButtons;
- q('#asst-chunks').textContent = (cfg.chunks || 0) + ' фрагментов учебников в индексе';
- var u = cfg.usage || {}, u30 = cfg.usage30 || {};
- q('#asst-usage').innerHTML = 'Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. ' +
- 'За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.';
- }
- setStatus();
- q('#asst-rag').addEventListener('change', function () {
- LS.adminSaveAssistant({ rag: q('#asst-rag').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {});
- });
- q('#asst-exambtn').addEventListener('change', function () {
- LS.adminSaveAssistant({ examButtons: q('#asst-exambtn').checked }).then(function () { LS.toast('Сохранено (обновите страницу экзамена)', 'success'); }).catch(function () {});
- });
- q('#asst-reindex').addEventListener('click', async function () {
- var btn = q('#asst-reindex'); btn.disabled = true; btn.textContent = 'Индексирую…';
- try { var r = await LS.adminReindexTextbooks(); cfg.chunks = (r && r.chunks) || 0; setStatus(); LS.toast('Готово: ' + cfg.chunks + ' фрагментов', 'success'); }
- catch (e) { LS.toast('Ошибка индексации', 'error'); }
- finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; }
- });
- presetSel.addEventListener('change', function () {
- var p = (cfg.presets || [])[Number(presetSel.value)];
- if (p) { q('#asst-url').value = p.url; q('#asst-model').value = p.model; }
- });
- q('#asst-save').addEventListener('click', async function () {
- var body = { url: q('#asst-url').value, model: q('#asst-model').value };
- var k = q('#asst-key').value.trim(); if (k) body.key = k;
- try { await LS.adminSaveAssistant(body); q('#asst-key').value = ''; LS.toast('Сохранено', 'success');
- cfg = await LS.adminGetAssistant(); cfg.hasKey && (q('#asst-key').placeholder = 'Ключ сохранён — введите новый, чтобы заменить'); setStatus();
- } catch (e) { LS.toast('Ошибка: ' + (e.message || ''), 'error'); }
- });
- q('#asst-test').addEventListener('click', async function () {
- var res = q('#asst-test-res'); res.innerHTML = 'Проверяю…';
- var body = { url: q('#asst-url').value, model: q('#asst-model').value };
- var k = q('#asst-key').value.trim(); if (k) body.key = k;
- try {
- var r = await LS.adminTestAssistant(body);
- res.innerHTML = r && r.ok
- ? '✓ Работает (' + (r.model || '') + '): ' + (r.sample || 'ответ получен').replace(/'
- : '✗ ' + ((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').toString().slice(0, 200).replace(/';
- } catch (e) { res.innerHTML = '✗ ' + (e.message || 'ошибка') + ' '; }
- });
- q('#asst-clearkey').addEventListener('click', async function () {
- if (!await LS.confirm('Очистить сохранённый ключ? «Спроси» вернётся в FAQ-режим.', { title: 'Очистить ключ?', confirmText: 'Очистить' })) return;
- try { await LS.adminSaveAssistant({ clearKey: true }); LS.toast('Ключ очищен', 'success'); cfg = await LS.adminGetAssistant(); q('#asst-key').placeholder = 'API-ключ'; setStatus(); }
- catch (e) { LS.toast('Ошибка', 'error'); }
- });
- }
-
async function loadGamesAdmin() {
const grid = document.getElementById('games-features-grid');
- renderAssistantLlmCard(grid);
try {
const features = await LS.api('/api/admin/features');
grid.innerHTML = '';
diff --git a/frontend/js/assistant.js b/frontend/js/assistant.js
index 7c62fec..e292c79 100644
--- a/frontend/js/assistant.js
+++ b/frontend/js/assistant.js
@@ -280,9 +280,12 @@
'.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{position:absolute;left:0;bottom:66px;width:330px;max-width:88vw;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;}',
@@ -493,9 +496,10 @@
'Подсказка ' +
'Проверить решение ';
openBubble(
- 'Спроси Квантика' + (_chat.length ? 'Очистить ' : '') + '
' +
+ '' + faceSVG('happy') + ' Спроси Квантика' + (_chat.length ? 'Очистить ' : '') + '
' +
'
' + chips + modes +
- ' ', {});
+ ' ' +
+ 'Я помню последние ~6 сообщений этого разговора — как рабочая память: что было раньше, понимаю; старое постепенно забывается. «Очистить» — начать с чистого листа.
', {});
var inp = bubble.querySelector('.asst-ask-in');
var chatEl = bubble.querySelector('.asst-chat');
var chipsEl = bubble.querySelector('.asst-chips');
@@ -540,9 +544,16 @@
(LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }),
]).then(function (res) {
ph.remove();
- var model = res[0] && res[0].answer;
- var ans = (res[0] && res[0].answers) || [];
- var sources = (res[0] && res[0].sources) || [];
+ 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; return;
+ }
+ var model = r0.source === 'model' ? r0.answer : null;
+ 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 });