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
+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;
};
})();