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 @@