feat(admin): раздел «Генерация картинок» — управление провайдером и тест

Новый админ-раздел: 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 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-12 11:37:47 +03:00
parent 4e8c0841db
commit 88651d85ab
6 changed files with 286 additions and 42 deletions
@@ -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,
+96 -41
View File
@@ -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 };
+3
View File
@@ -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);