feat(assistant): красивый интерактивный модуль провайдеров + модели Kilo
Админ-раздел переделан: провайдеры — карточки (активный подсвечен, бейджи ключ/активен, кнопки Сделать активным/Тест/Изменить/Удалить, hover-подъём). Форма с лейблами и пресетами. Для Kilo — выпадающий список проверенных бесплатных моделей (Nemotron 550B / Owl Alpha / Nemotron Nano 30B / Laguna XS) и инлайн- переключатель модели прямо на карточке. Бэкенд: пресет Kilo + kiloModels в /admin/assistant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -879,11 +879,19 @@ function broadcast(req, res) {
|
||||
|
||||
/* ── Ассистент «Квантик»: конфиг LLM из админки ──────────────────────── */
|
||||
const ASSISTANT_PRESETS = [
|
||||
{ name: 'Kilo Code (бесплатно)', url: 'https://kilocode.ai/api/openrouter/chat/completions', model: 'nvidia/nemotron-3-ultra-550b-a55b:free' },
|
||||
{ name: 'Google Gemini', url: 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', model: 'gemini-2.5-flash' },
|
||||
{ name: 'Groq', url: 'https://api.groq.com/openai/v1/chat/completions', model: 'llama-3.3-70b-versatile' },
|
||||
{ name: 'OpenRouter', url: 'https://openrouter.ai/api/v1/chat/completions', model: 'meta-llama/llama-3.3-70b-instruct:free' },
|
||||
{ name: 'Ollama (локально)', url: 'http://localhost:11434/v1/chat/completions', model: 'qwen2.5:3b' },
|
||||
];
|
||||
// Проверенные бесплатные модели Kilo (чистый русский) — для выпадающего списка
|
||||
const KILO_MODELS = [
|
||||
{ id: 'nvidia/nemotron-3-ultra-550b-a55b:free', label: 'Nemotron 550B (флагман)' },
|
||||
{ id: 'openrouter/owl-alpha', label: 'Owl Alpha' },
|
||||
{ id: 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free', label: 'Nemotron Nano 30B (быстрее)' },
|
||||
{ id: 'poolside/laguna-xs.2:free', label: 'Laguna XS (лёгкая)' },
|
||||
];
|
||||
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 []; } }
|
||||
@@ -926,7 +934,7 @@ function getAssistant(_req, res) {
|
||||
res.json({
|
||||
providers, activeId, active,
|
||||
rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1',
|
||||
chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS,
|
||||
chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS, kiloModels: KILO_MODELS,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,49 @@
|
||||
'use strict';
|
||||
/* admin → «Помощник Квантик»: системный вкл/выкл + несколько провайдеров ИИ
|
||||
* (ключи/модели) с выбором активного и авто-перехватом при лимите + RAG, кнопки
|
||||
* экзамена, статистика и качество. */
|
||||
/* admin → «Помощник Квантик»: системный вкл/выкл + провайдеры ИИ (карточки,
|
||||
* активный, авто-перехват, переключение модели Kilo) + RAG/экзамен/статистика. */
|
||||
(function () {
|
||||
'use strict';
|
||||
let inited = false;
|
||||
var editingId = null;
|
||||
var IN = 'padding:8px 10px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font:inherit;font-size:.85rem;width:100%;box-sizing:border-box;background:var(--surface,#fff);color:var(--text,#0f172a)';
|
||||
var BTN = 'padding:8px 14px;border-radius:9px;border:1px solid var(--border,#e2e8f0);background:var(--surface,#fff);font:inherit;font-size:.82rem;font-weight:700;cursor:pointer;color:var(--text-2,#475569)';
|
||||
var SBTN = 'border:1px solid var(--border,#e2e8f0);background:var(--surface,#fff);border-radius:7px;padding:3px 9px;font:inherit;font-size:.74rem;font-weight:700;cursor:pointer;color:var(--text-2,#475569)';
|
||||
let inited = false, editingId = null;
|
||||
var esc = (window.LS && LS.escapeHtml) ? LS.escapeHtml : function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]; }); };
|
||||
var IN = 'padding:8px 11px;border:1px solid var(--border,#e2e8f0);border-radius:9px;font:inherit;font-size:.85rem;width:100%;box-sizing:border-box;background:var(--surface,#fff);color:var(--text,#0f172a)';
|
||||
var SPARK = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/></svg>';
|
||||
|
||||
function ensureStyle() {
|
||||
if (document.getElementById('asst-adm-style')) return;
|
||||
var s = document.createElement('style'); s.id = 'asst-adm-style';
|
||||
s.textContent = [
|
||||
'.asst-prov{display:flex;flex-direction:column;gap:9px;}',
|
||||
'.asst-pcard{display:flex;align-items:center;gap:12px;padding:11px 13px;border:1.5px solid var(--border,#e2e8f0);border-radius:13px;background:var(--surface,#fff);transition:border-color .15s,box-shadow .15s,transform .12s;}',
|
||||
'.asst-pcard:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(15,23,42,.07);}',
|
||||
'.asst-pcard.active{border-color:#9B5DE5;background:rgba(155,93,229,.06);box-shadow:0 2px 12px rgba(155,93,229,.14);}',
|
||||
'.asst-pcic{width:38px;height:38px;border-radius:11px;display:flex;align-items:center;justify-content:center;background:rgba(155,93,229,.12);color:#9B5DE5;flex-shrink:0;}',
|
||||
'.asst-pcb{flex:1;min-width:0;}',
|
||||
'.asst-pcn{font-weight:800;font-size:.92rem;color:var(--text,#0f172a);display:flex;align-items:center;gap:7px;flex-wrap:wrap;}',
|
||||
'.asst-pcs{font-size:.76rem;color:#8a94a6;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}',
|
||||
'.asst-bdg{font-size:.6rem;font-weight:800;text-transform:uppercase;letter-spacing:.03em;padding:2px 8px;border-radius:99px;}',
|
||||
'.asst-bdg.act{background:#9B5DE5;color:#fff;}',
|
||||
'.asst-bdg.key{background:rgba(5,150,82,.12);color:#059652;}',
|
||||
'.asst-bdg.nokey{background:rgba(224,51,94,.12);color:#e0335e;}',
|
||||
'.asst-pca{display:flex;gap:6px;flex-shrink:0;flex-wrap:wrap;justify-content:flex-end;}',
|
||||
'.asst-ib{border:1px solid var(--border,#e2e8f0);background:var(--surface,#fff);border-radius:8px;padding:5px 9px;font:inherit;font-size:.74rem;font-weight:700;cursor:pointer;color:var(--text-2,#475569);}',
|
||||
'.asst-ib:hover{border-color:#9B5DE5;color:#9B5DE5;}',
|
||||
'.asst-ib.primary{background:#9B5DE5;border-color:#9B5DE5;color:#fff;}',
|
||||
'.asst-ib.primary:hover{background:#7e3eca;color:#fff;}',
|
||||
'.asst-ksel{margin-top:7px;font:inherit;font-size:.78rem;padding:5px 9px;border:1px solid rgba(155,93,229,.3);border-radius:8px;background:var(--surface,#fff);color:var(--text,#0f172a);max-width:100%;}',
|
||||
'.asst-flabel{font-size:.74rem;font-weight:700;color:var(--text-2,#475569);margin:2px 0 -3px;}',
|
||||
'.asst-ptest{font-size:.8rem;line-height:1.5;padding:2px 0;}',
|
||||
].join('');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
async function render() {
|
||||
var host = document.getElementById('assistant-admin');
|
||||
if (!host) return;
|
||||
ensureStyle();
|
||||
host.innerHTML = '';
|
||||
|
||||
// ── Системный выключатель ──
|
||||
var feats = {};
|
||||
try { feats = await LS.api('/api/admin/features'); } catch (e) {}
|
||||
var feats = {}; try { feats = await LS.api('/api/admin/features'); } catch (e) {}
|
||||
var on = feats.assistant !== false;
|
||||
var master = document.createElement('div');
|
||||
master.className = 'perm-card' + (on ? ' enabled' : '');
|
||||
@@ -34,132 +59,141 @@
|
||||
.catch(function () { master.querySelector('#asst-master').checked = !v; LS.toast('Ошибка', 'error'); });
|
||||
});
|
||||
|
||||
var cfg = {};
|
||||
try { cfg = await LS.adminGetAssistant(); } catch (e) {}
|
||||
var providers = cfg.providers || [];
|
||||
var activeId = cfg.activeId;
|
||||
var presets = cfg.presets || [];
|
||||
var cfg = {}; try { cfg = await LS.adminGetAssistant(); } catch (e) {}
|
||||
var providers = cfg.providers || [], activeId = cfg.activeId, presets = cfg.presets || [], kiloModels = cfg.kiloModels || [];
|
||||
|
||||
// ── Уведомление о failover ──
|
||||
// ── Баннер failover ──
|
||||
if (cfg.failover) {
|
||||
var fo = cfg.failover;
|
||||
var reasonMap = { rate_limit: 'исчерпан лимит', http: 'ошибка API', timeout: 'таймаут', network: 'нет связи', error: 'ошибка' };
|
||||
var rsn = reasonMap[fo.reason] || 'ошибка';
|
||||
var fo = cfg.failover, rmap = { rate_limit: 'исчерпан лимит', http: 'ошибка API', timeout: 'таймаут', network: 'нет связи', error: 'ошибка' };
|
||||
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.style.cssText = 'margin-top:14px;padding:11px 14px;border-radius:11px;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>' : '');
|
||||
? '<b>Переключение провайдера.</b> «' + esc(fo.failedName || '?') + '» недоступен (' + (rmap[fo.reason] || 'ошибка') + ') — работаю на «' + esc(fo.servedName) + '». ' + (when ? '<span style="opacity:.7">' + esc(when) + '</span>' : '') + '<br><span style="opacity:.8">Снимется автоматически, когда активный снова заработает.</span>'
|
||||
: '<b>Все провайдеры ИИ недоступны</b> (' + (rmap[fo.reason] || 'ошибка') + '). Сейчас «Спроси» в FAQ-режиме.';
|
||||
host.appendChild(ban);
|
||||
}
|
||||
|
||||
// ── Провайдеры ИИ ──
|
||||
// ── Провайдеры (карточки) ──
|
||||
var pc = document.createElement('div');
|
||||
pc.className = 'perm-card';
|
||||
pc.style.cssText = 'flex-direction:column;align-items:stretch;gap:10px;margin-top:14px';
|
||||
var rows = providers.length ? providers.map(function (p) {
|
||||
return '<label class="asst-prov-row" style="display:flex;align-items:center;gap:9px;padding:8px 10px;border:1px solid var(--border,#e2e8f0);border-radius:9px">' +
|
||||
'<input type="radio" name="asst-active" value="' + p.id + '"' + (p.id === activeId ? ' checked' : '') + '>' +
|
||||
'<span style="font-weight:700">' + esc(p.name || 'Провайдер') + '</span>' +
|
||||
'<span style="color:#8a94a6;font-size:.78rem">' + esc(p.model || '') + ' · ' + (p.hasKey ? '<span style="color:#059652">ключ есть</span>' : '<span style="color:#e0335e">нет ключа</span>') + '</span>' +
|
||||
'<span style="margin-left:auto;display:flex;gap:5px">' +
|
||||
'<button type="button" style="' + SBTN + '" data-act="test" data-id="' + p.id + '">Тест</button>' +
|
||||
'<button type="button" style="' + SBTN + '" data-act="edit" data-id="' + p.id + '">Изм.</button>' +
|
||||
'<button type="button" style="' + SBTN + ';color:#e0335e" data-act="del" data-id="' + p.id + '">Удалить</button>' +
|
||||
'</span></label>';
|
||||
}).join('') : '<div style="color:#8a94a6;font-size:.84rem">Пока нет провайдеров — добавьте ниже.</div>';
|
||||
|
||||
pc.style.cssText = 'flex-direction:column;align-items:stretch;gap:11px;margin-top:14px';
|
||||
pc.innerHTML =
|
||||
'<div class="perm-label"><i data-lucide="sparkles" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>Провайдеры ИИ для «Спроси Квантика»</div>' +
|
||||
'<div class="perm-desc">Активный (отмечен точкой) используется первым. При лимите или ошибке Квантик автоматически пробует следующего с ключом. Без ключей — режим FAQ.</div>' +
|
||||
'<div id="asst-prov-list" style="display:flex;flex-direction:column;gap:7px">' + rows + '</div>' +
|
||||
'<div id="asst-prov-test" style="font-size:.82rem;line-height:1.5"></div>' +
|
||||
'<hr style="border:none;border-top:1px solid var(--border,#e2e8f0);margin:4px 0">' +
|
||||
'<div style="font-weight:700;font-size:.85rem" id="asst-form-title">Добавить провайдера</div>' +
|
||||
'<select id="asst-preset" style="' + IN + '"><option value="">— пресет провайдера —</option>' + presets.map(function (p, i) { return '<option value="' + i + '">' + esc(p.name) + '</option>'; }).join('') + '</select>' +
|
||||
'<input id="asst-name" placeholder="Название (напр. Groq основной)" style="' + IN + '" />' +
|
||||
'<input id="asst-url" placeholder="URL chat/completions" style="' + IN + '" />' +
|
||||
'<input id="asst-model" placeholder="Модель" style="' + IN + '" />' +
|
||||
'<input id="asst-key" type="password" autocomplete="off" placeholder="API-ключ" style="' + IN + '" />' +
|
||||
'<div style="display:flex;gap:8px;flex-wrap:wrap">' +
|
||||
'<button id="asst-save" style="' + BTN + ';background:#9B5DE5;border-color:#9B5DE5;color:#fff">Сохранить провайдера</button>' +
|
||||
'<button id="asst-cancel" style="' + BTN + ';display:none">Отмена</button>' +
|
||||
'</div>';
|
||||
'<div class="perm-desc">Активный (фиолетовый) используется первым. При лимите/ошибке Квантик автоматически пробует следующего с ключом. Без ключей — режим FAQ.</div>' +
|
||||
'<div class="asst-prov" id="asst-prov"></div>' +
|
||||
'<div id="asst-ptest" class="asst-ptest"></div>' +
|
||||
'<hr style="border:none;border-top:1px solid var(--border,#e2e8f0);margin:6px 0 2px">' +
|
||||
'<div style="font-weight:800;font-size:.86rem" id="asst-fhead">Добавить провайдера</div>' +
|
||||
'<div class="asst-flabel">Пресет</div>' +
|
||||
'<select id="asst-preset" style="' + IN + '"><option value="">— выбрать провайдера —</option>' + presets.map(function (p, i) { return '<option value="' + i + '">' + esc(p.name) + '</option>'; }).join('') + '</select>' +
|
||||
'<div class="asst-flabel">Название</div><input id="asst-name" placeholder="напр. Kilo основной" style="' + IN + '" />' +
|
||||
'<div class="asst-flabel">URL (chat/completions)</div><input id="asst-url" placeholder="https://…/chat/completions" style="' + IN + '" />' +
|
||||
'<div id="asst-kbox" style="display:none"><div class="asst-flabel">Модель Kilo (бесплатные)</div><select id="asst-kmodels" style="' + IN + '"></select></div>' +
|
||||
'<div class="asst-flabel">Модель</div><input id="asst-model" placeholder="model-id" style="' + IN + '" />' +
|
||||
'<div class="asst-flabel">API-ключ</div><input id="asst-key" type="password" autocomplete="off" placeholder="ключ" style="' + IN + '" />' +
|
||||
'<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:4px"><button id="asst-save" class="asst-ib primary" style="padding:8px 16px">Сохранить провайдера</button><button id="asst-cancel" class="asst-ib" style="display:none;padding:8px 16px">Отмена</button></div>';
|
||||
host.appendChild(pc);
|
||||
|
||||
// ── Настройки и статистика ──
|
||||
// ── Настройки/статистика ──
|
||||
var u = cfg.usage || {}, u30 = cfg.usage30 || {}, f = cfg.feedback || {};
|
||||
var sc = document.createElement('div');
|
||||
sc.className = 'perm-card';
|
||||
sc.style.cssText = 'flex-direction:column;align-items:stretch;gap:9px;margin-top:14px';
|
||||
sc.className = 'perm-card'; sc.style.cssText = 'flex-direction:column;align-items:stretch;gap:9px;margin-top:14px';
|
||||
sc.innerHTML =
|
||||
'<div class="perm-label"><i data-lucide="settings-2" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>Настройки и статистика</div>' +
|
||||
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-rag" ' + (cfg.rag !== false ? 'checked' : '') + '> Искать ответы по учебникам (RAG)</label>' +
|
||||
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-exambtn" ' + (cfg.examButtons ? 'checked' : '') + '> Кнопки помощника на карточках экзамена</label>' +
|
||||
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><button id="asst-reindex" style="' + BTN + '">Переиндексировать учебники</button><span id="asst-chunks" style="font-size:.78rem;color:#8a94a6">' + (cfg.chunks || 0) + ' фрагментов</span></div>' +
|
||||
'<div style="font-size:.78rem;color:#8a94a6">Сегодня: ' + ((cfg.usage || {}).model_calls || 0) + ' к ИИ, ' + ((cfg.usage || {}).cache_hits || 0) + ' из кэша, ' + ((cfg.usage || {}).faq || 0) + ' FAQ. За 30 дней: ' + ((cfg.usage30 || {}).model_calls || 0) + ' / ' + ((cfg.usage30 || {}).cache_hits || 0) + ' / ' + ((cfg.usage30 || {}).faq || 0) + '.</div>' +
|
||||
'<div style="font-size:.78rem;color:#8a94a6">Оценки (30 дн): ' + ((cfg.feedback || {}).up || 0) + ' лайков, ' + ((cfg.feedback || {}).down || 0) + ' дизлайков' +
|
||||
(((cfg.feedback || {}).recent || []).length ? '. Не помогло: ' + cfg.feedback.recent.map(function (x) { return '«' + esc(String(x.q || '').slice(0, 40)) + '»'; }).join(', ') : '') + '</div>';
|
||||
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center"><button id="asst-reindex" class="asst-ib">Переиндексировать учебники</button><span id="asst-chunks" style="font-size:.78rem;color:#8a94a6">' + (cfg.chunks || 0) + ' фрагментов</span></div>' +
|
||||
'<div style="font-size:.78rem;color:#8a94a6">Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.</div>' +
|
||||
'<div style="font-size:.78rem;color:#8a94a6">Оценки (30 дн): ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' + ((f.recent || []).length ? '. Не помогло: ' + f.recent.map(function (x) { return '«' + esc(String(x.q || '').slice(0, 40)) + '»'; }).join(', ') : '') + '</div>';
|
||||
host.appendChild(sc);
|
||||
|
||||
if (window.lucide) lucide.createIcons();
|
||||
var Q = function (s) { return host.querySelector(s); };
|
||||
|
||||
var q = function (s) { return host.querySelector(s); };
|
||||
function clearForm() { editingId = null; q('#asst-form-title').textContent = 'Добавить провайдера'; q('#asst-name').value = ''; q('#asst-url').value = ''; q('#asst-model').value = ''; q('#asst-key').value = ''; q('#asst-key').placeholder = 'API-ключ'; q('#asst-cancel').style.display = 'none'; }
|
||||
// ── рендер карточек провайдеров ──
|
||||
var listEl = Q('#asst-prov');
|
||||
if (!providers.length) { listEl.innerHTML = '<div style="color:#8a94a6;font-size:.84rem">Пока нет провайдеров — добавьте ниже.</div>'; }
|
||||
else listEl.innerHTML = providers.map(function (p) {
|
||||
var isKilo = /kilocode\.ai/.test(p.url || '');
|
||||
var act = p.id === activeId;
|
||||
var ksel = '';
|
||||
if (isKilo) {
|
||||
var opts = kiloModels.slice();
|
||||
if (!opts.some(function (m) { return m.id === p.model; })) opts = [{ id: p.model, label: p.model }].concat(opts);
|
||||
ksel = '<select class="asst-ksel" data-ksel="' + p.id + '">' + opts.map(function (m) { return '<option value="' + esc(m.id) + '"' + (m.id === p.model ? ' selected' : '') + '>' + esc(m.label) + '</option>'; }).join('') + '</select>';
|
||||
}
|
||||
return '<div class="asst-pcard' + (act ? ' active' : '') + '">' +
|
||||
'<div class="asst-pcic">' + SPARK + '</div>' +
|
||||
'<div class="asst-pcb"><div class="asst-pcn">' + esc(p.name || 'Провайдер') +
|
||||
(act ? '<span class="asst-bdg act">активен</span>' : '') +
|
||||
'<span class="asst-bdg ' + (p.hasKey ? 'key' : 'nokey') + '">' + (p.hasKey ? 'ключ есть' : 'нет ключа') + '</span></div>' +
|
||||
'<div class="asst-pcs">' + esc(p.model || '') + '</div>' + ksel + '</div>' +
|
||||
'<div class="asst-pca">' +
|
||||
(act ? '' : '<button class="asst-ib primary" data-act="activate" data-id="' + p.id + '">Сделать активным</button>') +
|
||||
'<button class="asst-ib" data-act="test" data-id="' + p.id + '">Тест</button>' +
|
||||
'<button class="asst-ib" data-act="edit" data-id="' + p.id + '">Изм.</button>' +
|
||||
'<button class="asst-ib" data-act="del" data-id="' + p.id + '" style="color:#e0335e">Удалить</button>' +
|
||||
'</div></div>';
|
||||
}).join('');
|
||||
|
||||
// активный провайдер
|
||||
pc.querySelectorAll('input[name="asst-active"]').forEach(function (r) {
|
||||
r.addEventListener('change', function () { LS.adminSetActiveProvider(this.value).then(function () { LS.toast('Активный провайдер обновлён', 'success'); }).catch(function () {}); });
|
||||
function clearForm() { editingId = null; Q('#asst-fhead').textContent = 'Добавить провайдера'; Q('#asst-name').value = ''; Q('#asst-url').value = ''; Q('#asst-model').value = ''; Q('#asst-key').value = ''; Q('#asst-key').placeholder = 'ключ'; Q('#asst-cancel').style.display = 'none'; toggleKilo(); }
|
||||
function toggleKilo() {
|
||||
var isKilo = /kilocode\.ai/.test(Q('#asst-url').value || '');
|
||||
Q('#asst-kbox').style.display = isKilo ? '' : 'none';
|
||||
if (isKilo) {
|
||||
var sel = Q('#asst-kmodels');
|
||||
if (!sel.options.length) sel.innerHTML = kiloModels.map(function (m) { return '<option value="' + esc(m.id) + '">' + esc(m.label) + '</option>'; }).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// карточные действия
|
||||
listEl.querySelectorAll('[data-ksel]').forEach(function (sel) {
|
||||
sel.addEventListener('change', function () {
|
||||
LS.adminSaveProvider({ id: sel.getAttribute('data-ksel'), model: sel.value }).then(function () { LS.toast('Модель обновлена', 'success'); render(); }).catch(function () { LS.toast('Ошибка', 'error'); });
|
||||
});
|
||||
});
|
||||
// действия по провайдеру
|
||||
pc.querySelectorAll('[data-act]').forEach(function (b) {
|
||||
listEl.querySelectorAll('[data-act]').forEach(function (b) {
|
||||
b.addEventListener('click', async function () {
|
||||
var id = b.getAttribute('data-id'), act = b.getAttribute('data-act');
|
||||
if (act === 'del') {
|
||||
if (!await LS.confirm('Удалить провайдера?', { title: 'Удалить?', confirmText: 'Удалить' })) return;
|
||||
try { await LS.adminDeleteProvider(id); LS.toast('Удалён', 'success'); render(); } catch (e) { LS.toast('Ошибка', 'error'); }
|
||||
} else if (act === 'edit') {
|
||||
if (act === 'activate') { try { await LS.adminSetActiveProvider(id); LS.toast('Активный провайдер обновлён', 'success'); render(); } catch (e) { LS.toast('Ошибка', 'error'); } }
|
||||
else if (act === 'del') { if (!await LS.confirm('Удалить провайдера?', { title: 'Удалить?', confirmText: 'Удалить' })) return; try { await LS.adminDeleteProvider(id); LS.toast('Удалён', 'success'); render(); } catch (e) { LS.toast('Ошибка', 'error'); } }
|
||||
else if (act === 'edit') {
|
||||
var p = providers.find(function (x) { return x.id === id; }); if (!p) return;
|
||||
editingId = id; q('#asst-form-title').textContent = 'Изменить: ' + (p.name || '');
|
||||
q('#asst-name').value = p.name || ''; q('#asst-url').value = p.url || ''; q('#asst-model').value = p.model || '';
|
||||
q('#asst-key').value = ''; q('#asst-key').placeholder = p.hasKey ? 'Ключ сохранён — введите новый, чтобы заменить' : 'API-ключ';
|
||||
q('#asst-cancel').style.display = ''; q('#asst-name').focus();
|
||||
editingId = id; Q('#asst-fhead').textContent = 'Изменить: ' + (p.name || '');
|
||||
Q('#asst-name').value = p.name || ''; Q('#asst-url').value = p.url || ''; Q('#asst-model').value = p.model || '';
|
||||
Q('#asst-key').value = ''; Q('#asst-key').placeholder = p.hasKey ? 'ключ сохранён — введите новый, чтобы заменить' : 'ключ';
|
||||
Q('#asst-cancel').style.display = ''; toggleKilo(); Q('#asst-name').focus();
|
||||
} else if (act === 'test') {
|
||||
var res = q('#asst-prov-test'); res.innerHTML = 'Проверяю…';
|
||||
try {
|
||||
var r = await LS.adminTestAssistant({ id: id });
|
||||
res.innerHTML = r && r.ok ? '<span style="color:#059652">✓ Работает (' + (r.model || '') + '): ' + esc(String(r.sample || 'ответ получен')) + '</span>'
|
||||
: '<span style="color:#e0335e">✗ ' + esc(String((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').slice(0, 200)) + '</span>';
|
||||
} catch (e) { res.innerHTML = '<span style="color:#e0335e">✗ ' + esc(e.message || 'ошибка') + '</span>'; }
|
||||
var res = Q('#asst-ptest'); res.innerHTML = 'Проверяю…';
|
||||
try { var r = await LS.adminTestAssistant({ id: id }); res.innerHTML = r && r.ok ? '<span style="color:#059652">✓ Работает (' + esc(r.model || '') + '): ' + esc(String(r.sample || 'ответ получен')) + '</span>' : '<span style="color:#e0335e">✗ ' + esc(String((r && (r.error || ('HTTP ' + r.status))) || 'ошибка').slice(0, 200)) + '</span>'; } catch (e) { res.innerHTML = '<span style="color:#e0335e">✗ ' + esc(e.message || 'ошибка') + '</span>'; }
|
||||
}
|
||||
});
|
||||
});
|
||||
// пресет → заполнить url/model/name
|
||||
q('#asst-preset').addEventListener('change', function () {
|
||||
var p = presets[Number(this.value)]; if (p) { q('#asst-url').value = p.url; q('#asst-model').value = p.model; if (!q('#asst-name').value) q('#asst-name').value = p.name; }
|
||||
});
|
||||
q('#asst-cancel').addEventListener('click', clearForm);
|
||||
q('#asst-save').addEventListener('click', async function () {
|
||||
var body = { name: q('#asst-name').value, url: q('#asst-url').value, model: q('#asst-model').value };
|
||||
|
||||
// форма
|
||||
Q('#asst-preset').addEventListener('change', function () { var p = presets[Number(this.value)]; if (p) { Q('#asst-url').value = p.url; Q('#asst-model').value = p.model; if (!Q('#asst-name').value) Q('#asst-name').value = p.name; toggleKilo(); } });
|
||||
Q('#asst-url').addEventListener('input', toggleKilo);
|
||||
Q('#asst-kmodels').addEventListener('change', function () { Q('#asst-model').value = this.value; });
|
||||
Q('#asst-cancel').addEventListener('click', clearForm);
|
||||
Q('#asst-save').addEventListener('click', async function () {
|
||||
var body = { name: Q('#asst-name').value, url: Q('#asst-url').value, model: Q('#asst-model').value };
|
||||
if (editingId) body.id = editingId;
|
||||
var k = q('#asst-key').value.trim(); if (k) body.key = k;
|
||||
var k = Q('#asst-key').value.trim(); if (k) body.key = k;
|
||||
if (!body.url || !body.model) { LS.toast('Заполни URL и модель', 'warn'); return; }
|
||||
try { await LS.adminSaveProvider(body); LS.toast('Сохранено', 'success'); clearForm(); render(); } catch (e) { LS.toast('Ошибка: ' + (e.message || ''), 'error'); }
|
||||
});
|
||||
|
||||
// настройки
|
||||
q('#asst-rag').addEventListener('change', function () { LS.adminSaveAssistant({ rag: q('#asst-rag').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
|
||||
q('#asst-exambtn').addEventListener('change', function () { LS.adminSaveAssistant({ examButtons: q('#asst-exambtn').checked }).then(function () { LS.toast('Сохранено (обновите страницу экзамена)', 'success'); }).catch(function () {}); });
|
||||
q('#asst-reindex').addEventListener('click', async function () {
|
||||
var btn = q('#asst-reindex'); btn.disabled = true; btn.textContent = 'Индексирую…';
|
||||
try { var r = await LS.adminReindexTextbooks(); q('#asst-chunks').textContent = ((r && r.chunks) || 0) + ' фрагментов'; LS.toast('Готово', 'success'); }
|
||||
catch (e) { LS.toast('Ошибка индексации', 'error'); }
|
||||
finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; }
|
||||
Q('#asst-rag').addEventListener('change', function () { LS.adminSaveAssistant({ rag: Q('#asst-rag').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); });
|
||||
Q('#asst-exambtn').addEventListener('change', function () { LS.adminSaveAssistant({ examButtons: Q('#asst-exambtn').checked }).then(function () { LS.toast('Сохранено (обновите страницу экзамена)', 'success'); }).catch(function () {}); });
|
||||
Q('#asst-reindex').addEventListener('click', async function () {
|
||||
var btn = Q('#asst-reindex'); btn.disabled = true; btn.textContent = 'Индексирую…';
|
||||
try { var r = await LS.adminReindexTextbooks(); Q('#asst-chunks').textContent = ((r && r.chunks) || 0) + ' фрагментов'; LS.toast('Готово', 'success'); }
|
||||
catch (e) { LS.toast('Ошибка индексации', 'error'); } finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; }
|
||||
});
|
||||
}
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.assistant = {
|
||||
init: async () => { if (inited) return; inited = true; await render(); },
|
||||
reload: render,
|
||||
};
|
||||
window.AdminSections.assistant = { init: async () => { if (inited) return; inited = true; await render(); }, reload: render };
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user