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
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
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):
+ {
+ }
+}}
+
+
+
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):
+ {
+ }
+}}
+
+
+
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": "