| 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 |
|