diff --git a/backend/src/controllers/imggenController.js b/backend/src/controllers/imggenController.js
new file mode 100644
index 0000000..642ef7e
--- /dev/null
+++ b/backend/src/controllers/imggenController.js
@@ -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 };
diff --git a/backend/src/routes/imggen.js b/backend/src/routes/imggen.js
new file mode 100644
index 0000000..87a59e8
--- /dev/null
+++ b/backend/src/routes/imggen.js
@@ -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;
diff --git a/backend/src/server.js b/backend/src/server.js
index 887dbc0..b17c2a5 100644
--- a/backend/src/server.js
+++ b/backend/src/server.js
@@ -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)
diff --git a/frontend/flashcards.html b/frontend/flashcards.html
index 0f08984..7224818 100644
--- a/frontend/flashcards.html
+++ b/frontend/flashcards.html
@@ -560,6 +560,7 @@
+
@@ -951,13 +952,32 @@ function imgRowHtml(c, side) {
`;
}
- return `
+ return `
`;
}
+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 МБ');
diff --git a/frontend/js/assistant.js b/frontend/js/assistant.js
index 1bf9534..0c84e86 100644
--- a/frontend/js/assistant.js
+++ b/frontend/js/assistant.js
@@ -490,7 +490,8 @@
chatEl.innerHTML = '';
_chat.forEach(function (m) {
var d = msgEl(m.role);
- if (m.role === 'assistant') { d.innerHTML = '
'; renderRich(d.querySelector('.asst-rich'), m.content); }
+ if (m.img) d.innerHTML = '

';
+ else if (m.role === 'assistant') { d.innerHTML = '
'; renderRich(d.querySelector('.asst-rich'), m.content); }
else d.textContent = m.content;
chatEl.appendChild(d);
});
@@ -498,7 +499,7 @@
}
var FB_UP = '
';
var FB_DOWN = '
';
- 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 = '
' +
'' +
'' +
- '
';
+ '
' +
+ '
';
openBubble(
'' + faceSVG('happy') + 'Спроси Квантика' +
'
' +
@@ -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 = '

'; _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);
diff --git a/frontend/js/imggen.js b/frontend/js/imggen.js
new file mode 100644
index 0000000..954d0f8
--- /dev/null
+++ b/frontend/js/imggen.js
@@ -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 ({'&':'&','<':'<','>':'>','"':'"'})[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 = '
'
+ + '
' + esc(opts.title || 'Сгенерировать картинку') + '
'
+ + '
'
+ + '
ИИ-картинка для иллюстраций и декора (не для точных схем — графиков, формул). FLUX.1 · бесплатно.
'
+ + '
'
+ + '
'
+ + '
'
+ + '
';
+ 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 = '
Генерирую картинку… (5–15 сек)
';
+ try {
+ var r = await LS.imageGen(prompt);
+ if (r && r.url) { lastUrl = r.url; prev.innerHTML = '

'; useRow.style.display = 'flex'; genBtn.textContent = 'Перегенерировать'; }
+ else prev.innerHTML = '
Не получилось
';
+ } catch (e) { prev.innerHTML = '
' + esc((e && e.data && e.data.error) || e.message || 'Ошибка') + '
'; }
+ 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;
+ };
+})();
diff --git a/frontend/lesson-editor.html b/frontend/lesson-editor.html
index 2976211..c6fb6a0 100644
--- a/frontend/lesson-editor.html
+++ b/frontend/lesson-editor.html
@@ -1004,6 +1004,7 @@
+
@@ -1602,6 +1603,9 @@
+
@@ -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
══════════════════════════════════════════════════════════════════ */
diff --git a/js/api.js b/js/api.js
index 683729a..f3aaee1 100644
--- a/js/api.js
+++ b/js/api.js
@@ -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 || {}); }