From 6e0a00fd8b33b0b67f87e96f2c9f7963950e9d26 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 4 Jun 2026 21:28:34 +0300 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20=D0=B0=D0=B2=D1=82=D0=BE-?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BB?= =?UTF-8?q?=D0=B8=D0=BC=D0=B8=D1=82=D0=BE=D0=B2=20=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=B9=20=D0=B4=D0=BB=D1=8F=20=D0=BB=D1=8E=D0=B1?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B0=D0=B9=D0=B4?= =?UTF-8?q?=D0=B5=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый GET /admin/assistant/models: тянет список моделей провайдера с лимитами (OpenAI-совместимый /models: context_length+max_completion_tokens+pricing; нативный Google generativelanguage: inputTokenLimit/outputTokenLimit) и кэширует лимиты текущей модели на провайдере. Карточка показывает лимиты у ВСЕХ провайдеров (не только Kilo), для отсутствующих — фоновая авто-подгрузка. В форме — кнопка «Загрузить модели провайдера» с выбором модели и её лимитами. Так Gemini и любые новые модели получают лимиты автоматически. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/controllers/adminController.js | 70 +++++++++++++++++++++- backend/src/routes/admin.js | 1 + frontend/js/admin/sections/assistant.js | 62 ++++++++++++++++--- js/api.js | 3 +- 4 files changed, 125 insertions(+), 11 deletions(-) diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index 47ba6a5..29ac790 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -916,7 +916,7 @@ function getAssistant(_req, res) { } } const list = _aProviders(); - const providers = list.map(p => ({ id: p.id, name: p.name, url: p.url, model: p.model, hasKey: !!p.key })); + const providers = list.map(p => ({ id: p.id, name: p.name, url: p.url, model: p.model, hasKey: !!p.key, ctx: p.ctx || null, out: p.out || null, free: (typeof p.free === 'boolean' ? p.free : null) })); const activeId = _aset('assistant_active') || (providers[0] && providers[0].id) || null; const ap = list.find(p => p.id === activeId); const active = !!(ap && (ap.key || _aIsLocal(ap.url))); @@ -962,11 +962,20 @@ function saveProvider(req, res) { let p; if (b.id) { p = arr.find(x => x.id === b.id); if (!p) { p = { id: b.id }; arr.push(p); } } else { p = { id: 'p' + Date.now().toString(36) + Math.floor(Math.random() * 1000) }; arr.push(p); } + const prevModel = p.model; if (typeof b.name === 'string') p.name = b.name.trim().slice(0, 40) || p.name || 'Провайдер'; if (typeof b.url === 'string') p.url = b.url.trim().slice(0, 300); if (typeof b.model === 'string') p.model = b.model.trim().slice(0, 120); if (typeof b.key === 'string' && b.key.trim()) p.key = b.key.trim().slice(0, 400); if (!p.name) p.name = 'Провайдер'; + // Лимиты модели (ctx/out/free): из тела или сброс при смене модели (перезапросятся авто) + if (b.ctx !== undefined || b.out !== undefined || b.free !== undefined) { + if (b.ctx !== undefined) p.ctx = (b.ctx === null || b.ctx === '') ? null : (Number(b.ctx) || null); + if (b.out !== undefined) p.out = (b.out === null || b.out === '') ? null : (Number(b.out) || null); + if (b.free !== undefined) p.free = (typeof b.free === 'boolean') ? b.free : null; + } else if (typeof b.model === 'string' && p.model !== prevModel) { + p.ctx = null; p.out = null; p.free = null; + } _aSetProviders(arr); if (b.makeActive || arr.length === 1) db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_active', ?)").run(p.id); audit(req, 'assistant.provider', p.id, 'сохранён'); @@ -983,6 +992,63 @@ function deleteProvider(req, res) { res.json({ ok: true }); } +/* Запрос списка моделей провайдера с лимитами. Понимает OpenAI-совместимый /models + * (context_length + max_completion_tokens + pricing) и нативный Google generativelanguage + * (inputTokenLimit / outputTokenLimit). */ +async function _fetchModels(url, key) { + if (typeof fetch !== 'function') return { error: 'fetch недоступен' }; + url = String(url || ''); + const isGoogle = /generativelanguage\.googleapis\.com/.test(url); + let ep; const headers = {}; + if (isGoogle) { + const base = url.replace(/\/openai\/chat\/completions.*$/, '').replace(/\/chat\/completions.*$/, ''); + ep = base + '/models?pageSize=200' + (key ? '&key=' + encodeURIComponent(key) : ''); + } else { + ep = url.replace(/\/chat\/completions.*$/, '/models'); + if (key) headers.Authorization = 'Bearer ' + key; + } + const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 15000); + try { + const r = await fetch(ep, { headers, signal: ctrl.signal }); + if (!r.ok) return { error: 'HTTP ' + r.status, status: r.status }; + const j = await r.json(); + const raw = j.data || j.models || []; + const models = []; + for (const m of raw) { + const methods = m.supportedGenerationMethods; + if (methods && methods.indexOf('generateContent') === -1) continue; // Google: только генеративные + const id = String(m.id || m.name || '').replace(/^models\//, ''); + if (!id) continue; + const tp = m.top_provider || {}; + const pr = m.pricing || {}; + const ctx = m.context_length || tp.context_length || m.inputTokenLimit || null; + const out = tp.max_completion_tokens || m.outputTokenLimit || null; + let free = null; + if (pr && (pr.prompt != null || pr.completion != null)) free = Number(pr.prompt) === 0 && Number(pr.completion) === 0; + models.push({ id, ctx: ctx || null, out: out || null, free }); + } + return { models }; + } catch (e) { return { error: e.name === 'AbortError' ? 'timeout' : 'network' }; } + finally { clearTimeout(timer); } +} + +/* GET /api/admin/assistant/models?id=&url=&key= — модели провайдера с лимитами. + * С id: берём сохранённого провайдера и кэшируем лимиты его текущей модели. */ +async function getProviderModels(req, res) { + const id = req.query.id ? String(req.query.id) : ''; + let url = req.query.url, key = req.query.key, prov = null; + if (id) { prov = _aProviders().find(x => x.id === id); if (!prov) return res.json({ error: 'провайдер не найден' }); url = prov.url; key = prov.key; } + const r = await _fetchModels(url, key); + if (r.error) return res.json({ error: r.error, status: r.status }); + let current = null; + if (prov) { + const arr = _aProviders(); const p2 = arr.find(x => x.id === id); + const m = p2 && r.models.find(x => x.id === p2.model); + if (p2 && m) { p2.ctx = m.ctx; p2.out = m.out; p2.free = m.free; _aSetProviders(arr); current = { ctx: m.ctx, out: m.out, free: m.free }; } + } + res.json({ models: r.models, current }); +} + /* POST /api/admin/assistant/active { id } — выбрать активного провайдера */ function setActiveProvider(req, res) { const id = String((req.body && req.body.id) || ''); @@ -1036,5 +1102,5 @@ module.exports = { getSecurityLog, clearSecurityLog, getTopics, createTopic, updateTopic, deleteTopic, broadcast, - getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider, + getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider, getProviderModels, }; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 561e26d..17cd9db 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -17,6 +17,7 @@ router.get('/assistant', ctrl.getAssistant); 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/provider', ctrl.saveProvider); router.delete('/assistant/provider/:id', requireRole('admin'), ctrl.deleteProvider); router.post('/assistant/active', ctrl.setActiveProvider); diff --git a/frontend/js/admin/sections/assistant.js b/frontend/js/admin/sections/assistant.js index 12c0893..88f845c 100644 --- a/frontend/js/admin/sections/assistant.js +++ b/frontend/js/admin/sections/assistant.js @@ -8,6 +8,7 @@ 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 = ''; function fmtTok(n) { if (!n) return '—'; if (n >= 1000000) { var m = n / 1000000; return (m >= 10 ? Math.round(m) : m.toFixed(1).replace(/\.0$/, '')) + 'M'; } if (n >= 1000) return Math.round(n / 1000) + 'K'; return String(n); } + function fmtLimits(L) { var s = 'контекст ' + fmtTok(L.ctx) + ' · ответ до ' + fmtTok(L.out) + ' токенов'; if (L.free === true) s += ' · бесплатно'; else if (L.free === false) s += ' · платно'; return s; } function ensureStyle() { if (document.getElementById('asst-adm-style')) return; @@ -100,6 +101,8 @@ '
URL (chat/completions)
' + '' + '
Модель
' + + '
' + + '' + '
API-ключ
' + '
' + ''; @@ -127,14 +130,18 @@ else listEl.innerHTML = providers.map(function (p) { var isKilo = /kilocode\.ai/.test(p.url || ''); var act = p.id === activeId; - var ksel = '', lim = ''; + 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 = ''; - var km = kiloModels.find(function (m) { return m.id === p.model; }); - if (km) lim = '
контекст ' + fmtTok(km.ctx) + ' · ответ до ' + fmtTok(km.out) + ' токенов · бесплатно
'; } + // лимиты: сохранённые на провайдере → для Kilo из списка → иначе авто-подгрузка + var L = p.ctx ? { ctx: p.ctx, out: p.out, free: p.free } : null; + if (!L && isKilo) { var km = kiloModels.find(function (m) { return m.id === p.model; }); if (km) L = { ctx: km.ctx, out: km.out, free: true }; } + var lim = L + ? '
' + fmtLimits(L) + '
' + : '
лимиты: загрузка…
'; return '
' + '
' + SPARK + '
' + '
' + esc(p.name || 'Провайдер') + @@ -149,8 +156,20 @@ '
'; }).join(''); + // авто-подгрузка лимитов для провайдеров без сохранённых (Gemini, новые модели) — фоном, сервер кэширует + var _pickedLimits = null; + providers.forEach(function (p) { + if (p.ctx) return; + if (/kilocode\.ai/.test(p.url || '') && kiloModels.some(function (m) { return m.id === p.model; })) return; + LS.adminAssistantModels({ id: p.id }).then(function (r) { + var el = host.querySelector('[data-lim="' + p.id + '"]'); if (!el) return; + if (r && !r.error && r.current) { el.style.opacity = ''; el.innerHTML = fmtLimits(r.current); } + else { el.style.display = 'none'; } + }).catch(function () { var el = host.querySelector('[data-lim="' + p.id + '"]'); if (el) el.style.display = 'none'; }); + }); + function openForm(show) { Q('#asst-form').style.display = show ? 'flex' : 'none'; } - function clearForm() { editingId = null; Q('#asst-fhead').textContent = 'Новый провайдер'; Q('#asst-preset').value = ''; Q('#asst-name').value = ''; Q('#asst-url').value = ''; Q('#asst-model').value = ''; Q('#asst-key').value = ''; Q('#asst-key').placeholder = 'ключ'; toggleKilo(); openForm(false); } + function clearForm() { editingId = null; _pickedLimits = null; Q('#asst-fhead').textContent = 'Новый провайдер'; Q('#asst-preset').value = ''; Q('#asst-name').value = ''; Q('#asst-url').value = ''; Q('#asst-model').value = ''; Q('#asst-key').value = ''; Q('#asst-key').placeholder = 'ключ'; Q('#asst-fetched').style.display = 'none'; Q('#asst-fetched').innerHTML = ''; Q('#asst-fetch-st').textContent = ''; toggleKilo(); openForm(false); } function toggleKilo() { var isKilo = /kilocode\.ai/.test(Q('#asst-url').value || ''); Q('#asst-kbox').style.display = isKilo ? '' : 'none'; @@ -163,7 +182,10 @@ // карточные действия 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'); }); + var km = kiloModels.find(function (m) { return m.id === sel.value; }); + var body = { id: sel.getAttribute('data-ksel'), model: sel.value }; + if (km) { body.ctx = km.ctx; body.out = km.out; body.free = true; } + LS.adminSaveProvider(body).then(function () { LS.toast('Модель обновлена', 'success'); render(); }).catch(function () { LS.toast('Ошибка', 'error'); }); }); }); listEl.querySelectorAll('[data-act]').forEach(function (b) { @@ -186,14 +208,38 @@ // форма Q('#asst-ftoggle').addEventListener('click', function () { var open = Q('#asst-form').style.display !== 'none'; if (open) { clearForm(); } else { clearForm(); openForm(true); Q('#asst-name').focus(); } }); - 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-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; _pickedLimits = null; Q('#asst-fetched').style.display = 'none'; Q('#asst-fetch-st').textContent = ''; toggleKilo(); } }); + Q('#asst-url').addEventListener('input', function () { toggleKilo(); _pickedLimits = null; Q('#asst-fetched').style.display = 'none'; Q('#asst-fetch-st').textContent = ''; }); + Q('#asst-kmodels').addEventListener('change', function () { Q('#asst-model').value = this.value; var km = kiloModels.find(function (m) { return m.id === Q('#asst-model').value; }); _pickedLimits = km ? { ctx: km.ctx, out: km.out, free: true } : null; }); Q('#asst-cancel').addEventListener('click', clearForm); + Q('#asst-fetch').addEventListener('click', async function () { + var st = Q('#asst-fetch-st'), btn = this; + var params = editingId ? { id: editingId } : {}; + if (!editingId) { params.url = Q('#asst-url').value.trim(); params.key = Q('#asst-key').value.trim(); if (!params.url) { st.textContent = 'Сначала введите URL'; return; } } + btn.disabled = true; st.textContent = 'Загружаю…'; + try { + var r = await LS.adminAssistantModels(params); + if (!r || r.error) { st.textContent = 'Ошибка: ' + ((r && (r.error || ('HTTP ' + r.status))) || 'нет данных'); return; } + var models = r.models || []; + if (!models.length) { st.textContent = 'Моделей не найдено'; return; } + var sel = Q('#asst-fetched'); + sel.innerHTML = '' + models.map(function (m) { + return ''; + }).join(''); + sel.style.display = ''; st.textContent = 'Загружено: ' + models.length + ' — выберите модель'; + } catch (e) { st.textContent = 'Ошибка'; } finally { btn.disabled = false; } + }); + Q('#asst-fetched').addEventListener('change', function () { + var o = this.options[this.selectedIndex]; if (!o || !o.value) { _pickedLimits = null; return; } + Q('#asst-model').value = o.value; + var fr = o.getAttribute('data-free'); + _pickedLimits = { ctx: Number(o.getAttribute('data-ctx')) || null, out: Number(o.getAttribute('data-out')) || null, free: fr === '' ? null : fr === 'true' }; + }); 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; + if (_pickedLimits) { body.ctx = _pickedLimits.ctx; body.out = _pickedLimits.out; body.free = _pickedLimits.free; } 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'); } }); diff --git a/js/api.js b/js/api.js index 444a211..215b3df 100644 --- a/js/api.js +++ b/js/api.js @@ -1052,7 +1052,7 @@ window.LS = { createMaterialCollection, updateMaterialCollection, deleteMaterialCollection, assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, - adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, + adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels, fcListDecks, fcCreateDeck, fcAddCard, escapeHtml, esc, parseDate, fmtRelTime, safeHref, @@ -1284,6 +1284,7 @@ async function adminReindexTextbooks() { return req('POST', '/admin/assistant/re async function adminSaveProvider(d) { return req('POST', '/admin/assistant/provider', d); } 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 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); }