diff --git a/.upgradenotes/MDL-85738-2025081910434216.yml b/.upgradenotes/MDL-85738-2025081910434216.yml new file mode 100644 index 00000000000..fe6732c7804 --- /dev/null +++ b/.upgradenotes/MDL-85738-2025081910434216.yml @@ -0,0 +1,11 @@ +issueNumber: MDL-85738 +notes: + core_ai: + - message: > + - Added `get_enabled_actions_in_course_module` method in public/ai/classes/manager.php to get enabled AI actions in course module. + - Added `is_ai_tools_enabled_in_course` method in public/ai/classes/manager.php to check if AI tools is enabled in course. + - Added `is_action_enabled_in_context` method in public/ai/classes/manager.php to check if an action is enabled in a particular context. + - Added `get_ai_fields_from_course_module` method in public/ai/classes/manager.php to get the AI related fields from the course module. + - Added `is_html_editor_placement_available` method in public/ai/placement/editor/classes/utils.php to check if editor placement is enabled. + - Added `get_actions_available` method in public/ai/placement/editor/classes/utils.php to get available actions for editor placement. + type: improved diff --git a/public/ai/classes/manager.php b/public/ai/classes/manager.php index 8c3f4bfc546..ec1339726c4 100644 --- a/public/ai/classes/manager.php +++ b/public/ai/classes/manager.php @@ -28,6 +28,7 @@ use core\plugininfo\aiprovider as aiproviderplugin; * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class manager { + /** * Create a new AI manager. * @@ -338,6 +339,36 @@ class manager { } } + /** + * Check if an action is enabled in a particular context. + * + * @param \context $context The context to use. + * @param string $actionclass The action class name to check. + * @return bool Return true enabled and allowed. + */ + public function is_action_enabled_in_context(\context $context, string $actionclass): bool { + // Only check if we are in a supported context. + if (in_array($context->contextlevel, [CONTEXT_COURSE, CONTEXT_COURSECAT, CONTEXT_MODULE])) { + // Return false if AI tools is not enabled at the course level. + if (!self::is_ai_tools_enabled_in_course($context)) { + return false; + } + + if ($context->contextlevel == CONTEXT_MODULE) { + // Detect if this is a newly created module (doesn't have any AI settings yet). + $record = self::get_ai_fields_from_course_module($context->instanceid); + if (is_null($record->enabledaiactions)) { + return true; + } + // Check if the action is one of our enabled ones. + $enabledactions = self::get_enabled_actions_in_course_module($record); + return in_array($actionclass, $enabledactions); + } + } + + return true; + } + /** * Check if an action is available. * Action is available if it is enabled for at least one enabled provider. @@ -684,4 +715,69 @@ class manager { } return true; } + + /** + * Get the AI related fields from the course module. + * + * @param int $id The course module to check. + * @return \stdClass Return AI related fields. + */ + public static function get_ai_fields_from_course_module(int $id): \stdClass { + global $DB; + + return $DB->get_record( + table: 'course_modules', + conditions: ['id' => $id], + fields: 'enableaitools, enabledaiactions', + ); + } + + /** + * Get the enabled actions in a course module context. + * + * @param \stdClass $record AI related fields from course module. + * @return array An array of enabled actions in the course module. + */ + public static function get_enabled_actions_in_course_module(\stdClass $record): array { + $enabledaiactions = []; + + if (is_null($record->enableaitools) || $record->enableaitools) { + // Get AI action settings and determine which ones are enabled. + if (!empty($record->enabledaiactions)) { + $enabledaiactions = array_keys( + array_filter((array) json_decode($record->enabledaiactions), function ($value): bool { + return $value == 1; + }) + ); + // Set to classname format. + foreach ($enabledaiactions as $key => $action) { + $enabledaiactions[$key] = "core_ai\\aiactions\\{$action}"; + } + } + } + + return $enabledaiactions; + } + + /** + * Check if AI tools are enabled in the course. + * + * @param \context $context The context to check. + * @return bool True if AI tools are enabled in the course, false otherwise. + */ + public static function is_ai_tools_enabled_in_course(\context $context): bool { + global $DB; + + if (in_array($context->contextlevel, [CONTEXT_COURSE, CONTEXT_COURSECAT])) { + $courseid = $context->instanceid; + } else { + $courseid = $DB->get_field('course_modules', 'course', ['id' => $context->instanceid]); + } + + $enableaitools = $DB->get_field('course', 'enableaitools', ['id' => $courseid]); + if (!is_null($enableaitools) && !$enableaitools) { + return false; + } + return true; + } } diff --git a/public/ai/placement/courseassist/amd/build/placement.min.js b/public/ai/placement/courseassist/amd/build/placement.min.js index 730b0c31318..ee48616ed3b 100644 --- a/public/ai/placement/courseassist/amd/build/placement.min.js +++ b/public/ai/placement/courseassist/amd/build/placement.min.js @@ -1,3 +1,3 @@ -define("aiplacement_courseassist/placement",["exports","core/templates","core/ajax","core/copy_to_clipboard","core/notification","aiplacement_courseassist/selectors","core_ai/policy","core_ai/helper","core/drawer_events","core/pubsub","core_message/message_drawer_helper","core/str","core/local/aria/focuslock","core/pagehelpers"],(function(_exports,_templates,_ajax,_copy_to_clipboard,_notification,_selectors,_policy,_helper,_drawer_events,_pubsub,MessageDrawerHelper,_str,FocusLock,_pagehelpers){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_templates=_interopRequireDefault(_templates),_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification),_selectors=_interopRequireDefault(_selectors),_policy=_interopRequireDefault(_policy),_helper=_interopRequireDefault(_helper),_drawer_events=_interopRequireDefault(_drawer_events),MessageDrawerHelper=_interopRequireWildcard(MessageDrawerHelper),FocusLock=_interopRequireWildcard(FocusLock);var _default=class{constructor(userId,contextId){_defineProperty(this,"userId",void 0),_defineProperty(this,"contextId",void 0),this.userId=userId,this.contextId=contextId,this.aiDrawerElement=document.querySelector(_selectors.default.ELEMENTS.AIDRAWER),this.aiDrawerBodyElement=document.querySelector(_selectors.default.ELEMENTS.AIDRAWER_BODY),this.pageElement=document.querySelector(_selectors.default.ELEMENTS.PAGE),this.jumpToElement=document.querySelector(_selectors.default.ELEMENTS.JUMPTO),this.actionElement=document.querySelector(_selectors.default.ELEMENTS.ACTION),this.aiDrawerCloseElement=this.aiDrawerElement.querySelector(_selectors.default.ELEMENTS.AIDRAWER_CLOSE),this.lastAction="",this.responses=new Map,this.isDrawerFocusLocked=!1,this.registerEventListeners()}registerEventListeners(){document.addEventListener("click",(async e=>{if(e.target.closest(_selectors.default.ACTIONS.SUMMARY)){e.preventDefault(),this.openAIDrawer(),this.lastAction="summarise",this.actionElement.focus();if(!await this.isPolicyAccepted())return void this.displayPolicy();this.displayAction(this.lastAction)}if(e.target.closest(_selectors.default.ACTIONS.EXPLAIN)){e.preventDefault(),this.openAIDrawer(),this.lastAction="explain",this.actionElement.focus();if(!await this.isPolicyAccepted())return void this.displayPolicy();this.displayAction(this.lastAction)}e.target.closest(_selectors.default.ELEMENTS.AIDRAWER_CLOSE)&&(e.preventDefault(),this.closeAIDrawer())})),document.addEventListener("keydown",(e=>{this.isAIDrawerOpen()&&"Escape"===e.key&&this.closeAIDrawer()})),(0,_pubsub.subscribe)(_drawer_events.default.DRAWER_SHOWN,(()=>{this.isAIDrawerOpen()&&this.closeAIDrawer()})),this.jumpToElement.addEventListener("focus",(()=>{this.aiDrawerCloseElement.focus()})),this.aiDrawerElement.addEventListener("focus",(()=>{this.actionElement.focus()})),this.actionElement.addEventListener("blur",(()=>{this.actionElement.classList.remove("active")}))}registerPolicyEventListeners(){const acceptAction=document.querySelector(_selectors.default.ACTIONS.ACCEPT),declineAction=document.querySelector(_selectors.default.ACTIONS.DECLINE);acceptAction&&this.lastAction.length&&acceptAction.addEventListener("click",(e=>{e.preventDefault(),this.acceptPolicy().then((()=>this.displayAction(this.lastAction))).catch(_notification.default.exception)})),declineAction&&declineAction.addEventListener("click",(e=>{e.preventDefault(),this.closeAIDrawer()}))}registerErrorEventListeners(){const retryAction=document.querySelector(_selectors.default.ACTIONS.RETRY);retryAction&&this.lastAction.length&&retryAction.addEventListener("click",(e=>{e.preventDefault(),this.displayAction(this.lastAction)}))}registerResponseEventListeners(){document.querySelectorAll(_selectors.default.ACTIONS.REGENERATE).forEach((regenerateAction=>{const responseElement=regenerateAction.closest(_selectors.default.ELEMENTS.RESPONSE);if(regenerateAction&&responseElement){const actionPerformed=responseElement.getAttribute("data-action-performed");regenerateAction.addEventListener("click",(e=>{e.preventDefault(),this.removeResponseFromStack(actionPerformed),this.displayAction(actionPerformed)}))}}))}registerLoadingEventListeners(){const cancelAction=document.querySelector(_selectors.default.ACTIONS.CANCEL);cancelAction&&cancelAction.addEventListener("click",(e=>{e.preventDefault(),this.setRequestCancelled(),this.toggleAIDrawer(),this.removeResponseFromStack("loading");const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses}))}isAIDrawerOpen(){return this.aiDrawerElement.classList.contains("show")}isRequestCancelled(){return"1"===this.aiDrawerBodyElement.dataset.cancelled}setRequestCancelled(){this.aiDrawerBodyElement.dataset.cancelled="1"}openAIDrawer(){MessageDrawerHelper.hide(),this.aiDrawerElement.classList.add("show"),this.aiDrawerElement.setAttribute("tabindex",0),this.aiDrawerBodyElement.setAttribute("aria-live","polite"),this.pageElement.classList.contains("show-drawer-right")||this.addPadding(),this.jumpToElement.setAttribute("tabindex",0),this.jumpToElement.focus(),(0,_pagehelpers.isSmall)()&&(FocusLock.trapFocus(this.aiDrawerElement),this.aiDrawerElement.setAttribute("aria-modal","true"),this.aiDrawerElement.setAttribute("role","dialog"),this.isDrawerFocusLocked=!0)}closeAIDrawer(){this.isDrawerFocusLocked&&(FocusLock.untrapFocus(),this.aiDrawerElement.removeAttribute("aria-modal"),this.aiDrawerElement.setAttribute("role","region")),this.aiDrawerElement.classList.remove("show"),this.aiDrawerElement.setAttribute("tabindex",-1),this.aiDrawerBodyElement.removeAttribute("aria-live"),this.pageElement.classList.contains("show-drawer-right")&&"1"===this.aiDrawerBodyElement.dataset.removepadding&&this.removePadding(),this.jumpToElement.setAttribute("tabindex",-1),this.actionElement.classList.add("active"),this.actionElement.focus()}toggleAIDrawer(){this.isAIDrawerOpen()?this.closeAIDrawer():this.openAIDrawer()}addPadding(){this.pageElement.classList.add("show-drawer-right"),this.aiDrawerBodyElement.dataset.removepadding="1"}removePadding(){this.pageElement.classList.remove("show-drawer-right"),this.aiDrawerBodyElement.dataset.removepadding="0"}async getParamsForAction(action){let params={};switch(action){case"summarise":params.method="aiplacement_courseassist_summarise_text",params.heading=await(0,_str.getString)("aisummary","aiplacement_courseassist");break;case"explain":params.method="aiplacement_courseassist_explain_text",params.heading=await(0,_str.getString)("aiexplain","aiplacement_courseassist")}return params}async isPolicyAccepted(){return await _policy.default.getPolicyStatus(this.userId)}acceptPolicy(){return _policy.default.acceptPolicy()}hasGeneratedContent(action){return this.responses.has(action)}displayPolicy(){_templates.default.render("core_ai/policyblock",{}).then((html=>{this.aiDrawerBodyElement.innerHTML=html,this.registerPolicyEventListeners()})).catch(_notification.default.exception)}displayLoading(){_templates.default.render("aiplacement_courseassist/loading",{}).then((html=>{this.addResponseToStack("loading",html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerLoadingEventListeners()})).then((()=>{this.removeResponseFromStack("loading")})).catch(_notification.default.exception)}async displayAction(action){if(this.hasGeneratedContent(action)){const existingReponse=document.querySelector('[data-action-performed="'+action+'"]');existingReponse&&(this.aiDrawerBodyElement.scrollTop=existingReponse.offsetTop)}else{this.displayLoading(),this.aiDrawerBodyElement.innerHTML="";const request={methodname:(await this.getParamsForAction(action)).method,args:{contextid:this.contextId,prompttext:this.getTextContent()}};try{const responseObj=await _ajax.default.call([request])[0];if(responseObj.error)return void this.displayError(responseObj.error,responseObj.errormessage);if(!this.isRequestCancelled()){const generatedContent=_helper.default.formatResponse(responseObj.generatedcontent);return void this.displayResponse(generatedContent,action)}this.aiDrawerBodyElement.dataset.cancelled="0"}catch(error){window.console.log(error),this.displayError()}}}addResponseToStack(action,html){this.responses.set(action,html)}removeResponseFromStack(action){this.responses.has(action)&&this.responses.delete(action)}getResponseStack(){let stack="";const responses=[...this.responses.values()].reverse();for(const response of responses)stack+=response;return stack}async displayResponse(content,action){const args={content:content,heading:(await this.getParamsForAction(action)).heading,action:action};_templates.default.render("aiplacement_courseassist/response",args).then((html=>{this.addResponseToStack(action,html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerResponseEventListeners()})).catch(_notification.default.exception)}async displayError(){let error=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",errorMessage=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";error||(error=await(0,_str.getString)("error:defaultname","core_ai"),errorMessage=await(0,_str.getString)("error:defaultmessage","core_ai")),_templates.default.render("aiplacement_courseassist/error",{error:error,errorMessage:errorMessage}).then((html=>{this.addResponseToStack("error",html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerErrorEventListeners()})).then((()=>{this.removeResponseFromStack("error")})).catch(_notification.default.exception)}getTextContent(){const mainRegion=document.querySelector(_selectors.default.ELEMENTS.MAIN_REGION);return mainRegion.innerText||mainRegion.textContent}};return _exports.default=_default,_exports.default})); +define("aiplacement_courseassist/placement",["exports","core/templates","core/ajax","core/copy_to_clipboard","core/notification","aiplacement_courseassist/selectors","core_ai/policy","core_ai/helper","core/drawer_events","core/pubsub","core_message/message_drawer_helper","core/str","core/local/aria/focuslock","core/pagehelpers"],(function(_exports,_templates,_ajax,_copy_to_clipboard,_notification,_selectors,_policy,_helper,_drawer_events,_pubsub,MessageDrawerHelper,_str,FocusLock,_pagehelpers){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireWildcard(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}return newObj.default=obj,cache&&cache.set(obj,newObj),newObj}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_templates=_interopRequireDefault(_templates),_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification),_selectors=_interopRequireDefault(_selectors),_policy=_interopRequireDefault(_policy),_helper=_interopRequireDefault(_helper),_drawer_events=_interopRequireDefault(_drawer_events),MessageDrawerHelper=_interopRequireWildcard(MessageDrawerHelper),FocusLock=_interopRequireWildcard(FocusLock);var _default=class{constructor(userId,contextId){_defineProperty(this,"userId",void 0),_defineProperty(this,"contextId",void 0),this.userId=userId,this.contextId=contextId,this.aiDrawerElement=document.querySelector(_selectors.default.ELEMENTS.AIDRAWER),this.aiDrawerBodyElement=document.querySelector(_selectors.default.ELEMENTS.AIDRAWER_BODY),this.pageElement=document.querySelector(_selectors.default.ELEMENTS.PAGE),this.jumpToElement=document.querySelector(_selectors.default.ELEMENTS.JUMPTO),this.actionElement=document.querySelector(_selectors.default.ELEMENTS.ACTION),this.aiDrawerCloseElement=this.aiDrawerElement.querySelector(_selectors.default.ELEMENTS.AIDRAWER_CLOSE),this.lastAction="",this.responses=new Map,this.isDrawerFocusLocked=!1,this.registerEventListeners()}registerEventListeners(){document.addEventListener("click",(async e=>{if(e.target.closest(_selectors.default.ACTIONS.SUMMARY)){e.preventDefault(),this.openAIDrawer(),this.lastAction="summarise_text",this.actionElement.focus();if(!await this.isPolicyAccepted())return void this.displayPolicy();this.displayAction(this.lastAction)}if(e.target.closest(_selectors.default.ACTIONS.EXPLAIN)){e.preventDefault(),this.openAIDrawer(),this.lastAction="explain_text",this.actionElement.focus();if(!await this.isPolicyAccepted())return void this.displayPolicy();this.displayAction(this.lastAction)}e.target.closest(_selectors.default.ELEMENTS.AIDRAWER_CLOSE)&&(e.preventDefault(),this.closeAIDrawer())})),document.addEventListener("keydown",(e=>{this.isAIDrawerOpen()&&"Escape"===e.key&&this.closeAIDrawer()})),(0,_pubsub.subscribe)(_drawer_events.default.DRAWER_SHOWN,(()=>{this.isAIDrawerOpen()&&this.closeAIDrawer()})),this.jumpToElement&&this.jumpToElement.addEventListener("focus",(()=>{this.aiDrawerCloseElement.focus()})),this.aiDrawerElement.addEventListener("focus",(()=>{this.actionElement.focus()})),this.actionElement&&this.actionElement.addEventListener("blur",(()=>{this.actionElement.classList.remove("active")}))}registerPolicyEventListeners(){const acceptAction=document.querySelector(_selectors.default.ACTIONS.ACCEPT),declineAction=document.querySelector(_selectors.default.ACTIONS.DECLINE);acceptAction&&this.lastAction.length&&acceptAction.addEventListener("click",(e=>{e.preventDefault(),this.acceptPolicy().then((()=>this.displayAction(this.lastAction))).catch(_notification.default.exception)})),declineAction&&declineAction.addEventListener("click",(e=>{e.preventDefault(),this.closeAIDrawer()}))}registerErrorEventListeners(){const retryAction=document.querySelector(_selectors.default.ACTIONS.RETRY);retryAction&&this.lastAction.length&&retryAction.addEventListener("click",(e=>{e.preventDefault(),this.displayAction(this.lastAction)}))}registerResponseEventListeners(){document.querySelectorAll(_selectors.default.ACTIONS.REGENERATE).forEach((regenerateAction=>{const responseElement=regenerateAction.closest(_selectors.default.ELEMENTS.RESPONSE);if(regenerateAction&&responseElement){const actionPerformed=responseElement.getAttribute("data-action-performed");regenerateAction.addEventListener("click",(e=>{e.preventDefault(),this.removeResponseFromStack(actionPerformed),this.displayAction(actionPerformed)}))}}))}registerLoadingEventListeners(){const cancelAction=document.querySelector(_selectors.default.ACTIONS.CANCEL);cancelAction&&cancelAction.addEventListener("click",(e=>{e.preventDefault(),this.setRequestCancelled(),this.toggleAIDrawer(),this.removeResponseFromStack("loading");const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses}))}isAIDrawerOpen(){return this.aiDrawerElement.classList.contains("show")}isRequestCancelled(){return"1"===this.aiDrawerBodyElement.dataset.cancelled}setRequestCancelled(){this.aiDrawerBodyElement.dataset.cancelled="1"}openAIDrawer(){MessageDrawerHelper.hide(),this.aiDrawerElement.classList.add("show"),this.aiDrawerElement.setAttribute("tabindex",0),this.aiDrawerBodyElement.setAttribute("aria-live","polite"),this.pageElement.classList.contains("show-drawer-right")||this.addPadding(),this.jumpToElement.setAttribute("tabindex",0),this.jumpToElement.focus(),(0,_pagehelpers.isSmall)()&&(FocusLock.trapFocus(this.aiDrawerElement),this.aiDrawerElement.setAttribute("aria-modal","true"),this.aiDrawerElement.setAttribute("role","dialog"),this.isDrawerFocusLocked=!0)}closeAIDrawer(){this.isDrawerFocusLocked&&(FocusLock.untrapFocus(),this.aiDrawerElement.removeAttribute("aria-modal"),this.aiDrawerElement.setAttribute("role","region")),this.aiDrawerElement.classList.remove("show"),this.aiDrawerElement.setAttribute("tabindex",-1),this.aiDrawerBodyElement.removeAttribute("aria-live"),this.pageElement.classList.contains("show-drawer-right")&&"1"===this.aiDrawerBodyElement.dataset.removepadding&&this.removePadding(),this.jumpToElement.setAttribute("tabindex",-1),this.actionElement.classList.add("active"),this.actionElement.focus()}toggleAIDrawer(){this.isAIDrawerOpen()?this.closeAIDrawer():this.openAIDrawer()}addPadding(){this.pageElement.classList.add("show-drawer-right"),this.aiDrawerBodyElement.dataset.removepadding="1"}removePadding(){this.pageElement.classList.remove("show-drawer-right"),this.aiDrawerBodyElement.dataset.removepadding="0"}async getParamsForAction(action){let params={};switch(action){case"summarise_text":params.method="aiplacement_courseassist_summarise_text",params.heading=await(0,_str.getString)("aisummary","aiplacement_courseassist");break;case"explain_text":params.method="aiplacement_courseassist_explain_text",params.heading=await(0,_str.getString)("aiexplain","aiplacement_courseassist")}return params}async isPolicyAccepted(){return await _policy.default.getPolicyStatus(this.userId)}acceptPolicy(){return _policy.default.acceptPolicy()}hasGeneratedContent(action){return this.responses.has(action)}displayPolicy(){_templates.default.render("core_ai/policyblock",{}).then((html=>{this.aiDrawerBodyElement.innerHTML=html,this.registerPolicyEventListeners()})).catch(_notification.default.exception)}displayLoading(){_templates.default.render("aiplacement_courseassist/loading",{}).then((html=>{this.addResponseToStack("loading",html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerLoadingEventListeners()})).then((()=>{this.removeResponseFromStack("loading")})).catch(_notification.default.exception)}async displayAction(action){if(this.hasGeneratedContent(action)){const existingReponse=document.querySelector('[data-action-performed="'+action+'"]');existingReponse&&(this.aiDrawerBodyElement.scrollTop=existingReponse.offsetTop)}else{this.displayLoading(),this.aiDrawerBodyElement.innerHTML="";const request={methodname:(await this.getParamsForAction(action)).method,args:{contextid:this.contextId,prompttext:this.getTextContent()}};try{const responseObj=await _ajax.default.call([request])[0];if(responseObj.error)return void this.displayError(responseObj.error,responseObj.errormessage);if(!this.isRequestCancelled()){const generatedContent=_helper.default.formatResponse(responseObj.generatedcontent);return void this.displayResponse(generatedContent,action)}this.aiDrawerBodyElement.dataset.cancelled="0"}catch(error){window.console.log(error),this.displayError()}}}addResponseToStack(action,html){this.responses.set(action,html)}removeResponseFromStack(action){this.responses.has(action)&&this.responses.delete(action)}getResponseStack(){let stack="";const responses=[...this.responses.values()].reverse();for(const response of responses)stack+=response;return stack}async displayResponse(content,action){const args={content:content,heading:(await this.getParamsForAction(action)).heading,action:action};_templates.default.render("aiplacement_courseassist/response",args).then((html=>{this.addResponseToStack(action,html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerResponseEventListeners()})).catch(_notification.default.exception)}async displayError(){let error=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",errorMessage=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"";error||(error=await(0,_str.getString)("error:defaultname","core_ai"),errorMessage=await(0,_str.getString)("error:defaultmessage","core_ai")),_templates.default.render("aiplacement_courseassist/error",{error:error,errorMessage:errorMessage}).then((html=>{this.addResponseToStack("error",html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerErrorEventListeners()})).then((()=>{this.removeResponseFromStack("error")})).catch(_notification.default.exception)}getTextContent(){const mainRegion=document.querySelector(_selectors.default.ELEMENTS.MAIN_REGION);return mainRegion.innerText||mainRegion.textContent}};return _exports.default=_default,_exports.default})); //# sourceMappingURL=placement.min.js.map \ No newline at end of file diff --git a/public/ai/placement/courseassist/amd/build/placement.min.js.map b/public/ai/placement/courseassist/amd/build/placement.min.js.map index 849e61e7515..a0dfdeee864 100644 --- a/public/ai/placement/courseassist/amd/build/placement.min.js.map +++ b/public/ai/placement/courseassist/amd/build/placement.min.js.map @@ -1 +1 @@ -{"version":3,"file":"placement.min.js","sources":["../src/placement.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Module to load and render the tools for the AI assist plugin.\n *\n * @module aiplacement_courseassist/placement\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Templates from 'core/templates';\nimport Ajax from 'core/ajax';\nimport 'core/copy_to_clipboard';\nimport Notification from 'core/notification';\nimport Selectors from 'aiplacement_courseassist/selectors';\nimport Policy from 'core_ai/policy';\nimport AIHelper from 'core_ai/helper';\nimport DrawerEvents from 'core/drawer_events';\nimport {subscribe} from 'core/pubsub';\nimport * as MessageDrawerHelper from 'core_message/message_drawer_helper';\nimport {getString} from 'core/str';\nimport * as FocusLock from 'core/local/aria/focuslock';\nimport {isSmall} from \"core/pagehelpers\";\n\nconst AICourseAssist = class {\n\n /**\n * The user ID.\n * @type {Integer}\n */\n userId;\n /**\n * The context ID.\n * @type {Integer}\n */\n contextId;\n\n /**\n * Constructor.\n * @param {Integer} userId The user ID.\n * @param {Integer} contextId The context ID.\n */\n constructor(userId, contextId) {\n this.userId = userId;\n this.contextId = contextId;\n\n this.aiDrawerElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER);\n this.aiDrawerBodyElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER_BODY);\n this.pageElement = document.querySelector(Selectors.ELEMENTS.PAGE);\n this.jumpToElement = document.querySelector(Selectors.ELEMENTS.JUMPTO);\n this.actionElement = document.querySelector(Selectors.ELEMENTS.ACTION);\n this.aiDrawerCloseElement = this.aiDrawerElement.querySelector(Selectors.ELEMENTS.AIDRAWER_CLOSE);\n this.lastAction = '';\n this.responses = new Map();\n this.isDrawerFocusLocked = false;\n\n this.registerEventListeners();\n }\n\n /**\n * Register event listeners.\n */\n registerEventListeners() {\n document.addEventListener('click', async(e) => {\n // Display summarise.\n const summariseAction = e.target.closest(Selectors.ACTIONS.SUMMARY);\n if (summariseAction) {\n e.preventDefault();\n this.openAIDrawer();\n this.lastAction = 'summarise';\n this.actionElement.focus();\n const isPolicyAccepted = await this.isPolicyAccepted();\n if (!isPolicyAccepted) {\n // Display policy.\n this.displayPolicy();\n return;\n }\n this.displayAction(this.lastAction);\n }\n // Display explain.\n const explainAction = e.target.closest(Selectors.ACTIONS.EXPLAIN);\n if (explainAction) {\n e.preventDefault();\n this.openAIDrawer();\n this.lastAction = 'explain';\n this.actionElement.focus();\n const isPolicyAccepted = await this.isPolicyAccepted();\n if (!isPolicyAccepted) {\n // Display policy.\n this.displayPolicy();\n return;\n }\n this.displayAction(this.lastAction);\n }\n // Close AI drawer.\n const closeAiDrawer = e.target.closest(Selectors.ELEMENTS.AIDRAWER_CLOSE);\n if (closeAiDrawer) {\n e.preventDefault();\n this.closeAIDrawer();\n }\n });\n\n document.addEventListener('keydown', e => {\n if (this.isAIDrawerOpen() && e.key === 'Escape') {\n this.closeAIDrawer();\n }\n });\n\n // Close AI drawer if message drawer is shown.\n subscribe(DrawerEvents.DRAWER_SHOWN, () => {\n if (this.isAIDrawerOpen()) {\n this.closeAIDrawer();\n }\n });\n\n // Focus on the AI drawer's close button when the jump-to element is focused.\n this.jumpToElement.addEventListener('focus', () => {\n this.aiDrawerCloseElement.focus();\n });\n\n // Focus on the action element when the AI drawer container receives focus.\n this.aiDrawerElement.addEventListener('focus', () => {\n this.actionElement.focus();\n });\n\n // Remove active from the action element when it loses focus.\n this.actionElement.addEventListener('blur', () => {\n this.actionElement.classList.remove('active');\n });\n }\n\n /**\n * Register event listeners for the policy.\n */\n registerPolicyEventListeners() {\n const acceptAction = document.querySelector(Selectors.ACTIONS.ACCEPT);\n const declineAction = document.querySelector(Selectors.ACTIONS.DECLINE);\n if (acceptAction && this.lastAction.length) {\n acceptAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.acceptPolicy().then(() => {\n return this.displayAction(this.lastAction);\n }).catch(Notification.exception);\n });\n }\n if (declineAction) {\n declineAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.closeAIDrawer();\n });\n }\n }\n\n /**\n * Register event listeners for the error.\n */\n registerErrorEventListeners() {\n const retryAction = document.querySelector(Selectors.ACTIONS.RETRY);\n if (retryAction && this.lastAction.length) {\n retryAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.displayAction(this.lastAction);\n });\n }\n }\n\n /**\n * Register event listeners for the responses.\n */\n registerResponseEventListeners() {\n // Get all regenerate action buttons (one per response in the AI drawer).\n const regenerateActions = document.querySelectorAll(Selectors.ACTIONS.REGENERATE);\n // Add event listeners for each regenerate action.\n regenerateActions.forEach(regenerateAction => {\n const responseElement = regenerateAction.closest(Selectors.ELEMENTS.RESPONSE);\n if (regenerateAction && responseElement) {\n // Get the action that this response is associated with.\n const actionPerformed = responseElement.getAttribute('data-action-performed');\n regenerateAction.addEventListener('click', (e) => {\n e.preventDefault();\n // Remove the old response before displaying the new one.\n this.removeResponseFromStack(actionPerformed);\n this.displayAction(actionPerformed);\n });\n }\n });\n }\n\n registerLoadingEventListeners() {\n const cancelAction = document.querySelector(Selectors.ACTIONS.CANCEL);\n if (cancelAction) {\n cancelAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.setRequestCancelled();\n this.toggleAIDrawer();\n this.removeResponseFromStack('loading');\n // Refresh the response stack to avoid false indication of loading.\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n });\n }\n }\n\n /**\n * Check if the AI drawer is open.\n * @return {boolean} True if the AI drawer is open, false otherwise.\n */\n isAIDrawerOpen() {\n return this.aiDrawerElement.classList.contains('show');\n }\n\n /**\n * Check if the request is cancelled.\n * @return {boolean} True if the request is cancelled, false otherwise.\n */\n isRequestCancelled() {\n return this.aiDrawerBodyElement.dataset.cancelled === '1';\n }\n\n setRequestCancelled() {\n this.aiDrawerBodyElement.dataset.cancelled = '1';\n }\n\n /**\n * Open the AI drawer.\n */\n openAIDrawer() {\n // Close message drawer if it is shown.\n MessageDrawerHelper.hide();\n this.aiDrawerElement.classList.add('show');\n this.aiDrawerElement.setAttribute('tabindex', 0);\n this.aiDrawerBodyElement.setAttribute('aria-live', 'polite');\n if (!this.pageElement.classList.contains('show-drawer-right')) {\n this.addPadding();\n }\n this.jumpToElement.setAttribute('tabindex', 0);\n this.jumpToElement.focus();\n\n // If the AI drawer is opened on a small screen, we need to trap the focus tab within the AI drawer.\n if (isSmall()) {\n FocusLock.trapFocus(this.aiDrawerElement);\n this.aiDrawerElement.setAttribute('aria-modal', 'true');\n this.aiDrawerElement.setAttribute('role', 'dialog');\n this.isDrawerFocusLocked = true;\n }\n }\n\n /**\n * Close the AI drawer.\n */\n closeAIDrawer() {\n // Untrap focus if it was locked.\n if (this.isDrawerFocusLocked) {\n FocusLock.untrapFocus();\n this.aiDrawerElement.removeAttribute('aria-modal');\n this.aiDrawerElement.setAttribute('role', 'region');\n }\n\n this.aiDrawerElement.classList.remove('show');\n this.aiDrawerElement.setAttribute('tabindex', -1);\n this.aiDrawerBodyElement.removeAttribute('aria-live');\n if (this.pageElement.classList.contains('show-drawer-right') && this.aiDrawerBodyElement.dataset.removepadding === '1') {\n this.removePadding();\n }\n this.jumpToElement.setAttribute('tabindex', -1);\n\n // We can enforce a focus-visible state on the focus element using element.focus({focusVisible: true}).\n // Unfortunately, this feature isn't supported in all browsers, only Firefox provides support for it.\n // Therefore, we will apply the active class to the action element and set focus on it.\n // This action will make the action element appear focused.\n // When the action element loses focus,\n // we will remove the active class at {@see registerEventListeners()}\n this.actionElement.classList.add('active');\n this.actionElement.focus();\n }\n\n /**\n * Toggle the AI drawer.\n */\n toggleAIDrawer() {\n if (this.isAIDrawerOpen()) {\n this.closeAIDrawer();\n } else {\n this.openAIDrawer();\n }\n }\n\n /**\n * Add padding to the page to make space for the AI drawer.\n */\n addPadding() {\n this.pageElement.classList.add('show-drawer-right');\n this.aiDrawerBodyElement.dataset.removepadding = '1';\n }\n\n /**\n * Remove padding from the page.\n */\n removePadding() {\n this.pageElement.classList.remove('show-drawer-right');\n this.aiDrawerBodyElement.dataset.removepadding = '0';\n }\n\n /**\n * Get important params related to the action.\n * @param {string} action The action to use.\n * @returns {object} The params to use for the action.\n */\n async getParamsForAction(action) {\n let params = {};\n\n switch (action) {\n case 'summarise':\n params.method = 'aiplacement_courseassist_summarise_text';\n params.heading = await getString('aisummary', 'aiplacement_courseassist');\n break;\n\n case 'explain':\n params.method = 'aiplacement_courseassist_explain_text';\n params.heading = await getString('aiexplain', 'aiplacement_courseassist');\n break;\n }\n\n return params;\n }\n\n /**\n * Check if the policy is accepted.\n * @return {bool} True if the policy is accepted, false otherwise.\n */\n async isPolicyAccepted() {\n return await Policy.getPolicyStatus(this.userId);\n }\n\n /**\n * Accept the policy.\n * @return {Promise}\n */\n acceptPolicy() {\n return Policy.acceptPolicy();\n }\n\n /**\n * Check if the AI drawer has already generated content for a particular action.\n * @param {string} action The action to check.\n * @return {boolean} True if the AI drawer has generated content, false otherwise.\n */\n hasGeneratedContent(action) {\n return this.responses.has(action);\n }\n\n /**\n * Display the policy.\n */\n displayPolicy() {\n Templates.render('core_ai/policyblock', {}).then((html) => {\n this.aiDrawerBodyElement.innerHTML = html;\n this.registerPolicyEventListeners();\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the loading spinner.\n */\n displayLoading() {\n Templates.render('aiplacement_courseassist/loading', {}).then((html) => {\n this.addResponseToStack('loading', html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerLoadingEventListeners();\n return;\n }).then(() => {\n this.removeResponseFromStack('loading');\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the action result in the AI drawer.\n * @param {string} action The action to display.\n */\n async displayAction(action) {\n if (this.hasGeneratedContent(action)) {\n // Scroll to generated content.\n const existingReponse = document.querySelector('[data-action-performed=\"' + action + '\"]');\n if (existingReponse) {\n this.aiDrawerBodyElement.scrollTop = existingReponse.offsetTop;\n }\n } else {\n // Display loading spinner.\n this.displayLoading();\n // Clear the drawer to prevent including the previously generated response in the new response prompt.\n this.aiDrawerBodyElement.innerHTML = '';\n const params = await this.getParamsForAction(action);\n const request = {\n methodname: params.method,\n args: {\n contextid: this.contextId,\n prompttext: this.getTextContent(),\n }\n };\n try {\n const responseObj = await Ajax.call([request])[0];\n if (responseObj.error) {\n this.displayError(responseObj.error, responseObj.errormessage);\n return;\n } else {\n if (!this.isRequestCancelled()) {\n // Perform replacements on the generated context to ensure it is formatted correctly.\n const generatedContent = AIHelper.formatResponse(responseObj.generatedcontent);\n this.displayResponse(generatedContent, action);\n return;\n } else {\n this.aiDrawerBodyElement.dataset.cancelled = '0';\n }\n }\n } catch (error) {\n window.console.log(error);\n this.displayError();\n }\n }\n }\n\n /**\n * Add the HTML response to the response stack.\n * The stack will be used to display all responses in the AI drawer.\n * @param {String} action The action key.\n * @param {String} html The HTML to store.\n */\n addResponseToStack(action, html) {\n this.responses.set(action, html);\n }\n\n /**\n * Remove a stored response, allowing for a regenerated one.\n * @param {String} action The action key.\n */\n removeResponseFromStack(action) {\n if (this.responses.has(action)) {\n this.responses.delete(action);\n }\n }\n\n /**\n * Return a stack of HTML responses.\n * @return {String} HTML responses.\n */\n getResponseStack() {\n let stack = '';\n // Reverse to get newest first.\n const responses = [...this.responses.values()].reverse();\n for (const response of responses) {\n stack += response;\n }\n return stack;\n }\n\n /**\n * Display the responses.\n * @param {String} content The content to display.\n * @param {String} action The action used.\n */\n async displayResponse(content, action) {\n const params = await this.getParamsForAction(action);\n const args = {\n content: content,\n heading: params.heading,\n action: action,\n };\n Templates.render('aiplacement_courseassist/response', args).then((html) => {\n this.addResponseToStack(action, html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerResponseEventListeners();\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the error.\n *\n * @param {String} error The error name to display.\n * @param {String} errorMessage The error message to display.\n */\n async displayError(error = '', errorMessage = '') {\n if (!error) {\n // Get the default error message.\n error = await getString('error:defaultname', 'core_ai');\n errorMessage = await getString('error:defaultmessage', 'core_ai');\n }\n Templates.render('aiplacement_courseassist/error', {'error': error, 'errorMessage': errorMessage}).then((html) => {\n this.addResponseToStack('error', html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerErrorEventListeners();\n return;\n }).then(() => {\n this.removeResponseFromStack('error');\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Get the text content of the main region.\n * @return {String} The text content.\n */\n getTextContent() {\n const mainRegion = document.querySelector(Selectors.ELEMENTS.MAIN_REGION);\n return mainRegion.innerText || mainRegion.textContent;\n }\n};\n\nexport default AICourseAssist;\n"],"names":["constructor","userId","contextId","aiDrawerElement","document","querySelector","Selectors","ELEMENTS","AIDRAWER","aiDrawerBodyElement","AIDRAWER_BODY","pageElement","PAGE","jumpToElement","JUMPTO","actionElement","ACTION","aiDrawerCloseElement","this","AIDRAWER_CLOSE","lastAction","responses","Map","isDrawerFocusLocked","registerEventListeners","addEventListener","async","e","target","closest","ACTIONS","SUMMARY","preventDefault","openAIDrawer","focus","isPolicyAccepted","displayPolicy","displayAction","EXPLAIN","closeAIDrawer","isAIDrawerOpen","key","DrawerEvents","DRAWER_SHOWN","classList","remove","registerPolicyEventListeners","acceptAction","ACCEPT","declineAction","DECLINE","length","acceptPolicy","then","catch","Notification","exception","registerErrorEventListeners","retryAction","RETRY","registerResponseEventListeners","querySelectorAll","REGENERATE","forEach","regenerateAction","responseElement","RESPONSE","actionPerformed","getAttribute","removeResponseFromStack","registerLoadingEventListeners","cancelAction","CANCEL","setRequestCancelled","toggleAIDrawer","getResponseStack","innerHTML","contains","isRequestCancelled","dataset","cancelled","MessageDrawerHelper","hide","add","setAttribute","addPadding","FocusLock","trapFocus","untrapFocus","removeAttribute","removepadding","removePadding","action","params","method","heading","Policy","getPolicyStatus","hasGeneratedContent","has","render","html","displayLoading","addResponseToStack","existingReponse","scrollTop","offsetTop","request","methodname","getParamsForAction","args","contextid","prompttext","getTextContent","responseObj","Ajax","call","error","displayError","errormessage","generatedContent","AIHelper","formatResponse","generatedcontent","displayResponse","window","console","log","set","delete","stack","values","reverse","response","content","errorMessage","mainRegion","MAIN_REGION","innerText","textContent"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"placement.min.js","sources":["../src/placement.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Module to load and render the tools for the AI assist plugin.\n *\n * @module aiplacement_courseassist/placement\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Templates from 'core/templates';\nimport Ajax from 'core/ajax';\nimport 'core/copy_to_clipboard';\nimport Notification from 'core/notification';\nimport Selectors from 'aiplacement_courseassist/selectors';\nimport Policy from 'core_ai/policy';\nimport AIHelper from 'core_ai/helper';\nimport DrawerEvents from 'core/drawer_events';\nimport {subscribe} from 'core/pubsub';\nimport * as MessageDrawerHelper from 'core_message/message_drawer_helper';\nimport {getString} from 'core/str';\nimport * as FocusLock from 'core/local/aria/focuslock';\nimport {isSmall} from \"core/pagehelpers\";\n\nconst AICourseAssist = class {\n\n /**\n * The user ID.\n * @type {Integer}\n */\n userId;\n /**\n * The context ID.\n * @type {Integer}\n */\n contextId;\n\n /**\n * Constructor.\n * @param {Integer} userId The user ID.\n * @param {Integer} contextId The context ID.\n */\n constructor(userId, contextId) {\n this.userId = userId;\n this.contextId = contextId;\n\n this.aiDrawerElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER);\n this.aiDrawerBodyElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER_BODY);\n this.pageElement = document.querySelector(Selectors.ELEMENTS.PAGE);\n this.jumpToElement = document.querySelector(Selectors.ELEMENTS.JUMPTO);\n this.actionElement = document.querySelector(Selectors.ELEMENTS.ACTION);\n this.aiDrawerCloseElement = this.aiDrawerElement.querySelector(Selectors.ELEMENTS.AIDRAWER_CLOSE);\n this.lastAction = '';\n this.responses = new Map();\n this.isDrawerFocusLocked = false;\n\n this.registerEventListeners();\n }\n\n /**\n * Register event listeners.\n */\n registerEventListeners() {\n document.addEventListener('click', async(e) => {\n // Display summarise.\n const summariseAction = e.target.closest(Selectors.ACTIONS.SUMMARY);\n if (summariseAction) {\n e.preventDefault();\n this.openAIDrawer();\n this.lastAction = 'summarise_text';\n this.actionElement.focus();\n const isPolicyAccepted = await this.isPolicyAccepted();\n if (!isPolicyAccepted) {\n // Display policy.\n this.displayPolicy();\n return;\n }\n this.displayAction(this.lastAction);\n }\n // Display explain.\n const explainAction = e.target.closest(Selectors.ACTIONS.EXPLAIN);\n if (explainAction) {\n e.preventDefault();\n this.openAIDrawer();\n this.lastAction = 'explain_text';\n this.actionElement.focus();\n const isPolicyAccepted = await this.isPolicyAccepted();\n if (!isPolicyAccepted) {\n // Display policy.\n this.displayPolicy();\n return;\n }\n this.displayAction(this.lastAction);\n }\n // Close AI drawer.\n const closeAiDrawer = e.target.closest(Selectors.ELEMENTS.AIDRAWER_CLOSE);\n if (closeAiDrawer) {\n e.preventDefault();\n this.closeAIDrawer();\n }\n });\n\n document.addEventListener('keydown', e => {\n if (this.isAIDrawerOpen() && e.key === 'Escape') {\n this.closeAIDrawer();\n }\n });\n\n // Close AI drawer if message drawer is shown.\n subscribe(DrawerEvents.DRAWER_SHOWN, () => {\n if (this.isAIDrawerOpen()) {\n this.closeAIDrawer();\n }\n });\n\n // Check if there is course assist control region in the page.\n if (this.jumpToElement) {\n // Focus on the AI drawer's close button when the jump-to element is focused.\n this.jumpToElement.addEventListener('focus', () => {\n this.aiDrawerCloseElement.focus();\n });\n }\n\n // Focus on the action element when the AI drawer container receives focus.\n this.aiDrawerElement.addEventListener('focus', () => {\n this.actionElement.focus();\n });\n\n // Check if the action element exists.\n if (this.actionElement) {\n // Remove active from the action element when it loses focus.\n this.actionElement.addEventListener('blur', () => {\n this.actionElement.classList.remove('active');\n });\n }\n }\n\n /**\n * Register event listeners for the policy.\n */\n registerPolicyEventListeners() {\n const acceptAction = document.querySelector(Selectors.ACTIONS.ACCEPT);\n const declineAction = document.querySelector(Selectors.ACTIONS.DECLINE);\n if (acceptAction && this.lastAction.length) {\n acceptAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.acceptPolicy().then(() => {\n return this.displayAction(this.lastAction);\n }).catch(Notification.exception);\n });\n }\n if (declineAction) {\n declineAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.closeAIDrawer();\n });\n }\n }\n\n /**\n * Register event listeners for the error.\n */\n registerErrorEventListeners() {\n const retryAction = document.querySelector(Selectors.ACTIONS.RETRY);\n if (retryAction && this.lastAction.length) {\n retryAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.displayAction(this.lastAction);\n });\n }\n }\n\n /**\n * Register event listeners for the responses.\n */\n registerResponseEventListeners() {\n // Get all regenerate action buttons (one per response in the AI drawer).\n const regenerateActions = document.querySelectorAll(Selectors.ACTIONS.REGENERATE);\n // Add event listeners for each regenerate action.\n regenerateActions.forEach(regenerateAction => {\n const responseElement = regenerateAction.closest(Selectors.ELEMENTS.RESPONSE);\n if (regenerateAction && responseElement) {\n // Get the action that this response is associated with.\n const actionPerformed = responseElement.getAttribute('data-action-performed');\n regenerateAction.addEventListener('click', (e) => {\n e.preventDefault();\n // Remove the old response before displaying the new one.\n this.removeResponseFromStack(actionPerformed);\n this.displayAction(actionPerformed);\n });\n }\n });\n }\n\n registerLoadingEventListeners() {\n const cancelAction = document.querySelector(Selectors.ACTIONS.CANCEL);\n if (cancelAction) {\n cancelAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.setRequestCancelled();\n this.toggleAIDrawer();\n this.removeResponseFromStack('loading');\n // Refresh the response stack to avoid false indication of loading.\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n });\n }\n }\n\n /**\n * Check if the AI drawer is open.\n * @return {boolean} True if the AI drawer is open, false otherwise.\n */\n isAIDrawerOpen() {\n return this.aiDrawerElement.classList.contains('show');\n }\n\n /**\n * Check if the request is cancelled.\n * @return {boolean} True if the request is cancelled, false otherwise.\n */\n isRequestCancelled() {\n return this.aiDrawerBodyElement.dataset.cancelled === '1';\n }\n\n setRequestCancelled() {\n this.aiDrawerBodyElement.dataset.cancelled = '1';\n }\n\n /**\n * Open the AI drawer.\n */\n openAIDrawer() {\n // Close message drawer if it is shown.\n MessageDrawerHelper.hide();\n this.aiDrawerElement.classList.add('show');\n this.aiDrawerElement.setAttribute('tabindex', 0);\n this.aiDrawerBodyElement.setAttribute('aria-live', 'polite');\n if (!this.pageElement.classList.contains('show-drawer-right')) {\n this.addPadding();\n }\n this.jumpToElement.setAttribute('tabindex', 0);\n this.jumpToElement.focus();\n\n // If the AI drawer is opened on a small screen, we need to trap the focus tab within the AI drawer.\n if (isSmall()) {\n FocusLock.trapFocus(this.aiDrawerElement);\n this.aiDrawerElement.setAttribute('aria-modal', 'true');\n this.aiDrawerElement.setAttribute('role', 'dialog');\n this.isDrawerFocusLocked = true;\n }\n }\n\n /**\n * Close the AI drawer.\n */\n closeAIDrawer() {\n // Untrap focus if it was locked.\n if (this.isDrawerFocusLocked) {\n FocusLock.untrapFocus();\n this.aiDrawerElement.removeAttribute('aria-modal');\n this.aiDrawerElement.setAttribute('role', 'region');\n }\n\n this.aiDrawerElement.classList.remove('show');\n this.aiDrawerElement.setAttribute('tabindex', -1);\n this.aiDrawerBodyElement.removeAttribute('aria-live');\n if (this.pageElement.classList.contains('show-drawer-right') && this.aiDrawerBodyElement.dataset.removepadding === '1') {\n this.removePadding();\n }\n this.jumpToElement.setAttribute('tabindex', -1);\n\n // We can enforce a focus-visible state on the focus element using element.focus({focusVisible: true}).\n // Unfortunately, this feature isn't supported in all browsers, only Firefox provides support for it.\n // Therefore, we will apply the active class to the action element and set focus on it.\n // This action will make the action element appear focused.\n // When the action element loses focus,\n // we will remove the active class at {@see registerEventListeners()}\n this.actionElement.classList.add('active');\n this.actionElement.focus();\n }\n\n /**\n * Toggle the AI drawer.\n */\n toggleAIDrawer() {\n if (this.isAIDrawerOpen()) {\n this.closeAIDrawer();\n } else {\n this.openAIDrawer();\n }\n }\n\n /**\n * Add padding to the page to make space for the AI drawer.\n */\n addPadding() {\n this.pageElement.classList.add('show-drawer-right');\n this.aiDrawerBodyElement.dataset.removepadding = '1';\n }\n\n /**\n * Remove padding from the page.\n */\n removePadding() {\n this.pageElement.classList.remove('show-drawer-right');\n this.aiDrawerBodyElement.dataset.removepadding = '0';\n }\n\n /**\n * Get important params related to the action.\n * @param {string} action The action to use.\n * @returns {object} The params to use for the action.\n */\n async getParamsForAction(action) {\n let params = {};\n\n switch (action) {\n case 'summarise_text':\n params.method = 'aiplacement_courseassist_summarise_text';\n params.heading = await getString('aisummary', 'aiplacement_courseassist');\n break;\n\n case 'explain_text':\n params.method = 'aiplacement_courseassist_explain_text';\n params.heading = await getString('aiexplain', 'aiplacement_courseassist');\n break;\n }\n\n return params;\n }\n\n /**\n * Check if the policy is accepted.\n * @return {bool} True if the policy is accepted, false otherwise.\n */\n async isPolicyAccepted() {\n return await Policy.getPolicyStatus(this.userId);\n }\n\n /**\n * Accept the policy.\n * @return {Promise}\n */\n acceptPolicy() {\n return Policy.acceptPolicy();\n }\n\n /**\n * Check if the AI drawer has already generated content for a particular action.\n * @param {string} action The action to check.\n * @return {boolean} True if the AI drawer has generated content, false otherwise.\n */\n hasGeneratedContent(action) {\n return this.responses.has(action);\n }\n\n /**\n * Display the policy.\n */\n displayPolicy() {\n Templates.render('core_ai/policyblock', {}).then((html) => {\n this.aiDrawerBodyElement.innerHTML = html;\n this.registerPolicyEventListeners();\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the loading spinner.\n */\n displayLoading() {\n Templates.render('aiplacement_courseassist/loading', {}).then((html) => {\n this.addResponseToStack('loading', html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerLoadingEventListeners();\n return;\n }).then(() => {\n this.removeResponseFromStack('loading');\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the action result in the AI drawer.\n * @param {string} action The action to display.\n */\n async displayAction(action) {\n if (this.hasGeneratedContent(action)) {\n // Scroll to generated content.\n const existingReponse = document.querySelector('[data-action-performed=\"' + action + '\"]');\n if (existingReponse) {\n this.aiDrawerBodyElement.scrollTop = existingReponse.offsetTop;\n }\n } else {\n // Display loading spinner.\n this.displayLoading();\n // Clear the drawer to prevent including the previously generated response in the new response prompt.\n this.aiDrawerBodyElement.innerHTML = '';\n const params = await this.getParamsForAction(action);\n const request = {\n methodname: params.method,\n args: {\n contextid: this.contextId,\n prompttext: this.getTextContent(),\n }\n };\n try {\n const responseObj = await Ajax.call([request])[0];\n if (responseObj.error) {\n this.displayError(responseObj.error, responseObj.errormessage);\n return;\n } else {\n if (!this.isRequestCancelled()) {\n // Perform replacements on the generated context to ensure it is formatted correctly.\n const generatedContent = AIHelper.formatResponse(responseObj.generatedcontent);\n this.displayResponse(generatedContent, action);\n return;\n } else {\n this.aiDrawerBodyElement.dataset.cancelled = '0';\n }\n }\n } catch (error) {\n window.console.log(error);\n this.displayError();\n }\n }\n }\n\n /**\n * Add the HTML response to the response stack.\n * The stack will be used to display all responses in the AI drawer.\n * @param {String} action The action key.\n * @param {String} html The HTML to store.\n */\n addResponseToStack(action, html) {\n this.responses.set(action, html);\n }\n\n /**\n * Remove a stored response, allowing for a regenerated one.\n * @param {String} action The action key.\n */\n removeResponseFromStack(action) {\n if (this.responses.has(action)) {\n this.responses.delete(action);\n }\n }\n\n /**\n * Return a stack of HTML responses.\n * @return {String} HTML responses.\n */\n getResponseStack() {\n let stack = '';\n // Reverse to get newest first.\n const responses = [...this.responses.values()].reverse();\n for (const response of responses) {\n stack += response;\n }\n return stack;\n }\n\n /**\n * Display the responses.\n * @param {String} content The content to display.\n * @param {String} action The action used.\n */\n async displayResponse(content, action) {\n const params = await this.getParamsForAction(action);\n const args = {\n content: content,\n heading: params.heading,\n action: action,\n };\n Templates.render('aiplacement_courseassist/response', args).then((html) => {\n this.addResponseToStack(action, html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerResponseEventListeners();\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the error.\n *\n * @param {String} error The error name to display.\n * @param {String} errorMessage The error message to display.\n */\n async displayError(error = '', errorMessage = '') {\n if (!error) {\n // Get the default error message.\n error = await getString('error:defaultname', 'core_ai');\n errorMessage = await getString('error:defaultmessage', 'core_ai');\n }\n Templates.render('aiplacement_courseassist/error', {'error': error, 'errorMessage': errorMessage}).then((html) => {\n this.addResponseToStack('error', html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerErrorEventListeners();\n return;\n }).then(() => {\n this.removeResponseFromStack('error');\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Get the text content of the main region.\n * @return {String} The text content.\n */\n getTextContent() {\n const mainRegion = document.querySelector(Selectors.ELEMENTS.MAIN_REGION);\n return mainRegion.innerText || mainRegion.textContent;\n }\n};\n\nexport default AICourseAssist;\n"],"names":["constructor","userId","contextId","aiDrawerElement","document","querySelector","Selectors","ELEMENTS","AIDRAWER","aiDrawerBodyElement","AIDRAWER_BODY","pageElement","PAGE","jumpToElement","JUMPTO","actionElement","ACTION","aiDrawerCloseElement","this","AIDRAWER_CLOSE","lastAction","responses","Map","isDrawerFocusLocked","registerEventListeners","addEventListener","async","e","target","closest","ACTIONS","SUMMARY","preventDefault","openAIDrawer","focus","isPolicyAccepted","displayPolicy","displayAction","EXPLAIN","closeAIDrawer","isAIDrawerOpen","key","DrawerEvents","DRAWER_SHOWN","classList","remove","registerPolicyEventListeners","acceptAction","ACCEPT","declineAction","DECLINE","length","acceptPolicy","then","catch","Notification","exception","registerErrorEventListeners","retryAction","RETRY","registerResponseEventListeners","querySelectorAll","REGENERATE","forEach","regenerateAction","responseElement","RESPONSE","actionPerformed","getAttribute","removeResponseFromStack","registerLoadingEventListeners","cancelAction","CANCEL","setRequestCancelled","toggleAIDrawer","getResponseStack","innerHTML","contains","isRequestCancelled","dataset","cancelled","MessageDrawerHelper","hide","add","setAttribute","addPadding","FocusLock","trapFocus","untrapFocus","removeAttribute","removepadding","removePadding","action","params","method","heading","Policy","getPolicyStatus","hasGeneratedContent","has","render","html","displayLoading","addResponseToStack","existingReponse","scrollTop","offsetTop","request","methodname","getParamsForAction","args","contextid","prompttext","getTextContent","responseObj","Ajax","call","error","displayError","errormessage","generatedContent","AIHelper","formatResponse","generatedcontent","displayResponse","window","console","log","set","delete","stack","values","reverse","response","content","errorMessage","mainRegion","MAIN_REGION","innerText","textContent"],"mappings":"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"} \ No newline at end of file diff --git a/public/ai/placement/courseassist/amd/build/selectors.min.js b/public/ai/placement/courseassist/amd/build/selectors.min.js index 5336b652420..4715eb32ce9 100644 --- a/public/ai/placement/courseassist/amd/build/selectors.min.js +++ b/public/ai/placement/courseassist/amd/build/selectors.min.js @@ -1,3 +1,3 @@ -define("aiplacement_courseassist/selectors",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;return _exports.default={ELEMENTS:{AIDRAWER:"#ai-drawer",AIDRAWER_BODY:"#ai-drawer .ai-drawer-body",PAGE:"#page",MAIN_REGION:'[role="main"]',AIDRAWER_CLOSE:"#ai-drawer-close",RESPONSE:".course-assist-response",JUMPTO:'.course-assist-controls [data-region="jumpto"]',ACTION:'.course-assist-controls [data-input-type="action"]'},ACTIONS:{SUMMARY:'.course-assist-controls [data-action="summarise"]',EXPLAIN:'.course-assist-controls [data-action="explain"]',RETRY:'.course-assist-controls [data-action="retry"]',DECLINE:'.ai-policy-block [data-action="decline"]',ACCEPT:'.ai-policy-block [data-action="accept"]',REGENERATE:'.course-assist-controls [data-action="regenerate"]',CANCEL:'.course-assist-controls [data-action="cancel"]'}},_exports.default})); +define("aiplacement_courseassist/selectors",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;return _exports.default={ELEMENTS:{AIDRAWER:"#ai-drawer",AIDRAWER_BODY:"#ai-drawer .ai-drawer-body",PAGE:"#page",MAIN_REGION:'[role="main"]',AIDRAWER_CLOSE:"#ai-drawer-close",RESPONSE:".course-assist-response",JUMPTO:'.course-assist-controls [data-region="jumpto"]',ACTION:'.course-assist-controls [data-input-type="action"]'},ACTIONS:{SUMMARY:'.course-assist-controls [data-action="summarise_text"]',EXPLAIN:'.course-assist-controls [data-action="explain_text"]',RETRY:'.course-assist-controls [data-action="retry"]',DECLINE:'.ai-policy-block [data-action="decline"]',ACCEPT:'.ai-policy-block [data-action="accept"]',REGENERATE:'.course-assist-controls [data-action="regenerate"]',CANCEL:'.course-assist-controls [data-action="cancel"]'}},_exports.default})); //# sourceMappingURL=selectors.min.js.map \ No newline at end of file diff --git a/public/ai/placement/courseassist/amd/build/selectors.min.js.map b/public/ai/placement/courseassist/amd/build/selectors.min.js.map index f97ebf66870..008adcf5755 100644 --- a/public/ai/placement/courseassist/amd/build/selectors.min.js.map +++ b/public/ai/placement/courseassist/amd/build/selectors.min.js.map @@ -1 +1 @@ -{"version":3,"file":"selectors.min.js","sources":["../src/selectors.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Define all of the selectors we will be using on the AI Course assistant.\n *\n * @module aiplacement_courseassist/selectors\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default {\n ELEMENTS: {\n AIDRAWER: '#ai-drawer',\n AIDRAWER_BODY: '#ai-drawer .ai-drawer-body',\n PAGE: '#page',\n MAIN_REGION: '[role=\"main\"]',\n AIDRAWER_CLOSE: '#ai-drawer-close',\n RESPONSE: '.course-assist-response',\n JUMPTO: '.course-assist-controls [data-region=\"jumpto\"]',\n ACTION: '.course-assist-controls [data-input-type=\"action\"]',\n },\n ACTIONS: {\n SUMMARY: '.course-assist-controls [data-action=\"summarise\"]',\n EXPLAIN: '.course-assist-controls [data-action=\"explain\"]',\n RETRY: '.course-assist-controls [data-action=\"retry\"]',\n DECLINE: '.ai-policy-block [data-action=\"decline\"]',\n ACCEPT: '.ai-policy-block [data-action=\"accept\"]',\n REGENERATE: '.course-assist-controls [data-action=\"regenerate\"]',\n CANCEL: '.course-assist-controls [data-action=\"cancel\"]',\n }\n};\n"],"names":["ELEMENTS","AIDRAWER","AIDRAWER_BODY","PAGE","MAIN_REGION","AIDRAWER_CLOSE","RESPONSE","JUMPTO","ACTION","ACTIONS","SUMMARY","EXPLAIN","RETRY","DECLINE","ACCEPT","REGENERATE","CANCEL"],"mappings":"oLAsBe,CACXA,SAAU,CACNC,SAAU,aACVC,cAAe,6BACfC,KAAM,QACNC,YAAa,gBACbC,eAAgB,mBAChBC,SAAU,0BACVC,OAAQ,iDACRC,OAAQ,sDAEZC,QAAS,CACLC,QAAS,oDACTC,QAAS,kDACTC,MAAO,gDACPC,QAAS,2CACTC,OAAQ,0CACRC,WAAY,qDACZC,OAAQ"} \ No newline at end of file +{"version":3,"file":"selectors.min.js","sources":["../src/selectors.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Define all of the selectors we will be using on the AI Course assistant.\n *\n * @module aiplacement_courseassist/selectors\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default {\n ELEMENTS: {\n AIDRAWER: '#ai-drawer',\n AIDRAWER_BODY: '#ai-drawer .ai-drawer-body',\n PAGE: '#page',\n MAIN_REGION: '[role=\"main\"]',\n AIDRAWER_CLOSE: '#ai-drawer-close',\n RESPONSE: '.course-assist-response',\n JUMPTO: '.course-assist-controls [data-region=\"jumpto\"]',\n ACTION: '.course-assist-controls [data-input-type=\"action\"]',\n },\n ACTIONS: {\n SUMMARY: '.course-assist-controls [data-action=\"summarise_text\"]',\n EXPLAIN: '.course-assist-controls [data-action=\"explain_text\"]',\n RETRY: '.course-assist-controls [data-action=\"retry\"]',\n DECLINE: '.ai-policy-block [data-action=\"decline\"]',\n ACCEPT: '.ai-policy-block [data-action=\"accept\"]',\n REGENERATE: '.course-assist-controls [data-action=\"regenerate\"]',\n CANCEL: '.course-assist-controls [data-action=\"cancel\"]',\n }\n};\n"],"names":["ELEMENTS","AIDRAWER","AIDRAWER_BODY","PAGE","MAIN_REGION","AIDRAWER_CLOSE","RESPONSE","JUMPTO","ACTION","ACTIONS","SUMMARY","EXPLAIN","RETRY","DECLINE","ACCEPT","REGENERATE","CANCEL"],"mappings":"oLAsBe,CACXA,SAAU,CACNC,SAAU,aACVC,cAAe,6BACfC,KAAM,QACNC,YAAa,gBACbC,eAAgB,mBAChBC,SAAU,0BACVC,OAAQ,iDACRC,OAAQ,sDAEZC,QAAS,CACLC,QAAS,yDACTC,QAAS,uDACTC,MAAO,gDACPC,QAAS,2CACTC,OAAQ,0CACRC,WAAY,qDACZC,OAAQ"} \ No newline at end of file diff --git a/public/ai/placement/courseassist/amd/src/placement.js b/public/ai/placement/courseassist/amd/src/placement.js index 84f064c6d3d..6ca8463f2ee 100644 --- a/public/ai/placement/courseassist/amd/src/placement.js +++ b/public/ai/placement/courseassist/amd/src/placement.js @@ -80,7 +80,7 @@ const AICourseAssist = class { if (summariseAction) { e.preventDefault(); this.openAIDrawer(); - this.lastAction = 'summarise'; + this.lastAction = 'summarise_text'; this.actionElement.focus(); const isPolicyAccepted = await this.isPolicyAccepted(); if (!isPolicyAccepted) { @@ -95,7 +95,7 @@ const AICourseAssist = class { if (explainAction) { e.preventDefault(); this.openAIDrawer(); - this.lastAction = 'explain'; + this.lastAction = 'explain_text'; this.actionElement.focus(); const isPolicyAccepted = await this.isPolicyAccepted(); if (!isPolicyAccepted) { @@ -126,20 +126,26 @@ const AICourseAssist = class { } }); - // Focus on the AI drawer's close button when the jump-to element is focused. - this.jumpToElement.addEventListener('focus', () => { - this.aiDrawerCloseElement.focus(); - }); + // Check if there is course assist control region in the page. + if (this.jumpToElement) { + // Focus on the AI drawer's close button when the jump-to element is focused. + this.jumpToElement.addEventListener('focus', () => { + this.aiDrawerCloseElement.focus(); + }); + } // Focus on the action element when the AI drawer container receives focus. this.aiDrawerElement.addEventListener('focus', () => { this.actionElement.focus(); }); - // Remove active from the action element when it loses focus. - this.actionElement.addEventListener('blur', () => { - this.actionElement.classList.remove('active'); - }); + // Check if the action element exists. + if (this.actionElement) { + // Remove active from the action element when it loses focus. + this.actionElement.addEventListener('blur', () => { + this.actionElement.classList.remove('active'); + }); + } } /** @@ -323,12 +329,12 @@ const AICourseAssist = class { let params = {}; switch (action) { - case 'summarise': + case 'summarise_text': params.method = 'aiplacement_courseassist_summarise_text'; params.heading = await getString('aisummary', 'aiplacement_courseassist'); break; - case 'explain': + case 'explain_text': params.method = 'aiplacement_courseassist_explain_text'; params.heading = await getString('aiexplain', 'aiplacement_courseassist'); break; diff --git a/public/ai/placement/courseassist/amd/src/selectors.js b/public/ai/placement/courseassist/amd/src/selectors.js index 39650c63f8d..41afc20e55e 100644 --- a/public/ai/placement/courseassist/amd/src/selectors.js +++ b/public/ai/placement/courseassist/amd/src/selectors.js @@ -32,8 +32,8 @@ export default { ACTION: '.course-assist-controls [data-input-type="action"]', }, ACTIONS: { - SUMMARY: '.course-assist-controls [data-action="summarise"]', - EXPLAIN: '.course-assist-controls [data-action="explain"]', + SUMMARY: '.course-assist-controls [data-action="summarise_text"]', + EXPLAIN: '.course-assist-controls [data-action="explain_text"]', RETRY: '.course-assist-controls [data-action="retry"]', DECLINE: '.ai-policy-block [data-action="decline"]', ACCEPT: '.ai-policy-block [data-action="accept"]', diff --git a/public/ai/placement/courseassist/classes/external/explain_text.php b/public/ai/placement/courseassist/classes/external/explain_text.php index 94ec7d91922..f2856ca82f1 100644 --- a/public/ai/placement/courseassist/classes/external/explain_text.php +++ b/public/ai/placement/courseassist/classes/external/explain_text.php @@ -78,7 +78,9 @@ class explain_text extends external_api { // Check the user has permission to use the AI service. self::validate_context($context); - if (!utils::is_course_assist_available($context)) { + + // Check if AI Placement course assist is available. + if (!utils::is_course_assist_available()) { throw new \moodle_exception('nocourseassist', 'aiplacement_courseassist'); } diff --git a/public/ai/placement/courseassist/classes/external/summarise_text.php b/public/ai/placement/courseassist/classes/external/summarise_text.php index e153720ffb9..0d6ca4b95e8 100644 --- a/public/ai/placement/courseassist/classes/external/summarise_text.php +++ b/public/ai/placement/courseassist/classes/external/summarise_text.php @@ -78,7 +78,9 @@ class summarise_text extends external_api { // Check the user has permission to use the AI service. self::validate_context($context); - if (!utils::is_course_assist_available($context)) { + + // Check if AI Placement course assist is available. + if (!utils::is_course_assist_available()) { throw new \moodle_exception('nocourseassist', 'aiplacement_courseassist'); } diff --git a/public/ai/placement/courseassist/classes/output/assist_ui.php b/public/ai/placement/courseassist/classes/output/assist_ui.php index 8afeeba770f..dc546434558 100644 --- a/public/ai/placement/courseassist/classes/output/assist_ui.php +++ b/public/ai/placement/courseassist/classes/output/assist_ui.php @@ -102,6 +102,6 @@ class assist_ui { } // Check if the user has permission to use the AI service. - return utils::is_course_assist_available($PAGE->context); + return utils::is_course_assist_available(); } } diff --git a/public/ai/placement/courseassist/classes/utils.php b/public/ai/placement/courseassist/classes/utils.php index 906aa21fbd8..fd67e044b16 100644 --- a/public/ai/placement/courseassist/classes/utils.php +++ b/public/ai/placement/courseassist/classes/utils.php @@ -29,22 +29,17 @@ use core_ai\manager; */ class utils { /** - * Check if AI Placement course assist is available for the context. + * Check if AI Placement course assist is available. * - * @param \context $context The context. * @return bool True if AI Placement course assist is available, false otherwise. */ - public static function is_course_assist_available(\context $context): bool { + public static function is_course_assist_available(): bool { [$plugintype, $pluginname] = explode('_', \core_component::normalize_componentname('aiplacement_courseassist'), 2); $pluginmanager = \core_plugin_manager::resolve_plugininfo_class($plugintype); if (!$pluginmanager::is_plugin_enabled($pluginname)) { return false; } - if (empty(self::get_actions_available($context))) { - return false; - } - return true; } @@ -52,37 +47,34 @@ class utils { * Get all the actions available and return action data for template. * * @param \context $context The context. + * @param bool $checkcontext If true, check the action is available in context. * @return array Return the actions available with data. */ - public static function get_actions_available(\context $context): array { + public static function get_actions_available(\context $context, bool $checkcontext = true): array { $actions = []; - $actionclasses = [ - summarise_text::class, - explain_text::class, - ]; $manager = \core\di::get(manager::class); - $providers = $manager->get_providers_for_actions($actionclasses, true); // Summarise text. if (has_capability('aiplacement/courseassist:summarise_text', $context) && $manager->is_action_available(summarise_text::class) && $manager->is_action_enabled('aiplacement_courseassist', summarise_text::class) - && !empty($providers[summarise_text::class]) + && (!$checkcontext || $manager->is_action_enabled_in_context($context, summarise_text::class)) ) { $actions[] = [ - 'action' => 'summarise', + 'action' => 'summarise_text', 'buttontext' => get_string('summarise', 'aiplacement_courseassist'), 'title' => get_string('summarise_tooltips', 'aiplacement_courseassist'), ]; } + // Explain text. if (has_capability('aiplacement/courseassist:explain_text', $context) && $manager->is_action_available(explain_text::class) && $manager->is_action_enabled('aiplacement_courseassist', explain_text::class) - && !empty($providers[explain_text::class]) + && (!$checkcontext || $manager->is_action_enabled_in_context($context, explain_text::class)) ) { $actions[] = [ - 'action' => 'explain', + 'action' => 'explain_text', 'buttontext' => get_string('explain', 'aiplacement_courseassist'), 'title' => get_string('explain_tooltips', 'aiplacement_courseassist'), ]; diff --git a/public/ai/placement/courseassist/templates/action_button.mustache b/public/ai/placement/courseassist/templates/action_button.mustache index 7b7516dd28c..9c774a338b1 100644 --- a/public/ai/placement/courseassist/templates/action_button.mustache +++ b/public/ai/placement/courseassist/templates/action_button.mustache @@ -26,7 +26,7 @@ Example context (json): { "isdropdown": true, - "action": "explain", + "action": "explain_text", "buttontext": "Explain", "title": "Create an AI-generated explanation of the page content" } diff --git a/public/ai/placement/courseassist/templates/actions.mustache b/public/ai/placement/courseassist/templates/actions.mustache index 05c2914f0ce..71aad74d060 100644 --- a/public/ai/placement/courseassist/templates/actions.mustache +++ b/public/ai/placement/courseassist/templates/actions.mustache @@ -28,12 +28,12 @@ "isdropdown": true, "actions": [ { - "action": "summarise", + "action": "summarise_text", "buttontext": "Summarise", "title": "Create an AI-generated summary of the page content" }, { - "action": "explain", + "action": "explain_text", "buttontext": "Explain", "title": "Create an AI-generated explanation of the page content" } diff --git a/public/ai/placement/courseassist/templates/actions_dropdown.mustache b/public/ai/placement/courseassist/templates/actions_dropdown.mustache index e1a1609fa7f..2c9e78b581f 100644 --- a/public/ai/placement/courseassist/templates/actions_dropdown.mustache +++ b/public/ai/placement/courseassist/templates/actions_dropdown.mustache @@ -26,12 +26,12 @@ { "actions": [ { - "action": "summarise", + "action": "summarise_text", "buttontext": "Summarise", "title": "Create an AI-generated summary of the page content" }, { - "action": "explain", + "action": "explain_text", "buttontext": "Explain", "title": "Create an AI-generated explanation of the page content" } diff --git a/public/ai/placement/courseassist/templates/response.mustache b/public/ai/placement/courseassist/templates/response.mustache index 81df329cc76..cd421dee9d0 100644 --- a/public/ai/placement/courseassist/templates/response.mustache +++ b/public/ai/placement/courseassist/templates/response.mustache @@ -28,7 +28,7 @@ { "content": "

Content to display

", "heading": "AI Explain", - "action": "explain" + "action": "explain_text" } }}
diff --git a/public/ai/placement/courseassist/tests/utils_test.php b/public/ai/placement/courseassist/tests/utils_test.php index 74181ef0903..e28e9e3117d 100644 --- a/public/ai/placement/courseassist/tests/utils_test.php +++ b/public/ai/placement/courseassist/tests/utils_test.php @@ -16,9 +16,10 @@ namespace aiplacement_courseassist; -use core_ai\aiactions\generate_text; -use core_ai\aiactions\summarise_text; -use core_ai\manager; +use core_ai\ai_test_trait; + +defined('MOODLE_INTERNAL') || die(); +require_once(__DIR__ . '/../../../tests/ai_test_trait.php'); /** * AI Placement course assist utils test. @@ -29,103 +30,103 @@ use core_ai\manager; * @covers \aiplacement_courseassist\utils */ final class utils_test extends \advanced_testcase { + use ai_test_trait; + + /** @var array List of users. */ + private array $users; + /** @var \stdClass Course object. */ + private \stdClass $course; + /** @var \context_course Course context. */ + private \context_course $context; + /** @var \stdClass Teacher role. */ + private \stdClass $teacherrole; + + public function setUp(): void { + global $DB; + parent::setUp(); + + $this->resetAfterTest(); + $this->users[1] = $this->getDataGenerator()->create_user(); + $this->users[2] = $this->getDataGenerator()->create_user(); + $this->course = $this->getDataGenerator()->create_course(); + $this->context = \context_course::instance($this->course->id); + $this->teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']); + $this->getDataGenerator()->enrol_user($this->users[1]->id, $this->course->id, 'manager'); + $this->getDataGenerator()->enrol_user($this->users[2]->id, $this->course->id, 'editingteacher'); + } + + /** + * Data provider for supported placement tests. + * + * @return array + */ + public static function course_assist_actions_available_provider(): array { + return [ + 'Two actions' => [ + 'actionstouse' => [ + 'summarise_text', + 'explain_text', + ], + 'expectedcount' => 2, + ], + 'Summarise only' => [ + 'actionstouse' => [ + 'summarise_text', + ], + 'expectedcount' => 1, + ], + 'Explain only' => [ + 'actionstouse' => [ + 'explain_text', + ], + 'expectedcount' => 1, + ], + 'No actions' => [ + 'actionstouse' => [], + 'expectedcount' => 0, + ], + ]; + } /** * Test is_course_assist_available method. */ public function test_is_course_assist_available(): void { - global $DB; - $this->resetAfterTest(); - $user1 = $this->getDataGenerator()->create_user(); - $user2 = $this->getDataGenerator()->create_user(); - $course = $this->getDataGenerator()->create_course(); - $context = \context_course::instance($course->id); - $teacherrole = $DB->get_record('role', ['shortname' => 'editingteacher']); - $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'manager'); - $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'editingteacher'); - - // Provider is not enabled. - $this->setUser($user1); - $this->assertFalse(utils::is_course_assist_available($context)); - - // Provider is enabled, but plugin is not enabled. - set_config('enabled', 1, 'aiprovider_openai'); - set_config('apikey', '123', 'aiprovider_openai'); - set_config('enabled', 0, 'aiplacement_courseassist'); - $this->assertFalse(utils::is_course_assist_available($context)); - - // Plugin is enabled but user does not have capability. - assign_capability('aiplacement/courseassist:summarise_text', CAP_PROHIBIT, $teacherrole->id, $context); - assign_capability('aiplacement/courseassist:explain_text', CAP_PROHIBIT, $teacherrole->id, $context); - $this->setUser($user2); set_config('enabled', 1, 'aiplacement_courseassist'); - $this->assertFalse(utils::is_course_assist_available($context)); + $this->assertTrue(utils::is_course_assist_available()); - // Plugin is enabled, user has capability and placement action is not available. - $this->setUser($user1); - set_config('summarise_text', 0, 'aiplacement_courseassist'); - set_config('explain_text', 0, 'aiplacement_courseassist'); - $this->assertFalse(utils::is_course_assist_available($context)); - - // Plugin is enabled, user has capability and provider action is not available. - $this->setUser($user1); - set_config('summarise_text', 0, 'aiprovider_openai'); - set_config('summarise_text', 1, 'aiplacement_courseassist'); - set_config('explain_text', 0, 'aiprovider_openai'); - set_config('explain_text', 1, 'aiplacement_courseassist'); - $this->assertFalse(utils::is_course_assist_available($context)); - - // Plugin is enabled, user has capability, placement action is available and provider action is available. - $mockmanager = $this->createMock(manager::class); - $mockmanager->method('is_action_available')->willReturn(true); - $mockmanager->method('is_action_enabled')->willReturn(true); - $mockmanager->method('get_providers_for_actions')->willReturn([ - summarise_text::class => ['aiprovider_openai'], - ]); - - \core\di::set(manager::class, function() use ($mockmanager) { - return $mockmanager; - }); - - $this->setUser($user1); - set_config('summarise_text', 1, 'aiplacement_courseassist'); - set_config('explain_text', 1, 'aiprovider_openai'); - set_config('explain_text', 1, 'aiplacement_courseassist'); - $this->assertTrue(utils::is_course_assist_available($context)); + set_config('enabled', 0, 'aiplacement_courseassist'); + $this->assertFalse(utils::is_course_assist_available()); } /** * Test get_actions_available method. + * + * @param array $actionstouse The actions to use. + * @param int $expectedcount Expected count of actions. + * @dataProvider course_assist_actions_available_provider */ - public function test_get_actions_available(): void { - global $DB; - $this->resetAfterTest(); - $user1 = $this->getDataGenerator()->create_user(); - $course = $this->getDataGenerator()->create_course(); - $context = \context_course::instance($course->id); - $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'manager'); - $this->setUser($user1); + public function test_get_actions_available( + array $actionstouse, + int $expectedcount, + ): void { + $this->setUser($this->users[2]); + // Set up the provider with the required action config. + $this->create_ai_provider($actionstouse, \aiprovider_openai\provider::class); + set_config('enabled', 1, 'aiplacement_courseassist'); - // Two actions enabled. - set_config('enabled', 1, 'aiprovider_openai'); - set_config('apikey', '123', 'aiprovider_openai'); - set_config('explain_text', 1, 'aiplacement_courseassist'); - set_config('summarise_text', 1, 'aiplacement_courseassist'); - $manager = \core\di::get(manager::class); - $manager->create_provider_instance( - classname: '\aiprovider_openai\provider', - name: 'dummy', - enabled: true, - config: ['apikey' => '123'], - ); - $this->assertCount(2, utils::get_actions_available($context)); + // Enable the actions and check the count. + foreach ($actionstouse as $action) { + set_config($action, 1, 'aiplacement_courseassist'); + } + $actions = utils::get_actions_available($this->context, true); + $this->assertCount($expectedcount, $actions); - // One action enabled. - set_config('summarise_text', 0, 'aiplacement_courseassist'); - $this->assertCount(1, utils::get_actions_available($context)); - - // No actions enabled. - set_config('explain_text', 0, 'aiplacement_courseassist'); - $this->assertCount(0, utils::get_actions_available($context)); + // Prohibit the user and check again. + foreach ($actionstouse as $action) { + assign_capability("aiplacement/courseassist:{$action}", CAP_PROHIBIT, $this->teacherrole->id, $this->context); + } + $actions = utils::get_actions_available($this->context, true); + $this->assertCount(0, $actions); } } diff --git a/public/ai/placement/editor/classes/utils.php b/public/ai/placement/editor/classes/utils.php index 23df0a91d43..b38ebde342e 100644 --- a/public/ai/placement/editor/classes/utils.php +++ b/public/ai/placement/editor/classes/utils.php @@ -16,6 +16,8 @@ namespace aiplacement_editor; +use core_ai\aiactions\generate_image; +use core_ai\aiactions\generate_text; use core_ai\manager; /** @@ -33,16 +35,16 @@ class utils { * @param \context $context The context. * @param string $actionname The name of the action. * @param string $actionclass The class name of the action. - * @return bool True if the action is available, false otherwise. + * @param bool $checkcontext If true, check the action is available in context. + * @return bool If the action is accessible, available, and enable. */ public static function is_html_editor_placement_action_available( \context $context, string $actionname, - string $actionclass + string $actionclass, + bool $checkcontext = true, ): bool { - [$plugintype, $pluginname] = explode('_', \core_component::normalize_componentname('aiplacement_editor'), 2); - $pluginmanager = \core_plugin_manager::resolve_plugininfo_class($plugintype); - if (!$pluginmanager::is_plugin_enabled($pluginname)) { + if (!self::is_html_editor_placement_available()) { return false; } @@ -51,9 +53,57 @@ class utils { has_capability("aiplacement/editor:{$actionname}", $context) && $aimanager->is_action_available($actionclass) && $aimanager->is_action_enabled('aiplacement_editor', $actionclass) + && (!$checkcontext || $aimanager->is_action_enabled_in_context($context, $actionclass)) ) { return true; } + return false; } + + /** + * Check if AI Placement HTML editor is available. + * + * @return bool If the placement is enabled. + */ + public static function is_html_editor_placement_available(): bool { + [$plugintype, $pluginname] = explode('_', \core_component::normalize_componentname('aiplacement_editor'), 2); + $pluginmanager = \core_plugin_manager::resolve_plugininfo_class($plugintype); + if (!$pluginmanager::is_plugin_enabled($pluginname)) { + return false; + } + + return true; + } + + /** + * Get all the actions available for HTML editor placement. + * + * @param \context $context The context. + * @param bool $checkcontext If true, check the action is available in context. + * @return array Return the actions available with data. + */ + public static function get_actions_available(\context $context, bool $checkcontext = true): array { + $actions = []; + + // Action generate_text. + if (self::is_html_editor_placement_action_available($context, 'generate_text', generate_text::class, $checkcontext)) { + $actions[] = [ + 'action' => 'generate_text', + 'buttontext' => get_string('action_generate_text', 'core_ai'), + 'title' => get_string('action_generate_text_desc', 'core_ai'), + ]; + } + + // Action generate_image. + if (self::is_html_editor_placement_action_available($context, 'generate_image', generate_image::class, $checkcontext)) { + $actions[] = [ + 'action' => 'generate_image', + 'buttontext' => get_string('action_generate_image', 'core_ai'), + 'title' => get_string('action_generate_image_desc', 'core_ai'), + ]; + } + + return $actions; + } } diff --git a/public/ai/placement/editor/tests/external/generate_text_test.php b/public/ai/placement/editor/tests/external/generate_text_test.php index a491524a119..92a3a88c7f6 100644 --- a/public/ai/placement/editor/tests/external/generate_text_test.php +++ b/public/ai/placement/editor/tests/external/generate_text_test.php @@ -49,6 +49,7 @@ final class generate_text_test extends \advanced_testcase { $mockmanager->method('process_action')->willReturn($response); $mockmanager->method('is_action_available')->willReturn(true); $mockmanager->method('is_action_enabled')->willReturn(true); + $mockmanager->method('is_action_enabled_in_context')->willReturn(true); \core\di::set(\core_ai\manager::class, function() use ($mockmanager) { return $mockmanager; }); diff --git a/public/ai/placement/editor/tests/utils_test.php b/public/ai/placement/editor/tests/utils_test.php index fa86b7cd544..c57d82bb04a 100644 --- a/public/ai/placement/editor/tests/utils_test.php +++ b/public/ai/placement/editor/tests/utils_test.php @@ -16,8 +16,10 @@ namespace aiplacement_editor; -use core_ai\aiactions\generate_image; -use core_ai\aiactions\generate_text; +use core_ai\ai_test_trait; + +defined('MOODLE_INTERNAL') || die(); +require_once(__DIR__ . '/../../../tests/ai_test_trait.php'); /** * Text editor placement utils test. @@ -25,9 +27,11 @@ use core_ai\aiactions\generate_text; * @package aiplacement_editor * @copyright 2024 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - * @covers \aiplacement_courseassist\utils + * @covers \aiplacement_editor\utils */ final class utils_test extends \advanced_testcase { + use ai_test_trait; + /** @var array List of users. */ private array $users; /** @var \stdClass Course object. */ @@ -52,36 +56,52 @@ final class utils_test extends \advanced_testcase { } /** - * Test is_html_editor_placement_action_available method. + * Data provider for supported placement tests. * - * @param string $actionname Action name. - * @param string $actionclass Action class. - * @dataProvider html_editor_placement_action_available_provider + * @return array */ - public function test_is_html_editor_placement_action_available( - string $actionname, - string $actionclass, - ): void { - // Provider is not enabled. - $this->setUser($this->users[1]); - $this->assertFalse(utils::is_html_editor_placement_action_available( - context: $this->context, - actionname: $actionname, - actionclass: $actionclass - )); + public static function html_editor_placement_actions_available_provider(): array { + return [ + 'Two actions' => [ + 'actionstouse' => [ + 'generate_text', + 'generate_image', + ], + 'expectedcount' => 2, + ], + 'Generate text only' => [ + 'actionstouse' => [ + 'generate_text', + ], + 'expectedcount' => 1, + ], + 'Generate image only' => [ + 'actionstouse' => [ + 'generate_image', + ], + 'expectedcount' => 1, + ], + 'No actions' => [ + 'actionstouse' => [], + 'expectedcount' => 0, + ], + ]; + } - // Plugin is not enabled. - $this->setUser($this->users[1]); + /** + * Test is_html_editor_placement_action_available method. + */ + public function test_is_html_editor_placement_action_available(): void { + // Everything is disabled to begin with, and user does not have capability. + // Sequentially enable settings until all conditions are met. + $actionname = 'generate_text'; + $actionclass = 'core_ai\\aiactions\\' . $actionname; set_config('enabled', 0, 'aiplacement_editor'); - $this->assertFalse(utils::is_html_editor_placement_action_available( - context: $this->context, - actionname: $actionname, - actionclass: $actionclass - )); - - // Plugin is enabled but user does not have capability. + set_config($actionname, 0, 'aiplacement_editor'); assign_capability("aiplacement/editor:{$actionname}", CAP_PROHIBIT, $this->teacherrole->id, $this->context); $this->setUser($this->users[2]); + + // Enable the placement plugin. set_config('enabled', 1, 'aiplacement_editor'); $this->assertFalse(utils::is_html_editor_placement_action_available( context: $this->context, @@ -89,33 +109,25 @@ final class utils_test extends \advanced_testcase { actionclass: $actionclass )); - // Plugin is enabled, user has capability and placement action is not available. - $this->setUser($this->users[1]); - set_config($actionname, 0, 'aiplacement_editor'); + // Enable the provider. + $this->create_ai_provider([$actionname], \aiprovider_openai\provider::class); $this->assertFalse(utils::is_html_editor_placement_action_available( context: $this->context, actionname: $actionname, actionclass: $actionclass )); - // Plugin is enabled, user has capability and provider action is not available. + // Switch to a user with the required capability. $this->setUser($this->users[1]); + $this->assertFalse(utils::is_html_editor_placement_action_available( + context: $this->context, + actionname: $actionname, + actionclass: $actionclass + )); + + // Enable the action for the placement plugin. + // All requirements should now be met. set_config($actionname, 1, 'aiplacement_editor'); - $this->assertFalse(utils::is_html_editor_placement_action_available( - context: $this->context, - actionname: $actionname, - actionclass: $actionclass - )); - - // Plugin is enabled, user has capability, placement action is available and provider action is available. - $mockmanager = $this->createMock(\core_ai\manager::class); - $mockmanager->method('is_action_available')->willReturn(true); - $mockmanager->method('is_action_enabled')->willReturn(true); - - \core\di::set(\core_ai\manager::class, function() use ($mockmanager) { - return $mockmanager; - }); - $this->setUser($this->users[1]); $this->assertTrue(utils::is_html_editor_placement_action_available( context: $this->context, actionname: $actionname, @@ -124,20 +136,46 @@ final class utils_test extends \advanced_testcase { } /** - * Data provider for {@see test_is_html_editor_placement_action_available} + * Test get_actions_available method. * - * @return array + * @param array $actionstouse The actions to use. + * @param int $expectedcount Expected count of actions. + * @dataProvider html_editor_placement_actions_available_provider */ - public static function html_editor_placement_action_available_provider(): array { - return [ - 'Text generation' => [ - 'generate_text', - generate_text::class, - ], - 'Image generation' => [ - 'generate_image', - generate_image::class, - ], - ]; + public function test_get_actions_available( + array $actionstouse, + int $expectedcount, + ): void { + $this->setUser($this->users[2]); + // Set up the provider with the required action config. + $this->create_ai_provider($actionstouse, \aiprovider_openai\provider::class); + set_config('enabled', 1, 'aiplacement_editor'); + + // Enable the actions and check the count. + foreach ($actionstouse as $action) { + set_config($action, 1, 'aiplacement_editor'); + } + $actions = utils::get_actions_available($this->context, true); + $this->assertCount($expectedcount, $actions); + + // Prohibit the user and check again. + foreach ($actionstouse as $action) { + assign_capability("aiplacement/editor:{$action}", CAP_PROHIBIT, $this->teacherrole->id, $this->context); + } + $actions = utils::get_actions_available($this->context, true); + $this->assertCount(0, $actions); + } + + /** + * Test is_html_editor_placement_available method. + */ + public function test_is_html_editor_placement_available(): void { + // Plugin is not enabled. + set_config('enabled', 0, 'aiplacement_editor'); + $this->assertFalse(utils::is_html_editor_placement_available()); + + // Plugin is enabled. + set_config('enabled', 1, 'aiplacement_editor'); + $this->assertTrue(utils::is_html_editor_placement_available()); } } diff --git a/public/ai/tests/ai_test_trait.php b/public/ai/tests/ai_test_trait.php new file mode 100644 index 00000000000..aa2dc8c0462 --- /dev/null +++ b/public/ai/tests/ai_test_trait.php @@ -0,0 +1,59 @@ +. + +namespace core_ai; + +/** + * Test trait for AI. + * + * @package core_ai + * @category test + * @copyright 2025 Stevani Andolo + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +trait ai_test_trait { + /** + * Creates a dummy AI provider. + * + * @param array $actions A set of actions to configure the provider with. + * @param string $providerclass + */ + private function create_ai_provider(array $actions, $providerclass): void { + global $DB; + + $actionconfig = []; + foreach ($actions as $action) { + $actionclass = 'core_ai\\aiactions\\' . $action; + $actionconfig[$actionclass] = [ + 'enabled' => true, + 'settings' => [ + 'model' => 'test', + 'endpoint' => 'test', + 'systeminstruction' => 'test', + ], + ]; + } + + $config = ['apikey' => 'test']; + $record = new \stdClass(); + $record->name = 'test'; + $record->provider = $providerclass; + $record->enabled = 1; + $record->config = json_encode($config); + $record->actionconfig = json_encode($actionconfig); + $DB->insert_record('ai_providers', $record); + } +} diff --git a/public/ai/tests/manager_test.php b/public/ai/tests/manager_test.php index fde4e81f268..f5e2346ad30 100644 --- a/public/ai/tests/manager_test.php +++ b/public/ai/tests/manager_test.php @@ -746,4 +746,142 @@ final class manager_test extends \advanced_testcase { $result = $manager->is_action_available($action); $this->assertFalse($result); } + + /** + * Test is_ai_tools_enabled_in_course method. + * + * @return void + */ + public function test_is_ai_tools_enabled_in_course(): void { + global $DB; + + $this->resetAfterTest(); + + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + $context = \context_course::instance($course->id); + + $manager = \core\di::get(manager::class); + $aitoolsenabled = $manager::is_ai_tools_enabled_in_course($context); + $this->assertTrue($aitoolsenabled); + + $course->enableaitools = 0; + $DB->update_record('course', $course); + + $aitoolsenabled = $manager::is_ai_tools_enabled_in_course($context); + $this->assertFalse($aitoolsenabled); + } + + /** + * Test get_enabled_actions_in_course_module method. + * + * @param string $enabledactions + * @param array $expectedactions + * @dataProvider ai_actions_provider + * @return void + */ + public function test_get_enabled_actions_in_course_module( + string $enabledactions, + array $expectedactions, + ): void { + global $PAGE; + + $this->resetAfterTest(); + + $manager = \core\di::get(manager::class); + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + + // Create forum module and add enabled AI actions. + $module = $generator->create_module('forum', [ + 'course' => $course->id, + 'enabledaiactions' => $enabledactions, + ]); + + // Set the page context to the module context. + $ctx = \context_module::instance($module->cmid); + $PAGE->set_context($ctx); + + // Get all enabled actions in a course module. + $record = $manager::get_ai_fields_from_course_module($module->cmid); + $allactions = $manager::get_enabled_actions_in_course_module($record); + foreach ($expectedactions as $expectedaction) { + $this->assertContains($expectedaction, $allactions); + } + $this->assertCount(count($expectedactions), $allactions); + } + + /** + * Test is_action_enabled_in_context method. + * + * @return void + */ + public function test_is_action_enabled_in_context(): void { + global $PAGE; + + $this->resetAfterTest(); + + $manager = \core\di::get(manager::class); + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + + // Create forum module and enabled only the generate text action. + $module = $generator->create_module('forum', [ + 'course' => $course->id, + 'enabledaiactions' => json_encode(['generate_text' => 1]), + ]); + + // Set the page context to the module context. + $modulecontext = \context_module::instance($module->cmid); + $PAGE->set_context($modulecontext); + + // Only the generate text action should be available. + $result = $manager->is_action_enabled_in_context($modulecontext, generate_text::class); + $this->assertTrue($result); + $result = $manager->is_action_enabled_in_context($modulecontext, explain_text::class); + $this->assertFalse($result); + + // Explain text should be available outside the module context. + $systemcontext = \context_system::instance(); + $result = $manager->is_action_enabled_in_context($systemcontext, explain_text::class); + $this->assertTrue($result); + } + + /** + * Data provider for {@see test_get_enabled_actions_in_course_module} + * + * @return array + */ + public static function ai_actions_provider(): array { + return [ + 'actioncombo1' => [ + json_encode(['generate_text' => 1, 'generate_image' => 1]), + [ + generate_text::class, + generate_image::class, + ], + ], + 'actioncombo2' => [ + json_encode(['summarise_text' => 1, 'explain_text' => 1]), + [ + summarise_text::class, + explain_text::class, + ], + ], + 'actioncombo3' => [ + json_encode(['summarise_text' => 1, 'explain_text' => 0, 'generate_text' => 1, 'generate_image' => 1]), + [ + summarise_text::class, + generate_text::class, + generate_image::class, + ], + ], + 'actioncombo4' => [ + json_encode(['summarise_text' => 0, 'explain_text' => 1, 'generate_text' => 0, 'generate_image' => 0]), + [ + explain_text::class, + ], + ], + ]; + } } diff --git a/public/lang/en/ai.php b/public/lang/en/ai.php index 44e3276990b..06241f81a67 100644 --- a/public/lang/en/ai.php +++ b/public/lang/en/ai.php @@ -26,6 +26,7 @@ $string['acceptai'] = 'Accept and continue'; $string['action'] = 'Action'; $string['action_explain_text'] = 'Explain text'; $string['action_explain_text_desc'] = 'Explains the text content on a course page.'; +$string['action_explain_text_help'] = 'Provides an explanation that expands on key ideas, simplifies complex concepts, and adds context to make the text easier to understand.'; $string['action_explain_text_instruction'] = 'You will receive a text input from the user. Your task is to explain the provided text. Follow these guidelines: 1. Elaborate: Expand on key ideas and concepts, ensuring the explanation adds meaningful depth and avoids restating the text verbatim. 2. Simplify: Break down complex terms or ideas into simpler components, making them easy to understand for a wide audience, including learners. @@ -40,13 +41,16 @@ Important Instructions: Ensure the explanation is easy to read and effectively conveys the main points of the original text.'; $string['action_generate_image'] = 'Generate image'; $string['action_generate_image_desc'] = 'Generates an image based on a text prompt.'; +$string['action_generate_image_help'] = 'Creates an image based on a prompt.'; $string['action_generate_text'] = 'Generate text'; $string['action_generate_text_desc'] = 'Generates text based on a text prompt.'; +$string['action_generate_text_help'] = 'Creates a text based on a prompt.'; $string['action_generate_text_instruction'] = 'You will receive a text input from the user. Your task is to generate text based on their request. Follow these important instructions: 1. Return the summary in plain text only. 2. Do not include any markdown formatting, greetings, or platitudes.'; $string['action_summarise_text'] = 'Summarise text'; $string['action_summarise_text_desc'] = 'Summarises the text content on a course page.'; +$string['action_summarise_text_help'] = 'Creates a brief summary of the content in a page.'; $string['action_summarise_text_instruction'] = 'You will receive a text input from the user. Your task is to summarize the provided text. Follow these guidelines: 1. Condense: Shorten long passages into key points. 2. Simplify: Make complex information easier to understand, especially for learners. @@ -64,12 +68,17 @@ $string['actionsettingprovider_desc'] = 'These settings control how the {$a->pro $string['actionsettings'] = 'Action settings'; $string['actionsettings_desc'] = 'These settings control the AI actions for this provider instance.'; $string['ai'] = 'AI'; +$string['aiactionshdr'] = 'Select AI features for this activity:'; $string['aiactionregister'] = 'AI action register'; $string['aiplacements'] = 'AI placements'; $string['aipolicyacceptance'] = 'AI policy acceptance'; $string['aipolicyregister'] = 'AI policy register'; $string['aiproviders'] = 'AI providers'; $string['aireports'] = 'AI reports'; +$string['aitools'] = 'AI tools'; +$string['aitoolsincourseactivitydesc'] = 'If set to Yes, you can specify which AI features will be available.'; +$string['aitoolsincoursedesc'] = 'If set to Yes, AI tools will be available for activities in this course. AI tools can be configured in each activity\'s setting.'; +$string['aitoolsnotenabled'] = 'To specify which AI features you want to be available in this activity. Go to course settings and allow AI Tools.'; $string['aiusage'] = 'AI usage'; $string['aiusagepolicy'] = 'AI usage policy'; $string['availableplacements'] = 'Choose where AI actions are available'; @@ -86,6 +95,8 @@ $string['contentwatermark'] = 'Generated by AI'; $string['createnewprovider'] = 'Create a new provider instance'; $string['dateaccepted'] = 'Date accepted'; $string['declineaipolicy'] = 'Decline'; +$string['enableaitoolsincourse'] = 'Allow AI tools for this course'; +$string['enableaitoolsincourseactivity'] = 'Allow AI tools in this activity'; $string['enableglobalratelimit'] = 'Set site-wide rate limit'; $string['enableglobalratelimit_help'] = 'Limit the number of requests that the AI provider can receive across the entire site every hour.'; $string['enableuserratelimit'] = 'Set user rate limit'; @@ -113,6 +124,8 @@ $string['globalratelimit_help'] = 'The number of site-wide requests allowed per $string['manageaiplacements'] = 'Manage AI placements'; $string['manageaiproviders'] = 'Manage AI providers'; $string['noproviders'] = 'This action is unavailable. No AI providers are configured for this action.'; +$string['off'] = 'Off'; +$string['on'] = 'On'; $string['placement'] = 'Placement'; $string['placementactionsettings'] = 'Actions'; $string['placementactionsettings_desc'] = 'The AI actions available for this placement.'; diff --git a/public/lib/datalib.php b/public/lib/datalib.php index b64694b7ab1..94ca7df03d5 100644 --- a/public/lib/datalib.php +++ b/public/lib/datalib.php @@ -1394,7 +1394,7 @@ function get_all_instances_in_courses($modulename, $courses, $userid=NULL, $incl $params['modulename'] = $modulename; if (!$rawmods = $DB->get_records_sql("SELECT cm.id AS coursemodule, m.*, cw.section, cm.visible AS visible, - cm.groupmode, cm.groupingid, cm.lang + cm.groupmode, cm.groupingid, cm.lang, cm.enableaitools, cm.enabledaiactions FROM {course_modules} cm, {course_sections} cw, {modules} md, {".$modulename."} m WHERE cm.course $coursessql AND diff --git a/public/lib/db/install.xml b/public/lib/db/install.xml index c730da5458c..dbc8c6bf639 100644 --- a/public/lib/db/install.xml +++ b/public/lib/db/install.xml @@ -106,6 +106,7 @@ + @@ -345,6 +346,8 @@ + + diff --git a/public/lib/db/upgrade.php b/public/lib/db/upgrade.php index abd67aa0920..8421206ef15 100644 --- a/public/lib/db/upgrade.php +++ b/public/lib/db/upgrade.php @@ -2094,5 +2094,36 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2025082900.01); } + if ($oldversion < 2025090200.01) { + // Define field enableaitools to be added to course. + $table = new xmldb_table('course'); + $field = new xmldb_field('enableaitools', XMLDB_TYPE_INTEGER, '1', null, null, null, null, 'pdfexportfont'); + + // Conditionally launch add field enableaitools. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define field enableaitools to be added to course_modules. + $table = new xmldb_table('course_modules'); + $field = new xmldb_field('enableaitools', XMLDB_TYPE_INTEGER, '1', null, null, null, null, 'lang'); + + // Conditionally launch add field enableaitools. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Define field enabledaiactions to be added to course_modules. + $field = new xmldb_field('enabledaiactions', XMLDB_TYPE_TEXT, null, null, null, null, null, 'enableaitools'); + + // Conditionally launch add field enabledaiactions. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2025090200.01); + } + return true; } diff --git a/public/version.php b/public/version.php index ad93bde3688..61b113e09e0 100644 --- a/public/version.php +++ b/public/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2025090200.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2025090200.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '5.1dev+ (Build: 20250902)'; // Human-friendly version name