diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index f9ee141..f6d4c22 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -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, }; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index cbb7d57..73e8ce0 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -22,6 +22,9 @@ router.put('/assistant', ctrl.saveAssistant); router.post('/assistant/test', ctrl.testAssistant); router.post('/assistant/reindex', ctrl.reindexTextbooks); router.get('/assistant/models', ctrl.getProviderModels); +router.post('/assistant/scan', ctrl.scanModels); +router.post('/assistant/probe', ctrl.probeModel); +router.post('/assistant/models/apply', ctrl.applyModels); router.get('/imggen', ctrl.getImggen); router.put('/imggen', ctrl.saveImggen); router.post('/imggen/test', ctrl.testImggen); diff --git a/frontend/js/admin/sections/assistant.js b/frontend/js/admin/sections/assistant.js index 215d09e..106da44 100644 --- a/frontend/js/admin/sections/assistant.js +++ b/frontend/js/admin/sections/assistant.js @@ -108,6 +108,20 @@ ''; 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 = + '
Каталог бесплатных моделей Kilo
' + + '
Сканирует шлюз, находит бесплатные модели и тестирует каждую тест-запросом на русском. Этот список показывается в выпадашке моделей у Kilo-провайдеров. ' + + (cfg.kiloModelsCustom ? 'Сейчас: обновлён сканированием.' : 'Сейчас: встроенный список.') + '
' + + '
' + + '' + + (cfg.kiloModelsCustom ? '' : '') + + '
' + + '
'; + 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 '' + + '' + + '' + esc(m.id) + '' + + '' + fmtTok(m.ctx) + '/' + fmtTok(m.out) + '' + + '' + (m.status === 'current' ? 'в списке' : 'новая') + '' + + '…'; + }).join(''); + var goneRows = (r.gone || []).map(function (g) { + return '—' + + '' + esc(g.id) + '' + + '—исчезла' + + 'будет убрана'; + }).join(''); + res.innerHTML = '
' + + '' + + '' + + '' + rows + goneRows + '
модельctx/outстатусрусский
' + + '
' + + 'отмечены: текущие + новые с чистым русским
'; + 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 = '' + pr.ratio + '% · ' + esc(pr.verdict) + ' · ' + (pr.ms / 1000).toFixed(1) + 'с'; + if (!good) { var cb = tr.querySelector('.asst-scan-cb'); if (cb) cb.checked = false; } + } else { + cell.innerHTML = '' + esc(String((pr && (pr.error || ('HTTP ' + pr.status))) || 'ошибка').slice(0, 60)) + ''; + 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 || {}; diff --git a/js/api.js b/js/api.js index b672a90..a6b9a9b 100644 --- a/js/api.js +++ b/js/api.js @@ -1186,6 +1186,7 @@ window.LS = { assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels, + adminAssistantScan, adminAssistantProbe, adminAssistantApplyModels, fcListDecks, fcCreateDeck, fcAddCard, fcStudySession, fcReview, prepListTracks, prepMyTracks, prepStudentTracks, prepSetStudent, prepUnsetStudent, prepClassStatus, prepSetClass, escapeHtml, esc, @@ -1436,6 +1437,9 @@ async function adminSaveProvider(d) { return req('POST', '/admin/assistant async function adminDeleteProvider(id) { return req('DELETE', `/admin/assistant/provider/${id}`); } async function adminSetActiveProvider(id) { return req('POST', '/admin/assistant/active', { id }); } async function adminAssistantModels(params) { const q = new URLSearchParams(params || {}).toString(); return req('GET', '/admin/assistant/models' + (q ? '?' + q : '')); } +async function adminAssistantScan(id) { return req('POST', '/admin/assistant/scan', id ? { id } : {}); } +async function adminAssistantProbe(id, model) { return req('POST', '/admin/assistant/probe', { id, model }); } +async function adminAssistantApplyModels(models, reset) { return req('POST', '/admin/assistant/models/apply', reset ? { reset: true } : { models }); } async function fcListDecks() { return req('GET', '/flashcards/decks'); } async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); } async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }