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:
@@ -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 };
|
||||||
@@ -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;
|
||||||
@@ -160,6 +160,7 @@ app.use('/api/questions', questionRoutes);
|
|||||||
app.use('/api/classes', classRoutes);
|
app.use('/api/classes', classRoutes);
|
||||||
app.use('/api/assignments', assignmentRoutes);
|
app.use('/api/assignments', assignmentRoutes);
|
||||||
app.use('/api/files', fileRoutes);
|
app.use('/api/files', fileRoutes);
|
||||||
|
app.use('/api/imggen', require('./routes/imggen'));
|
||||||
app.use('/api/tests', testRoutes);
|
app.use('/api/tests', testRoutes);
|
||||||
app.use('/api/notifications', notificationRoutes);
|
app.use('/api/notifications', notificationRoutes);
|
||||||
app.use('/api/permissions', permissionRoutes);
|
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('/img', express.static(path.join(frontendDir, 'img'), staticCache));
|
||||||
app.use('/avatars', express.static(path.join(__dirname, '../uploads/avatars'), { maxAge: '1d' }));
|
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/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' }));
|
app.use('/uploads/materials', express.static(path.join(__dirname, '../uploads/materials'), { maxAge: '7d' }));
|
||||||
|
|
||||||
// Redirect legacy .html URLs → clean URLs (301)
|
// Redirect legacy .html URLs → clean URLs (301)
|
||||||
|
|||||||
@@ -560,6 +560,7 @@
|
|||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
<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/api.js"></script>
|
||||||
|
<script src="/js/imggen.js"></script>
|
||||||
<script src="/js/sidebar.js"></script>
|
<script src="/js/sidebar.js"></script>
|
||||||
<script src="/js/notifications.js"></script>
|
<script src="/js/notifications.js"></script>
|
||||||
<script src="/js/search.js"></script>
|
<script src="/js/search.js"></script>
|
||||||
@@ -951,13 +952,32 @@ function imgRowHtml(c, side) {
|
|||||||
</button>
|
</button>
|
||||||
</div></div>`;
|
</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}')">
|
<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>
|
<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>`;
|
</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) {
|
async function uploadFcImage(file) {
|
||||||
if (!file || !file.type || !file.type.startsWith('image/')) throw new Error('Только изображения');
|
if (!file || !file.type || !file.type.startsWith('image/')) throw new Error('Только изображения');
|
||||||
if (file.size > 5 * 1024 * 1024) throw new Error('Файл больше 5 МБ');
|
if (file.size > 5 * 1024 * 1024) throw new Error('Файл больше 5 МБ');
|
||||||
|
|||||||
@@ -490,7 +490,8 @@
|
|||||||
chatEl.innerHTML = '';
|
chatEl.innerHTML = '';
|
||||||
_chat.forEach(function (m) {
|
_chat.forEach(function (m) {
|
||||||
var d = msgEl(m.role);
|
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;
|
else d.textContent = m.content;
|
||||||
chatEl.appendChild(d);
|
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_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 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) {
|
function openAsk(prefill) {
|
||||||
var sel = _lastSel, pc = getPageContext();
|
var sel = _lastSel, pc = getPageContext();
|
||||||
var ctxBtns = '';
|
var ctxBtns = '';
|
||||||
@@ -512,7 +513,8 @@
|
|||||||
var modes = '<div class="asst-modes">' +
|
var modes = '<div class="asst-modes">' +
|
||||||
'<button class="asst-mode on" data-m="answer">Ответ</button>' +
|
'<button class="asst-mode on" data-m="answer">Ответ</button>' +
|
||||||
'<button class="asst-mode" data-m="hint">Подсказка</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(
|
openBubble(
|
||||||
'<div class="asst-name"><span class="asst-name-face">' + faceSVG('happy') + '</span>Спроси Квантика' +
|
'<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>' +
|
'<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 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) {
|
function send(q, context, chatEl, mode) {
|
||||||
q = (q || '').trim();
|
q = (q || '').trim();
|
||||||
if (q.length < 2) return;
|
if (q.length < 2) return;
|
||||||
|
if (mode === 'draw') return drawInChat(q, chatEl);
|
||||||
var history = _chat.slice(-6);
|
var history = _chat.slice(-6);
|
||||||
_chat.push({ role: 'user', content: q });
|
_chat.push({ role: 'user', content: q });
|
||||||
var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u);
|
var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u);
|
||||||
|
|||||||
@@ -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 = '<div class="ig-box">'
|
||||||
|
+ '<div class="ig-head"><span class="ig-title">' + esc(opts.title || 'Сгенерировать картинку') + '</span><button class="ig-x" data-x>×</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;
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -1004,6 +1004,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/api.js"></script>
|
<script src="/js/api.js"></script>
|
||||||
|
<script src="/js/imggen.js"></script>
|
||||||
<script src="/js/sidebar.js"></script>
|
<script src="/js/sidebar.js"></script>
|
||||||
<script src="/js/svg-sanitize.js"></script>
|
<script src="/js/svg-sanitize.js"></script>
|
||||||
<script src="/js/svg-draw.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()">
|
<button class="img-upload-btn" onclick="document.getElementById('img-file-${bid}').click()">
|
||||||
<i data-lucide="upload" style="width:14px;height:14px"></i> Загрузить файл
|
<i data-lucide="upload" style="width:14px;height:14px"></i> Загрузить файл
|
||||||
</button>
|
</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>
|
<span class="img-upload-progress" id="img-status-${bid}"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="block-row">
|
<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
|
FEATURE: Code syntax highlighting
|
||||||
══════════════════════════════════════════════════════════════════ */
|
══════════════════════════════════════════════════════════════════ */
|
||||||
|
|||||||
@@ -1050,7 +1050,7 @@ window.LS = {
|
|||||||
crAdminGetAllHistory, crAdminGetTeachersList,
|
crAdminGetAllHistory, crAdminGetTeachersList,
|
||||||
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
|
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
|
||||||
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
|
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,
|
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
||||||
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
|
adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels,
|
||||||
fcListDecks, fcCreateDeck, fcAddCard,
|
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 assistantFeedback(rating, q) { return req('POST', '/assistant/feedback', { rating, q: q || undefined }); }
|
||||||
async function assistantMemory() { return req('GET', '/assistant/memory'); }
|
async function assistantMemory() { return req('GET', '/assistant/memory'); }
|
||||||
async function assistantMemoryClear(id) { return req('DELETE', '/assistant/memory' + (id ? '/' + id : '')); }
|
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 adminGetAssistant() { return req('GET', '/admin/assistant'); }
|
||||||
async function adminSaveAssistant(d) { return req('PUT', '/admin/assistant', d); }
|
async function adminSaveAssistant(d) { return req('PUT', '/admin/assistant', d); }
|
||||||
async function adminTestAssistant(d) { return req('POST', '/admin/assistant/test', d || {}); }
|
async function adminTestAssistant(d) { return req('POST', '/admin/assistant/test', d || {}); }
|
||||||
|
|||||||
Reference in New Issue
Block a user