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)
+21 -1
View File
@@ -560,6 +560,7 @@
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script src="/js/api.js"></script>
<script src="/js/imggen.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script src="/js/search.js"></script>
@@ -951,13 +952,32 @@ function imgRowHtml(c, side) {
</button>
</div></div>`;
}
return `<div class="card-img-row">
return `<div class="card-img-row" style="display:flex;gap:6px;flex-wrap:wrap">
<button class="card-img-add" onclick="pickCardImage(${c.id},'${side}')">
<svg class="ic" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
Картинка
</button>
<button class="card-img-add" onclick="genCardImage(${c.id},'${side}')" title="Сгенерировать с ИИ">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 3l2.2 6.3L22 12l-6.8 2.7L13 21l-2.2-6.3L4 12l6.8-2.7z"/><path d="M5 4v3M3.5 5.5h3"/></svg>
ИИ
</button></div>`;
}
function genCardImage(cardId, side) {
if (!LS.imagePromptModal) { LS.toast('Модуль генерации не загружен'); return; }
const card = _cards.find(c => c.id === cardId);
LS.imagePromptModal({
title: 'Картинка для карточки',
placeholder: card && card[side === 'front' ? 'front' : 'back'] ? 'Иллюстрация к: ' + (card[side === 'front' ? 'front' : 'back'] || '') : '',
onUse: async function (url) {
const c = _cards.find(x => x.id === cardId); if (!c) return;
const field = side === 'front' ? 'front_image' : 'back_image';
await LS.api(`/api/flashcards/cards/${cardId}`, { method: 'PUT', body: JSON.stringify({ [field]: url }) }).catch(()=>{});
c[field] = url; updateCardImgRow(cardId, side); LS.toast('Картинка добавлена', 'success');
}
});
}
async function uploadFcImage(file) {
if (!file || !file.type || !file.type.startsWith('image/')) throw new Error('Только изображения');
if (file.size > 5 * 1024 * 1024) throw new Error('Файл больше 5 МБ');
+25 -3
View File
@@ -490,7 +490,8 @@
chatEl.innerHTML = '';
_chat.forEach(function (m) {
var d = msgEl(m.role);
if (m.role === 'assistant') { d.innerHTML = '<div class="asst-rich"></div>'; renderRich(d.querySelector('.asst-rich'), m.content); }
if (m.img) d.innerHTML = '<img src="' + m.img + '" alt="" style="width:100%;border-radius:10px;display:block">';
else if (m.role === 'assistant') { d.innerHTML = '<div class="asst-rich"></div>'; renderRich(d.querySelector('.asst-rich'), m.content); }
else d.textContent = m.content;
chatEl.appendChild(d);
});
@@ -498,7 +499,7 @@
}
var FB_UP = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v11"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>';
var FB_DOWN = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(180deg)"><path d="M7 10v11"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>';
var MODE_PH = { answer: 'Спроси что угодно: «объясни…», «как…»', hint: 'Задача, по которой нужна подсказка…', check: 'Вставь своё решение — проверю…' };
var MODE_PH = { answer: 'Спроси что угодно: «объясни…», «как…»', hint: 'Задача, по которой нужна подсказка…', check: 'Вставь своё решение — проверю…', draw: 'Опиши картинку: «кот-учёный, плоская иллюстрация»' };
function openAsk(prefill) {
var sel = _lastSel, pc = getPageContext();
var ctxBtns = '';
@@ -512,7 +513,8 @@
var modes = '<div class="asst-modes">' +
'<button class="asst-mode on" data-m="answer">Ответ</button>' +
'<button class="asst-mode" data-m="hint">Подсказка</button>' +
'<button class="asst-mode" data-m="check">Проверить решение</button></div>';
'<button class="asst-mode" data-m="check">Проверить решение</button>' +
'<button class="asst-mode" data-m="draw">Нарисовать</button></div>';
openBubble(
'<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Спроси Квантика' +
'<button class="asst-link" data-a="mem" style="float:right;font-weight:600;margin-right:24px">Память</button>' +
@@ -580,9 +582,29 @@
});
}
function srcUrl(s) { return '/textbook/' + s.slug + (s.ref ? '#sec-' + s.ref : ''); }
function drawInChat(prompt, chatEl) {
prompt = (prompt || '').trim();
if (prompt.length < 3) return;
_chat.push({ role: 'user', content: 'Нарисуй: ' + prompt });
var u = msgEl('user'); u.textContent = 'Нарисуй: ' + prompt; chatEl.appendChild(u);
var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.textContent = 'Рисую картинку…'; chatEl.appendChild(ph);
chatEl.scrollTop = chatEl.scrollHeight;
LS.imageGen(prompt).then(function (r) {
ph.remove();
var d = msgEl('assistant');
if (r && r.url) { d.innerHTML = '<img src="' + r.url + '" alt="" style="width:100%;border-radius:10px;display:block">'; _chat.push({ role: 'assistant', content: '[картинка]', img: r.url }); }
else d.textContent = 'Не получилось нарисовать.';
chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight;
}).catch(function (err) {
ph.remove(); var d = msgEl('assistant');
d.textContent = (err && err.data && err.data.error) || 'Не получилось нарисовать.';
chatEl.appendChild(d); chatEl.scrollTop = chatEl.scrollHeight;
});
}
function send(q, context, chatEl, mode) {
q = (q || '').trim();
if (q.length < 2) return;
if (mode === 'draw') return drawInChat(q, chatEl);
var history = _chat.slice(-6);
_chat.push({ role: 'user', content: q });
var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u);
+64
View File
@@ -0,0 +1,64 @@
'use strict';
/* Переиспользуемый модал генерации картинок. LS.imagePromptModal({title, placeholder, onUse}).
* Зависит от LS.imageGen (api.js) и LS.toast. Подключать на страницах с кнопкой генерации. */
(function () {
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'})[c];}); }
function ensureStyle(){
if (document.getElementById('imggen-style')) return;
var s=document.createElement('style'); s.id='imggen-style';
s.textContent=[
'.ig-ov{position:fixed;inset:0;z-index:2000;display:flex;align-items:center;justify-content:center;background:rgba(15,23,42,.5);backdrop-filter:blur(6px);padding:20px}',
'.ig-box{background:var(--surface,#fff);border:1.5px solid var(--border,#e2e8f0);border-radius:20px;width:440px;max-width:96vw;max-height:92vh;overflow:auto;padding:20px 22px;box-shadow:0 24px 70px rgba(0,0,0,.3)}',
'.ig-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}',
".ig-title{font-family:'Unbounded',sans-serif;font-weight:800;font-size:.98rem}",
'.ig-x{border:none;background:none;font-size:1.4rem;line-height:1;cursor:pointer;color:var(--text-2,#64748b)}',
'.ig-ta{width:100%;box-sizing:border-box;min-height:64px;padding:10px 12px;border:1.5px solid var(--border,#e2e8f0);border-radius:11px;font:inherit;font-size:.86rem;resize:vertical;background:var(--surface,#fff);color:var(--text,#0f172a)}',
'.ig-hint{font-size:.7rem;color:var(--text-3,#94a3b8);margin:6px 0 10px;line-height:1.45}',
".ig-btn{padding:9px 16px;border-radius:10px;border:none;cursor:pointer;font:700 .82rem 'Manrope',sans-serif}",
'.ig-btn.primary{background:var(--violet,#9B5DE5);color:#fff}',
'.ig-btn.ghost{background:transparent;border:1.5px solid var(--border-h,#cbd5e1);color:var(--text-2,#475569)}',
'.ig-btn:disabled{opacity:.55;cursor:not-allowed}',
'.ig-preview{margin-top:14px;border-radius:14px;overflow:hidden;border:1.5px solid var(--border,#e2e8f0);background:#0d0d1f;min-height:120px;display:flex;align-items:center;justify-content:center}',
'.ig-preview img{width:100%;display:block}',
'.ig-busy{color:#9aa5b4;font-size:.84rem;padding:28px;text-align:center}',
'.ig-actions{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}',
].join('');
document.head.appendChild(s);
}
window.LS = window.LS || {};
LS.imagePromptModal = function (opts) {
opts = opts || {}; ensureStyle();
var ov = document.createElement('div'); ov.className = 'ig-ov';
ov.innerHTML = '<div class="ig-box">'
+ '<div class="ig-head"><span class="ig-title">' + esc(opts.title || 'Сгенерировать картинку') + '</span><button class="ig-x" data-x>&times;</button></div>'
+ '<textarea class="ig-ta" placeholder="' + esc(opts.placeholder || 'Опиши картинку: «кот-учёный в очках, плоская иллюстрация»') + '"></textarea>'
+ '<div class="ig-hint">ИИ-картинка для иллюстраций и декора (не для точных схем — графиков, формул). FLUX.1 · бесплатно.</div>'
+ '<div class="ig-actions"><button class="ig-btn primary" data-gen>Сгенерировать</button></div>'
+ '<div class="ig-preview" data-prev style="display:none"></div>'
+ '<div class="ig-actions" data-userow style="display:none"><button class="ig-btn primary" data-use>' + esc(opts.useLabel || 'Использовать') + '</button><button class="ig-btn ghost" data-again>Ещё вариант</button></div>'
+ '</div>';
document.body.appendChild(ov);
var ta = ov.querySelector('.ig-ta'), prev = ov.querySelector('[data-prev]'), useRow = ov.querySelector('[data-userow]'), genBtn = ov.querySelector('[data-gen]');
var lastUrl = null;
if (opts.prompt) ta.value = opts.prompt;
function close(){ ov.remove(); }
ov.addEventListener('click', function (e) { if (e.target === ov || e.target.hasAttribute('data-x')) close(); });
async function gen(){
var prompt = ta.value.trim();
if (prompt.length < 3) { LS.toast && LS.toast('Опиши, что нарисовать', 'warn'); return; }
genBtn.disabled = true; genBtn.textContent = 'Рисую…'; useRow.style.display = 'none';
prev.style.display = 'flex'; prev.innerHTML = '<div class="ig-busy">Генерирую картинку… (5–15 сек)</div>';
try {
var r = await LS.imageGen(prompt);
if (r && r.url) { lastUrl = r.url; prev.innerHTML = '<img src="' + r.url + '" alt="">'; useRow.style.display = 'flex'; genBtn.textContent = 'Перегенерировать'; }
else prev.innerHTML = '<div class="ig-busy">Не получилось</div>';
} catch (e) { prev.innerHTML = '<div class="ig-busy">' + esc((e && e.data && e.data.error) || e.message || 'Ошибка') + '</div>'; }
finally { genBtn.disabled = false; if (genBtn.textContent === 'Рисую…') genBtn.textContent = 'Сгенерировать'; }
}
genBtn.onclick = gen;
ov.querySelector('[data-again]').onclick = gen;
ov.querySelector('[data-use]').onclick = function () { if (lastUrl && opts.onUse) opts.onUse(lastUrl); close(); };
setTimeout(function () { ta.focus(); }, 50);
return ov;
};
})();
+16
View File
@@ -1004,6 +1004,7 @@
</div>
<script src="/js/api.js"></script>
<script src="/js/imggen.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/svg-sanitize.js"></script>
<script src="/js/svg-draw.js"></script>
@@ -1602,6 +1603,9 @@
<button class="img-upload-btn" onclick="document.getElementById('img-file-${bid}').click()">
<i data-lucide="upload" style="width:14px;height:14px"></i> Загрузить файл
</button>
<button class="img-upload-btn" onclick="genBlockImage('${bid}')" title="Сгенерировать изображение с ИИ">
<i data-lucide="sparkles" style="width:14px;height:14px"></i> Сгенерировать
</button>
<span class="img-upload-progress" id="img-status-${bid}"></span>
</div>
<div class="block-row">
@@ -3033,6 +3037,18 @@
}
}
function genBlockImage(bid) {
if (!LS.imagePromptModal) { LS.toast('Модуль генерации не загружен'); return; }
LS.imagePromptModal({
title: 'Изображение для урока',
onUse: function (url) {
updateBlockData(bid, 'url', url);
rerenderBlock(bid);
markDirty();
}
});
}
/* ══════════════════════════════════════════════════════════════════
FEATURE: Code syntax highlighting
══════════════════════════════════════════════════════════════════ */
+3 -1
View File
@@ -1050,7 +1050,7 @@ window.LS = {
crAdminGetAllHistory, crAdminGetTeachersList,
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear,
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus,
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
fcListDecks, fcCreateDeck, fcAddCard,
@@ -1279,6 +1279,8 @@ async function assistantFlashcards(text, title) { return req('POST', '/assistant
async function assistantFeedback(rating, q) { return req('POST', '/assistant/feedback', { rating, q: q || undefined }); }
async function assistantMemory() { return req('GET', '/assistant/memory'); }
async function assistantMemoryClear(id) { return req('DELETE', '/assistant/memory' + (id ? '/' + id : '')); }
async function imageGen(prompt) { return req('POST', '/imggen', { prompt }); }
async function imageGenStatus() { return req('GET', '/imggen/status'); }
async function adminGetAssistant() { return req('GET', '/admin/assistant'); }
async function adminSaveAssistant(d) { return req('PUT', '/admin/assistant', d); }
async function adminTestAssistant(d) { return req('POST', '/admin/assistant/test', d || {}); }