}\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_courseassist/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 displaySummary() {\n if (!this.hasGeneratedContent()) {\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_courseassist_summarise_text',\n args: {\n contextid: this.contextId,\n prompttext: this.getTextContent(),\n }\n };\n try {\n const responseObj = await Ajax.call([request])[0];\n if (responseObj.error) {\n window.console.log(responseObj.error);\n this.displayError();\n return;\n } else {\n if (!this.isRequestCancelled()) {\n window.console.log(responseObj);\n // Replace double line breaks with and with for paragraphs.\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 }\n }\n }\n\n /**\n * Display the response.\n * @param {String} content The content to display.\n */\n displayResponse(content) {\n Templates.render('aiplacement_courseassist/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_courseassist/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\nexport default AICourseAssist;\n"],"names":["constructor","userId","contextId","aiDrawerElement","document","querySelector","Selectors","ELEMENTS","AIDRAWER","aiDrawerBodyElement","AIDRAWER_BODY","pageElement","PAGE","registerEventListeners","addEventListener","async","e","target","closest","ACTIONS","SUMMARY","preventDefault","toggleAIDrawer","this","isPolicyAccepted","displayPolicy","displaySummary","registerPolicyEventListeners","acceptAction","ACCEPT","declineAction","DECLINE","acceptPolicy","then","catch","Notification","exception","closeAIDrawer","registerErrorEventListeners","retryAction","RETRY","dataset","hasdata","registerResponseEventListeners","regenerateAction","REGENERATE","registerLoadingEventListeners","cancelAction","CANCEL","setRequestCancelled","isAIDrawerOpen","classList","contains","isRequestCancelled","cancelled","openAIDrawer","add","setAttribute","addPadding","disableSummaryButton","remove","removeAttribute","removepadding","removePadding","enableSummaryButton","summaryButton","focus","Policy","getPolicyStatus","hasGeneratedContent","render","html","innerHTML","displayLoading","request","methodname","args","contextid","prompttext","getTextContent","responseObj","Ajax","call","error","window","console","log","displayError","generatedContent","AIHelper","replaceLineBreaks","generatedcontent","displayResponse","content","mainRegion","MAIN_REGION","innerText","textContent"],"mappings":"i4BA+BuB,MAkBnBA,YAAYC,OAAQC,+FACXD,OAASA,YACTC,UAAYA,eAEZC,gBAAkBC,SAASC,cAAcC,mBAAUC,SAASC,eAC5DC,oBAAsBL,SAASC,cAAcC,mBAAUC,SAASG,oBAChEC,YAAcP,SAASC,cAAcC,mBAAUC,SAASK,WAExDC,yBAMTA,yBACIT,SAASU,iBAAiB,SAASC,MAAAA,OACPC,EAAEC,OAAOC,QAAQZ,mBAAUa,QAAQC,SACtC,CACjBJ,EAAEK,sBACGC,2BAC0BC,KAAKC,oCAG3BC,qBAIJC,qBAQjBC,qCACUC,aAAexB,SAASC,cAAcC,mBAAUa,QAAQU,QACxDC,cAAgB1B,SAASC,cAAcC,mBAAUa,QAAQY,SAC3DH,cACAA,aAAad,iBAAiB,SAAUE,IACpCA,EAAEK,sBACGW,eAAeC,MAAK,IACdV,KAAKG,mBACbQ,MAAMC,sBAAaC,cAG1BN,eACAA,cAAchB,iBAAiB,SAAUE,IACrCA,EAAEK,sBACGgB,mBAQjBC,oCACUC,YAAcnC,SAASC,cAAcC,mBAAUa,QAAQqB,OACzDD,aACAA,YAAYzB,iBAAiB,SAAUE,IACnCA,EAAEK,sBACGZ,oBAAoBgC,QAAQC,QAAU,SACtChB,oBAQjBiB,uCACUC,iBAAmBxC,SAASC,cAAcC,mBAAUa,QAAQ0B,YAC9DD,kBACAA,iBAAiB9B,iBAAiB,SAAUE,IACxCA,EAAEK,sBACGZ,oBAAoBgC,QAAQC,QAAU,SACtChB,oBAKjBoB,sCACUC,aAAe3C,SAASC,cAAcC,mBAAUa,QAAQ6B,QAC1DD,cACAA,aAAajC,iBAAiB,SAAUE,IACpCA,EAAEK,sBACG4B,2BACA3B,oBASjB4B,wBACW3B,KAAKpB,gBAAgBgD,UAAUC,SAAS,QAOnDC,2BAC0D,MAA/C9B,KAAKd,oBAAoBgC,QAAQa,UAG5CL,2BACSxC,oBAAoBgC,QAAQa,UAAY,IAMjDC,oBACSpD,gBAAgBgD,UAAUK,IAAI,aAC9B/C,oBAAoBgD,aAAa,YAAa,UAC9ClC,KAAKZ,YAAYwC,UAAUC,SAAS,2BAChCM,kBAGJC,uBAMTtB,qBACSlC,gBAAgBgD,UAAUS,OAAO,aACjCnD,oBAAoBoD,gBAAgB,aACrCtC,KAAKZ,YAAYwC,UAAUC,SAAS,sBAA2E,MAAnD7B,KAAKd,oBAAoBgC,QAAQqB,oBACxFC,qBAGJC,sBAMT1C,iBACQC,KAAK2B,sBACAb,qBAEAkB,eAObG,kBACS/C,YAAYwC,UAAUK,IAAI,0BAC1B/C,oBAAoBgC,QAAQqB,cAAgB,IAMrDC,qBACSpD,YAAYwC,UAAUS,OAAO,0BAC7BnD,oBAAoBgC,QAAQqB,cAAgB,IAMrDH,6BACUM,cAAgB7D,SAASC,cAAcC,mBAAUa,QAAQC,SAC3D6C,eACAA,cAAcR,aAAa,WAAY,GAO/CO,4BACUC,cAAgB7D,SAASC,cAAcC,mBAAUa,QAAQC,SAC3D6C,gBACAA,cAAcJ,gBAAgB,YAC9BI,cAAcC,+CASLC,gBAAOC,gBAAgB7C,KAAKtB,QAO7C+B,sBACWmC,gBAAOnC,eAOlBqC,4BACwD,MAA7C9C,KAAKd,oBAAoBgC,QAAQC,QAM5CjB,mCACc6C,OAAO,sBAAuB,IAAIrC,MAAMsC,YACzC9D,oBAAoB+D,UAAYD,UAChC5C,kCAENO,MAAMC,sBAAaC,WAM1BqC,oCACcH,OAAO,mCAAoC,IAAIrC,MAAMsC,YACtD9D,oBAAoB+D,UAAYD,UAChCzB,mCAENZ,MAAMC,sBAAaC,sCAOjBb,KAAK8C,sBAAuB,MAExBI,sBAEAhE,oBAAoB+D,UAAY,SAC/BE,QAAU,CACZC,WAAY,0CACZC,KAAM,CACFC,UAAWtD,KAAKrB,UAChB4E,WAAYvD,KAAKwD,6BAIfC,kBAAoBC,cAAKC,KAAK,CAACR,UAAU,MAC3CM,YAAYG,aACZC,OAAOC,QAAQC,IAAIN,YAAYG,iBAC1BI,mBAGAhE,KAAK8B,qBAAsB,CAC5B+B,OAAOC,QAAQC,IAAIN,mBAEbQ,iBAAmBC,gBAASC,kBAAkBV,YAAYW,mCAC3DC,gBAAgBJ,uBAGhB/E,oBAAoBgC,QAAQa,UAAY,IAGvD,MAAO6B,OACLC,OAAOC,QAAQC,IAAIH,SAS/BS,gBAAgBC,4BACFvB,OAAO,oCAAqC,CAACuB,QAASA,UAAU5D,MAAMsC,YACvE9D,oBAAoB+D,UAAYD,UAChC9D,oBAAoBgC,QAAQC,QAAU,SACtCC,oCAENT,MAAMC,sBAAaC,WAM1BmD,kCACcjB,OAAO,iCAAkC,IAAIrC,MAAMsC,YACpD9D,oBAAoB+D,UAAYD,UAChCjC,iCAENJ,MAAMC,sBAAaC,WAO1B2C,uBACUe,WAAa1F,SAASC,cAAcC,mBAAUC,SAASwF,oBACtDD,WAAWE,WAAaF,WAAWG"}
\ No newline at end of file
diff --git a/ai/placement/courseassist/amd/build/selectors.min.js b/ai/placement/courseassist/amd/build/selectors.min.js
new file mode 100644
index 00000000000..9bae628eba3
--- /dev/null
+++ b/ai/placement/courseassist/amd/build/selectors.min.js
@@ -0,0 +1,3 @@
+define("aiplacement_courseassist/selectors",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;return _exports.default={ELEMENTS:{AIDRAWER:"#ai-drawer",AIDRAWER_BODY:"#ai-drawer .ai-drawer-body",PAGE:"#page",MAIN_REGION:'[role="main"]'},ACTIONS:{SUMMARY:'[data-action="course-summarise"]',RETRY:'[data-action="course-summarise-retry"]',DECLINE:'[data-action="course-summarise-policy-decline"]',ACCEPT:'.ai-policy-block [data-action="accept"]',REGENERATE:'[data-action="course-summarise-regenerate"]',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/courseassist/amd/build/selectors.min.js.map b/ai/placement/courseassist/amd/build/selectors.min.js.map
new file mode 100644
index 00000000000..07f96f30736
--- /dev/null
+++ b/ai/placement/courseassist/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_courseassist/selectors\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default {\n ELEMENTS: {\n AIDRAWER: '#ai-drawer',\n AIDRAWER_BODY: '#ai-drawer .ai-drawer-body',\n PAGE: '#page',\n MAIN_REGION: '[role=\"main\"]',\n },\n ACTIONS: {\n SUMMARY: '[data-action=\"course-summarise\"]',\n RETRY: '[data-action=\"course-summarise-retry\"]',\n DECLINE: '[data-action=\"course-summarise-policy-decline\"]',\n ACCEPT: '.ai-policy-block [data-action=\"accept\"]',\n REGENERATE: '[data-action=\"course-summarise-regenerate\"]',\n CANCEL: '.ai-policy-block [data-action=\"decline\"]',\n }\n};\n"],"names":["ELEMENTS","AIDRAWER","AIDRAWER_BODY","PAGE","MAIN_REGION","ACTIONS","SUMMARY","RETRY","DECLINE","ACCEPT","REGENERATE","CANCEL"],"mappings":"oLAsBe,CACXA,SAAU,CACNC,SAAU,aACVC,cAAe,6BACfC,KAAM,QACNC,YAAa,iBAEjBC,QAAS,CACLC,QAAS,mCACTC,MAAO,yCACPC,QAAS,kDACTC,OAAQ,0CACRC,WAAY,8CACZC,OAAQ"}
\ No newline at end of file
diff --git a/ai/placement/courseassist/amd/src/placement.js b/ai/placement/courseassist/amd/src/placement.js
new file mode 100644
index 00000000000..55177a53c93
--- /dev/null
+++ b/ai/placement/courseassist/amd/src/placement.js
@@ -0,0 +1,356 @@
+// 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_courseassist/placement
+ * @copyright 2024 Huong Nguyen
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import Templates from 'core/templates';
+import Ajax from 'core/ajax';
+import 'core/copy_to_clipboard';
+import Notification from 'core/notification';
+import Selectors from 'aiplacement_courseassist/selectors';
+import Policy from 'core_ai/policy';
+import AIHelper from 'core_ai/helper';
+
+const AICourseAssist = class {
+
+ /**
+ * The user ID.
+ * @type {Integer}
+ */
+ userId;
+ /**
+ * The context ID.
+ * @type {Integer}
+ */
+ contextId;
+
+ /**
+ * Constructor.
+ * @param {Integer} userId The user ID.
+ * @param {Integer} contextId The context ID.
+ */
+ constructor(userId, contextId) {
+ this.userId = userId;
+ this.contextId = contextId;
+
+ this.aiDrawerElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER);
+ this.aiDrawerBodyElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER_BODY);
+ this.pageElement = document.querySelector(Selectors.ELEMENTS.PAGE);
+
+ this.registerEventListeners();
+ }
+
+ /**
+ * Register event listeners.
+ */
+ registerEventListeners() {
+ document.addEventListener('click', async(e) => {
+ const summariseAction = e.target.closest(Selectors.ACTIONS.SUMMARY);
+ if (summariseAction) {
+ e.preventDefault();
+ this.toggleAIDrawer();
+ const isPolicyAccepted = await this.isPolicyAccepted();
+ if (!isPolicyAccepted) {
+ // Display policy.
+ this.displayPolicy();
+ return;
+ }
+ // Display summary.
+ this.displaySummary();
+ }
+ });
+ }
+
+ /**
+ * 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.displaySummary();
+ }).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.displaySummary();
+ });
+ }
+ }
+
+ /**
+ * 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.displaySummary();
+ });
+ }
+ }
+
+ 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() {
+ 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.disableSummaryButton();
+ }
+
+ /**
+ * 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.enableSummaryButton();
+ }
+
+ /**
+ * 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 summary button.
+ */
+ disableSummaryButton() {
+ const summaryButton = document.querySelector(Selectors.ACTIONS.SUMMARY);
+ if (summaryButton) {
+ summaryButton.setAttribute('disabled', 1);
+ }
+ }
+
+ /**
+ * Enable the summary button and focus on it.
+ */
+ enableSummaryButton() {
+ const summaryButton = document.querySelector(Selectors.ACTIONS.SUMMARY);
+ 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_courseassist/loading', {}).then((html) => {
+ this.aiDrawerBodyElement.innerHTML = html;
+ this.registerLoadingEventListeners();
+ return;
+ }).catch(Notification.exception);
+ }
+
+ /**
+ * Display the summary.
+ */
+ async displaySummary() {
+ if (!this.hasGeneratedContent()) {
+ // Display loading spinner.
+ this.displayLoading();
+ // Clear the drawer content to prevent sending some unnecessary content.
+ this.aiDrawerBodyElement.innerHTML = '';
+ const request = {
+ methodname: 'aiplacement_courseassist_summarise_text',
+ args: {
+ contextid: this.contextId,
+ prompttext: this.getTextContent(),
+ }
+ };
+ try {
+ const responseObj = await Ajax.call([request])[0];
+ if (responseObj.error) {
+ window.console.log(responseObj.error);
+ this.displayError();
+ return;
+ } else {
+ if (!this.isRequestCancelled()) {
+ window.console.log(responseObj);
+ // Replace double line breaks with and with
for paragraphs.
+ const generatedContent = AIHelper.replaceLineBreaks(responseObj.generatedcontent);
+ this.displayResponse(generatedContent);
+ return;
+ } else {
+ this.aiDrawerBodyElement.dataset.cancelled = '0';
+ }
+ }
+ } catch (error) {
+ window.console.log(error);
+ }
+ }
+ }
+
+ /**
+ * Display the response.
+ * @param {String} content The content to display.
+ */
+ displayResponse(content) {
+ Templates.render('aiplacement_courseassist/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_courseassist/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;
+ }
+};
+
+export default AICourseAssist;
diff --git a/ai/placement/courseassist/amd/src/selectors.js b/ai/placement/courseassist/amd/src/selectors.js
new file mode 100644
index 00000000000..4c802b7d55f
--- /dev/null
+++ b/ai/placement/courseassist/amd/src/selectors.js
@@ -0,0 +1,38 @@
+// 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_courseassist/selectors
+ * @copyright 2024 Huong Nguyen
+ * @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: {
+ SUMMARY: '[data-action="course-summarise"]',
+ RETRY: '[data-action="course-summarise-retry"]',
+ DECLINE: '[data-action="course-summarise-policy-decline"]',
+ ACCEPT: '.ai-policy-block [data-action="accept"]',
+ REGENERATE: '[data-action="course-summarise-regenerate"]',
+ CANCEL: '.ai-policy-block [data-action="decline"]',
+ }
+};
diff --git a/ai/placement/courseassist/classes/external/summarise_text.php b/ai/placement/courseassist/classes/external/summarise_text.php
new file mode 100644
index 00000000000..e3479356966
--- /dev/null
+++ b/ai/placement/courseassist/classes/external/summarise_text.php
@@ -0,0 +1,152 @@
+.
+
+namespace aiplacement_courseassist\external;
+
+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_courseassist
+ * @copyright 2024 Huong Nguyen
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class summarise_text extends external_api {
+
+ /**
+ * 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,
+ ),
+ 'prompttext' => new external_value(
+ PARAM_RAW,
+ 'The prompt text for the AI service',
+ VALUE_REQUIRED,
+ ),
+ ]);
+ }
+
+ /**
+ * Summarise text from the AI placement.
+ *
+ * @param int $contextid The context ID.
+ * @param string $prompttext The data encoded as a json array.
+ * @return array The generated content.
+ * @since Moodle 4.5
+ */
+ public static function execute(
+ int $contextid,
+ string $prompttext
+ ): array {
+ global $USER;
+ // Parameter validation.
+ [
+ 'contextid' => $contextid,
+ 'prompttext' => $prompttext,
+ ] = self::validate_parameters(self::execute_parameters(), [
+ 'contextid' => $contextid,
+ 'prompttext' => $prompttext,
+ ]);
+ // 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);
+ require_capability('aiplacement/courseassist:summarise_text', $context);
+
+ // Prepare the action.
+ $action = new \core_ai\aiactions\summarise_text(
+ contextid: $contextid,
+ userid: $USER->id,
+ prompttext: $prompttext,
+ );
+
+ // Send the action to the AI manager.
+ $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(),
+ 'prompttext' => $prompttext,
+ ];
+ }
+
+ /**
+ * 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
+ ),
+ 'timecreated' => new external_value(
+ PARAM_INT,
+ 'The time the request was created',
+ VALUE_REQUIRED,
+ ),
+ 'prompttext' => new external_value(
+ PARAM_RAW,
+ 'The prompt text for the AI service',
+ 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/courseassist/classes/hook_callbacks.php b/ai/placement/courseassist/classes/hook_callbacks.php
new file mode 100644
index 00000000000..32fba1062ae
--- /dev/null
+++ b/ai/placement/courseassist/classes/hook_callbacks.php
@@ -0,0 +1,47 @@
+.
+
+namespace aiplacement_courseassist;
+
+use core\hook\output\after_http_headers;
+use core\hook\output\before_footer_html_generation;
+
+/**
+ * Hook callbacks for the course assist AI Placement.
+ *
+ * @package aiplacement_courseassist
+ * @copyright 2024 Matt Porritt
+ * @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_courseassist\output\assist_ui::load_assist_ui($hook);
+ }
+
+ /**
+ * Bootstrap the summarise button.
+ *
+ * @param after_http_headers $hook
+ */
+ public static function after_http_headers(after_http_headers $hook): void {
+ \aiplacement_courseassist\output\assist_ui::load_summarise_button($hook);
+ }
+}
diff --git a/ai/placement/courseassist/classes/output/assist_ui.php b/ai/placement/courseassist/classes/output/assist_ui.php
new file mode 100644
index 00000000000..a424d1b555f
--- /dev/null
+++ b/ai/placement/courseassist/classes/output/assist_ui.php
@@ -0,0 +1,107 @@
+.
+
+namespace aiplacement_courseassist\output;
+
+use core\hook\output\after_http_headers;
+use core\hook\output\before_footer_html_generation;
+use core_ai\aiactions\summarise_text;
+use core_ai\manager;
+
+/**
+ * Output handler for the course assist AI Placement.
+ *
+ * @package aiplacement_courseassist
+ * @copyright 2024 Matt Porritt
+ * @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;
+ }
+
+ // Load the markup for the assist interface.
+ $params = [
+ 'userid' => $USER->id,
+ 'contextid' => $PAGE->context->id,
+ ];
+ $html = $OUTPUT->render_from_template('aiplacement_courseassist/drawer', $params);
+ $hook->add_html($html);
+ }
+
+ /**
+ * Bootstrap the summarise button.
+ *
+ * @param after_http_headers $hook
+ */
+ public static function load_summarise_button(after_http_headers $hook): void {
+ global $OUTPUT;
+
+ // Preflight checks.
+ if (!self::preflight_checks()) {
+ return;
+ }
+
+ $html = $OUTPUT->render_from_template('aiplacement_courseassist/summarise_button', []);
+ $hook->add_html($html);
+ }
+
+ /**
+ * 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_courseassist', '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;
+ }
+ [$plugintype, $pluginname] = explode('_', \core_component::normalize_componentname('aiplacement_courseassist'), 2);
+ $manager = \core_plugin_manager::resolve_plugininfo_class($plugintype);
+ if (!$manager::is_plugin_enabled($pluginname)) {
+ return false;
+ }
+ $providers = manager::get_providers_for_actions([summarise_text::class], true);
+ if (!has_capability('aiplacement/courseassist:summarise_text', $PAGE->context)
+ || !manager::is_action_enabled('aiplacement_courseassist', 'summarise_text')
+ || empty($providers[summarise_text::class])
+ ) {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/ai/placement/courseassist/classes/placement.php b/ai/placement/courseassist/classes/placement.php
new file mode 100644
index 00000000000..052fefd3687
--- /dev/null
+++ b/ai/placement/courseassist/classes/placement.php
@@ -0,0 +1,35 @@
+.
+
+namespace aiplacement_courseassist;
+
+/**
+ * Class placement.
+ *
+ * @package aiplacement_courseassist
+ * @copyright 2024 Matt Porritt
+ * @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\summarise_text::class,
+ ];
+ }
+
+}
diff --git a/ai/placement/courseassist/classes/privacy/provider.php b/ai/placement/courseassist/classes/privacy/provider.php
new file mode 100644
index 00000000000..39fbbe9318a
--- /dev/null
+++ b/ai/placement/courseassist/classes/privacy/provider.php
@@ -0,0 +1,35 @@
+.
+
+namespace aiplacement_courseassist\privacy;
+
+use core_privacy\local\metadata\null_provider;
+
+/**
+ * Privacy Subsystem for course assistance placement implementing null_provider.
+ *
+ * @package aiplacement_courseassist
+ * @copyright 2024 Matt Porritt
+ * @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/courseassist/db/access.php b/ai/placement/courseassist/db/access.php
new file mode 100644
index 00000000000..a4653dc3fa3
--- /dev/null
+++ b/ai/placement/courseassist/db/access.php
@@ -0,0 +1,37 @@
+.
+
+/**
+ * Capabilities for the aiplacement_courseassist plugin.
+ *
+ * @package aiplacement_courseassist
+ * @copyright 2024 Huong Nguyen
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+defined('MOODLE_INTERNAL') || die();
+
+$capabilities = [
+ 'aiplacement/courseassist:summarise_text' => [
+ 'captype' => 'write',
+ 'contextlevel' => CONTEXT_COURSE,
+ 'archetypes' => [
+ 'manager' => CAP_ALLOW,
+ 'editingteacher' => CAP_ALLOW,
+ 'teacher' => CAP_ALLOW,
+ 'student' => CAP_ALLOW,
+ ],
+ ],
+];
diff --git a/ai/placement/courseassist/db/hooks.php b/ai/placement/courseassist/db/hooks.php
new file mode 100644
index 00000000000..18ce010a1ec
--- /dev/null
+++ b/ai/placement/courseassist/db/hooks.php
@@ -0,0 +1,38 @@
+.
+
+/**
+ * Hook callbacks for the course assist placement
+ *
+ * @package aiplacement_courseassist
+ * @copyright 2024 Matt Porritt
+ * @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_courseassist\hook_callbacks::class . '::before_footer_html_generation',
+ 'priority' => 0,
+ ],
+ [
+ 'hook' => \core\hook\output\after_http_headers::class,
+ 'callback' => \aiplacement_courseassist\hook_callbacks::class . '::after_http_headers',
+ 'priority' => 0,
+ ],
+];
diff --git a/ai/placement/courseassist/db/services.php b/ai/placement/courseassist/db/services.php
new file mode 100644
index 00000000000..746870fb83d
--- /dev/null
+++ b/ai/placement/courseassist/db/services.php
@@ -0,0 +1,35 @@
+.
+
+/**
+ * Course Assistance Placement webservice definitions.
+ *
+ * @package aiplacement_courseassist
+ * @copyright 2024 Huong Nguyen
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$functions = [
+ 'aiplacement_courseassist_summarise_text' => [
+ 'classname' => 'aiplacement_courseassist\external\summarise_text',
+ 'description' => 'Summarise text for the Course Assistance Placement',
+ 'type' => 'write',
+ 'ajax' => true,
+ 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE],
+ ],
+];
diff --git a/ai/placement/courseassist/lang/en/aiplacement_courseassist.php b/ai/placement/courseassist/lang/en/aiplacement_courseassist.php
new file mode 100644
index 00000000000..6cc5a2243d4
--- /dev/null
+++ b/ai/placement/courseassist/lang/en/aiplacement_courseassist.php
@@ -0,0 +1,35 @@
+.
+
+/**
+ * Strings for component aiplacement_courseassist, language 'en'.
+ *
+ * @package aiplacement_courseassist
+ * @copyright 2024 Matt Porritt
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['aisummary'] = 'AI summary';
+$string['courseassist:summarise_text'] = 'Summarise text';
+$string['copy'] = 'Copy';
+$string['generatefailtitle'] = 'Something went wrong';
+$string['generating'] = 'Generating your response';
+$string['pluginname'] = 'Course Assistance Placement';
+$string['privacy:metadata'] = 'The Course Assistance placement plugin does not store any personal data.';
+$string['regenerate'] = 'Regenerate';
+$string['summarise'] = 'Summarise';
+$string['summarise_tooltips'] = 'Create an AI-generated summary of the page content';
+$string['tryagain'] = 'Try again';
diff --git a/ai/placement/courseassist/pix/sparkles-white.svg b/ai/placement/courseassist/pix/sparkles-white.svg
new file mode 100644
index 00000000000..9bddb5fcab0
--- /dev/null
+++ b/ai/placement/courseassist/pix/sparkles-white.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/ai/placement/courseassist/pix/sparkles.svg b/ai/placement/courseassist/pix/sparkles.svg
new file mode 100644
index 00000000000..403ee534686
--- /dev/null
+++ b/ai/placement/courseassist/pix/sparkles.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/ai/placement/courseassist/styles.css b/ai/placement/courseassist/styles.css
new file mode 100644
index 00000000000..0165f92d797
--- /dev/null
+++ b/ai/placement/courseassist/styles.css
@@ -0,0 +1,77 @@
+.ai-drawer {
+ position: fixed;
+ top: 60px;
+ bottom: 0;
+ right: calc(-315px + -10px);
+ width: 315px;
+ background-color: #f8f9fa;
+ z-index: 2001;
+ transition: right 0.2s ease, top 0.2s ease, bottom 0.2s ease, visibility 0.2s ease, transform 0.5s ease;
+ visibility: hidden;
+}
+.ai-drawer.show {
+ right: 0;
+ visibility: visible;
+}
+
+.ai-drawer-header {
+ padding: 0;
+ height: 60px;
+ display: flex;
+ align-items: center;
+}
+
+.ai-drawer-header .ai-drawer-button {
+ margin-left: auto;
+ margin-right: 5px;
+}
+
+.ai-drawer-body {
+ position: relative;
+ height: calc(100vh - 120px);
+ display: flex;
+ flex-direction: column;
+ flex-wrap: nowrap;
+ padding: 0.4rem;
+ overflow-y: auto;
+ scrollbar-width: thin;
+ scrollbar-color: #6a737b #f8f9fa;
+}
+
+#course-summarise-response {
+ font-size: 0.875em;
+}
+
+.course-summarise-response-controls button .icon,
+.course-summarise-response-watermark img.icon {
+ margin-right: 0;
+}
+
+.ai-course-summarise-controls button.btn.btn-outline-secondary {
+ color: unset;
+}
+
+.ai-course-summarise-controls button.btn.btn-outline-secondary span.ai-course-summarise-sparkles-icon {
+ display: inline-block;
+}
+
+.ai-course-summarise-controls button.btn.btn-outline-secondary span.ai-course-summarise-sparkles-icon.white {
+ display: none;
+}
+
+.ai-course-summarise-controls button.btn.btn-outline-secondary:not([disabled]):hover {
+ color: #fff;
+}
+
+.ai-course-summarise-controls button.btn.btn-outline-secondary:not([disabled]):hover span.ai-course-summarise-sparkles-icon {
+ display: none;
+}
+
+.ai-course-summarise-controls button.btn.btn-outline-secondary:not([disabled]):hover span.ai-course-summarise-sparkles-icon.white {
+ display: inline-block;
+}
+
+.ai-course-summarise-controls button img.icon {
+ width: auto;
+ vertical-align: sub;
+}
diff --git a/ai/placement/courseassist/templates/drawer.mustache b/ai/placement/courseassist/templates/drawer.mustache
new file mode 100644
index 00000000000..34b8679aa25
--- /dev/null
+++ b/ai/placement/courseassist/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_courseassist/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
"
+ }
+}}
+
+{{#js}}
+ require(['aiplacement_courseassist/placement'], function(AICourseAssist) {
+ const AI = new AICourseAssist({{userid}}, {{contextid}});
+ });
+{{/js}}
diff --git a/ai/placement/courseassist/templates/error.mustache b/ai/placement/courseassist/templates/error.mustache
new file mode 100644
index 00000000000..91ccf47c95b
--- /dev/null
+++ b/ai/placement/courseassist/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_courseassist/error
+
+ Template to display error message when AI placement fails.
+
+ Context variables required for this template:
+ * none
+
+ Example context (json):
+ {
+ }
+}}
+
+
+
+
+
+
+ {{#str}} tryagain, aiplacement_courseassist {{/str}}
+
+
+
+
+
diff --git a/ai/placement/courseassist/templates/loading.mustache b/ai/placement/courseassist/templates/loading.mustache
new file mode 100644
index 00000000000..bbb959766a9
--- /dev/null
+++ b/ai/placement/courseassist/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_courseassist/loading
+
+ Template to display loading message when AI placement is in progress.
+
+ Context variables required for this template:
+ * none
+
+ Example context (json):
+ {
+ }
+}}
+
+
+
+
+
+
+ {{#str}} cancel, core {{/str}}
+
+
+
+
+
diff --git a/ai/placement/courseassist/templates/response.mustache b/ai/placement/courseassist/templates/response.mustache
new file mode 100644
index 00000000000..7384ce410cb
--- /dev/null
+++ b/ai/placement/courseassist/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_courseassist/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
"
+ }
+}}
+
+
+
+
+
+ {{{content}}}
+
+
+
+ {{#pix}} sparkles, aiplacement_courseassist {{/pix}}
+ {{#str}} contentwatermark, core_ai {{/str}}
+
+
+
+
+ {{#pix}} a/refresh, core {{/pix}}
+ {{#str}} regenerate, aiplacement_courseassist {{/str}}
+
+
+ {{#pix}} e/copy, core {{/pix}}
+ {{#str}} copy, aiplacement_courseassist {{/str}}
+
+
+
+
+
diff --git a/ai/placement/courseassist/templates/summarise_button.mustache b/ai/placement/courseassist/templates/summarise_button.mustache
new file mode 100644
index 00000000000..2c22587994b
--- /dev/null
+++ b/ai/placement/courseassist/templates/summarise_button.mustache
@@ -0,0 +1,46 @@
+{{!
+ 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_courseassist/summarise_button
+
+ Template to display summarise button for the course assist.
+
+ Context variables required for this template:
+ * none
+
+ Example context (json):
+ {
+ }
+}}
+
+
+
+ {{#pix}} sparkles, aiplacement_courseassist {{/pix}}
+
+
+ {{#pix}} sparkles-white, aiplacement_courseassist {{/pix}}
+
+ {{#str}} summarise, aiplacement_courseassist {{/str}}
+
+
diff --git a/ai/placement/courseassist/tests/behat/course_assist_summarise.feature b/ai/placement/courseassist/tests/behat/course_assist_summarise.feature
new file mode 100644
index 00000000000..0002ed7c6f7
--- /dev/null
+++ b/ai/placement/courseassist/tests/behat/course_assist_summarise.feature
@@ -0,0 +1,56 @@
+@core_ai @aiplacement_courseassist
+Feature: AI Course assist summarise
+ In order to generate a summary of a module using AI, as a teacher, I need to be able to use the AI course assist summarise feature
+
+ Background:
+ Given the following "users" exist:
+ | username | firstname | lastname | email |
+ | teacher1 | Teacher | 1 | t1@example.com |
+ | teacher2 | Teacher | 2 | t2@example.com |
+ And the following "courses" exist:
+ | fullname | shortname | format |
+ | Course 1 | C1 | topics |
+ And the following "roles" exist:
+ | name | shortname | description | archetype |
+ | Custom editing teacher | custom1 | My custom role 1 | editingteacher |
+ | Custom teacher | custom2 | My custom role 2 | editingteacher |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | teacher1 | C1 | custom1 |
+ | teacher2 | C1 | custom2 |
+ And the following "activities" exist:
+ | activity | name | intro | introformat | course | content | contentformat | idnumber |
+ | page | PageName1 | PageDesc1 | 1 | C1 | PageContent | 1 | 1 |
+ And the following "permission overrides" exist:
+ | capability | permission | role | contextlevel | reference |
+ | aiplacement/courseassist:summarise_text | Prohibit | custom2 | Course | C1 |
+ And I log in as "admin"
+ And I enable "openai" "aiprovider" plugin
+ And I enable "courseassist" "aiplacement" plugin
+
+ @javascript
+ Scenario: Summarise text using AI is not available if placement is not enabled
+ Given I disable "courseassist" "aiplacement" plugin
+ When I am on the "PageName1" "page activity" page logged in as teacher1
+ Then "Summarise" "button" should not exist
+ And I enable "courseassist" "aiplacement" plugin
+ And I am on the "PageName1" "page activity" page logged in as teacher1
+ And "Summarise" "button" should exist
+
+ @javascript
+ Scenario: Summarise text using AI is not available if provider is not enabled
+ Given I disable "openai" "aiprovider" plugin
+ When I am on the "PageName1" "page activity" page logged in as teacher1
+ Then "Summarise" "button" should not exist
+ And I enable "openai" "aiprovider" plugin
+ And I am on the "PageName1" "page activity" page logged in as teacher1
+ And "Summarise" "button" should exist
+
+ @javascript
+ Scenario: Summarise text using AI is not available if the user does not have permission
+ When I am on the "PageName1" "page activity" page logged in as teacher2
+ Then "Summarise" "button" should not exist
+ When I am on the "PageName1" "page activity" page logged in as teacher1
+ And "Summarise" "button" should exist
+ And I click on "Summarise" "button"
+ And I should see "Welcome to the new AI feature!" in the ".ai-drawer" "css_element"
diff --git a/ai/placement/courseassist/version.php b/ai/placement/courseassist/version.php
new file mode 100644
index 00000000000..475fed90b80
--- /dev/null
+++ b/ai/placement/courseassist/version.php
@@ -0,0 +1,30 @@
+.
+
+/**
+ * Version information for aiplacement_courseassist.
+ *
+ * @package aiplacement_courseassist
+ * @copyright 2024 Matt Porritt
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->component = 'aiplacement_courseassist';
+$plugin->version = 2024061401;
+$plugin->requires = 2024041600;
+$plugin->maturity = MATURITY_ALPHA;
diff --git a/ai/placement/editor/lang/en/aiplacement_editor.php b/ai/placement/editor/lang/en/aiplacement_editor.php
index a7782ce0394..7d4b2684a30 100644
--- a/ai/placement/editor/lang/en/aiplacement_editor.php
+++ b/ai/placement/editor/lang/en/aiplacement_editor.php
@@ -22,8 +22,6 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-$string['accept'] = 'Accept and continue';
-$string['cancelai'] = 'Decline';
$string['generatecontent'] = 'Generate AI Content';
$string['generateimage'] = 'AI generate image';
$string['generateimagesetting'] = 'Enable generate image';
diff --git a/ai/templates/policyblock.mustache b/ai/templates/policyblock.mustache
new file mode 100644
index 00000000000..31ae1431270
--- /dev/null
+++ b/ai/templates/policyblock.mustache
@@ -0,0 +1,46 @@
+{{!
+ 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 core_ai/policyblock
+
+ Template to display AI policy in the block drawer.
+
+ Context variables required for this template:
+ * none
+
+ Example context (json):
+ {
+ }
+}}
+
+
+
+
+ {{#str}} userpolicy, core_ai {{/str}}
+
+
+ {{#str}} declineaipolicy, core_ai {{/str}}
+
+
+ {{#str}} acceptai, core_ai {{/str}}
+
+
+
+
+
diff --git a/ai/templates/policymodal.mustache b/ai/templates/policymodal.mustache
index 0ea0cc47af6..236da47887b 100644
--- a/ai/templates/policymodal.mustache
+++ b/ai/templates/policymodal.mustache
@@ -51,8 +51,8 @@
{{/body}}
{{$footer}}
- {{#str}} cancelai, aiplacement_editor {{/str}}
- {{#str}} accept, aiplacement_editor {{/str}}
+ {{#str}} declineaipolicy, core_ai {{/str}}
+ {{#str}} acceptai, core_ai {{/str}}
{{/footer}}
{{/ core/modal }}
diff --git a/lang/en/ai.php b/lang/en/ai.php
index 56328c2625c..cdaddfb4bd6 100644
--- a/lang/en/ai.php
+++ b/lang/en/ai.php
@@ -22,6 +22,7 @@
* @copyright 2024 Matt Porritt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
+$string['acceptai'] = 'Accept and continue';
$string['action'] = 'Action';
$string['action_generate_image'] = 'Generate image';
$string['action_generate_image_desc'] = 'Generates an image based on a text prompt.';
@@ -59,7 +60,8 @@ $string['availableproviders'] = 'Available AI providers';
$string['availableproviders_desc'] = 'Select an AI provider to manage its settings.
AI providers are responsible for providing the AI services used by the AI subsystem.
Each enabled provider makes available one or more "AI Actions". Actions can be enabled or disabled for each provider in the provider plugin settings.';
-$string['imagewatermark'] = 'Generated by AI';
+$string['contentwatermark'] = 'Generated by AI';
+$string['declineaipolicy'] = 'Decline';
$string['manageaiplacements'] = 'Manage AI placements';
$string['manageaiproviders'] = 'Manage AI providers';
$string['noproviders'] = 'This action is unavailable. No providers are configured for this action';
@@ -114,11 +116,11 @@ They control how the provider connects to the AI service, and related operations
$string['userpolicy'] = 'Welcome to the new AI feature!
This Artificial Intelligence (AI) feature is based solely on external Large Language Models (LLM) to improve your learning and teaching experience. Before you start using these AI services, please read this usage policy.
-
+
Accuracy of AI-generated content
AI can provide useful suggestions and information, but its accuracy may vary. You should always double-check the information provided to make sure it\'s accurate, complete, and suitable for your specific situation.
-
+
How your data is processed
This AI feature is provided by external, third-party LLMs. If you choose to use this feature, any inputs or personal data you submit will be processed in accordance with the privacy policy of the third-party service. We recommend that you review the LLM\'s privacy to understand how your personal data will be handled.
diff --git a/lib/classes/hook/output/after_http_headers.php b/lib/classes/hook/output/after_http_headers.php
new file mode 100644
index 00000000000..4cfeea34543
--- /dev/null
+++ b/lib/classes/hook/output/after_http_headers.php
@@ -0,0 +1,65 @@
+.
+
+namespace core\hook\output;
+
+/**
+ * Class after_http_headers
+ *
+ * @package core
+ * @copyright 2024 Huong Nguyen
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @property-read \renderer_base $renderer The page renderer object
+ */
+#[\core\attribute\tags('output')]
+#[\core\attribute\label('Allows plugins to make changes after headers are sent')]
+class after_http_headers {
+ /**
+ * Hook to allow subscribers to modify the process after headers are sent.
+ *
+ * @param \renderer_base $renderer
+ * @param string $output
+ */
+ public function __construct(
+ /** @var \renderer_base The page renderer object */
+ public readonly \renderer_base $renderer,
+ /** @var string The collected output */
+ private string $output = '',
+ ) {
+ }
+
+ /**
+ * Plugins implementing callback can add any HTML after headers content.
+ *
+ * Must be a string containing valid html content.
+ *
+ * @param null|string $output
+ */
+ public function add_html(?string $output): void {
+ if ($output) {
+ $this->output .= $output;
+ }
+ }
+
+ /**
+ * Returns all HTML added by the plugins
+ *
+ * @return string
+ */
+ public function get_output(): string {
+ return $this->output;
+ }
+}
diff --git a/lib/classes/output/core_renderer.php b/lib/classes/output/core_renderer.php
index 0b7b3ecc312..7b201004858 100644
--- a/lib/classes/output/core_renderer.php
+++ b/lib/classes/output/core_renderer.php
@@ -18,6 +18,7 @@ namespace core\output;
use breadcrumb_navigation_node;
use cm_info;
+use core\hook\output\after_http_headers;
use core_block\output\block_contents;
use core_block\output\block_move_target;
use core_completion\cm_completion_details;
@@ -937,7 +938,14 @@ class core_renderer extends renderer_base {
if (!$this->page->cm || !empty($this->page->layout_options['noactivityheader'])) {
$header .= $this->skip_link_target('maincontent');
}
- return $header;
+
+ $hook = new after_http_headers(
+ renderer: $this,
+ output: $header,
+ );
+ di::get(hook_manager::class)->dispatch($hook);
+
+ return $hook->get_output();
}
/**
diff --git a/lib/editor/tiny/plugins/aiplacement/amd/build/generatetext.min.js b/lib/editor/tiny/plugins/aiplacement/amd/build/generatetext.min.js
index f99cb03fbe7..3e014a5f6bd 100644
--- a/lib/editor/tiny/plugins/aiplacement/amd/build/generatetext.min.js
+++ b/lib/editor/tiny/plugins/aiplacement/amd/build/generatetext.min.js
@@ -1,3 +1,3 @@
-define("tiny_aiplacement/generatetext",["exports","./textmodal","core/ajax","core/str","core/templates","./options","./textmark","./generatebase"],(function(_exports,_textmodal,_ajax,_str,_templates,_options,_textmark,_generatebase){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_textmodal=_interopRequireDefault(_textmodal),_ajax=_interopRequireDefault(_ajax),_templates=_interopRequireDefault(_templates),_textmark=_interopRequireDefault(_textmark),_generatebase=_interopRequireDefault(_generatebase);class GenerateText extends _generatebase.default{constructor(){var obj,key,value;super(...arguments),value={GENERATEBUTTON:()=>'[id="'.concat(this.editor.id,'_tiny_aiplacement_generatebutton"]'),PROMPTAREA:()=>'[id="'.concat(this.editor.id,'_tiny_aiplacement_textprompt"]'),RESPONSEWRAPPER:".tiny_aiplacement_textresponse",RESPONSEPLACEHOLDER:".tiny_aiplacement_textresponse_placeholder",GENERATEDRESPONSE:()=>'[id="'.concat(this.editor.id,'_tiny_aiplacement_textresponse"]'),INSERTBTN:'[data-action="inserter"]',BACKTBTN:'[data-action="back"]'},(key="SELECTORS")in(obj=this)?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value}getModalClass(){return _textmodal.default}handleContentModalClick(e,root){const actions={generate:()=>this.handleSubmit(root,e.target),inserter:()=>this.handleInsert(root,e.target),cancel:()=>this.modalObject.destroy(),back:()=>{this.modalObject.destroy(),this.displayContentModal()}},actionKey=Object.keys(actions).find((key=>e.target.closest('[data-action="'.concat(key,'"]'))));actionKey&&(e.preventDefault(),actions[actionKey]())}setupPromptArea(root){const generateBtn=root.querySelector(this.SELECTORS.GENERATEBUTTON()),promptArea=root.querySelector(this.SELECTORS.PROMPTAREA());promptArea.addEventListener("input",(()=>{generateBtn.disabled=""===promptArea.value.trim()}))}async handleSubmit(root,submitBtn){await this.displayLoading(root,submitBtn);const request={methodname:"aiplacement_editor_generate_text",args:this.getRequestArgs(root)};try{this.responseObj=await _ajax.default.call([request])[0],this.responseObj.error?this.handleGenerationError(root,submitBtn,""):(await this.displayGeneratedText(root),this.hideLoading(root,submitBtn))}catch(error){this.handleGenerationError(root,submitBtn,"")}}async handleInsert(root,submitBtn){await this.displayLoading(root,submitBtn);const generatedResponseDiv=root.querySelector(this.SELECTORS.GENERATEDRESPONSE()),wrappedEditedResponse=await _textmark.default.wrapEditedSections(this.responseObj.generatedcontent,generatedResponseDiv.value);this.responseObj.editedtext=this.replaceLineBreaks(wrappedEditedResponse);const formattedResponse=await _templates.default.render("tiny_aiplacement/textinsert",this.responseObj);this.editor.insertContent(formattedResponse),this.editor.execCommand("mceRepaint"),this.editor.windowManager.close(),this.modalObject.hide()}async handleGenerationError(root,submitBtn){let errorMessage=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"";errorMessage||(errorMessage=await(0,_str.getString)("errorgeneral","tiny_aiplacement")),this.modalObject.setBody(_templates.default.render("tiny_aiplacement/modalbodyerror",{errorMessage:errorMessage}));const backBtn=root.querySelector(this.SELECTORS.BACKTBTN),generateBtn=root.querySelector(this.SELECTORS.GENERATEBUTTON());backBtn.classList.remove("hidden"),generateBtn.classList.add("hidden"),this.hideLoading(root,submitBtn)}async displayGeneratedText(root){root.querySelector(this.SELECTORS.INSERTBTN).classList.remove("hidden");root.querySelector(this.SELECTORS.GENERATEDRESPONSE()).value=this.responseObj.generatedcontent;root.querySelector(this.SELECTORS.RESPONSEWRAPPER).classList.remove("hidden");root.querySelector(this.SELECTORS.RESPONSEPLACEHOLDER).classList.add("hidden")}getRequestArgs(root){return{contextid:(0,_options.getContextId)(this.editor),prompttext:root.querySelector(this.SELECTORS.PROMPTAREA()).value}}replaceLineBreaks(text){const textWithBreaks=text.replace(/\n{2,}|\r\n/g," ").replace(/\n/g," ");return"".concat(textWithBreaks,"
")}}return _exports.default=GenerateText,_exports.default}));
+define("tiny_aiplacement/generatetext",["exports","./textmodal","core/ajax","core/str","core/templates","core_ai/helper","./options","./textmark","./generatebase"],(function(_exports,_textmodal,_ajax,_str,_templates,_helper,_options,_textmark,_generatebase){function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_textmodal=_interopRequireDefault(_textmodal),_ajax=_interopRequireDefault(_ajax),_templates=_interopRequireDefault(_templates),_helper=_interopRequireDefault(_helper),_textmark=_interopRequireDefault(_textmark),_generatebase=_interopRequireDefault(_generatebase);class GenerateText extends _generatebase.default{constructor(){var obj,key,value;super(...arguments),value={GENERATEBUTTON:()=>'[id="'.concat(this.editor.id,'_tiny_aiplacement_generatebutton"]'),PROMPTAREA:()=>'[id="'.concat(this.editor.id,'_tiny_aiplacement_textprompt"]'),RESPONSEWRAPPER:".tiny_aiplacement_textresponse",RESPONSEPLACEHOLDER:".tiny_aiplacement_textresponse_placeholder",GENERATEDRESPONSE:()=>'[id="'.concat(this.editor.id,'_tiny_aiplacement_textresponse"]'),INSERTBTN:'[data-action="inserter"]',BACKTBTN:'[data-action="back"]'},(key="SELECTORS")in(obj=this)?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value}getModalClass(){return _textmodal.default}handleContentModalClick(e,root){const actions={generate:()=>this.handleSubmit(root,e.target),inserter:()=>this.handleInsert(root,e.target),cancel:()=>this.modalObject.destroy(),back:()=>{this.modalObject.destroy(),this.displayContentModal()}},actionKey=Object.keys(actions).find((key=>e.target.closest('[data-action="'.concat(key,'"]'))));actionKey&&(e.preventDefault(),actions[actionKey]())}setupPromptArea(root){const generateBtn=root.querySelector(this.SELECTORS.GENERATEBUTTON()),promptArea=root.querySelector(this.SELECTORS.PROMPTAREA());promptArea.addEventListener("input",(()=>{generateBtn.disabled=""===promptArea.value.trim()}))}async handleSubmit(root,submitBtn){await this.displayLoading(root,submitBtn);const request={methodname:"aiplacement_editor_generate_text",args:this.getRequestArgs(root)};try{this.responseObj=await _ajax.default.call([request])[0],this.responseObj.error?this.handleGenerationError(root,submitBtn,""):(await this.displayGeneratedText(root),this.hideLoading(root,submitBtn))}catch(error){this.handleGenerationError(root,submitBtn,"")}}async handleInsert(root,submitBtn){await this.displayLoading(root,submitBtn);const generatedResponseDiv=root.querySelector(this.SELECTORS.GENERATEDRESPONSE()),wrappedEditedResponse=await _textmark.default.wrapEditedSections(this.responseObj.generatedcontent,generatedResponseDiv.value);this.responseObj.editedtext=_helper.default.replaceLineBreaks(wrappedEditedResponse);const formattedResponse=await _templates.default.render("tiny_aiplacement/textinsert",this.responseObj);this.editor.insertContent(formattedResponse),this.editor.execCommand("mceRepaint"),this.editor.windowManager.close(),this.modalObject.hide()}async handleGenerationError(root,submitBtn){let errorMessage=arguments.length>2&&void 0!==arguments[2]?arguments[2]:"";errorMessage||(errorMessage=await(0,_str.getString)("errorgeneral","tiny_aiplacement")),this.modalObject.setBody(_templates.default.render("tiny_aiplacement/modalbodyerror",{errorMessage:errorMessage}));const backBtn=root.querySelector(this.SELECTORS.BACKTBTN),generateBtn=root.querySelector(this.SELECTORS.GENERATEBUTTON());backBtn.classList.remove("hidden"),generateBtn.classList.add("hidden"),this.hideLoading(root,submitBtn)}async displayGeneratedText(root){root.querySelector(this.SELECTORS.INSERTBTN).classList.remove("hidden");root.querySelector(this.SELECTORS.GENERATEDRESPONSE()).value=this.responseObj.generatedcontent;root.querySelector(this.SELECTORS.RESPONSEWRAPPER).classList.remove("hidden");root.querySelector(this.SELECTORS.RESPONSEPLACEHOLDER).classList.add("hidden")}getRequestArgs(root){return{contextid:(0,_options.getContextId)(this.editor),prompttext:root.querySelector(this.SELECTORS.PROMPTAREA()).value}}}return _exports.default=GenerateText,_exports.default}));
//# sourceMappingURL=generatetext.min.js.map
\ No newline at end of file
diff --git a/lib/editor/tiny/plugins/aiplacement/amd/build/generatetext.min.js.map b/lib/editor/tiny/plugins/aiplacement/amd/build/generatetext.min.js.map
index 59729634072..4aaebd08fb9 100644
--- a/lib/editor/tiny/plugins/aiplacement/amd/build/generatetext.min.js.map
+++ b/lib/editor/tiny/plugins/aiplacement/amd/build/generatetext.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"generatetext.min.js","sources":["../src/generatetext.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 * Tiny AI generate text.\n *\n * @module tiny_aiplacement/generatetext\n * @copyright 2024 Matt Porritt \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport TextModal from './textmodal';\nimport Ajax from 'core/ajax';\nimport {getString} from 'core/str';\nimport Templates from 'core/templates';\nimport {getContextId} from './options';\nimport TinyAiTextMarker from './textmark';\nimport GenerateBase from './generatebase';\n\nexport default class GenerateText extends GenerateBase {\n SELECTORS = {\n GENERATEBUTTON: () => `[id=\"${this.editor.id}_tiny_aiplacement_generatebutton\"]`,\n PROMPTAREA: () => `[id=\"${this.editor.id}_tiny_aiplacement_textprompt\"]`,\n RESPONSEWRAPPER: '.tiny_aiplacement_textresponse',\n RESPONSEPLACEHOLDER: '.tiny_aiplacement_textresponse_placeholder',\n GENERATEDRESPONSE: () => `[id=\"${this.editor.id}_tiny_aiplacement_textresponse\"]`,\n INSERTBTN: '[data-action=\"inserter\"]',\n BACKTBTN: '[data-action=\"back\"]',\n };\n\n getModalClass() {\n return TextModal;\n }\n\n /**\n * Handle click events within the text modal.\n *\n * @param {Event} e - The click event object.\n * @param {HTMLElement} root - The root element of the modal.\n */\n handleContentModalClick(e, root) {\n const actions = {\n generate: () => this.handleSubmit(root, e.target),\n inserter: () => this.handleInsert(root, e.target),\n cancel: () => this.modalObject.destroy(),\n back: () => {\n this.modalObject.destroy();\n this.displayContentModal();\n },\n };\n\n const actionKey = Object.keys(actions).find(key => e.target.closest(`[data-action=\"${key}\"]`));\n if (actionKey) {\n e.preventDefault();\n actions[actionKey]();\n }\n }\n\n /**\n * Set up the prompt area in the modal, adding necessary event listeners.\n *\n * @param {HTMLElement} root - The root element of the modal.\n */\n setupPromptArea(root) {\n const generateBtn = root.querySelector(this.SELECTORS.GENERATEBUTTON());\n const promptArea = root.querySelector(this.SELECTORS.PROMPTAREA());\n\n promptArea.addEventListener('input', () => {\n generateBtn.disabled = promptArea.value.trim() === '';\n });\n }\n\n /**\n * Handle the submit action.\n *\n * @param {Object} root The root element of the modal.\n * @param {Object} submitBtn The submit button element.\n */\n async handleSubmit(root, submitBtn) {\n await this.displayLoading(root, submitBtn);\n\n const requestArgs = this.getRequestArgs(root);\n const request = {\n methodname: 'aiplacement_editor_generate_text',\n args: requestArgs\n };\n\n try {\n this.responseObj = await Ajax.call([request])[0];\n if (this.responseObj.error) {\n this.handleGenerationError(root, submitBtn, '');\n } else {\n await this.displayGeneratedText(root);\n this.hideLoading(root, submitBtn);\n }\n } catch (error) {\n this.handleGenerationError(root, submitBtn, '');\n }\n }\n\n /**\n * Handle the insert action.\n *\n * @param {Object} root The root element of the modal.\n * @param {HTMLElement} submitBtn - The submit button element.\n */\n async handleInsert(root, submitBtn) {\n await this.displayLoading(root, submitBtn);\n\n // Update the generated response with the content from the form.\n // In case the user has edited the response.\n const generatedResponseDiv = root.querySelector(this.SELECTORS.GENERATEDRESPONSE());\n\n // Wrap the edited sections in the response with tags.\n // This is so we can differentiate between the edited sections and the generated content.\n const wrappedEditedResponse = await TinyAiTextMarker.wrapEditedSections(\n this.responseObj.generatedcontent,\n generatedResponseDiv.value)\n ;\n\n // Replace double line breaks with and with
for paragraphs.\n this.responseObj.editedtext = this.replaceLineBreaks(wrappedEditedResponse);\n\n // Generate the HTML for the response.\n const formattedResponse = await Templates.render('tiny_aiplacement/textinsert', this.responseObj);\n\n // Insert the response into the editor.\n this.editor.insertContent(formattedResponse);\n this.editor.execCommand('mceRepaint');\n this.editor.windowManager.close();\n\n // Close the modal and return to the editor.\n this.modalObject.hide();\n }\n\n /**\n * Handle a generation error.\n *\n * @param {Object} root The root element of the modal.\n * @param {Object} submitBtn The submit button element.\n * @param {String} errorMessage The error message to display.\n */\n async handleGenerationError(root, submitBtn, errorMessage = '') {\n if (!errorMessage) {\n // Get the default error message.\n errorMessage = await getString('errorgeneral', 'tiny_aiplacement');\n }\n this.modalObject.setBody(Templates.render('tiny_aiplacement/modalbodyerror', {'errorMessage': errorMessage}));\n const backBtn = root.querySelector(this.SELECTORS.BACKTBTN);\n const generateBtn = root.querySelector(this.SELECTORS.GENERATEBUTTON());\n backBtn.classList.remove('hidden');\n generateBtn.classList.add('hidden');\n this.hideLoading(root, submitBtn);\n }\n\n /**\n * Display the generated image in the modal.\n *\n * @param {HTMLElement} root - The root element of the modal.\n */\n async displayGeneratedText(root) {\n const insertBtn = root.querySelector(this.SELECTORS.INSERTBTN);\n insertBtn.classList.remove('hidden');\n\n // Add generated text to the modal.\n const generatedResponseDiv = root.querySelector(this.SELECTORS.GENERATEDRESPONSE());\n generatedResponseDiv.value = this.responseObj.generatedcontent;\n const responseWrapper = root.querySelector(this.SELECTORS.RESPONSEWRAPPER);\n responseWrapper.classList.remove('hidden');\n const responsePlaceholder = root.querySelector(this.SELECTORS.RESPONSEPLACEHOLDER);\n responsePlaceholder.classList.add('hidden');\n }\n\n /**\n * Get the request args for the generated text.\n *\n * @param {Object} root The root element of the modal.\n */\n getRequestArgs(root) {\n const contextId = getContextId(this.editor);\n const promptText = root.querySelector(this.SELECTORS.PROMPTAREA()).value;\n\n return {\n contextid: contextId,\n prompttext: promptText\n };\n }\n\n /**\n * Replace double line breaks with and with
for paragraphs.\n * This is to handle the difference in response from the AI to what is expected by the editor.\n *\n * @param {String} text The text to replace.\n * @returns {String}\n */\n replaceLineBreaks(text) {\n // Replace double line breaks with
for paragraphs\n const textWithParagraphs = text.replace(/\\n{2,}|\\r\\n/g, ' ');\n\n // Replace remaining single line breaks with tags\n const textWithBreaks = textWithParagraphs.replace(/\\n/g, ' ');\n\n // Add opening and closing
tags to wrap the entire content\n return `
${textWithBreaks}
`;\n }\n}\n"],"names":["GenerateText","GenerateBase","GENERATEBUTTON","this","editor","id","PROMPTAREA","RESPONSEWRAPPER","RESPONSEPLACEHOLDER","GENERATEDRESPONSE","INSERTBTN","BACKTBTN","getModalClass","TextModal","handleContentModalClick","e","root","actions","generate","handleSubmit","target","inserter","handleInsert","cancel","modalObject","destroy","back","displayContentModal","actionKey","Object","keys","find","key","closest","preventDefault","setupPromptArea","generateBtn","querySelector","SELECTORS","promptArea","addEventListener","disabled","value","trim","submitBtn","displayLoading","request","methodname","args","getRequestArgs","responseObj","Ajax","call","error","handleGenerationError","displayGeneratedText","hideLoading","generatedResponseDiv","wrappedEditedResponse","TinyAiTextMarker","wrapEditedSections","generatedcontent","editedtext","replaceLineBreaks","formattedResponse","Templates","render","insertContent","execCommand","windowManager","close","hide","errorMessage","setBody","backBtn","classList","remove","add","contextid","prompttext","text","textWithBreaks","replace"],"mappings":"inBA+BqBA,qBAAqBC,gFAC1B,CACRC,eAAgB,mBAAcC,KAAKC,OAAOC,yCAC1CC,WAAY,mBAAcH,KAAKC,OAAOC,qCACtCE,gBAAiB,iCACjBC,oBAAqB,6CACrBC,kBAAmB,mBAAcN,KAAKC,OAAOC,uCAC7CK,UAAW,2BACXC,SAAU,4JAGdC,uBACWC,mBASXC,wBAAwBC,EAAGC,YACjBC,QAAU,CACZC,SAAU,IAAMf,KAAKgB,aAAaH,KAAMD,EAAEK,QAC1CC,SAAU,IAAMlB,KAAKmB,aAAaN,KAAMD,EAAEK,QAC1CG,OAAQ,IAAMpB,KAAKqB,YAAYC,UAC/BC,KAAM,UACGF,YAAYC,eACZE,wBAIPC,UAAYC,OAAOC,KAAKb,SAASc,MAAKC,KAAOjB,EAAEK,OAAOa,gCAAyBD,aACjFJ,YACAb,EAAEmB,iBACFjB,QAAQW,cAShBO,gBAAgBnB,YACNoB,YAAcpB,KAAKqB,cAAclC,KAAKmC,UAAUpC,kBAChDqC,WAAavB,KAAKqB,cAAclC,KAAKmC,UAAUhC,cAErDiC,WAAWC,iBAAiB,SAAS,KACjCJ,YAAYK,SAAuC,KAA5BF,WAAWG,MAAMC,6BAU7B3B,KAAM4B,iBACfzC,KAAK0C,eAAe7B,KAAM4B,iBAG1BE,QAAU,CACZC,WAAY,mCACZC,KAHgB7C,KAAK8C,eAAejC,gBAO/BkC,kBAAoBC,cAAKC,KAAK,CAACN,UAAU,GAC1C3C,KAAK+C,YAAYG,WACZC,sBAAsBtC,KAAM4B,UAAW,WAEtCzC,KAAKoD,qBAAqBvC,WAC3BwC,YAAYxC,KAAM4B,YAE7B,MAAOS,YACAC,sBAAsBtC,KAAM4B,UAAW,wBAUjC5B,KAAM4B,iBACfzC,KAAK0C,eAAe7B,KAAM4B,iBAI1Ba,qBAAuBzC,KAAKqB,cAAclC,KAAKmC,UAAU7B,qBAIzDiD,4BAA8BC,kBAAiBC,mBACjDzD,KAAK+C,YAAYW,iBACjBJ,qBAAqBf,YAIpBQ,YAAYY,WAAa3D,KAAK4D,kBAAkBL,6BAG/CM,wBAA0BC,mBAAUC,OAAO,8BAA+B/D,KAAK+C,kBAGhF9C,OAAO+D,cAAcH,wBACrB5D,OAAOgE,YAAY,mBACnBhE,OAAOiE,cAAcC,aAGrB9C,YAAY+C,mCAUOvD,KAAM4B,eAAW4B,oEAAe,GACnDA,eAEDA,mBAAqB,kBAAU,eAAgB,0BAE9ChD,YAAYiD,QAAQR,mBAAUC,OAAO,kCAAmC,cAAiBM,sBACxFE,QAAU1D,KAAKqB,cAAclC,KAAKmC,UAAU3B,UAC5CyB,YAAcpB,KAAKqB,cAAclC,KAAKmC,UAAUpC,kBACtDwE,QAAQC,UAAUC,OAAO,UACzBxC,YAAYuC,UAAUE,IAAI,eACrBrB,YAAYxC,KAAM4B,sCAQA5B,MACLA,KAAKqB,cAAclC,KAAKmC,UAAU5B,WAC1CiE,UAAUC,OAAO,UAGE5D,KAAKqB,cAAclC,KAAKmC,UAAU7B,qBAC1CiC,MAAQvC,KAAK+C,YAAYW,iBACtB7C,KAAKqB,cAAclC,KAAKmC,UAAU/B,iBAC1CoE,UAAUC,OAAO,UACL5D,KAAKqB,cAAclC,KAAKmC,UAAU9B,qBAC1CmE,UAAUE,IAAI,UAQtC5B,eAAejC,YAIJ,CACH8D,WAJc,yBAAa3E,KAAKC,QAKhC2E,WAJe/D,KAAKqB,cAAclC,KAAKmC,UAAUhC,cAAcoC,OAevEqB,kBAAkBiB,YAKRC,eAHqBD,KAAKE,QAAQ,eAAgB,cAGdA,QAAQ,MAAO,4BAG5CD"}
\ No newline at end of file
+{"version":3,"file":"generatetext.min.js","sources":["../src/generatetext.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 * Tiny AI generate text.\n *\n * @module tiny_aiplacement/generatetext\n * @copyright 2024 Matt Porritt \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport TextModal from './textmodal';\nimport Ajax from 'core/ajax';\nimport {getString} from 'core/str';\nimport Templates from 'core/templates';\nimport AIHelper from 'core_ai/helper';\nimport {getContextId} from './options';\nimport TinyAiTextMarker from './textmark';\nimport GenerateBase from './generatebase';\n\nexport default class GenerateText extends GenerateBase {\n SELECTORS = {\n GENERATEBUTTON: () => `[id=\"${this.editor.id}_tiny_aiplacement_generatebutton\"]`,\n PROMPTAREA: () => `[id=\"${this.editor.id}_tiny_aiplacement_textprompt\"]`,\n RESPONSEWRAPPER: '.tiny_aiplacement_textresponse',\n RESPONSEPLACEHOLDER: '.tiny_aiplacement_textresponse_placeholder',\n GENERATEDRESPONSE: () => `[id=\"${this.editor.id}_tiny_aiplacement_textresponse\"]`,\n INSERTBTN: '[data-action=\"inserter\"]',\n BACKTBTN: '[data-action=\"back\"]',\n };\n\n getModalClass() {\n return TextModal;\n }\n\n /**\n * Handle click events within the text modal.\n *\n * @param {Event} e - The click event object.\n * @param {HTMLElement} root - The root element of the modal.\n */\n handleContentModalClick(e, root) {\n const actions = {\n generate: () => this.handleSubmit(root, e.target),\n inserter: () => this.handleInsert(root, e.target),\n cancel: () => this.modalObject.destroy(),\n back: () => {\n this.modalObject.destroy();\n this.displayContentModal();\n },\n };\n\n const actionKey = Object.keys(actions).find(key => e.target.closest(`[data-action=\"${key}\"]`));\n if (actionKey) {\n e.preventDefault();\n actions[actionKey]();\n }\n }\n\n /**\n * Set up the prompt area in the modal, adding necessary event listeners.\n *\n * @param {HTMLElement} root - The root element of the modal.\n */\n setupPromptArea(root) {\n const generateBtn = root.querySelector(this.SELECTORS.GENERATEBUTTON());\n const promptArea = root.querySelector(this.SELECTORS.PROMPTAREA());\n\n promptArea.addEventListener('input', () => {\n generateBtn.disabled = promptArea.value.trim() === '';\n });\n }\n\n /**\n * Handle the submit action.\n *\n * @param {Object} root The root element of the modal.\n * @param {Object} submitBtn The submit button element.\n */\n async handleSubmit(root, submitBtn) {\n await this.displayLoading(root, submitBtn);\n\n const requestArgs = this.getRequestArgs(root);\n const request = {\n methodname: 'aiplacement_editor_generate_text',\n args: requestArgs\n };\n\n try {\n this.responseObj = await Ajax.call([request])[0];\n if (this.responseObj.error) {\n this.handleGenerationError(root, submitBtn, '');\n } else {\n await this.displayGeneratedText(root);\n this.hideLoading(root, submitBtn);\n }\n } catch (error) {\n this.handleGenerationError(root, submitBtn, '');\n }\n }\n\n /**\n * Handle the insert action.\n *\n * @param {Object} root The root element of the modal.\n * @param {HTMLElement} submitBtn - The submit button element.\n */\n async handleInsert(root, submitBtn) {\n await this.displayLoading(root, submitBtn);\n\n // Update the generated response with the content from the form.\n // In case the user has edited the response.\n const generatedResponseDiv = root.querySelector(this.SELECTORS.GENERATEDRESPONSE());\n\n // Wrap the edited sections in the response with tags.\n // This is so we can differentiate between the edited sections and the generated content.\n const wrappedEditedResponse = await TinyAiTextMarker.wrapEditedSections(\n this.responseObj.generatedcontent,\n generatedResponseDiv.value)\n ;\n\n // Replace double line breaks with and with for paragraphs.\n this.responseObj.editedtext = AIHelper.replaceLineBreaks(wrappedEditedResponse);\n\n // Generate the HTML for the response.\n const formattedResponse = await Templates.render('tiny_aiplacement/textinsert', this.responseObj);\n\n // Insert the response into the editor.\n this.editor.insertContent(formattedResponse);\n this.editor.execCommand('mceRepaint');\n this.editor.windowManager.close();\n\n // Close the modal and return to the editor.\n this.modalObject.hide();\n }\n\n /**\n * Handle a generation error.\n *\n * @param {Object} root The root element of the modal.\n * @param {Object} submitBtn The submit button element.\n * @param {String} errorMessage The error message to display.\n */\n async handleGenerationError(root, submitBtn, errorMessage = '') {\n if (!errorMessage) {\n // Get the default error message.\n errorMessage = await getString('errorgeneral', 'tiny_aiplacement');\n }\n this.modalObject.setBody(Templates.render('tiny_aiplacement/modalbodyerror', {'errorMessage': errorMessage}));\n const backBtn = root.querySelector(this.SELECTORS.BACKTBTN);\n const generateBtn = root.querySelector(this.SELECTORS.GENERATEBUTTON());\n backBtn.classList.remove('hidden');\n generateBtn.classList.add('hidden');\n this.hideLoading(root, submitBtn);\n }\n\n /**\n * Display the generated image in the modal.\n *\n * @param {HTMLElement} root - The root element of the modal.\n */\n async displayGeneratedText(root) {\n const insertBtn = root.querySelector(this.SELECTORS.INSERTBTN);\n insertBtn.classList.remove('hidden');\n\n // Add generated text to the modal.\n const generatedResponseDiv = root.querySelector(this.SELECTORS.GENERATEDRESPONSE());\n generatedResponseDiv.value = this.responseObj.generatedcontent;\n const responseWrapper = root.querySelector(this.SELECTORS.RESPONSEWRAPPER);\n responseWrapper.classList.remove('hidden');\n const responsePlaceholder = root.querySelector(this.SELECTORS.RESPONSEPLACEHOLDER);\n responsePlaceholder.classList.add('hidden');\n }\n\n /**\n * Get the request args for the generated text.\n *\n * @param {Object} root The root element of the modal.\n */\n getRequestArgs(root) {\n const contextId = getContextId(this.editor);\n const promptText = root.querySelector(this.SELECTORS.PROMPTAREA()).value;\n\n return {\n contextid: contextId,\n prompttext: promptText\n };\n }\n}\n"],"names":["GenerateText","GenerateBase","GENERATEBUTTON","this","editor","id","PROMPTAREA","RESPONSEWRAPPER","RESPONSEPLACEHOLDER","GENERATEDRESPONSE","INSERTBTN","BACKTBTN","getModalClass","TextModal","handleContentModalClick","e","root","actions","generate","handleSubmit","target","inserter","handleInsert","cancel","modalObject","destroy","back","displayContentModal","actionKey","Object","keys","find","key","closest","preventDefault","setupPromptArea","generateBtn","querySelector","SELECTORS","promptArea","addEventListener","disabled","value","trim","submitBtn","displayLoading","request","methodname","args","getRequestArgs","responseObj","Ajax","call","error","handleGenerationError","displayGeneratedText","hideLoading","generatedResponseDiv","wrappedEditedResponse","TinyAiTextMarker","wrapEditedSections","generatedcontent","editedtext","AIHelper","replaceLineBreaks","formattedResponse","Templates","render","insertContent","execCommand","windowManager","close","hide","errorMessage","setBody","backBtn","classList","remove","add","contextid","prompttext"],"mappings":"krBAgCqBA,qBAAqBC,gFAC1B,CACRC,eAAgB,mBAAcC,KAAKC,OAAOC,yCAC1CC,WAAY,mBAAcH,KAAKC,OAAOC,qCACtCE,gBAAiB,iCACjBC,oBAAqB,6CACrBC,kBAAmB,mBAAcN,KAAKC,OAAOC,uCAC7CK,UAAW,2BACXC,SAAU,4JAGdC,uBACWC,mBASXC,wBAAwBC,EAAGC,YACjBC,QAAU,CACZC,SAAU,IAAMf,KAAKgB,aAAaH,KAAMD,EAAEK,QAC1CC,SAAU,IAAMlB,KAAKmB,aAAaN,KAAMD,EAAEK,QAC1CG,OAAQ,IAAMpB,KAAKqB,YAAYC,UAC/BC,KAAM,UACGF,YAAYC,eACZE,wBAIPC,UAAYC,OAAOC,KAAKb,SAASc,MAAKC,KAAOjB,EAAEK,OAAOa,gCAAyBD,aACjFJ,YACAb,EAAEmB,iBACFjB,QAAQW,cAShBO,gBAAgBnB,YACNoB,YAAcpB,KAAKqB,cAAclC,KAAKmC,UAAUpC,kBAChDqC,WAAavB,KAAKqB,cAAclC,KAAKmC,UAAUhC,cAErDiC,WAAWC,iBAAiB,SAAS,KACjCJ,YAAYK,SAAuC,KAA5BF,WAAWG,MAAMC,6BAU7B3B,KAAM4B,iBACfzC,KAAK0C,eAAe7B,KAAM4B,iBAG1BE,QAAU,CACZC,WAAY,mCACZC,KAHgB7C,KAAK8C,eAAejC,gBAO/BkC,kBAAoBC,cAAKC,KAAK,CAACN,UAAU,GAC1C3C,KAAK+C,YAAYG,WACZC,sBAAsBtC,KAAM4B,UAAW,WAEtCzC,KAAKoD,qBAAqBvC,WAC3BwC,YAAYxC,KAAM4B,YAE7B,MAAOS,YACAC,sBAAsBtC,KAAM4B,UAAW,wBAUjC5B,KAAM4B,iBACfzC,KAAK0C,eAAe7B,KAAM4B,iBAI1Ba,qBAAuBzC,KAAKqB,cAAclC,KAAKmC,UAAU7B,qBAIzDiD,4BAA8BC,kBAAiBC,mBACjDzD,KAAK+C,YAAYW,iBACjBJ,qBAAqBf,YAIpBQ,YAAYY,WAAaC,gBAASC,kBAAkBN,6BAGnDO,wBAA0BC,mBAAUC,OAAO,8BAA+BhE,KAAK+C,kBAGhF9C,OAAOgE,cAAcH,wBACrB7D,OAAOiE,YAAY,mBACnBjE,OAAOkE,cAAcC,aAGrB/C,YAAYgD,mCAUOxD,KAAM4B,eAAW6B,oEAAe,GACnDA,eAEDA,mBAAqB,kBAAU,eAAgB,0BAE9CjD,YAAYkD,QAAQR,mBAAUC,OAAO,kCAAmC,cAAiBM,sBACxFE,QAAU3D,KAAKqB,cAAclC,KAAKmC,UAAU3B,UAC5CyB,YAAcpB,KAAKqB,cAAclC,KAAKmC,UAAUpC,kBACtDyE,QAAQC,UAAUC,OAAO,UACzBzC,YAAYwC,UAAUE,IAAI,eACrBtB,YAAYxC,KAAM4B,sCAQA5B,MACLA,KAAKqB,cAAclC,KAAKmC,UAAU5B,WAC1CkE,UAAUC,OAAO,UAGE7D,KAAKqB,cAAclC,KAAKmC,UAAU7B,qBAC1CiC,MAAQvC,KAAK+C,YAAYW,iBACtB7C,KAAKqB,cAAclC,KAAKmC,UAAU/B,iBAC1CqE,UAAUC,OAAO,UACL7D,KAAKqB,cAAclC,KAAKmC,UAAU9B,qBAC1CoE,UAAUE,IAAI,UAQtC7B,eAAejC,YAIJ,CACH+D,WAJc,yBAAa5E,KAAKC,QAKhC4E,WAJehE,KAAKqB,cAAclC,KAAKmC,UAAUhC,cAAcoC"}
\ No newline at end of file
diff --git a/lib/editor/tiny/plugins/aiplacement/amd/build/mediaimage.min.js b/lib/editor/tiny/plugins/aiplacement/amd/build/mediaimage.min.js
index deec2823abd..4760aafb70b 100644
--- a/lib/editor/tiny/plugins/aiplacement/amd/build/mediaimage.min.js
+++ b/lib/editor/tiny/plugins/aiplacement/amd/build/mediaimage.min.js
@@ -5,6 +5,6 @@ define("tiny_aiplacement/mediaimage",["exports","core/str","tiny_media/image","c
* @module tiny_aiplacement/mediaimage
* @copyright 2024 Matt Porritt
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_image=_interopRequireDefault(_image),_notification=_interopRequireDefault(_notification),(0,_prefetch.prefetchStrings)("core_ai",["imagewatermark"]);class AiMediaImage extends _image.default{constructor(editor,url,alt){super(editor),this.generatedImageUrl=url,this.altText=alt,(0,_str.getString)("imagewatermark","core_ai").then((watermark=>{this.watermark=watermark})).catch(_notification.default.exception)}getSelectedImage(){const imgElement=document.createElement("img");return imgElement.src=this.generatedImageUrl,imgElement.alt=this.truncateAltText(this.altText),imgElement}truncateAltText(altText){const watermark=" - "+this.watermark;if(altText.length+watermark.length<=125)altText+=watermark;else{const remainingLength=125-watermark.length-"...".length;altText=altText.substring(0,remainingLength)+"..."+watermark}return altText}}return _exports.default=AiMediaImage,_exports.default}));
+ */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_image=_interopRequireDefault(_image),_notification=_interopRequireDefault(_notification),(0,_prefetch.prefetchStrings)("core_ai",["imagewatermark"]);class AiMediaImage extends _image.default{constructor(editor,url,alt){super(editor),this.generatedImageUrl=url,this.altText=alt,(0,_str.getString)("contentwatermark","core_ai").then((watermark=>{this.watermark=watermark})).catch(_notification.default.exception)}getSelectedImage(){const imgElement=document.createElement("img");return imgElement.src=this.generatedImageUrl,imgElement.alt=this.truncateAltText(this.altText),imgElement}truncateAltText(altText){const watermark=" - "+this.watermark;if(altText.length+watermark.length<=125)altText+=watermark;else{const remainingLength=125-watermark.length-"...".length;altText=altText.substring(0,remainingLength)+"..."+watermark}return altText}}return _exports.default=AiMediaImage,_exports.default}));
//# sourceMappingURL=mediaimage.min.js.map
\ No newline at end of file
diff --git a/lib/editor/tiny/plugins/aiplacement/amd/build/mediaimage.min.js.map b/lib/editor/tiny/plugins/aiplacement/amd/build/mediaimage.min.js.map
index 9f37f1eeeaf..ff07d3ba787 100644
--- a/lib/editor/tiny/plugins/aiplacement/amd/build/mediaimage.min.js.map
+++ b/lib/editor/tiny/plugins/aiplacement/amd/build/mediaimage.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"mediaimage.min.js","sources":["../src/mediaimage.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 * AI Modal for Tiny.\n *\n * @module tiny_aiplacement/mediaimage\n * @copyright 2024 Matt Porritt \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {getString} from 'core/str';\nimport MediaImage from 'tiny_media/image';\nimport Notification from 'core/notification';\nimport {prefetchStrings} from 'core/prefetch';\n\nprefetchStrings('core_ai', [\n 'imagewatermark',\n]);\n\nexport default class AiMediaImage extends MediaImage {\n constructor(editor, url, alt) {\n super(editor); // Call the parent class constructor\n this.generatedImageUrl = url;\n this.altText = alt;\n getString('imagewatermark', 'core_ai').then((watermark) => {\n this.watermark = watermark;\n return;\n }).catch(Notification.exception);\n }\n\n getSelectedImage() {\n const imgElement = document.createElement('img');\n\n // Set attributes for the img element\n imgElement.src = this.generatedImageUrl;\n imgElement.alt = this.truncateAltText(this.altText);\n\n return imgElement;\n }\n\n /**\n * Truncate the alt text if it is longer than the maximum length.\n * @param {String} altText The alt text\n * @return {string} The truncated alt text\n */\n truncateAltText(altText) {\n const maximumAltTextLength = 125;\n const watermark = ' - ' + this.watermark;\n const ellipsis = '...';\n\n // Append the watermark to the alt text.\n if (altText.length + watermark.length <= maximumAltTextLength) {\n altText = altText + watermark;\n } else {\n const remainingLength = maximumAltTextLength - watermark.length - ellipsis.length;\n altText = altText.substring(0, remainingLength) + ellipsis + watermark;\n }\n return altText;\n }\n}\n"],"names":["AiMediaImage","MediaImage","constructor","editor","url","alt","generatedImageUrl","altText","then","watermark","catch","Notification","exception","getSelectedImage","imgElement","document","createElement","src","this","truncateAltText","length","remainingLength","substring"],"mappings":";;;;;;;6MA4BgB,UAAW,CACvB,yBAGiBA,qBAAqBC,eACtCC,YAAYC,OAAQC,IAAKC,WACfF,aACDG,kBAAoBF,SACpBG,QAAUF,uBACL,iBAAkB,WAAWG,MAAMC,iBACpCA,UAAYA,aAElBC,MAAMC,sBAAaC,WAG1BC,yBACUC,WAAaC,SAASC,cAAc,cAG1CF,WAAWG,IAAMC,KAAKZ,kBACtBQ,WAAWT,IAAMa,KAAKC,gBAAgBD,KAAKX,SAEpCO,WAQXK,gBAAgBZ,eAENE,UAAY,MAAQS,KAAKT,aAI3BF,QAAQa,OAASX,UAAUW,QALF,IAMzBb,SAAoBE,cACjB,OACGY,gBARmB,IAQsBZ,UAAUW,OAN5C,MAM8DA,OAC3Eb,QAAUA,QAAQe,UAAU,EAAGD,iBAPlB,MAOgDZ,iBAE1DF"}
\ No newline at end of file
+{"version":3,"file":"mediaimage.min.js","sources":["../src/mediaimage.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 * AI Modal for Tiny.\n *\n * @module tiny_aiplacement/mediaimage\n * @copyright 2024 Matt Porritt \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {getString} from 'core/str';\nimport MediaImage from 'tiny_media/image';\nimport Notification from 'core/notification';\nimport {prefetchStrings} from 'core/prefetch';\n\nprefetchStrings('core_ai', [\n 'imagewatermark',\n]);\n\nexport default class AiMediaImage extends MediaImage {\n constructor(editor, url, alt) {\n super(editor); // Call the parent class constructor\n this.generatedImageUrl = url;\n this.altText = alt;\n getString('contentwatermark', 'core_ai').then((watermark) => {\n this.watermark = watermark;\n return;\n }).catch(Notification.exception);\n }\n\n getSelectedImage() {\n const imgElement = document.createElement('img');\n\n // Set attributes for the img element\n imgElement.src = this.generatedImageUrl;\n imgElement.alt = this.truncateAltText(this.altText);\n\n return imgElement;\n }\n\n /**\n * Truncate the alt text if it is longer than the maximum length.\n * @param {String} altText The alt text\n * @return {string} The truncated alt text\n */\n truncateAltText(altText) {\n const maximumAltTextLength = 125;\n const watermark = ' - ' + this.watermark;\n const ellipsis = '...';\n\n // Append the watermark to the alt text.\n if (altText.length + watermark.length <= maximumAltTextLength) {\n altText = altText + watermark;\n } else {\n const remainingLength = maximumAltTextLength - watermark.length - ellipsis.length;\n altText = altText.substring(0, remainingLength) + ellipsis + watermark;\n }\n return altText;\n }\n}\n"],"names":["AiMediaImage","MediaImage","constructor","editor","url","alt","generatedImageUrl","altText","then","watermark","catch","Notification","exception","getSelectedImage","imgElement","document","createElement","src","this","truncateAltText","length","remainingLength","substring"],"mappings":";;;;;;;6MA4BgB,UAAW,CACvB,yBAGiBA,qBAAqBC,eACtCC,YAAYC,OAAQC,IAAKC,WACfF,aACDG,kBAAoBF,SACpBG,QAAUF,uBACL,mBAAoB,WAAWG,MAAMC,iBACtCA,UAAYA,aAElBC,MAAMC,sBAAaC,WAG1BC,yBACUC,WAAaC,SAASC,cAAc,cAG1CF,WAAWG,IAAMC,KAAKZ,kBACtBQ,WAAWT,IAAMa,KAAKC,gBAAgBD,KAAKX,SAEpCO,WAQXK,gBAAgBZ,eAENE,UAAY,MAAQS,KAAKT,aAI3BF,QAAQa,OAASX,UAAUW,QALF,IAMzBb,SAAoBE,cACjB,OACGY,gBARmB,IAQsBZ,UAAUW,OAN5C,MAM8DA,OAC3Eb,QAAUA,QAAQe,UAAU,EAAGD,iBAPlB,MAOgDZ,iBAE1DF"}
\ No newline at end of file
diff --git a/lib/editor/tiny/plugins/aiplacement/amd/src/generatetext.js b/lib/editor/tiny/plugins/aiplacement/amd/src/generatetext.js
index 64dbc5b9ec6..7cdbce262fe 100644
--- a/lib/editor/tiny/plugins/aiplacement/amd/src/generatetext.js
+++ b/lib/editor/tiny/plugins/aiplacement/amd/src/generatetext.js
@@ -25,6 +25,7 @@ import TextModal from './textmodal';
import Ajax from 'core/ajax';
import {getString} from 'core/str';
import Templates from 'core/templates';
+import AIHelper from 'core_ai/helper';
import {getContextId} from './options';
import TinyAiTextMarker from './textmark';
import GenerateBase from './generatebase';
@@ -131,7 +132,7 @@ export default class GenerateText extends GenerateBase {
;
// Replace double line breaks with and with
for paragraphs.
- this.responseObj.editedtext = this.replaceLineBreaks(wrappedEditedResponse);
+ this.responseObj.editedtext = AIHelper.replaceLineBreaks(wrappedEditedResponse);
// Generate the HTML for the response.
const formattedResponse = await Templates.render('tiny_aiplacement/textinsert', this.responseObj);
@@ -197,22 +198,4 @@ export default class GenerateText extends GenerateBase {
prompttext: promptText
};
}
-
- /**
- * Replace double line breaks with and with
for paragraphs.
- * This is to handle the difference in response from the AI to what is expected by the editor.
- *
- * @param {String} text The text to replace.
- * @returns {String}
- */
- replaceLineBreaks(text) {
- // Replace double line breaks with
for paragraphs
- const textWithParagraphs = text.replace(/\n{2,}|\r\n/g, ' ');
-
- // Replace remaining single line breaks with tags
- const textWithBreaks = textWithParagraphs.replace(/\n/g, ' ');
-
- // Add opening and closing
tags to wrap the entire content
- return `
${textWithBreaks}
`;
- }
}
diff --git a/lib/editor/tiny/plugins/aiplacement/amd/src/mediaimage.js b/lib/editor/tiny/plugins/aiplacement/amd/src/mediaimage.js
index 4d9053ea481..3837cdb95b6 100644
--- a/lib/editor/tiny/plugins/aiplacement/amd/src/mediaimage.js
+++ b/lib/editor/tiny/plugins/aiplacement/amd/src/mediaimage.js
@@ -35,7 +35,7 @@ export default class AiMediaImage extends MediaImage {
super(editor); // Call the parent class constructor
this.generatedImageUrl = url;
this.altText = alt;
- getString('imagewatermark', 'core_ai').then((watermark) => {
+ getString('contentwatermark', 'core_ai').then((watermark) => {
this.watermark = watermark;
return;
}).catch(Notification.exception);
diff --git a/lib/plugins.json b/lib/plugins.json
index 3d804113eac..a5a270a467c 100644
--- a/lib/plugins.json
+++ b/lib/plugins.json
@@ -1,6 +1,7 @@
{
"standard": {
"aiplacement": [
+ "courseassist",
"editor"
],
"aiprovider": [