From 88651d85ab1fe80e2a6e8fd65f3085c705a46397 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 12 Jun 2026 11:37:47 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=20=C2=AB=D0=93=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BA=D0=B0=D1=80=D1=82=D0=B8=D0=BD=D0=BE=D0=BA=C2=BB?= =?UTF-8?q?=20=E2=80=94=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B0=D0=B9=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=BE=D0=BC=20=D0=B8=20=D1=82=D0=B5=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый админ-раздел: Account ID / токен (маскируется) / модель Cloudflare, лимиты (пауза, дневной лимит) из БД, статистика, кнопка теста генерации. imggenController: лимиты и модель теперь из конфига, поддержка JSON и бинарного ответа CF, переиспользуемые generateImage() и stats(). Бэкенд GET/PUT /api/admin/imggen + POST /api/admin/imggen/test (admin-only). Co-Authored-By: Claude Opus 4.8 --- backend/src/controllers/adminController.js | 53 ++++++++ backend/src/controllers/imggenController.js | 137 ++++++++++++++------ backend/src/routes/admin.js | 3 + frontend/admin.html | 11 ++ frontend/js/admin/admin.js | 3 +- frontend/js/admin/sections/imggen.js | 121 +++++++++++++++++ 6 files changed, 286 insertions(+), 42 deletions(-) create mode 100644 frontend/js/admin/sections/imggen.js diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index 3485cdb..156625d 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -1094,8 +1094,61 @@ async function testAssistant(req, res) { res.json(r); } +/* ── Генерация картинок (Cloudflare Workers AI) ──────────────────────────── */ +const IMGGEN_MODELS = [ + { id: '@cf/black-forest-labs/flux-1-schnell', label: 'FLUX.1 schnell — рекомендуется, быстрый' }, + { id: '@cf/stabilityai/stable-diffusion-xl-base-1.0', label: 'Stable Diffusion XL' }, + { id: '@cf/bytedance/stable-diffusion-xl-lightning', label: 'SDXL Lightning — очень быстрый' }, + { id: '@cf/lykon/dreamshaper-8-lcm', label: 'DreamShaper 8 LCM' }, +]; +function _imgCfg() { try { return JSON.parse(_aset('imggen_provider') || '{}') || {}; } catch (e) { return {}; } } + +function getImggen(_req, res) { + const c = _imgCfg(); + let stats = { count: 0, bytes: 0 }; + try { stats = require('./imggenController').stats(); } catch (e) {} + const cd = Number(c.cooldownMs), dc = Number(c.dailyCap); + res.json({ + provider: c.provider || 'cloudflare', + accountId: c.accountId || '', + model: c.model || '@cf/black-forest-labs/flux-1-schnell', + hasToken: !!c.token, + cooldownMs: Number.isFinite(cd) && cd >= 0 ? cd : 4000, + dailyCap: Number.isFinite(dc) && dc >= 0 ? dc : 40, + enabled: !!(c.provider === 'cloudflare' && c.accountId && c.token), + models: IMGGEN_MODELS, + stats, + }); +} + +function saveImggen(req, res) { + const b = req.body || {}; + const c = _imgCfg(); + c.provider = b.provider || c.provider || 'cloudflare'; + if (b.accountId !== undefined) c.accountId = String(b.accountId || '').trim(); + if (b.model !== undefined) c.model = String(b.model || '').trim(); + if (b.clearToken) c.token = ''; + else if (b.token && b.token !== '••••••••') c.token = String(b.token).trim(); + if (b.cooldownMs !== undefined) { const n = Number(b.cooldownMs); if (Number.isFinite(n) && n >= 0) c.cooldownMs = n; } + if (b.dailyCap !== undefined) { const n = Number(b.dailyCap); if (Number.isFinite(n) && n >= 0) c.dailyCap = n; } + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('imggen_provider', ?)").run(JSON.stringify(c)); + audit(req, 'imggen.config', 'imggen', 'настройки генерации картинок'); + res.json({ ok: true }); +} + +async function testImggen(req, res) { + const prompt = (String((req.body && req.body.prompt) || '').trim()) || 'a cute friendly mascot, flat illustration, warm tones'; + try { + const out = await require('./imggenController').generateImage(prompt, 0); + res.json({ ok: true, url: out.url, prompt: out.prompt }); + } catch (e) { + res.status(502).json({ ok: false, error: e.message || 'Ошибка', detail: e.detail }); + } +} + module.exports = { getStats, getOverview, globalSearch, + getImggen, saveImggen, testImggen, getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail, clearUserSessions, deleteSession, updateUser, banUser, deleteUser, getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures, diff --git a/backend/src/controllers/imggenController.js b/backend/src/controllers/imggenController.js index 8fc3916..372814c 100644 --- a/backend/src/controllers/imggenController.js +++ b/backend/src/controllers/imggenController.js @@ -1,7 +1,8 @@ 'use strict'; -/* Генерация изображений (Cloudflare Workers AI · FLUX.1 schnell). - * Конфиг в app_settings.imggen_provider: { provider, accountId, token, model }. - * Картинка сохраняется в uploads/generated и отдаётся URL'ом. */ +/* Генерация изображений (Cloudflare Workers AI · FLUX.1 schnell и др.). + * Конфиг в app_settings.imggen_provider: { provider, accountId, token, model, cooldownMs?, dailyCap? }. + * Картинка сохраняется в uploads/generated и отдаётся URL'ом. + * Управление — админ-раздел «Генерация картинок» (adminController.getImggen/saveImggen/testImggen). */ const fs = require('fs'); const path = require('path'); const db = require('../db/db'); @@ -9,13 +10,25 @@ const db = require('../db/db'); const GEN_DIR = path.join(__dirname, '../../uploads/generated'); const _cooldown = new Map(); // userId → last ts (антиспам) const _daily = new Map(); // userId → { day, count } -const COOLDOWN_MS = 4000; -const DAILY_CAP = 40; +const COOLDOWN_DEFAULT = 4000; +const DAILY_DEFAULT = 40; function _cfg() { try { const r = db.prepare("SELECT value FROM app_settings WHERE key='imggen_provider'").get(); return r ? JSON.parse(r.value) : null; } catch (e) { return null; } } function _enabled() { const c = _cfg(); return !!(c && c.provider === 'cloudflare' && c.accountId && c.token); } +function _limits() { + const c = _cfg() || {}; + const cd = Number(c.cooldownMs); + const dc = Number(c.dailyCap); + return { + cooldownMs: Number.isFinite(cd) && cd >= 0 ? cd : COOLDOWN_DEFAULT, + dailyCap: Number.isFinite(dc) && dc >= 0 ? dc : DAILY_DEFAULT, + }; +} + +/* GET /api/imggen/status — для UI (показывать кнопки или нет) */ +function status(req, res) { res.json({ enabled: _enabled() }); } /* FLUX лучше понимает английский. Если в промпте есть кириллица — переводим * через тот же LLM-провайдер, что и ассистент (с failover). При сбое — исходный текст. */ @@ -34,51 +47,93 @@ async function _toEnglish(prompt) { return prompt; // перевод не удался — отправим как есть } -/* GET /api/imggen/status — для UI (показывать кнопки или нет) */ -function status(req, res) { res.json({ enabled: _enabled() }); } - -/* POST /api/imggen { prompt, width?, height? } → { url } */ -async function generate(req, res) { - const cfg = _cfg(); - if (!_enabled()) return res.status(503).json({ error: 'Генерация изображений не настроена' }); - const prompt = String((req.body && req.body.prompt) || '').trim().slice(0, 500); - if (prompt.length < 3) return res.status(400).json({ error: 'Опиши, что нарисовать (хотя бы пару слов)' }); - if (typeof fetch !== 'function') return res.status(503).json({ error: 'fetch недоступен' }); - - const uid = req.user.id, now = Date.now(); - if (now - (_cooldown.get(uid) || 0) < COOLDOWN_MS) return res.status(429).json({ error: 'Чуть помедленнее — подожди пару секунд' }); - const today = new Date().toISOString().slice(0, 10); - const d = _daily.get(uid); - if (d && d.day === today && d.count >= DAILY_CAP) return res.status(429).json({ error: 'Дневной лимит генераций исчерпан, попробуй завтра' }); - _cooldown.set(uid, now); - - const enPrompt = await _toEnglish(prompt); - +/* Низкоуровневый вызов Cloudflare. Возвращает Buffer (PNG) или бросает Error + * (с опц. .detail). Поддерживает оба формата ответа CF: JSON {result:{image:b64}} + * (FLUX) и сырой бинарный image/* (SDXL и пр.). */ +async function _callCF(cfg, prompt) { + if (typeof fetch !== 'function') throw new Error('fetch недоступен'); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 30000); try { const r = await fetch(`https://api.cloudflare.com/client/v4/accounts/${cfg.accountId}/ai/run/${cfg.model}`, { method: 'POST', headers: { Authorization: 'Bearer ' + cfg.token, 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt: enPrompt, steps: 4 }), + body: JSON.stringify({ prompt, steps: 4 }), signal: ctrl.signal, }); - if (!r.ok) { const t = await r.text(); return res.status(502).json({ error: 'Сервис картинок ответил ошибкой (' + r.status + ')', detail: t.slice(0, 120) }); } - const j = await r.json(); - const b64 = j && j.result && j.result.image; - if (!b64) return res.status(502).json({ error: 'Пустой ответ от сервиса' }); - const buf = Buffer.from(b64, 'base64'); - if (buf.length < 500) return res.status(502).json({ error: 'Некорректное изображение' }); - - fs.mkdirSync(GEN_DIR, { recursive: true }); - const name = uid + '-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + '.png'; - fs.writeFileSync(path.join(GEN_DIR, name), buf); - - _daily.set(uid, { day: today, count: (d && d.day === today ? d.count : 0) + 1 }); - res.json({ url: '/uploads/generated/' + name }); + if (!r.ok) { + const t = await r.text(); + const e = new Error('Сервис картинок ответил ошибкой (' + r.status + ')'); + e.detail = t.slice(0, 160); + throw e; + } + const ct = (r.headers.get('content-type') || '').toLowerCase(); + let buf; + if (ct.includes('application/json')) { + const j = await r.json(); + const b64 = j && j.result && j.result.image; + if (!b64) throw new Error('Пустой ответ от сервиса'); + buf = Buffer.from(b64, 'base64'); + } else { + buf = Buffer.from(await r.arrayBuffer()); + } + if (buf.length < 500) throw new Error('Некорректное изображение'); + return buf; } catch (e) { - res.status(502).json({ error: e.name === 'AbortError' ? 'Слишком долго — попробуй ещё раз' : 'Не удалось сгенерировать' }); + if (e.name === 'AbortError') throw new Error('Слишком долго — попробуй ещё раз'); + throw e; } finally { clearTimeout(timer); } } -module.exports = { generate, status }; +function _save(uid, buf) { + fs.mkdirSync(GEN_DIR, { recursive: true }); + const name = (uid || 0) + '-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + '.png'; + fs.writeFileSync(path.join(GEN_DIR, name), buf); + return '/uploads/generated/' + name; +} + +/* Публичная: перевод → генерация → сохранение. Без пер-юзер лимитов + * (их применяет route generate). Возвращает { url, prompt } или бросает Error. */ +async function generateImage(prompt, uid) { + if (!_enabled()) throw new Error('Генерация изображений не настроена'); + const p = String(prompt || '').trim().slice(0, 500); + if (p.length < 3) throw new Error('Опиши, что нарисовать (хотя бы пару слов)'); + const en = await _toEnglish(p); + const buf = await _callCF(_cfg(), en); + return { url: _save(uid, buf), prompt: en }; +} + +/* Статистика для админки: сколько картинок сгенерировано (файлов) и общий размер. */ +function stats() { + try { + const files = fs.readdirSync(GEN_DIR).filter(f => f.endsWith('.png')); + let bytes = 0; + for (const f of files) { try { bytes += fs.statSync(path.join(GEN_DIR, f)).size; } catch (e) {} } + return { count: files.length, bytes }; + } catch (e) { return { count: 0, bytes: 0 }; } +} + +/* POST /api/imggen { prompt } → { url } */ +async function generate(req, res) { + if (!_enabled()) return res.status(503).json({ error: 'Генерация изображений не настроена' }); + const prompt = String((req.body && req.body.prompt) || '').trim().slice(0, 500); + if (prompt.length < 3) return res.status(400).json({ error: 'Опиши, что нарисовать (хотя бы пару слов)' }); + + const uid = req.user.id, now = Date.now(); + const L = _limits(); + if (now - (_cooldown.get(uid) || 0) < L.cooldownMs) return res.status(429).json({ error: 'Чуть помедленнее — подожди пару секунд' }); + const today = new Date().toISOString().slice(0, 10); + const d = _daily.get(uid); + if (L.dailyCap > 0 && d && d.day === today && d.count >= L.dailyCap) return res.status(429).json({ error: 'Дневной лимит генераций исчерпан, попробуй завтра' }); + _cooldown.set(uid, now); + + try { + const out = await generateImage(prompt, uid); + _daily.set(uid, { day: today, count: (d && d.day === today ? d.count : 0) + 1 }); + res.json({ url: out.url }); + } catch (e) { + res.status(502).json({ error: e.message || 'Не удалось сгенерировать', detail: e.detail }); + } +} + +module.exports = { generate, status, generateImage, stats }; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 17cd9db..685e2e7 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -18,6 +18,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.get('/imggen', ctrl.getImggen); +router.put('/imggen', ctrl.saveImggen); +router.post('/imggen/test', ctrl.testImggen); 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/admin.html b/frontend/admin.html index c0252ea..2ccb7b5 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -1073,6 +1073,9 @@ + @@ -1555,6 +1558,13 @@
Загрузка…
+ +
+
Генерация картинок
+
ИИ-генерация изображений (Cloudflare Workers AI · FLUX/SDXL): провайдер, модель, лимиты и тест. Картинки — для иллюстраций и декора, не для точных схем.
+
Загрузка…
+
+
Управление играми
@@ -2128,6 +2138,7 @@ + diff --git a/frontend/js/admin/admin.js b/frontend/js/admin/admin.js index f2a9eb5..aeef22b 100644 --- a/frontend/js/admin/admin.js +++ b/frontend/js/admin/admin.js @@ -15,7 +15,7 @@ AdminCtx.isAdmin = isAdmin; /* Admin-only tabs: show to everyone for discoverability, but lock for non-admins */ - const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games','btn-tab-assistant']; + const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games','btn-tab-assistant','btn-tab-imggen']; const lockSvg = ''; ADMIN_ONLY_TABS.forEach(id => { const el = document.getElementById(id); @@ -66,6 +66,7 @@ sims: 'sims', games: 'games', assistant: 'assistant', + imggen: 'imggen', sublog: 'sublog', access: 'access', }; diff --git a/frontend/js/admin/sections/imggen.js b/frontend/js/admin/sections/imggen.js new file mode 100644 index 0000000..90d167a --- /dev/null +++ b/frontend/js/admin/sections/imggen.js @@ -0,0 +1,121 @@ +'use strict'; +/* admin → «Генерация картинок»: провайдер Cloudflare (Account ID, токен, модель), + * лимиты (пауза, дневной лимит), статистика и тест-генерация. */ +(function () { + 'use strict'; + let inited = false; + 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)'; + function fmtBytes(n) { if (!n) return '0'; if (n >= 1048576) return (n / 1048576).toFixed(1) + ' МБ'; if (n >= 1024) return Math.round(n / 1024) + ' КБ'; return n + ' Б'; } + + function ensureStyle() { + if (document.getElementById('img-adm-style')) return; + var s = document.createElement('style'); s.id = 'img-adm-style'; + s.textContent = [ + '.img-card{border:1.5px solid var(--border,#e2e8f0);border-radius:14px;background:var(--surface,#fff);padding:16px 18px;margin-top:14px;}', + '.img-flabel{font-size:.76rem;font-weight:700;color:var(--text-2,#475569);margin:11px 0 4px;}', + '.img-row{display:flex;gap:12px;flex-wrap:wrap;}', + '.img-row > div{flex:1;min-width:140px;}', + '.img-bdg{font-size:.62rem;font-weight:800;text-transform:uppercase;letter-spacing:.03em;padding:3px 10px;border-radius:99px;}', + '.img-bdg.on{background:rgba(5,150,82,.13);color:#059652;}', + '.img-bdg.off{background:rgba(140,148,166,.16);color:#8a94a6;}', + '.img-btn{padding:9px 17px;border-radius:10px;border:none;cursor:pointer;font:700 .82rem Manrope,sans-serif;}', + '.img-btn.primary{background:#9B5DE5;color:#fff;}', + '.img-btn.primary:hover{background:#7e3eca;}', + '.img-btn.ghost{background:transparent;border:1.5px solid var(--border-h,#cbd5e1);color:var(--text-2,#475569);}', + '.img-btn:disabled{opacity:.55;cursor:not-allowed;}', + '.img-prev{margin-top:12px;border-radius:12px;overflow:hidden;border:1px solid var(--border,#e2e8f0);background:#0d0d1f;min-height:90px;display:flex;align-items:center;justify-content:center;}', + '.img-prev img{max-width:100%;display:block;}', + '.img-busy{color:#9aa5b4;font-size:.82rem;padding:24px;text-align:center;}', + '.img-hint{font-size:.72rem;color:#8a94a6;margin-top:5px;line-height:1.45;}', + ].join(''); + document.head.appendChild(s); + } + + async function render() { + var host = document.getElementById('imggen-admin'); + if (!host) return; + ensureStyle(); + host.innerHTML = '
Загрузка…
'; + + var cfg = {}; + try { cfg = await LS.api('/api/admin/imggen'); } catch (e) { host.innerHTML = '
Не удалось загрузить настройки
'; return; } + var models = cfg.models || []; + var cdSec = Math.round((cfg.cooldownMs != null ? cfg.cooldownMs : 4000) / 1000); + + host.innerHTML = + '
' + + '
' + + '
Cloudflare Workers AI
' + + '' + (cfg.enabled ? 'Включена' : 'Не настроена') + '' + + '
' + + '
Бесплатная генерация (FLUX.1 / SDXL). Токен и Account ID — из дашборда Cloudflare (Workers AI). Хранятся в БД, не в git.
' + + + '
Account ID
' + + '' + + + '
API-токен
' + + '' + + (cfg.hasToken ? '' : '') + + + '
Модель
' + + '' + + + '
' + + '
Пауза между запросами (сек)
' + + '
Дневной лимит на пользователя
' + + '
' + + '
0 в дневном лимите — без ограничения.
' + + + '
' + + '
Сгенерировано: ' + (cfg.stats ? cfg.stats.count : 0) + ' картинок · ' + (cfg.stats ? fmtBytes(cfg.stats.bytes) : '0') + '
' + + '' + + '
' + + '
' + + + '
' + + '
Тест генерации
' + + '' + + '
' + + '' + + '
'; + + var Q = function (s) { return host.querySelector(s); }; + + Q('#img-save').addEventListener('click', async function () { + var btn = this; btn.disabled = true; + var body = { + provider: 'cloudflare', + accountId: Q('#img-acc').value.trim(), + model: Q('#img-model').value, + cooldownMs: Math.max(0, Number(Q('#img-cd').value) || 0) * 1000, + dailyCap: Math.max(0, Number(Q('#img-cap').value) || 0), + }; + var clr = Q('#img-clear-token'); + if (clr && clr.checked) body.clearToken = true; + else { var t = Q('#img-token').value.trim(); if (t) body.token = t; } + try { await LS.api('/api/admin/imggen', { method: 'PUT', body: JSON.stringify(body) }); LS.toast('Сохранено', 'success'); render(); } + catch (e) { LS.toast('Ошибка сохранения', 'error'); btn.disabled = false; } + }); + + Q('#img-test-btn').addEventListener('click', async function () { + var btn = this, prev = Q('#img-test-prev'); + var prompt = Q('#img-test-prompt').value.trim(); + btn.disabled = true; btn.textContent = 'Рисую…'; + prev.style.display = 'flex'; prev.innerHTML = '
Генерирую… (5–15 сек)
'; + try { + var r = await LS.api('/api/admin/imggen/test', { method: 'POST', body: JSON.stringify({ prompt: prompt }) }); + if (r && r.url) prev.innerHTML = ''; + else prev.innerHTML = '
Пустой ответ
'; + } catch (e) { + var msg = (e && e.data && (e.data.error || e.data.detail)) || e.message || 'Ошибка'; + prev.innerHTML = '
' + esc(msg) + '
'; + } finally { btn.disabled = false; btn.textContent = 'Сгенерировать тест'; } + }); + } + + window.AdminSections = window.AdminSections || {}; + window.AdminSections.imggen = { init: async () => { if (inited) return; inited = true; await render(); }, reload: render }; +})();