PluginProbe
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses / 4.4.5
LearnPress – WordPress LMS Plugin for Create and Sell Online Courses v4.4.5
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 4.2.0 All 139 releases
learnpress / assets / src / js / frontend / ai-assistant.js

ai-assistant.js in LearnPress – WordPress LMS Plugin for Create and Sell Online Courses 4.4.5, at assets/src/js/frontend/ai-assistant.js

774 lines 22.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 } );
774