/* global wpforms_ai_chat_element, WPFormsAIModal, wpf */ /** * @param this.modeStrings.learnMore * @param wpforms_ai_chat_element.dislike * @param wpforms_ai_chat_element.refresh * @param wpforms_ai_chat_element.confirm.refreshTitle * @param wpforms_ai_chat_element.confirm.refreshMessage * @param this.modeStrings.samplePrompts * @param this.modeStrings.errors.rate_limit * @param this.modeStrings.reasons.rate_limit * @param this.modeStrings.descrEndDot */ /** * The `WPFormsAIChatHTMLElement` element loader. * * @since 1.9.2 */ ( function() { // Additional modules provided by wpforms_ai_chat_element. const modules = wpforms_ai_chat_element.modules || []; // Import all modules dynamically. Promise.all( modules.map( ( module ) => import( module.path ) ) ) .then( ( importedModules ) => { // Create the helpers object dynamically. const helpers = {}; let api; importedModules.forEach( ( module, index ) => { const moduleName = modules[ index ].name; if ( moduleName === 'api' ) { api = module.default(); return; } helpers[ moduleName ] = module.default; } ); window.WPFormsAi = { api, helpers, }; // Register the custom HTML element. customElements.define( 'wpforms-ai-chat', WPFormsAIChatHTMLElement ); // eslint-disable-line no-use-before-define } ) .catch( ( error ) => { wpf.debug( 'Error importing modules:', error ); } ); }() ); /** * The WPForms AI chat. * * Custom HTML element class. * * @since 1.9.1 */ class WPFormsAIChatHTMLElement extends HTMLElement { /** * Element constructor. * * @since 1.9.1 */ constructor() { // eslint-disable-line no-useless-constructor // Always call super first in constructor. super(); } /** * Element connected to the DOM. * * @since 1.9.1 */ connectedCallback() { // eslint-disable-line complexity // Init chat properties. this.chatMode = this.getAttribute( 'mode' ) ?? 'text'; this.fieldId = this.getAttribute( 'field-id' ) ?? ''; this.prefill = this.getAttribute( 'prefill' ) ?? ''; this.autoSubmit = this.getAttribute( 'auto-submit' ) === 'true'; this.modeStrings = wpforms_ai_chat_element[ this.chatMode ] ?? {}; this.loadingState = false; // Init chat helpers according to the chat mode. this.modeHelpers = this.getHelpers( this ); // Bail if chat mode helpers not found. if ( ! this.modeHelpers ) { console.error( `WPFormsAI error: chat mode "${ this.chatMode }" helpers not found` ); // eslint-disable-line no-console return; } // Render chat HTML. if ( ! this.innerHTML.trim() ) { this.innerHTML = this.getInnerHTML(); } // Get chat elements. this.wrapper = this.querySelector( '.wpforms-ai-chat' ); this.input = this.querySelector( '.wpforms-ai-chat-message-input input, .wpforms-ai-chat-message-input textarea' ); this.welcomeScreenSamplePrompts = this.querySelector( '.wpforms-ai-chat-welcome-screen-sample-prompts' ); this.sendButton = this.querySelector( '.wpforms-ai-chat-send' ); this.stopButton = this.querySelector( '.wpforms-ai-chat-stop' ); this.messageList = this.querySelector( '.wpforms-ai-chat-message-list' ); // Flags. this.isTextarea = this.input.tagName === 'TEXTAREA'; this.preventResizeInput = false; // Compact scrollbar for non-Mac devices. if ( ! navigator.userAgent.includes( 'Macintosh' ) ) { this.messageList.classList.add( 'wpforms-scrollbar-compact' ); } // Bind events. this.events(); // Init answers. this.initAnswers(); // Init mode. if ( typeof this.modeHelpers.init === 'function' ) { this.modeHelpers.init(); } // Auto-submit if enabled and prefill is provided if ( this.autoSubmit && this.prefill ) { this.input.value = this.prefill; setTimeout( () => this.sendMessage( true ), 250 ); } } /** * Get initial innerHTML markup. * * @since 1.9.1 * * @return {string} The inner HTML markup. */ getInnerHTML() { if ( this.modeStrings.chatHtml ) { return this.decodeHTMLEntities( this.modeStrings.chatHtml ); } return `
${ this.getWelcomeScreen() }
${ this.getMessageInputField() }
`; } /** * Get the message input field HTML. * * @since 1.9.2 * * @return {string} The message input field markup. */ getMessageInputField() { if ( typeof this.modeHelpers.getMessageInputField === 'function' ) { return this.modeHelpers.getMessageInputField(); } return ``; } /** * Get the Welcome screen HTML markup. * * @since 1.9.1 * * @return {string} The Welcome screen markup. */ getWelcomeScreen() { let content; if ( this.modeHelpers.isWelcomeScreen() ) { content = this.getWelcomeScreenContent(); } else { this.messagePreAdded = true; content = this.modeHelpers.getWarningMessage(); } return `

${ this.modeStrings.title }

${ this.modeStrings.description } ${ this.modeStrings.learnMore }${ this.modeStrings.descrEndDot }
${ content }
`; } /** * Get the welcome screen content. * * @since 1.9.4 * * @return {string} The welcome screen content. */ getWelcomeScreenContent() { const samplePrompts = this.modeStrings?.samplePrompts, li = []; if ( ! samplePrompts && ! this.modeStrings?.initialChat ) { return ''; } if ( samplePrompts ) { // Render sample prompts. for ( const i in samplePrompts ) { li.push( `
  • ${ samplePrompts[ i ].title }
  • ` ); } return ` `; } if ( this.prefill.length > 0 ) { return ''; } this.messagePreAdded = true; return this.modeHelpers?.getInitialChat( this.modeStrings.initialChat ); } /** * Get the spinner SVG image. * * @since 1.9.1 * * @return {string} The spinner SVG markup. */ getSpinnerSvg() { return ``; } /** * Add event listeners. * * @since 1.9.1 */ events() { this.sendButton.addEventListener( 'click', this.sendMessage.bind( this ) ); this.stopButton.addEventListener( 'click', this.stopLoading.bind( this ) ); this.input.addEventListener( 'keyup', this.keyUp.bind( this ) ); this.bindWelcomeScreenEvents(); } /** * Bind welcome screen events. * * @since 1.9.1 */ bindWelcomeScreenEvents() { if ( this.welcomeScreenSamplePrompts === null ) { return; } // Click on the default item in the welcome screen. this.welcomeScreenSamplePrompts.querySelectorAll( 'li' ).forEach( ( li ) => { li.addEventListener( 'click', this.clickDefaultItem.bind( this ) ); li.addEventListener( 'keydown', ( e ) => { if ( e.code === 'Enter' ) { e.preventDefault(); this.clickDefaultItem( e ); } } ); } ); } /** * Init all answers. * * @since 1.9.2 */ initAnswers() { if ( ! this.modeStrings.chatHtml ) { return; } this.wpformsAiApi = this.getAiApi(); this.messageList.querySelectorAll( '.wpforms-chat-item-answer' ).forEach( ( answer ) => { this.initAnswer( answer ); } ); } /** * Keyboard `keyUp` event handler. * * @since 1.9.1 * * @param {KeyboardEvent} e The keyboard event. */ keyUp( e ) { // eslint-disable-line complexity switch ( e.code ) { case 'Enter': // Send a message on `Enter` key press. // In the case of textarea, `Shift + Enter` adds a new line. if ( ! this.isTextarea || ( this.isTextarea && ! e.shiftKey ) ) { e.preventDefault(); this.sendMessage(); } break; case 'ArrowUp': // Navigate through the chat history. // In the case of textarea, `Ctrl + Up` is used. if ( ! this.isTextarea || ( this.isTextarea && e.ctrlKey ) ) { e.preventDefault(); this.arrowUp(); } break; case 'ArrowDown': // Navigate through the chat history. // In the case of textarea, `Ctrl + Down` is used. if ( ! this.isTextarea || ( this.isTextarea && e.ctrlKey ) ) { e.preventDefault(); this.arrowDown(); } break; default: // Update the chat history. this.history.update( { question: this.input.value } ); } } /** * Send a question message to the chat. * * @since 1.9.1 * * @param {boolean} allowHTML Whether to allow HTML in the message. */ sendMessage( allowHTML = false ) { let message = this.input.value; if ( ! message ) { return; } if ( ! allowHTML ) { message = this.htmlSpecialChars( message ); } // Fire event before sending the message. this.triggerEvent( 'wpformsAIChatBeforeSendMessage', { fieldId: this.fieldId, mode: this.chatMode } ); this.addFirstMessagePre(); this.welcomeScreenSamplePrompts?.remove(); this.resetInput(); this.addMessage( message, true ); this.startLoading(); if ( message.trim() === '' ) { this.addEmptyResultsError(); return; } if ( typeof this.modeHelpers.prepareMessage === 'function' ) { message = this.modeHelpers.prepareMessage( message ); } this.getAiApi() .prompt( message, this.sessionId ) .then( this.addAnswer.bind( this ) ) .catch( this.apiResponseError.bind( this ) ); } /** * AI API error handler. * * @since 1.9.2 * * @param {Object|string} error The error object or string. */ apiResponseError( error ) { // eslint-disable-line complexity const cause = error?.cause ?? null; // Handle the rate limit error. if ( cause === 429 ) { this.addError( this.modeStrings.errors.rate_limit || wpforms_ai_chat_element.errors.rate_limit, this.modeStrings.reasons.rate_limit || wpforms_ai_chat_element.reasons.rate_limit ); return; } // Handle the Internal Server Error. if ( cause === 500 ) { this.addEmptyResultsError(); return; } this.addError( error.message || this.modeStrings.errors.default || wpforms_ai_chat_element.errors.default, this.modeStrings.reasons.default || wpforms_ai_chat_element.reasons.default ); wpf.debug( 'WPFormsAI error: ', error ); } /** * Before the first message. * * @since 1.9.1 */ addFirstMessagePre() { if ( this.sessionId || this.messagePreAdded ) { return; } this.messagePreAdded = true; const divider = document.createElement( 'div' ); divider.classList.add( 'wpforms-ai-chat-divider' ); this.messageList.appendChild( divider ); } /** * Click on the default item in the welcome screen. * * @since 1.9.1 * * @param {Event} e The event object. */ clickDefaultItem( e ) { const li = e.target.nodeName === 'LI' ? e.target : e.target.closest( 'li' ); const message = li.querySelector( 'a' )?.textContent; e.preventDefault(); if ( ! message ) { return; } this.input.value = message; // Update the chat history. this.history.push( { question: message } ); this.sendMessage(); } /** * Click on the dislike button. * * @since 1.9.1 * * @param {Event} e The event object. */ clickDislikeButton( e ) { const button = e.target; const answer = button?.closest( '.wpforms-chat-item-answer' ); if ( ! answer ) { return; } button.classList.add( 'clicked' ); button.setAttribute( 'disabled', true ); const responseId = answer.getAttribute( 'data-response-id' ); this.wpformsAiApi.rate( false, responseId ); } /** * Click on the refresh button. * * @since 1.9.1 */ async clickRefreshButton() { const refreshConfirm = () => { // Restore the welcome screen. this.prefill = ''; this.messageList.innerHTML = this.getWelcomeScreen(); this.welcomeScreenSamplePrompts = this.querySelector( '.wpforms-ai-chat-welcome-screen-sample-prompts' ); this.bindWelcomeScreenEvents(); this.scrollMessagesTo( 'top' ); // Clear the session ID. this.wpformsAiApi = null; this.sessionId = null; this.messagePreAdded = null; this.wrapper.removeAttribute( 'data-session-id' ); // Clear the chat history. this.history.clear(); // Fire the event after refreshing the chat. this.triggerEvent( 'wpformsAIChatAfterRefresh', { fieldId: this.fieldId } ); }; const refreshCancel = () => { // Fire the event when refresh is canceled. this.triggerEvent( 'wpformsAIChatCancelRefresh', { fieldId: this.fieldId } ); }; // Fire the event before refresh confirmation is opened. this.triggerEvent( 'wpformsAIChatBeforeRefreshConfirm', { fieldId: this.fieldId } ); // Open a confirmation modal. WPFormsAIModal.confirmModal( { title: wpforms_ai_chat_element.confirm.refreshTitle, content: wpforms_ai_chat_element.confirm.refreshMessage, onConfirm: refreshConfirm, onCancel: refreshCancel, } ); } /** * Start loading. * * @since 1.9.1 */ startLoading() { this.loadingState = true; this.sendButton.classList.add( 'wpforms-hidden' ); this.stopButton.classList.remove( 'wpforms-hidden' ); this.input.setAttribute( 'disabled', true ); this.input.setAttribute( 'placeholder', this.modeStrings.waiting ); } /** * Stop loading. * * @since 1.9.1 */ stopLoading() { this.loadingState = false; this.messageList.querySelector( '.wpforms-chat-item-answer-waiting' )?.remove(); this.sendButton.classList.remove( 'wpforms-hidden' ); this.stopButton.classList.add( 'wpforms-hidden' ); this.input.removeAttribute( 'disabled' ); this.input.setAttribute( 'placeholder', this.modeStrings.placeholder ); this.input.focus(); } /** * Keyboard `ArrowUp` key event handler. * * @since 1.9.1 */ arrowUp() { const prev = this.history.prev()?.question; if ( typeof prev !== 'undefined' ) { this.input.value = prev; } } /** * Keyboard `ArrowDown` key event handler. * * @since 1.9.1 */ arrowDown() { const next = this.history.next()?.question; if ( typeof next !== 'undefined' ) { this.input.value = next; } } /** * Get AI API object instance. * * @since 1.9.1 * * @return {Object} The AI API object. */ getAiApi() { if ( this.wpformsAiApi ) { return this.wpformsAiApi; } // Attempt to get the session ID from the element attribute OR the data attribute. // It is necessary to restore the session ID after restoring the chat element. this.sessionId = this.wrapper.getAttribute( 'data-session-id' ) || null; // Create a new AI API object instance. this.wpformsAiApi = window.WPFormsAi.api( this.chatMode, this.sessionId ); return this.wpformsAiApi; } /** * Scroll message list to given edge. * * @since 1.9.1 * * @param {string} edge The edge to scroll to; `top` or `bottom`. */ scrollMessagesTo( edge = 'bottom' ) { if ( edge === 'top' ) { this.messageList.scrollTop = 0; return; } if ( this.messageList.scrollHeight - this.messageList.scrollTop < 22 ) { return; } this.messageList.scrollTop = this.messageList.scrollHeight; } /** * Add a message to the chat. * * @since 1.9.1 * * @param {string} message The message to add. * @param {boolean} isQuestion Whether it is a question. * @param {Object} response The response data, optional. * * @return {HTMLElement} The message element. */ addMessage( message, isQuestion, response = null ) { const { messageList } = this; const element = document.createElement( 'div' ); element.classList.add( 'wpforms-chat-item' ); messageList.appendChild( element ); if ( isQuestion ) { // Add a question. element.innerHTML = message; element.classList.add( 'wpforms-chat-item-question' ); // Add a waiting spinner. const spinnerWrapper = document.createElement( 'div' ), spinner = document.createElement( 'div' ); spinnerWrapper.classList.add( 'wpforms-chat-item-answer-waiting' ); spinner.classList.add( 'wpforms-chat-item-spinner' ); spinner.innerHTML = this.getSpinnerSvg(); spinnerWrapper.appendChild( spinner ); messageList.appendChild( spinnerWrapper ); // Add an empty chat history item. this.history.push( {} ); } else { // Add an answer. const itemContent = document.createElement( 'div' ); itemContent.classList.add( 'wpforms-chat-item-content' ); element.appendChild( itemContent ); // Remove the waiting spinner. messageList.querySelector( '.wpforms-chat-item-answer-waiting' )?.remove(); // Remove the active class from the previous answer. this.messageList.querySelector( '.wpforms-chat-item-answer.active' )?.classList.remove( 'active' ); // Update element classes and attributes. element.classList.add( 'wpforms-chat-item-answer' ); element.classList.add( 'active' ); element.classList.add( 'wpforms-chat-item-typing' ); element.classList.add( 'wpforms-chat-item-' + this.chatMode ); element.setAttribute( 'data-response-id', response?.responseId ?? '' ); // Update the answer in the chat history. this.history.update( { answer: message } ); // Type the message with the typewriter effect. this.typeText( itemContent, message, this.addedAnswer.bind( this ) ); } this.scrollMessagesTo( 'bottom' ); return element; } /** * Add an error to the chat. * * @since 1.9.1 * * @param {string} errorTitle The error title. * @param {string} errorReason The error title. */ addError( errorTitle, errorReason ) { this.addNotice( errorTitle, errorReason ); } /** * Add a warning to the chat. * * @since 1.9.2 * * @param {string} warningTitle The warning title. * @param {string} warningReason The warning reason. */ addWarning( warningTitle, warningReason ) { this.addNotice( warningTitle, warningReason, 'warning' ); } /** * Add a notice to the chat. * * @since 1.9.2 * * @param {string} title The notice title. * @param {string} reason The notice reason. * @param {string} type The notice type. */ addNotice( title, reason, type = 'error' ) { let content = ``; // Bail if loading was stopped. if ( ! this.loadingState ) { return; } if ( title ) { content += `

    ${ title }

    `; } if ( reason ) { content += `${ reason }`; } const chatItem = document.createElement( 'div' ); const itemContent = document.createElement( 'div' ); chatItem.classList.add( 'wpforms-chat-item' ); chatItem.classList.add( 'wpforms-chat-item-' + type ); itemContent.classList.add( 'wpforms-chat-item-content' ); chatItem.appendChild( itemContent ); this.messageList.querySelector( '.wpforms-chat-item-answer-waiting' )?.remove(); this.messageList.appendChild( chatItem ); // Add the error to the chat. // Type the message with the typewriter effect. this.typeText( itemContent, content, () => { this.stopLoading(); } ); } /** * Add an empty results error to the chat. * * @since 1.9.1 */ addEmptyResultsError() { this.addError( this.modeStrings.errors.empty || wpforms_ai_chat_element.errors.empty, this.modeStrings.reasons.empty || wpforms_ai_chat_element.reasons.empty ); } /** * Add a prohibited code warning to the chat. * * @since 1.9.2 */ addProhibitedCodeWarning() { this.addWarning( this.modeStrings.warnings.prohibited_code || wpforms_ai_chat_element.warnings.prohibited_code, this.modeStrings.reasons.prohibited_code || wpforms_ai_chat_element.reasons.prohibited_code ); } /** * Add an answer to the chat. * * @since 1.9.1 * * @param {Object} response The response data to add. */ addAnswer( response ) { // Bail if loading was stopped. if ( ! this.loadingState || ! response ) { return; } // Output processing time to console if available. if ( response.processingData ) { wpf.debug( 'WPFormsAI processing data:', response.processingData ); } // Sanitize response. const sanitizedResponse = this.sanitizeResponse( { ...response } ); if ( this.hasProhibitedCode( response, sanitizedResponse ) ) { this.addProhibitedCodeWarning(); return; } const answerHTML = this.modeHelpers.getAnswer( sanitizedResponse ); if ( ! answerHTML ) { this.addEmptyResultsError(); return; } // Store the session ID from response. this.sessionId = response.sessionId; // Set the session ID to the chat wrapper data attribute. this.wrapper.setAttribute( 'data-session-id', this.sessionId ); // Fire the event before adding the answer to the chat. this.triggerEvent( 'wpformsAIChatBeforeAddAnswer', { chat: this, response: sanitizedResponse } ); // Add the answer to the chat. this.addMessage( answerHTML, false, sanitizedResponse ); this.triggerEvent( 'wpformsAIChatAfterAddAnswer', { fieldId: this.fieldId } ); } /** * Check if the response has a prohibited code. * * @since 1.9.2 * * @param {Object} response The response data. * @param {Array} sanitizedResponse The sanitized response data. * * @return {boolean} Whether the answer has a prohibited code. */ hasProhibitedCode( response, sanitizedResponse ) { if ( typeof this.modeHelpers.hasProhibitedCode === 'function' ) { return this.modeHelpers.hasProhibitedCode( response, sanitizedResponse ); } return false; } /** * Sanitize response. * * @since 1.9.2 * * @param {Object} response The response data to sanitize. * * @return {Object} The sanitized response. */ sanitizeResponse( response ) { if ( typeof this.modeHelpers.sanitizeResponse === 'function' ) { return this.modeHelpers.sanitizeResponse( response ); } return response; } /** * The added answer callback. * * @since 1.9.1 * * @param {HTMLElement} element The answer element. */ addedAnswer( element ) { // Add answer buttons when typing is finished. element.innerHTML += this.getAnswerButtons(); element.parentElement.classList.remove( 'wpforms-chat-item-typing' ); this.stopLoading(); this.initAnswer( element ); // Added answer callback. this.modeHelpers.addedAnswer( element ); // Fire the event when the answer added to the chat. this.triggerEvent( 'wpformsAIChatAddedAnswer', { chat: this, element } ); } /** * Init answer. * * @since 1.9.2 * * @param {HTMLElement} element The answer element. */ initAnswer( element ) { if ( ! element ) { return; } // Prepare answer buttons and init the tooltips. element.querySelectorAll( '.wpforms-help-tooltip' ).forEach( ( icon ) => { let title = icon.getAttribute( 'title' ); if ( ! title ) { title = icon.classList.contains( 'dislike' ) ? wpforms_ai_chat_element.dislike : ''; title = icon.classList.contains( 'refresh' ) ? wpforms_ai_chat_element.refresh : title; icon.setAttribute( 'title', title ); } icon.classList.remove( 'tooltipstered' ); } ); wpf.initTooltips( element ); // Add event listeners. element.addEventListener( 'click', this.setActiveAnswer.bind( this ) ); element.querySelector( '.wpforms-ai-chat-answer-button.dislike' ) ?.addEventListener( 'click', this.clickDislikeButton.bind( this ) ); element.querySelector( '.wpforms-ai-chat-answer-button.refresh' ) ?.addEventListener( 'click', this.clickRefreshButton.bind( this ) ); } /** * Set active answer. * * @since 1.9.2 * * @param {Event} e The event object. */ setActiveAnswer( e ) { let answer = e.target.closest( '.wpforms-chat-item-answer' ); answer = answer || e.target; if ( answer.classList.contains( 'active' ) ) { return; } this.messageList.querySelector( '.wpforms-chat-item-answer.active' )?.classList.remove( 'active' ); answer.classList.add( 'active' ); const responseId = answer.getAttribute( 'data-response-id' ); if ( this.modeHelpers.setActiveAnswer ) { this.modeHelpers.setActiveAnswer( answer ); } // Trigger the event. this.triggerEvent( 'wpformsAIChatSetActiveAnswer', { chat: this, responseId } ); } /** * Get the answer buttons HTML markup. * * @since 1.9.1 * * @return {string} The answer buttons HTML markup. */ getAnswerButtons() { return `
    ${ this.modeHelpers.getAnswerButtonsPre() }
    `; } /** * Type text into an element with the typewriter effect. * * @since 1.9.1 * * @param {HTMLElement} element The element to type into. * @param {string} text The text to type. * @param {Function} finishedCallback The callback function to call when typing is finished. */ typeText( element, text, finishedCallback ) { const chunkSize = 5; const chat = this; let index = 0; let content = ''; /** * Type single character. * * @since 1.9.1 */ function type() { const chunk = text.substring( index, index + chunkSize ); content += chunk; // Remove broken HTML tag from the end of the string. element.innerHTML = content.replace( /<[^>]{0,300}$/g, '' ); index += chunkSize; if ( index < text.length && chat.loadingState ) { // Recursive call to output the next chunk. setTimeout( type, 20 ); } else if ( typeof finishedCallback === 'function' ) { // Call the callback function when typing is finished. finishedCallback( element ); } chat.scrollMessagesTo( 'bottom' ); } type(); } /** * Get the `helpers` object according to the chat mode. * * @since 1.9.1 * * @param {WPFormsAIChatHTMLElement} chat Chat element. * * @return {Object} Choices helpers object. */ getHelpers( chat ) { const helpers = window.WPFormsAi.helpers; return helpers[ chat.chatMode ]( chat ) ?? null; } /** * Reset the message input field. * * @since 1.9.2 */ resetInput() { this.input.value = ''; if ( this.modeHelpers.resetInput ) { this.modeHelpers.resetInput(); } } /** * Escape HTML special characters. * * @since 1.9.1 * * @param {string} html HTML string. * * @return {string} Escaped HTML string. */ htmlSpecialChars( html ) { return html.replace( /[<>]/g, ( x ) => '�' + x.charCodeAt( 0 ) + ';' ); } /** * Decode HTML entities. * * @since 1.9.2 * * @param {string} html Encoded HTML string. * * @return {string} Decoded HTML string. */ decodeHTMLEntities( html ) { const txt = document.createElement( 'textarea' ); txt.innerHTML = html; return txt.value; } /** * Wrapper to trigger a custom event and return the event object. * * @since 1.9.1 * * @param {string} eventName Event name to trigger (custom or native). * @param {Object} args Trigger arguments. * * @return {Event} Event object. */ triggerEvent( eventName, args = {} ) { const event = new CustomEvent( eventName, { detail: args } ); document.dispatchEvent( event ); return event; } /** * Chat history object. * * @since 1.9.1 */ history = { /** * Chat history data. * * @since 1.9.1 * * @type {Array} */ data: [], /** * Chat history pointer. * * @since 1.9.1 * * @type {number} */ pointer: 0, /** * Default item. * * @since 1.9.1 * * @type {Object} */ defaultItem: { question: '', answer: null, }, /** * Get history data by pointer. * * @since 1.9.1 * * @param {number|null} pointer The history pointer. * * @return {Object} The history item. */ get( pointer = null ) { if ( pointer ) { this.pointer = pointer; } if ( this.pointer < 1 ) { this.pointer = 0; } else if ( this.pointer >= this.data.length ) { this.pointer = this.data.length - 1; } return this.data[ this.pointer ] ?? {}; }, /** * Get history data by pointer. * * @since 1.9.1 * * @return {Object} The history item. */ prev() { this.pointer -= 1; return this.get(); }, /** * Get history data by pointer. * * @since 1.9.1 * * @return {Object} The history item. */ next() { this.pointer += 1; return this.get(); }, /** * Push an item to the chat history. * * @since 1.9.1 * * @param {Object} item The item to push. * * @return {void} */ push( item ) { if ( item.answer ) { this.data[ this.data.length - 1 ].answer = item.answer; return; } this.data.push( { ...this.defaultItem, ...item } ); this.pointer = this.data.length - 1; }, /** * Update the last history item. * * @since 1.9.1 * * @param {Object} item The updated history item. * * @return {void} */ update( item ) { const lastKey = this.data.length > 0 ? this.data.length - 1 : 0; const lastItem = this.data[ lastKey ] ?? this.defaultItem; this.pointer = lastKey; this.data[ lastKey ] = { ...lastItem, ...item }; }, /** * Clear the chat history. * * @since 1.9.1 */ clear() { this.data = []; this.pointer = 0; }, }; }