diff --git a/.upgradenotes/MDL-82942-2025022601253889.yml b/.upgradenotes/MDL-82942-2025022601253889.yml new file mode 100644 index 00000000000..6018db0b8bd --- /dev/null +++ b/.upgradenotes/MDL-82942-2025022601253889.yml @@ -0,0 +1,6 @@ +issueNumber: MDL-82942 +notes: + aiplacement_courseassist: + - message: The `aiplacement_courseassist` templates and CSS have been modified. + These changes allow for multiple actions to be nested in a dropdown menu. + type: improved diff --git a/ai/amd/build/helper.min.js b/ai/amd/build/helper.min.js index 55b607b4e3a..cf0c839a2ea 100644 --- a/ai/amd/build/helper.min.js +++ b/ai/amd/build/helper.min.js @@ -6,6 +6,6 @@ define("core_ai/helper",["exports"],(function(_exports){Object.defineProperty(_e * @copyright 2024 Huong Nguyen * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class{static replaceLineBreaks(text){const textWithBreaks=text.replace(/\n{2,}|\r\n/g,"

").replace(/\n/g,"
");return"

".concat(textWithBreaks,"

")}},_exports.default})); +class{static replaceLineBreaks(text){const textWithBreaks=text.replace(/\n{2,}|\r\n/g,"

").replace(/\n/g,"
");return"

".concat(textWithBreaks,"

")}static replaceMarkdown(text){return text.replace(/\*\*(.*?)\*\*/g,"$1")}static formatResponse(text){let formattedText=this.replaceLineBreaks(text);return formattedText=this.replaceMarkdown(formattedText),formattedText}},_exports.default})); //# sourceMappingURL=helper.min.js.map \ No newline at end of file diff --git a/ai/amd/build/helper.min.js.map b/ai/amd/build/helper.min.js.map index 5c7f7680b51..d5beb920885 100644 --- a/ai/amd/build/helper.min.js.map +++ b/ai/amd/build/helper.min.js.map @@ -1 +1 @@ -{"version":3,"file":"helper.min.js","sources":["../src/helper.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 * The helper module or AI Subsystem.\n *\n * @module core_ai/helper\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default class AIHelper {\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 static 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":["text","textWithBreaks","replace"],"mappings":";;;;;;;;+BA8B6BA,YAKfC,eAHqBD,KAAKE,QAAQ,eAAgB,cAGdA,QAAQ,MAAO,4BAG5CD"} \ No newline at end of file +{"version":3,"file":"helper.min.js","sources":["../src/helper.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 * The helper module or AI Subsystem.\n *\n * @module core_ai/helper\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default class AIHelper {\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 static 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 /**\n * Replace markdown formatting.\n * Even when asked not to, AI models will sometimes return markdown.\n *\n * @param {String} text The text to replace.\n * @returns {String}\n */\n static replaceMarkdown(text) {\n // Replace markdown bold formatting HTML equivalent.\n const textWithMarkdown = text.replace(/\\*\\*(.*?)\\*\\*/g, '$1');\n\n return textWithMarkdown;\n }\n\n /**\n * Format the response provided by the AI model.\n *\n * @param {String} text The text to format.\n * @returns {String}\n */\n static formatResponse(text) {\n let formattedText = this.replaceLineBreaks(text) ;\n formattedText = this.replaceMarkdown(formattedText);\n\n return formattedText;\n }\n}\n"],"names":["text","textWithBreaks","replace","formattedText","this","replaceLineBreaks","replaceMarkdown"],"mappings":";;;;;;;;+BA8B6BA,YAKfC,eAHqBD,KAAKE,QAAQ,eAAgB,cAGdA,QAAQ,MAAO,4BAG5CD,8CAUMD,aAEMA,KAAKE,QAAQ,iBAAkB,6CAWtCF,UACdG,cAAgBC,KAAKC,kBAAkBL,aAC3CG,cAAgBC,KAAKE,gBAAgBH,eAE9BA"} \ No newline at end of file diff --git a/ai/amd/src/helper.js b/ai/amd/src/helper.js index ac27d473e48..64389a62e75 100644 --- a/ai/amd/src/helper.js +++ b/ai/amd/src/helper.js @@ -38,4 +38,31 @@ export default class AIHelper { // Add opening and closing

tags to wrap the entire content return `

${textWithBreaks}

`; } + + /** + * Replace markdown formatting. + * Even when asked not to, AI models will sometimes return markdown. + * + * @param {String} text The text to replace. + * @returns {String} + */ + static replaceMarkdown(text) { + // Replace markdown bold formatting HTML equivalent. + const textWithMarkdown = text.replace(/\*\*(.*?)\*\*/g, '$1'); + + return textWithMarkdown; + } + + /** + * Format the response provided by the AI model. + * + * @param {String} text The text to format. + * @returns {String} + */ + static formatResponse(text) { + let formattedText = this.replaceLineBreaks(text) ; + formattedText = this.replaceMarkdown(formattedText); + + return formattedText; + } } diff --git a/ai/classes/aiactions/explain_text.php b/ai/classes/aiactions/explain_text.php new file mode 100644 index 00000000000..7e6deea48ac --- /dev/null +++ b/ai/classes/aiactions/explain_text.php @@ -0,0 +1,66 @@ +. + +namespace core_ai\aiactions; + +use core_ai\aiactions\responses\response_base; + +/** + * Explain text class. + * + * @package core_ai + * @copyright 2024 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class explain_text extends base { + /** + * Create a new instance of the explain_text action. + * + * It’s responsible for performing any setup tasks, + * such as getting additional data from the database etc. + * + * @param int $contextid The context id the action was created in. + * @param int $userid The user id making the request. + * @param string $prompttext The prompt text used to generate the image. + */ + public function __construct( + int $contextid, + /** @var int The user id requesting the action. */ + protected int $userid, + /** @var string The prompt text used to generate the summary text */ + protected string $prompttext, + ) { + parent::__construct($contextid); + } + + #[\Override] + public function store(response_base $response): int { + global $DB; + + $responsearr = $response->get_response_data(); + + $record = new \stdClass(); + $record->prompt = $this->prompttext; + $record->responseid = $responsearr['id']; // Can be null. + $record->fingerprint = $responsearr['fingerprint']; // Can be null. + $record->generatedcontent = $responsearr['generatedcontent']; // Can be null. + $record->finishreason = $responsearr['finishreason']; // Can be null. + $record->prompttokens = $responsearr['prompttokens']; // Can be null. + $record->completiontoken = $responsearr['completiontokens']; // Can be null. + + return $DB->insert_record($this->get_tablename(), $record); + } +} diff --git a/ai/classes/aiactions/responses/response_explain_text.php b/ai/classes/aiactions/responses/response_explain_text.php new file mode 100644 index 00000000000..8fca94c4a73 --- /dev/null +++ b/ai/classes/aiactions/responses/response_explain_text.php @@ -0,0 +1,90 @@ +. + +namespace core_ai\aiactions\responses; + +/** + * Explain text action response class. + * + * Any method that processes an action must return an instance of this class. + * + * @package core_ai + * @copyright 2024 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class response_explain_text extends response_base { + /** @var string|null A unique identifier for the chat completion, returned by the AI. */ + private ?string $id = null; + + /** @var string|null This fingerprint represents the backend configuration that the model runs with. */ + private ?string $fingerprint = null; + + /** @var string|null The contents of the generated message. */ + private ?string $generatedcontent = null; + + /** @var string|null The reason the model stopped generating tokens. */ + private ?string $finishreason = null; + + /** @var string|null Number of tokens in the prompt. */ + private ?string $prompttokens = null; + + /** @var string|null Number of tokens in the generated completion. */ + private ?string $completiontokens = null; + + /** + * Constructor. + * + * @param bool $success The success status of the action. + * @param int $errorcode Error code. Must exist if success is false. + * @param string $errormessage Error message. Must exist if success is false + */ + public function __construct( + bool $success, + int $errorcode = 0, + string $errormessage = '', + ) { + parent::__construct( + success: $success, + actionname: 'explain_text', + errorcode: $errorcode, + errormessage: $errormessage, + ); + } + + #[\Override] + public function set_response_data(array $response): void { + $this->id = $response['id'] ?? null; + $this->fingerprint = $response['fingerprint'] ?? null; + $this->generatedcontent = $response['generatedcontent'] ?? null; + $this->finishreason = $response['finishreason'] ?? null; + $this->prompttokens = $response['prompttokens'] ?? null; + $this->completiontokens = $response['completiontokens'] ?? null; + $this->model = $response['model'] ?? null; + } + + #[\Override] + public function get_response_data(): array { + return [ + 'id' => $this->id, + 'fingerprint' => $this->fingerprint, + 'generatedcontent' => $this->generatedcontent, + 'finishreason' => $this->finishreason, + 'prompttokens' => $this->prompttokens, + 'completiontokens' => $this->completiontokens, + 'model' => $this->model, + ]; + } +} diff --git a/ai/classes/privacy/provider.php b/ai/classes/privacy/provider.php index dc60ee17f4e..921b70332e1 100644 --- a/ai/classes/privacy/provider.php +++ b/ai/classes/privacy/provider.php @@ -83,6 +83,14 @@ class provider implements 'prompttokens' => 'privacy:metadata:ai_action_summarise_text:prompttokens', 'completiontoken' => 'privacy:metadata:ai_action_summarise_text:completiontoken', ], 'privacy:metadata:ai_action_summarise_text'); + $collection->add_database_table('ai_action_explain_text', [ + 'prompt' => 'privacy:metadata:ai_action_explain_text:prompt', + 'responseid' => 'privacy:metadata:ai_action_explain_text:responseid', + 'fingerprint' => 'privacy:metadata:ai_action_explain_text:fingerprint', + 'generatedcontent' => 'privacy:metadata:ai_action_explain_text:generatedcontent', + 'prompttokens' => 'privacy:metadata:ai_action_explain_text:prompttokens', + 'completiontoken' => 'privacy:metadata:ai_action_explain_text:completiontoken', + ], 'privacy:metadata:ai_action_explain_text'); return $collection; } @@ -137,6 +145,17 @@ class provider implements AND aar.userid = :userid"; $contextlist->add_from_sql($sql, ['userid' => $userid]); + // AI action explain text. + $sql = "SELECT DISTINCT ctx.id + FROM {context} ctx + JOIN {ai_action_register} aar + ON aar.contextid = ctx.id + JOIN {ai_action_explain_text} aaet + ON aaet.id = aar.actionid + WHERE aar.actionname = 'explain_text' + AND aar.userid = :userid"; + $contextlist->add_from_sql($sql, ['userid' => $userid]); + return $contextlist; } @@ -308,6 +327,50 @@ class provider implements writer::with_context($context)->export_related_data($subcontexts, $name, $details); } $textsummarisedetails->close(); + + // AI action explain text. + $sql = "SELECT aar.actionname, aar.success, aar.provider, aar.timecreated, aar.timecompleted, aar.contextid, + aaet.prompt, aaet.responseid, aaet.fingerprint, aaet.generatedcontent, + aaet.prompttokens, aaet.completiontoken, aar.model + FROM {ai_action_register} aar + JOIN {ai_action_explain_text} aaet + ON aar.actionid = aaet.id + JOIN {context} ctx + ON aar.contextid = ctx.id + WHERE aar.actionname = 'explain_text' + AND aar.userid = :userid + AND ctx.id " . $contextsql; + $params = [ + 'userid' => $userid, + ]; + $params += $contextparams; + $textexplaindetails = $DB->get_recordset_sql($sql, $params); + foreach ($textexplaindetails as $textexplaindetail) { + $subcontexts = [ + get_string('ai', 'core_ai'), + get_string('action_explain_text', 'core_ai'), + date('c', $textexplaindetail->timecreated), + ]; + $details = (object) [ + 'actionname' => $textexplaindetail->actionname, + 'contextid' => $textexplaindetail->contextid, + 'prompt' => $textexplaindetail->prompt, + 'responseid' => $textexplaindetail->responseid, + 'fingerprint' => $textexplaindetail->fingerprint, + 'generatedcontent' => $textexplaindetail->generatedcontent, + 'prompttokens' => $textexplaindetail->prompttokens, + 'completiontoken' => $textexplaindetail->completiontoken, + 'model' => $textexplaindetail->model, + 'success' => transform::yesno($textexplaindetail->success), + 'provider' => $textexplaindetail->provider, + 'timecreated' => transform::datetime($textexplaindetail->timecreated), + 'timecompleted' => transform::datetime($textexplaindetail->timecompleted), + ]; + $name = 'action_explain_text'; + $context = \context::instance_by_id($textexplaindetail->contextid); + writer::with_context($context)->export_related_data($subcontexts, $name, $details); + } + $textexplaindetails->close(); } /** @@ -402,6 +465,30 @@ class provider implements WHERE id " . $aastidsql; $DB->execute($sql, $aastidparams); } + + // AI action explain text. + $sql = "SELECT DISTINCT aaet.id + FROM {ai_action_register} aar + JOIN {ai_action_explain_text} aaet + ON aar.actionid = aaet.id + JOIN {context} ctx + ON aar.contextid = ctx.id + WHERE aar.actionname = 'explain_text' + AND ctx.id = :contextid"; + $params = [ + 'contextid' => $context->id, + ]; + $aaetids = $DB->get_records_sql_menu($sql, $params); + if ($aaetids) { + [$aaetidsql, $aaetidparams] = $DB->get_in_or_equal(array_keys($aaetids), SQL_PARAMS_NAMED); + $sql = "UPDATE {ai_action_explain_text} + SET prompt = '', + responseid = '', + fingerprint = '', + generatedcontent = '' + WHERE id " . $aaetidsql; + $DB->execute($sql, $aaetidparams); + } } /** @@ -513,6 +600,33 @@ class provider implements WHERE id " . $aastidsql; $DB->execute($sql, $aastidparams); } + + // AI action explain text. + $sql = "SELECT DISTINCT aaet.id AS textexplainid + FROM {ai_action_register} aar + JOIN {ai_action_explain_text} aaet + ON aar.actionid = aaet.id + JOIN {context} ctx + ON aar.contextid = ctx.id + WHERE aar.actionname = 'explain_text' + AND aar.userid = :userid + AND ctx.id " . $contextsql; + $textexplaindetails = $DB->get_recordset_sql($sql, $params); + $aaetids = []; + foreach ($textexplaindetails as $textexplaindetail) { + $aaetids[] = $textexplaindetail->textexplainid; + } + $textexplaindetails->close(); + if ($aaetids) { + [$aaetidsql, $aaetidparams] = $DB->get_in_or_equal($aaetids, SQL_PARAMS_NAMED); + $sql = "UPDATE {ai_action_explain_text} + SET prompt = '', + responseid = '', + fingerprint = '', + generatedcontent = '' + WHERE id " . $aaetidsql; + $DB->execute($sql, $aaetidparams); + } } /** @@ -563,6 +677,17 @@ class provider implements WHERE aar.actionname = 'summarise_text' AND aar.contextid = :contextid"; $userlist->add_from_sql('userid', $sql, ['contextid' => $context->id]); + + // AI action explain text. + $sql = "SELECT DISTINCT aar.userid + FROM {context} ctx + JOIN {ai_action_register} aar + ON aar.contextid = ctx.id + JOIN {ai_action_explain_text} aaet + ON aaet.id = aar.actionid + WHERE aar.actionname = 'explain_text' + AND aar.contextid = :contextid"; + $userlist->add_from_sql('userid', $sql, ['contextid' => $context->id]); } /** @@ -658,5 +783,27 @@ class provider implements WHERE id " . $aastidsql; $DB->execute($sql, $aastidparams); } + + // AI action explain text. + $sql = "SELECT DISTINCT aaet.id + FROM {ai_action_register} aar + JOIN {ai_action_explain_text} aaet + ON aar.actionid = aaet.id + JOIN {context} ctx + ON aar.contextid = ctx.id + WHERE aar.actionname = 'explain_text' + AND ctx.id = :contextid + AND aar.userid " . $useridssql; + $aaetids = $DB->get_records_sql_menu($sql, $params); + if ($aaetids) { + [$aaetidsql, $aaetidparams] = $DB->get_in_or_equal(array_keys($aaetids), SQL_PARAMS_NAMED); + $sql = "UPDATE {ai_action_explain_text} + SET prompt = '', + responseid = '', + fingerprint = '', + generatedcontent = '' + WHERE id " . $aaetidsql; + $DB->execute($sql, $aaetidparams); + } } } diff --git a/ai/placement/courseassist/amd/build/placement.min.js b/ai/placement/courseassist/amd/build/placement.min.js index 8f9b6056f53..4161580ed17 100644 --- a/ai/placement/courseassist/amd/build/placement.min.js +++ b/ai/placement/courseassist/amd/build/placement.min.js @@ -1,3 +1,3 @@ -define("aiplacement_courseassist/placement",["exports","core/templates","core/ajax","core/copy_to_clipboard","core/notification","aiplacement_courseassist/selectors","core_ai/policy","core_ai/helper","core/drawer_events","core/pubsub","core_message/message_drawer_helper"],(function(_exports,_templates,_ajax,_copy_to_clipboard,_notification,_selectors,_policy,_helper,_drawer_events,_pubsub,MessageDrawerHelper){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_templates=_interopRequireDefault(_templates),_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification),_selectors=_interopRequireDefault(_selectors),_policy=_interopRequireDefault(_policy),_helper=_interopRequireDefault(_helper),_drawer_events=_interopRequireDefault(_drawer_events),MessageDrawerHelper=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(MessageDrawerHelper);var _default=class{constructor(userId,contextId){_defineProperty(this,"userId",void 0),_defineProperty(this,"contextId",void 0),this.userId=userId,this.contextId=contextId,this.aiDrawerElement=document.querySelector(_selectors.default.ELEMENTS.AIDRAWER),this.aiDrawerBodyElement=document.querySelector(_selectors.default.ELEMENTS.AIDRAWER_BODY),this.pageElement=document.querySelector(_selectors.default.ELEMENTS.PAGE),this.registerEventListeners()}registerEventListeners(){document.addEventListener("click",(async e=>{if(e.target.closest(_selectors.default.ACTIONS.SUMMARY)){e.preventDefault(),this.toggleAIDrawer();if(!await this.isPolicyAccepted())return void this.displayPolicy();this.displaySummary()}})),(0,_pubsub.subscribe)(_drawer_events.default.DRAWER_SHOWN,(()=>{this.isAIDrawerOpen()&&this.closeAIDrawer()}))}registerPolicyEventListeners(){const acceptAction=document.querySelector(_selectors.default.ACTIONS.ACCEPT),declineAction=document.querySelector(_selectors.default.ACTIONS.DECLINE);acceptAction&&acceptAction.addEventListener("click",(e=>{e.preventDefault(),this.acceptPolicy().then((()=>this.displaySummary())).catch(_notification.default.exception)})),declineAction&&declineAction.addEventListener("click",(e=>{e.preventDefault(),this.closeAIDrawer()}))}registerErrorEventListeners(){const retryAction=document.querySelector(_selectors.default.ACTIONS.RETRY);retryAction&&retryAction.addEventListener("click",(e=>{e.preventDefault(),this.aiDrawerBodyElement.dataset.hasdata="0",this.displaySummary()}))}registerResponseEventListeners(){const regenerateAction=document.querySelector(_selectors.default.ACTIONS.REGENERATE);regenerateAction&®enerateAction.addEventListener("click",(e=>{e.preventDefault(),this.aiDrawerBodyElement.dataset.hasdata="0",this.displaySummary()}))}registerLoadingEventListeners(){const cancelAction=document.querySelector(_selectors.default.ACTIONS.CANCEL);cancelAction&&cancelAction.addEventListener("click",(e=>{e.preventDefault(),this.setRequestCancelled(),this.toggleAIDrawer()}))}isAIDrawerOpen(){return this.aiDrawerElement.classList.contains("show")}isRequestCancelled(){return"1"===this.aiDrawerBodyElement.dataset.cancelled}setRequestCancelled(){this.aiDrawerBodyElement.dataset.cancelled="1"}openAIDrawer(){MessageDrawerHelper.hide(),this.aiDrawerElement.classList.add("show"),this.aiDrawerBodyElement.setAttribute("aria-live","polite"),this.pageElement.classList.contains("show-drawer-right")||this.addPadding(),this.disableSummaryButton()}closeAIDrawer(){this.aiDrawerElement.classList.remove("show"),this.aiDrawerBodyElement.removeAttribute("aria-live"),this.pageElement.classList.contains("show-drawer-right")&&"1"===this.aiDrawerBodyElement.dataset.removepadding&&this.removePadding(),this.enableSummaryButton()}toggleAIDrawer(){this.isAIDrawerOpen()?this.closeAIDrawer():this.openAIDrawer()}addPadding(){this.pageElement.classList.add("show-drawer-right"),this.aiDrawerBodyElement.dataset.removepadding="1"}removePadding(){this.pageElement.classList.remove("show-drawer-right"),this.aiDrawerBodyElement.dataset.removepadding="0"}disableSummaryButton(){const summaryButton=document.querySelector(_selectors.default.ACTIONS.SUMMARY);summaryButton&&summaryButton.setAttribute("disabled",1)}enableSummaryButton(){const summaryButton=document.querySelector(_selectors.default.ACTIONS.SUMMARY);summaryButton&&(summaryButton.removeAttribute("disabled"),summaryButton.focus())}async isPolicyAccepted(){return await _policy.default.getPolicyStatus(this.userId)}acceptPolicy(){return _policy.default.acceptPolicy()}hasGeneratedContent(){return"1"===this.aiDrawerBodyElement.dataset.hasdata}displayPolicy(){_templates.default.render("core_ai/policyblock",{}).then((html=>{this.aiDrawerBodyElement.innerHTML=html,this.registerPolicyEventListeners()})).catch(_notification.default.exception)}displayLoading(){_templates.default.render("aiplacement_courseassist/loading",{}).then((html=>{this.aiDrawerBodyElement.innerHTML=html,this.registerLoadingEventListeners()})).catch(_notification.default.exception)}async displaySummary(){if(!this.hasGeneratedContent()){this.displayLoading(),this.aiDrawerBodyElement.innerHTML="";const request={methodname:"aiplacement_courseassist_summarise_text",args:{contextid:this.contextId,prompttext:this.getTextContent()}};try{const responseObj=await _ajax.default.call([request])[0];if(responseObj.error)return void this.displayError();if(!this.isRequestCancelled()){const generatedContent=_helper.default.replaceLineBreaks(responseObj.generatedcontent);return void this.displayResponse(generatedContent)}this.aiDrawerBodyElement.dataset.cancelled="0"}catch(error){window.console.log(error),this.displayError()}}}displayResponse(content){_templates.default.render("aiplacement_courseassist/response",{content:content}).then((html=>{this.aiDrawerBodyElement.innerHTML=html,this.aiDrawerBodyElement.dataset.hasdata="1",this.registerResponseEventListeners()})).catch(_notification.default.exception)}displayError(){_templates.default.render("aiplacement_courseassist/error",{}).then((html=>{this.aiDrawerBodyElement.innerHTML=html,this.registerErrorEventListeners()})).catch(_notification.default.exception)}getTextContent(){const mainRegion=document.querySelector(_selectors.default.ELEMENTS.MAIN_REGION);return mainRegion.innerText||mainRegion.textContent}};return _exports.default=_default,_exports.default})); +define("aiplacement_courseassist/placement",["exports","core/templates","core/ajax","core/copy_to_clipboard","core/notification","aiplacement_courseassist/selectors","core_ai/policy","core_ai/helper","core/drawer_events","core/pubsub","core_message/message_drawer_helper","core/str"],(function(_exports,_templates,_ajax,_copy_to_clipboard,_notification,_selectors,_policy,_helper,_drawer_events,_pubsub,MessageDrawerHelper,_str){function _getRequireWildcardCache(nodeInterop){if("function"!=typeof WeakMap)return null;var cacheBabelInterop=new WeakMap,cacheNodeInterop=new WeakMap;return(_getRequireWildcardCache=function(nodeInterop){return nodeInterop?cacheNodeInterop:cacheBabelInterop})(nodeInterop)}function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0,_templates=_interopRequireDefault(_templates),_ajax=_interopRequireDefault(_ajax),_notification=_interopRequireDefault(_notification),_selectors=_interopRequireDefault(_selectors),_policy=_interopRequireDefault(_policy),_helper=_interopRequireDefault(_helper),_drawer_events=_interopRequireDefault(_drawer_events),MessageDrawerHelper=function(obj,nodeInterop){if(!nodeInterop&&obj&&obj.__esModule)return obj;if(null===obj||"object"!=typeof obj&&"function"!=typeof obj)return{default:obj};var cache=_getRequireWildcardCache(nodeInterop);if(cache&&cache.has(obj))return cache.get(obj);var newObj={},hasPropertyDescriptor=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var key in obj)if("default"!==key&&Object.prototype.hasOwnProperty.call(obj,key)){var desc=hasPropertyDescriptor?Object.getOwnPropertyDescriptor(obj,key):null;desc&&(desc.get||desc.set)?Object.defineProperty(newObj,key,desc):newObj[key]=obj[key]}newObj.default=obj,cache&&cache.set(obj,newObj);return newObj}(MessageDrawerHelper);var _default=class{constructor(userId,contextId){_defineProperty(this,"userId",void 0),_defineProperty(this,"contextId",void 0),this.userId=userId,this.contextId=contextId,this.aiDrawerElement=document.querySelector(_selectors.default.ELEMENTS.AIDRAWER),this.aiDrawerBodyElement=document.querySelector(_selectors.default.ELEMENTS.AIDRAWER_BODY),this.pageElement=document.querySelector(_selectors.default.ELEMENTS.PAGE),this.lastAction="",this.responses=new Map,this.registerEventListeners()}registerEventListeners(){document.addEventListener("click",(async e=>{if(e.target.closest(_selectors.default.ACTIONS.SUMMARY)){e.preventDefault(),this.openAIDrawer(),this.lastAction="summarise";if(!await this.isPolicyAccepted())return void this.displayPolicy();this.displayAction(this.lastAction)}if(e.target.closest(_selectors.default.ACTIONS.EXPLAIN)){e.preventDefault(),this.openAIDrawer(),this.lastAction="explain";if(!await this.isPolicyAccepted())return void this.displayPolicy();this.displayAction(this.lastAction)}e.target.closest(_selectors.default.ELEMENTS.AIDRAWER_CLOSE)&&(e.preventDefault(),this.closeAIDrawer())})),(0,_pubsub.subscribe)(_drawer_events.default.DRAWER_SHOWN,(()=>{this.isAIDrawerOpen()&&this.closeAIDrawer()}))}registerPolicyEventListeners(){const acceptAction=document.querySelector(_selectors.default.ACTIONS.ACCEPT),declineAction=document.querySelector(_selectors.default.ACTIONS.DECLINE);acceptAction&&this.lastAction.length&&acceptAction.addEventListener("click",(e=>{e.preventDefault(),this.acceptPolicy().then((()=>this.displayAction(this.lastAction))).catch(_notification.default.exception)})),declineAction&&declineAction.addEventListener("click",(e=>{e.preventDefault(),this.closeAIDrawer()}))}registerErrorEventListeners(){const retryAction=document.querySelector(_selectors.default.ACTIONS.RETRY);retryAction&&this.lastAction.length&&retryAction.addEventListener("click",(e=>{e.preventDefault(),this.displayAction(this.lastAction)}))}registerResponseEventListeners(){document.querySelectorAll(_selectors.default.ACTIONS.REGENERATE).forEach((regenerateAction=>{const responseElement=regenerateAction.closest(_selectors.default.ELEMENTS.RESPONSE);if(regenerateAction&&responseElement){const actionPerformed=responseElement.getAttribute("data-action-performed");regenerateAction.addEventListener("click",(e=>{e.preventDefault(),this.removeResponseFromStack(actionPerformed),this.displayAction(actionPerformed)}))}}))}registerLoadingEventListeners(){const cancelAction=document.querySelector(_selectors.default.ACTIONS.CANCEL);cancelAction&&cancelAction.addEventListener("click",(e=>{e.preventDefault(),this.setRequestCancelled(),this.toggleAIDrawer(),this.removeResponseFromStack("loading");const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses}))}isAIDrawerOpen(){return this.aiDrawerElement.classList.contains("show")}isRequestCancelled(){return"1"===this.aiDrawerBodyElement.dataset.cancelled}setRequestCancelled(){this.aiDrawerBodyElement.dataset.cancelled="1"}openAIDrawer(){MessageDrawerHelper.hide(),this.aiDrawerElement.classList.add("show"),this.aiDrawerBodyElement.setAttribute("aria-live","polite"),this.pageElement.classList.contains("show-drawer-right")||this.addPadding()}closeAIDrawer(){this.aiDrawerElement.classList.remove("show"),this.aiDrawerBodyElement.removeAttribute("aria-live"),this.pageElement.classList.contains("show-drawer-right")&&"1"===this.aiDrawerBodyElement.dataset.removepadding&&this.removePadding()}toggleAIDrawer(){this.isAIDrawerOpen()?this.closeAIDrawer():this.openAIDrawer()}addPadding(){this.pageElement.classList.add("show-drawer-right"),this.aiDrawerBodyElement.dataset.removepadding="1"}removePadding(){this.pageElement.classList.remove("show-drawer-right"),this.aiDrawerBodyElement.dataset.removepadding="0"}async getParamsForAction(action){let params={};switch(action){case"summarise":params.method="aiplacement_courseassist_summarise_text",params.heading=await(0,_str.getString)("aisummary","aiplacement_courseassist");break;case"explain":params.method="aiplacement_courseassist_explain_text",params.heading=await(0,_str.getString)("aiexplain","aiplacement_courseassist")}return params}async isPolicyAccepted(){return await _policy.default.getPolicyStatus(this.userId)}acceptPolicy(){return _policy.default.acceptPolicy()}hasGeneratedContent(action){return this.responses.has(action)}displayPolicy(){_templates.default.render("core_ai/policyblock",{}).then((html=>{this.aiDrawerBodyElement.innerHTML=html,this.registerPolicyEventListeners()})).catch(_notification.default.exception)}displayLoading(){_templates.default.render("aiplacement_courseassist/loading",{}).then((html=>{this.addResponseToStack("loading",html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerLoadingEventListeners()})).then((()=>{this.removeResponseFromStack("loading")})).catch(_notification.default.exception)}async displayAction(action){if(this.hasGeneratedContent(action)){const existingReponse=document.querySelector('[data-action-performed="'+action+'"]');existingReponse&&(this.aiDrawerBodyElement.scrollTop=existingReponse.offsetTop)}else{this.displayLoading(),this.aiDrawerBodyElement.innerHTML="";const request={methodname:(await this.getParamsForAction(action)).method,args:{contextid:this.contextId,prompttext:this.getTextContent()}};try{const responseObj=await _ajax.default.call([request])[0];if(responseObj.error)return void this.displayError();if(!this.isRequestCancelled()){const generatedContent=_helper.default.formatResponse(responseObj.generatedcontent);return void this.displayResponse(generatedContent,action)}this.aiDrawerBodyElement.dataset.cancelled="0"}catch(error){window.console.log(error),this.displayError()}}}addResponseToStack(action,html){this.responses.set(action,html)}removeResponseFromStack(action){this.responses.has(action)&&this.responses.delete(action)}getResponseStack(){let stack="";const responses=[...this.responses.values()].reverse();for(const response of responses)stack+=response;return stack}async displayResponse(content,action){const args={content:content,heading:(await this.getParamsForAction(action)).heading,action:action};_templates.default.render("aiplacement_courseassist/response",args).then((html=>{this.addResponseToStack(action,html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerResponseEventListeners()})).catch(_notification.default.exception)}displayError(){_templates.default.render("aiplacement_courseassist/error",{}).then((html=>{this.addResponseToStack("error",html);const responses=this.getResponseStack();this.aiDrawerBodyElement.innerHTML=responses,this.registerErrorEventListeners()})).then((()=>{this.removeResponseFromStack("error")})).catch(_notification.default.exception)}getTextContent(){const mainRegion=document.querySelector(_selectors.default.ELEMENTS.MAIN_REGION);return mainRegion.innerText||mainRegion.textContent}};return _exports.default=_default,_exports.default})); //# sourceMappingURL=placement.min.js.map \ No newline at end of file diff --git a/ai/placement/courseassist/amd/build/placement.min.js.map b/ai/placement/courseassist/amd/build/placement.min.js.map index b9ee162e85c..cb91c023e1b 100644 --- a/ai/placement/courseassist/amd/build/placement.min.js.map +++ b/ai/placement/courseassist/amd/build/placement.min.js.map @@ -1 +1 @@ -{"version":3,"file":"placement.min.js","sources":["../src/placement.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Module to load and render the tools for the AI assist plugin.\n *\n * @module aiplacement_courseassist/placement\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Templates from 'core/templates';\nimport Ajax from 'core/ajax';\nimport 'core/copy_to_clipboard';\nimport Notification from 'core/notification';\nimport Selectors from 'aiplacement_courseassist/selectors';\nimport Policy from 'core_ai/policy';\nimport AIHelper from 'core_ai/helper';\nimport DrawerEvents from 'core/drawer_events';\nimport {subscribe} from 'core/pubsub';\nimport * as MessageDrawerHelper from 'core_message/message_drawer_helper';\n\nconst AICourseAssist = class {\n\n /**\n * The user ID.\n * @type {Integer}\n */\n userId;\n /**\n * The context ID.\n * @type {Integer}\n */\n contextId;\n\n /**\n * Constructor.\n * @param {Integer} userId The user ID.\n * @param {Integer} contextId The context ID.\n */\n constructor(userId, contextId) {\n this.userId = userId;\n this.contextId = contextId;\n\n this.aiDrawerElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER);\n this.aiDrawerBodyElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER_BODY);\n this.pageElement = document.querySelector(Selectors.ELEMENTS.PAGE);\n\n this.registerEventListeners();\n }\n\n /**\n * Register event listeners.\n */\n registerEventListeners() {\n document.addEventListener('click', async(e) => {\n const summariseAction = e.target.closest(Selectors.ACTIONS.SUMMARY);\n if (summariseAction) {\n e.preventDefault();\n this.toggleAIDrawer();\n const isPolicyAccepted = await this.isPolicyAccepted();\n if (!isPolicyAccepted) {\n // Display policy.\n this.displayPolicy();\n return;\n }\n // Display summary.\n this.displaySummary();\n }\n });\n\n // Close AI drawer if message drawer is shown.\n subscribe(DrawerEvents.DRAWER_SHOWN, () => {\n if (this.isAIDrawerOpen()) {\n this.closeAIDrawer();\n }\n });\n }\n\n /**\n * Register event listeners for the policy.\n */\n registerPolicyEventListeners() {\n const acceptAction = document.querySelector(Selectors.ACTIONS.ACCEPT);\n const declineAction = document.querySelector(Selectors.ACTIONS.DECLINE);\n if (acceptAction) {\n acceptAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.acceptPolicy().then(() => {\n return this.displaySummary();\n }).catch(Notification.exception);\n });\n }\n if (declineAction) {\n declineAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.closeAIDrawer();\n });\n }\n }\n\n /**\n * Register event listeners for the error.\n */\n registerErrorEventListeners() {\n const retryAction = document.querySelector(Selectors.ACTIONS.RETRY);\n if (retryAction) {\n retryAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.aiDrawerBodyElement.dataset.hasdata = '0';\n this.displaySummary();\n });\n }\n }\n\n /**\n * Register event listeners for the response.\n */\n registerResponseEventListeners() {\n const regenerateAction = document.querySelector(Selectors.ACTIONS.REGENERATE);\n if (regenerateAction) {\n regenerateAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.aiDrawerBodyElement.dataset.hasdata = '0';\n this.displaySummary();\n });\n }\n }\n\n registerLoadingEventListeners() {\n const cancelAction = document.querySelector(Selectors.ACTIONS.CANCEL);\n if (cancelAction) {\n cancelAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.setRequestCancelled();\n this.toggleAIDrawer();\n });\n }\n }\n\n /**\n * Check if the AI drawer is open.\n * @return {boolean} True if the AI drawer is open, false otherwise.\n */\n isAIDrawerOpen() {\n return this.aiDrawerElement.classList.contains('show');\n }\n\n /**\n * Check if the request is cancelled.\n * @return {boolean} True if the request is cancelled, false otherwise.\n */\n isRequestCancelled() {\n return this.aiDrawerBodyElement.dataset.cancelled === '1';\n }\n\n setRequestCancelled() {\n this.aiDrawerBodyElement.dataset.cancelled = '1';\n }\n\n /**\n * Open the AI drawer.\n */\n openAIDrawer() {\n // Close message drawer if it is shown.\n MessageDrawerHelper.hide();\n this.aiDrawerElement.classList.add('show');\n this.aiDrawerBodyElement.setAttribute('aria-live', 'polite');\n if (!this.pageElement.classList.contains('show-drawer-right')) {\n this.addPadding();\n }\n // Disable the summary button.\n this.disableSummaryButton();\n }\n\n /**\n * Close the AI drawer.\n */\n closeAIDrawer() {\n this.aiDrawerElement.classList.remove('show');\n this.aiDrawerBodyElement.removeAttribute('aria-live');\n if (this.pageElement.classList.contains('show-drawer-right') && this.aiDrawerBodyElement.dataset.removepadding === '1') {\n this.removePadding();\n }\n // Enable the summary button.\n this.enableSummaryButton();\n }\n\n /**\n * Toggle the AI drawer.\n */\n toggleAIDrawer() {\n if (this.isAIDrawerOpen()) {\n this.closeAIDrawer();\n } else {\n this.openAIDrawer();\n }\n }\n\n /**\n * Add padding to the page to make space for the AI drawer.\n */\n addPadding() {\n this.pageElement.classList.add('show-drawer-right');\n this.aiDrawerBodyElement.dataset.removepadding = '1';\n }\n\n /**\n * Remove padding from the page.\n */\n removePadding() {\n this.pageElement.classList.remove('show-drawer-right');\n this.aiDrawerBodyElement.dataset.removepadding = '0';\n }\n\n /**\n * Disable the summary button.\n */\n disableSummaryButton() {\n const summaryButton = document.querySelector(Selectors.ACTIONS.SUMMARY);\n if (summaryButton) {\n summaryButton.setAttribute('disabled', 1);\n }\n }\n\n /**\n * Enable the summary button and focus on it.\n */\n enableSummaryButton() {\n const summaryButton = document.querySelector(Selectors.ACTIONS.SUMMARY);\n if (summaryButton) {\n summaryButton.removeAttribute('disabled');\n summaryButton.focus();\n }\n }\n\n /**\n * Check if the policy is accepted.\n * @return {bool} True if the policy is accepted, false otherwise.\n */\n async isPolicyAccepted() {\n return await Policy.getPolicyStatus(this.userId);\n }\n\n /**\n * Accept the policy.\n * @return {Promise}\n */\n acceptPolicy() {\n return Policy.acceptPolicy();\n }\n\n /**\n * Check if the AI drawer has generated content or not.\n * @return {boolean} True if the AI drawer has generated content, false otherwise.\n */\n hasGeneratedContent() {\n return this.aiDrawerBodyElement.dataset.hasdata === '1';\n }\n\n /**\n * Display the policy.\n */\n displayPolicy() {\n Templates.render('core_ai/policyblock', {}).then((html) => {\n this.aiDrawerBodyElement.innerHTML = html;\n this.registerPolicyEventListeners();\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the loading spinner.\n */\n displayLoading() {\n Templates.render('aiplacement_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 this.displayError();\n return;\n } else {\n if (!this.isRequestCancelled()) {\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 this.displayError();\n }\n }\n }\n\n /**\n * Display the response.\n * @param {String} content The content to display.\n */\n displayResponse(content) {\n Templates.render('aiplacement_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","DrawerEvents","DRAWER_SHOWN","isAIDrawerOpen","closeAIDrawer","registerPolicyEventListeners","acceptAction","ACCEPT","declineAction","DECLINE","acceptPolicy","then","catch","Notification","exception","registerErrorEventListeners","retryAction","RETRY","dataset","hasdata","registerResponseEventListeners","regenerateAction","REGENERATE","registerLoadingEventListeners","cancelAction","CANCEL","setRequestCancelled","classList","contains","isRequestCancelled","cancelled","openAIDrawer","MessageDrawerHelper","hide","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","displayError","generatedContent","AIHelper","replaceLineBreaks","generatedcontent","displayResponse","window","console","log","content","mainRegion","MAIN_REGION","innerText","textContent"],"mappings":"m/DAkCuB,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,2CAKHC,uBAAaC,cAAc,KAC7BL,KAAKM,uBACAC,mBAQjBC,qCACUC,aAAe5B,SAASC,cAAcC,mBAAUa,QAAQc,QACxDC,cAAgB9B,SAASC,cAAcC,mBAAUa,QAAQgB,SAC3DH,cACAA,aAAalB,iBAAiB,SAAUE,IACpCA,EAAEK,sBACGe,eAAeC,MAAK,IACdd,KAAKG,mBACbY,MAAMC,sBAAaC,cAG1BN,eACAA,cAAcpB,iBAAiB,SAAUE,IACrCA,EAAEK,sBACGS,mBAQjBW,oCACUC,YAActC,SAASC,cAAcC,mBAAUa,QAAQwB,OACzDD,aACAA,YAAY5B,iBAAiB,SAAUE,IACnCA,EAAEK,sBACGZ,oBAAoBmC,QAAQC,QAAU,SACtCnB,oBAQjBoB,uCACUC,iBAAmB3C,SAASC,cAAcC,mBAAUa,QAAQ6B,YAC9DD,kBACAA,iBAAiBjC,iBAAiB,SAAUE,IACxCA,EAAEK,sBACGZ,oBAAoBmC,QAAQC,QAAU,SACtCnB,oBAKjBuB,sCACUC,aAAe9C,SAASC,cAAcC,mBAAUa,QAAQgC,QAC1DD,cACAA,aAAapC,iBAAiB,SAAUE,IACpCA,EAAEK,sBACG+B,2BACA9B,oBASjBO,wBACWN,KAAKpB,gBAAgBkD,UAAUC,SAAS,QAOnDC,2BAC0D,MAA/ChC,KAAKd,oBAAoBmC,QAAQY,UAG5CJ,2BACS3C,oBAAoBmC,QAAQY,UAAY,IAMjDC,eAEIC,oBAAoBC,YACfxD,gBAAgBkD,UAAUO,IAAI,aAC9BnD,oBAAoBoD,aAAa,YAAa,UAC9CtC,KAAKZ,YAAY0C,UAAUC,SAAS,2BAChCQ,kBAGJC,uBAMTjC,qBACS3B,gBAAgBkD,UAAUW,OAAO,aACjCvD,oBAAoBwD,gBAAgB,aACrC1C,KAAKZ,YAAY0C,UAAUC,SAAS,sBAA2E,MAAnD/B,KAAKd,oBAAoBmC,QAAQsB,oBACxFC,qBAGJC,sBAMT9C,iBACQC,KAAKM,sBACAC,qBAEA2B,eAObK,kBACSnD,YAAY0C,UAAUO,IAAI,0BAC1BnD,oBAAoBmC,QAAQsB,cAAgB,IAMrDC,qBACSxD,YAAY0C,UAAUW,OAAO,0BAC7BvD,oBAAoBmC,QAAQsB,cAAgB,IAMrDH,6BACUM,cAAgBjE,SAASC,cAAcC,mBAAUa,QAAQC,SAC3DiD,eACAA,cAAcR,aAAa,WAAY,GAO/CO,4BACUC,cAAgBjE,SAASC,cAAcC,mBAAUa,QAAQC,SAC3DiD,gBACAA,cAAcJ,gBAAgB,YAC9BI,cAAcC,+CASLC,gBAAOC,gBAAgBjD,KAAKtB,QAO7CmC,sBACWmC,gBAAOnC,eAOlBqC,4BACwD,MAA7ClD,KAAKd,oBAAoBmC,QAAQC,QAM5CpB,mCACciD,OAAO,sBAAuB,IAAIrC,MAAMsC,YACzClE,oBAAoBmE,UAAYD,UAChC5C,kCAENO,MAAMC,sBAAaC,WAM1BqC,oCACcH,OAAO,mCAAoC,IAAIrC,MAAMsC,YACtDlE,oBAAoBmE,UAAYD,UAChC1B,mCAENX,MAAMC,sBAAaC,sCAOjBjB,KAAKkD,sBAAuB,MAExBI,sBAEApE,oBAAoBmE,UAAY,SAC/BE,QAAU,CACZC,WAAY,0CACZC,KAAM,CACFC,UAAW1D,KAAKrB,UAChBgF,WAAY3D,KAAK4D,6BAIfC,kBAAoBC,cAAKC,KAAK,CAACR,UAAU,MAC3CM,YAAYG,uBACPC,mBAGAjE,KAAKgC,qBAAsB,OAEtBkC,iBAAmBC,gBAASC,kBAAkBP,YAAYQ,mCAC3DC,gBAAgBJ,uBAGhBhF,oBAAoBmC,QAAQY,UAAY,IAGvD,MAAO+B,OACLO,OAAOC,QAAQC,IAAIT,YACdC,iBASjBK,gBAAgBI,4BACFvB,OAAO,oCAAqC,CAACuB,QAASA,UAAU5D,MAAMsC,YACvElE,oBAAoBmE,UAAYD,UAChClE,oBAAoBmC,QAAQC,QAAU,SACtCC,oCAENR,MAAMC,sBAAaC,WAM1BgD,kCACcd,OAAO,iCAAkC,IAAIrC,MAAMsC,YACpDlE,oBAAoBmE,UAAYD,UAChClC,iCAENH,MAAMC,sBAAaC,WAO1B2C,uBACUe,WAAa9F,SAASC,cAAcC,mBAAUC,SAAS4F,oBACtDD,WAAWE,WAAaF,WAAWG"} \ No newline at end of file +{"version":3,"file":"placement.min.js","sources":["../src/placement.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Module to load and render the tools for the AI assist plugin.\n *\n * @module aiplacement_courseassist/placement\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Templates from 'core/templates';\nimport Ajax from 'core/ajax';\nimport 'core/copy_to_clipboard';\nimport Notification from 'core/notification';\nimport Selectors from 'aiplacement_courseassist/selectors';\nimport Policy from 'core_ai/policy';\nimport AIHelper from 'core_ai/helper';\nimport DrawerEvents from 'core/drawer_events';\nimport {subscribe} from 'core/pubsub';\nimport * as MessageDrawerHelper from 'core_message/message_drawer_helper';\nimport {getString} from 'core/str';\n\nconst AICourseAssist = class {\n\n /**\n * The user ID.\n * @type {Integer}\n */\n userId;\n /**\n * The context ID.\n * @type {Integer}\n */\n contextId;\n\n /**\n * Constructor.\n * @param {Integer} userId The user ID.\n * @param {Integer} contextId The context ID.\n */\n constructor(userId, contextId) {\n this.userId = userId;\n this.contextId = contextId;\n\n this.aiDrawerElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER);\n this.aiDrawerBodyElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER_BODY);\n this.pageElement = document.querySelector(Selectors.ELEMENTS.PAGE);\n this.lastAction = '';\n this.responses = new Map();\n\n this.registerEventListeners();\n }\n\n /**\n * Register event listeners.\n */\n registerEventListeners() {\n document.addEventListener('click', async(e) => {\n // Display summarise.\n const summariseAction = e.target.closest(Selectors.ACTIONS.SUMMARY);\n if (summariseAction) {\n e.preventDefault();\n this.openAIDrawer();\n this.lastAction = 'summarise';\n const isPolicyAccepted = await this.isPolicyAccepted();\n if (!isPolicyAccepted) {\n // Display policy.\n this.displayPolicy();\n return;\n }\n this.displayAction(this.lastAction);\n }\n // Display explain.\n const explainAction = e.target.closest(Selectors.ACTIONS.EXPLAIN);\n if (explainAction) {\n e.preventDefault();\n this.openAIDrawer();\n this.lastAction = 'explain';\n const isPolicyAccepted = await this.isPolicyAccepted();\n if (!isPolicyAccepted) {\n // Display policy.\n this.displayPolicy();\n return;\n }\n this.displayAction(this.lastAction);\n }\n // Close AI drawer.\n const closeAiDrawer = e.target.closest(Selectors.ELEMENTS.AIDRAWER_CLOSE);\n if (closeAiDrawer) {\n e.preventDefault();\n this.closeAIDrawer();\n }\n });\n\n // Close AI drawer if message drawer is shown.\n subscribe(DrawerEvents.DRAWER_SHOWN, () => {\n if (this.isAIDrawerOpen()) {\n this.closeAIDrawer();\n }\n });\n }\n\n /**\n * Register event listeners for the policy.\n */\n registerPolicyEventListeners() {\n const acceptAction = document.querySelector(Selectors.ACTIONS.ACCEPT);\n const declineAction = document.querySelector(Selectors.ACTIONS.DECLINE);\n if (acceptAction && this.lastAction.length) {\n acceptAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.acceptPolicy().then(() => {\n return this.displayAction(this.lastAction);\n }).catch(Notification.exception);\n });\n }\n if (declineAction) {\n declineAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.closeAIDrawer();\n });\n }\n }\n\n /**\n * Register event listeners for the error.\n */\n registerErrorEventListeners() {\n const retryAction = document.querySelector(Selectors.ACTIONS.RETRY);\n if (retryAction && this.lastAction.length) {\n retryAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.displayAction(this.lastAction);\n });\n }\n }\n\n /**\n * Register event listeners for the responses.\n */\n registerResponseEventListeners() {\n // Get all regenerate action buttons (one per response in the AI drawer).\n const regenerateActions = document.querySelectorAll(Selectors.ACTIONS.REGENERATE);\n // Add event listeners for each regenerate action.\n regenerateActions.forEach(regenerateAction => {\n const responseElement = regenerateAction.closest(Selectors.ELEMENTS.RESPONSE);\n if (regenerateAction && responseElement) {\n // Get the action that this response is associated with.\n const actionPerformed = responseElement.getAttribute('data-action-performed');\n regenerateAction.addEventListener('click', (e) => {\n e.preventDefault();\n // Remove the old response before displaying the new one.\n this.removeResponseFromStack(actionPerformed);\n this.displayAction(actionPerformed);\n });\n }\n });\n }\n\n registerLoadingEventListeners() {\n const cancelAction = document.querySelector(Selectors.ACTIONS.CANCEL);\n if (cancelAction) {\n cancelAction.addEventListener('click', (e) => {\n e.preventDefault();\n this.setRequestCancelled();\n this.toggleAIDrawer();\n this.removeResponseFromStack('loading');\n // Refresh the response stack to avoid false indication of loading.\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n });\n }\n }\n\n /**\n * Check if the AI drawer is open.\n * @return {boolean} True if the AI drawer is open, false otherwise.\n */\n isAIDrawerOpen() {\n return this.aiDrawerElement.classList.contains('show');\n }\n\n /**\n * Check if the request is cancelled.\n * @return {boolean} True if the request is cancelled, false otherwise.\n */\n isRequestCancelled() {\n return this.aiDrawerBodyElement.dataset.cancelled === '1';\n }\n\n setRequestCancelled() {\n this.aiDrawerBodyElement.dataset.cancelled = '1';\n }\n\n /**\n * Open the AI drawer.\n */\n openAIDrawer() {\n // Close message drawer if it is shown.\n MessageDrawerHelper.hide();\n this.aiDrawerElement.classList.add('show');\n this.aiDrawerBodyElement.setAttribute('aria-live', 'polite');\n if (!this.pageElement.classList.contains('show-drawer-right')) {\n this.addPadding();\n }\n }\n\n /**\n * Close the AI drawer.\n */\n closeAIDrawer() {\n this.aiDrawerElement.classList.remove('show');\n this.aiDrawerBodyElement.removeAttribute('aria-live');\n if (this.pageElement.classList.contains('show-drawer-right') && this.aiDrawerBodyElement.dataset.removepadding === '1') {\n this.removePadding();\n }\n }\n\n /**\n * Toggle the AI drawer.\n */\n toggleAIDrawer() {\n if (this.isAIDrawerOpen()) {\n this.closeAIDrawer();\n } else {\n this.openAIDrawer();\n }\n }\n\n /**\n * Add padding to the page to make space for the AI drawer.\n */\n addPadding() {\n this.pageElement.classList.add('show-drawer-right');\n this.aiDrawerBodyElement.dataset.removepadding = '1';\n }\n\n /**\n * Remove padding from the page.\n */\n removePadding() {\n this.pageElement.classList.remove('show-drawer-right');\n this.aiDrawerBodyElement.dataset.removepadding = '0';\n }\n\n /**\n * Get important params related to the action.\n * @param {string} action The action to use.\n * @returns {object} The params to use for the action.\n */\n async getParamsForAction(action) {\n let params = {};\n\n switch (action) {\n case 'summarise':\n params.method = 'aiplacement_courseassist_summarise_text';\n params.heading = await getString('aisummary', 'aiplacement_courseassist');\n break;\n\n case 'explain':\n params.method = 'aiplacement_courseassist_explain_text';\n params.heading = await getString('aiexplain', 'aiplacement_courseassist');\n break;\n }\n\n return params;\n }\n\n /**\n * Check if the policy is accepted.\n * @return {bool} True if the policy is accepted, false otherwise.\n */\n async isPolicyAccepted() {\n return await Policy.getPolicyStatus(this.userId);\n }\n\n /**\n * Accept the policy.\n * @return {Promise}\n */\n acceptPolicy() {\n return Policy.acceptPolicy();\n }\n\n /**\n * Check if the AI drawer has already generated content for a particular action.\n * @param {string} action The action to check.\n * @return {boolean} True if the AI drawer has generated content, false otherwise.\n */\n hasGeneratedContent(action) {\n return this.responses.has(action);\n }\n\n /**\n * Display the policy.\n */\n displayPolicy() {\n Templates.render('core_ai/policyblock', {}).then((html) => {\n this.aiDrawerBodyElement.innerHTML = html;\n this.registerPolicyEventListeners();\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the loading spinner.\n */\n displayLoading() {\n Templates.render('aiplacement_courseassist/loading', {}).then((html) => {\n this.addResponseToStack('loading', html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerLoadingEventListeners();\n return;\n }).then(() => {\n this.removeResponseFromStack('loading');\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the action result in the AI drawer.\n * @param {string} action The action to display.\n */\n async displayAction(action) {\n if (this.hasGeneratedContent(action)) {\n // Scroll to generated content.\n const existingReponse = document.querySelector('[data-action-performed=\"' + action + '\"]');\n if (existingReponse) {\n this.aiDrawerBodyElement.scrollTop = existingReponse.offsetTop;\n }\n } else {\n // Display loading spinner.\n this.displayLoading();\n // Clear the drawer to prevent including the previously generated response in the new response prompt.\n this.aiDrawerBodyElement.innerHTML = '';\n const params = await this.getParamsForAction(action);\n const request = {\n methodname: params.method,\n args: {\n contextid: this.contextId,\n prompttext: this.getTextContent(),\n }\n };\n try {\n const responseObj = await Ajax.call([request])[0];\n if (responseObj.error) {\n this.displayError();\n return;\n } else {\n if (!this.isRequestCancelled()) {\n // Perform replacements on the generated context to ensure it is formatted correctly.\n const generatedContent = AIHelper.formatResponse(responseObj.generatedcontent);\n this.displayResponse(generatedContent, action);\n return;\n } else {\n this.aiDrawerBodyElement.dataset.cancelled = '0';\n }\n }\n } catch (error) {\n window.console.log(error);\n this.displayError();\n }\n }\n }\n\n /**\n * Add the HTML response to the response stack.\n * The stack will be used to display all responses in the AI drawer.\n * @param {String} action The action key.\n * @param {String} html The HTML to store.\n */\n addResponseToStack(action, html) {\n this.responses.set(action, html);\n }\n\n /**\n * Remove a stored response, allowing for a regenerated one.\n * @param {String} action The action key.\n */\n removeResponseFromStack(action) {\n if (this.responses.has(action)) {\n this.responses.delete(action);\n }\n }\n\n /**\n * Return a stack of HTML responses.\n * @return {String} HTML responses.\n */\n getResponseStack() {\n let stack = '';\n // Reverse to get newest first.\n const responses = [...this.responses.values()].reverse();\n for (const response of responses) {\n stack += response;\n }\n return stack;\n }\n\n /**\n * Display the responses.\n * @param {String} content The content to display.\n * @param {String} action The action used.\n */\n async displayResponse(content, action) {\n const params = await this.getParamsForAction(action);\n const args = {\n content: content,\n heading: params.heading,\n action: action,\n };\n Templates.render('aiplacement_courseassist/response', args).then((html) => {\n this.addResponseToStack(action, html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerResponseEventListeners();\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Display the error.\n */\n displayError() {\n Templates.render('aiplacement_courseassist/error', {}).then((html) => {\n this.addResponseToStack('error', html);\n const responses = this.getResponseStack();\n this.aiDrawerBodyElement.innerHTML = responses;\n this.registerErrorEventListeners();\n return;\n }).then(() => {\n this.removeResponseFromStack('error');\n return;\n }).catch(Notification.exception);\n }\n\n /**\n * Get the text content of the main region.\n * @return {String} The text content.\n */\n getTextContent() {\n const mainRegion = document.querySelector(Selectors.ELEMENTS.MAIN_REGION);\n return mainRegion.innerText || mainRegion.textContent;\n }\n};\n\nexport default AICourseAssist;\n"],"names":["constructor","userId","contextId","aiDrawerElement","document","querySelector","Selectors","ELEMENTS","AIDRAWER","aiDrawerBodyElement","AIDRAWER_BODY","pageElement","PAGE","lastAction","responses","Map","registerEventListeners","addEventListener","async","e","target","closest","ACTIONS","SUMMARY","preventDefault","openAIDrawer","this","isPolicyAccepted","displayPolicy","displayAction","EXPLAIN","AIDRAWER_CLOSE","closeAIDrawer","DrawerEvents","DRAWER_SHOWN","isAIDrawerOpen","registerPolicyEventListeners","acceptAction","ACCEPT","declineAction","DECLINE","length","acceptPolicy","then","catch","Notification","exception","registerErrorEventListeners","retryAction","RETRY","registerResponseEventListeners","querySelectorAll","REGENERATE","forEach","regenerateAction","responseElement","RESPONSE","actionPerformed","getAttribute","removeResponseFromStack","registerLoadingEventListeners","cancelAction","CANCEL","setRequestCancelled","toggleAIDrawer","getResponseStack","innerHTML","classList","contains","isRequestCancelled","dataset","cancelled","MessageDrawerHelper","hide","add","setAttribute","addPadding","remove","removeAttribute","removepadding","removePadding","action","params","method","heading","Policy","getPolicyStatus","hasGeneratedContent","has","render","html","displayLoading","addResponseToStack","existingReponse","scrollTop","offsetTop","request","methodname","getParamsForAction","args","contextid","prompttext","getTextContent","responseObj","Ajax","call","error","displayError","generatedContent","AIHelper","formatResponse","generatedcontent","displayResponse","window","console","log","set","delete","stack","values","reverse","response","content","mainRegion","MAIN_REGION","innerText","textContent"],"mappings":"mgEAmCuB,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,WACxDC,WAAa,QACbC,UAAY,IAAIC,SAEhBC,yBAMTA,yBACIZ,SAASa,iBAAiB,SAASC,MAAAA,OAEPC,EAAEC,OAAOC,QAAQf,mBAAUgB,QAAQC,SACtC,CACjBJ,EAAEK,sBACGC,oBACAZ,WAAa,sBACaa,KAAKC,oCAG3BC,qBAGJC,cAAcH,KAAKb,eAGNM,EAAEC,OAAOC,QAAQf,mBAAUgB,QAAQQ,SACtC,CACfX,EAAEK,sBACGC,oBACAZ,WAAa,oBACaa,KAAKC,oCAG3BC,qBAGJC,cAAcH,KAAKb,YAGNM,EAAEC,OAAOC,QAAQf,mBAAUC,SAASwB,kBAEtDZ,EAAEK,sBACGQ,0CAKHC,uBAAaC,cAAc,KAC7BR,KAAKS,uBACAH,mBAQjBI,qCACUC,aAAejC,SAASC,cAAcC,mBAAUgB,QAAQgB,QACxDC,cAAgBnC,SAASC,cAAcC,mBAAUgB,QAAQkB,SAC3DH,cAAgBX,KAAKb,WAAW4B,QAChCJ,aAAapB,iBAAiB,SAAUE,IACpCA,EAAEK,sBACGkB,eAAeC,MAAK,IACdjB,KAAKG,cAAcH,KAAKb,cAChC+B,MAAMC,sBAAaC,cAG1BP,eACAA,cAActB,iBAAiB,SAAUE,IACrCA,EAAEK,sBACGQ,mBAQjBe,oCACUC,YAAc5C,SAASC,cAAcC,mBAAUgB,QAAQ2B,OACzDD,aAAetB,KAAKb,WAAW4B,QAC/BO,YAAY/B,iBAAiB,SAAUE,IACnCA,EAAEK,sBACGK,cAAcH,KAAKb,eAQpCqC,iCAE8B9C,SAAS+C,iBAAiB7C,mBAAUgB,QAAQ8B,YAEpDC,SAAQC,yBAChBC,gBAAkBD,iBAAiBjC,QAAQf,mBAAUC,SAASiD,aAChEF,kBAAoBC,gBAAiB,OAE/BE,gBAAkBF,gBAAgBG,aAAa,yBACrDJ,iBAAiBrC,iBAAiB,SAAUE,IACxCA,EAAEK,sBAEGmC,wBAAwBF,sBACxB5B,cAAc4B,wBAMnCG,sCACUC,aAAezD,SAASC,cAAcC,mBAAUgB,QAAQwC,QAC1DD,cACAA,aAAa5C,iBAAiB,SAAUE,IACpCA,EAAEK,sBACGuC,2BACAC,sBACAL,wBAAwB,iBAEvB7C,UAAYY,KAAKuC,wBAClBxD,oBAAoByD,UAAYpD,aASjDqB,wBACWT,KAAKvB,gBAAgBgE,UAAUC,SAAS,QAOnDC,2BAC0D,MAA/C3C,KAAKjB,oBAAoB6D,QAAQC,UAG5CR,2BACStD,oBAAoB6D,QAAQC,UAAY,IAMjD9C,eAEI+C,oBAAoBC,YACftE,gBAAgBgE,UAAUO,IAAI,aAC9BjE,oBAAoBkE,aAAa,YAAa,UAC9CjD,KAAKf,YAAYwD,UAAUC,SAAS,2BAChCQ,aAOb5C,qBACS7B,gBAAgBgE,UAAUU,OAAO,aACjCpE,oBAAoBqE,gBAAgB,aACrCpD,KAAKf,YAAYwD,UAAUC,SAAS,sBAA2E,MAAnD1C,KAAKjB,oBAAoB6D,QAAQS,oBACxFC,gBAObhB,iBACQtC,KAAKS,sBACAH,qBAEAP,eAObmD,kBACSjE,YAAYwD,UAAUO,IAAI,0BAC1BjE,oBAAoB6D,QAAQS,cAAgB,IAMrDC,qBACSrE,YAAYwD,UAAUU,OAAO,0BAC7BpE,oBAAoB6D,QAAQS,cAAgB,6BAQ5BE,YACjBC,OAAS,UAELD,YACC,YACDC,OAAOC,OAAS,0CAChBD,OAAOE,cAAgB,kBAAU,YAAa,sCAG7C,UACDF,OAAOC,OAAS,wCAChBD,OAAOE,cAAgB,kBAAU,YAAa,mCAI/CF,6CAQMG,gBAAOC,gBAAgB5D,KAAKzB,QAO7CyC,sBACW2C,gBAAO3C,eAQlB6C,oBAAoBN,eACTvD,KAAKZ,UAAU0E,IAAIP,QAM9BrD,mCACc6D,OAAO,sBAAuB,IAAI9C,MAAM+C,YACzCjF,oBAAoByD,UAAYwB,UAChCtD,kCAENQ,MAAMC,sBAAaC,WAM1B6C,oCACcF,OAAO,mCAAoC,IAAI9C,MAAM+C,YACtDE,mBAAmB,UAAWF,YAC7B5E,UAAYY,KAAKuC,wBAClBxD,oBAAoByD,UAAYpD,eAChC8C,mCAENjB,MAAK,UACCgB,wBAAwB,cAE9Bf,MAAMC,sBAAaC,+BAONmC,WACZvD,KAAK6D,oBAAoBN,QAAS,OAE5BY,gBAAkBzF,SAASC,cAAc,2BAA6B4E,OAAS,MACjFY,uBACKpF,oBAAoBqF,UAAYD,gBAAgBE,eAEtD,MAEEJ,sBAEAlF,oBAAoByD,UAAY,SAE/B8B,QAAU,CACZC,kBAFiBvE,KAAKwE,mBAAmBjB,SAEtBE,OACnBgB,KAAM,CACFC,UAAW1E,KAAKxB,UAChBmG,WAAY3E,KAAK4E,6BAIfC,kBAAoBC,cAAKC,KAAK,CAACT,UAAU,MAC3CO,YAAYG,uBACPC,mBAGAjF,KAAK2C,qBAAsB,OAEtBuC,iBAAmBC,gBAASC,eAAeP,YAAYQ,mCACxDC,gBAAgBJ,iBAAkB3B,aAGlCxE,oBAAoB6D,QAAQC,UAAY,IAGvD,MAAOmC,OACLO,OAAOC,QAAQC,IAAIT,YACdC,iBAWjBf,mBAAmBX,OAAQS,WAClB5E,UAAUsG,IAAInC,OAAQS,MAO/B/B,wBAAwBsB,QAChBvD,KAAKZ,UAAU0E,IAAIP,cACdnE,UAAUuG,OAAOpC,QAQ9BhB,uBACQqD,MAAQ,SAENxG,UAAY,IAAIY,KAAKZ,UAAUyG,UAAUC,cAC1C,MAAMC,YAAY3G,UACnBwG,OAASG,gBAENH,4BAQWI,QAASzC,cAErBkB,KAAO,CACTuB,QAASA,QACTtC,eAHiB1D,KAAKwE,mBAAmBjB,SAGzBG,QAChBH,OAAQA,2BAEFQ,OAAO,oCAAqCU,MAAMxD,MAAM+C,YACzDE,mBAAmBX,OAAQS,YAC1B5E,UAAYY,KAAKuC,wBAClBxD,oBAAoByD,UAAYpD,eAChCoC,oCAENN,MAAMC,sBAAaC,WAM1B6D,kCACclB,OAAO,iCAAkC,IAAI9C,MAAM+C,YACpDE,mBAAmB,QAASF,YAC3B5E,UAAYY,KAAKuC,wBAClBxD,oBAAoByD,UAAYpD,eAChCiC,iCAENJ,MAAK,UACCgB,wBAAwB,YAE9Bf,MAAMC,sBAAaC,WAO1BwD,uBACUqB,WAAavH,SAASC,cAAcC,mBAAUC,SAASqH,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 index 142769d1980..59e46034b9f 100644 --- a/ai/placement/courseassist/amd/build/selectors.min.js +++ b/ai/placement/courseassist/amd/build/selectors.min.js @@ -1,3 +1,3 @@ -define("aiplacement_courseassist/selectors",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;return _exports.default={ELEMENTS:{AIDRAWER:"#ai-drawer",AIDRAWER_BODY:"#ai-drawer .ai-drawer-body",PAGE:"#page",MAIN_REGION:'[role="main"]'},ACTIONS:{SUMMARY:'[data-action="course-summarise"]',RETRY:'[data-action="course-summarise-retry"]',DECLINE:'.ai-policy-block [data-action="decline"]',ACCEPT:'.ai-policy-block [data-action="accept"]',REGENERATE:'[data-action="course-summarise-regenerate"]',CANCEL:'[data-action="course-summarise-cancel"]'}},_exports.default})); +define("aiplacement_courseassist/selectors",["exports"],(function(_exports){Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.default=void 0;return _exports.default={ELEMENTS:{AIDRAWER:"#ai-drawer",AIDRAWER_BODY:"#ai-drawer .ai-drawer-body",PAGE:"#page",MAIN_REGION:'[role="main"]',AIDRAWER_CLOSE:"#ai-drawer-close",RESPONSE:".course-assist-response"},ACTIONS:{SUMMARY:'.course-assist-controls [data-action="summarise"]',EXPLAIN:'.course-assist-controls [data-action="explain"]',RETRY:'.course-assist-controls [data-action="retry"]',DECLINE:'.ai-policy-block [data-action="decline"]',ACCEPT:'.ai-policy-block [data-action="accept"]',REGENERATE:'.course-assist-controls [data-action="regenerate"]',CANCEL:'.course-assist-controls [data-action="cancel"]'}},_exports.default})); //# 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 index bb818bdf74b..d209487d89d 100644 --- a/ai/placement/courseassist/amd/build/selectors.min.js.map +++ b/ai/placement/courseassist/amd/build/selectors.min.js.map @@ -1 +1 @@ -{"version":3,"file":"selectors.min.js","sources":["../src/selectors.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Define all of the selectors we will be using on the AI Course assistant.\n *\n * @module aiplacement_courseassist/selectors\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default {\n ELEMENTS: {\n AIDRAWER: '#ai-drawer',\n AIDRAWER_BODY: '#ai-drawer .ai-drawer-body',\n PAGE: '#page',\n MAIN_REGION: '[role=\"main\"]',\n },\n ACTIONS: {\n SUMMARY: '[data-action=\"course-summarise\"]',\n RETRY: '[data-action=\"course-summarise-retry\"]',\n DECLINE: '.ai-policy-block [data-action=\"decline\"]',\n ACCEPT: '.ai-policy-block [data-action=\"accept\"]',\n REGENERATE: '[data-action=\"course-summarise-regenerate\"]',\n CANCEL: '[data-action=\"course-summarise-cancel\"]',\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,2CACTC,OAAQ,0CACRC,WAAY,8CACZC,OAAQ"} \ No newline at end of file +{"version":3,"file":"selectors.min.js","sources":["../src/selectors.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Define all of the selectors we will be using on the AI Course assistant.\n *\n * @module aiplacement_courseassist/selectors\n * @copyright 2024 Huong Nguyen \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default {\n ELEMENTS: {\n AIDRAWER: '#ai-drawer',\n AIDRAWER_BODY: '#ai-drawer .ai-drawer-body',\n PAGE: '#page',\n MAIN_REGION: '[role=\"main\"]',\n AIDRAWER_CLOSE: '#ai-drawer-close',\n RESPONSE: '.course-assist-response',\n },\n ACTIONS: {\n SUMMARY: '.course-assist-controls [data-action=\"summarise\"]',\n EXPLAIN: '.course-assist-controls [data-action=\"explain\"]',\n RETRY: '.course-assist-controls [data-action=\"retry\"]',\n DECLINE: '.ai-policy-block [data-action=\"decline\"]',\n ACCEPT: '.ai-policy-block [data-action=\"accept\"]',\n REGENERATE: '.course-assist-controls [data-action=\"regenerate\"]',\n CANCEL: '.course-assist-controls [data-action=\"cancel\"]',\n }\n};\n"],"names":["ELEMENTS","AIDRAWER","AIDRAWER_BODY","PAGE","MAIN_REGION","AIDRAWER_CLOSE","RESPONSE","ACTIONS","SUMMARY","EXPLAIN","RETRY","DECLINE","ACCEPT","REGENERATE","CANCEL"],"mappings":"oLAsBe,CACXA,SAAU,CACNC,SAAU,aACVC,cAAe,6BACfC,KAAM,QACNC,YAAa,gBACbC,eAAgB,mBAChBC,SAAU,2BAEdC,QAAS,CACLC,QAAS,oDACTC,QAAS,kDACTC,MAAO,gDACPC,QAAS,2CACTC,OAAQ,0CACRC,WAAY,qDACZC,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 index e0547f40169..8b6de596cb1 100644 --- a/ai/placement/courseassist/amd/src/placement.js +++ b/ai/placement/courseassist/amd/src/placement.js @@ -31,6 +31,7 @@ import AIHelper from 'core_ai/helper'; import DrawerEvents from 'core/drawer_events'; import {subscribe} from 'core/pubsub'; import * as MessageDrawerHelper from 'core_message/message_drawer_helper'; +import {getString} from 'core/str'; const AICourseAssist = class { @@ -57,6 +58,8 @@ const AICourseAssist = class { this.aiDrawerElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER); this.aiDrawerBodyElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER_BODY); this.pageElement = document.querySelector(Selectors.ELEMENTS.PAGE); + this.lastAction = ''; + this.responses = new Map(); this.registerEventListeners(); } @@ -66,18 +69,39 @@ const AICourseAssist = class { */ registerEventListeners() { document.addEventListener('click', async(e) => { + // Display summarise. const summariseAction = e.target.closest(Selectors.ACTIONS.SUMMARY); if (summariseAction) { e.preventDefault(); - this.toggleAIDrawer(); + this.openAIDrawer(); + this.lastAction = 'summarise'; const isPolicyAccepted = await this.isPolicyAccepted(); if (!isPolicyAccepted) { // Display policy. this.displayPolicy(); return; } - // Display summary. - this.displaySummary(); + this.displayAction(this.lastAction); + } + // Display explain. + const explainAction = e.target.closest(Selectors.ACTIONS.EXPLAIN); + if (explainAction) { + e.preventDefault(); + this.openAIDrawer(); + this.lastAction = 'explain'; + const isPolicyAccepted = await this.isPolicyAccepted(); + if (!isPolicyAccepted) { + // Display policy. + this.displayPolicy(); + return; + } + this.displayAction(this.lastAction); + } + // Close AI drawer. + const closeAiDrawer = e.target.closest(Selectors.ELEMENTS.AIDRAWER_CLOSE); + if (closeAiDrawer) { + e.preventDefault(); + this.closeAIDrawer(); } }); @@ -95,11 +119,11 @@ const AICourseAssist = class { registerPolicyEventListeners() { const acceptAction = document.querySelector(Selectors.ACTIONS.ACCEPT); const declineAction = document.querySelector(Selectors.ACTIONS.DECLINE); - if (acceptAction) { + if (acceptAction && this.lastAction.length) { acceptAction.addEventListener('click', (e) => { e.preventDefault(); this.acceptPolicy().then(() => { - return this.displaySummary(); + return this.displayAction(this.lastAction); }).catch(Notification.exception); }); } @@ -116,27 +140,34 @@ const AICourseAssist = class { */ registerErrorEventListeners() { const retryAction = document.querySelector(Selectors.ACTIONS.RETRY); - if (retryAction) { + if (retryAction && this.lastAction.length) { retryAction.addEventListener('click', (e) => { e.preventDefault(); - this.aiDrawerBodyElement.dataset.hasdata = '0'; - this.displaySummary(); + this.displayAction(this.lastAction); }); } } /** - * Register event listeners for the response. + * Register event listeners for the responses. */ registerResponseEventListeners() { - const regenerateAction = document.querySelector(Selectors.ACTIONS.REGENERATE); - if (regenerateAction) { - regenerateAction.addEventListener('click', (e) => { - e.preventDefault(); - this.aiDrawerBodyElement.dataset.hasdata = '0'; - this.displaySummary(); - }); - } + // Get all regenerate action buttons (one per response in the AI drawer). + const regenerateActions = document.querySelectorAll(Selectors.ACTIONS.REGENERATE); + // Add event listeners for each regenerate action. + regenerateActions.forEach(regenerateAction => { + const responseElement = regenerateAction.closest(Selectors.ELEMENTS.RESPONSE); + if (regenerateAction && responseElement) { + // Get the action that this response is associated with. + const actionPerformed = responseElement.getAttribute('data-action-performed'); + regenerateAction.addEventListener('click', (e) => { + e.preventDefault(); + // Remove the old response before displaying the new one. + this.removeResponseFromStack(actionPerformed); + this.displayAction(actionPerformed); + }); + } + }); } registerLoadingEventListeners() { @@ -146,6 +177,10 @@ const AICourseAssist = class { e.preventDefault(); this.setRequestCancelled(); this.toggleAIDrawer(); + this.removeResponseFromStack('loading'); + // Refresh the response stack to avoid false indication of loading. + const responses = this.getResponseStack(); + this.aiDrawerBodyElement.innerHTML = responses; }); } } @@ -181,8 +216,6 @@ const AICourseAssist = class { if (!this.pageElement.classList.contains('show-drawer-right')) { this.addPadding(); } - // Disable the summary button. - this.disableSummaryButton(); } /** @@ -194,8 +227,6 @@ const AICourseAssist = class { if (this.pageElement.classList.contains('show-drawer-right') && this.aiDrawerBodyElement.dataset.removepadding === '1') { this.removePadding(); } - // Enable the summary button. - this.enableSummaryButton(); } /** @@ -226,24 +257,26 @@ const AICourseAssist = class { } /** - * Disable the summary button. + * Get important params related to the action. + * @param {string} action The action to use. + * @returns {object} The params to use for the action. */ - disableSummaryButton() { - const summaryButton = document.querySelector(Selectors.ACTIONS.SUMMARY); - if (summaryButton) { - summaryButton.setAttribute('disabled', 1); - } - } + async getParamsForAction(action) { + let params = {}; - /** - * Enable the summary button and focus on it. - */ - enableSummaryButton() { - const summaryButton = document.querySelector(Selectors.ACTIONS.SUMMARY); - if (summaryButton) { - summaryButton.removeAttribute('disabled'); - summaryButton.focus(); + switch (action) { + case 'summarise': + params.method = 'aiplacement_courseassist_summarise_text'; + params.heading = await getString('aisummary', 'aiplacement_courseassist'); + break; + + case 'explain': + params.method = 'aiplacement_courseassist_explain_text'; + params.heading = await getString('aiexplain', 'aiplacement_courseassist'); + break; } + + return params; } /** @@ -263,11 +296,12 @@ const AICourseAssist = class { } /** - * Check if the AI drawer has generated content or not. + * Check if the AI drawer has already generated content for a particular action. + * @param {string} action The action to check. * @return {boolean} True if the AI drawer has generated content, false otherwise. */ - hasGeneratedContent() { - return this.aiDrawerBodyElement.dataset.hasdata === '1'; + hasGeneratedContent(action) { + return this.responses.has(action); } /** @@ -286,23 +320,36 @@ const AICourseAssist = class { */ displayLoading() { Templates.render('aiplacement_courseassist/loading', {}).then((html) => { - this.aiDrawerBodyElement.innerHTML = html; + this.addResponseToStack('loading', html); + const responses = this.getResponseStack(); + this.aiDrawerBodyElement.innerHTML = responses; this.registerLoadingEventListeners(); return; + }).then(() => { + this.removeResponseFromStack('loading'); + return; }).catch(Notification.exception); } /** - * Display the summary. + * Display the action result in the AI drawer. + * @param {string} action The action to display. */ - async displaySummary() { - if (!this.hasGeneratedContent()) { + async displayAction(action) { + if (this.hasGeneratedContent(action)) { + // Scroll to generated content. + const existingReponse = document.querySelector('[data-action-performed="' + action + '"]'); + if (existingReponse) { + this.aiDrawerBodyElement.scrollTop = existingReponse.offsetTop; + } + } else { // Display loading spinner. this.displayLoading(); - // Clear the drawer content to prevent sending some unnecessary content. + // Clear the drawer to prevent including the previously generated response in the new response prompt. this.aiDrawerBodyElement.innerHTML = ''; + const params = await this.getParamsForAction(action); const request = { - methodname: 'aiplacement_courseassist_summarise_text', + methodname: params.method, args: { contextid: this.contextId, prompttext: this.getTextContent(), @@ -315,9 +362,9 @@ const AICourseAssist = class { return; } else { if (!this.isRequestCancelled()) { - // Replace double line breaks with
and with

for paragraphs. - const generatedContent = AIHelper.replaceLineBreaks(responseObj.generatedcontent); - this.displayResponse(generatedContent); + // Perform replacements on the generated context to ensure it is formatted correctly. + const generatedContent = AIHelper.formatResponse(responseObj.generatedcontent); + this.displayResponse(generatedContent, action); return; } else { this.aiDrawerBodyElement.dataset.cancelled = '0'; @@ -331,13 +378,55 @@ const AICourseAssist = class { } /** - * Display the response. - * @param {String} content The content to display. + * Add the HTML response to the response stack. + * The stack will be used to display all responses in the AI drawer. + * @param {String} action The action key. + * @param {String} html The HTML to store. */ - displayResponse(content) { - Templates.render('aiplacement_courseassist/response', {content: content}).then((html) => { - this.aiDrawerBodyElement.innerHTML = html; - this.aiDrawerBodyElement.dataset.hasdata = '1'; + addResponseToStack(action, html) { + this.responses.set(action, html); + } + + /** + * Remove a stored response, allowing for a regenerated one. + * @param {String} action The action key. + */ + removeResponseFromStack(action) { + if (this.responses.has(action)) { + this.responses.delete(action); + } + } + + /** + * Return a stack of HTML responses. + * @return {String} HTML responses. + */ + getResponseStack() { + let stack = ''; + // Reverse to get newest first. + const responses = [...this.responses.values()].reverse(); + for (const response of responses) { + stack += response; + } + return stack; + } + + /** + * Display the responses. + * @param {String} content The content to display. + * @param {String} action The action used. + */ + async displayResponse(content, action) { + const params = await this.getParamsForAction(action); + const args = { + content: content, + heading: params.heading, + action: action, + }; + Templates.render('aiplacement_courseassist/response', args).then((html) => { + this.addResponseToStack(action, html); + const responses = this.getResponseStack(); + this.aiDrawerBodyElement.innerHTML = responses; this.registerResponseEventListeners(); return; }).catch(Notification.exception); @@ -348,9 +437,14 @@ const AICourseAssist = class { */ displayError() { Templates.render('aiplacement_courseassist/error', {}).then((html) => { - this.aiDrawerBodyElement.innerHTML = html; + this.addResponseToStack('error', html); + const responses = this.getResponseStack(); + this.aiDrawerBodyElement.innerHTML = responses; this.registerErrorEventListeners(); return; + }).then(() => { + this.removeResponseFromStack('error'); + return; }).catch(Notification.exception); } diff --git a/ai/placement/courseassist/amd/src/selectors.js b/ai/placement/courseassist/amd/src/selectors.js index 27300d09fe8..a1389be758d 100644 --- a/ai/placement/courseassist/amd/src/selectors.js +++ b/ai/placement/courseassist/amd/src/selectors.js @@ -26,13 +26,16 @@ export default { AIDRAWER_BODY: '#ai-drawer .ai-drawer-body', PAGE: '#page', MAIN_REGION: '[role="main"]', + AIDRAWER_CLOSE: '#ai-drawer-close', + RESPONSE: '.course-assist-response', }, ACTIONS: { - SUMMARY: '[data-action="course-summarise"]', - RETRY: '[data-action="course-summarise-retry"]', + SUMMARY: '.course-assist-controls [data-action="summarise"]', + EXPLAIN: '.course-assist-controls [data-action="explain"]', + RETRY: '.course-assist-controls [data-action="retry"]', DECLINE: '.ai-policy-block [data-action="decline"]', ACCEPT: '.ai-policy-block [data-action="accept"]', - REGENERATE: '[data-action="course-summarise-regenerate"]', - CANCEL: '[data-action="course-summarise-cancel"]', + REGENERATE: '.course-assist-controls [data-action="regenerate"]', + CANCEL: '.course-assist-controls [data-action="cancel"]', } }; diff --git a/ai/placement/courseassist/classes/external/explain_text.php b/ai/placement/courseassist/classes/external/explain_text.php new file mode 100644 index 00000000000..efcff168e85 --- /dev/null +++ b/ai/placement/courseassist/classes/external/explain_text.php @@ -0,0 +1,155 @@ +. + +namespace aiplacement_courseassist\external; + +use aiplacement_courseassist\utils; +use core_external\external_api; +use core_external\external_function_parameters; +use core_external\external_value; + +/** + * External API to call explain text action for this placement. + * + * @package aiplacement_courseassist + * @copyright 2024 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class explain_text extends external_api { + + /** + * Explain text parameters. + * + * @return external_function_parameters + * @since Moodle 5.0 + */ + 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, + ), + ]); + } + + /** + * Explain 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 5.0 + */ + 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); + if (!utils::is_course_assist_available($context)) { + throw new \moodle_exception('nocourseassist', 'aiplacement_courseassist'); + } + + // Prepare the action. + $action = new \core_ai\aiactions\explain_text( + contextid: $contextid, + userid: $USER->id, + prompttext: $prompttext, + ); + + // Send the action to the AI manager. + $manager = \core\di::get(\core_ai\manager::class); + $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 5.0 + */ + 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 index 32fba1062ae..9a1b8305bbd 100644 --- a/ai/placement/courseassist/classes/hook_callbacks.php +++ b/ai/placement/courseassist/classes/hook_callbacks.php @@ -37,11 +37,11 @@ class hook_callbacks { } /** - * Bootstrap the summarise button. + * Bootstrap the action buttons. * * @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); + \aiplacement_courseassist\output\assist_ui::action_buttons_handler($hook); } } diff --git a/ai/placement/courseassist/classes/output/assist_ui.php b/ai/placement/courseassist/classes/output/assist_ui.php index 1af34375ef8..8afeeba770f 100644 --- a/ai/placement/courseassist/classes/output/assist_ui.php +++ b/ai/placement/courseassist/classes/output/assist_ui.php @@ -51,19 +51,30 @@ class assist_ui { } /** - * Bootstrap the summarise button. + * Determine if we should be loading a single button or a dropdown. * * @param after_http_headers $hook */ - public static function load_summarise_button(after_http_headers $hook): void { - global $OUTPUT; + public static function action_buttons_handler(after_http_headers $hook): void { + global $PAGE, $OUTPUT; // Preflight checks. if (!self::preflight_checks()) { return; } - $html = $OUTPUT->render_from_template('aiplacement_courseassist/summarise_button', []); + $actions['actions'] = utils::get_actions_available($PAGE->context); + + // No actions available. + if (empty($actions['actions'])) { + return; + } + + if (count($actions['actions']) > 1) { + $actions['isdropdown'] = true; + } + + $html = $OUTPUT->render_from_template('aiplacement_courseassist/actions', $actions); $hook->add_html($html); } diff --git a/ai/placement/courseassist/classes/placement.php b/ai/placement/courseassist/classes/placement.php index 07938232b66..37655b441a3 100644 --- a/ai/placement/courseassist/classes/placement.php +++ b/ai/placement/courseassist/classes/placement.php @@ -29,6 +29,7 @@ class placement extends \core_ai\placement { public static function get_action_list(): array { return [ \core_ai\aiactions\summarise_text::class, + \core_ai\aiactions\explain_text::class, ]; } diff --git a/ai/placement/courseassist/classes/utils.php b/ai/placement/courseassist/classes/utils.php index 9b1e4a58f5d..906aa21fbd8 100644 --- a/ai/placement/courseassist/classes/utils.php +++ b/ai/placement/courseassist/classes/utils.php @@ -16,6 +16,7 @@ namespace aiplacement_courseassist; +use core_ai\aiactions\explain_text; use core_ai\aiactions\summarise_text; use core_ai\manager; @@ -40,16 +41,53 @@ class utils { return false; } - $aimanager = \core\di::get(manager::class); - $providers = $aimanager->get_providers_for_actions([summarise_text::class], true); - if (!has_capability('aiplacement/courseassist:summarise_text', $context) - || !$aimanager->is_action_available(summarise_text::class) - || !$aimanager->is_action_enabled('aiplacement_courseassist', summarise_text::class) - || empty($providers[summarise_text::class]) - ) { + if (empty(self::get_actions_available($context))) { return false; } return true; } + + /** + * Get all the actions available and return action data for template. + * + * @param \context $context The context. + * @return array Return the actions available with data. + */ + public static function get_actions_available(\context $context): array { + $actions = []; + $actionclasses = [ + summarise_text::class, + explain_text::class, + ]; + $manager = \core\di::get(manager::class); + $providers = $manager->get_providers_for_actions($actionclasses, true); + + // Summarise text. + if (has_capability('aiplacement/courseassist:summarise_text', $context) + && $manager->is_action_available(summarise_text::class) + && $manager->is_action_enabled('aiplacement_courseassist', summarise_text::class) + && !empty($providers[summarise_text::class]) + ) { + $actions[] = [ + 'action' => 'summarise', + 'buttontext' => get_string('summarise', 'aiplacement_courseassist'), + 'title' => get_string('summarise_tooltips', 'aiplacement_courseassist'), + ]; + } + // Explain text. + if (has_capability('aiplacement/courseassist:explain_text', $context) + && $manager->is_action_available(explain_text::class) + && $manager->is_action_enabled('aiplacement_courseassist', explain_text::class) + && !empty($providers[explain_text::class]) + ) { + $actions[] = [ + 'action' => 'explain', + 'buttontext' => get_string('explain', 'aiplacement_courseassist'), + 'title' => get_string('explain_tooltips', 'aiplacement_courseassist'), + ]; + } + + return $actions; + } } diff --git a/ai/placement/courseassist/db/access.php b/ai/placement/courseassist/db/access.php index a4653dc3fa3..efd908bab83 100644 --- a/ai/placement/courseassist/db/access.php +++ b/ai/placement/courseassist/db/access.php @@ -34,4 +34,14 @@ $capabilities = [ 'student' => CAP_ALLOW, ], ], + 'aiplacement/courseassist:explain_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/services.php b/ai/placement/courseassist/db/services.php index 746870fb83d..d89b9ffd631 100644 --- a/ai/placement/courseassist/db/services.php +++ b/ai/placement/courseassist/db/services.php @@ -32,4 +32,11 @@ $functions = [ 'ajax' => true, 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], ], + 'aiplacement_courseassist_explain_text' => [ + 'classname' => 'aiplacement_courseassist\external\explain_text', + 'description' => 'Explain 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 index de4c69873b6..5d56733de8f 100644 --- a/ai/placement/courseassist/lang/en/aiplacement_courseassist.php +++ b/ai/placement/courseassist/lang/en/aiplacement_courseassist.php @@ -22,9 +22,14 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +$string['aiexplain'] = 'AI explain'; +$string['aifeatures'] = 'AI features'; $string['aisummary'] = 'AI summary'; +$string['courseassist:explain_text'] = 'Explain text'; $string['courseassist:summarise_text'] = 'Summarise text'; $string['copy'] = 'Copy'; +$string['explain'] = 'Explain'; +$string['explain_tooltips'] = 'Create an AI-generated explanation of the page content'; $string['generatefailtitle'] = 'Something went wrong'; $string['generating'] = 'Generating your response'; $string['nocourseassist'] = 'An AI-generated summary is not available for this context.'; diff --git a/ai/placement/courseassist/styles.css b/ai/placement/courseassist/styles.css index 61162bf55ed..d03ec618b8a 100644 --- a/ai/placement/courseassist/styles.css +++ b/ai/placement/courseassist/styles.css @@ -38,40 +38,37 @@ scrollbar-color: #6a737b #f8f9fa; } -#course-summarise-response { +.course-assist-response-content { font-size: 0.875em; } -.course-summarise-response-controls button .icon, -.course-summarise-response-watermark img.icon { +#ai-features .icon { + min-width: 16px; +} + +.course-assist-response-header .icon { + width: 30px; + height: 30px; +} + +.course-assist-controls .icon, +.ai-course-assist-sparkles-icon .icon { margin-right: 0; } -.ai-course-summarise-controls button.btn.btn-outline-secondary { +.course-assist-controls .ai-action { color: unset; } -.ai-course-summarise-controls button.btn.btn-outline-secondary span.ai-course-summarise-sparkles-icon { +/* Toggle visibility of sparkles icon on hover. */ +.course-assist-controls .ai-action .ai-course-assist-sparkles-icon, +.course-assist-controls .ai-action:not([disabled]):hover .ai-course-assist-sparkles-icon.white, +.course-assist-controls.show .ai-action:not([disabled]) .ai-course-assist-sparkles-icon.white { display: inline-block; } -.ai-course-summarise-controls button.btn.btn-outline-secondary span.ai-course-summarise-sparkles-icon.white { +.course-assist-controls .ai-action .ai-course-assist-sparkles-icon.white, +.course-assist-controls .ai-action:not([disabled]):hover .ai-course-assist-sparkles-icon, +.course-assist-controls.show .ai-action:not([disabled]) .ai-course-assist-sparkles-icon { 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/action_button.mustache b/ai/placement/courseassist/templates/action_button.mustache new file mode 100644 index 00000000000..d1cd5805224 --- /dev/null +++ b/ai/placement/courseassist/templates/action_button.mustache @@ -0,0 +1,47 @@ +{{! + 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/action_button + * isdropdown - Is this a dropdown item + * actions - The action being performed + * buttontext - The text to display for the button + * title - The tooltip/title text to use + + Template to display sparkles icon. + + Example context (json): + { + "isdropdown": true, + "action": "explain", + "buttontext": "Explain", + "title": "Create an AI-generated explanation of the page content" + } +}} + \ No newline at end of file diff --git a/ai/placement/courseassist/templates/actions.mustache b/ai/placement/courseassist/templates/actions.mustache new file mode 100644 index 00000000000..7ae3768f4a9 --- /dev/null +++ b/ai/placement/courseassist/templates/actions.mustache @@ -0,0 +1,53 @@ +{{! + 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/actions + + Template to display AI action in a dropdown or single action button for the course assist. + + Context variables required for this template: + * isdropdown - If this is a dropdown, load the appropriate layout + * actions - List of actions to generate buttons for + + Example context (json): + { + "isdropdown": true, + "actions": [ + { + "action": "summarise", + "buttontext": "Summarise", + "title": "Create an AI-generated summary of the page content" + }, + { + "action": "explain", + "buttontext": "Explain", + "title": "Create an AI-generated explanation of the page content" + } + ] + } +}} +

+ {{#isdropdown}} + {{> aiplacement_courseassist/actions_dropdown }} + {{/isdropdown}} + {{^isdropdown}} + {{#actions}} + {{> aiplacement_courseassist/action_button }} + {{/actions}} + {{/isdropdown}} +
diff --git a/ai/placement/courseassist/templates/actions_dropdown.mustache b/ai/placement/courseassist/templates/actions_dropdown.mustache new file mode 100644 index 00000000000..be498b31be8 --- /dev/null +++ b/ai/placement/courseassist/templates/actions_dropdown.mustache @@ -0,0 +1,56 @@ +{{! + 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/actions_dropdown + + Template to display AI action in a dropdown for the course assist. + + Context variables required for this template: + * actions - List of actions to generate buttons for + + Example context (json): + { + "actions": [ + { + "action": "summarise", + "buttontext": "Summarise", + "title": "Create an AI-generated summary of the page content" + }, + { + "action": "explain", + "buttontext": "Explain", + "title": "Create an AI-generated explanation of the page content" + } + ] + } +}} + + diff --git a/ai/placement/courseassist/templates/drawer.mustache b/ai/placement/courseassist/templates/drawer.mustache index 42e650a8cca..1394618f8b6 100644 --- a/ai/placement/courseassist/templates/drawer.mustache +++ b/ai/placement/courseassist/templates/drawer.mustache @@ -22,25 +22,21 @@ 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

" + "contextid": "1" } }}
-
-
- {{{content}}} -
+
{{#js}} require(['aiplacement_courseassist/placement'], function(AICourseAssist) { diff --git a/ai/placement/courseassist/templates/error.mustache b/ai/placement/courseassist/templates/error.mustache index 91ccf47c95b..f20d7c0e4e9 100644 --- a/ai/placement/courseassist/templates/error.mustache +++ b/ai/placement/courseassist/templates/error.mustache @@ -28,13 +28,13 @@ }}
-

+

{{#pix}} req, core {{/pix}} {{#str}} generatefailtitle, aiplacement_courseassist {{/str}}

-
-
diff --git a/ai/placement/courseassist/templates/loading.mustache b/ai/placement/courseassist/templates/loading.mustache index bbb959766a9..6c0dfb954d2 100644 --- a/ai/placement/courseassist/templates/loading.mustache +++ b/ai/placement/courseassist/templates/loading.mustache @@ -28,13 +28,13 @@ }}
-

+

{{#pix}} i/loading, core {{/pix}} {{#str}} generating, aiplacement_courseassist {{/str}}

-
-
diff --git a/ai/placement/courseassist/templates/response.mustache b/ai/placement/courseassist/templates/response.mustache index 7384ce410cb..81df329cc76 100644 --- a/ai/placement/courseassist/templates/response.mustache +++ b/ai/placement/courseassist/templates/response.mustache @@ -21,33 +21,42 @@ Context variables required for this template: * content - Content to display + * heading - The heading to display + * action - The action being performed Example context (json): { - "content": "

Content to display

" + "content": "

Content to display

", + "heading": "AI Explain", + "action": "explain" } }} -
+
-

- {{#str}} aisummary, aiplacement_courseassist {{/str}} +

+ + {{#pix}} sparkles, aiplacement_courseassist {{/pix}} + + {{heading}}

-
+
{{{content}}}
-
+
- {{#pix}} sparkles, aiplacement_courseassist {{/pix}} + + {{#pix}} sparkles, aiplacement_courseassist {{/pix}} + {{#str}} contentwatermark, core_ai {{/str}}
-
- - diff --git a/ai/placement/courseassist/templates/sparkles_icon.mustache b/ai/placement/courseassist/templates/sparkles_icon.mustache new file mode 100644 index 00000000000..a623dc7dab1 --- /dev/null +++ b/ai/placement/courseassist/templates/sparkles_icon.mustache @@ -0,0 +1,31 @@ +{{! + 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/sparkles_icon + + Template to display sparkles icon. + + Example context (json): + { + } +}} + + {{#pix}} sparkles, aiplacement_courseassist {{/pix}} + + + {{#pix}} sparkles-white, aiplacement_courseassist {{/pix}} + \ No newline at end of file diff --git a/ai/placement/courseassist/templates/summarise_button.mustache b/ai/placement/courseassist/templates/summarise_button.mustache deleted file mode 100644 index a8dfa11c178..00000000000 --- a/ai/placement/courseassist/templates/summarise_button.mustache +++ /dev/null @@ -1,46 +0,0 @@ -{{! - 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): - { - } -}} -
- -
diff --git a/ai/placement/courseassist/tests/behat/course_assist_features.feature b/ai/placement/courseassist/tests/behat/course_assist_features.feature new file mode 100644 index 00000000000..799de01b3b6 --- /dev/null +++ b/ai/placement/courseassist/tests/behat/course_assist_features.feature @@ -0,0 +1,84 @@ +@core_ai @aiplacement_courseassist +Feature: AI course assist features + In order to use AI course assist features + As a user + I need to access a course and choose the desired AI 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 the following "core_ai > ai providers" exist: + | provider | name | enabled | apikey | orgid | + | aiprovider_openai | OpenAI API test | 1 | 123 | abc | + And the following config values are set as admin: + | enabled | 1 | aiplacement_courseassist | + + Scenario: AI features dropdown is visible when more than one feature is enabled + When I am on the "PageName1" "page activity" page logged in as teacher1 + Then "AI features" "button" should exist + # Check nested buttons exist too. + And "Summarise" "button" should exist + And "Explain" "button" should exist + + Scenario: AI features dropdown is not visible when only one feature is enabled + Given I set the following action configuration for ai provider with name "OpenAI API test": + | action | enabled | + | explain_text | 0 | + | summarise_text | 1 | + When I am on the "PageName1" "page activity" page logged in as teacher1 + Then "AI features" "button" should not exist + And "Explain" "button" should not exist + # Only the summarise button should exist. + And "Summarise" "button" should exist + + Scenario: AI features are not available if placement is not enabled + Given the following config values are set as admin: + | enabled | | aiplacement_courseassist | + When I am on the "PageName1" "page activity" page logged in as teacher1 + Then "AI features" "button" should not exist + + Scenario: AI features are not available if provider action is not enabled + Given I set the following action configuration for ai provider with name "OpenAI API test": + | action | enabled | + | explain_text | 0 | + | summarise_text | 0 | + When I am on the "PageName1" "page activity" page logged in as teacher1 + Then "AI features" "button" should not exist + + Scenario: AI features are not available if placement action is not enabled + Given I set the following action configuration for ai provider with name "OpenAI API test": + | action | enabled | + | explain_text | 0 | + | summarise_text | 0 | + When I am on the "PageName1" "page activity" page logged in as teacher1 + Then "AI features" "button" should not exist + + Scenario: AI features are not available if the user does not have permission + When I am on the "PageName1" "page activity" page logged in as teacher2 + Then "AI features" "button" should not exist + + @javascript + Scenario: I can view the AI drawer contents using the AI features dropdown + Given I am on the "PageName1" "page activity" page logged in as teacher1 + When I click on "AI features" "button" + And I click on "Summarise" "button" + Then I should see "Welcome to the new AI feature!" in the ".ai-drawer" "css_element" diff --git a/ai/placement/courseassist/tests/behat/course_assist_summarise.feature b/ai/placement/courseassist/tests/behat/course_assist_summarise.feature deleted file mode 100644 index 24960707b22..00000000000 --- a/ai/placement/courseassist/tests/behat/course_assist_summarise.feature +++ /dev/null @@ -1,103 +0,0 @@ -@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 the following "core_ai > ai providers" exist: - |provider | name | enabled | apikey | orgid | - |aiprovider_openai| OpenAI API test | 1 | 123 | abc | - 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" the ai provider with name "OpenAI API test" - When I am on the "PageName1" "page activity" page logged in as teacher1 - Then "Summarise" "button" should not exist - And I "enable" the ai provider with name "OpenAI API test" - 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 action is not enabled - Given the following config values are set as admin: - | summarise_text | | aiprovider_openai | - And I set the following action configuration for ai provider with name "OpenAI API test": - | action | enabled | - | summarise_text | 0 | - When I am on the "PageName1" "page activity" page logged in as teacher1 - Then "Summarise" "button" should not exist - And I set the following action configuration for ai provider with name "OpenAI API test": - | action | enabled | - | summarise_text | 1 | - 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 placement action is not enabled - Given the following config values are set as admin: - | summarise_text | | aiplacement_courseassist | - When I am on the "PageName1" "page activity" page logged in as teacher1 - Then "Summarise" "button" should not exist - And the following config values are set as admin: - | summarise_text | 1 | aiplacement_courseassist | - 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 action is not enabled and placement action is enabled - Given the following config values are set as admin: - | summarise_text | | aiplacement_courseassist | - And I set the following action configuration for ai provider with name "OpenAI API test": - | action | enabled | - | summarise_text | 0 | - When I am on the "PageName1" "page activity" page logged in as teacher1 - Then "Summarise" "button" should not exist - And the following config values are set as admin: - | summarise_text | 1 | aiplacement_courseassist | - And I am on the "PageName1" "page activity" page logged in as teacher1 - And "Summarise" "button" should not exist - And I set the following action configuration for ai provider with name "OpenAI API test": - | action | enabled | - | summarise_text | 1 | - 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/tests/utils_test.php b/ai/placement/courseassist/tests/utils_test.php index c7e56aeeb63..74181ef0903 100644 --- a/ai/placement/courseassist/tests/utils_test.php +++ b/ai/placement/courseassist/tests/utils_test.php @@ -48,13 +48,15 @@ final class utils_test extends \advanced_testcase { $this->setUser($user1); $this->assertFalse(utils::is_course_assist_available($context)); - // Plugin is not enabled. - $this->setUser($user1); + // Provider is enabled, but plugin is not enabled. + set_config('enabled', 1, 'aiprovider_openai'); + set_config('apikey', '123', 'aiprovider_openai'); set_config('enabled', 0, 'aiplacement_courseassist'); $this->assertFalse(utils::is_course_assist_available($context)); // Plugin is enabled but user does not have capability. assign_capability('aiplacement/courseassist:summarise_text', CAP_PROHIBIT, $teacherrole->id, $context); + assign_capability('aiplacement/courseassist:explain_text', CAP_PROHIBIT, $teacherrole->id, $context); $this->setUser($user2); set_config('enabled', 1, 'aiplacement_courseassist'); $this->assertFalse(utils::is_course_assist_available($context)); @@ -62,28 +64,68 @@ final class utils_test extends \advanced_testcase { // Plugin is enabled, user has capability and placement action is not available. $this->setUser($user1); set_config('summarise_text', 0, 'aiplacement_courseassist'); + set_config('explain_text', 0, 'aiplacement_courseassist'); $this->assertFalse(utils::is_course_assist_available($context)); // Plugin is enabled, user has capability and provider action is not available. $this->setUser($user1); set_config('summarise_text', 0, 'aiprovider_openai'); set_config('summarise_text', 1, 'aiplacement_courseassist'); + set_config('explain_text', 0, 'aiprovider_openai'); + set_config('explain_text', 1, 'aiplacement_courseassist'); $this->assertFalse(utils::is_course_assist_available($context)); // Plugin is enabled, user has capability, placement action is available and provider action is available. - $mockmanager = $this->createMock(\core_ai\manager::class); + $mockmanager = $this->createMock(manager::class); $mockmanager->method('is_action_available')->willReturn(true); $mockmanager->method('is_action_enabled')->willReturn(true); $mockmanager->method('get_providers_for_actions')->willReturn([ summarise_text::class => ['aiprovider_openai'], ]); - \core\di::set(\core_ai\manager::class, function() use ($mockmanager) { + \core\di::set(manager::class, function() use ($mockmanager) { return $mockmanager; }); $this->setUser($user1); set_config('summarise_text', 1, 'aiplacement_courseassist'); + set_config('explain_text', 1, 'aiprovider_openai'); + set_config('explain_text', 1, 'aiplacement_courseassist'); $this->assertTrue(utils::is_course_assist_available($context)); } + + /** + * Test get_actions_available method. + */ + public function test_get_actions_available(): void { + global $DB; + $this->resetAfterTest(); + $user1 = $this->getDataGenerator()->create_user(); + $course = $this->getDataGenerator()->create_course(); + $context = \context_course::instance($course->id); + $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'manager'); + $this->setUser($user1); + + // Two actions enabled. + set_config('enabled', 1, 'aiprovider_openai'); + set_config('apikey', '123', 'aiprovider_openai'); + set_config('explain_text', 1, 'aiplacement_courseassist'); + set_config('summarise_text', 1, 'aiplacement_courseassist'); + $manager = \core\di::get(manager::class); + $manager->create_provider_instance( + classname: '\aiprovider_openai\provider', + name: 'dummy', + enabled: true, + config: ['apikey' => '123'], + ); + $this->assertCount(2, utils::get_actions_available($context)); + + // One action enabled. + set_config('summarise_text', 0, 'aiplacement_courseassist'); + $this->assertCount(1, utils::get_actions_available($context)); + + // No actions enabled. + set_config('explain_text', 0, 'aiplacement_courseassist'); + $this->assertCount(0, utils::get_actions_available($context)); + } } diff --git a/ai/placement/courseassist/version.php b/ai/placement/courseassist/version.php index 70250d4f2e9..77d8f4c03c0 100644 --- a/ai/placement/courseassist/version.php +++ b/ai/placement/courseassist/version.php @@ -25,6 +25,6 @@ defined('MOODLE_INTERNAL') || die(); $plugin->component = 'aiplacement_courseassist'; -$plugin->version = 2024121800; +$plugin->version = 2025012000; $plugin->requires = 2024100100; $plugin->maturity = MATURITY_STABLE; diff --git a/ai/provider/azureai/classes/process_explain_text.php b/ai/provider/azureai/classes/process_explain_text.php new file mode 100644 index 00000000000..d27dbe43e23 --- /dev/null +++ b/ai/provider/azureai/classes/process_explain_text.php @@ -0,0 +1,28 @@ +. + +namespace aiprovider_azureai; + +/** + * Class process text explanation. + * + * @package aiprovider_azureai + * @copyright 2024 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class process_explain_text extends process_generate_text { + +} diff --git a/ai/provider/azureai/classes/provider.php b/ai/provider/azureai/classes/provider.php index b33bcc719c4..a05ac39dfc4 100644 --- a/ai/provider/azureai/classes/provider.php +++ b/ai/provider/azureai/classes/provider.php @@ -37,6 +37,7 @@ class provider extends \core_ai\provider { \core_ai\aiactions\generate_text::class, \core_ai\aiactions\generate_image::class, \core_ai\aiactions\summarise_text::class, + \core_ai\aiactions\explain_text::class, ]; } @@ -74,7 +75,7 @@ class provider extends \core_ai\provider { $actionname = substr($action, (strrpos($action, '\\') + 1)); $customdata['actionname'] = $actionname; $customdata['action'] = $action; - if ($actionname === 'generate_text' || $actionname === 'summarise_text') { + if ($actionname === 'generate_text' || $actionname === 'summarise_text' || $actionname === 'explain_text') { return new form\action_generate_text_form(customdata: $customdata); } else if ($actionname === 'generate_image') { return new form\action_generate_image_form(customdata: $customdata); @@ -90,7 +91,7 @@ class provider extends \core_ai\provider { 'actionname' => $actionname, 'action' => $action, ]; - if ($actionname === 'generate_text' || $actionname === 'summarise_text') { + if ($actionname === 'generate_text' || $actionname === 'summarise_text' || $actionname === 'explain_text') { $mform = new form\action_generate_text_form(customdata: $customdata); return $mform->get_defaults(); } else if ($actionname === 'generate_image') { diff --git a/ai/provider/azureai/lang/en/aiprovider_azureai.php b/ai/provider/azureai/lang/en/aiprovider_azureai.php index 7457a3b613e..bd95764dcaa 100644 --- a/ai/provider/azureai/lang/en/aiprovider_azureai.php +++ b/ai/provider/azureai/lang/en/aiprovider_azureai.php @@ -22,6 +22,11 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +$string['action:explain_text:apiversion'] = 'API version'; +$string['action:explain_text:deployment'] = 'Deployment ID'; +$string['action:explain_text:deployment_help'] = 'The deployment ID that relates to the API endpoint the provider uses for this action.'; +$string['action:explain_text:systeminstruction'] = 'System instruction'; +$string['action:explain_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.'; $string['action:generate_image:apiversion'] = 'API version'; $string['action:generate_image:deployment'] = 'Deployment ID'; $string['action:generate_image:deployment_help'] = 'The deployment ID that relates to the API endpoint the provider uses for this action.'; diff --git a/ai/provider/azureai/tests/provider_test.php b/ai/provider/azureai/tests/provider_test.php index 4375a406b2c..6d4906794c4 100644 --- a/ai/provider/azureai/tests/provider_test.php +++ b/ai/provider/azureai/tests/provider_test.php @@ -54,10 +54,11 @@ final class provider_test extends \advanced_testcase { public function test_get_action_list(): void { $actionlist = $this->provider->get_action_list(); $this->assertIsArray($actionlist); - $this->assertEquals(3, count($actionlist)); + $this->assertEquals(4, count($actionlist)); $this->assertContains(\core_ai\aiactions\generate_text::class, $actionlist); $this->assertContains(\core_ai\aiactions\generate_image::class, $actionlist); $this->assertContains(\core_ai\aiactions\summarise_text::class, $actionlist); + $this->assertContains(\core_ai\aiactions\explain_text::class, $actionlist); } /** diff --git a/ai/provider/openai/classes/process_explain_text.php b/ai/provider/openai/classes/process_explain_text.php new file mode 100644 index 00000000000..ab5cd2f1424 --- /dev/null +++ b/ai/provider/openai/classes/process_explain_text.php @@ -0,0 +1,28 @@ +. + +namespace aiprovider_openai; + +/** + * Class process text explanation. + * + * @package aiprovider_openai + * @copyright 2024 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class process_explain_text extends process_generate_text { + +} diff --git a/ai/provider/openai/classes/provider.php b/ai/provider/openai/classes/provider.php index 62067f29227..033588f8446 100644 --- a/ai/provider/openai/classes/provider.php +++ b/ai/provider/openai/classes/provider.php @@ -37,6 +37,7 @@ class provider extends \core_ai\provider { \core_ai\aiactions\generate_text::class, \core_ai\aiactions\generate_image::class, \core_ai\aiactions\summarise_text::class, + \core_ai\aiactions\explain_text::class, ]; } @@ -79,7 +80,7 @@ class provider extends \core_ai\provider { $actionname = substr($action, (strrpos($action, '\\') + 1)); $customdata['actionname'] = $actionname; $customdata['action'] = $action; - if ($actionname === 'generate_text' || $actionname === 'summarise_text') { + if ($actionname === 'generate_text' || $actionname === 'summarise_text' || $actionname === 'explain_text') { return new form\action_generate_text_form(customdata: $customdata); } else if ($actionname === 'generate_image') { return new form\action_generate_image_form(customdata: $customdata); @@ -96,7 +97,7 @@ class provider extends \core_ai\provider { 'action' => $action, 'providername' => 'aiprovider_openai', ]; - if ($actionname === 'generate_text' || $actionname === 'summarise_text') { + if ($actionname === 'generate_text' || $actionname === 'summarise_text' || $actionname === 'explain_text') { $mform = new form\action_generate_text_form(customdata: $customdata); return $mform->get_defaults(); } else if ($actionname === 'generate_image') { diff --git a/ai/provider/openai/lang/en/aiprovider_openai.php b/ai/provider/openai/lang/en/aiprovider_openai.php index 7a0c135c692..f8b9a005a38 100644 --- a/ai/provider/openai/lang/en/aiprovider_openai.php +++ b/ai/provider/openai/lang/en/aiprovider_openai.php @@ -22,6 +22,11 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +$string['action:explain_text:endpoint'] = 'API endpoint'; +$string['action:explain_text:model'] = 'AI model'; +$string['action:explain_text:model_help'] = 'The model used to explain the provided text.'; +$string['action:explain_text:systeminstruction'] = 'System instruction'; +$string['action:explain_text:systeminstruction_help'] = 'This instruction is sent to the AI model along with the user\'s prompt. Editing this instruction is not recommended unless absolutely required.'; $string['action:generate_image:endpoint'] = 'API endpoint'; $string['action:generate_image:model'] = 'AI model'; $string['action:generate_image:model_help'] = 'The model used to generate images from user prompts.'; diff --git a/ai/provider/openai/tests/provider_test.php b/ai/provider/openai/tests/provider_test.php index 60da8d1704b..50e54481a25 100644 --- a/ai/provider/openai/tests/provider_test.php +++ b/ai/provider/openai/tests/provider_test.php @@ -53,10 +53,11 @@ final class provider_test extends \advanced_testcase { public function test_get_action_list(): void { $actionlist = $this->provider->get_action_list(); $this->assertIsArray($actionlist); - $this->assertCount(3, $actionlist); + $this->assertCount(4, $actionlist); $this->assertContains(\core_ai\aiactions\generate_text::class, $actionlist); $this->assertContains(\core_ai\aiactions\generate_image::class, $actionlist); $this->assertContains(\core_ai\aiactions\summarise_text::class, $actionlist); + $this->assertContains(\core_ai\aiactions\explain_text::class, $actionlist); } /** diff --git a/ai/templates/policyblock.mustache b/ai/templates/policyblock.mustache index 31ae1431270..e5b9246125d 100644 --- a/ai/templates/policyblock.mustache +++ b/ai/templates/policyblock.mustache @@ -28,7 +28,7 @@ }}
-

+

{{#str}} aiusagepolicy, core_ai {{/str}}

diff --git a/ai/tests/aiactions/responses/response_explain_text_test.php b/ai/tests/aiactions/responses/response_explain_text_test.php new file mode 100644 index 00000000000..2afc78e234f --- /dev/null +++ b/ai/tests/aiactions/responses/response_explain_text_test.php @@ -0,0 +1,78 @@ +. + +namespace core_ai\aiactions\responses; + +use core_ai\aiactions\responses\response_explain_text; + +/** + * Test response_generate_image_test action methods. + * + * @package core_ai + * @copyright 2024 David Woloszyn + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \core_ai\aiactions\responses\response_explain_text + */ +final class response_explain_text_test extends \advanced_testcase { + /** + * Test get_basename. + */ + public function test_get_success(): void { + $actionresponse = new response_explain_text( + success: true, + ); + + $this->assertTrue($actionresponse->get_success()); + $this->assertEquals('explain_text', $actionresponse->get_actionname()); + } + + /** + * Test constructor with error. + */ + public function test_construct_error(): void { + $this->expectException(\coding_exception::class); + $this->expectExceptionMessage('Error code and message must exist in an error response.'); + new response_explain_text( + success: false, + ); + } + + /** + * Test set_response_data. + */ + public function test_set_response_data(): void { + $this->resetAfterTest(); + $body = [ + 'id' => 'chatcmpl-123', + 'fingerprint' => 'fp_44709d6fcb', + 'generatedcontent' => 'This is the generated content', + 'finishreason' => 'stop', + 'prompttokens' => 9, + 'completiontokens' => 12, + ]; + $actionresponse = new response_explain_text( + success: true, + ); + $actionresponse->set_response_data($body); + + $this->assertEquals($body['id'], $actionresponse->get_response_data()['id']); + $this->assertEquals($body['fingerprint'], $actionresponse->get_response_data()['fingerprint']); + $this->assertEquals($body['generatedcontent'], $actionresponse->get_response_data()['generatedcontent']); + $this->assertEquals($body['finishreason'], $actionresponse->get_response_data()['finishreason']); + $this->assertEquals($body['prompttokens'], $actionresponse->get_response_data()['prompttokens']); + $this->assertEquals($body['completiontokens'], $actionresponse->get_response_data()['completiontokens']); + } +} diff --git a/ai/tests/manager_test.php b/ai/tests/manager_test.php index 939cce69b85..85042605a8b 100644 --- a/ai/tests/manager_test.php +++ b/ai/tests/manager_test.php @@ -19,6 +19,7 @@ namespace core_ai; use core_ai\aiactions\generate_image; use core_ai\aiactions\generate_text; use core_ai\aiactions\summarise_text; +use core_ai\aiactions\explain_text; use core_ai\aiactions\responses\response_generate_image; /** @@ -65,6 +66,7 @@ final class manager_test extends \advanced_testcase { generate_text::class, generate_image::class, summarise_text::class, + explain_text::class, ], $actions); } @@ -262,6 +264,7 @@ final class manager_test extends \advanced_testcase { $actions = [ generate_text::class, summarise_text::class, + explain_text::class, ]; // Create two provider instances. @@ -293,6 +296,7 @@ final class manager_test extends \advanced_testcase { // Assert that there is only one provider for each action. $this->assertCount(2, $providers[generate_text::class]); $this->assertCount(2, $providers[summarise_text::class]); + $this->assertCount(2, $providers[explain_text::class]); // Disable the generate text action for the Open AI provider. $setresult = $manager->set_action_state( diff --git a/lang/en/ai.php b/lang/en/ai.php index 2719908c7e3..27738d150db 100644 --- a/lang/en/ai.php +++ b/lang/en/ai.php @@ -24,6 +24,20 @@ */ $string['acceptai'] = 'Accept and continue'; $string['action'] = 'Action'; +$string['action_explain_text'] = 'Explain text'; +$string['action_explain_text_desc'] = 'Explains the text content on a course page.'; +$string['action_explain_text_instruction'] = 'You will receive a text input from the user. Your task is to explain the provided text. Follow these guidelines: + 1. Elaborate: Expand on key ideas and concepts, ensuring the explanation adds meaningful depth and avoids restating the text verbatim. + 2. Simplify: Break down complex terms or ideas into simpler components, making them easy to understand for a wide audience, including learners. + 3. Provide Context: Explain why something happens, how it works, or what its purpose is. Include relevant examples or analogies to enhance understanding where appropriate. + 4. Organise Logically: Structure your explanation to flow naturally, beginning with fundamental ideas before moving to finer details. + +Important Instructions: + 1. Return the summary in plain text only. + 2. Do not include any markdown formatting, greetings, or platitudes. + 3. Focus on clarity, conciseness, and accessibility. + +Ensure the explanation is easy to read and effectively conveys the main points of the original text.'; $string['action_generate_image'] = 'Generate image'; $string['action_generate_image_desc'] = 'Generates an image based on a text prompt.'; $string['action_generate_text'] = 'Generate text'; @@ -85,6 +99,13 @@ $string['placementactionsettings'] = 'Actions'; $string['placementactionsettings_desc'] = 'The AI actions available for this placement.'; $string['placementsettings'] = 'Placement-specific settings'; $string['placementsettings_desc'] = 'These settings control how this AI placement connects to the AI service, and related operations.'; +$string['privacy:metadata:ai_action_explain_text'] = 'A table storing the explain text requests made by users.'; +$string['privacy:metadata:ai_action_explain_text:completiontoken'] = 'The completion tokens used to explain the text.'; +$string['privacy:metadata:ai_action_explain_text:fingerprint'] = 'The unique hash representing the state/version of the model and content.'; +$string['privacy:metadata:ai_action_explain_text:generatedcontent'] = 'The actual text generated by the AI model based on the input prompt.'; +$string['privacy:metadata:ai_action_explain_text:prompt'] = 'The prompt for the explain text request.'; +$string['privacy:metadata:ai_action_explain_text:prompttokens'] = 'The prompt tokens used to explain the text.'; +$string['privacy:metadata:ai_action_explain_text:responseid'] = 'The ID of the response.'; $string['privacy:metadata:ai_action_generate_image'] = 'A table storing the image generation requests made by users.'; $string['privacy:metadata:ai_action_generate_image:aspectratio'] = 'The aspect ratio of the generated images.'; $string['privacy:metadata:ai_action_generate_image:numberimages'] = 'The number of images generated.'; diff --git a/lib/db/install.xml b/lib/db/install.xml index c2f3622a06c..13561092232 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -1,5 +1,5 @@ - @@ -4973,6 +4973,21 @@ + + + + + + + + + + + + + + +
diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index b8eeffe40d4..a5a2b6dca83 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -1412,5 +1412,34 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2025013100.01); } + if ($oldversion < 2025022100.00) { + // Define table ai_action_explain_text to be created. + $table = new xmldb_table('ai_action_explain_text'); + + // Adding fields to table ai_action_explain_text. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('prompt', XMLDB_TYPE_TEXT, null, null, null, null, null); + $table->add_field('responseid', XMLDB_TYPE_CHAR, '128', null, null, null, null); + $table->add_field('fingerprint', XMLDB_TYPE_CHAR, '128', null, null, null, null); + $table->add_field('generatedcontent', XMLDB_TYPE_TEXT, null, null, null, null, null); + $table->add_field('finishreason', XMLDB_TYPE_CHAR, '128', null, null, null, null); + $table->add_field('prompttokens', XMLDB_TYPE_INTEGER, '10', null, null, null, null); + $table->add_field('completiontoken', XMLDB_TYPE_INTEGER, '10', null, null, null, null); + + // Adding keys to table ai_action_explain_text. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + + // Conditionally launch create table for ai_action_explain_text. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Add explain action config to the AI providers. + upgrade_add_explain_action_to_ai_providers(); + + // Main savepoint reached. + upgrade_main_savepoint(true, 2025022100.00); + } + return true; } diff --git a/lib/db/upgradelib.php b/lib/db/upgradelib.php index 9291c021c75..84a0e1258e5 100644 --- a/lib/db/upgradelib.php +++ b/lib/db/upgradelib.php @@ -2026,3 +2026,43 @@ function upgrade_convert_ai_providers_to_instances() { 'action_summarise_text_systeminstruction']; array_walk($openaisettings, static fn($setting) => unset_config($setting, 'aiprovider_openai')); } + +/** + * Upgrade script to get all current AI providers and update their action config to include explain. + */ +function upgrade_add_explain_action_to_ai_providers() { + global $DB; + $currentrecords = $DB->get_recordset('ai_providers'); + + foreach ($currentrecords as $currentrecord) { + $actionconfig = json_decode($currentrecord->actionconfig, true); + if ($currentrecord->provider === 'aiprovider_openai\provider') { + $explainconfig = [ + 'enabled' => true, + 'settings' => [ + 'model' => 'gpt-4o', + 'endpoint' => 'https://api.openai.com/v1/chat/completions', + 'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'), + ], + ]; + } else if ($currentrecord->provider === 'aiprovider_azureai\provider') { + $explainconfig = [ + 'enabled' => true, + 'settings' => [ + 'deployment' => '', + 'apiversion' => '2024-06-01', + 'systeminstruction' => get_string('action_explain_text_instruction', 'core_ai'), + ], + ]; + } + + // Update the record with the changes. + if (!empty($explainconfig)) { + $actionconfig['core_ai\aiactions\explain_text'] = $explainconfig; + $currentrecord->actionconfig = json_encode($actionconfig); + $DB->update_record('ai_providers', $currentrecord); + } + } + + $currentrecords->close(); +} 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 3e014a5f6bd..8290c73b9af 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","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})); +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.formatResponse(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 4aaebd08fb9..5ae01e10564 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 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 +{"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 and removed any markdown.\n this.responseObj.editedtext = AIHelper.formatResponse(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","formatResponse","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,eAAeN,6BAGhDO,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/src/generatetext.js b/lib/editor/tiny/plugins/aiplacement/amd/src/generatetext.js index 7cdbce262fe..484f796f040 100644 --- a/lib/editor/tiny/plugins/aiplacement/amd/src/generatetext.js +++ b/lib/editor/tiny/plugins/aiplacement/amd/src/generatetext.js @@ -131,8 +131,8 @@ export default class GenerateText extends GenerateBase { generatedResponseDiv.value) ; - // Replace double line breaks with
and with

for paragraphs. - this.responseObj.editedtext = AIHelper.replaceLineBreaks(wrappedEditedResponse); + // Replace double line breaks with
and with

for paragraphs and removed any markdown. + this.responseObj.editedtext = AIHelper.formatResponse(wrappedEditedResponse); // Generate the HTML for the response. const formattedResponse = await Templates.render('tiny_aiplacement/textinsert', this.responseObj);