// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Module to load and render the tools for the AI assist plugin.
*
* @module aiplacement_courseassist/placement
* @copyright 2024 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Templates from 'core/templates';
import Ajax from 'core/ajax';
import 'core/copy_to_clipboard';
import Notification from 'core/notification';
import Selectors from 'aiplacement_courseassist/selectors';
import Policy from 'core_ai/policy';
import AIHelper from 'core_ai/helper';
import DrawerEvents from 'core/drawer_events';
import {subscribe} from 'core/pubsub';
import * as MessageDrawerHelper from 'core_message/message_drawer_helper';
import {getString} from 'core/str';
const AICourseAssist = class {
/**
* The user ID.
* @type {Integer}
*/
userId;
/**
* The context ID.
* @type {Integer}
*/
contextId;
/**
* Constructor.
* @param {Integer} userId The user ID.
* @param {Integer} contextId The context ID.
*/
constructor(userId, contextId) {
this.userId = userId;
this.contextId = contextId;
this.aiDrawerElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER);
this.aiDrawerBodyElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER_BODY);
this.pageElement = document.querySelector(Selectors.ELEMENTS.PAGE);
this.lastAction = '';
this.responses = new Map();
this.registerEventListeners();
}
/**
* Register event listeners.
*/
registerEventListeners() {
document.addEventListener('click', async(e) => {
// Display summarise.
const summariseAction = e.target.closest(Selectors.ACTIONS.SUMMARY);
if (summariseAction) {
e.preventDefault();
this.openAIDrawer();
this.lastAction = 'summarise';
const isPolicyAccepted = await this.isPolicyAccepted();
if (!isPolicyAccepted) {
// Display policy.
this.displayPolicy();
return;
}
this.displayAction(this.lastAction);
}
// Display explain.
const explainAction = e.target.closest(Selectors.ACTIONS.EXPLAIN);
if (explainAction) {
e.preventDefault();
this.openAIDrawer();
this.lastAction = 'explain';
const isPolicyAccepted = await this.isPolicyAccepted();
if (!isPolicyAccepted) {
// Display policy.
this.displayPolicy();
return;
}
this.displayAction(this.lastAction);
}
// Close AI drawer.
const closeAiDrawer = e.target.closest(Selectors.ELEMENTS.AIDRAWER_CLOSE);
if (closeAiDrawer) {
e.preventDefault();
this.closeAIDrawer();
}
});
// Close AI drawer if message drawer is shown.
subscribe(DrawerEvents.DRAWER_SHOWN, () => {
if (this.isAIDrawerOpen()) {
this.closeAIDrawer();
}
});
}
/**
* Register event listeners for the policy.
*/
registerPolicyEventListeners() {
const acceptAction = document.querySelector(Selectors.ACTIONS.ACCEPT);
const declineAction = document.querySelector(Selectors.ACTIONS.DECLINE);
if (acceptAction && this.lastAction.length) {
acceptAction.addEventListener('click', (e) => {
e.preventDefault();
this.acceptPolicy().then(() => {
return this.displayAction(this.lastAction);
}).catch(Notification.exception);
});
}
if (declineAction) {
declineAction.addEventListener('click', (e) => {
e.preventDefault();
this.closeAIDrawer();
});
}
}
/**
* Register event listeners for the error.
*/
registerErrorEventListeners() {
const retryAction = document.querySelector(Selectors.ACTIONS.RETRY);
if (retryAction && this.lastAction.length) {
retryAction.addEventListener('click', (e) => {
e.preventDefault();
this.displayAction(this.lastAction);
});
}
}
/**
* Register event listeners for the responses.
*/
registerResponseEventListeners() {
// Get all regenerate action buttons (one per response in the AI drawer).
const regenerateActions = document.querySelectorAll(Selectors.ACTIONS.REGENERATE);
// Add event listeners for each regenerate action.
regenerateActions.forEach(regenerateAction => {
const responseElement = regenerateAction.closest(Selectors.ELEMENTS.RESPONSE);
if (regenerateAction && responseElement) {
// Get the action that this response is associated with.
const actionPerformed = responseElement.getAttribute('data-action-performed');
regenerateAction.addEventListener('click', (e) => {
e.preventDefault();
// Remove the old response before displaying the new one.
this.removeResponseFromStack(actionPerformed);
this.displayAction(actionPerformed);
});
}
});
}
registerLoadingEventListeners() {
const cancelAction = document.querySelector(Selectors.ACTIONS.CANCEL);
if (cancelAction) {
cancelAction.addEventListener('click', (e) => {
e.preventDefault();
this.setRequestCancelled();
this.toggleAIDrawer();
this.removeResponseFromStack('loading');
// Refresh the response stack to avoid false indication of loading.
const responses = this.getResponseStack();
this.aiDrawerBodyElement.innerHTML = responses;
});
}
}
/**
* Check if the AI drawer is open.
* @return {boolean} True if the AI drawer is open, false otherwise.
*/
isAIDrawerOpen() {
return this.aiDrawerElement.classList.contains('show');
}
/**
* Check if the request is cancelled.
* @return {boolean} True if the request is cancelled, false otherwise.
*/
isRequestCancelled() {
return this.aiDrawerBodyElement.dataset.cancelled === '1';
}
setRequestCancelled() {
this.aiDrawerBodyElement.dataset.cancelled = '1';
}
/**
* Open the AI drawer.
*/
openAIDrawer() {
// Close message drawer if it is shown.
MessageDrawerHelper.hide();
this.aiDrawerElement.classList.add('show');
this.aiDrawerBodyElement.setAttribute('aria-live', 'polite');
if (!this.pageElement.classList.contains('show-drawer-right')) {
this.addPadding();
}
}
/**
* Close the AI drawer.
*/
closeAIDrawer() {
this.aiDrawerElement.classList.remove('show');
this.aiDrawerBodyElement.removeAttribute('aria-live');
if (this.pageElement.classList.contains('show-drawer-right') && this.aiDrawerBodyElement.dataset.removepadding === '1') {
this.removePadding();
}
}
/**
* Toggle the AI drawer.
*/
toggleAIDrawer() {
if (this.isAIDrawerOpen()) {
this.closeAIDrawer();
} else {
this.openAIDrawer();
}
}
/**
* Add padding to the page to make space for the AI drawer.
*/
addPadding() {
this.pageElement.classList.add('show-drawer-right');
this.aiDrawerBodyElement.dataset.removepadding = '1';
}
/**
* Remove padding from the page.
*/
removePadding() {
this.pageElement.classList.remove('show-drawer-right');
this.aiDrawerBodyElement.dataset.removepadding = '0';
}
/**
* Get important params related to the action.
* @param {string} action The action to use.
* @returns {object} The params to use for the action.
*/
async getParamsForAction(action) {
let params = {};
switch (action) {
case 'summarise':
params.method = 'aiplacement_courseassist_summarise_text';
params.heading = await getString('aisummary', 'aiplacement_courseassist');
break;
case 'explain':
params.method = 'aiplacement_courseassist_explain_text';
params.heading = await getString('aiexplain', 'aiplacement_courseassist');
break;
}
return params;
}
/**
* Check if the policy is accepted.
* @return {bool} True if the policy is accepted, false otherwise.
*/
async isPolicyAccepted() {
return await Policy.getPolicyStatus(this.userId);
}
/**
* Accept the policy.
* @return {Promise<Object>}
*/
acceptPolicy() {
return Policy.acceptPolicy();
}
/**
* Check if the AI drawer has already generated content for a particular action.
* @param {string} action The action to check.
* @return {boolean} True if the AI drawer has generated content, false otherwise.
*/
hasGeneratedContent(action) {
return this.responses.has(action);
}
/**
* Display the policy.
*/
displayPolicy() {
Templates.render('core_ai/policyblock', {}).then((html) => {
this.aiDrawerBodyElement.innerHTML = html;
this.registerPolicyEventListeners();
return;
}).catch(Notification.exception);
}
/**
* Display the loading spinner.
*/
displayLoading() {
Templates.render('aiplacement_courseassist/loading', {}).then((html) => {
this.addResponseToStack('loading', html);
const responses = this.getResponseStack();
this.aiDrawerBodyElement.innerHTML = responses;
this.registerLoadingEventListeners();
return;
}).then(() => {
this.removeResponseFromStack('loading');
return;
}).catch(Notification.exception);
}
/**
* Display the action result in the AI drawer.
* @param {string} action The action to display.
*/
async displayAction(action) {
if (this.hasGeneratedContent(action)) {
// Scroll to generated content.
const existingReponse = document.querySelector('[data-action-performed="' + action + '"]');
if (existingReponse) {
this.aiDrawerBodyElement.scrollTop = existingReponse.offsetTop;
}
} else {
// Display loading spinner.
this.displayLoading();
// Clear the drawer to prevent including the previously generated response in the new response prompt.
this.aiDrawerBodyElement.innerHTML = '';
const params = await this.getParamsForAction(action);
const request = {
methodname: params.method,
args: {
contextid: this.contextId,
prompttext: this.getTextContent(),
}
};
try {
const responseObj = await Ajax.call([request])[0];
if (responseObj.error) {
this.displayError();
return;
} else {
if (!this.isRequestCancelled()) {
// Perform replacements on the generated context to ensure it is formatted correctly.
const generatedContent = AIHelper.formatResponse(responseObj.generatedcontent);
this.displayResponse(generatedContent, action);
return;
} else {
this.aiDrawerBodyElement.dataset.cancelled = '0';
}
}
} catch (error) {
window.console.log(error);
this.displayError();
}
}
}
/**
* Add the HTML response to the response stack.
* The stack will be used to display all responses in the AI drawer.
* @param {String} action The action key.
* @param {String} html The HTML to store.
*/
addResponseToStack(action, html) {
this.responses.set(action, html);
}
/**
* Remove a stored response, allowing for a regenerated one.
* @param {String} action The action key.
*/
removeResponseFromStack(action) {
if (this.responses.has(action)) {
this.responses.delete(action);
}
}
/**
* Return a stack of HTML responses.
* @return {String} HTML responses.
*/
getResponseStack() {
let stack = '';
// Reverse to get newest first.
const responses = [...this.responses.values()].reverse();
for (const response of responses) {
stack += response;
}
return stack;
}
/**
* Display the responses.
* @param {String} content The content to display.
* @param {String} action The action used.
*/
async displayResponse(content, action) {
const params = await this.getParamsForAction(action);
const args = {
content: content,
heading: params.heading,
action: action,
};
Templates.render('aiplacement_courseassist/response', args).then((html) => {
this.addResponseToStack(action, html);
const responses = this.getResponseStack();
this.aiDrawerBodyElement.innerHTML = responses;
this.registerResponseEventListeners();
return;
}).catch(Notification.exception);
}
/**
* Display the error.
*/
displayError() {
Templates.render('aiplacement_courseassist/error', {}).then((html) => {
this.addResponseToStack('error', html);
const responses = this.getResponseStack();
this.aiDrawerBodyElement.innerHTML = responses;
this.registerErrorEventListeners();
return;
}).then(() => {
this.removeResponseFromStack('error');
return;
}).catch(Notification.exception);
}
/**
* Get the text content of the main region.
* @return {String} The text content.
*/
getTextContent() {
const mainRegion = document.querySelector(Selectors.ELEMENTS.MAIN_REGION);
return mainRegion.innerText || mainRegion.textContent;
}
};
export default AICourseAssist;