@ -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 , '"' ) . replace ( /'/g , ''' ) ;
}
/** Тост справа под шапкой: выезд справа, 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 ) {
saveStatusEl . textContent = '' ;
alert ( gd . error || 'Не удалось сгенерировать подсказки.' ) ;
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 runHintsProgressFromServer ( st . missing ) ;
if ( saveMsg ) saveMsg . textContent = ` ${ msg } Подсказки обновлены через ИИ. ` ;
try {
await refreshHintsInForm ( ) ;
} catch ( _ ) {
/* не блокируем успех сохранения */
}
showAiToast ( 'Подсказки сгенерированы.' , 'ok' ) ;
} catch ( he ) {
saveStatusEl . textContent = '' ;
alert ( he . message || 'Не удалось сгенерировать подсказки.' ) ;
if ( saveMsg ) saveMsg . textContent = ` ${ msg } (подсказки не созданы) ` ;
if ( saveModal ) saveModal . showModal ( ) ;
return ;
}
} 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 ) {
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 ( _ ) {
// Не блокируем успех генерации вопросов.
}
const skipped = Number ( hd . skipped || 0 ) ;
aiStatusEl . textContent = skipped
? ` Готово: вопросы + подсказки ( ${ hd . generated } , пропущено ${ skipped } ). `
: ` Готово: вопросы + подсказки ( ${ hd . generated } ). ` ;
/* не блокируем успех */
}
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 ) {