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
+75 -20
View File
@@ -886,12 +886,27 @@ const ASSISTANT_PRESETS = [
];
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 _aProviders() { try { return JSON.parse(_aset('assistant_providers') || '[]') || []; } catch (e) { return []; } }
function _aSetProviders(arr) { db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_providers', ?)").run(JSON.stringify(arr)); }
function _aIsLocal(u) { return /\/\/(localhost|127\.0\.0\.1)/.test(u || ''); }
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);
// Миграция legacy-настроек в список провайдеров (один раз)
if (!_aset('assistant_providers')) {
const lurl = _aset('assistant_llm_url') || process.env.ASSISTANT_LLM_URL;
const lkey = _aset('assistant_llm_key') || process.env.ASSISTANT_LLM_KEY;
const lmodel = _aset('assistant_llm_model') || process.env.ASSISTANT_LLM_MODEL;
if (lurl || lkey || lmodel) {
_aSetProviders([{ id: 'p1', name: 'Провайдер 1', url: lurl || ASSISTANT_PRESETS[1].url, model: lmodel || ASSISTANT_PRESETS[1].model, key: lkey || '' }]);
if (!_aset('assistant_active')) db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', 'p1')").run();
}
}
const list = _aProviders();
const providers = list.map(p => ({ id: p.id, name: p.name, url: p.url, model: p.model, hasKey: !!p.key }));
const activeId = _aset('assistant_active') || (providers[0] && providers[0].id) || null;
const ap = list.find(p => p.id === activeId);
const active = !!(ap && (ap.key || _aIsLocal(ap.url)));
let chunks = 0, usage = { model_calls: 0, cache_hits: 0, faq: 0 }, usage30 = { model_calls: 0, cache_hits: 0, faq: 0 };
try { chunks = db.prepare('SELECT COUNT(*) n FROM textbook_chunks').get().n; } catch (e) {}
try {
@@ -907,23 +922,55 @@ function getAssistant(_req, res) {
feedback.recent = db.prepare("SELECT q, created_at FROM assistant_feedback WHERE rating=-1 AND q IS NOT NULL AND q <> '' ORDER BY id DESC LIMIT 5").all();
} catch (e) {}
res.json({
url, model, hasKey, keyFromEnv: !dbKey && !!process.env.ASSISTANT_LLM_KEY, active: !!(hasKey || local),
providers, activeId, active,
rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1',
chunks, usage, usage30, feedback, presets: ASSISTANT_PRESETS,
});
}
/* PATCH /api/admin/assistant — только тумблеры (RAG, кнопки экзамена) */
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 (typeof b.rag === 'boolean') set('assistant_rag', b.rag ? '1' : '0');
if (typeof b.examButtons === 'boolean') set('assistant_exam_buttons', b.examButtons ? '1' : '0');
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 ? 'ключ очищен' : 'обновлено');
audit(req, 'assistant.config', 'assistant', 'настройки');
res.json({ ok: true });
}
/* POST /api/admin/assistant/provider — добавить/обновить провайдера */
function saveProvider(req, res) {
const arr = _aProviders();
const b = req.body || {};
let p;
if (b.id) { p = arr.find(x => x.id === b.id); if (!p) { p = { id: b.id }; arr.push(p); } }
else { p = { id: 'p' + Date.now().toString(36) + Math.floor(Math.random() * 1000) }; arr.push(p); }
if (typeof b.name === 'string') p.name = b.name.trim().slice(0, 40) || p.name || 'Провайдер';
if (typeof b.url === 'string') p.url = b.url.trim().slice(0, 300);
if (typeof b.model === 'string') p.model = b.model.trim().slice(0, 120);
if (typeof b.key === 'string' && b.key.trim()) p.key = b.key.trim().slice(0, 400);
if (!p.name) p.name = 'Провайдер';
_aSetProviders(arr);
if (b.makeActive || arr.length === 1) db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', ?)").run(p.id);
audit(req, 'assistant.provider', p.id, 'сохранён');
res.json({ ok: true, id: p.id });
}
/* DELETE /api/admin/assistant/provider/:id */
function deleteProvider(req, res) {
let arr = _aProviders();
arr = arr.filter(x => x.id !== req.params.id);
_aSetProviders(arr);
if (_aset('assistant_active') === req.params.id && arr[0]) db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', ?)").run(arr[0].id);
audit(req, 'assistant.provider.del', req.params.id, 'удалён');
res.json({ ok: true });
}
/* POST /api/admin/assistant/active { id } — выбрать активного провайдера */
function setActiveProvider(req, res) {
const id = String((req.body && req.body.id) || '');
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', ?)").run(id);
audit(req, 'assistant.active', id, 'активный провайдер');
res.json({ ok: true });
}
@@ -939,14 +986,22 @@ function reindexTextbooks(req, res) {
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);
let override;
if (b.id) {
const p = _aProviders().find(x => x.id === b.id);
if (!p) return res.json({ ok: false, error: 'провайдер не найден' });
// если ключ пуст (не вводили) — берём сохранённый у этого провайдера
override = { url: (b.url && b.url.trim()) || p.url, model: (b.model && b.model.trim()) || p.model, key: (b.key && b.key.trim()) || p.key };
} else {
const cfg = a.llmConfig();
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 = _aIsLocal(override.url);
override.on = !!(override.key || override.local);
const r = await a.pingLLM(override);
res.json(r);
@@ -961,5 +1016,5 @@ module.exports = {
getSecurityLog, clearSecurityLog,
getTopics, createTopic, updateTopic, deleteTopic,
broadcast,
getAssistant, saveAssistant, testAssistant, reindexTextbooks,
getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider,
};