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 || {}); }