ccfb151eca
- imggen: дневной счётчик генераций перенесён из in-memory Map в таблицу imggen_usage (миграция 070) — переживает рестарт. Cooldown остаётся в памяти, но добавлена периодическая чистка Map + старых строк imggen_usage (>7 дн). - classroom-cleanup: ретеншн error_log (app_settings.error_log_retention_days, по умолч. 30; 0 = выкл) в том же суточном джобе. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
163 lines
8.9 KiB
JavaScript
163 lines
8.9 KiB
JavaScript
'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 };
|