'use strict'; /* Генерация изображений (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'); const { isFeatureEnabledForUser } = require('../middleware/features'); const GEN_DIR = path.join(__dirname, '../../uploads/generated'); const _cooldown = new Map(); // userId → last ts (антиспам, короткоживущий, ок в памяти) const COOLDOWN_DEFAULT = 4000; const DAILY_DEFAULT = 40; // Дневной счётчик — в БД (imggen_usage), переживает рестарт. function _dailyCount(uid, day) { try { const r = db.prepare('SELECT count FROM imggen_usage WHERE user_id=? AND day=?').get(uid, day); return r ? r.count : 0; } catch (e) { return 0; } } function _dailyBump(uid, day) { try { db.prepare('INSERT INTO imggen_usage (user_id, day, count) VALUES (?,?,1) ON CONFLICT(user_id, day) DO UPDATE SET count = count + 1').run(uid, day); } catch (e) {} } // Чистка in-memory cooldown (старше минуты) + старых строк imggen_usage — не растём бесконечно. setInterval(() => { const cut = Date.now() - 60_000; for (const [k, t] of _cooldown) if (t < cut) _cooldown.delete(k); try { db.prepare("DELETE FROM imggen_usage WHERE day < date('now','-7 days')").run(); } catch (e) {} }, 5 * 60_000).unref(); 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 _configured(c) { c = (c === undefined) ? _cfg() : c; return !!(c && c.provider === 'cloudflare' && c.accountId && c.token); } // «Включено» = настроено И не выключено тумблером (c.on !== false). Тумблер независим от наличия токена. function _enabled() { const c = _cfg(); return _configured(c) && c.on !== false; } 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, }; } // Фича включена для ЭТОГО пользователя (глобально + оверлей класса + free_student). function _featOn(req) { return isFeatureEnabledForUser(req.user.id, req.user.role, 'imggen'); } /* GET /api/imggen/status — для UI (показывать кнопки или нет) */ function status(req, res) { res.json({ enabled: _enabled() && _featOn(req) }); } /* FLUX лучше понимает английский. Если в промпте есть кириллица — переводим * через тот же LLM-провайдер, что и ассистент (с failover). При сбое — исходный текст. */ async function _toEnglish(prompt) { if (!/[Ѐ-ӿ]/.test(prompt)) return prompt; try { const { callLLMFailover } = require('./assistantController'); const sys = 'You translate image-generation prompts into concise, vivid English. ' + 'Output ONLY the English prompt — no quotes, no notes, no preamble. Keep it short and descriptive.'; const r = await callLLMFailover([{ role: 'system', content: sys }, { role: 'user', content: prompt }], 120); if (r && r.text) { const en = String(r.text).replace(/^["'«»\s]+|["'«»\s]+$/g, '').replace(/\s+/g, ' ').trim(); if (en && !/[Ѐ-ӿ]/.test(en)) return en.slice(0, 500); } } catch (e) {} return 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, steps: 4 }), signal: ctrl.signal, }); 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) { if (e.name === 'AbortError') throw new Error('Слишком долго — попробуй ещё раз'); throw e; } finally { clearTimeout(timer); } } 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) { // Низкоуровневая: проверяем только наличие конфига (тумблер on/off — забота вызывающего // route /api/imggen). Так админ-тест работает даже при выключенном тумблере. if (!_configured()) 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 (!_featOn(req)) return res.status(403).json({ error: 'Генерация картинок отключена для вашего класса' }); if (!_enabled()) return res.status(503).json({ error: _configured() ? 'Генерация картинок временно выключена' : 'Генерация изображений не настроена' }); 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); if (L.dailyCap > 0 && _dailyCount(uid, today) >= L.dailyCap) return res.status(429).json({ error: 'Дневной лимит генераций исчерпан, попробуй завтра' }); _cooldown.set(uid, now); try { const out = await generateImage(prompt, uid); _dailyBump(uid, today); res.json({ url: out.url }); } catch (e) { res.status(502).json({ error: e.message || 'Не удалось сгенерировать', detail: e.detail }); } } module.exports = { generate, status, generateImage, stats };