| @@ -42,8 +42,16 @@ | ||
| 42 | 42 | quizCard: '.lp-ai-assistant__quiz-card', |
| 43 | 43 | quizOptionBtn: '.lp-ai-assistant__quiz-option', |
| 44 | 44 | }; |
| 45 | 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 | + | |
| 46 | 54 | init() { |
| 47 | 55 | if ( ! this.validateConfig() ) { |
| 48 | 56 | return; |
| 49 | 57 | } |
| @@ -91,8 +99,18 @@ | ||
| 91 | 99 | if ( ! Number.isInteger( this.config.courseId ) || this.config.courseId <= 0 ) { |
| 92 | 100 | return false; |
| 93 | 101 | } |
| 94 | 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 | + | |
| 95 | 113 | this.config.itemId = itemId; |
| 96 | 114 | this.config.lessonId = itemId; // Backward compatibility for existing AJAX contract. |
| 97 | 115 | this.config.context = this.config.context === 'quiz' ? 'quiz' : 'lesson'; |
| 98 | 116 | this.config.quizCompleted = !! this.config.quizCompleted; |
| @@ -420,22 +438,38 @@ | ||
| 420 | 438 | localStorage.removeItem( this.storageKey ); |
| 421 | 439 | this.setQuizInputMode( false ); |
| 422 | 440 | } |
| 423 | 441 | |
| 424 | - escHtml( text ) { | |
| 425 | - const div = document.createElement( 'div' ); | |
| 426 | - div.appendChild( document.createTextNode( String( text ) ) ); | |
| 427 | - return div.innerHTML; | |
| 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; | |
| 428 | 464 | } |
| 429 | 465 | |
| 430 | 466 | appendMessage( role, text ) { |
| 431 | - const el = document.createElement( 'div' ); | |
| 432 | - el.className = `lp-ai-assistant__msg lp-ai-assistant__msg--${ role }`; | |
| 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; | |
| 433 | 469 | |
| 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>`; | |
| 470 | + el.appendChild( this.createEl( 'span', 'lp-ai-assistant__msg-label', label ) ); | |
| 471 | + el.appendChild( this.createEl( 'p', 'lp-ai-assistant__msg-text', text ) ); | |
| 438 | 472 | |
| 439 | 473 | this.elements.msgList.appendChild( el ); |
| 440 | 474 | this.elements.msgList.scrollTop = this.elements.msgList.scrollHeight; |
| 441 | 475 | return el; |
| @@ -511,14 +545,31 @@ | ||
| 511 | 545 | |
| 512 | 546 | return true; |
| 513 | 547 | } |
| 514 | 548 | |
| 515 | - renderQuizReviewOptions( review ) { | |
| 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 ) { | |
| 516 | 568 | const options = Array.isArray( review.options ) ? review.options : []; |
| 517 | 569 | |
| 518 | 570 | return options.map( ( option, index ) => { |
| 519 | - const letter = String.fromCharCode( 65 + index ); | |
| 520 | - const classes = [ 'lp-ai-assistant__quiz-option' ]; | |
| 571 | + const classes = []; | |
| 521 | 572 | if ( index === review.correct_index ) { |
| 522 | 573 | classes.push( 'is-correct-answer' ); |
| 523 | 574 | } |
| 524 | 575 | |
| @@ -525,33 +576,47 @@ | ||
| 525 | 576 | if ( index === review.selected_index ) { |
| 526 | 577 | classes.push( review.is_correct ? 'is-selected-correct' : 'is-selected-wrong' ); |
| 527 | 578 | } |
| 528 | 579 | |
| 529 | - return `<button class="${ classes.join( ' ' ) }" disabled>${ letter }. ${ this.escHtml( option ) }</button>`; | |
| 530 | - } ).join( '' ); | |
| 580 | + const btn = this.buildQuizOption( option, index, classes ); | |
| 581 | + btn.disabled = true; | |
| 582 | + | |
| 583 | + return btn; | |
| 584 | + } ); | |
| 531 | 585 | } |
| 532 | 586 | |
| 533 | 587 | 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 ) ); | |
| 588 | + const card = this.createEl( 'div', 'lp-ai-assistant__quiz-card lp-ai-assistant__quiz-card--review' ); | |
| 589 | + card.dataset.reviewKey = this.getQuizReviewKey( review ); | |
| 537 | 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 | + | |
| 538 | 603 | 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>'; | |
| 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 | + ); | |
| 547 | 612 | |
| 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; | |
| 613 | + if ( review.explanation ) { | |
| 614 | + feedbackEl.appendChild( this.createEl( 'div', '', review.explanation ) ); | |
| 615 | + } | |
| 553 | 616 | |
| 617 | + card.appendChild( feedbackEl ); | |
| 618 | + | |
| 554 | 619 | this.elements.msgList.appendChild( card ); |
| 555 | 620 | } |
| 556 | 621 | |
| 557 | 622 | renderQuizState() { |
| @@ -584,22 +649,33 @@ | ||
| 584 | 649 | this.setQuizInputMode( false ); |
| 585 | 650 | return; |
| 586 | 651 | } |
| 587 | 652 | |
| 588 | - const card = document.createElement( 'div' ); | |
| 589 | - card.className = 'lp-ai-assistant__quiz-card lp-ai-assistant__quiz-card--active'; | |
| 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 : []; | |
| 590 | 655 | |
| 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( '' ); | |
| 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 || '' ) ); | |
| 596 | 664 | |
| 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>`; | |
| 665 | + const optionsEl = this.createEl( 'div', 'lp-ai-assistant__quiz-options' ); | |
| 666 | + options.forEach( ( option, index ) => { | |
| 667 | + const btn = this.buildQuizOption( option, index ); | |
| 601 | 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 | + | |
| 602 | 678 | this.elements.msgList.appendChild( card ); |
| 603 | 679 | this.setQuizInputMode( true ); |
| 604 | 680 | } |
| 605 | 681 | |
| @@ -639,10 +715,12 @@ | ||
| 639 | 715 | |
| 640 | 716 | const dataSend = { |
| 641 | 717 | action: 'openai_assistant_chat', |
| 642 | 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, | |
| 643 | 722 | item_id: this.config.itemId, |
| 644 | - course_id: this.config.courseId, | |
| 645 | 723 | history: contextHistory, |
| 646 | 724 | active_quiz_questions: this.activeQuizState || [], |
| 647 | 725 | action_hint: typeof actionHint === 'string' ? actionHint : '', |
| 648 | 726 | }; |