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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user