feat(assistant): несколько провайдеров ИИ + выбор активного + авто-перехват при лимите

Конфиг стал списком провайдеров (assistant_providers) + активный (assistant_active).
llmConfig берёт активного; providersOrdered — активный первым, затем остальные с
ключом; callLLMFailover перебирает их при 429/сетевой ошибке (второй ключ подхватывает
при исчерпании квоты). Legacy мигрируется в список. Админ-раздел: список провайдеров
(радио-активный, Тест/Изменить/Удалить) + форма с пресетами. Эндпоинты
POST/DELETE /admin/assistant/provider(/:id), POST /admin/assistant/active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 20:21:06 +03:00
parent 78300845ed
commit e2bff24b5b
5 changed files with 227 additions and 98 deletions
+48 -8
View File
@@ -230,12 +230,37 @@ function searchFaq(q, n) {
/* Конфиг берём из 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 _isLocal(url) { return /\/\/(localhost|127\.0\.0\.1)/.test(url || ''); }
/* Список провайдеров (несколько ключей/моделей). Хранится JSON в app_settings.
* Если списка нет — синтезируем из legacy-настроек/ENV, чтобы ничего не сломать. */
function _providers() {
let arr = [];
try { arr = JSON.parse(_setting('assistant_providers') || '[]'); } catch (e) {}
if (!Array.isArray(arr)) arr = [];
if (!arr.length) {
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';
arr = [{ id: 'p1', name: 'Провайдер 1', url, model, key }];
}
return arr;
}
/* Конфиги в порядке использования: активный первым, затем остальные с ключом
* (для авто-перехвата при лимите/ошибке). */
function providersOrdered() {
const arr = _providers().filter(p => p && (p.key || _isLocal(p.url)));
const activeId = _setting('assistant_active');
const active = arr.filter(p => p.id === activeId);
const rest = arr.filter(p => p.id !== activeId);
return active.concat(rest).map(p => ({ id: p.id, name: p.name, url: p.url, key: p.key, model: p.model, local: _isLocal(p.url), on: true }));
}
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 ordered = providersOrdered();
if (ordered.length) return ordered[0];
const url = _setting('assistant_llm_url') || process.env.ASSISTANT_LLM_URL || 'https://api.groq.com/openai/v1/chat/completions';
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) };
return { url, key: '', model, local: _isLocal(url), on: false };
}
/* RAG: релевантные куски учебников (textbook_chunks) под вопрос.
@@ -289,6 +314,21 @@ async function callLLM(messages, maxTokens, override) {
} catch (e) { return { text: null, error: e.name === 'AbortError' ? 'timeout' : 'network' }; } finally { clearTimeout(timer); }
}
/* Перебор провайдеров: активный, затем остальные — при лимите/сетевой ошибке.
* Останавливаемся на успехе или на «контентной» неудаче (пустой ответ). */
const _RETRYABLE = { rate_limit: 1, http: 1, timeout: 1, network: 1 };
async function callLLMFailover(messages, maxTokens) {
const cfgs = providersOrdered();
if (!cfgs.length) return { text: null, error: 'off' };
let last = { text: null, error: 'off' };
for (const c of cfgs) {
last = await callLLM(messages, maxTokens, c);
if (last.text) return last;
if (!_RETRYABLE[last.error]) break; // не лимит/сеть — нет смысла пробовать другие
}
return last;
}
/* Тест-пинг для админки: подробный статус (status/ошибка/пример ответа). */
async function pingLLM(override) {
const cfg = override || llmConfig();
@@ -350,7 +390,7 @@ async function askModel(q, hits, context, history, role, mode) {
const msgs = [{ role: 'system', content: 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);
return callLLMFailover(msgs, 420);
}
/* ── POST /api/assistant/ask { q, context?, history? } ── «Спроси Квантика» ─
@@ -367,7 +407,7 @@ async function ask(req, res) {
const hits = searchFaq(q, 3);
const faqJson = hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null }));
if (!llmConfig().on) { bumpUsage('faq'); return res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] }); }
if (!providersOrdered().length) { bumpUsage('faq'); return res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] }); }
const rag = ragContext(q);
@@ -416,14 +456,14 @@ function feedback(req, res) {
* Генерирует учебные карточки из текста (модель → JSON). Карточки фронт
* создаёт сам через существующий API флешкарт. */
async function flashcardsFromText(req, res) {
if (!llmConfig().on) return res.status(503).json({ error: 'LLM не настроена' });
if (!providersOrdered().length) 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: 'Слишком мало текста' });
const sys = 'Ты составляешь учебные флешкарты по тексту. Верни СТРОГО JSON-массив из 5–6 объектов ' +
'вида {"front":"...","back":"..."} без markdown и пояснений. front — короткий вопрос, back — краткий ответ (1–2 предложения). ' +
'По-русски. Формулы в LaTeX между $...$. Никакого текста вне JSON.';
const rr = await callLLM([{ role: 'system', content: sys }, { role: 'user', content: text }], 1400);
const rr = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: text }], 1400);
const raw = rr && rr.text;
let cards = [];
if (raw) {