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:
@@ -877,6 +877,51 @@ function broadcast(req, res) {
|
||||
res.json({ ok: true, sent: users.length });
|
||||
}
|
||||
|
||||
/* ── Ассистент «Квантик»: конфиг LLM из админки ──────────────────────── */
|
||||
const ASSISTANT_PRESETS = [
|
||||
{ name: 'Google Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', model: 'gemini-2.5-flash' },
|
||||
{ name: 'Groq', url: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.3-70b-versatile' },
|
||||
{ name: 'OpenRouter', url: 'https://openrouter.ai/api/v1/chat/completions', model: 'meta-llama/llama-3.3-70b-instruct:free' },
|
||||
{ name: 'Ollama (локально)', url: 'http://localhost:11434/v1/chat/completions', model: 'qwen2.5:3b' },
|
||||
];
|
||||
function _aset(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; }
|
||||
|
||||
function getAssistant(_req, res) {
|
||||
const url = _aset('assistant_llm_url') || process.env.ASSISTANT_LLM_URL || ASSISTANT_PRESETS[1].url;
|
||||
const model = _aset('assistant_llm_model') || process.env.ASSISTANT_LLM_MODEL || ASSISTANT_PRESETS[1].model;
|
||||
const dbKey = _aset('assistant_llm_key');
|
||||
const hasKey = !!(dbKey || process.env.ASSISTANT_LLM_KEY);
|
||||
const local = /\/\/(localhost|127\.0\.0\.1)/.test(url);
|
||||
res.json({ url, model, hasKey, keyFromEnv: !dbKey && !!process.env.ASSISTANT_LLM_KEY, active: !!(hasKey || local), presets: ASSISTANT_PRESETS });
|
||||
}
|
||||
|
||||
function saveAssistant(req, res) {
|
||||
const set = (k, v) => db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(k, v);
|
||||
const del = (k) => db.prepare('DELETE FROM app_settings WHERE key = ?').run(k);
|
||||
const b = req.body || {};
|
||||
if (typeof b.url === 'string') set('assistant_llm_url', b.url.trim().slice(0, 300));
|
||||
if (typeof b.model === 'string') set('assistant_llm_model', b.model.trim().slice(0, 120));
|
||||
if (b.clearKey) del('assistant_llm_key');
|
||||
else if (typeof b.key === 'string' && b.key.trim()) set('assistant_llm_key', b.key.trim().slice(0, 400));
|
||||
audit(req, 'assistant.config', 'assistant', b.clearKey ? 'ключ очищен' : 'обновлено');
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
async function testAssistant(req, res) {
|
||||
const a = require('./assistantController');
|
||||
const cfg = a.llmConfig();
|
||||
const b = req.body || {};
|
||||
const override = {
|
||||
url: (typeof b.url === 'string' && b.url.trim()) ? b.url.trim() : cfg.url,
|
||||
model: (typeof b.model === 'string' && b.model.trim()) ? b.model.trim() : cfg.model,
|
||||
key: (typeof b.key === 'string' && b.key.trim()) ? b.key.trim() : cfg.key,
|
||||
};
|
||||
override.local = /\/\/(localhost|127\.0\.0\.1)/.test(override.url);
|
||||
override.on = !!(override.key || override.local);
|
||||
const r = await a.pingLLM(override);
|
||||
res.json(r);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getStats, getOverview, globalSearch,
|
||||
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
||||
@@ -886,4 +931,5 @@ module.exports = {
|
||||
getSecurityLog, clearSecurityLog,
|
||||
getTopics, createTopic, updateTopic, deleteTopic,
|
||||
broadcast,
|
||||
getAssistant, saveAssistant, testAssistant,
|
||||
};
|
||||
|
||||
@@ -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