testingwebapp fixes, weeek tasks 2948-2958

This commit is contained in:
Константин Лебединский
2026-05-04 21:29:23 +05:00
parent 0229bc250b
commit 1ea83aa6b4
9 changed files with 1035 additions and 214 deletions
+434 -126
View File
@@ -25,7 +25,10 @@
const questionsEl = $('#questions');
const qCountEl = $('#q-count');
const saveStatusEl = $('#save-status');
const aiStatusEl = $('#ai-status');
const aiKeepTitleEl = $('#ai-keep-title');
const aiImproveFocusEl = $('#ai-improve-focus');
const aiImportClearBtn = $('#ai-import-clear');
const addQuestionAiBtn = $('#add-question-ai');
const chainActiveEl = { checked: true, _val: true }; // display-only — реальный toggle в блоке «Показ в каталоге»
const chainActiveDisplay = $('#chain-active-display');
const aiTopicEl = $('#ai-topic');
@@ -34,7 +37,6 @@
const templateGlobalMultiEl = $('#template-global-multi');
const templateMinCorrectEl = $('#template-min-correct');
const templateMaxCorrectEl = $('#template-max-correct');
const docProgressEl = $('#doc-progress');
const introUpdatedEl = $('#intro-updated');
const introForkBannerEl = $('#intro-fork-banner');
const versionsListEl = $('#versions-list');
@@ -167,6 +169,16 @@
if (qNode) updateOptionsCounter(qNode);
scheduleDirtyCheck();
});
const optAiBtn = $('.opt-ai', node);
if (optAiBtn && qNode) {
optAiBtn.addEventListener('click', async (ev) => {
ev.preventDefault();
const opts = $$('.opt-item', qNode);
const idx = opts.indexOf(node);
if (idx < 0) return;
await improveSingleOption(qNode, idx);
});
}
return node;
}
@@ -476,6 +488,92 @@
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/** Тост справа под шапкой: выезд справа, variant ok/err/info, авто-уезд по durationMs. */
function hideToastAnimated(el) {
if (!el) return;
clearTimeout(showAiToast._timer);
clearTimeout(showAiToast._fallbackHide);
el.classList.remove('editor-gen-toast--open');
let done = false;
const finish = () => {
if (done) return;
done = true;
clearTimeout(showAiToast._fallbackHide);
el.removeEventListener('transitionend', onEnd);
el.hidden = true;
el.textContent = '';
el.removeAttribute('data-variant');
};
const onEnd = (ev) => {
if (ev.propertyName !== 'transform') return;
finish();
};
el.addEventListener('transitionend', onEnd);
showAiToast._fallbackHide = setTimeout(finish, 500);
}
function showAiToast(message, variant = 'info', durationMs = 4800) {
const el = document.getElementById('editor-gen-toast');
if (!el) return;
clearTimeout(showAiToast._timer);
clearTimeout(showAiToast._fallbackHide);
if (!message) {
if (!el.hidden) hideToastAnimated(el);
return;
}
if (el.classList.contains('editor-gen-toast--open') && !el.hidden) {
el.textContent = message;
el.dataset.variant = variant;
if (durationMs != null && durationMs > 0) {
showAiToast._timer = setTimeout(() => hideToastAnimated(el), durationMs);
}
return;
}
el.textContent = message;
el.dataset.variant = variant;
el.hidden = false;
el.classList.remove('editor-gen-toast--open');
void el.offsetWidth;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.classList.add('editor-gen-toast--open');
});
});
if (durationMs != null && durationMs > 0) {
showAiToast._timer = setTimeout(() => hideToastAnimated(el), durationMs);
}
}
function formatEtaSeconds(sec) {
const s = Math.min(7200, Math.max(5, Math.round(sec)));
if (s < 60) return `примерно ${s} с`;
const m = Math.ceil(s / 60);
if (m === 1) return 'примерно 1 мин';
return `примерно ${m} мин`;
}
function etaFullGenerateSeconds(nQ) {
return Math.min(900, Math.max(25, 20 + nQ * 12));
}
async function runHintsProgressFromServer(startMissing) {
const total = Math.max(0, Number(startMissing) || 0);
if (!total) return;
let iter = 0;
const maxIter = Math.min(500, Math.max(total * 3, total + 15));
while (iter < maxIter) {
iter += 1;
const line = `Подсказки ИИ: шаг ${iter} из ~${total}`;
showAiToast(line, 'info', 0);
const r = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate-next`, { method: 'POST' });
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.error || 'Не удалось сгенерировать подсказку.');
if (d.done || d.remaining === 0) break;
if (d.generated) continue;
break;
}
}
// ─── collect ───────────────────────────────────────────────────────
function collectPayload() {
@@ -668,26 +766,22 @@
+ 'Сгенерировать недостающие подсказки через ИИ?',
);
if (okGen) {
saveStatusEl.textContent = `Создаём ИИ-подсказки (${st.missing} из ${st.total})…`;
const gr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' });
const gd = await gr.json().catch(() => ({}));
if (!gr.ok) {
try {
await runHintsProgressFromServer(st.missing);
if (saveMsg) saveMsg.textContent = `${msg} Подсказки обновлены через ИИ.`;
try {
await refreshHintsInForm();
} catch (_) {
/* не блокируем успех сохранения */
}
showAiToast('Подсказки сгенерированы.', 'ok');
} catch (he) {
saveStatusEl.textContent = '';
alert(gd.error || 'Не удалось сгенерировать подсказки.');
if (saveMsg) saveMsg.textContent = msg + ' (подсказки не созданы)';
alert(he.message || 'Не удалось сгенерировать подсказки.');
if (saveMsg) saveMsg.textContent = `${msg} (подсказки не созданы)`;
if (saveModal) saveModal.showModal();
return;
}
const skipped = Number(gd.skipped || 0);
const tail = gd.failed
? ` Подсказки: ${gd.generated} создано, ${gd.failed} не удалось${skipped ? `, пропущено ${skipped}` : ''}.`
: ` Подсказки созданы (${gd.generated})${skipped ? `, пропущено ${skipped}` : ''}.`;
if (saveMsg) saveMsg.textContent = msg + tail;
try {
await refreshHintsInForm();
} catch (_) {
/* не блокируем успех сохранения */
}
} else if (saveMsg) {
saveMsg.textContent = msg;
}
@@ -716,14 +810,15 @@
alert('Укажите тему.');
return;
}
// Предупреждение, если в тесте уже есть вопросы или заполненное название/описание
const keepTitle = !!(aiKeepTitleEl && aiKeepTitleEl.checked);
const hasContent = questionsEl.children.length > 0
|| titleEl.value.trim()
|| descEl.value.trim();
if (hasContent) {
const ok = confirm(
'Полная генерация заменит текущее название, описание и все вопросы.\n\nПродолжить?'
);
const warn = keepTitle
? 'Полная генерация заменит описание и все вопросы. Название в редакторе не будет заменено, если включено «Не менять название».\n\nПродолжить?'
: 'Полная генерация заменит текущее название, описание и все вопросы.\n\nПродолжить?';
const ok = confirm(warn);
if (!ok) return;
}
const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
@@ -736,7 +831,11 @@
minCorrect: globalMulti ? globalRange.minCorrect : 1,
maxCorrect: globalMulti ? globalRange.maxCorrect : 1,
}));
aiStatusEl.textContent = 'Генерируем структуру и вопросы…';
showAiToast(
`Генерация ${nQ} вопросов · ~${formatEtaSeconds(etaFullGenerateSeconds(nQ))}`,
'info',
0,
);
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-test`, {
method: 'POST',
@@ -750,44 +849,48 @@
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
const draft = data.draft;
if (draft.title) {
if (draft.title && !keepTitle) {
titleEl.value = draft.title;
if (aiTopicEl) aiTopicEl.value = draft.title;
} else if (aiTopicEl) {
aiTopicEl.value = topic;
}
if (draft.description) descEl.value = draft.description;
questionsEl.innerHTML = '';
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
scheduleDirtyCheck();
aiStatusEl.textContent = `Готово: ${draft.questions?.length || 0} вопросов.`;
const hintsEl = document.getElementById('test-hints-enabled');
const modeEl = document.querySelector('input[name="result-mode"]:checked');
if (hintsEl && hintsEl.checked && modeEl && modeEl.value === 'immediate') {
aiStatusEl.textContent = 'Сохраняем черновик…';
showAiToast('Сохраняем черновик перед подсказками…', 'info', 0);
try {
await saveCurrentDraftQuietly();
aiStatusEl.textContent = 'Генерируем вопросы… затем подсказки…';
const hr = await fetch(`/api/tests/${TEST_ID}/ai/hints/generate`, { method: 'POST' });
const hd = await hr.json().catch(() => ({}));
if (hr.ok) {
try {
await refreshHintsInForm();
} catch (_) {
// Не блокируем успех генерации вопросов.
}
const skipped = Number(hd.skipped || 0);
aiStatusEl.textContent = skipped
? `Готово: вопросы + подсказки (${hd.generated}, пропущено ${skipped}).`
: `Готово: вопросы + подсказки (${hd.generated}).`;
const hs = await fetch(`/api/tests/${TEST_ID}/ai/hints/status`).then((x) => x.json());
const miss = Number(hs.missing || 0);
if (miss > 0) {
await runHintsProgressFromServer(miss);
}
try {
await refreshHintsInForm();
} catch (_) {
/* не блокируем успех */
}
showAiToast(`Готово: ${draft.questions?.length || 0} вопросов и подсказки`, 'ok');
} catch (_) {
// Оставляем базовый статус готовности вопросов.
showAiToast(
`Вопросы готовы (${draft.questions?.length || 0}); подсказки не созданы`,
'err',
);
}
} else {
showAiToast(`Готово: ${draft.questions?.length || 0} вопросов`, 'ok');
}
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
} catch (e) {
aiStatusEl.textContent = '';
alert(e.message || 'AI: ошибка.');
showAiToast('', 'info');
const msg = e.message || 'AI: ошибка.';
showAiToast(msg, 'err', 7000);
alert(msg);
}
});
@@ -804,6 +907,8 @@
let _extractedText = '';
let _extractedFileName = '';
/** Имена всех загруженных файлов (несколько выборов из разных папок склеиваются). */
let _importFileNames = [];
/** HTML карточки вопроса в модалке предпросмотра импорта (как в разборе: текст, подсказка, все варианты). */
function buildImportPreviewQuestionHtml(q, index) {
@@ -858,31 +963,110 @@
importModal.showModal();
}
// Фаза 1: выбрать файл → извлечь текст, обновить метку дропзоны
async function handleImportFile(file) {
if (!file) return;
aiStatusEl.textContent = `Загружаем «${file.name}»…`;
importDropzone.classList.add('import-dropzone--loading');
function clearImportState() {
_extractedText = '';
_extractedFileName = '';
_importFileNames = [];
if (importDropzoneLabel) importDropzoneLabel.textContent = 'Перетащите файлы сюда или нажмите';
importDropzone?.classList.remove('import-dropzone--done', 'import-dropzone--loading');
if (docUserHint) docUserHint.value = '';
}
if (aiImportClearBtn) {
aiImportClearBtn.addEventListener('click', () => {
clearImportState();
showAiToast('Загрузка сброшена.', 'info');
});
}
// Фаза 1: выбрать файл(ы) → извлечь текст, обновить метку дропзоны
async function handleImportFiles(fileList) {
const files = Array.from(fileList || []).filter(Boolean);
if (!files.length) return;
if (files.length > 5) {
showAiToast('Не более 5 файлов за раз.', 'err');
return;
}
const allowed = ['.pdf', '.docx', '.txt', '.md'];
for (const file of files) {
const ext = ('.' + file.name.split('.').pop()).toLowerCase();
if (!allowed.includes(ext)) {
showAiToast(`Формат «${ext}» не поддерживается.`, 'err');
return;
}
}
const appendHint = _extractedText.trim() ? ' · добавляем к уже загруженным' : '';
showAiToast(
files.length > 1
? `Загружаем ${files.length} файла…${appendHint}`
: `Загружаем «${files[0].name}»…${appendHint}`,
'info',
0,
);
importDropzone?.classList.add('import-dropzone--loading');
try {
const fd = new FormData();
fd.append('file', file);
const r = await fetch('/api/tests/import/document', { method: 'POST', body: fd });
const data = await r.json();
files.forEach((f) => fd.append('files', f));
const r = await fetch('/api/tests/import/document', {
method: 'POST',
body: fd,
credentials: 'same-origin',
});
let data;
const raw = await r.text();
try {
data = raw.trim() ? JSON.parse(raw) : {};
} catch {
if (r.status === 413) throw new Error('Файл слишком большой для сервера или прокси.');
if (r.status === 401) throw new Error('Сессия истекла — войдите снова и повторите загрузку.');
throw new Error(
`Сервер вернул не JSON (код ${r.status}). Обновите страницу или войдите снова.`,
);
}
if (!r.ok) throw new Error(data.error || 'Не удалось загрузить файл.');
_extractedText = data.extractedText || '';
_extractedFileName = file.name;
aiStatusEl.textContent = `Файл загружен: «${file.name}» · ${data.textLength ?? 0} символов`;
if (importDropzoneLabel) importDropzoneLabel.textContent = `${file.name}`;
importDropzone.classList.add('import-dropzone--done');
const batchText = (data.extractedText || '').trim();
const names = Array.isArray(data.originalNames) ? data.originalNames : [data.originalName || ''];
const batchNames = names.filter(Boolean);
const hadExisting = !!_extractedText.trim();
const batchLen = Number(data.textLength) || batchText.length;
if (batchText) {
if (hadExisting) {
_extractedText = `${_extractedText.trimEnd()}\n\n---\n\n${batchText}`;
} else {
_extractedText = batchText;
}
batchNames.forEach((n) => _importFileNames.push(n));
_extractedFileName = _importFileNames.join(', ');
if (importDropzoneLabel) {
const n = _importFileNames.length;
importDropzoneLabel.textContent =
n <= 1
? `${_importFileNames[0] || files[0]?.name || 'файл'} · можно добавить ещё`
: `${n} файлов · можно добавить ещё`;
}
importDropzone?.classList.add('import-dropzone--done');
const totalLen = _extractedText.length;
if (hadExisting) {
showAiToast(`Добавлено · ${batchLen} симв. · всего ${totalLen}`, 'ok');
} else {
showAiToast(`Загружено · ${totalLen} символов`, 'ok');
}
} else if (hadExisting) {
showAiToast('Текст из этих файлов пуст — уже загруженное не меняли.', 'info', 4500);
} else {
_extractedFileName = '';
showAiToast('Текст из файлов не извлечён.', 'info', 4500);
}
} catch (e) {
aiStatusEl.textContent = '';
showAiToast('', 'info');
openImportModal(
'Ошибка загрузки',
`<div class="import-modal-review"><p class="import-modal-review__alert import-modal-review__alert--error">${escHtml(e.message || 'Не удалось загрузить файл.')}</p></div>`,
[{ label: 'Закрыть', onClick: () => importModal.close() }],
);
} finally {
importDropzone.classList.remove('import-dropzone--loading');
importDropzone?.classList.remove('import-dropzone--loading');
}
}
@@ -892,8 +1076,12 @@
const userHint = docUserHint ? docUserHint.value.trim() : '';
docGenerateBtn.disabled = true;
docGenerateBtn.textContent = 'Генерируем…';
aiStatusEl.textContent = 'Генерируем тест из документа…';
if (docProgressEl) docProgressEl.textContent = 'Шаг 1/3: подготовка шаблона…';
const nQDoc = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
showAiToast(
`Документ: шаг 1/3 · подготовка · ~${formatEtaSeconds(etaFullGenerateSeconds(nQDoc))}`,
'info',
0,
);
try {
const nQ = Math.min(30, Math.max(1, Number(aiQCountEl?.value || 7) || 7));
const nO = Math.min(8, Math.max(2, Number(aiOCountEl?.value || 3) || 3));
@@ -905,7 +1093,7 @@
minCorrect: globalMulti ? globalRange.minCorrect : 1,
maxCorrect: globalMulti ? globalRange.maxCorrect : 1,
}));
if (docProgressEl) docProgressEl.textContent = аг 2/3: генерация вопросов…';
showAiToast('Документ: шаг 2/3 · генерация вопросов…', 'info', 0);
const r = await fetch('/api/tests/generate-from-extracted', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -914,8 +1102,7 @@
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'Ошибка генерации.');
const g = data.generation || {};
aiStatusEl.textContent = '';
if (docProgressEl) docProgressEl.textContent = 'Шаг 3/3: подготовка к применению…';
showAiToast('Документ: шаг 3/3 · готово к предпросмотру', 'ok', 3200);
if (!g.available) {
openImportModal(
@@ -953,22 +1140,15 @@
qs.forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
scheduleDirtyCheck();
aiStatusEl.textContent = `Применено: ${qs.length} вопросов.`;
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
// Сброс зоны загрузки
_extractedText = '';
_extractedFileName = '';
if (importDropzoneLabel) importDropzoneLabel.textContent = 'Перетащите файл сюда или нажмите';
importDropzone.classList.remove('import-dropzone--done');
if (docUserHint) docUserHint.value = '';
aiStatusEl.textContent = '';
showAiToast(`Применено: ${qs.length} вопросов`, 'ok');
clearImportState();
},
},
{ label: 'Отмена', onClick: () => importModal.close() },
],
);
} catch (e) {
aiStatusEl.textContent = '';
showAiToast(e.message || 'Ошибка генерации', 'err', 7000);
openImportModal(
'Ошибка генерации',
`<div class="import-modal-review"><p class="import-modal-review__alert import-modal-review__alert--error">${escHtml(e.message || 'Не удалось сгенерировать тест.')}</p></div>`,
@@ -979,7 +1159,6 @@
docGenerateBtn.disabled = false;
docGenerateBtn.innerHTML = '<span class="material-symbols-outlined text-base" style="vertical-align:-3px;">auto_awesome</span> Сгенерировать из документа';
}
if (docProgressEl) setTimeout(() => { docProgressEl.textContent = ''; }, 2500);
}
}
@@ -990,9 +1169,9 @@
if (fileInput) {
const onchange = async (ev) => {
fileInput.removeEventListener('change', onchange);
const f = ev.target.files && ev.target.files[0];
const picked = ev.target.files ? Array.from(ev.target.files) : [];
ev.target.value = '';
await handleImportFile(f);
await handleImportFiles(picked);
if (_extractedText) handleGenerateFromDoc();
};
fileInput.addEventListener('change', onchange);
@@ -1004,9 +1183,9 @@
});
$('#ai-import-file').addEventListener('change', (ev) => {
const file = ev.target.files && ev.target.files[0];
const picked = ev.target.files ? Array.from(ev.target.files) : [];
ev.target.value = '';
handleImportFile(file);
handleImportFiles(picked);
});
// Drag-and-drop на зону загрузки
@@ -1027,33 +1206,42 @@
importDropzone.addEventListener('drop', (e) => {
e.preventDefault();
importDropzone.classList.remove('import-dropzone--over');
const file = e.dataTransfer?.files?.[0];
if (!file) return;
const allowed = ['.pdf', '.docx', '.txt', '.md'];
const ext = ('.' + file.name.split('.').pop()).toLowerCase();
if (!allowed.includes(ext)) {
aiStatusEl.textContent = `Формат «${ext}» не поддерживается.`;
setTimeout(() => (aiStatusEl.textContent = ''), 3000);
const dropped = Array.from(e.dataTransfer?.files || []);
const batch = dropped.filter((f) => {
const ext = ('.' + f.name.split('.').pop()).toLowerCase();
return allowed.includes(ext);
}).slice(0, 5);
if (!batch.length) {
if (dropped.length) {
showAiToast('Поддерживаются только PDF, DOCX, TXT, MD.', 'err');
}
return;
}
handleImportFile(file);
handleImportFiles(batch);
});
}
// Drag-and-drop на всю страницу (когда перетаскивают извне браузера)
document.addEventListener('dragover', (e) => { e.preventDefault(); });
document.addEventListener('drop', (e) => {
if (importDropzone && importDropzone.contains(e.target)) return; // уже обработано
if (importDropzone && importDropzone.contains(e.target)) return;
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (!file) return;
const allowed = ['.pdf', '.docx', '.txt', '.md'];
const ext = ('.' + file.name.split('.').pop()).toLowerCase();
if (!allowed.includes(ext)) return;
// Подсвечиваем зону и обрабатываем
const dropped = Array.from(e.dataTransfer?.files || []);
const batch = dropped.filter((f) => {
const ext = ('.' + f.name.split('.').pop()).toLowerCase();
return allowed.includes(ext);
}).slice(0, 5);
if (!batch.length) {
if (dropped.length) {
showAiToast('Поддерживаются только PDF, DOCX, TXT, MD.', 'err');
}
return;
}
importDropzone?.classList.add('import-dropzone--over');
setTimeout(() => importDropzone?.classList.remove('import-dropzone--over'), 600);
handleImportFile(file);
handleImportFiles(batch);
});
// ─── AI v2 (E1.8): generate-by-title / check / improve ─────────
@@ -1120,7 +1308,11 @@
const nORaw = prompt('Сколько вариантов в каждом вопросе?', '4');
if (nORaw == null) return;
const nO = Math.max(2, Math.min(12, parseInt(nORaw, 10) || 4));
aiStatusEl.textContent = 'Генерируем по названию…';
showAiToast(
`Генерация по названию · ~${formatEtaSeconds(etaFullGenerateSeconds(nQ))}`,
'info',
0,
);
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-by-title`, {
method: 'POST',
@@ -1134,16 +1326,17 @@
});
const data = await r.json();
if (!r.ok) {
aiStatusEl.textContent = '';
showAiToast('', 'info');
return aiAlert(data);
}
const draft = data.draft;
showAiToast('Черновик готов — подтвердите в диалоге', 'ok');
const ok = confirm(
`Готово: «${draft.title}», вопросов — ${draft.questions.length}.\n` +
'Применить как черновик? Текущие вопросы будут заменены.',
);
if (!ok) {
aiStatusEl.textContent = '';
showAiToast('', 'info');
return;
}
if (draft.title) titleEl.value = draft.title;
@@ -1152,10 +1345,9 @@
(draft.questions || []).forEach((q) => questionsEl.appendChild(renderQuestion(q)));
renumber();
scheduleDirtyCheck();
aiStatusEl.textContent = `Применено: ${draft.questions.length} вопросов.`;
setTimeout(() => (aiStatusEl.textContent = ''), 4000);
showAiToast(`Применено: ${draft.questions.length} вопросов`, 'ok');
} catch (e) {
aiStatusEl.textContent = '';
showAiToast('', 'info');
aiAlert(null, e.message);
}
});
@@ -1167,7 +1359,7 @@
alert('В тесте нет вопросов — нечего проверять.');
return;
}
aiStatusEl.textContent = 'Анализируем…';
showAiToast('Проверка теста…', 'info', 0);
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/check`, {
method: 'POST',
@@ -1179,8 +1371,9 @@
}),
});
const data = await r.json();
aiStatusEl.textContent = '';
showAiToast('', 'info');
if (!r.ok) return aiAlert(data);
showAiToast('Проверка готова — см. окно', 'ok');
const rev = data.review || {};
const verdict = rev.verdict || 'warn';
const verdictMap = {
@@ -1208,7 +1401,7 @@
className: 'px-3 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm' },
]);
} catch (e) {
aiStatusEl.textContent = '';
showAiToast('', 'info');
aiAlert(null, e.message);
}
});
@@ -1220,7 +1413,7 @@
alert('В тесте нет вопросов — нечего улучшать.');
return;
}
aiStatusEl.textContent = 'Улучшаем…';
showAiToast('Подготовка улучшений…', 'info', 0);
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/improve`, {
method: 'POST',
@@ -1229,11 +1422,13 @@
testTitle: titleEl.value,
testDescription: descEl.value,
questions: payload.questions,
focus: (aiImproveFocusEl && aiImproveFocusEl.value) || 'all',
}),
});
const data = await r.json();
aiStatusEl.textContent = '';
showAiToast('', 'info');
if (!r.ok) return aiAlert(data);
showAiToast('Выберите в окне, что применить', 'ok');
const items = data.items || [];
if (!items.length) {
openModal('Улучшение теста', '<p>Нечего улучшать.</p>', [
@@ -1297,21 +1492,71 @@
$('.q-multi', node).checked = !!it.suggested.hasMultipleAnswers;
const optsEl = $('.q-options', node);
optsEl.innerHTML = '';
it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o)));
it.suggested.options.forEach((o) => optsEl.appendChild(renderOption(o, node)));
});
$$('#questions .q-item').forEach((node) => {
if (!node.classList.contains('q-removed')) syncOptionInputTypes(node);
});
renumber();
modal.close();
scheduleDirtyCheck();
aiStatusEl.textContent = 'Изменения применены. Не забудьте сохранить.';
setTimeout(() => (aiStatusEl.textContent = ''), 5000);
showAiToast('Изменения применены — сохраните тест', 'ok');
},
},
]);
} catch (e) {
aiStatusEl.textContent = '';
showAiToast('', 'info');
aiAlert(null, e.message);
}
});
async function improveSingleOption(qNode, optIndex) {
const rows = $$('.opt-item', qNode);
const row = rows[optIndex];
if (!row) return;
const overlay = $('.opt-ai-overlay', row);
const ta = $('.opt-text', row);
const aiBtn = $('.opt-ai', row);
const setLocalBusy = (on) => {
if (overlay) overlay.classList.toggle('hidden', !on);
if (ta) ta.toggleAttribute('readonly', !!on);
if (aiBtn) aiBtn.disabled = !!on;
row.classList.toggle('opt-item--ai-busy', !!on);
};
setLocalBusy(true);
try {
const r = await fetch(`/api/tests/${TEST_ID}/ai/improve-option`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
testTitle: titleEl.value,
testDescription: descEl.value,
questionText: $('.q-text', qNode).value,
optionIndex: optIndex,
options: rows.map((op) => ({
text: $('.opt-text', op).value.trim(),
isCorrect: $('.opt-correct', op).checked,
})),
}),
});
const data = await r.json();
if (!r.ok) {
setLocalBusy(false);
return aiAlert(data);
}
const t = $('.opt-text', row);
t.value = (data.text != null ? data.text : t.value) || '';
autoResize(t);
scheduleDirtyCheck();
setLocalBusy(false);
} catch (e) {
setLocalBusy(false);
aiAlert(null, e.message);
}
}
/** Перемешивает строки вариантов в DOM; чекбоксы «верный» остаются при своих полях. */
function shuffleQuestionOptionsDom(qNode) {
const optsEl = $('.q-options', qNode);
@@ -1325,6 +1570,44 @@
rows.forEach((el) => optsEl.appendChild(el));
}
function pickQuestionAiMode(canDistractors) {
const dlg = document.getElementById('dlg-q-ai-mode');
const distBtn = document.getElementById('q-ai-mode-distractors');
const qBtn = document.getElementById('q-ai-mode-question');
const optBtn = document.getElementById('q-ai-mode-options');
const cancelBtn = document.getElementById('q-ai-mode-cancel');
if (!dlg || typeof dlg.showModal !== 'function') {
return Promise.resolve('rephrase');
}
if (distBtn) {
distBtn.disabled = !canDistractors;
distBtn.style.opacity = canDistractors ? '' : '0.45';
distBtn.title = canDistractors ? '' : 'Нет пустых полей для дистракторов';
}
return new Promise((resolve) => {
let settled = false;
const safeResolve = (val) => {
if (settled) return;
settled = true;
resolve(val);
};
const onClose = () => {
safeResolve(null);
};
dlg.addEventListener('close', onClose, { once: true });
const pick = (mode) => () => {
dlg.removeEventListener('close', onClose);
safeResolve(mode);
dlg.close();
};
distBtn?.addEventListener('click', pick('distractors'), { once: true });
qBtn?.addEventListener('click', pick('rephrase'), { once: true });
optBtn?.addEventListener('click', pick('improve_options'), { once: true });
cancelBtn?.addEventListener('click', pick(null), { once: true });
dlg.showModal();
});
}
async function aiGenerateQuestion(node) {
const qTextEl = $('.q-text', node);
const qText = qTextEl.value.trim();
@@ -1333,32 +1616,25 @@
const multi = $('.q-multi', node).checked;
const overlay = $('.q-ai-overlay', node);
// Показываем оверлей
overlay?.classList.remove('hidden');
node.style.pointerEvents = 'none';
try {
// Собираем варианты с их состоянием
const existingOptions = existingOpts.map((op) => ({
text: $('.opt-text', op).value.trim(),
isCorrect: $('.opt-correct', op).checked,
}));
const emptySlots = existingOptions.filter((o) => !o.text).length;
const filledSlots = existingOptions.filter((o) => o.text).length;
// Выбираем режим:
// - нет текста вопроса → full
// - есть вопрос + есть пустые варианты (и хоть один заполнен) → distractors
// - есть вопрос, все варианты заполнены или вариантов нет → rephrase
let requestMode;
if (!qText) {
requestMode = 'full';
} else if (emptySlots > 0 && filledSlots > 0) {
requestMode = 'distractors';
} else {
requestMode = 'rephrase';
const choice = await pickQuestionAiMode(emptySlots > 0);
if (!choice) return;
requestMode = choice;
}
overlay?.classList.remove('hidden');
node.style.pointerEvents = 'none';
const r = await fetch(`/api/tests/${TEST_ID}/ai/generate-question`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1375,20 +1651,17 @@
const data = await r.json();
if (!r.ok) throw new Error(data.error || 'AI: ошибка.');
// Обновляем текст вопроса (кроме режима дистракторов — текст не меняем)
if (data.mode !== 'distractors') {
if (data.mode === 'rephrase' || data.mode === 'full') {
qTextEl.value = data.text || qText;
autoResize(qTextEl);
}
const optsEl = $('.q-options', node);
if (data.mode === 'full' && Array.isArray(data.options) && data.options.length) {
// Полная замена вариантов
optsEl.innerHTML = '';
data.options.forEach((o) => optsEl.appendChild(renderOption(o, node)));
$('.q-multi', node).checked = !!data.hasMultipleAnswers;
} else if (data.mode === 'distractors' && Array.isArray(data.options) && data.options.length) {
// Заполняем только пустые слоты
let dIdx = 0;
existingOpts.forEach((op) => {
const t = $('.opt-text', op);
@@ -1399,6 +1672,15 @@
}
});
shuffleQuestionOptionsDom(node);
} else if (data.mode === 'improve_options' && Array.isArray(data.options) && data.options.length) {
const rows = $$('.opt-item', node);
data.options.forEach((o, i) => {
if (!rows[i]) return;
const ta = $('.opt-text', rows[i]);
ta.value = o.text != null ? o.text : ta.value;
$('.opt-correct', rows[i]).checked = !!o.isCorrect;
autoResize(ta);
});
}
syncOptionInputTypes(node);
@@ -1406,14 +1688,40 @@
updateAiButtonLabel(node);
scheduleDirtyCheck();
} catch (e) {
aiStatusEl.textContent = '';
alert(e.message || 'AI: ошибка.');
const msg = e.message || 'AI: ошибка.';
showAiToast(msg, 'err', 7000);
alert(msg);
} finally {
overlay?.classList.add('hidden');
node.style.pointerEvents = '';
}
}
if (addQuestionAiBtn) {
addQuestionAiBtn.addEventListener('click', async () => {
const node = renderQuestion({
text: '',
hasMultipleAnswers: false,
options: [
{ text: '', isCorrect: true },
{ text: '', isCorrect: false },
{ text: '', isCorrect: false },
{ text: '', isCorrect: false },
],
});
questionsEl.appendChild(node);
renumber();
scheduleDirtyCheck();
try {
node.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (_) {
/* ok */
}
showAiToast('Добавлен блок — запускаем ИИ для одного вопроса…', 'info');
await aiGenerateQuestion(node);
});
}
// ─── chain active state (грузим summary, чтобы знать стартовое значение) ───
function updateChainActiveDisplay(active) {