PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.9
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.9
4.4.9 4.4.8 4.4.7 4.4.6 4.4.5 4.4.4 4.4.3 4.4.2 4.4.1 4.4.0 4.3.9.1 4.3.9 4.3.8 4.3.7 4.1.6.9 4.1.6.9.1 4.1.6.9.2 4.1.6.9.3 4.1.6.9.4 4.1.7 4.1.7.1 4.1.7.2 4.1.7.3 4.1.7.3.1 4.1.7.3.2 All 140 releases
← All changes | assets/src/js/frontend/ai-assistant.js +773 -695 4.3.9 → 4.4.9 View file →
@@ -1,695 +1,773 @@
1 -/**
2 - * LP AI Assistant - frontend chat widget.
3 - *
4 - * LearnPress runtime implementation:
5 - * - ES6 class module.
6 - * - Delegated events via lpUtils.eventHandlers.
7 - * - Boot via lpUtils.lpOnElementReady.
8 - * - AJAX transport via window.lpAJAXG.fetchAJAX.
9 - *
10 - * @since 4.3.5
11 - * @version 1.0.0
12 - */
13 -import * as lpUtils from '../utils.js';
14 -import SweetAlert from 'sweetalert2';
15 -
16 -export class AIAssistantWidget {
17 - constructor() {
18 - this.config = null;
19 - this.root = null;
20 - this.elements = {};
21 - this.storageKey = '';
22 - this.history = [];
23 - this.activeQuizState = null;
24 - this.isRequesting = false;
25 - this.quizHookBound = false;
26 - this.lastQuizReviewSignature = '';
27 - }
28 -
29 - static selectors = {
30 - root: '#lp-ai-assistant',
31 - toggleBtn: '.lp-ai-assistant__toggle',
32 - panel: '.lp-ai-assistant__panel',
33 - closeBtn: '.lp-ai-assistant__close-btn',
34 - clearBtn: '.lp-ai-assistant__clear-btn',
35 - msgList: '.lp-ai-assistant__messages',
36 - inputEl: '.lp-ai-assistant__input',
37 - sendBtn: '.lp-ai-assistant__send-btn',
38 - quickBtn: '.lp-ai-assistant__quick-btn',
39 - quickActions: '.lp-ai-assistant__quick-actions',
40 - inputArea: '.lp-ai-assistant__input-area',
41 - smartReviewBtn: '.lp-ai-assistant__smart-review-btn',
42 - quizCard: '.lp-ai-assistant__quiz-card',
43 - quizOptionBtn: '.lp-ai-assistant__quiz-option',
44 - };
45 -
46 - init() {
47 - if ( ! this.validateConfig() ) {
48 - return;
49 - }
50 -
51 - this.root = document.querySelector( AIAssistantWidget.selectors.root );
52 - if ( ! this.root ) {
53 - return;
54 - }
55 -
56 - this.cacheElements();
57 - if ( ! this.validateDOM() ) {
58 - return;
59 - }
60 -
61 - this.storageKey = `lp_ai_chat_${ this.config.context }_${ this.config.itemId }`;
62 - this.applyInitialState();
63 - this.loadHistory();
64 - this.renderHistoryToDOM();
65 - this.bindQuizCompletedHook();
66 - this.events();
67 - }
68 -
69 - validateConfig() {
70 - if ( typeof window.lpAIAssistant !== 'object' || ! window.lpAIAssistant ) {
71 - return false;
72 - }
73 -
74 - this.config = window.lpAIAssistant;
75 - if ( ! this.config.enabled ) {
76 - return false;
77 - }
78 -
79 - const requiredString = [ 'nonce', 'ajaxUrl' ];
80 - for ( const key of requiredString ) {
81 - if ( typeof this.config[ key ] !== 'string' || ! this.config[ key ] ) {
82 - return false;
83 - }
84 - }
85 -
86 - const itemId = Number.isInteger( this.config.itemId ) ? this.config.itemId : this.config.lessonId;
87 - if ( ! Number.isInteger( itemId ) || itemId <= 0 ) {
88 - return false;
89 - }
90 -
91 - if ( ! Number.isInteger( this.config.courseId ) || this.config.courseId <= 0 ) {
92 - return false;
93 - }
94 -
95 - this.config.itemId = itemId;
96 - this.config.lessonId = itemId; // Backward compatibility for existing AJAX contract.
97 - this.config.context = this.config.context === 'quiz' ? 'quiz' : 'lesson';
98 - this.config.quizCompleted = !! this.config.quizCompleted;
99 - this.config.enabledActions = {
100 - summarize: true,
101 - explain: true,
102 - quick_quiz: true,
103 - smart_review: true,
104 - ...( this.config.enabledActions || {} ),
105 - };
106 -
107 - this.config.i18n = {
108 - you: this.config?.i18n?.you || 'You',
109 - assistant: this.config?.i18n?.assistant || 'AI Assistant',
110 - thinking: this.config?.i18n?.thinking || 'Thinking...',
111 - sendError: this.config?.i18n?.sendError || 'An error occurred. Please try again.',
112 - clearConfirm: this.config?.i18n?.clearConfirm || 'Clear chat history?',
113 - explainPrompt: this.config?.i18n?.explainPrompt || 'Explain a concept from this lesson.',
114 - quizPrompt: this.config?.i18n?.quizPrompt || 'Create a quick quiz from this lesson.',
115 - summarizePrompt: this.config?.i18n?.summarizePrompt || 'Summarize this lesson with key points.',
116 - smartReviewPrompt: this.config?.i18n?.smartReviewPrompt || 'Give me a smart review of my quiz results.',
117 - quizCorrectTitle: this.config?.i18n?.quizCorrectTitle || 'Correct!',
118 - quizWrongTitle: this.config?.i18n?.quizWrongTitle || 'Not correct!',
119 - };
120 -
121 - return true;
122 - }
123 -
124 - cacheElements() {
125 - this.elements.toggleBtn = document.querySelector( AIAssistantWidget.selectors.toggleBtn );
126 - this.elements.panel = this.root.querySelector( AIAssistantWidget.selectors.panel );
127 - this.elements.closeBtn = this.root.querySelector( AIAssistantWidget.selectors.closeBtn );
128 - this.elements.clearBtn = this.root.querySelector( AIAssistantWidget.selectors.clearBtn );
129 - this.elements.msgList = this.root.querySelector( AIAssistantWidget.selectors.msgList );
130 - this.elements.inputEl = this.root.querySelector( AIAssistantWidget.selectors.inputEl );
131 - this.elements.sendBtn = this.root.querySelector( AIAssistantWidget.selectors.sendBtn );
132 - this.elements.inputArea = this.root.querySelector( AIAssistantWidget.selectors.inputArea );
133 - this.elements.quickActions = this.root.querySelector( AIAssistantWidget.selectors.quickActions );
134 - this.elements.smartReviewBtn = this.root.querySelector( AIAssistantWidget.selectors.smartReviewBtn );
135 - }
136 -
137 - validateDOM() {
138 - // inputEl and sendBtn are optional — absent when free chat is disabled.
139 - return !! (
140 - this.elements.toggleBtn &&
141 - this.elements.panel &&
142 - this.elements.msgList
143 - );
144 - }
145 -
146 - applyInitialState() {
147 - if ( this.elements.smartReviewBtn ) {
148 - const showSmartReview = this.config.context === 'quiz'
149 - ? this.config.quizCompleted
150 - : !! this.config.enabledActions?.smart_review;
151 - this.elements.smartReviewBtn.hidden = ! showSmartReview;
152 - }
153 -
154 - this.setQuizInputMode( false );
155 - }
156 -
157 - bindQuizCompletedHook() {
158 - if ( this.config.context === 'quiz' ) {
159 - return;
160 - }
161 -
162 - if ( this.quizHookBound || ! this.elements.smartReviewBtn ) {
163 - return;
164 - }
165 -
166 - const hooks = window?.wp?.hooks;
167 - if ( ! hooks || typeof hooks.addAction !== 'function' ) {
168 - return;
169 - }
170 -
171 - hooks.addAction( 'lp-js-quiz-answer', 'learnpress/ai-assistant-smart-review', ( answered, status ) => {
172 - if ( String( status || '' ).toLowerCase() !== 'completed' ) {
173 - return;
174 - }
175 -
176 - this.config.quizCompleted = true;
177 - this.elements.smartReviewBtn.hidden = false;
178 - } );
179 -
180 - this.quizHookBound = true;
181 - }
182 -
183 - events() {
184 - if ( AIAssistantWidget._loadedEvents ) {
185 - return;
186 - }
187 - AIAssistantWidget._loadedEvents = this;
188 -
189 - lpUtils.eventHandlers( 'click', [
190 - {
191 - selector: AIAssistantWidget.selectors.toggleBtn,
192 - class: this,
193 - callBack: this.handleToggleClick.name,
194 - },
195 - {
196 - selector: `${ AIAssistantWidget.selectors.root } ${ AIAssistantWidget.selectors.closeBtn }`,
197 - class: this,
198 - callBack: this.handleCloseClick.name,
199 - },
200 - {
201 - selector: `${ AIAssistantWidget.selectors.root } ${ AIAssistantWidget.selectors.clearBtn }`,
202 - class: this,
203 - callBack: this.handleClearClick.name,
204 - },
205 - {
206 - selector: `${ AIAssistantWidget.selectors.root } ${ AIAssistantWidget.selectors.sendBtn }`,
207 - class: this,
208 - callBack: this.handleSendClick.name,
209 - },
210 - {
211 - selector: `${ AIAssistantWidget.selectors.root } ${ AIAssistantWidget.selectors.quickBtn }`,
212 - class: this,
213 - callBack: this.handleQuickActionClick.name,
214 - },
215 - {
216 - selector: `${ AIAssistantWidget.selectors.root } ${ AIAssistantWidget.selectors.quizOptionBtn }`,
217 - class: this,
218 - callBack: this.handleQuizOptionClick.name,
219 - },
220 - ] );
221 -
222 - lpUtils.eventHandlers( 'keydown', [
223 - {
224 - selector: `${ AIAssistantWidget.selectors.root } ${ AIAssistantWidget.selectors.inputEl }`,
225 - class: this,
226 - callBack: this.handleInputKeydown.name,
227 - },
228 - {
229 - selector: 'body',
230 - class: this,
231 - callBack: this.handleEscapeKeydown.name,
232 - },
233 - ] );
234 - }
235 -
236 - handleToggleClick( args ) {
237 - args.e.preventDefault();
238 - if ( this.elements.panel.hidden ) {
239 - this.openPanel();
240 - } else {
241 - this.closePanel();
242 - }
243 - }
244 -
245 - handleCloseClick( args ) {
246 - args.e.preventDefault();
247 - this.closePanel();
248 - }
249 -
250 - handleClearClick( args ) {
251 - args.e.preventDefault();
252 - SweetAlert.fire( {
253 - title: this.config.i18n.clearConfirm,
254 - icon: 'warning',
255 - showCancelButton: true,
256 - confirmButtonColor: 'var(--lp-primary-color, #ffb606)',
257 - } ).then( ( result ) => {
258 - if ( result.isConfirmed ) {
259 - this.clearHistory();
260 - }
261 - } );
262 - }
263 -
264 - handleSendClick( args ) {
265 - args.e.preventDefault();
266 - this.sendMessage( this.elements.inputEl?.value ?? '' );
267 - }
268 -
269 - handleQuickActionClick( args ) {
270 - args.e.preventDefault();
271 -
272 - if ( this.activeQuizState?.is_active ) {
273 - return;
274 - }
275 -
276 - const btn = args.target.closest( AIAssistantWidget.selectors.quickBtn );
277 - if ( ! btn ) {
278 - return;
279 - }
280 -
281 - const action = btn.dataset.lpAiAction;
282 - const prompts = {
283 - explain: this.config.i18n.explainPrompt,
284 - 'quick-quiz': this.config.i18n.quizPrompt,
285 - summarize: this.config.i18n.summarizePrompt,
286 - 'smart-review': this.config.i18n.smartReviewPrompt,
287 - };
288 -
289 - const prompt = prompts[ action ];
290 - if ( ! prompt ) {
291 - return;
292 - }
293 -
294 - this.openPanel();
295 - this.sendMessage( prompt, action );
296 - }
297 -
298 - handleQuizOptionClick( args ) {
299 - args.e.preventDefault();
300 - if ( this.isRequesting || ! this.activeQuizState?.is_active ) {
301 - return;
302 - }
303 -
304 - const btn = args.target.closest( AIAssistantWidget.selectors.quizOptionBtn );
305 - if ( ! btn ) {
306 - return;
307 - }
308 -
309 - const answerText = ( btn.dataset.option || btn.textContent || '' ).trim();
310 - if ( ! answerText ) {
311 - return;
312 - }
313 -
314 - this.sendMessage( answerText );
315 - }
316 -
317 - handleInputKeydown( args ) {
318 - if ( this.activeQuizState?.is_active ) {
319 - return;
320 - }
321 -
322 - if ( args.e.key === 'Enter' && ! args.e.shiftKey ) {
323 - args.e.preventDefault();
324 - this.sendMessage( this.elements.inputEl?.value ?? '' );
325 - }
326 - }
327 -
328 - handleEscapeKeydown( args ) {
329 - if ( args.e.key !== 'Escape' ) {
330 - return;
331 - }
332 -
333 - if ( this.elements.panel && ! this.elements.panel.hidden ) {
334 - this.closePanel();
335 - }
336 - }
337 -
338 - getAjaxHandle() {
339 - const ajaxHandle = window.lpAJAXG;
340 - if ( ! ajaxHandle || typeof ajaxHandle.fetchAJAX !== 'function' ) {
341 - return null;
342 - }
343 -
344 - return ajaxHandle;
345 - }
346 -
347 - openPanel() {
348 - this.elements.panel.hidden = false;
349 - this.root.setAttribute( 'aria-hidden', 'false' );
350 - this.elements.toggleBtn.setAttribute( 'aria-expanded', 'true' );
351 - this.elements.toggleBtn.classList.add( 'is-hidden' );
352 - this.elements.inputEl?.focus();
353 -
354 - if ( this.elements.msgList ) {
355 - this.elements.msgList.scrollTop = this.elements.msgList.scrollHeight;
356 - }
357 - }
358 -
359 - closePanel() {
360 - this.elements.panel.hidden = true;
361 - this.root.setAttribute( 'aria-hidden', 'true' );
362 - this.elements.toggleBtn.setAttribute( 'aria-expanded', 'false' );
363 - this.elements.toggleBtn.classList.remove( 'is-hidden' );
364 - this.elements.toggleBtn.focus();
365 - }
366 -
367 - setLoadingState( isLoading ) {
368 - this.isRequesting = isLoading;
369 - if ( this.elements.sendBtn ) {
370 - this.elements.sendBtn.disabled = isLoading;
371 - }
372 - if ( this.elements.inputEl ) {
373 - this.elements.inputEl.disabled = isLoading;
374 - }
375 - }
376 -
377 - setQuizInputMode( isQuizActive ) {
378 - if ( this.elements.inputArea ) {
379 - this.elements.inputArea.classList.toggle( 'lp-ai-assistant__input-area--hidden', isQuizActive );
380 - }
381 -
382 - if ( this.elements.quickActions ) {
383 - this.elements.quickActions.classList.toggle( 'lp-ai-assistant__quick-actions--disabled', isQuizActive );
384 - }
385 - }
386 -
387 - loadHistory() {
388 - try {
389 - const raw = localStorage.getItem( this.storageKey );
390 - this.history = raw ? JSON.parse( raw ) : [];
391 - if ( ! Array.isArray( this.history ) ) {
392 - this.history = [];
393 - }
394 -
395 - const lastReview = [ ...this.history ]
396 - .reverse()
397 - .find( ( item ) => item?.type === 'quiz_review' && item?.review );
398 - this.lastQuizReviewSignature = lastReview?.review
399 - ? this.getQuizReviewKey( lastReview.review )
400 - : '';
401 - } catch ( _e ) {
402 - this.history = [];
403 - this.lastQuizReviewSignature = '';
404 - }
405 - }
406 -
407 - saveHistory() {
408 - try {
409 - localStorage.setItem( this.storageKey, JSON.stringify( this.history ) );
410 - } catch ( _e ) {
411 - // Ignore storage errors.
412 - }
413 - }
414 -
415 - clearHistory() {
416 - this.history = [];
417 - this.activeQuizState = null;
418 - this.lastQuizReviewSignature = '';
419 - this.elements.msgList.innerHTML = '';
420 - localStorage.removeItem( this.storageKey );
421 - this.setQuizInputMode( false );
422 - }
423 -
424 - escHtml( text ) {
425 - const div = document.createElement( 'div' );
426 - div.appendChild( document.createTextNode( String( text ) ) );
427 - return div.innerHTML;
428 - }
429 -
430 - appendMessage( role, text ) {
431 - const el = document.createElement( 'div' );
432 - el.className = `lp-ai-assistant__msg lp-ai-assistant__msg--${ role }`;
433 -
434 - const label = role === 'user' ? this.config.i18n.you : this.config.i18n.assistant;
435 - el.innerHTML =
436 - `<span class="lp-ai-assistant__msg-label">${ this.escHtml( label ) }</span>` +
437 - `<p class="lp-ai-assistant__msg-text">${ this.escHtml( text ) }</p>`;
438 -
439 - this.elements.msgList.appendChild( el );
440 - this.elements.msgList.scrollTop = this.elements.msgList.scrollHeight;
441 - return el;
442 - }
443 -
444 - renderHistoryToDOM() {
445 - this.elements.msgList.innerHTML = '';
446 - this.history.forEach( ( message ) => {
447 - if ( message?.type === 'quiz_review' && message?.review ) {
448 - this.appendQuizReviewCard( message.review );
449 - return;
450 - }
451 -
452 - if ( ! message || ! [ 'user', 'assistant' ].includes( message.role ) ) {
453 - return;
454 - }
455 -
456 - this.appendMessage( message.role, message.content || '' );
457 - } );
458 -
459 - this.renderQuizState();
460 - }
461 -
462 - getQuizReviewFromState( quiz ) {
463 - if ( ! quiz || ! quiz.feedback || typeof quiz.feedback !== 'object' ) {
464 - return null;
465 - }
466 -
467 - const currentIndex = Number.parseInt( quiz.current_index || 0, 10 );
468 - const questionIndex = Math.max( 0, currentIndex - 1 );
469 - const question = quiz.questions?.[ questionIndex ];
470 - if ( ! question || ! Array.isArray( question.options ) ) {
471 - return null;
472 - }
473 -
474 - const selectedIndex = Number.parseInt( quiz.feedback.selected_index ?? -1, 10 );
475 - const correctIndex = Number.parseInt( quiz.feedback.correct_index ?? -1, 10 );
476 -
477 - return {
478 - question_index: questionIndex,
479 - total: Number.parseInt( quiz.total || question.options.length || 0, 10 ),
480 - question: question.question || '',
481 - options: question.options,
482 - selected_index: selectedIndex,
483 - correct_index: correctIndex,
484 - is_correct: !! quiz.feedback.is_correct,
485 - explanation: quiz.feedback.explanation || '',
486 - };
487 - }
488 -
489 - getQuizReviewKey( review ) {
490 - return [
491 - review.question_index,
492 - review.total,
493 - review.selected_index,
494 - review.correct_index,
495 - review.is_correct ? 1 : 0,
496 - ].join( '|' );
497 - }
498 -
499 - pushQuizReviewToHistory( review ) {
500 - const reviewKey = this.getQuizReviewKey( review );
501 - if ( reviewKey === this.lastQuizReviewSignature ) {
502 - return false;
503 - }
504 - this.lastQuizReviewSignature = reviewKey;
505 -
506 - this.history.push( {
507 - type: 'quiz_review',
508 - review,
509 - } );
510 - this.saveHistory();
511 -
512 - return true;
513 - }
514 -
515 - renderQuizReviewOptions( review ) {
516 - const options = Array.isArray( review.options ) ? review.options : [];
517 -
518 - return options.map( ( option, index ) => {
519 - const letter = String.fromCharCode( 65 + index );
520 - const classes = [ 'lp-ai-assistant__quiz-option' ];
521 - if ( index === review.correct_index ) {
522 - classes.push( 'is-correct-answer' );
523 - }
524 -
525 - if ( index === review.selected_index ) {
526 - classes.push( review.is_correct ? 'is-selected-correct' : 'is-selected-wrong' );
527 - }
528 -
529 - return `<button class="${ classes.join( ' ' ) }" disabled>${ letter }. ${ this.escHtml( option ) }</button>`;
530 - } ).join( '' );
531 - }
532 -
533 - appendQuizReviewCard( review ) {
534 - const card = document.createElement( 'div' );
535 - card.className = 'lp-ai-assistant__quiz-card lp-ai-assistant__quiz-card--review';
536 - card.setAttribute( 'data-review-key', this.getQuizReviewKey( review ) );
537 -
538 - const feedbackClass = review.is_correct ? 'is-correct' : 'is-wrong';
539 - const feedbackTitle = review.is_correct
540 - ? this.config.i18n.quizCorrectTitle
541 - : this.config.i18n.quizWrongTitle;
542 - const feedbackHtml =
543 - `<div class="lp-ai-assistant__quiz-feedback ${ feedbackClass }">` +
544 - `<strong>${ this.escHtml( feedbackTitle ) }</strong>` +
545 - ( review.explanation ? `<div>${ this.escHtml( review.explanation ) }</div>` : '' ) +
546 - '</div>';
547 -
548 - card.innerHTML =
549 - `<div class="lp-ai-assistant__quiz-head">Question ${ review.question_index + 1 }/${ review.total || review.options.length }</div>` +
550 - `<div class="lp-ai-assistant__quiz-question">${ this.escHtml( review.question || '' ) }</div>` +
551 - `<div class="lp-ai-assistant__quiz-options">${ this.renderQuizReviewOptions( review ) }</div>` +
552 - feedbackHtml;
553 -
554 - this.elements.msgList.appendChild( card );
555 - }
556 -
557 - renderQuizState() {
558 - const oldActiveQuizCard = this.elements.msgList.querySelector( '.lp-ai-assistant__quiz-card--active' );
559 - if ( oldActiveQuizCard ) {
560 - oldActiveQuizCard.remove();
561 - }
562 -
563 - if ( ! this.activeQuizState || ! this.activeQuizState.questions ) {
564 - this.setQuizInputMode( false );
565 - return;
566 - }
567 -
568 - const quiz = this.activeQuizState;
569 - const review = this.getQuizReviewFromState( quiz );
570 - if ( review ) {
571 - if ( this.pushQuizReviewToHistory( review ) ) {
572 - this.appendQuizReviewCard( review );
573 - }
574 - }
575 -
576 - if ( ! quiz.is_active ) {
577 - this.setQuizInputMode( false );
578 - return;
579 - }
580 -
581 - const currentIndex = Number.parseInt( quiz.current_index || 0, 10 );
582 - const question = quiz.questions?.[ currentIndex ];
583 - if ( ! question ) {
584 - this.setQuizInputMode( false );
585 - return;
586 - }
587 -
588 - const card = document.createElement( 'div' );
589 - card.className = 'lp-ai-assistant__quiz-card lp-ai-assistant__quiz-card--active';
590 -
591 - const options = Array.isArray( question.options ) ? question.options : [];
592 - const optionsHtml = options.map( ( option, index ) => {
593 - const letter = String.fromCharCode( 65 + index );
594 - return `<button class="lp-ai-assistant__quiz-option" data-index="${ index }" data-option="${ this.escHtml( option ) }">${ letter }. ${ this.escHtml( option ) }</button>`;
595 - } ).join( '' );
596 -
597 - card.innerHTML =
598 - `<div class="lp-ai-assistant__quiz-head">Question ${ currentIndex + 1 }/${ quiz.total || options.length }</div>` +
599 - `<div class="lp-ai-assistant__quiz-question">${ this.escHtml( question.question || '' ) }</div>` +
600 - `<div class="lp-ai-assistant__quiz-options">${ optionsHtml }</div>`;
601 -
602 - this.elements.msgList.appendChild( card );
603 - this.setQuizInputMode( true );
604 - }
605 -
606 - scrollToMessageStart( messageEl ) {
607 - const msgList = this.elements.msgList;
608 - if ( ! msgList || ! messageEl || ! msgList.contains( messageEl ) ) {
609 - return;
610 - }
611 -
612 - msgList.scrollTop = Math.max( 0, messageEl.offsetTop - 8 );
613 - }
614 -
615 - sendMessage( message, actionHint = '' ) {
616 - const text = ( message || '' ).trim();
617 - if ( this.isRequesting || ! text ) {
618 - return;
619 - }
620 -
621 - const ajaxHandle = this.getAjaxHandle();
622 - if ( ! ajaxHandle ) {
623 - this.appendMessage( 'assistant', this.config.i18n.sendError );
624 - return;
625 - }
626 -
627 - this.appendMessage( 'user', text );
628 -
629 - const contextHistory = this.history.slice();
630 - this.history.push( { role: 'user', content: text } );
631 - this.saveHistory();
632 - if ( this.elements.inputEl ) {
633 - this.elements.inputEl.value = '';
634 - }
635 -
636 - const pendingEl = this.appendMessage( 'assistant', this.config.i18n.thinking );
637 - const pendingTextEl = pendingEl.querySelector( '.lp-ai-assistant__msg-text' );
638 - this.setLoadingState( true );
639 -
640 - const dataSend = {
641 - action: 'openai_assistant_chat',
642 - message: text,
643 - item_id: this.config.itemId,
644 - course_id: this.config.courseId,
645 - history: contextHistory,
646 - active_quiz_questions: this.activeQuizState || [],
647 - action_hint: typeof actionHint === 'string' ? actionHint : '',
648 - };
649 -
650 - const callBack = {
651 - success: ( response ) => {
652 - if ( response?.status === 'success' && response?.data ) {
653 - pendingTextEl.textContent = response.data.message || '';
654 -
655 - if ( response?.data?.type === 'quiz' ) {
656 - this.activeQuizState = response?.data?.quiz || null;
657 - this.renderQuizState();
658 -
659 - const isQuizCompleted = !! this.activeQuizState?.completed || this.activeQuizState?.is_active === false;
660 - if ( isQuizCompleted && this.elements.msgList?.contains( pendingEl ) ) {
661 - // Keep completion feedback after the last review card.
662 - this.elements.msgList.appendChild( pendingEl );
663 - }
664 - } else {
665 - this.activeQuizState = null;
666 - this.renderQuizState();
667 - }
668 -
669 - this.history.push( { role: 'assistant', content: response.data.message } );
670 - this.saveHistory();
671 - } else {
672 - this.activeQuizState = null;
673 - this.renderQuizState();
674 - pendingTextEl.textContent = response?.message || this.config.i18n.sendError;
675 - }
676 - },
677 - error: () => {
678 - this.activeQuizState = null;
679 - this.renderQuizState();
680 - pendingTextEl.textContent = this.config.i18n.sendError;
681 - },
682 - completed: () => {
683 - this.setLoadingState( false );
684 - this.scrollToMessageStart( pendingEl );
685 - },
686 - };
687 -
688 - ajaxHandle.fetchAJAX( dataSend, callBack );
689 - }
690 -}
691 -
692 -const aiAssistantWidget = new AIAssistantWidget();
693 -lpUtils.lpOnElementReady( AIAssistantWidget.selectors.root, () => {
694 - aiAssistantWidget.init();
695 -} );
1 +/**
2 + * LP AI Assistant - frontend chat widget.
3 + *
4 + * LearnPress runtime implementation:
5 + * - ES6 class module.
6 + * - Delegated events via lpUtils.eventHandlers.
7 + * - Boot via lpUtils.lpOnElementReady.
8 + * - AJAX transport via window.lpAJAXG.fetchAJAX.
9 + *
10 + * @since 4.3.5
11 + * @version 1.0.0
12 + */
13 +import * as lpUtils from '../utils.js';
14 +import SweetAlert from 'sweetalert2';
15 +
16 +export class AIAssistantWidget {
17 + constructor() {
18 + this.config = null;
19 + this.root = null;
20 + this.elements = {};
21 + this.storageKey = '';
22 + this.history = [];
23 + this.activeQuizState = null;
24 + this.isRequesting = false;
25 + this.quizHookBound = false;
26 + this.lastQuizReviewSignature = '';
27 + }
28 +
29 + static selectors = {
30 + root: '#lp-ai-assistant',
31 + toggleBtn: '.lp-ai-assistant__toggle',
32 + panel: '.lp-ai-assistant__panel',
33 + closeBtn: '.lp-ai-assistant__close-btn',
34 + clearBtn: '.lp-ai-assistant__clear-btn',
35 + msgList: '.lp-ai-assistant__messages',
36 + inputEl: '.lp-ai-assistant__input',
37 + sendBtn: '.lp-ai-assistant__send-btn',
38 + quickBtn: '.lp-ai-assistant__quick-btn',
39 + quickActions: '.lp-ai-assistant__quick-actions',
40 + inputArea: '.lp-ai-assistant__input-area',
41 + smartReviewBtn: '.lp-ai-assistant__smart-review-btn',
42 + quizCard: '.lp-ai-assistant__quiz-card',
43 + quizOptionBtn: '.lp-ai-assistant__quiz-option',
44 + };
45 +
46 + /**
47 + * Curriculum item types the assistant supports.
48 + *
49 + * Mirrors AIAssistantController::get_supported_item_types(). The server re-validates,
50 + * so this only avoids pointless requests.
51 + */
52 + static itemTypes = [ 'lp_lesson', 'lp_quiz' ];
53 +
54 + init() {
55 + if ( ! this.validateConfig() ) {
56 + return;
57 + }
58 +
59 + this.root = document.querySelector( AIAssistantWidget.selectors.root );
60 + if ( ! this.root ) {
61 + return;
62 + }
63 +
64 + this.cacheElements();
65 + if ( ! this.validateDOM() ) {
66 + return;
67 + }
68 +
69 + this.storageKey = `lp_ai_chat_${ this.config.context }_${ this.config.itemId }`;
70 + this.applyInitialState();
71 + this.loadHistory();
72 + this.renderHistoryToDOM();
73 + this.bindQuizCompletedHook();
74 + this.events();
75 + }
76 +
77 + validateConfig() {
78 + if ( typeof window.lpAIAssistant !== 'object' || ! window.lpAIAssistant ) {
79 + return false;
80 + }
81 +
82 + this.config = window.lpAIAssistant;
83 + if ( ! this.config.enabled ) {
84 + return false;
85 + }
86 +
87 + const requiredString = [ 'nonce', 'ajaxUrl' ];
88 + for ( const key of requiredString ) {
89 + if ( typeof this.config[ key ] !== 'string' || ! this.config[ key ] ) {
90 + return false;
91 + }
92 + }
93 +
94 + const itemId = Number.isInteger( this.config.itemId ) ? this.config.itemId : this.config.lessonId;
95 + if ( ! Number.isInteger( itemId ) || itemId <= 0 ) {
96 + return false;
97 + }
98 +
99 + if ( ! Number.isInteger( this.config.courseId ) || this.config.courseId <= 0 ) {
100 + return false;
101 + }
102 +
103 + /**
104 + * itemType is required and must come from the server-localized config. It is
105 + * never guessed from context, lessonId or the numeric ID: a course item is
106 + * identified by (courseId, itemType, itemId), and inferring one leg of that
107 + * tuple on the client would let a lesson request address a quiz record.
108 + */
109 + if ( ! AIAssistantWidget.itemTypes.includes( this.config.itemType ) ) {
110 + return false;
111 + }
112 +
113 + this.config.itemId = itemId;
114 + this.config.lessonId = itemId; // Backward compatibility for existing AJAX contract.
115 + this.config.context = this.config.context === 'quiz' ? 'quiz' : 'lesson';
116 + this.config.quizCompleted = !! this.config.quizCompleted;
117 + this.config.enabledActions = {
118 + summarize: true,
119 + explain: true,
120 + quick_quiz: true,
121 + smart_review: true,
122 + ...( this.config.enabledActions || {} ),
123 + };
124 +
125 + this.config.i18n = {
126 + you: this.config?.i18n?.you || 'You',
127 + assistant: this.config?.i18n?.assistant || 'AI Assistant',
128 + thinking: this.config?.i18n?.thinking || 'Thinking...',
129 + sendError: this.config?.i18n?.sendError || 'An error occurred. Please try again.',
130 + clearConfirm: this.config?.i18n?.clearConfirm || 'Clear chat history?',
131 + explainPrompt: this.config?.i18n?.explainPrompt || 'Explain a concept from this lesson.',
132 + quizPrompt: this.config?.i18n?.quizPrompt || 'Create a quick quiz from this lesson.',
133 + summarizePrompt: this.config?.i18n?.summarizePrompt || 'Summarize this lesson with key points.',
134 + smartReviewPrompt: this.config?.i18n?.smartReviewPrompt || 'Give me a smart review of my quiz results.',
135 + quizCorrectTitle: this.config?.i18n?.quizCorrectTitle || 'Correct!',
136 + quizWrongTitle: this.config?.i18n?.quizWrongTitle || 'Not correct!',
137 + };
138 +
139 + return true;
140 + }
141 +
142 + cacheElements() {
143 + this.elements.toggleBtn = document.querySelector( AIAssistantWidget.selectors.toggleBtn );
144 + this.elements.panel = this.root.querySelector( AIAssistantWidget.selectors.panel );
145 + this.elements.closeBtn = this.root.querySelector( AIAssistantWidget.selectors.closeBtn );
146 + this.elements.clearBtn = this.root.querySelector( AIAssistantWidget.selectors.clearBtn );
147 + this.elements.msgList = this.root.querySelector( AIAssistantWidget.selectors.msgList );
148 + this.elements.inputEl = this.root.querySelector( AIAssistantWidget.selectors.inputEl );
149 + this.elements.sendBtn = this.root.querySelector( AIAssistantWidget.selectors.sendBtn );
150 + this.elements.inputArea = this.root.querySelector( AIAssistantWidget.selectors.inputArea );
151 + this.elements.quickActions = this.root.querySelector( AIAssistantWidget.selectors.quickActions );
152 + this.elements.smartReviewBtn = this.root.querySelector( AIAssistantWidget.selectors.smartReviewBtn );
153 + }
154 +
155 + validateDOM() {
156 + // inputEl and sendBtn are optional — absent when free chat is disabled.
157 + return !! (
158 + this.elements.toggleBtn &&
159 + this.elements.panel &&
160 + this.elements.msgList
161 + );
162 + }
163 +
164 + applyInitialState() {
165 + if ( this.elements.smartReviewBtn ) {
166 + const showSmartReview = this.config.context === 'quiz'
167 + ? this.config.quizCompleted
168 + : !! this.config.enabledActions?.smart_review;
169 + this.elements.smartReviewBtn.hidden = ! showSmartReview;
170 + }
171 +
172 + this.setQuizInputMode( false );
173 + }
174 +
175 + bindQuizCompletedHook() {
176 + if ( this.config.context === 'quiz' ) {
177 + return;
178 + }
179 +
180 + if ( this.quizHookBound || ! this.elements.smartReviewBtn ) {
181 + return;
182 + }
183 +
184 + const hooks = window?.wp?.hooks;
185 + if ( ! hooks || typeof hooks.addAction !== 'function' ) {
186 + return;
187 + }
188 +
189 + hooks.addAction( 'lp-js-quiz-answer', 'learnpress/ai-assistant-smart-review', ( answered, status ) => {
190 + if ( String( status || '' ).toLowerCase() !== 'completed' ) {
191 + return;
192 + }
193 +
194 + this.config.quizCompleted = true;
195 + this.elements.smartReviewBtn.hidden = false;
196 + } );
197 +
198 + this.quizHookBound = true;
199 + }
200 +
201 + events() {
202 + if ( AIAssistantWidget._loadedEvents ) {
203 + return;
204 + }
205 + AIAssistantWidget._loadedEvents = this;
206 +
207 + lpUtils.eventHandlers( 'click', [
208 + {
209 + selector: AIAssistantWidget.selectors.toggleBtn,
210 + class: this,
211 + callBack: this.handleToggleClick.name,
212 + },
213 + {
214 + selector: `${ AIAssistantWidget.selectors.root } ${ AIAssistantWidget.selectors.closeBtn }`,
215 + class: this,
216 + callBack: this.handleCloseClick.name,
217 + },
218 + {
219 + selector: `${ AIAssistantWidget.selectors.root } ${ AIAssistantWidget.selectors.clearBtn }`,
220 + class: this,
221 + callBack: this.handleClearClick.name,
222 + },
223 + {
224 + selector: `${ AIAssistantWidget.selectors.root } ${ AIAssistantWidget.selectors.sendBtn }`,
225 + class: this,
226 + callBack: this.handleSendClick.name,
227 + },
228 + {
229 + selector: `${ AIAssistantWidget.selectors.root } ${ AIAssistantWidget.selectors.quickBtn }`,
230 + class: this,
231 + callBack: this.handleQuickActionClick.name,
232 + },
233 + {
234 + selector: `${ AIAssistantWidget.selectors.root } ${ AIAssistantWidget.selectors.quizOptionBtn }`,
235 + class: this,
236 + callBack: this.handleQuizOptionClick.name,
237 + },
238 + ] );
239 +
240 + lpUtils.eventHandlers( 'keydown', [
241 + {
242 + selector: `${ AIAssistantWidget.selectors.root } ${ AIAssistantWidget.selectors.inputEl }`,
243 + class: this,
244 + callBack: this.handleInputKeydown.name,
245 + },
246 + {
247 + selector: 'body',
248 + class: this,
249 + callBack: this.handleEscapeKeydown.name,
250 + },
251 + ] );
252 + }
253 +
254 + handleToggleClick( args ) {
255 + args.e.preventDefault();
256 + if ( this.elements.panel.hidden ) {
257 + this.openPanel();
258 + } else {
259 + this.closePanel();
260 + }
261 + }
262 +
263 + handleCloseClick( args ) {
264 + args.e.preventDefault();
265 + this.closePanel();
266 + }
267 +
268 + handleClearClick( args ) {
269 + args.e.preventDefault();
270 + SweetAlert.fire( {
271 + title: this.config.i18n.clearConfirm,
272 + icon: 'warning',
273 + showCancelButton: true,
274 + confirmButtonColor: 'var(--lp-primary-color, #ffb606)',
275 + } ).then( ( result ) => {
276 + if ( result.isConfirmed ) {
277 + this.clearHistory();
278 + }
279 + } );
280 + }
281 +
282 + handleSendClick( args ) {
283 + args.e.preventDefault();
284 + this.sendMessage( this.elements.inputEl?.value ?? '' );
285 + }
286 +
287 + handleQuickActionClick( args ) {
288 + args.e.preventDefault();
289 +
290 + if ( this.activeQuizState?.is_active ) {
291 + return;
292 + }
293 +
294 + const btn = args.target.closest( AIAssistantWidget.selectors.quickBtn );
295 + if ( ! btn ) {
296 + return;
297 + }
298 +
299 + const action = btn.dataset.lpAiAction;
300 + const prompts = {
301 + explain: this.config.i18n.explainPrompt,
302 + 'quick-quiz': this.config.i18n.quizPrompt,
303 + summarize: this.config.i18n.summarizePrompt,
304 + 'smart-review': this.config.i18n.smartReviewPrompt,
305 + };
306 +
307 + const prompt = prompts[ action ];
308 + if ( ! prompt ) {
309 + return;
310 + }
311 +
312 + this.openPanel();
313 + this.sendMessage( prompt, action );
314 + }
315 +
316 + handleQuizOptionClick( args ) {
317 + args.e.preventDefault();
318 + if ( this.isRequesting || ! this.activeQuizState?.is_active ) {
319 + return;
320 + }
321 +
322 + const btn = args.target.closest( AIAssistantWidget.selectors.quizOptionBtn );
323 + if ( ! btn ) {
324 + return;
325 + }
326 +
327 + const answerText = ( btn.dataset.option || btn.textContent || '' ).trim();
328 + if ( ! answerText ) {
329 + return;
330 + }
331 +
332 + this.sendMessage( answerText );
333 + }
334 +
335 + handleInputKeydown( args ) {
336 + if ( this.activeQuizState?.is_active ) {
337 + return;
338 + }
339 +
340 + if ( args.e.key === 'Enter' && ! args.e.shiftKey ) {
341 + args.e.preventDefault();
342 + this.sendMessage( this.elements.inputEl?.value ?? '' );
343 + }
344 + }
345 +
346 + handleEscapeKeydown( args ) {
347 + if ( args.e.key !== 'Escape' ) {
348 + return;
349 + }
350 +
351 + if ( this.elements.panel && ! this.elements.panel.hidden ) {
352 + this.closePanel();
353 + }
354 + }
355 +
356 + getAjaxHandle() {
357 + const ajaxHandle = window.lpAJAXG;
358 + if ( ! ajaxHandle || typeof ajaxHandle.fetchAJAX !== 'function' ) {
359 + return null;
360 + }
361 +
362 + return ajaxHandle;
363 + }
364 +
365 + openPanel() {
366 + this.elements.panel.hidden = false;
367 + this.root.setAttribute( 'aria-hidden', 'false' );
368 + this.elements.toggleBtn.setAttribute( 'aria-expanded', 'true' );
369 + this.elements.toggleBtn.classList.add( 'is-hidden' );
370 + this.elements.inputEl?.focus();
371 +
372 + if ( this.elements.msgList ) {
373 + this.elements.msgList.scrollTop = this.elements.msgList.scrollHeight;
374 + }
375 + }
376 +
377 + closePanel() {
378 + this.elements.panel.hidden = true;
379 + this.root.setAttribute( 'aria-hidden', 'true' );
380 + this.elements.toggleBtn.setAttribute( 'aria-expanded', 'false' );
381 + this.elements.toggleBtn.classList.remove( 'is-hidden' );
382 + this.elements.toggleBtn.focus();
383 + }
384 +
385 + setLoadingState( isLoading ) {
386 + this.isRequesting = isLoading;
387 + if ( this.elements.sendBtn ) {
388 + this.elements.sendBtn.disabled = isLoading;
389 + }
390 + if ( this.elements.inputEl ) {
391 + this.elements.inputEl.disabled = isLoading;
392 + }
393 + }
394 +
395 + setQuizInputMode( isQuizActive ) {
396 + if ( this.elements.inputArea ) {
397 + this.elements.inputArea.classList.toggle( 'lp-ai-assistant__input-area--hidden', isQuizActive );
398 + }
399 +
400 + if ( this.elements.quickActions ) {
401 + this.elements.quickActions.classList.toggle( 'lp-ai-assistant__quick-actions--disabled', isQuizActive );
402 + }
403 + }
404 +
405 + loadHistory() {
406 + try {
407 + const raw = localStorage.getItem( this.storageKey );
408 + this.history = raw ? JSON.parse( raw ) : [];
409 + if ( ! Array.isArray( this.history ) ) {
410 + this.history = [];
411 + }
412 +
413 + const lastReview = [ ...this.history ]
414 + .reverse()
415 + .find( ( item ) => item?.type === 'quiz_review' && item?.review );
416 + this.lastQuizReviewSignature = lastReview?.review
417 + ? this.getQuizReviewKey( lastReview.review )
418 + : '';
419 + } catch ( _e ) {
420 + this.history = [];
421 + this.lastQuizReviewSignature = '';
422 + }
423 + }
424 +
425 + saveHistory() {
426 + try {
427 + localStorage.setItem( this.storageKey, JSON.stringify( this.history ) );
428 + } catch ( _e ) {
429 + // Ignore storage errors.
430 + }
431 + }
432 +
433 + clearHistory() {
434 + this.history = [];
435 + this.activeQuizState = null;
436 + this.lastQuizReviewSignature = '';
437 + this.elements.msgList.innerHTML = '';
438 + localStorage.removeItem( this.storageKey );
439 + this.setQuizInputMode( false );
440 + }
441 +
442 + /**
443 + * Create an element with a class and plain text content.
444 + *
445 + * All assistant content originates from OpenAI output, so it is built through the
446 + * DOM API only. Nothing on this path goes through innerHTML: text assigned via
447 + * textContent can never become markup, and attributes set via the DOM API can never
448 + * break out into a new attribute or event handler.
449 + *
450 + * @param {string} tag Tag name.
451 + * @param {string} className Class attribute.
452 + * @param {string} text Text content.
453 + * @return {HTMLElement} The created element.
454 + */
455 + createEl( tag, className, text = '' ) {
456 + const el = document.createElement( tag );
457 + if ( className ) {
458 + el.className = className;
459 + }
460 + if ( text !== '' ) {
461 + el.textContent = String( text );
462 + }
463 + return el;
464 + }
465 +
466 + appendMessage( role, text ) {
467 + const el = this.createEl( 'div', `lp-ai-assistant__msg lp-ai-assistant__msg--${ role }` );
468 + const label = role === 'user' ? this.config.i18n.you : this.config.i18n.assistant;
469 +
470 + el.appendChild( this.createEl( 'span', 'lp-ai-assistant__msg-label', label ) );
471 + el.appendChild( this.createEl( 'p', 'lp-ai-assistant__msg-text', text ) );
472 +
473 + this.elements.msgList.appendChild( el );
474 + this.elements.msgList.scrollTop = this.elements.msgList.scrollHeight;
475 + return el;
476 + }
477 +
478 + renderHistoryToDOM() {
479 + this.elements.msgList.innerHTML = '';
480 + this.history.forEach( ( message ) => {
481 + if ( message?.type === 'quiz_review' && message?.review ) {
482 + this.appendQuizReviewCard( message.review );
483 + return;
484 + }
485 +
486 + if ( ! message || ! [ 'user', 'assistant' ].includes( message.role ) ) {
487 + return;
488 + }
489 +
490 + this.appendMessage( message.role, message.content || '' );
491 + } );
492 +
493 + this.renderQuizState();
494 + }
495 +
496 + getQuizReviewFromState( quiz ) {
497 + if ( ! quiz || ! quiz.feedback || typeof quiz.feedback !== 'object' ) {
498 + return null;
499 + }
500 +
501 + const currentIndex = Number.parseInt( quiz.current_index || 0, 10 );
502 + const questionIndex = Math.max( 0, currentIndex - 1 );
503 + const question = quiz.questions?.[ questionIndex ];
504 + if ( ! question || ! Array.isArray( question.options ) ) {
505 + return null;
506 + }
507 +
508 + const selectedIndex = Number.parseInt( quiz.feedback.selected_index ?? -1, 10 );
509 + const correctIndex = Number.parseInt( quiz.feedback.correct_index ?? -1, 10 );
510 +
511 + return {
512 + question_index: questionIndex,
513 + total: Number.parseInt( quiz.total || question.options.length || 0, 10 ),
514 + question: question.question || '',
515 + options: question.options,
516 + selected_index: selectedIndex,
517 + correct_index: correctIndex,
518 + is_correct: !! quiz.feedback.is_correct,
519 + explanation: quiz.feedback.explanation || '',
520 + };
521 + }
522 +
523 + getQuizReviewKey( review ) {
524 + return [
525 + review.question_index,
526 + review.total,
527 + review.selected_index,
528 + review.correct_index,
529 + review.is_correct ? 1 : 0,
530 + ].join( '|' );
531 + }
532 +
533 + pushQuizReviewToHistory( review ) {
534 + const reviewKey = this.getQuizReviewKey( review );
535 + if ( reviewKey === this.lastQuizReviewSignature ) {
536 + return false;
537 + }
538 + this.lastQuizReviewSignature = reviewKey;
539 +
540 + this.history.push( {
541 + type: 'quiz_review',
542 + review,
543 + } );
544 + this.saveHistory();
545 +
546 + return true;
547 + }
548 +
549 + /**
550 + * Build one quiz option button.
551 + *
552 + * @param {string} option Option text from model output.
553 + * @param {number} index Zero-based option index.
554 + * @param {string[]} extraClasses Additional state classes.
555 + * @return {HTMLButtonElement} The option button.
556 + */
557 + buildQuizOption( option, index, extraClasses = [] ) {
558 + const classes = [ 'lp-ai-assistant__quiz-option', ...extraClasses ].join( ' ' );
559 + const letter = String.fromCharCode( 65 + index );
560 +
561 + const btn = this.createEl( 'button', classes, `${ letter }. ${ String( option ) }` );
562 + btn.type = 'button';
563 +
564 + return btn;
565 + }
566 +
567 + buildQuizReviewOptions( review ) {
568 + const options = Array.isArray( review.options ) ? review.options : [];
569 +
570 + return options.map( ( option, index ) => {
571 + const classes = [];
572 + if ( index === review.correct_index ) {
573 + classes.push( 'is-correct-answer' );
574 + }
575 +
576 + if ( index === review.selected_index ) {
577 + classes.push( review.is_correct ? 'is-selected-correct' : 'is-selected-wrong' );
578 + }
579 +
580 + const btn = this.buildQuizOption( option, index, classes );
581 + btn.disabled = true;
582 +
583 + return btn;
584 + } );
585 + }
586 +
587 + appendQuizReviewCard( review ) {
588 + const card = this.createEl( 'div', 'lp-ai-assistant__quiz-card lp-ai-assistant__quiz-card--review' );
589 + card.dataset.reviewKey = this.getQuizReviewKey( review );
590 +
591 + const optionCount = Array.isArray( review.options ) ? review.options.length : 0;
592 + const total = review.total || optionCount;
593 +
594 + card.appendChild(
595 + this.createEl( 'div', 'lp-ai-assistant__quiz-head', `Question ${ review.question_index + 1 }/${ total }` )
596 + );
597 + card.appendChild( this.createEl( 'div', 'lp-ai-assistant__quiz-question', review.question || '' ) );
598 +
599 + const optionsEl = this.createEl( 'div', 'lp-ai-assistant__quiz-options' );
600 + this.buildQuizReviewOptions( review ).forEach( ( btn ) => optionsEl.appendChild( btn ) );
601 + card.appendChild( optionsEl );
602 +
603 + const feedbackClass = review.is_correct ? 'is-correct' : 'is-wrong';
604 + const feedbackEl = this.createEl( 'div', `lp-ai-assistant__quiz-feedback ${ feedbackClass }` );
605 + feedbackEl.appendChild(
606 + this.createEl(
607 + 'strong',
608 + '',
609 + review.is_correct ? this.config.i18n.quizCorrectTitle : this.config.i18n.quizWrongTitle
610 + )
611 + );
612 +
613 + if ( review.explanation ) {
614 + feedbackEl.appendChild( this.createEl( 'div', '', review.explanation ) );
615 + }
616 +
617 + card.appendChild( feedbackEl );
618 +
619 + this.elements.msgList.appendChild( card );
620 + }
621 +
622 + renderQuizState() {
623 + const oldActiveQuizCard = this.elements.msgList.querySelector( '.lp-ai-assistant__quiz-card--active' );
624 + if ( oldActiveQuizCard ) {
625 + oldActiveQuizCard.remove();
626 + }
627 +
628 + if ( ! this.activeQuizState || ! this.activeQuizState.questions ) {
629 + this.setQuizInputMode( false );
630 + return;
631 + }
632 +
633 + const quiz = this.activeQuizState;
634 + const review = this.getQuizReviewFromState( quiz );
635 + if ( review ) {
636 + if ( this.pushQuizReviewToHistory( review ) ) {
637 + this.appendQuizReviewCard( review );
638 + }
639 + }
640 +
641 + if ( ! quiz.is_active ) {
642 + this.setQuizInputMode( false );
643 + return;
644 + }
645 +
646 + const currentIndex = Number.parseInt( quiz.current_index || 0, 10 );
647 + const question = quiz.questions?.[ currentIndex ];
648 + if ( ! question ) {
649 + this.setQuizInputMode( false );
650 + return;
651 + }
652 +
653 + const card = this.createEl( 'div', 'lp-ai-assistant__quiz-card lp-ai-assistant__quiz-card--active' );
654 + const options = Array.isArray( question.options ) ? question.options : [];
655 +
656 + card.appendChild(
657 + this.createEl(
658 + 'div',
659 + 'lp-ai-assistant__quiz-head',
660 + `Question ${ currentIndex + 1 }/${ quiz.total || options.length }`
661 + )
662 + );
663 + card.appendChild( this.createEl( 'div', 'lp-ai-assistant__quiz-question', question.question || '' ) );
664 +
665 + const optionsEl = this.createEl( 'div', 'lp-ai-assistant__quiz-options' );
666 + options.forEach( ( option, index ) => {
667 + const btn = this.buildQuizOption( option, index );
668 +
669 + // Assigned through dataset, never interpolated into an HTML attribute — model
670 + // output containing a quote cannot open a new attribute or event handler.
671 + btn.dataset.index = String( index );
672 + btn.dataset.option = String( option );
673 +
674 + optionsEl.appendChild( btn );
675 + } );
676 + card.appendChild( optionsEl );
677 +
678 + this.elements.msgList.appendChild( card );
679 + this.setQuizInputMode( true );
680 + }
681 +
682 + scrollToMessageStart( messageEl ) {
683 + const msgList = this.elements.msgList;
684 + if ( ! msgList || ! messageEl || ! msgList.contains( messageEl ) ) {
685 + return;
686 + }
687 +
688 + msgList.scrollTop = Math.max( 0, messageEl.offsetTop - 8 );
689 + }
690 +
691 + sendMessage( message, actionHint = '' ) {
692 + const text = ( message || '' ).trim();
693 + if ( this.isRequesting || ! text ) {
694 + return;
695 + }
696 +
697 + const ajaxHandle = this.getAjaxHandle();
698 + if ( ! ajaxHandle ) {
699 + this.appendMessage( 'assistant', this.config.i18n.sendError );
700 + return;
701 + }
702 +
703 + this.appendMessage( 'user', text );
704 +
705 + const contextHistory = this.history.slice();
706 + this.history.push( { role: 'user', content: text } );
707 + this.saveHistory();
708 + if ( this.elements.inputEl ) {
709 + this.elements.inputEl.value = '';
710 + }
711 +
712 + const pendingEl = this.appendMessage( 'assistant', this.config.i18n.thinking );
713 + const pendingTextEl = pendingEl.querySelector( '.lp-ai-assistant__msg-text' );
714 + this.setLoadingState( true );
715 +
716 + const dataSend = {
717 + action: 'openai_assistant_chat',
718 + message: text,
719 + // Composite item identity — all three legs are required server-side.
720 + course_id: this.config.courseId,
721 + item_type: this.config.itemType,
722 + item_id: this.config.itemId,
723 + history: contextHistory,
724 + active_quiz_questions: this.activeQuizState || [],
725 + action_hint: typeof actionHint === 'string' ? actionHint : '',
726 + };
727 +
728 + const callBack = {
729 + success: ( response ) => {
730 + if ( response?.status === 'success' && response?.data ) {
731 + pendingTextEl.textContent = response.data.message || '';
732 +
733 + if ( response?.data?.type === 'quiz' ) {
734 + this.activeQuizState = response?.data?.quiz || null;
735 + this.renderQuizState();
736 +
737 + const isQuizCompleted = !! this.activeQuizState?.completed || this.activeQuizState?.is_active === false;
738 + if ( isQuizCompleted && this.elements.msgList?.contains( pendingEl ) ) {
739 + // Keep completion feedback after the last review card.
740 + this.elements.msgList.appendChild( pendingEl );
741 + }
742 + } else {
743 + this.activeQuizState = null;
744 + this.renderQuizState();
745 + }
746 +
747 + this.history.push( { role: 'assistant', content: response.data.message } );
748 + this.saveHistory();
749 + } else {
750 + this.activeQuizState = null;
751 + this.renderQuizState();
752 + pendingTextEl.textContent = response?.message || this.config.i18n.sendError;
753 + }
754 + },
755 + error: () => {
756 + this.activeQuizState = null;
757 + this.renderQuizState();
758 + pendingTextEl.textContent = this.config.i18n.sendError;
759 + },
760 + completed: () => {
761 + this.setLoadingState( false );
762 + this.scrollToMessageStart( pendingEl );
763 + },
764 + };
765 +
766 + ajaxHandle.fetchAJAX( dataSend, callBack );
767 + }
768 +}
769 +
770 +const aiAssistantWidget = new AIAssistantWidget();
771 +lpUtils.lpOnElementReady( AIAssistantWidget.selectors.root, () => {
772 + aiAssistantWidget.init();
773 +} );