feat(assistant): уведомление о failover в админке
callLLMFailover пишет состояние в app_settings.assistant_failover: какой провайдер исчерпан и каким подхвачено (или «все недоступны»); при успехе активного флаг снимается. Админ-раздел показывает баннер «Провайдер X недоступен — работаю на Y». Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -921,10 +921,12 @@ function getAssistant(_req, res) {
|
|||||||
if (f) { feedback.up = f.up; feedback.down = f.down; }
|
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();
|
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) {}
|
} catch (e) {}
|
||||||
|
let failover = null;
|
||||||
|
try { var fv = _aset('assistant_failover'); if (fv) failover = JSON.parse(fv); } catch (e) {}
|
||||||
res.json({
|
res.json({
|
||||||
providers, activeId, active,
|
providers, activeId, active,
|
||||||
rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1',
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -317,15 +317,29 @@ async function callLLM(messages, maxTokens, override) {
|
|||||||
/* Перебор провайдеров: активный, затем остальные — при лимите/сетевой ошибке.
|
/* Перебор провайдеров: активный, затем остальные — при лимите/сетевой ошибке.
|
||||||
* Останавливаемся на успехе или на «контентной» неудаче (пустой ответ). */
|
* Останавливаемся на успехе или на «контентной» неудаче (пустой ответ). */
|
||||||
const _RETRYABLE = { rate_limit: 1, http: 1, timeout: 1, network: 1 };
|
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) {
|
async function callLLMFailover(messages, maxTokens) {
|
||||||
const cfgs = providersOrdered();
|
const cfgs = providersOrdered();
|
||||||
if (!cfgs.length) return { text: null, error: 'off' };
|
if (!cfgs.length) return { text: null, error: 'off' };
|
||||||
let last = { text: null, error: 'off' };
|
let last = { text: null, error: 'off' }, firstErr = null;
|
||||||
for (const c of cfgs) {
|
for (let i = 0; i < cfgs.length; i++) {
|
||||||
last = await callLLM(messages, maxTokens, c);
|
last = await callLLM(messages, maxTokens, cfgs[i]);
|
||||||
if (last.text) return last;
|
if (i === 0) firstErr = last.error;
|
||||||
if (!_RETRYABLE[last.error]) break; // не лимит/сеть — нет смысла пробовать другие
|
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;
|
return last;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,20 @@
|
|||||||
var activeId = cfg.activeId;
|
var activeId = cfg.activeId;
|
||||||
var presets = cfg.presets || [];
|
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
|
||||||
|
? '<b>Переключение провайдера.</b> «' + esc(fo.failedName || '?') + '» недоступен (' + rsn + ') — работаю на «' + esc(fo.servedName) + '». ' + (when ? '<span style="opacity:.7">' + esc(when) + '</span>' : '') + '<br><span style="opacity:.8">Снимется автоматически, когда активный снова заработает.</span>'
|
||||||
|
: '<b>Все провайдеры ИИ недоступны</b> (' + rsn + '). Сейчас «Спроси» работает в FAQ-режиме. ' + (when ? '<span style="opacity:.7">' + esc(when) + '</span>' : '');
|
||||||
|
host.appendChild(ban);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Провайдеры ИИ ──
|
// ── Провайдеры ИИ ──
|
||||||
var pc = document.createElement('div');
|
var pc = document.createElement('div');
|
||||||
pc.className = 'perm-card';
|
pc.className = 'perm-card';
|
||||||
|
|||||||
Reference in New Issue
Block a user