feat(assistant): админ-панель LLM (ключ/URL/модель/тест) + многоходовой чат
Админка (Управление → игры/фичи): карточка «Помощник Квантик — модель» — пресеты провайдеров, URL/модель, поле ключа, кнопки Сохранить/Проверить/ Очистить ключ, индикатор статуса. Конфиг в app_settings (без рестарта), откат на ENV/дефолты; нет ключа → автоматически FAQ-режим. Эндпоинты GET/PUT/POST /api/admin/assistant(/test), admin-only. «Спроси Квантика» теперь многоходовой чат: история диалога (последние 6 реплик) уходит модели, ответы рендерятся как чат-лента, кнопка «Очистить». Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -225,23 +225,28 @@ function searchFaq(q, n) {
|
||||
* ASSISTANT_LLM_KEY (Bearer-ключ; для localhost/Ollama не нужен)
|
||||
* ASSISTANT_LLM_MODEL (по умолч. llama-3.3-70b-versatile)
|
||||
* Если ключ не задан и URL не локальный — тихо работаем как раньше (FAQ). */
|
||||
const LLM_URL = process.env.ASSISTANT_LLM_URL || 'https://api.groq.com/openai/v1/chat/completions';
|
||||
const LLM_KEY = process.env.ASSISTANT_LLM_KEY || '';
|
||||
const LLM_MODEL = process.env.ASSISTANT_LLM_MODEL || 'llama-3.3-70b-versatile';
|
||||
const LLM_LOCAL = /\/\/(localhost|127\.0\.0\.1)/.test(LLM_URL);
|
||||
|
||||
const LLM_ON = !!(LLM_KEY || LLM_LOCAL);
|
||||
/* Конфиг берём из app_settings (правится из админки без рестарта), с откатом
|
||||
* на ENV и дефолты. Если ключа нет и URL не локальный — работаем как FAQ. */
|
||||
function _setting(k) { try { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; } catch (e) { return null; } }
|
||||
function llmConfig() {
|
||||
const url = _setting('assistant_llm_url') || process.env.ASSISTANT_LLM_URL || 'https://api.groq.com/openai/v1/chat/completions';
|
||||
const key = _setting('assistant_llm_key') || process.env.ASSISTANT_LLM_KEY || '';
|
||||
const model = _setting('assistant_llm_model') || process.env.ASSISTANT_LLM_MODEL || 'llama-3.3-70b-versatile';
|
||||
const local = /\/\/(localhost|127\.0\.0\.1)/.test(url);
|
||||
return { url, key, model, local, on: !!(key || local) };
|
||||
}
|
||||
|
||||
/* Низкоуровневый вызов OpenAI-совместимого chat/completions. */
|
||||
async function callLLM(messages, maxTokens) {
|
||||
if (typeof fetch !== 'function' || !LLM_ON) return null;
|
||||
async function callLLM(messages, maxTokens, override) {
|
||||
const cfg = override || llmConfig();
|
||||
if (typeof fetch !== 'function' || !cfg.on) return null;
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 15000);
|
||||
try {
|
||||
const r = await fetch(LLM_URL, {
|
||||
const r = await fetch(cfg.url, {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, LLM_KEY ? { Authorization: `Bearer ${LLM_KEY}` } : {}),
|
||||
body: JSON.stringify({ model: LLM_MODEL, temperature: 0.3, max_tokens: maxTokens || 320, messages }),
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, cfg.key ? { Authorization: `Bearer ${cfg.key}` } : {}),
|
||||
body: JSON.stringify({ model: cfg.model, temperature: 0.3, max_tokens: maxTokens || 320, messages }),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!r.ok) return null;
|
||||
@@ -251,6 +256,33 @@ async function callLLM(messages, maxTokens) {
|
||||
} catch (e) { return null; } finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
/* Тест-пинг для админки: подробный статус (status/ошибка/пример ответа). */
|
||||
async function pingLLM(override) {
|
||||
const cfg = override || llmConfig();
|
||||
if (!cfg.url) return { ok: false, error: 'URL не задан' };
|
||||
if (!cfg.key && !/\/\/(localhost|127\.0\.0\.1)/.test(cfg.url)) return { ok: false, error: 'Ключ не задан' };
|
||||
if (typeof fetch !== 'function') return { ok: false, error: 'fetch недоступен' };
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 15000);
|
||||
try {
|
||||
const r = await fetch(cfg.url, {
|
||||
method: 'POST',
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, cfg.key ? { Authorization: `Bearer ${cfg.key}` } : {}),
|
||||
body: JSON.stringify({ model: cfg.model, max_tokens: 16, messages: [{ role: 'user', content: 'Ответь одним словом: привет' }] }),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
const txt = await r.text();
|
||||
if (!r.ok) {
|
||||
let msg = txt.slice(0, 300);
|
||||
try { const j = JSON.parse(txt); if (j && j.error) msg = String(j.error.message || JSON.stringify(j.error)).slice(0, 300); } catch (e) {}
|
||||
return { ok: false, status: r.status, error: msg };
|
||||
}
|
||||
let sample = '';
|
||||
try { const j = JSON.parse(txt); sample = String((j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || '').slice(0, 120); } catch (e) {}
|
||||
return { ok: true, status: r.status, sample, model: cfg.model };
|
||||
} catch (e) { return { ok: false, error: e.name === 'AbortError' ? 'Таймаут (15с)' : (e.message || 'Ошибка сети') }; } finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
const ASSISTANT_SYS = 'Ты — Квантик, дружелюбный помощник учебной платформы LearnSpace. ' +
|
||||
'Отвечай по-русски, кратко и понятно, на «ты», как для школьника. ' +
|
||||
'Если вопрос о работе платформы — опирайся на справку ниже и не выдумывай разделы/кнопки, которых в ней нет ' +
|
||||
@@ -258,23 +290,28 @@ const ASSISTANT_SYS = 'Ты — Квантик, дружелюбный помо
|
||||
'Если это учебный или общий вопрос (математика, физика, объяснить понятие, решить пример) — отвечай по существу и помоги разобраться. ' +
|
||||
'Формулы и математику оформляй в LaTeX между знаками доллара, например $a^2+b^2=c^2$. Не используй эмодзи.';
|
||||
|
||||
async function askModel(q, hits, context) {
|
||||
async function askModel(q, hits, context, history) {
|
||||
const ref = hits.map((h, i) => `${i + 1}. ${h.q}\n${h.a}${h.url ? ` (раздел: ${h.url})` : ''}`).join('\n') || '(пусто)';
|
||||
const user = (context ? `Контекст со страницы (на него опирайся, если вопрос про него):\n${context}\n\n` : '') +
|
||||
`Справка по платформе:\n${ref}\n\nВопрос: ${q}`;
|
||||
return callLLM([{ role: 'system', content: ASSISTANT_SYS }, { role: 'user', content: user }], 380);
|
||||
const msgs = [{ role: 'system', content: ASSISTANT_SYS }];
|
||||
(history || []).forEach(m => { if (m && (m.role === 'user' || m.role === 'assistant') && m.content) msgs.push({ role: m.role, content: String(m.content).slice(0, 1500) }); });
|
||||
msgs.push({ role: 'user', content: user });
|
||||
return callLLM(msgs, 420);
|
||||
}
|
||||
|
||||
/* ── POST /api/assistant/ask { q, context? } ── «Спроси Квантика» ─────────
|
||||
* Грунтуем ответ топ-FAQ (+ опц. контекстом страницы/выделенного). Если LLM
|
||||
* настроена — даём её ответ (source:'model'), иначе FAQ (source:'faq'). */
|
||||
/* ── POST /api/assistant/ask { q, context?, history? } ── «Спроси Квантика» ─
|
||||
* Грунтуем ответ топ-FAQ (+ опц. контекст страницы + история диалога). Если
|
||||
* LLM настроена — её ответ (source:'model'), иначе FAQ (source:'faq'). */
|
||||
async function ask(req, res) {
|
||||
const q = String((req.body && req.body.q) || '').trim().slice(0, 500);
|
||||
if (!q || q.length < 2) return res.json({ source: 'faq', answer: null, answers: [] });
|
||||
const context = String((req.body && req.body.context) || '').slice(0, 4000);
|
||||
let history = (req.body && req.body.history);
|
||||
history = Array.isArray(history) ? history.slice(-6) : [];
|
||||
const hits = searchFaq(q, 3);
|
||||
let answer = null;
|
||||
if (LLM_ON) { try { answer = await askModel(q, hits, context); } catch (e) { answer = null; } }
|
||||
if (llmConfig().on) { try { answer = await askModel(q, hits, context, history); } catch (e) { answer = null; } }
|
||||
res.json({
|
||||
source: answer ? 'model' : 'faq',
|
||||
answer: answer || null,
|
||||
@@ -286,7 +323,7 @@ async function ask(req, res) {
|
||||
* Генерирует учебные карточки из текста (модель → JSON). Карточки фронт
|
||||
* создаёт сам через существующий API флешкарт. */
|
||||
async function flashcardsFromText(req, res) {
|
||||
if (!LLM_ON) return res.status(503).json({ error: 'LLM не настроена' });
|
||||
if (!llmConfig().on) return res.status(503).json({ error: 'LLM не настроена' });
|
||||
const text = String((req.body && req.body.text) || '').trim().slice(0, 6000);
|
||||
const title = String((req.body && req.body.title) || 'Карточки').trim().slice(0, 80) || 'Карточки';
|
||||
if (text.length < 20) return res.status(400).json({ error: 'Слишком мало текста' });
|
||||
@@ -316,4 +353,4 @@ async function flashcardsFromText(req, res) {
|
||||
res.json({ title, cards });
|
||||
}
|
||||
|
||||
module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText };
|
||||
module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText, llmConfig, pingLLM };
|
||||
|
||||
Reference in New Issue
Block a user