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:
@@ -957,6 +957,12 @@ const KILO_MODELS = [
|
||||
];
|
||||
function _aset(k) { const r = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(k); return r && r.value != null ? r.value : null; }
|
||||
|
||||
// Рабочий список бесплатных моделей: обновлённый сканом (app_settings) либо хардкод KILO_MODELS как сид.
|
||||
function _kiloModels() {
|
||||
try { const r = _aset('assistant_kilo_models'); if (r) { const a = JSON.parse(r); if (Array.isArray(a) && a.length) return a; } } catch (e) {}
|
||||
return KILO_MODELS;
|
||||
}
|
||||
|
||||
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 || ''); }
|
||||
@@ -998,7 +1004,8 @@ function getAssistant(_req, res) {
|
||||
providers, activeId, active,
|
||||
rag: _aset('assistant_rag') !== '0', examButtons: _aset('assistant_exam_buttons') === '1',
|
||||
memory: _aset('assistant_memory') !== '0',
|
||||
chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS, kiloModels: KILO_MODELS,
|
||||
chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS,
|
||||
kiloModels: _kiloModels(), kiloModelsCustom: !!_aset('assistant_kilo_models'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1108,6 +1115,96 @@ async function getProviderModels(req, res) {
|
||||
res.json({ models: r.models, current });
|
||||
}
|
||||
|
||||
/* ── Сканер бесплатных моделей шлюза (наполняет список KILO_MODELS) ───────── */
|
||||
// Заведомо не-чат модели (музыка/картинки/эмбеддинги/модерация) — не тестируем.
|
||||
const _NONCHAT_RE = /(lyria|whisper|tts|embed|rerank|moderation|content-safety|guard|dall-?e|imagen|sora|veo|\bmusic\b)/i;
|
||||
|
||||
// Kilo-провайдер (со шлюзом kilocode.ai): по id, иначе активный, иначе первый с ключом.
|
||||
function _pickKiloProvider(id) {
|
||||
const arr = _aProviders();
|
||||
if (id) return arr.find(p => p.id === id) || null;
|
||||
const active = arr.find(p => p.id === _aset('assistant_active'));
|
||||
if (active && /kilocode\.ai/.test(active.url || '') && active.key) return active;
|
||||
return arr.find(p => /kilocode\.ai/.test(p.url || '') && p.key)
|
||||
|| arr.find(p => /kilocode\.ai/.test(p.url || '')) || null;
|
||||
}
|
||||
|
||||
/* POST /api/admin/assistant/scan { id? } — найти бесплатные модели на шлюзе провайдера.
|
||||
* Без инференса: список + сверка с текущим рабочим списком (что новое / что исчезло). */
|
||||
async function scanModels(req, res) {
|
||||
const prov = _pickKiloProvider(req.body && req.body.id);
|
||||
if (!prov) return res.json({ error: 'Нет Kilo-провайдера. Добавьте провайдера со шлюзом kilocode.ai с ключом.' });
|
||||
const r = await _fetchModels(prov.url, prov.key);
|
||||
if (r.error) return res.json({ error: r.error, status: r.status });
|
||||
const cur = _kiloModels();
|
||||
const curIds = new Set(cur.map(m => m.id));
|
||||
const liveIds = new Set(r.models.map(m => m.id));
|
||||
const free = r.models
|
||||
.filter(m => (m.free === true || /:free$/.test(m.id)) && !_NONCHAT_RE.test(m.id))
|
||||
.map(m => ({ id: m.id, ctx: m.ctx, out: m.out, status: curIds.has(m.id) ? 'current' : 'new' }));
|
||||
free.sort((a, b) => (a.status === b.status ? (b.ctx || 0) - (a.ctx || 0) : a.status === 'current' ? -1 : 1));
|
||||
const gone = cur.filter(m => !liveIds.has(m.id)).map(m => ({ id: m.id, label: m.label }));
|
||||
res.json({ providerId: prov.id, providerName: prov.name, total: r.models.length, models: free, gone, current: cur });
|
||||
}
|
||||
|
||||
/* POST /api/admin/assistant/probe { id?, model } — один тест-запрос на русском. */
|
||||
async function probeModel(req, res) {
|
||||
const b = req.body || {};
|
||||
const prov = _pickKiloProvider(b.id);
|
||||
if (!prov) return res.json({ ok: false, error: 'нет провайдера' });
|
||||
const model = String(b.model || '').trim().slice(0, 120);
|
||||
if (!model) return res.json({ ok: false, error: 'нет модели' });
|
||||
if (typeof fetch !== 'function') return res.json({ ok: false, error: 'fetch недоступен' });
|
||||
const PROMPT = 'Ученик 9 класса спрашивает: что такое синус острого угла в прямоугольном треугольнике? Объясни кратко и понятно. Отвечай только на русском языке.';
|
||||
const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 22000);
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const r = await fetch(prov.url, {
|
||||
method: 'POST', signal: ctrl.signal,
|
||||
headers: Object.assign({ 'Content-Type': 'application/json' }, prov.key ? { Authorization: 'Bearer ' + prov.key } : {}),
|
||||
body: JSON.stringify({ model, max_tokens: 160, temperature: 0.3, messages: [{ role: 'user', content: PROMPT }] }),
|
||||
});
|
||||
const ms = Date.now() - t0;
|
||||
const txt = await r.text();
|
||||
if (!r.ok) {
|
||||
let msg = txt.slice(0, 200);
|
||||
try { const j = JSON.parse(txt); if (j && j.error) msg = String(j.error.message || JSON.stringify(j.error)).slice(0, 200); } catch (e) {}
|
||||
return res.json({ ok: false, status: r.status, ms, error: msg });
|
||||
}
|
||||
let sample = '';
|
||||
try { const j = JSON.parse(txt); const m = j.choices && j.choices[0] && j.choices[0].message; sample = String((m && (m.content || m.reasoning)) || '').trim(); } catch (e) {}
|
||||
const letters = (sample.match(/[A-Za-zА-Яа-яЁё一-鿿]/g) || []).length;
|
||||
const cyr = (sample.match(/[А-Яа-яЁё]/g) || []).length;
|
||||
const cjk = (sample.match(/[一-鿿]/g) || []).length;
|
||||
const ratio = letters ? cyr / letters : 0;
|
||||
const verdict = !sample ? 'пусто' : cjk > 0 ? 'иероглифы' : ratio > 0.55 ? 'чистый русский' : ratio > 0.2 ? 'смешанный' : 'не русский';
|
||||
res.json({ ok: true, ms, ratio: Math.round(ratio * 100), cjk, verdict, sample: sample.replace(/\s+/g, ' ').slice(0, 180) });
|
||||
} catch (e) { res.json({ ok: false, ms: Date.now() - t0, error: e.name === 'AbortError' ? 'таймаут' : 'сеть' }); }
|
||||
finally { clearTimeout(timer); }
|
||||
}
|
||||
|
||||
/* POST /api/admin/assistant/models/apply { models:[{id,label,ctx,out}] | reset:true } */
|
||||
function applyModels(req, res) {
|
||||
const b = req.body || {};
|
||||
if (b.reset) {
|
||||
try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_kilo_models'").run(); } catch (e) {}
|
||||
audit(req, 'assistant.models', 'kilo', 'сброс к встроенному');
|
||||
return res.json({ ok: true, reset: true });
|
||||
}
|
||||
const arr = Array.isArray(b.models) ? b.models : null;
|
||||
if (!arr) return res.status(400).json({ error: 'models[] обязателен' });
|
||||
const clean = [];
|
||||
for (const m of arr.slice(0, 40)) {
|
||||
const id = String((m && m.id) || '').trim().slice(0, 120);
|
||||
if (!id) continue;
|
||||
clean.push({ id, label: String((m && m.label) || id).trim().slice(0, 80), ctx: Number(m && m.ctx) || null, out: Number(m && m.out) || null });
|
||||
}
|
||||
if (!clean.length) return res.status(400).json({ error: 'пустой список' });
|
||||
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_kilo_models', ?)").run(JSON.stringify(clean));
|
||||
audit(req, 'assistant.models', 'kilo', clean.length + ' моделей');
|
||||
res.json({ ok: true, count: clean.length });
|
||||
}
|
||||
|
||||
/* POST /api/admin/assistant/active { id } — выбрать активного провайдера */
|
||||
function setActiveProvider(req, res) {
|
||||
const id = String((req.body && req.body.id) || '');
|
||||
@@ -1221,4 +1318,5 @@ module.exports = {
|
||||
getTopics, createTopic, updateTopic, deleteTopic,
|
||||
broadcast,
|
||||
getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider, getProviderModels,
|
||||
scanModels, probeModel, applyModels,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user