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:
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user