feat(imggen): генерация картинок ИИ (FLUX.1) — ассистент, флэшкарты, редактор уроков

Бэкенд /api/imggen (status/generate, CF Workers AI, cooldown+дневной лимит).
Переиспользуемый модал LS.imagePromptModal (js/imggen.js).
Квантик: режим «Нарисовать» в чате (inline).
Флэшкарты: кнопка «ИИ» в блоке картинки карточки.
Редактор уроков: кнопка «Сгенерировать» в блоке изображения.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-12 10:41:59 +03:00
parent db2fccef56
commit d6faf6b22c
8 changed files with 206 additions and 5 deletions
@@ -0,0 +1,65 @@
'use strict';
/* Генерация изображений (Cloudflare Workers AI · FLUX.1 schnell).
* Конфиг в app_settings.imggen_provider: { provider, accountId, token, model }.
* Картинка сохраняется в uploads/generated и отдаётся URL'ом. */
const fs = require('fs');
const path = require('path');
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;
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); }
/* 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 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, 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 });
} catch (e) {
res.status(502).json({ error: e.name === 'AbortError' ? 'Слишком долго — попробуй ещё раз' : 'Не удалось сгенерировать' });
} finally { clearTimeout(timer); }
}
module.exports = { generate, status };
+10
View File
@@ -0,0 +1,10 @@
'use strict';
const router = require('express').Router();
const { authMiddleware } = require('../middleware/auth');
const ctrl = require('../controllers/imggenController');
router.use(authMiddleware);
router.get('/status', ctrl.status);
router.post('/', ctrl.generate);
module.exports = router;
+2
View File
@@ -160,6 +160,7 @@ app.use('/api/questions', questionRoutes);
app.use('/api/classes', classRoutes);
app.use('/api/assignments', assignmentRoutes);
app.use('/api/files', fileRoutes);
app.use('/api/imggen', require('./routes/imggen'));
app.use('/api/tests', testRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/permissions', permissionRoutes);
@@ -336,6 +337,7 @@ app.use('/css', express.static(path.join(frontendDir, 'css'), staticCache));
app.use('/img', express.static(path.join(frontendDir, 'img'), staticCache));
app.use('/avatars', express.static(path.join(__dirname, '../uploads/avatars'), { maxAge: '1d' }));
app.use('/uploads/flashcards', express.static(path.join(__dirname, '../uploads/flashcards'), { maxAge: '7d' }));
app.use('/uploads/generated', express.static(path.join(__dirname, '../uploads/generated'), { maxAge: '7d' }));
app.use('/uploads/materials', express.static(path.join(__dirname, '../uploads/materials'), { maxAge: '7d' }));
// Redirect legacy .html URLs → clean URLs (301)