From 2073cba52b809fff204da985c861c8e891b3d122 Mon Sep 17 00:00:00 2001 From: Laurent David Date: Mon, 21 Oct 2024 13:26:55 +0200 Subject: [PATCH] MDL-83503 ai_modassist: New AI Module assistant * Add a new AI placement so modules (like glossary) can add custom actions * Add tests --- .../modassist/amd/build/placement.min.js | 3 + .../modassist/amd/build/placement.min.js.map | 1 + .../modassist/amd/build/selectors.min.js | 3 + .../modassist/amd/build/selectors.min.js.map | 1 + ai/placement/modassist/amd/src/placement.js | 509 ++++++++++++++++++ ai/placement/modassist/amd/src/selectors.js | 39 ++ .../classes/action_process_response.php | 112 ++++ .../classes/external/generate_content.php | 161 ++++++ .../classes/external/process_response.php | 147 +++++ .../classes/form/mod_assist_action_form.php | 141 +++++ .../modassist/classes/hook_callbacks.php | 37 ++ .../modassist/classes/mod_action_info.php | 39 ++ .../modassist/classes/mod_assist_info.php | 85 +++ .../modassist/classes/output/assist_ui.php | 112 ++++ ai/placement/modassist/classes/placement.php | 36 ++ .../modassist/classes/privacy/provider.php | 35 ++ ai/placement/modassist/classes/utils.php | 119 ++++ ai/placement/modassist/db/access.php | 36 ++ ai/placement/modassist/db/hooks.php | 33 ++ ai/placement/modassist/db/services.php | 40 ++ .../lang/en/aiplacement_modassist.php | 33 ++ ai/placement/modassist/pix/sparkles-white.svg | 8 + ai/placement/modassist/pix/sparkles.svg | 8 + ai/placement/modassist/styles.css | 28 + .../templates/action_buttons.mustache | 60 +++ .../modassist/templates/drawer.mustache | 49 ++ .../modassist/templates/error.mustache | 43 ++ .../modassist/templates/loading.mustache | 43 ++ .../modassist/templates/response.mustache | 57 ++ .../mod_fake/classes/ai/mod_assist_info.php | 87 +++ .../fakeplugins/mod_fake/db/install.xml | 23 + .../fakeplugins/mod_fake/lang/en/fake.php | 28 + .../fixtures/fakeplugins/mod_fake/lib.php | 97 ++++ .../mod_fake/tests/generator/lib.php | 26 + .../fixtures/fakeplugins/mod_fake/version.php | 31 ++ ai/placement/modassist/tests/utils_test.php | 107 ++++ ai/placement/modassist/version.php | 30 ++ 37 files changed, 2447 insertions(+) create mode 100644 ai/placement/modassist/amd/build/placement.min.js create mode 100644 ai/placement/modassist/amd/build/placement.min.js.map create mode 100644 ai/placement/modassist/amd/build/selectors.min.js create mode 100644 ai/placement/modassist/amd/build/selectors.min.js.map create mode 100644 ai/placement/modassist/amd/src/placement.js create mode 100644 ai/placement/modassist/amd/src/selectors.js create mode 100644 ai/placement/modassist/classes/action_process_response.php create mode 100644 ai/placement/modassist/classes/external/generate_content.php create mode 100644 ai/placement/modassist/classes/external/process_response.php create mode 100644 ai/placement/modassist/classes/form/mod_assist_action_form.php create mode 100644 ai/placement/modassist/classes/hook_callbacks.php create mode 100644 ai/placement/modassist/classes/mod_action_info.php create mode 100644 ai/placement/modassist/classes/mod_assist_info.php create mode 100644 ai/placement/modassist/classes/output/assist_ui.php create mode 100644 ai/placement/modassist/classes/placement.php create mode 100644 ai/placement/modassist/classes/privacy/provider.php create mode 100644 ai/placement/modassist/classes/utils.php create mode 100644 ai/placement/modassist/db/access.php create mode 100644 ai/placement/modassist/db/hooks.php create mode 100644 ai/placement/modassist/db/services.php create mode 100644 ai/placement/modassist/lang/en/aiplacement_modassist.php create mode 100644 ai/placement/modassist/pix/sparkles-white.svg create mode 100644 ai/placement/modassist/pix/sparkles.svg create mode 100644 ai/placement/modassist/styles.css create mode 100644 ai/placement/modassist/templates/action_buttons.mustache create mode 100644 ai/placement/modassist/templates/drawer.mustache create mode 100644 ai/placement/modassist/templates/error.mustache create mode 100644 ai/placement/modassist/templates/loading.mustache create mode 100644 ai/placement/modassist/templates/response.mustache create mode 100644 ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/classes/ai/mod_assist_info.php create mode 100644 ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/db/install.xml create mode 100644 ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/lang/en/fake.php create mode 100644 ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/lib.php create mode 100644 ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/tests/generator/lib.php create mode 100644 ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/version.php create mode 100644 ai/placement/modassist/tests/utils_test.php create mode 100644 ai/placement/modassist/version.php diff --git a/ai/placement/modassist/amd/build/placement.min.js b/ai/placement/modassist/amd/build/placement.min.js new file mode 100644 index 0000000000000..7c9c915affd0a --- /dev/null +++ b/ai/placement/modassist/amd/build/placement.min.js @@ -0,0 +1,3 @@ +define("aiplacement_modassist/placement",["exports","core/templates","core/ajax","core/copy_to_clipboard","core/notification","aiplacement_modassist/selectors","core_ai/policy","core_ai/helper","core/drawer_events","core/pubsub","core_message/message_drawer_helper","core_form/modalform","core/str"],(function(_exports,_templates,_ajax,_copy_to_clipboard,_notification,_selectors,_policy,_helper,_drawer_events,_pubsub,MessageDrawerHelper,_modalform,_str){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 _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=function(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]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(MessageDrawerHelper),_modalform=_interopRequireDefault(_modalform);var _default=class{constructor(userId,contextId){_defineProperty(this,"userId",void 0),_defineProperty(this,"contextId",void 0),_defineProperty(this,"currentAction",void 0),_defineProperty(this,"currentActionData",void 0),_defineProperty(this,"currentGeneratedContent",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.clearActions(),this.registerEventListeners()}registerEventListeners(){const actionButtons=document.querySelectorAll(_selectors.default.ACTIONS.RUN);actionButtons&&(actionButtons.forEach((element=>{element.addEventListener("click",(async event=>{event.preventDefault(),this.toggleAIDrawer();if(!await this.isPolicyAccepted())return void this.displayPolicy();const modalForm=new _modalform.default({modalConfig:{title:element.dataset.actionDescription},formClass:"aiplacement_modassist\\form\\mod_assist_action_form",args:{userid:this.userId,action:element.dataset.actionSubtype,component:element.dataset.component,cmid:element.dataset.cmid},saveButtonText:(0,_str.getString)("continue")});modalForm.addEventListener(modalForm.events.FORM_SUBMITTED,(event=>{event.detail.result?(this.setCurrentAction(element.dataset.actionSubtype,event.detail.actiondata),this.generateContent()):(this.clearActions(),_notification.default.addNotification({type:"error",message:event.detail.errors.join("
")}))})),modalForm.show()}))})),(0,_pubsub.subscribe)(_drawer_events.default.DRAWER_SHOWN,(()=>{this.isAIDrawerOpen()&&this.closeAIDrawer()})))}registerPolicyEventListeners(){const acceptAction=document.querySelector(_selectors.default.ACTIONS.ACCEPT),declineAction=document.querySelector(_selectors.default.ACTIONS.DECLINE);acceptAction&&acceptAction.addEventListener("click",(e=>{e.preventDefault(),this.acceptPolicy().then((()=>this.generateContent())).catch(_notification.default.exception)})),declineAction&&declineAction.addEventListener("click",(e=>{e.preventDefault(),this.closeAIDrawer()}))}registerErrorEventListeners(){const retryAction=document.querySelector(_selectors.default.ACTIONS.RETRY);retryAction&&retryAction.addEventListener("click",(e=>{e.preventDefault(),this.aiDrawerBodyElement.dataset.hasdata="0",this.generateContent()}))}registerResponseEventListeners(){const regenerateAction=document.querySelector(_selectors.default.ACTIONS.REGENERATE);regenerateAction&®enerateAction.addEventListener("click",(e=>{e.preventDefault(),this.aiDrawerBodyElement.dataset.hasdata="0",this.generateContent()}));const applyAction=document.querySelector(_selectors.default.ACTIONS.APPLY);applyAction&&applyAction.addEventListener("click",(e=>{e.preventDefault(),this.applyActions()}))}registerLoadingEventListeners(){const cancelAction=document.querySelector(_selectors.default.ACTIONS.CANCEL);cancelAction&&cancelAction.addEventListener("click",(e=>{e.preventDefault(),this.setRequestCancelled(),this.toggleAIDrawer()}))}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.aiDrawerBodyElement.setAttribute("aria-live","polite"),this.pageElement.classList.contains("show-drawer-right")||this.addPadding(),this.disableActionButton()}closeAIDrawer(){this.aiDrawerElement.classList.remove("show"),this.aiDrawerBodyElement.removeAttribute("aria-live"),this.pageElement.classList.contains("show-drawer-right")&&"1"===this.aiDrawerBodyElement.dataset.removepadding&&this.removePadding(),this.enableActionButton()}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"}disableActionButton(){const currentAction=this.getCurrentAction();if(!currentAction)return;const summaryButton=document.querySelector(_selectors.default.ACTIONS.RUN+'[data-action-subtype="'+currentAction.action+'"]');summaryButton&&summaryButton.setAttribute("disabled",1)}enableActionButton(){const currentAction=this.getCurrentAction();if(!currentAction)return;const summaryButton=document.querySelector(_selectors.default.ACTIONS.RUN+'[data-action-subtype="'+currentAction.action+'"]');summaryButton&&(summaryButton.removeAttribute("disabled"),summaryButton.focus())}async isPolicyAccepted(){return await _policy.default.getPolicyStatus(this.userId)}acceptPolicy(){return _policy.default.acceptPolicy()}hasGeneratedContent(){return"1"===this.aiDrawerBodyElement.dataset.hasdata}displayPolicy(){_templates.default.render("core_ai/policyblock",{}).then((html=>{this.aiDrawerBodyElement.innerHTML=html,this.registerPolicyEventListeners()})).catch(_notification.default.exception)}displayLoading(){_templates.default.render("aiplacement_modassist/loading",{}).then((html=>{this.aiDrawerBodyElement.innerHTML=html,this.registerLoadingEventListeners()})).catch(_notification.default.exception)}async generateContent(){const currentAction=this.getCurrentAction();if(currentAction&&!this.hasGeneratedContent()&¤tAction){this.displayLoading(),this.aiDrawerBodyElement.innerHTML="";const request={methodname:"aiplacement_modassist_generate_content",args:{contextid:this.contextId,action:currentAction.action,data:JSON.stringify(currentAction.actionData)}};try{const responseObj=await _ajax.default.call([request])[0];if(this.aiDrawerBodyElement.dataset.rawGeneratedContent="",responseObj.error)return void this.displayError();if(!this.isRequestCancelled()){this.aiDrawerBodyElement.dataset.rawGeneratedContent=responseObj.generatedcontent;const generatedContent=_helper.default.replaceLineBreaks(responseObj.generatedcontent);return void this.displayResponse(generatedContent)}this.aiDrawerBodyElement.dataset.cancelled="0"}catch(error){window.console.log(error),this.displayError()}}}async applyActions(){const currentAction=this.getCurrentAction();if(currentAction&&this.hasGeneratedContent())try{this.displayLoading(),this.aiDrawerBodyElement.innerHTML="";const request={methodname:"aiplacement_modassist_process_response",args:{contextid:this.contextId,action:currentAction.action,generatedcontent:this.aiDrawerBodyElement.dataset.rawGeneratedContent}},responseObj=await _ajax.default.call([request])[0];if(responseObj.error)return void this.displayError();_notification.default.addNotification({type:"success",message:responseObj.message}),window.location.reload()}catch(error){window.console.log(error),this.displayError()}}displayResponse(content){_templates.default.render("aiplacement_modassist/response",{content:content}).then((html=>{this.aiDrawerBodyElement.innerHTML=html,this.aiDrawerBodyElement.dataset.hasdata="1",this.registerResponseEventListeners()})).catch(_notification.default.exception)}displayError(){_templates.default.render("aiplacement_modassist/error",{}).then((html=>{this.aiDrawerBodyElement.innerHTML=html,this.registerErrorEventListeners()})).catch(_notification.default.exception)}getTextContent(){const mainRegion=document.querySelector(_selectors.default.ELEMENTS.MAIN_REGION);return mainRegion.innerText||mainRegion.textContent}clearActions(){this.aiDrawerBodyElement.dataset.currentAction="",this.aiDrawerBodyElement.dataset.currentActionData=""}setCurrentAction(action,actionData){this.aiDrawerBodyElement.dataset.currentAction=action,this.aiDrawerBodyElement.dataset.currentActionData=JSON.stringify(actionData)}getCurrentAction(){return this.aiDrawerBodyElement.dataset.currentAction?{action:this.aiDrawerBodyElement.dataset.currentAction,actionData:JSON.parse(this.aiDrawerBodyElement.dataset.currentActionData)}:null}};return _exports.default=_default,_exports.default})); + +//# sourceMappingURL=placement.min.js.map \ No newline at end of file diff --git a/ai/placement/modassist/amd/build/placement.min.js.map b/ai/placement/modassist/amd/build/placement.min.js.map new file mode 100644 index 0000000000000..82150712b910f --- /dev/null +++ b/ai/placement/modassist/amd/build/placement.min.js.map @@ -0,0 +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_modassist/placement\n * @copyright 2024 Laurent David \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_modassist/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 ModalForm from 'core_form/modalform';\nimport {getString} from 'core/str';\n\nconst AIModAssist = 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 * The current action\n * @type {String}\n */\n currentAction;\n /**\n * The current action data\n * @type {String}\n */\n currentActionData;\n\n /**\n * The current generated content data\n * @type {String}\n */\n currentGeneratedContent;\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.clearActions();\n this.registerEventListeners();\n }\n\n /**\n * Register event listeners.\n */\n registerEventListeners() {\n const actionButtons = document.querySelectorAll(Selectors.ACTIONS.RUN);\n if (!actionButtons) {\n return;\n }\n actionButtons.forEach((element) => {\n element.addEventListener('click', async(event) => {\n event.preventDefault();\n this.toggleAIDrawer();\n const isPolicyAccepted = await this.isPolicyAccepted();\n if (!isPolicyAccepted) {\n // Display policy.\n this.displayPolicy();\n return;\n }\n const modalForm = new ModalForm({\n modalConfig: {\n title: element.dataset.actionDescription,\n },\n formClass: 'aiplacement_modassist\\\\form\\\\mod_assist_action_form',\n args: {\n userid: this.userId,\n action: element.dataset.actionSubtype,\n component: element.dataset.component,\n cmid: element.dataset.cmid\n },\n saveButtonText: getString('continue'),\n });\n\n modalForm.addEventListener(modalForm.events.FORM_SUBMITTED, event => {\n if (event.detail.result) {\n // Notify the user that the action was successful.\n this.setCurrentAction(element.dataset.actionSubtype, event.detail.actiondata);\n this.generateContent();\n } else {\n this.clearActions();\n Notification.addNotification({\n type: 'error',\n message: event.detail.errors.join('
')\n });\n }\n });\n modalForm.show();\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\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) {\n acceptAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.acceptPolicy().then(() => {\n return this.generateContent();\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) {\n retryAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.aiDrawerBodyElement.dataset.hasdata = '0';\n this.generateContent();\n });\n }\n }\n\n /**\n * Register event listeners for the response.\n */\n registerResponseEventListeners() {\n const regenerateAction = document.querySelector(Selectors.ACTIONS.REGENERATE);\n if (regenerateAction) {\n regenerateAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.aiDrawerBodyElement.dataset.hasdata = '0';\n this.generateContent();\n });\n }\n const applyAction = document.querySelector(Selectors.ACTIONS.APPLY);\n if (applyAction) {\n applyAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.applyActions();\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 });\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.aiDrawerBodyElement.setAttribute('aria-live', 'polite');\n if (!this.pageElement.classList.contains('show-drawer-right')) {\n this.addPadding();\n }\n // Disable the summary button.\n this.disableActionButton();\n }\n\n /**\n * Close the AI drawer.\n */\n closeAIDrawer() {\n this.aiDrawerElement.classList.remove('show');\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 // Enable the summary button.\n this.enableActionButton();\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 * Disable the relevant button.\n */\n disableActionButton() {\n const currentAction = this.getCurrentAction();\n if (!currentAction) {\n return;\n }\n const summaryButton = document.querySelector(\n Selectors.ACTIONS.RUN + '[data-action-subtype=\"' + currentAction.action + '\"]'\n );\n if (summaryButton) {\n summaryButton.setAttribute('disabled', 1);\n }\n }\n\n /**\n * Enable the summary button and focus on it.\n */\n enableActionButton() {\n const currentAction = this.getCurrentAction();\n if (!currentAction) {\n return;\n }\n const summaryButton = document.querySelector(\n Selectors.ACTIONS.RUN + '[data-action-subtype=\"' + currentAction.action + '\"]'\n );\n if (summaryButton) {\n summaryButton.removeAttribute('disabled');\n summaryButton.focus();\n }\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 generated content or not.\n * @return {boolean} True if the AI drawer has generated content, false otherwise.\n */\n hasGeneratedContent() {\n return this.aiDrawerBodyElement.dataset.hasdata === '1';\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_modassist/loading', {}).then((html) => {\n this.aiDrawerBodyElement.innerHTML = html;\n this.registerLoadingEventListeners();\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the summary.\n */\n async generateContent() {\n const currentAction = this.getCurrentAction();\n if (!currentAction) {\n return;\n }\n if (!this.hasGeneratedContent() && currentAction) {\n // Display loading spinner.\n this.displayLoading();\n // Clear the drawer content to prevent sending some unnecessary content.\n this.aiDrawerBodyElement.innerHTML = '';\n const request = {\n methodname: 'aiplacement_modassist_generate_content',\n args: {\n contextid: this.contextId,\n action: currentAction.action,\n data: JSON.stringify(currentAction.actionData)\n }\n };\n try {\n const responseObj = await Ajax.call([request])[0];\n this.aiDrawerBodyElement.dataset.rawGeneratedContent = \"\";\n if (responseObj.error) {\n this.displayError();\n return;\n } else {\n if (!this.isRequestCancelled()) {\n // Replace double line breaks with
and with

for paragraphs.\n this.aiDrawerBodyElement.dataset.rawGeneratedContent = responseObj.generatedcontent;\n const generatedContent = AIHelper.replaceLineBreaks(responseObj.generatedcontent);\n this.displayResponse(generatedContent);\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 async applyActions() {\n const currentAction = this.getCurrentAction();\n if (!currentAction) {\n return;\n }\n if (this.hasGeneratedContent()) {\n try {\n this.displayLoading();\n // Clear the drawer content to prevent sending some unnecessary content.\n this.aiDrawerBodyElement.innerHTML = '';\n const request = {\n methodname: 'aiplacement_modassist_process_response',\n args: {\n contextid: this.contextId,\n action: currentAction.action,\n generatedcontent: this.aiDrawerBodyElement.dataset.rawGeneratedContent\n }\n };\n const responseObj = await Ajax.call([request])[0];\n if (responseObj.error) {\n this.displayError();\n return;\n } else {\n Notification.addNotification({\n type: 'success',\n message: responseObj.message\n });\n window.location.reload();\n }\n } catch (error) {\n window.console.log(error);\n this.displayError();\n }\n }\n\n }\n /**\n * Display the response.\n * @param {String} content The content to display.\n */\n displayResponse(content) {\n Templates.render('aiplacement_modassist/response', {content: content}).then((html) => {\n this.aiDrawerBodyElement.innerHTML = html;\n this.aiDrawerBodyElement.dataset.hasdata = '1';\n this.registerResponseEventListeners();\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the error.\n */\n displayError() {\n Templates.render('aiplacement_modassist/error', {}).then((html) => {\n this.aiDrawerBodyElement.innerHTML = html;\n this.registerErrorEventListeners();\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 /**\n * Finish the current action.\n */\n clearActions() {\n this.aiDrawerBodyElement.dataset.currentAction = '';\n this.aiDrawerBodyElement.dataset.currentActionData = '';\n }\n\n /**\n * Set the current action.\n * @param {String} action\n * @param {Object} actionData\n */\n setCurrentAction(action, actionData) {\n this.aiDrawerBodyElement.dataset.currentAction = action;\n this.aiDrawerBodyElement.dataset.currentActionData = JSON.stringify(actionData);\n }\n\n /**\n * Get current action.\n * @return {{action: string, actionData: any}|null}\n */\n getCurrentAction() {\n if (!this.aiDrawerBodyElement.dataset.currentAction) {\n return null;\n }\n return {\n action: this.aiDrawerBodyElement.dataset.currentAction,\n actionData: JSON.parse(this.aiDrawerBodyElement.dataset.currentActionData)\n };\n }\n};\n\nexport default AIModAssist;\n"],"names":["constructor","userId","contextId","aiDrawerElement","document","querySelector","Selectors","ELEMENTS","AIDRAWER","aiDrawerBodyElement","AIDRAWER_BODY","pageElement","PAGE","clearActions","registerEventListeners","actionButtons","querySelectorAll","ACTIONS","RUN","forEach","element","addEventListener","async","event","preventDefault","toggleAIDrawer","this","isPolicyAccepted","displayPolicy","modalForm","ModalForm","modalConfig","title","dataset","actionDescription","formClass","args","userid","action","actionSubtype","component","cmid","saveButtonText","events","FORM_SUBMITTED","detail","result","setCurrentAction","actiondata","generateContent","addNotification","type","message","errors","join","show","DrawerEvents","DRAWER_SHOWN","isAIDrawerOpen","closeAIDrawer","registerPolicyEventListeners","acceptAction","ACCEPT","declineAction","DECLINE","e","acceptPolicy","then","catch","Notification","exception","registerErrorEventListeners","retryAction","RETRY","hasdata","registerResponseEventListeners","regenerateAction","REGENERATE","applyAction","APPLY","applyActions","registerLoadingEventListeners","cancelAction","CANCEL","setRequestCancelled","classList","contains","isRequestCancelled","cancelled","openAIDrawer","MessageDrawerHelper","hide","add","setAttribute","addPadding","disableActionButton","remove","removeAttribute","removepadding","removePadding","enableActionButton","currentAction","getCurrentAction","summaryButton","focus","Policy","getPolicyStatus","hasGeneratedContent","render","html","innerHTML","displayLoading","request","methodname","contextid","data","JSON","stringify","actionData","responseObj","Ajax","call","rawGeneratedContent","error","displayError","generatedcontent","generatedContent","AIHelper","replaceLineBreaks","displayResponse","window","console","log","location","reload","content","getTextContent","mainRegion","MAIN_REGION","innerText","textContent","currentActionData","parse"],"mappings":"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"} \ No newline at end of file diff --git a/ai/placement/modassist/amd/build/selectors.min.js b/ai/placement/modassist/amd/build/selectors.min.js new file mode 100644 index 0000000000000..7633d8ef187f3 --- /dev/null +++ b/ai/placement/modassist/amd/build/selectors.min.js @@ -0,0 +1,3 @@ +define("aiplacement_modassist/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"]'},ACTIONS:{RUN:'[data-action="mod-ai-assist-run"]',RETRY:'[data-action="mod-ai-assist-retry"]',DECLINE:'[data-action="mod-ai-assist-policy-decline"]',ACCEPT:'.ai-policy-block [data-action="accept"]',REGENERATE:'[data-action="mod-ai-assist-regenerate"]',APPLY:'[data-action="mod-ai-assist-apply"]',CANCEL:'.ai-policy-block [data-action="decline"]'}},_exports.default})); + +//# sourceMappingURL=selectors.min.js.map \ No newline at end of file diff --git a/ai/placement/modassist/amd/build/selectors.min.js.map b/ai/placement/modassist/amd/build/selectors.min.js.map new file mode 100644 index 0000000000000..3b0cce4e82acd --- /dev/null +++ b/ai/placement/modassist/amd/build/selectors.min.js.map @@ -0,0 +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_modassist/selectors\n * @copyright 2024 Laurent David \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 },\n ACTIONS: {\n RUN: '[data-action=\"mod-ai-assist-run\"]',\n RETRY: '[data-action=\"mod-ai-assist-retry\"]',\n DECLINE: '[data-action=\"mod-ai-assist-policy-decline\"]',\n ACCEPT: '.ai-policy-block [data-action=\"accept\"]',\n REGENERATE: '[data-action=\"mod-ai-assist-regenerate\"]',\n APPLY: '[data-action=\"mod-ai-assist-apply\"]',\n CANCEL: '.ai-policy-block [data-action=\"decline\"]',\n }\n};\n"],"names":["ELEMENTS","AIDRAWER","AIDRAWER_BODY","PAGE","MAIN_REGION","ACTIONS","RUN","RETRY","DECLINE","ACCEPT","REGENERATE","APPLY","CANCEL"],"mappings":"iLAsBe,CACXA,SAAU,CACNC,SAAU,aACVC,cAAe,6BACfC,KAAM,QACNC,YAAa,iBAEjBC,QAAS,CACLC,IAAK,oCACLC,MAAO,sCACPC,QAAS,+CACTC,OAAQ,0CACRC,WAAY,2CACZC,MAAO,sCACPC,OAAQ"} \ No newline at end of file diff --git a/ai/placement/modassist/amd/src/placement.js b/ai/placement/modassist/amd/src/placement.js new file mode 100644 index 0000000000000..a76d2a06bc044 --- /dev/null +++ b/ai/placement/modassist/amd/src/placement.js @@ -0,0 +1,509 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Module to load and render the tools for the AI assist plugin. + * + * @module aiplacement_modassist/placement + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import Templates from 'core/templates'; +import Ajax from 'core/ajax'; +import 'core/copy_to_clipboard'; +import Notification from 'core/notification'; +import Selectors from 'aiplacement_modassist/selectors'; +import Policy from 'core_ai/policy'; +import AIHelper from 'core_ai/helper'; +import DrawerEvents from 'core/drawer_events'; +import {subscribe} from 'core/pubsub'; +import * as MessageDrawerHelper from 'core_message/message_drawer_helper'; +import ModalForm from 'core_form/modalform'; +import {getString} from 'core/str'; + +const AIModAssist = class { + + /** + * The user ID. + * @type {Integer} + */ + userId; + /** + * The context ID. + * @type {Integer} + */ + contextId; + + /** + * The current action + * @type {String} + */ + currentAction; + /** + * The current action data + * @type {String} + */ + currentActionData; + + /** + * The current generated content data + * @type {String} + */ + currentGeneratedContent; + + /** + * Constructor. + * @param {Integer} userId The user ID. + * @param {Integer} contextId The context ID. + */ + constructor(userId, contextId) { + this.userId = userId; + this.contextId = contextId; + + this.aiDrawerElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER); + this.aiDrawerBodyElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER_BODY); + this.pageElement = document.querySelector(Selectors.ELEMENTS.PAGE); + this.clearActions(); + this.registerEventListeners(); + } + + /** + * Register event listeners. + */ + registerEventListeners() { + const actionButtons = document.querySelectorAll(Selectors.ACTIONS.RUN); + if (!actionButtons) { + return; + } + actionButtons.forEach((element) => { + element.addEventListener('click', async(event) => { + event.preventDefault(); + this.toggleAIDrawer(); + const isPolicyAccepted = await this.isPolicyAccepted(); + if (!isPolicyAccepted) { + // Display policy. + this.displayPolicy(); + return; + } + const modalForm = new ModalForm({ + modalConfig: { + title: element.dataset.actionDescription, + }, + formClass: 'aiplacement_modassist\\form\\mod_assist_action_form', + args: { + userid: this.userId, + action: element.dataset.actionSubtype, + component: element.dataset.component, + cmid: element.dataset.cmid + }, + saveButtonText: getString('continue'), + }); + + modalForm.addEventListener(modalForm.events.FORM_SUBMITTED, event => { + if (event.detail.result) { + // Notify the user that the action was successful. + this.setCurrentAction(element.dataset.actionSubtype, event.detail.actiondata); + this.generateContent(); + } else { + this.clearActions(); + Notification.addNotification({ + type: 'error', + message: event.detail.errors.join('
') + }); + } + }); + modalForm.show(); + }); + }); + + // Close AI drawer if message drawer is shown. + subscribe(DrawerEvents.DRAWER_SHOWN, () => { + if (this.isAIDrawerOpen()) { + this.closeAIDrawer(); + } + }); + } + + /** + * Register event listeners for the policy. + */ + registerPolicyEventListeners() { + const acceptAction = document.querySelector(Selectors.ACTIONS.ACCEPT); + const declineAction = document.querySelector(Selectors.ACTIONS.DECLINE); + if (acceptAction) { + acceptAction.addEventListener('click', (e) => { + e.preventDefault(); + this.acceptPolicy().then(() => { + return this.generateContent(); + }).catch(Notification.exception); + }); + } + if (declineAction) { + declineAction.addEventListener('click', (e) => { + e.preventDefault(); + this.closeAIDrawer(); + }); + } + } + + /** + * Register event listeners for the error. + */ + registerErrorEventListeners() { + const retryAction = document.querySelector(Selectors.ACTIONS.RETRY); + if (retryAction) { + retryAction.addEventListener('click', (e) => { + e.preventDefault(); + this.aiDrawerBodyElement.dataset.hasdata = '0'; + this.generateContent(); + }); + } + } + + /** + * Register event listeners for the response. + */ + registerResponseEventListeners() { + const regenerateAction = document.querySelector(Selectors.ACTIONS.REGENERATE); + if (regenerateAction) { + regenerateAction.addEventListener('click', (e) => { + e.preventDefault(); + this.aiDrawerBodyElement.dataset.hasdata = '0'; + this.generateContent(); + }); + } + const applyAction = document.querySelector(Selectors.ACTIONS.APPLY); + if (applyAction) { + applyAction.addEventListener('click', (e) => { + e.preventDefault(); + this.applyActions(); + }); + } + } + + registerLoadingEventListeners() { + const cancelAction = document.querySelector(Selectors.ACTIONS.CANCEL); + if (cancelAction) { + cancelAction.addEventListener('click', (e) => { + e.preventDefault(); + this.setRequestCancelled(); + this.toggleAIDrawer(); + }); + } + } + + /** + * Check if the AI drawer is open. + * @return {boolean} True if the AI drawer is open, false otherwise. + */ + isAIDrawerOpen() { + return this.aiDrawerElement.classList.contains('show'); + } + + /** + * Check if the request is cancelled. + * @return {boolean} True if the request is cancelled, false otherwise. + */ + isRequestCancelled() { + return this.aiDrawerBodyElement.dataset.cancelled === '1'; + } + + setRequestCancelled() { + this.aiDrawerBodyElement.dataset.cancelled = '1'; + } + + /** + * Open the AI drawer. + */ + openAIDrawer() { + // Close message drawer if it is shown. + MessageDrawerHelper.hide(); + this.aiDrawerElement.classList.add('show'); + this.aiDrawerBodyElement.setAttribute('aria-live', 'polite'); + if (!this.pageElement.classList.contains('show-drawer-right')) { + this.addPadding(); + } + // Disable the summary button. + this.disableActionButton(); + } + + /** + * Close the AI drawer. + */ + closeAIDrawer() { + this.aiDrawerElement.classList.remove('show'); + this.aiDrawerBodyElement.removeAttribute('aria-live'); + if (this.pageElement.classList.contains('show-drawer-right') && this.aiDrawerBodyElement.dataset.removepadding === '1') { + this.removePadding(); + } + // Enable the summary button. + this.enableActionButton(); + } + + /** + * Toggle the AI drawer. + */ + toggleAIDrawer() { + if (this.isAIDrawerOpen()) { + this.closeAIDrawer(); + } else { + this.openAIDrawer(); + } + } + + /** + * Add padding to the page to make space for the AI drawer. + */ + addPadding() { + this.pageElement.classList.add('show-drawer-right'); + this.aiDrawerBodyElement.dataset.removepadding = '1'; + } + + /** + * Remove padding from the page. + */ + removePadding() { + this.pageElement.classList.remove('show-drawer-right'); + this.aiDrawerBodyElement.dataset.removepadding = '0'; + } + + /** + * Disable the relevant button. + */ + disableActionButton() { + const currentAction = this.getCurrentAction(); + if (!currentAction) { + return; + } + const summaryButton = document.querySelector( + Selectors.ACTIONS.RUN + '[data-action-subtype="' + currentAction.action + '"]' + ); + if (summaryButton) { + summaryButton.setAttribute('disabled', 1); + } + } + + /** + * Enable the summary button and focus on it. + */ + enableActionButton() { + const currentAction = this.getCurrentAction(); + if (!currentAction) { + return; + } + const summaryButton = document.querySelector( + Selectors.ACTIONS.RUN + '[data-action-subtype="' + currentAction.action + '"]' + ); + if (summaryButton) { + summaryButton.removeAttribute('disabled'); + summaryButton.focus(); + } + } + + /** + * Check if the policy is accepted. + * @return {bool} True if the policy is accepted, false otherwise. + */ + async isPolicyAccepted() { + return await Policy.getPolicyStatus(this.userId); + } + + /** + * Accept the policy. + * @return {Promise} + */ + acceptPolicy() { + return Policy.acceptPolicy(); + } + + /** + * Check if the AI drawer has generated content or not. + * @return {boolean} True if the AI drawer has generated content, false otherwise. + */ + hasGeneratedContent() { + return this.aiDrawerBodyElement.dataset.hasdata === '1'; + } + + /** + * Display the policy. + */ + displayPolicy() { + Templates.render('core_ai/policyblock', {}).then((html) => { + this.aiDrawerBodyElement.innerHTML = html; + this.registerPolicyEventListeners(); + return; + }).catch(Notification.exception); + } + + /** + * Display the loading spinner. + */ + displayLoading() { + Templates.render('aiplacement_modassist/loading', {}).then((html) => { + this.aiDrawerBodyElement.innerHTML = html; + this.registerLoadingEventListeners(); + return; + }).catch(Notification.exception); + } + + /** + * Display the summary. + */ + async generateContent() { + const currentAction = this.getCurrentAction(); + if (!currentAction) { + return; + } + if (!this.hasGeneratedContent() && currentAction) { + // Display loading spinner. + this.displayLoading(); + // Clear the drawer content to prevent sending some unnecessary content. + this.aiDrawerBodyElement.innerHTML = ''; + const request = { + methodname: 'aiplacement_modassist_generate_content', + args: { + contextid: this.contextId, + action: currentAction.action, + data: JSON.stringify(currentAction.actionData) + } + }; + try { + const responseObj = await Ajax.call([request])[0]; + this.aiDrawerBodyElement.dataset.rawGeneratedContent = ""; + if (responseObj.error) { + this.displayError(); + return; + } else { + if (!this.isRequestCancelled()) { + // Replace double line breaks with
and with

for paragraphs. + this.aiDrawerBodyElement.dataset.rawGeneratedContent = responseObj.generatedcontent; + const generatedContent = AIHelper.replaceLineBreaks(responseObj.generatedcontent); + this.displayResponse(generatedContent); + return; + } else { + this.aiDrawerBodyElement.dataset.cancelled = '0'; + } + } + } catch (error) { + window.console.log(error); + this.displayError(); + } + } + } + + async applyActions() { + const currentAction = this.getCurrentAction(); + if (!currentAction) { + return; + } + if (this.hasGeneratedContent()) { + try { + this.displayLoading(); + // Clear the drawer content to prevent sending some unnecessary content. + this.aiDrawerBodyElement.innerHTML = ''; + const request = { + methodname: 'aiplacement_modassist_process_response', + args: { + contextid: this.contextId, + action: currentAction.action, + generatedcontent: this.aiDrawerBodyElement.dataset.rawGeneratedContent + } + }; + const responseObj = await Ajax.call([request])[0]; + if (responseObj.error) { + this.displayError(); + return; + } else { + Notification.addNotification({ + type: 'success', + message: responseObj.message + }); + window.location.reload(); + } + } catch (error) { + window.console.log(error); + this.displayError(); + } + } + + } + /** + * Display the response. + * @param {String} content The content to display. + */ + displayResponse(content) { + Templates.render('aiplacement_modassist/response', {content: content}).then((html) => { + this.aiDrawerBodyElement.innerHTML = html; + this.aiDrawerBodyElement.dataset.hasdata = '1'; + this.registerResponseEventListeners(); + return; + }).catch(Notification.exception); + } + + /** + * Display the error. + */ + displayError() { + Templates.render('aiplacement_modassist/error', {}).then((html) => { + this.aiDrawerBodyElement.innerHTML = html; + this.registerErrorEventListeners(); + return; + }).catch(Notification.exception); + } + + /** + * Get the text content of the main region. + * @return {String} The text content. + */ + getTextContent() { + const mainRegion = document.querySelector(Selectors.ELEMENTS.MAIN_REGION); + return mainRegion.innerText || mainRegion.textContent; + } + + /** + * Finish the current action. + */ + clearActions() { + this.aiDrawerBodyElement.dataset.currentAction = ''; + this.aiDrawerBodyElement.dataset.currentActionData = ''; + } + + /** + * Set the current action. + * @param {String} action + * @param {Object} actionData + */ + setCurrentAction(action, actionData) { + this.aiDrawerBodyElement.dataset.currentAction = action; + this.aiDrawerBodyElement.dataset.currentActionData = JSON.stringify(actionData); + } + + /** + * Get current action. + * @return {{action: string, actionData: any}|null} + */ + getCurrentAction() { + if (!this.aiDrawerBodyElement.dataset.currentAction) { + return null; + } + return { + action: this.aiDrawerBodyElement.dataset.currentAction, + actionData: JSON.parse(this.aiDrawerBodyElement.dataset.currentActionData) + }; + } +}; + +export default AIModAssist; diff --git a/ai/placement/modassist/amd/src/selectors.js b/ai/placement/modassist/amd/src/selectors.js new file mode 100644 index 0000000000000..090a77f8edcff --- /dev/null +++ b/ai/placement/modassist/amd/src/selectors.js @@ -0,0 +1,39 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Define all of the selectors we will be using on the AI Course assistant. + * + * @module aiplacement_modassist/selectors + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +export default { + ELEMENTS: { + AIDRAWER: '#ai-drawer', + AIDRAWER_BODY: '#ai-drawer .ai-drawer-body', + PAGE: '#page', + MAIN_REGION: '[role="main"]', + }, + ACTIONS: { + RUN: '[data-action="mod-ai-assist-run"]', + RETRY: '[data-action="mod-ai-assist-retry"]', + DECLINE: '[data-action="mod-ai-assist-policy-decline"]', + ACCEPT: '.ai-policy-block [data-action="accept"]', + REGENERATE: '[data-action="mod-ai-assist-regenerate"]', + APPLY: '[data-action="mod-ai-assist-apply"]', + CANCEL: '.ai-policy-block [data-action="decline"]', + } +}; diff --git a/ai/placement/modassist/classes/action_process_response.php b/ai/placement/modassist/classes/action_process_response.php new file mode 100644 index 0000000000000..6c4865a08c459 --- /dev/null +++ b/ai/placement/modassist/classes/action_process_response.php @@ -0,0 +1,112 @@ +. + +namespace aiplacement_modassist; + +use core\exception\coding_exception; + +/** + * Mod action Information. + * + * To be implemented by each module (can be several actions for the same module). + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @since Moodle 4.0 + */ +class action_process_response { + /** + * Constructor. + * + * @param bool $success The success status of the action. + * @param string $actionname The name of the action that was processed. + * @param int $errorcode Error code. Must exist if success is false. + * @param string $errormessage Error message. Must exist if success is false + * @param string $successmessage Success message. Must exist if success is true + */ + public function __construct( + /** @var bool The success status of the action. */ + private bool $success, + /** @var string The name of the action that was processed. */ + private string $actionname, + /** @var int Error code. Must exist if status is error. */ + private int $errorcode = 0, + /** @var string Error message. Must exist if status is error */ + private string $errormessage = '', + /** @var string Success message. Must exist if status is success */ + private string $successmessage = '' + ) { + $this->timecreated = \core\di::get(\core\clock::class)->time(); + if (!$success && ($errorcode == 0 || empty($errormessage))) { + throw new coding_exception('Error code and message must exist in an error response.'); + } + } + + /** + * Get the success status of the action. + * + * @return bool + */ + public function get_success(): bool { + return $this->success; + } + + /** + * Get the timestamp of when the response was created. + * + * @return int + */ + public function get_timecreated(): int { + return $this->timecreated; + } + + /** + * Get the name of the action that was processed. + * + * @return string + */ + public function get_actionname(): string { + return $this->actionname; + } + + /** + * Get the error code. + * + * @return int + */ + public function get_errorcode(): int { + return $this->errorcode; + } + + /** + * Get the error message. + * + * @return string + */ + public function get_errormessage(): string { + return $this->errormessage; + } + + /** + * Get the success message. + * + * @return string + */ + public function get_successmessage(): string { + return $this->successmessage; + } +} diff --git a/ai/placement/modassist/classes/external/generate_content.php b/ai/placement/modassist/classes/external/generate_content.php new file mode 100644 index 0000000000000..28673dfc51c5c --- /dev/null +++ b/ai/placement/modassist/classes/external/generate_content.php @@ -0,0 +1,161 @@ +. + +namespace aiplacement_modassist\external; + +use aiplacement_modassist\utils; +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_value; + +/** + * External API to call summarise text action for this placement. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class generate_content extends external_api { + + /** + * Generate content for the given action and the given module. + * + * @param int $contextid The context ID. + * @param string $action The action for the module. + * @param string $data + * @return array The generated content. + * @throws \moodle_exception + * @since Moodle 4.5 + */ + public static function execute( + int $contextid, + string $action, + string $data + ): array { + // Parameter validation. + [ + 'contextid' => $contextid, + 'action' => $action, + 'data' => $data, + ] = self::validate_parameters(self::execute_parameters(), [ + 'contextid' => $contextid, + 'action' => $action, + 'data' => $data, + ]); + // Context validation and permission check. + // Get the context from the passed in ID. + $context = \context::instance_by_id($contextid); + + // Check the user has permission to use the AI service. + self::validate_context($context); + if (!utils::is_mod_assist_available($context)) { + throw new \moodle_exception('nomodassist', 'aiplacement_modassist'); + } + $modinfo = utils::get_info_for_module($context); + if (empty($modinfo)) { + throw new \moodle_exception('nomodassist', 'aiplacement_modassist'); + } + $decodedactiondata = json_decode($data); + if ($decodedactiondata === null) { + throw new \moodle_exception('invaliddata', 'aiplacement_modassist'); + } + $action = $modinfo->get_ai_action($context, $action, $decodedactiondata); + if ($action === null) { + throw new \moodle_exception('invalidaction', 'aiplacement_modassist'); + } + $manager = new \core_ai\manager(); + $response = $manager->process_action($action); + // Return the response. + return [ + 'success' => $response->get_success(), + 'generatedcontent' => $response->get_response_data()['generatedcontent'] ?? '', + 'finishreason' => $response->get_response_data()['finishreason'] ?? '', + 'errorcode' => $response->get_errorcode(), + 'error' => $response->get_errormessage(), + 'timecreated' => $response->get_timecreated(), + ]; + } + + /** + * Generate content for the given action and the given module. + * + * @return external_function_parameters + * @since Moodle 4.5 + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'contextid' => new external_value( + PARAM_INT, + 'The context ID', + VALUE_REQUIRED, + ), + 'action' => new external_value( + PARAM_ALPHANUMEXT, + 'Action for the module', + VALUE_REQUIRED, + ), + 'data' => new external_value( + PARAM_RAW, + 'The data encoded as a json array', + VALUE_REQUIRED, + ), + ]); + } + + /** + * Generate content for the given action and the given module return value. + * + * @return external_function_parameters + * @since Moodle 4.5 + */ + public static function execute_returns(): external_function_parameters { + return new external_function_parameters([ + 'success' => new external_value( + PARAM_BOOL, + 'Was the request successful', + VALUE_REQUIRED + ), + 'timecreated' => new external_value( + PARAM_INT, + 'The time the request was created', + VALUE_REQUIRED, + ), + 'generatedcontent' => new external_value( + PARAM_RAW, + 'The text generated by AI.', + VALUE_DEFAULT, + ), + 'finishreason' => new external_value( + PARAM_ALPHA, + 'The reason generation was stopped', + VALUE_DEFAULT, + 'stop', + ), + 'errorcode' => new external_value( + PARAM_INT, + 'Error code if any', + VALUE_DEFAULT, + 0, + ), + 'error' => new external_value( + PARAM_TEXT, + 'Error message if any', + VALUE_DEFAULT, + '', + ), + ]); + } +} diff --git a/ai/placement/modassist/classes/external/process_response.php b/ai/placement/modassist/classes/external/process_response.php new file mode 100644 index 0000000000000..22d911c6f013a --- /dev/null +++ b/ai/placement/modassist/classes/external/process_response.php @@ -0,0 +1,147 @@ +. + +namespace aiplacement_modassist\external; + +use aiplacement_modassist\utils; +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_value; +use core_external\restricted_context_exception; + +/** + * External API to call to act on the module once response has been generated. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class process_response extends external_api { + + /** + * Process the returned content to act on the module. + * + * @param int $contextid The context ID. + * @param string $action The action for the module. + * @param string $generatedcontent + * @return array The generated content. + * @throws \moodle_exception + * @since Moodle 4.5 + */ + public static function execute( + int $contextid, + string $action, + string $generatedcontent + ): array { + // Parameter validation. + [ + 'contextid' => $contextid, + 'action' => $action, + 'generatedcontent' => $generatedcontent, + ] = self::validate_parameters(self::execute_parameters(), [ + 'contextid' => $contextid, + 'action' => $action, + 'generatedcontent' => $generatedcontent, + ]); + // Context validation and permission check. + // Get the context from the passed in ID. + $context = \context::instance_by_id($contextid); + + // Check the user has permission to use the AI service. + self::validate_context($context); + if (!utils::is_mod_assist_available($context)) { + throw new \moodle_exception('nomodassist', 'aiplacement_modassist'); + } + $modinfo = utils::get_info_for_module($context); + if (empty($modinfo)) { + throw new \moodle_exception('nomodassist', 'aiplacement_modassist'); + } + try { + $response = $modinfo->process_response($context, $action, $generatedcontent); + } catch (\Exception $e) { + return [ + 'success' => false, + 'errorcode' => $e->getCode(), + 'error' => $e->getMessage(), + ]; + } + // Return the response. + return [ + 'success' => true, + 'message' => $response->get_successmessage() ?? '', + ]; + } + + /** + * Summarise text parameters. + * + * @return external_function_parameters + * @since Moodle 4.5 + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters([ + 'contextid' => new external_value( + PARAM_INT, + 'The context ID', + VALUE_REQUIRED, + ), + 'action' => new external_value( + PARAM_ALPHANUMEXT, + 'Action for the module', + VALUE_REQUIRED, + ), + 'generatedcontent' => new external_value( + PARAM_RAW, + 'The generated content as text', + VALUE_REQUIRED, + ), + ]); + } + + /** + * Generate content return value. + * + * @return external_function_parameters + * @since Moodle 4.5 + */ + public static function execute_returns(): external_function_parameters { + return new external_function_parameters([ + 'success' => new external_value( + PARAM_BOOL, + 'Was the request successful', + VALUE_REQUIRED + ), + 'errorcode' => new external_value( + PARAM_INT, + 'Error code if any', + VALUE_DEFAULT, + 0, + ), + 'error' => new external_value( + PARAM_TEXT, + 'Error message if any', + VALUE_DEFAULT, + '', + ), + 'message' => new external_value( + PARAM_TEXT, + 'Error message if any', + VALUE_DEFAULT, + '', + ), + ]); + } +} diff --git a/ai/placement/modassist/classes/form/mod_assist_action_form.php b/ai/placement/modassist/classes/form/mod_assist_action_form.php new file mode 100644 index 0000000000000..919806e726818 --- /dev/null +++ b/ai/placement/modassist/classes/form/mod_assist_action_form.php @@ -0,0 +1,141 @@ +. + +namespace aiplacement_modassist\form; + +use aiplacement_modassist\utils; +use context; +use core_component; +use core_form\dynamic_form; +use moodle_exception; +use moodle_url; + +/** + * Mod assist form. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_assist_action_form extends dynamic_form { + + /** + * Process the form submission + * + * @return array + * @throws moodle_exception + */ + public function process_dynamic_submission(): array { + $this->check_access_for_dynamic_submission(); + $modinfo = utils::get_info_for_module($this->get_context_for_dynamic_submission()); + if (empty($modinfo)) { + return [ + 'result' => false, + ]; + } + return [ + 'result' => true, + 'actiondata' => $this->get_data(), + ]; + } + + /** + * Get context + * + * @return context + */ + protected function get_context_for_dynamic_submission(): context { + $cmid = $this->optional_param('cmid', null, PARAM_INT); + $module = $this->optional_param('component', null, PARAM_COMPONENT); + $cm = get_coursemodule_from_id($module, $cmid); + $context = \context_module::instance($cm->id); + return $context; + } + + /** + * Set data + * + * @return void + */ + public function set_data_for_dynamic_submission(): void { + $data = (object) [ + 'cmid' => $this->optional_param('cmid', 0, PARAM_INT), + 'userid' => $this->optional_param('userid', 0, PARAM_INT), + 'action' => $this->optional_param('action', '', PARAM_ALPHANUMEXT), + 'component' => $this->optional_param('component', '', PARAM_COMPONENT), + ]; + $this->set_data($data); + } + + /** + * Has access ? + * + * @return void + * @throws moodle_exception + */ + protected function check_access_for_dynamic_submission(): void { + if (!has_capability('aiplacement/modassist:generate_text', $this->get_context_for_dynamic_submission())) { + throw new moodle_exception(get_string('cannotgenerate', 'aiplacement_modassist'), ''); + } + } + + /** + * Get page URL + * + * @return moodle_url + */ + protected function get_page_url_for_dynamic_submission(): moodle_url { + $cmid = $this->optional_param('cmid', null, PARAM_INT); + $component = $this->optional_param('component', null, PARAM_COMPONENT); + $component = core_component::normalize_componentname($component); + [$plugintype, $pluginname] = explode('_', $component, 2); + if ($plugintype !== 'mod') { + throw new moodle_exception('invalidcomponent', 'aiplacement_modassist'); + } + return new moodle_url('/mod/view.php', ['id' => $cmid]); + } + + /** + * Form definition + * + * @return void + */ + protected function definition() { + $mform = $this->_form; + $mform->addElement('hidden', 'cmid'); + $mform->setType('cmid', PARAM_INT); + $mform->addElement('hidden', 'userid'); + $mform->setType('userid', PARAM_INT); + $mform->addElement('hidden', 'action'); + $mform->setType('action', PARAM_ALPHANUMEXT); + $mform->addElement('hidden', 'component'); + $mform->setType('component', PARAM_COMPONENT); + $modinfo = utils::get_info_for_module($this->get_context_for_dynamic_submission()); + if (empty($modinfo)) { + return; + } + $modinfo->add_action_form_definitions($mform, $this->get_action()); + } + + /** + * Get action + * + * @return string + */ + private function get_action(): string { + return $this->optional_param('action', '', PARAM_ALPHANUMEXT); + } +} diff --git a/ai/placement/modassist/classes/hook_callbacks.php b/ai/placement/modassist/classes/hook_callbacks.php new file mode 100644 index 0000000000000..340b677889960 --- /dev/null +++ b/ai/placement/modassist/classes/hook_callbacks.php @@ -0,0 +1,37 @@ +. + +namespace aiplacement_modassist; + +use core\hook\output\before_footer_html_generation; + +/** + * Hook callbacks for the course assist AI Placement. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class hook_callbacks { + /** + * Bootstrap the course assist UI. + * + * @param before_footer_html_generation $hook + */ + public static function before_footer_html_generation(before_footer_html_generation $hook): void { + \aiplacement_modassist\output\assist_ui::load_assist_ui($hook); + } +} diff --git a/ai/placement/modassist/classes/mod_action_info.php b/ai/placement/modassist/classes/mod_action_info.php new file mode 100644 index 0000000000000..419046f18ae31 --- /dev/null +++ b/ai/placement/modassist/classes/mod_action_info.php @@ -0,0 +1,39 @@ +. + +namespace aiplacement_modassist; + +use core_ai\aiactions\base; + +/** + * Mod action Information. + * + * To be implemented by each module (can be several actions for the same module). + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_action_info { + /** + * Get the AI action class. + * + * @return base[] The AI action class. + */ + public function get_ai_base_actions_classes(): array { + return []; + } +} diff --git a/ai/placement/modassist/classes/mod_assist_info.php b/ai/placement/modassist/classes/mod_assist_info.php new file mode 100644 index 0000000000000..f50d6245048c7 --- /dev/null +++ b/ai/placement/modassist/classes/mod_assist_info.php @@ -0,0 +1,85 @@ +. + +namespace aiplacement_modassist; + +use context; +use core_ai\aiactions\base as action_base; + +defined('MOODLE_INTERNAL') || die(); +global $CFG; +require_once($CFG->libdir . '/formslib.php'); + +/** + * Class mod_assist_info. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class mod_assist_info { + /** + * Constructor. + * + * @param \context $context The context. + */ + public function __construct( + /** @var \context $context the current context */ + protected \context $context + ) { + } + + /** + * Get the list of actions that are available for this placement. + * + * @return array + */ + public function get_base_action_list(): array { + return [ + \core_ai\aiactions\generate_text::class, + ]; + } + + /** + * Get the list of actions that are available for this placement. + * + * @param \MoodleQuickForm $mform + * @param string $action + * @return void + */ + abstract public function add_action_form_definitions(\MoodleQuickForm $mform, string $action): void; + + /** + * Get relevant AI action from parameters + * + * @param context $context + * @param string $action + * @param object $actiondata + * @return action_base|null + */ + abstract public function get_ai_action(context $context, string $action, object $actiondata): ?action_base; + + /** + * Process action response + * + * @param context $context + * @param string $action + * @param string $generatedcontent + * @return action_process_response|null $action_process_response + */ + abstract public function process_response(context $context, string $action, string $generatedcontent): ?action_process_response; + +} diff --git a/ai/placement/modassist/classes/output/assist_ui.php b/ai/placement/modassist/classes/output/assist_ui.php new file mode 100644 index 0000000000000..084e973042332 --- /dev/null +++ b/ai/placement/modassist/classes/output/assist_ui.php @@ -0,0 +1,112 @@ +. + +namespace aiplacement_modassist\output; + +use aiplacement_modassist\utils; +use context; +use core\hook\output\before_footer_html_generation; + +/** + * Output handler for the course assist AI Placement. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class assist_ui { + /** + * Bootstrap the course assist UI. + * + * @param before_footer_html_generation $hook + */ + public static function load_assist_ui(before_footer_html_generation $hook): void { + global $PAGE, $OUTPUT, $USER; + + // Preflight checks. + if (!self::preflight_checks()) { + return; + } + $params = [ + 'userid' => $USER->id, + 'contextid' => $PAGE->context->id, + 'content' => get_string('drawer:intro', 'aiplacement_modassist'), + ]; + $html = $OUTPUT->render_from_template('aiplacement_modassist/drawer', $params); + $hook->add_html($html); + } + + /** + * Bootstrap the course assist UI. + * + * @param context $context + * @return array|null + */ + public static function get_action_buttons(context $context): ?array { + global $PAGE, $OUTPUT, $USER; + + // Preflight checks. + if (!self::preflight_checks()) { + return null; + } + if ($context->contextlevel != CONTEXT_MODULE) { + return null; + } + $assistactionclass = utils::get_class_for_module($context, 'assist_action'); + if (!$assistactionclass) { + return null; + } + $assistbuttons = new $assistactionclass($USER->id, $context); + $cm = utils::get_course_module_from_context($context); + if (!$cm) { + return null; + } + $params = [ + 'userid' => $USER->id, + 'cmid' => $cm->id, + 'component' => $cm->modname, + ]; + + return array_merge($params, $assistbuttons->export_for_template($OUTPUT)); + } + + /** + * Preflight checks to determine if the assist UI should be loaded. + * + * @return bool + */ + private static function preflight_checks(): bool { + global $PAGE; + if (during_initial_install()) { + return false; + } + if (!get_config('aiplacement_modassist', 'version')) { + return false; + } + if (in_array($PAGE->pagelayout, ['maintenance', 'print', 'redirect', 'embedded'])) { + // Do not try to show assist UI inside iframe, in maintenance mode, + // when printing, or during redirects. + return false; + } + // Check we are in the right context, exit if not activity. + if ($PAGE->context->contextlevel != CONTEXT_MODULE) { + return false; + } + + // Check if the user has permission to use the AI service. + return utils::is_mod_assist_available($PAGE->context); + } +} diff --git a/ai/placement/modassist/classes/placement.php b/ai/placement/modassist/classes/placement.php new file mode 100644 index 0000000000000..81c3e764193c2 --- /dev/null +++ b/ai/placement/modassist/classes/placement.php @@ -0,0 +1,36 @@ +. + +namespace aiplacement_modassist; + +/** + * Class placement. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class placement extends \core_ai\placement { + + #[\Override] + public function get_action_list(): array { + return [ + \core_ai\aiactions\generate_text::class, + // Plus any other actions that are available for this placement. + ]; + } + +} diff --git a/ai/placement/modassist/classes/privacy/provider.php b/ai/placement/modassist/classes/privacy/provider.php new file mode 100644 index 0000000000000..53937837ae405 --- /dev/null +++ b/ai/placement/modassist/classes/privacy/provider.php @@ -0,0 +1,35 @@ +. + +namespace aiplacement_modassist\privacy; + +use core_privacy\local\metadata\null_provider; + +/** + * Privacy Subsystem for course assistance placement implementing null_provider. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @codeCoverageIgnore + */ +class provider implements null_provider { + + #[\Override] + public static function get_reason(): string { + return 'privacy:metadata'; + } +} diff --git a/ai/placement/modassist/classes/utils.php b/ai/placement/modassist/classes/utils.php new file mode 100644 index 0000000000000..e675b982687a4 --- /dev/null +++ b/ai/placement/modassist/classes/utils.php @@ -0,0 +1,119 @@ +. + +namespace aiplacement_modassist; + +use context; +use core_ai\manager; +use core_component; + +/** + * AI Placement course assist utils. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class utils { + /** + * Check if AI Placement course assist is available for the module. + * + * @param \context $context The context. + * @return bool True if AI Placement course assist is available, false otherwise. + */ + public static function is_mod_assist_available(\context $context): bool { + [$plugintype, $pluginname] = explode('_', core_component::normalize_componentname('aiplacement_modassist'), 2); + $manager = \core_plugin_manager::resolve_plugininfo_class($plugintype); + if (!$manager::is_plugin_enabled($pluginname)) { + return false; + } + if ($context->contextlevel !== CONTEXT_MODULE) { + return false; + } + $modassistinfos = self::get_info_for_module($context); + if (empty($modassistinfos)) { + return false; + } + $providerbyactions = manager::get_providers_for_actions($modassistinfos->get_base_action_list(), true); + foreach ($providerbyactions as $actionclass => $providers) { + $capabilityname = basename(str_replace('\\', '/', $actionclass)); + if (!has_capability('aiplacement/modassist:' . $capabilityname, $context) + || !manager::is_action_available($actionclass) + || !manager::is_action_enabled('aiplacement_modassist', $actionclass) + || empty($providers) + ) { + return false; + } + } + return true; + } + + /** + * Get mod assist information for module. + * + * @param \context $context + * @return mod_assist_info|null + */ + public static function get_info_for_module(\context $context): ?mod_assist_info { + $cm = self::get_course_module_from_context($context); + if (empty($cm)) { + return null; + } + $module = core_component::normalize_componentname($cm->modname); + $modinfoclassname = '\\'. $module . '\\ai\\mod_assist_info'; + if (!class_exists($modinfoclassname)) { + return null; + } + return new $modinfoclassname($context); + } + + /** + * Get course module for context + * + * @param context $context + * @return \stdClass|null + * @throws \coding_exception + */ + public static function get_course_module_from_context(\context $context) { + $cm = get_coursemodule_from_id('', $context->instanceid, 0, false, MUST_EXIST); + if (empty($cm)) { + return null; + } + return $cm; + } + + /** + * Get mod assist class for module + * + * @param \context $context + * @param string $classname + * @return string|null + */ + public static function get_class_for_module(\context $context, string $classname): ?string { + $cm = get_coursemodule_from_id('', $context->instanceid, 0, false, MUST_EXIST); + if (empty($cm)) { + return null; + } + $module = core_component::normalize_componentname($cm->modname); + $classes = core_component::get_component_classes_in_namespace($module, 'ai\\output'); + foreach ($classes as $classfullname => $namespace) { + if (strpos($classfullname, $classname) !== false) { + return $classfullname; + } + } + return null; + } +} diff --git a/ai/placement/modassist/db/access.php b/ai/placement/modassist/db/access.php new file mode 100644 index 0000000000000..52554b026438d --- /dev/null +++ b/ai/placement/modassist/db/access.php @@ -0,0 +1,36 @@ +. + +/** + * Capabilities for the aiplacement_modassist plugin. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +defined('MOODLE_INTERNAL') || die(); + +$capabilities = [ + 'aiplacement/modassist:generate_text' => [ + 'captype' => 'write', + 'contextlevel' => CONTEXT_COURSE, + 'archetypes' => [ + 'manager' => CAP_ALLOW, + 'editingteacher' => CAP_ALLOW, + 'teacher' => CAP_ALLOW, + ], + ], +]; diff --git a/ai/placement/modassist/db/hooks.php b/ai/placement/modassist/db/hooks.php new file mode 100644 index 0000000000000..b78844adfd791 --- /dev/null +++ b/ai/placement/modassist/db/hooks.php @@ -0,0 +1,33 @@ +. + +/** + * Hook callbacks for the course assist placement + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$callbacks = [ + [ + 'hook' => \core\hook\output\before_footer_html_generation::class, + 'callback' => \aiplacement_modassist\hook_callbacks::class . '::before_footer_html_generation', + 'priority' => 0, + ], +]; diff --git a/ai/placement/modassist/db/services.php b/ai/placement/modassist/db/services.php new file mode 100644 index 0000000000000..4639cc7b49361 --- /dev/null +++ b/ai/placement/modassist/db/services.php @@ -0,0 +1,40 @@ +. + +/** + * Course Assistance Placement webservice definitions. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$functions = [ + 'aiplacement_modassist_process_response' => [ + 'classname' => 'aiplacement_modassist\external\process_response', + 'description' => 'Process response from a Module Action Assistance Placement', + 'type' => 'write', + 'ajax' => true, + ], + 'aiplacement_modassist_generate_content' => [ + 'classname' => 'aiplacement_modassist\external\generate_content', + 'description' => 'Generate content for Module Action Assistance Placement to be processes by process_action later', + 'type' => 'write', + 'ajax' => true, + ], +]; diff --git a/ai/placement/modassist/lang/en/aiplacement_modassist.php b/ai/placement/modassist/lang/en/aiplacement_modassist.php new file mode 100644 index 0000000000000..ae4397ab10bdf --- /dev/null +++ b/ai/placement/modassist/lang/en/aiplacement_modassist.php @@ -0,0 +1,33 @@ +. + +/** + * Strings for component aiplacement_modassist, language 'en'. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +$string['apply'] = 'Apply'; +$string['drawer:intro'] = 'In this area you will see the results of the AI generation of the content you requested.'; +$string['generatefailtitle'] = 'Something went wrong'; +$string['generating'] = 'Generating your response'; +$string['nomodassist'] = 'AI Mod assistance is not available for this context'; +$string['pluginname'] = 'Module Assistance Placement'; +$string['privacy:metadata'] = 'The Module Assistance placement plugin does not store any personal data.'; +$string['regenerate'] = 'Regenerate'; +$string['results'] = 'Results'; +$string['tryagain'] = 'Try again'; diff --git a/ai/placement/modassist/pix/sparkles-white.svg b/ai/placement/modassist/pix/sparkles-white.svg new file mode 100644 index 0000000000000..9bddb5fcab02e --- /dev/null +++ b/ai/placement/modassist/pix/sparkles-white.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ai/placement/modassist/pix/sparkles.svg b/ai/placement/modassist/pix/sparkles.svg new file mode 100644 index 0000000000000..044ad75a2fe28 --- /dev/null +++ b/ai/placement/modassist/pix/sparkles.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ai/placement/modassist/styles.css b/ai/placement/modassist/styles.css new file mode 100644 index 0000000000000..007ee371b8f8d --- /dev/null +++ b/ai/placement/modassist/styles.css @@ -0,0 +1,28 @@ +.mod-ai-assist-controls button.btn.btn-outline-secondary { + color: unset; +} + +.mod-ai-assist-controls button.btn.btn-outline-secondary span.mod-ai-assist-sparkles-icon { + display: inline-block; +} + +.mod-ai-assist-controls button.btn.btn-outline-secondary span.mod-ai-assist-sparkles-icon.white { + display: none; +} + +.mod-ai-assist-controls button.btn.btn-outline-secondary:not([disabled]):hover { + color: #fff; +} + +.mod-ai-assist-controls button.btn.btn-outline-secondary:not([disabled]):hover span.mod-ai-assist-sparkles-icon { + display: none; +} + +.mod-ai-assist-controls button.btn.btn-outline-secondary:not([disabled]):hover span.mod-ai-assist-sparkles-icon.white { + display: inline-block; +} + +.mod-ai-assist-controls button img.icon { + width: auto; + vertical-align: sub; +} diff --git a/ai/placement/modassist/templates/action_buttons.mustache b/ai/placement/modassist/templates/action_buttons.mustache new file mode 100644 index 0000000000000..f214e1bab2314 --- /dev/null +++ b/ai/placement/modassist/templates/action_buttons.mustache @@ -0,0 +1,60 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template aiplacement_modassist/action_buttons + + Template to display summarise button for the course assist. + + Context variables required for this template: + * none + + Example context (json): + { + "actions": [ + { + "action": "glossary-add-entry", + "title": "summarise", + "tooltip": "summarise_tooltips, aiplacement_courseassist", + "component": "mod_glossary" + } + ] + } +}} +

+ {{#actions}} + + {{/actions}} +
\ No newline at end of file diff --git a/ai/placement/modassist/templates/drawer.mustache b/ai/placement/modassist/templates/drawer.mustache new file mode 100644 index 0000000000000..e5e709505ae59 --- /dev/null +++ b/ai/placement/modassist/templates/drawer.mustache @@ -0,0 +1,49 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template aiplacement_modassist/drawer + + Template to display the AI drawer for the course assist. + + Context variables required for this template: + * userid - User ID + * contextid - Context ID + * content - Content to display + + Example context (json): + { + "userid": "1", + "contextid": "1", + "content": "

Content to display

" + } +}} +
+
+ +
+
+ {{{content}}} +
+
+{{#js}} + require(['aiplacement_modassist/placement'], function(AIModAssist) { + const AI = new AIModAssist({{userid}}, {{contextid}}); + }); +{{/js}} diff --git a/ai/placement/modassist/templates/error.mustache b/ai/placement/modassist/templates/error.mustache new file mode 100644 index 0000000000000..cfb1006810caa --- /dev/null +++ b/ai/placement/modassist/templates/error.mustache @@ -0,0 +1,43 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template aiplacement_modassist/error + + Template to display error message when AI placement fails. + + Context variables required for this template: + * none + + Example context (json): + { + } +}} +
+
+

+ {{#pix}} req, core {{/pix}} + {{#str}} generatefailtitle, aiplacement_modassist {{/str}} +

+
+
+ +
+
+
+
diff --git a/ai/placement/modassist/templates/loading.mustache b/ai/placement/modassist/templates/loading.mustache new file mode 100644 index 0000000000000..a10b57a8c970a --- /dev/null +++ b/ai/placement/modassist/templates/loading.mustache @@ -0,0 +1,43 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template aiplacement_modassist/loading + + Template to display loading message when AI placement is in progress. + + Context variables required for this template: + * none + + Example context (json): + { + } +}} +
+
+

+ {{#pix}} i/loading, core {{/pix}} + {{#str}} generating, aiplacement_modassist {{/str}} +

+
+
+ +
+
+
+
diff --git a/ai/placement/modassist/templates/response.mustache b/ai/placement/modassist/templates/response.mustache new file mode 100644 index 0000000000000..429b6c6226287 --- /dev/null +++ b/ai/placement/modassist/templates/response.mustache @@ -0,0 +1,57 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template aiplacement_modassist/response + + Template to display reponse content for AI placement. + + Context variables required for this template: + * content - Content to display + + Example context (json): + { + "content": "

Content to display

" + } +}} +
+
+

+ {{#str}} results, aiplacement_modassist {{/str}} +

+
+
+ {{{content}}} +
+
+ + {{#pix}} sparkles, aiplacement_modassist {{/pix}} + {{#str}} contentwatermark, core_ai {{/str}} + +
+
+ + +
+
+
+
diff --git a/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/classes/ai/mod_assist_info.php b/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/classes/ai/mod_assist_info.php new file mode 100644 index 0000000000000..9251815337254 --- /dev/null +++ b/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/classes/ai/mod_assist_info.php @@ -0,0 +1,87 @@ +. + +namespace mod_fake\ai; + +use aiplacement_modassist\action_process_response; +use context; +use core_ai\aiactions\base as action_base; + +/** + * Class mod_assist_info. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_assist_info extends \aiplacement_modassist\mod_assist_info { + /** + * Get the list of actions that are available for this placement. + * + * @return array + */ + public function get_base_action_list(): array { + return [ + \core_ai\aiactions\generate_text::class, + ]; + } + + /** + * Add the form definitions for the action. + * + * @param \MoodleQuickForm $mform + * @param string $action + * @return void + * @throws \coding_exception + */ + public function add_action_form_definitions(\MoodleQuickForm $mform, string $action): void { + } + + + /** + * Add the form definitions for the glossary add entry action. + * + * @param \MoodleQuickForm $mform + * @return void + * @throws \coding_exception + */ + protected function add_glossary_add_entry_form_definitions(\MoodleQuickForm $mform): void { + } + + /** + * Get relevant AI action from parameters + * + * @param context $context + * @param string $action + * @param object $actiondata + * @return action_base|null + */ + public function get_ai_action(context $context, string $action, object $actiondata): ?action_base { + return null; + } + + /** + * Process action response + * + * @param context $context + * @param string $action + * @param string $generatedcontent + * @return action_process_response|null $action_process_response + */ + public function process_response(context $context, string $action, string $generatedcontent): ?action_process_response { + return null; + } +} diff --git a/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/db/install.xml b/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/db/install.xml new file mode 100644 index 0000000000000..e1411aaa2f24e --- /dev/null +++ b/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/db/install.xml @@ -0,0 +1,23 @@ + + +> + + + + + + + + + + + + + + + +
+
+
diff --git a/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/lang/en/fake.php b/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/lang/en/fake.php new file mode 100644 index 0000000000000..bc78a67e1f21b --- /dev/null +++ b/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/lang/en/fake.php @@ -0,0 +1,28 @@ +. + +/** + * Plugin strings are defined here. + * + * @package aiplacement_modassist + * @category string + * @copyright 2024 Laurent David + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$string['pluginname'] = 'Fake'; diff --git a/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/lib.php b/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/lib.php new file mode 100644 index 0000000000000..c3c4941ddcfd8 --- /dev/null +++ b/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/lib.php @@ -0,0 +1,97 @@ +. + +/** + * Library of interface functions and constants. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Return if the plugin supports $feature. + * + * @param string $feature Constant representing the feature. + * @return true | null True if the feature is supported, null otherwise. + */ +function fake_supports($feature) { + switch ($feature) { + case FEATURE_MOD_INTRO: + return true; + default: + return null; + } +} + +/** + * Saves a new instance of the mod_fake into the database. + * + * Given an object containing all the necessary data, (defined by the form + * in mod_form.php) this function will create a new instance and return the id + * number of the instance. + * + * @param object $moduleinstance An object from the form. + * @param mod_fake_mod_form $mform The form. + * @return int The id of the newly inserted record. + */ +function fake_add_instance($moduleinstance, $mform = null) { + global $DB; + + $moduleinstance->timecreated = time(); + + $id = $DB->insert_record('fake', $moduleinstance); + + return $id; +} + +/** + * Updates an instance of the mod_fake in the database. + * + * Given an object containing all the necessary data (defined in mod_form.php), + * this function will update an existing instance with new data. + * + * @param object $moduleinstance An object from the form in mod_form.php. + * @param mod_fake_mod_form $mform The form. + * @return bool True if successful, false otherwise. + */ +function fake_update_instance($moduleinstance, $mform = null) { + global $DB; + + $moduleinstance->timemodified = time(); + $moduleinstance->id = $moduleinstance->instance; + + return $DB->update_record('fake', $moduleinstance); +} + +/** + * Removes an instance of the mod_fake from the database. + * + * @param int $id Id of the module instance. + * @return bool True if successful, false on failure. + */ +function fake_delete_instance($id) { + global $DB; + + $exists = $DB->get_record('fake', ['id' => $id]); + if (!$exists) { + return false; + } + + $DB->delete_records('fake', ['id' => $id]); + + return true; +} diff --git a/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/tests/generator/lib.php b/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/tests/generator/lib.php new file mode 100644 index 0000000000000..39c78e46ed5cd --- /dev/null +++ b/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/tests/generator/lib.php @@ -0,0 +1,26 @@ +. + +/** + * Fake module for testing generator + * + * @package aiplacement_modassist + * @copyright 2022 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_fake_generator extends testing_module_generator { + +} diff --git a/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/version.php b/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/version.php new file mode 100644 index 0000000000000..8d11b0353ec25 --- /dev/null +++ b/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake/version.php @@ -0,0 +1,31 @@ +. + +/** + * Plugin version and other meta-data are defined here. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->component = 'mod_fake'; +$plugin->release = '0.1.0'; +$plugin->version = 2024102200; +$plugin->requires = 2022112800; +$plugin->maturity = MATURITY_ALPHA; diff --git a/ai/placement/modassist/tests/utils_test.php b/ai/placement/modassist/tests/utils_test.php new file mode 100644 index 0000000000000..fb1b7548562c6 --- /dev/null +++ b/ai/placement/modassist/tests/utils_test.php @@ -0,0 +1,107 @@ +. + +namespace aiplacement_modassist; + +/** + * AI Placement module assist utils test. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \aiplacement_modassist\utils + * @runTestsInSeparateProcesses + */ +final class utils_test extends \advanced_testcase { + + /** + * Test get_info_for_module method. + */ + public function setUp(): void { + global $CFG, $DB; + include_once($CFG->libdir . '/upgradelib.php'); + parent::setUp(); + $this->resetAfterTest(); + $modpath = "{$CFG->dirroot}/ai/placement/modassist/tests/fixtures/fakeplugins/mod_fake"; + $this->add_mocked_plugin( + 'mod', + 'fake', + $modpath + ); + // Make sure class is loaded in the classmap if not this fail to load the classes in the fake module. + $mockedcomponent = new \ReflectionClass(\core_component::class); + $fillclassmap = $mockedcomponent->getMethod('fill_classmap_cache'); + $fillclassmap->invoke(null); + $fillfilemap = $mockedcomponent->getMethod('fill_filemap_cache'); + $fillfilemap->invoke(null); + ob_start(); + upgrade_noncore(false); + upgrade_finished(); + ob_end_clean(); + } + + /** + * Test is_mod_assist_available method. + */ + public function test_is_mod_assist_available(): void { + global $DB; + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $course = $this->getDataGenerator()->create_course(); + $module = $this->getDataGenerator()->create_module('fake', ['course' => $course->id]); + $context = \context_module::instance($module->cmid); + $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); + set_config('enabled', 0, 'aiprovider_openai'); + $this->assertFalse(utils::is_mod_assist_available($context)); + + set_config('enabled', 1, 'aiprovider_openai'); + set_config('apikey', '123', 'aiprovider_openai'); + set_config('orgid', 'abc', 'aiprovider_openai'); + + // Plugin is not enabled. + $this->setUser($user1); + set_config('enabled', 0, 'aiplacement_modassist'); + $this->assertFalse(utils::is_mod_assist_available($context)); + + // Plugin is enabled but user does not have capability. + assign_capability('aiplacement/modassist:generate_text', CAP_PROHIBIT, $teacherrole->id, $context); + $this->setUser($user2); + set_config('enabled', 1, 'aiplacement_modassist'); + $this->assertFalse(utils::is_mod_assist_available($context)); + + // Plugin is enabled, user has capability and placement action is not available. + $this->setUser($user1); + set_config('generate_text', 0, 'aiplacement_modassist'); + $this->assertFalse(utils::is_mod_assist_available($context)); + + // Plugin is enabled, user has capability and provider action is not available. + $this->setUser($user1); + set_config('generate_text', 0, 'aiprovider_openai'); + set_config('generate_text', 1, 'aiplacement_modassist'); + $this->assertFalse(utils::is_mod_assist_available($context)); + + // Plugin is enabled, user has capability, placement action is available and provider action is available. + $this->setUser($user1); + set_config('generate_text', 1, 'aiprovider_openai'); + set_config('generate_text', 1, 'aiplacement_modassist'); + $this->assertTrue(utils::is_mod_assist_available($context)); + } +} diff --git a/ai/placement/modassist/version.php b/ai/placement/modassist/version.php new file mode 100644 index 0000000000000..6d79b79b45d5a --- /dev/null +++ b/ai/placement/modassist/version.php @@ -0,0 +1,30 @@ +. + +/** + * Version information for aiplacement_modassist. + * + * @package aiplacement_modassist + * @copyright 2024 Laurent David + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->component = 'aiplacement_modassist'; +$plugin->version = 2024102102; +$plugin->requires = 2024100100; +$plugin->maturity = MATURITY_STABLE;