Add massive collection of CSS, JavaScript and theme assets that were previously excluded: **CSS Files (681 total):** - HVAC plugin-specific styles (hvac-*.css): 34 files including dashboard, certificates, registration, mobile nav, accessibility fixes, animations, and welcome popup - Theme framework files (Astra, builder systems, layouts): 200+ files - Plugin compatibility styles (WooCommerce, WPForms, Elementor, Contact Form 7): 150+ files - WordPress core and editor styles: 50+ files - Responsive and RTL language support: 200+ files **JavaScript Files (400+ total):** - HVAC plugin functionality (hvac-*.js): 27 files including menu systems, dashboard enhancements, profile sharing, mobile responsive features, accessibility, and animations - Framework and library files: jQuery plugins, GSAP, AOS, Swiper, Chart.js, Lottie, Isotope - Plugin compatibility scripts: WPForms, WooCommerce, Elementor, Contact Form 7, LifterLMS - WordPress core functionality: customizer, admin, block editor compatibility - Third-party integrations: Stripe, SMTP, analytics, search functionality **Assets:** - Certificate background images and logos - Comprehensive theme styling infrastructure - Mobile-responsive design systems - Cross-browser compatibility assets - Performance-optimized minified versions **Updated .gitignore:** - Fixed asset directory whitelisting patterns to properly include CSS/JS/images - Added proper directory structure recognition (!/assets/css/, !/assets/js/, etc.) - Maintains security by excluding sensitive files while including essential assets This commit provides the complete frontend infrastructure needed for: - Full theme functionality and styling - Plugin feature implementations - Mobile responsiveness and accessibility - Cross-browser compatibility - Performance optimization - Developer workflow support
1247 lines
30 KiB
JavaScript
1247 lines
30 KiB
JavaScript
/* 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 `
|
|
<div class="wpforms-ai-chat">
|
|
<div class="wpforms-ai-chat-message-list">
|
|
${ this.getWelcomeScreen() }
|
|
</div>
|
|
<div class="wpforms-ai-chat-message-input">
|
|
${ this.getMessageInputField() }
|
|
<button type="button" class="wpforms-ai-chat-send"></button>
|
|
<button type="button" class="wpforms-ai-chat-stop wpforms-hidden"></button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 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 `<input type="text" placeholder="${ this.modeStrings.placeholder }">`;
|
|
}
|
|
|
|
/**
|
|
* 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 `
|
|
<div class="wpforms-ai-chat-message-item item-primary">
|
|
<div class="wpforms-ai-chat-welcome-screen">
|
|
<div class="wpforms-ai-chat-header">
|
|
<h3 class="wpforms-ai-chat-header-title">${ this.modeStrings.title }</h3>
|
|
<span class="wpforms-ai-chat-header-description">${ this.modeStrings.description }
|
|
<a href="${ this.modeStrings.learnMoreUrl }" target="_blank" rel="noopener noreferrer">${ this.modeStrings.learnMore }</a>${ this.modeStrings.descrEndDot }
|
|
</span>
|
|
</div>
|
|
${ content }
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 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( `
|
|
<li>
|
|
<i class="${ samplePrompts[ i ].icon }"></i>
|
|
<a href="#">${ samplePrompts[ i ].title }</a>
|
|
</li>
|
|
` );
|
|
}
|
|
|
|
return `
|
|
<ul class="wpforms-ai-chat-welcome-screen-sample-prompts">
|
|
${ li.join( '' ) }
|
|
</ul>
|
|
`;
|
|
}
|
|
|
|
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 `<svg class="wpforms-ai-chat-spinner-dots" width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><style>.spinner_S1WN{animation:spinner_MGfb .8s linear infinite;animation-delay:-.8s; fill: currentColor;}.spinner_Km9P{animation-delay:-.65s}.spinner_JApP{animation-delay:-.5s}@keyframes spinner_MGfb{93.75%,100%{opacity:.2}}</style><circle class="spinner_S1WN" cx="4" cy="12" r="3"/><circle class="spinner_S1WN spinner_Km9P" cx="12" cy="12" r="3"/><circle class="spinner_S1WN spinner_JApP" cx="20" cy="12" r="3"/></svg>`;
|
|
}
|
|
|
|
/**
|
|
* 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 += `<h4>${ title }</h4>`;
|
|
}
|
|
|
|
if ( reason ) {
|
|
content += `<span>${ reason }</span>`;
|
|
}
|
|
|
|
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 `
|
|
<div class="wpforms-ai-chat-answer-buttons">
|
|
${ this.modeHelpers.getAnswerButtonsPre() }
|
|
<div class="wpforms-ai-chat-answer-buttons-response">
|
|
<button type="button" class="wpforms-ai-chat-answer-button dislike wpforms-help-tooltip" data-tooltip-position="top" title="${ wpforms_ai_chat_element.dislike }"></button>
|
|
<button type="button" class="wpforms-ai-chat-answer-button refresh wpforms-help-tooltip" data-tooltip-position="top" title="${ wpforms_ai_chat_element.refresh }">
|
|
<i class="fa fa-trash-o"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
},
|
|
};
|
|
}
|