From aac1240658c553bd704228510562d2eebdb03216 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 4 Jun 2026 20:27:29 +0300 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20=D1=83=D0=B2=D0=B5=D0=B4?= =?UTF-8?q?=D0=BE=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=20failover?= =?UTF-8?q?=20=D0=B2=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit callLLMFailover пишет состояние в app_settings.assistant_failover: какой провайдер исчерпан и каким подхвачено (или «все недоступны»); при успехе активного флаг снимается. Админ-раздел показывает баннер «Провайдер X недоступен — работаю на Y». Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/controllers/adminController.js | 4 +++- .../src/controllers/assistantController.js | 24 +++++++++++++++---- frontend/js/admin/sections/assistant.js | 14 +++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index a306e99..afa1627 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -921,10 +921,12 @@ function getAssistant(_req, res) { if (f) { feedback.up = f.up; feedback.down = f.down; } 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) {} + let failover = null; + try { var fv = _aset('assistant_failover'); if (fv) failover = JSON.parse(fv); } catch (e) {} res.json({ providers, activeId, active, rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1', - chunks, usage, usage30, feedback, presets: ASSISTANT_PRESETS, + chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS, }); } diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js index d902d24..07957e3 100644 --- a/backend/src/controllers/assistantController.js +++ b/backend/src/controllers/assistantController.js @@ -317,15 +317,29 @@ async function callLLM(messages, maxTokens, override) { /* Перебор провайдеров: активный, затем остальные — при лимите/сетевой ошибке. * Останавливаемся на успехе или на «контентной» неудаче (пустой ответ). */ const _RETRYABLE = { rate_limit: 1, http: 1, timeout: 1, network: 1 }; +function _recordFailover(failed, served, reason) { + try { + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_failover', ?)") + .run(JSON.stringify({ at: new Date().toISOString(), failedId: failed && failed.id, failedName: failed && failed.name, servedId: served && served.id, servedName: served && served.name, reason: reason || 'error' })); + } catch (e) {} +} +function _clearFailover() { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} } + 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; // не лимит/сеть — нет смысла пробовать другие + let last = { text: null, error: 'off' }, firstErr = null; + for (let i = 0; i < cfgs.length; i++) { + last = await callLLM(messages, maxTokens, cfgs[i]); + if (i === 0) firstErr = last.error; + if (last.text) { + if (i === 0) _clearFailover(); // активный работает — снимаем флаг + else _recordFailover(cfgs[0], cfgs[i], firstErr); // активный упал → выручил запасной + return last; + } + if (!_RETRYABLE[last.error]) break; // не лимит/сеть — нет смысла пробовать других } + if (cfgs.length && _RETRYABLE[firstErr]) _recordFailover(cfgs[0], null, firstErr); // все недоступны return last; } diff --git a/frontend/js/admin/sections/assistant.js b/frontend/js/admin/sections/assistant.js index f211336..4776a6c 100644 --- a/frontend/js/admin/sections/assistant.js +++ b/frontend/js/admin/sections/assistant.js @@ -40,6 +40,20 @@ var activeId = cfg.activeId; var presets = cfg.presets || []; + // ── Уведомление о failover ── + if (cfg.failover) { + var fo = cfg.failover; + var reasonMap = { rate_limit: 'исчерпан лимит', http: 'ошибка API', timeout: 'таймаут', network: 'нет связи', error: 'ошибка' }; + var rsn = reasonMap[fo.reason] || 'ошибка'; + var when = ''; try { when = new Date(fo.at).toLocaleString('ru'); } catch (e) {} + var ban = document.createElement('div'); + ban.style.cssText = 'margin-top:14px;padding:11px 14px;border-radius:10px;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.4);color:#92400e;font-size:.84rem;line-height:1.5'; + ban.innerHTML = fo.servedName + ? 'Переключение провайдера. «' + esc(fo.failedName || '?') + '» недоступен (' + rsn + ') — работаю на «' + esc(fo.servedName) + '». ' + (when ? '' + esc(when) + '' : '') + '
Снимется автоматически, когда активный снова заработает.' + : 'Все провайдеры ИИ недоступны (' + rsn + '). Сейчас «Спроси» работает в FAQ-режиме. ' + (when ? '' + esc(when) + '' : ''); + host.appendChild(ban); + } + // ── Провайдеры ИИ ── var pc = document.createElement('div'); pc.className = 'perm-card';