feat(assistant): чёткий ответ при лимите ИИ (память не теряется), напоминание о памяти, отдельный раздел в админке

- Баг «не помнит»: на самом деле free-лимит Gemini (429). callLLM теперь
  возвращает ошибку; при 429 показываем «много запросов, подожди минутку —
  память не потеряется» и НЕ ломаем историю (убираем неудачный вопрос); при
  сбое — «не получилось, попробуй позже». Раньше показывалось «не нашёл ответ».
- В окне «Спроси» — пояснение, сколько помнит Квантик (≈6 реплик, рабочая память).
- Окна красивее: шире, аватар Квантика в шапке, мягкая анимация.
- Управление помощником вынесено в отдельный раздел админки «Помощник Квантик»
  (системный вкл/выкл + модель/ключ/тест/RAG/кнопки экзамена/статистика/качество);
  из раздела «Игры» конфиг убран.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 20:03:02 +03:00
parent 961504b256
commit 78300845ed
6 changed files with 176 additions and 116 deletions
+20 -10
View File
@@ -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();