feat(assistant): сканер бесплатных моделей Kilo в админке

Кнопка «Сканировать модели» в /admin#assistant: тянет live-список со шлюза
провайдера, отбирает бесплатные чат-модели (музыка/картинки/модерация
отсекаются), прогоняет каждую тест-запросом на русском и показывает отчёт
(новые / исчезнувшие / % кириллицы / скорость). «Применить выбранные»
сохраняет список в app_settings (assistant_kilo_models); хардкод KILO_MODELS
остаётся сидом, есть «Вернуть встроенный список».

Backend: scanModels/probeModel/applyModels (admin-only роуты), _kiloModels()
делает список динамическим. Переиспользует _fetchModels. Клиент: adminAssistantScan/Probe/ApplyModels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-24 14:18:04 +03:00
parent dc5501d723
commit d15c15ef2a
4 changed files with 191 additions and 1 deletions
+85
View File
@@ -108,6 +108,20 @@
'</div>';
host.appendChild(pc);
// ── Сканер бесплатных моделей шлюза Kilo ──
var sk = document.createElement('div');
sk.className = 'perm-card'; sk.style.cssText = 'flex-direction:column;align-items:stretch;gap:10px;margin-top:14px';
sk.innerHTML =
'<div class="perm-label"><i data-lucide="radar" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"></i>Каталог бесплатных моделей Kilo</div>' +
'<div class="perm-desc">Сканирует шлюз, находит бесплатные модели и тестирует каждую тест-запросом на русском. Этот список показывается в выпадашке моделей у Kilo-провайдеров. ' +
(cfg.kiloModelsCustom ? '<b>Сейчас: обновлён сканированием.</b>' : 'Сейчас: встроенный список.') + '</div>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
'<button id="asst-scan" class="asst-ib primary" style="padding:8px 14px;display:inline-flex;align-items:center;gap:6px">' + SPARK + 'Сканировать модели</button>' +
(cfg.kiloModelsCustom ? '<button id="asst-scan-reset" class="asst-ib">Вернуть встроенный список</button>' : '') +
'<span id="asst-scan-st" style="font-size:.78rem;color:#8a94a6"></span></div>' +
'<div id="asst-scan-res"></div>';
host.appendChild(sk);
// ── Настройки/статистика ──
var u = cfg.usage || {}, u30 = cfg.usage30 || {}, f = cfg.feedback || {};
var sc = document.createElement('div');
@@ -254,6 +268,77 @@
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 = 'Переиндексировать учебники'; }
});
// ── Сканер моделей ──
var scanProvId = null;
function applyScan() {
var trs = Q('#asst-scan-res').querySelectorAll('tbody tr[data-mid]');
var models = [];
trs.forEach(function (tr) {
var cb = tr.querySelector('.asst-scan-cb'); if (!cb || !cb.checked) return;
var id = tr.getAttribute('data-mid');
var ctx = Number(tr.getAttribute('data-ctx')) || null, out = Number(tr.getAttribute('data-out')) || null;
var ex = (cfg.kiloModels || []).find(function (x) { return x.id === id; });
var label = ex ? ex.label : (id.split('/').pop().replace(/:free$/, '') + (ctx ? ' (' + fmtTok(ctx) + ')' : ''));
models.push({ id: id, label: label, ctx: ctx, out: out });
});
if (!models.length) { LS.toast('Отметьте хотя бы одну модель', 'warn'); return; }
LS.adminAssistantApplyModels(models).then(function () { LS.toast('Список моделей обновлён (' + models.length + ')', 'success'); render(); }).catch(function () { LS.toast('Ошибка', 'error'); });
}
async function runScan() {
var st = Q('#asst-scan-st'), res = Q('#asst-scan-res'), btn = Q('#asst-scan');
btn.disabled = true; st.textContent = 'Сканирую шлюз…'; res.innerHTML = '';
var r; try { r = await LS.adminAssistantScan(); } catch (e) { st.textContent = 'Ошибка запроса'; btn.disabled = false; return; }
if (!r || r.error) { st.textContent = 'Ошибка: ' + esc((r && r.error) || '—'); btn.disabled = false; return; }
scanProvId = r.providerId;
st.textContent = 'Найдено бесплатных: ' + r.models.length + ' (провайдер «' + esc(r.providerName) + '»). Тестирую русский…';
var rows = r.models.map(function (m) {
return '<tr data-mid="' + esc(m.id) + '" data-ctx="' + (m.ctx || '') + '" data-out="' + (m.out || '') + '">' +
'<td style="padding:5px 6px"><input type="checkbox" class="asst-scan-cb"' + (m.status === 'current' ? ' checked' : '') + '></td>' +
'<td style="padding:5px 6px;font-family:ui-monospace,monospace;font-size:.74rem">' + esc(m.id) + '</td>' +
'<td style="padding:5px 6px;white-space:nowrap;color:#8a94a6">' + fmtTok(m.ctx) + '/' + fmtTok(m.out) + '</td>' +
'<td style="padding:5px 6px"><span class="asst-bdg ' + (m.status === 'current' ? 'key' : 'act') + '">' + (m.status === 'current' ? 'в списке' : 'новая') + '</span></td>' +
'<td class="asst-ru" style="padding:5px 6px;font-size:.75rem;color:#8a94a6">…</td></tr>';
}).join('');
var goneRows = (r.gone || []).map(function (g) {
return '<tr style="opacity:.55"><td style="padding:5px 6px">—</td>' +
'<td style="padding:5px 6px;font-family:ui-monospace,monospace;font-size:.74rem;text-decoration:line-through">' + esc(g.id) + '</td>' +
'<td style="padding:5px 6px">—</td><td style="padding:5px 6px"><span class="asst-bdg nokey">исчезла</span></td>' +
'<td style="padding:5px 6px;font-size:.75rem;color:#e0335e">будет убрана</td></tr>';
}).join('');
res.innerHTML = '<div style="overflow-x:auto;margin-top:4px"><table style="width:100%;border-collapse:collapse">' +
'<thead><tr style="text-align:left;color:#8a94a6;font-size:.72rem;border-bottom:1px solid var(--border,#e2e8f0)">' +
'<th style="padding:4px 6px"></th><th style="padding:4px 6px">модель</th><th style="padding:4px 6px">ctx/out</th><th style="padding:4px 6px">статус</th><th style="padding:4px 6px">русский</th></tr></thead>' +
'<tbody>' + rows + goneRows + '</tbody></table></div>' +
'<div style="margin-top:10px"><button id="asst-scan-apply" class="asst-ib primary" style="padding:8px 16px">Применить выбранные</button> ' +
'<span style="font-size:.74rem;color:#8a94a6">отмечены: текущие + новые с чистым русским</span></div>';
Q('#asst-scan-apply').addEventListener('click', applyScan);
// последовательный прогон тест-запросов
var trs = res.querySelectorAll('tbody tr[data-mid]');
for (var i = 0; i < trs.length; i++) {
var tr = trs[i], mid = tr.getAttribute('data-mid'), cell = tr.querySelector('.asst-ru');
cell.textContent = 'тест…';
try {
var pr = await LS.adminAssistantProbe(scanProvId, mid);
if (pr && pr.ok) {
var good = pr.cjk === 0 && pr.ratio > 55;
var col = good ? '#059652' : (pr.ratio > 20 && pr.cjk === 0) ? '#b45309' : '#e0335e';
cell.innerHTML = '<span style="color:' + col + '" title="' + esc(pr.sample || '') + '">' + pr.ratio + '% · ' + esc(pr.verdict) + ' · ' + (pr.ms / 1000).toFixed(1) + 'с</span>';
if (!good) { var cb = tr.querySelector('.asst-scan-cb'); if (cb) cb.checked = false; }
} else {
cell.innerHTML = '<span style="color:#e0335e">' + esc(String((pr && (pr.error || ('HTTP ' + pr.status))) || 'ошибка').slice(0, 60)) + '</span>';
var cb2 = tr.querySelector('.asst-scan-cb'); if (cb2) cb2.checked = false;
}
} catch (e) { cell.textContent = 'ошибка'; }
}
st.textContent = 'Готово. Отметьте нужные модели и нажмите «Применить выбранные».';
btn.disabled = false;
}
Q('#asst-scan').addEventListener('click', runScan);
if (Q('#asst-scan-reset')) Q('#asst-scan-reset').addEventListener('click', async function () {
if (!await LS.confirm('Вернуть встроенный список бесплатных моделей?', { title: 'Сброс списка', confirmText: 'Вернуть' })) return;
try { await LS.adminAssistantApplyModels(null, true); LS.toast('Возвращён встроенный список', 'success'); render(); } catch (e) { LS.toast('Ошибка', 'error'); }
});
}
window.AdminSections = window.AdminSections || {};