| 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 |
} ); |
| 696 |
|