Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1441 ariadna 1
// This file is part of Moodle - http://moodle.org/
2
//
3
// Moodle is free software: you can redistribute it and/or modify
4
// it under the terms of the GNU General Public License as published by
5
// the Free Software Foundation, either version 3 of the License, or
6
// (at your option) any later version.
7
//
8
// Moodle is distributed in the hope that it will be useful,
9
// but WITHOUT ANY WARRANTY; without even the implied warranty of
10
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
// GNU General Public License for more details.
12
//
13
// You should have received a copy of the GNU General Public License
14
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
15
 
16
/**
17
 * Module to load and render the tools for the AI assist plugin.
18
 *
19
 * @module     aiplacement_courseassist/placement
20
 * @copyright  2024 Huong Nguyen <huongnv13@gmail.com>
21
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22
 */
23
 
24
import Templates from 'core/templates';
25
import Ajax from 'core/ajax';
26
import 'core/copy_to_clipboard';
27
import Notification from 'core/notification';
28
import Selectors from 'aiplacement_courseassist/selectors';
29
import Policy from 'core_ai/policy';
30
import AIHelper from 'core_ai/helper';
31
import DrawerEvents from 'core/drawer_events';
32
import {subscribe} from 'core/pubsub';
33
import * as MessageDrawerHelper from 'core_message/message_drawer_helper';
34
import {getString} from 'core/str';
35
import * as FocusLock from 'core/local/aria/focuslock';
36
import {isSmall} from "core/pagehelpers";
37
 
38
const AICourseAssist = class {
39
 
40
    /**
41
     * The user ID.
42
     * @type {Integer}
43
     */
44
    userId;
45
    /**
46
     * The context ID.
47
     * @type {Integer}
48
     */
49
    contextId;
50
 
51
    /**
52
     * Constructor.
53
     * @param {Integer} userId The user ID.
54
     * @param {Integer} contextId The context ID.
55
     */
56
    constructor(userId, contextId) {
57
        this.userId = userId;
58
        this.contextId = contextId;
59
 
60
        this.aiDrawerElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER);
61
        this.aiDrawerBodyElement = document.querySelector(Selectors.ELEMENTS.AIDRAWER_BODY);
62
        this.pageElement = document.querySelector(Selectors.ELEMENTS.PAGE);
63
        this.jumpToElement = document.querySelector(Selectors.ELEMENTS.JUMPTO);
64
        this.actionElement = document.querySelector(Selectors.ELEMENTS.ACTION);
65
        this.aiDrawerCloseElement = this.aiDrawerElement.querySelector(Selectors.ELEMENTS.AIDRAWER_CLOSE);
66
        this.lastAction = '';
67
        this.responses = new Map();
68
        this.isDrawerFocusLocked = false;
69
 
70
        this.registerEventListeners();
71
    }
72
 
73
    /**
74
     * Register event listeners.
75
     */
76
    registerEventListeners() {
77
        document.addEventListener('click', async(e) => {
78
            // Display summarise.
79
            const summariseAction = e.target.closest(Selectors.ACTIONS.SUMMARY);
80
            if (summariseAction) {
81
                e.preventDefault();
82
                this.openAIDrawer();
83
                this.lastAction = 'summarise';
84
                this.actionElement.focus();
85
                const isPolicyAccepted = await this.isPolicyAccepted();
86
                if (!isPolicyAccepted) {
87
                    // Display policy.
88
                    this.displayPolicy();
89
                    return;
90
                }
91
                this.displayAction(this.lastAction);
92
            }
93
            // Display explain.
94
            const explainAction = e.target.closest(Selectors.ACTIONS.EXPLAIN);
95
            if (explainAction) {
96
                e.preventDefault();
97
                this.openAIDrawer();
98
                this.lastAction = 'explain';
99
                this.actionElement.focus();
100
                const isPolicyAccepted = await this.isPolicyAccepted();
101
                if (!isPolicyAccepted) {
102
                    // Display policy.
103
                    this.displayPolicy();
104
                    return;
105
                }
106
                this.displayAction(this.lastAction);
107
            }
108
            // Close AI drawer.
109
            const closeAiDrawer = e.target.closest(Selectors.ELEMENTS.AIDRAWER_CLOSE);
110
            if (closeAiDrawer) {
111
                e.preventDefault();
112
                this.closeAIDrawer();
113
            }
114
        });
115
 
116
        document.addEventListener('keydown', e => {
117
            if (this.isAIDrawerOpen() && e.key === 'Escape') {
118
                this.closeAIDrawer();
119
            }
120
        });
121
 
122
        // Close AI drawer if message drawer is shown.
123
        subscribe(DrawerEvents.DRAWER_SHOWN, () => {
124
            if (this.isAIDrawerOpen()) {
125
                this.closeAIDrawer();
126
            }
127
        });
128
 
129
        // Focus on the AI drawer's close button when the jump-to element is focused.
130
        this.jumpToElement.addEventListener('focus', () => {
131
            this.aiDrawerCloseElement.focus();
132
        });
133
 
134
        // Focus on the action element when the AI drawer container receives focus.
135
        this.aiDrawerElement.addEventListener('focus', () => {
136
            this.actionElement.focus();
137
        });
138
 
139
        // Remove active from the action element when it loses focus.
140
        this.actionElement.addEventListener('blur', () => {
141
            this.actionElement.classList.remove('active');
142
        });
143
    }
144
 
145
    /**
146
     * Register event listeners for the policy.
147
     */
148
    registerPolicyEventListeners() {
149
        const acceptAction = document.querySelector(Selectors.ACTIONS.ACCEPT);
150
        const declineAction = document.querySelector(Selectors.ACTIONS.DECLINE);
151
        if (acceptAction && this.lastAction.length) {
152
            acceptAction.addEventListener('click', (e) => {
153
                e.preventDefault();
154
                this.acceptPolicy().then(() => {
155
                    return this.displayAction(this.lastAction);
156
                }).catch(Notification.exception);
157
            });
158
        }
159
        if (declineAction) {
160
            declineAction.addEventListener('click', (e) => {
161
                e.preventDefault();
162
                this.closeAIDrawer();
163
            });
164
        }
165
    }
166
 
167
    /**
168
     * Register event listeners for the error.
169
     */
170
    registerErrorEventListeners() {
171
        const retryAction = document.querySelector(Selectors.ACTIONS.RETRY);
172
        if (retryAction && this.lastAction.length) {
173
            retryAction.addEventListener('click', (e) => {
174
                e.preventDefault();
175
                this.displayAction(this.lastAction);
176
            });
177
        }
178
    }
179
 
180
    /**
181
     * Register event listeners for the responses.
182
     */
183
    registerResponseEventListeners() {
184
        // Get all regenerate action buttons (one per response in the AI drawer).
185
        const regenerateActions = document.querySelectorAll(Selectors.ACTIONS.REGENERATE);
186
        // Add event listeners for each regenerate action.
187
        regenerateActions.forEach(regenerateAction => {
188
            const responseElement = regenerateAction.closest(Selectors.ELEMENTS.RESPONSE);
189
            if (regenerateAction && responseElement) {
190
                // Get the action that this response is associated with.
191
                const actionPerformed = responseElement.getAttribute('data-action-performed');
192
                regenerateAction.addEventListener('click', (e) => {
193
                    e.preventDefault();
194
                    // Remove the old response before displaying the new one.
195
                    this.removeResponseFromStack(actionPerformed);
196
                    this.displayAction(actionPerformed);
197
                });
198
            }
199
        });
200
    }
201
 
202
    registerLoadingEventListeners() {
203
        const cancelAction = document.querySelector(Selectors.ACTIONS.CANCEL);
204
        if (cancelAction) {
205
            cancelAction.addEventListener('click', (e) => {
206
                e.preventDefault();
207
                this.setRequestCancelled();
208
                this.toggleAIDrawer();
209
                this.removeResponseFromStack('loading');
210
                // Refresh the response stack to avoid false indication of loading.
211
                const responses = this.getResponseStack();
212
                this.aiDrawerBodyElement.innerHTML = responses;
213
            });
214
        }
215
    }
216
 
217
    /**
218
     * Check if the AI drawer is open.
219
     * @return {boolean} True if the AI drawer is open, false otherwise.
220
     */
221
    isAIDrawerOpen() {
222
        return this.aiDrawerElement.classList.contains('show');
223
    }
224
 
225
    /**
226
     * Check if the request is cancelled.
227
     * @return {boolean} True if the request is cancelled, false otherwise.
228
     */
229
    isRequestCancelled() {
230
        return this.aiDrawerBodyElement.dataset.cancelled === '1';
231
    }
232
 
233
    setRequestCancelled() {
234
        this.aiDrawerBodyElement.dataset.cancelled = '1';
235
    }
236
 
237
    /**
238
     * Open the AI drawer.
239
     */
240
    openAIDrawer() {
241
        // Close message drawer if it is shown.
242
        MessageDrawerHelper.hide();
243
        this.aiDrawerElement.classList.add('show');
244
        this.aiDrawerElement.setAttribute('tabindex', 0);
245
        this.aiDrawerBodyElement.setAttribute('aria-live', 'polite');
246
        if (!this.pageElement.classList.contains('show-drawer-right')) {
247
            this.addPadding();
248
        }
249
        this.jumpToElement.setAttribute('tabindex', 0);
250
        this.jumpToElement.focus();
251
 
252
        // If the AI drawer is opened on a small screen, we need to trap the focus tab within the AI drawer.
253
        if (isSmall()) {
254
            FocusLock.trapFocus(this.aiDrawerElement);
255
            this.aiDrawerElement.setAttribute('aria-modal', 'true');
256
            this.aiDrawerElement.setAttribute('role', 'dialog');
257
            this.isDrawerFocusLocked = true;
258
        }
259
    }
260
 
261
    /**
262
     * Close the AI drawer.
263
     */
264
    closeAIDrawer() {
265
        // Untrap focus if it was locked.
266
        if (this.isDrawerFocusLocked) {
267
            FocusLock.untrapFocus();
268
            this.aiDrawerElement.removeAttribute('aria-modal');
269
            this.aiDrawerElement.setAttribute('role', 'region');
270
        }
271
 
272
        this.aiDrawerElement.classList.remove('show');
273
        this.aiDrawerElement.setAttribute('tabindex', -1);
274
        this.aiDrawerBodyElement.removeAttribute('aria-live');
275
        if (this.pageElement.classList.contains('show-drawer-right') && this.aiDrawerBodyElement.dataset.removepadding === '1') {
276
            this.removePadding();
277
        }
278
        this.jumpToElement.setAttribute('tabindex', -1);
279
 
280
        // We can enforce a focus-visible state on the focus element using element.focus({focusVisible: true}).
281
        // Unfortunately, this feature isn't supported in all browsers, only Firefox provides support for it.
282
        // Therefore, we will apply the active class to the action element and set focus on it.
283
        // This action will make the action element appear focused.
284
        // When the action element loses focus,
285
        // we will remove the active class at {@see registerEventListeners()}
286
        this.actionElement.classList.add('active');
287
        this.actionElement.focus();
288
    }
289
 
290
    /**
291
     * Toggle the AI drawer.
292
     */
293
    toggleAIDrawer() {
294
        if (this.isAIDrawerOpen()) {
295
            this.closeAIDrawer();
296
        } else {
297
            this.openAIDrawer();
298
        }
299
    }
300
 
301
    /**
302
     * Add padding to the page to make space for the AI drawer.
303
     */
304
    addPadding() {
305
        this.pageElement.classList.add('show-drawer-right');
306
        this.aiDrawerBodyElement.dataset.removepadding = '1';
307
    }
308
 
309
    /**
310
     * Remove padding from the page.
311
     */
312
    removePadding() {
313
        this.pageElement.classList.remove('show-drawer-right');
314
        this.aiDrawerBodyElement.dataset.removepadding = '0';
315
    }
316
 
317
    /**
318
     * Get important params related to the action.
319
     * @param {string} action The action to use.
320
     * @returns {object} The params to use for the action.
321
     */
322
    async getParamsForAction(action) {
323
        let params = {};
324
 
325
        switch (action) {
326
            case 'summarise':
327
                params.method = 'aiplacement_courseassist_summarise_text';
328
                params.heading = await getString('aisummary', 'aiplacement_courseassist');
329
                break;
330
 
331
            case 'explain':
332
                params.method = 'aiplacement_courseassist_explain_text';
333
                params.heading = await getString('aiexplain', 'aiplacement_courseassist');
334
                break;
335
        }
336
 
337
        return params;
338
    }
339
 
340
    /**
341
     * Check if the policy is accepted.
342
     * @return {bool} True if the policy is accepted, false otherwise.
343
     */
344
    async isPolicyAccepted() {
345
        return await Policy.getPolicyStatus(this.userId);
346
    }
347
 
348
    /**
349
     * Accept the policy.
350
     * @return {Promise<Object>}
351
     */
352
    acceptPolicy() {
353
        return Policy.acceptPolicy();
354
    }
355
 
356
    /**
357
     * Check if the AI drawer has already generated content for a particular action.
358
     * @param {string} action The action to check.
359
     * @return {boolean} True if the AI drawer has generated content, false otherwise.
360
     */
361
    hasGeneratedContent(action) {
362
        return this.responses.has(action);
363
    }
364
 
365
    /**
366
     * Display the policy.
367
     */
368
    displayPolicy() {
369
        Templates.render('core_ai/policyblock', {}).then((html) => {
370
            this.aiDrawerBodyElement.innerHTML = html;
371
            this.registerPolicyEventListeners();
372
            return;
373
        }).catch(Notification.exception);
374
    }
375
 
376
    /**
377
     * Display the loading spinner.
378
     */
379
    displayLoading() {
380
        Templates.render('aiplacement_courseassist/loading', {}).then((html) => {
381
            this.addResponseToStack('loading', html);
382
            const responses = this.getResponseStack();
383
            this.aiDrawerBodyElement.innerHTML = responses;
384
            this.registerLoadingEventListeners();
385
            return;
386
        }).then(() => {
387
            this.removeResponseFromStack('loading');
388
            return;
389
        }).catch(Notification.exception);
390
    }
391
 
392
    /**
393
     * Display the action result in the AI drawer.
394
     * @param {string} action The action to display.
395
     */
396
    async displayAction(action) {
397
        if (this.hasGeneratedContent(action)) {
398
            // Scroll to generated content.
399
            const existingReponse = document.querySelector('[data-action-performed="' + action + '"]');
400
            if (existingReponse) {
401
                this.aiDrawerBodyElement.scrollTop = existingReponse.offsetTop;
402
            }
403
        } else {
404
            // Display loading spinner.
405
            this.displayLoading();
406
            // Clear the drawer to prevent including the previously generated response in the new response prompt.
407
            this.aiDrawerBodyElement.innerHTML = '';
408
            const params = await this.getParamsForAction(action);
409
            const request = {
410
                methodname: params.method,
411
                args: {
412
                    contextid: this.contextId,
413
                    prompttext: this.getTextContent(),
414
                }
415
            };
416
            try {
417
                const responseObj = await Ajax.call([request])[0];
418
                if (responseObj.error) {
419
                    this.displayError();
420
                    return;
421
                } else {
422
                    if (!this.isRequestCancelled()) {
423
                        // Perform replacements on the generated context to ensure it is formatted correctly.
424
                        const generatedContent = AIHelper.formatResponse(responseObj.generatedcontent);
425
                        this.displayResponse(generatedContent, action);
426
                        return;
427
                    } else {
428
                        this.aiDrawerBodyElement.dataset.cancelled = '0';
429
                    }
430
                }
431
            } catch (error) {
432
                window.console.log(error);
433
                this.displayError();
434
            }
435
        }
436
    }
437
 
438
    /**
439
     * Add the HTML response to the response stack.
440
     * The stack will be used to display all responses in the AI drawer.
441
     * @param {String} action The action key.
442
     * @param {String} html The HTML to store.
443
     */
444
    addResponseToStack(action, html) {
445
        this.responses.set(action, html);
446
    }
447
 
448
    /**
449
     * Remove a stored response, allowing for a regenerated one.
450
     * @param {String} action The action key.
451
     */
452
    removeResponseFromStack(action) {
453
        if (this.responses.has(action)) {
454
            this.responses.delete(action);
455
        }
456
    }
457
 
458
    /**
459
     * Return a stack of HTML responses.
460
     * @return {String} HTML responses.
461
     */
462
    getResponseStack() {
463
        let stack = '';
464
        // Reverse to get newest first.
465
        const responses = [...this.responses.values()].reverse();
466
        for (const response of responses) {
467
            stack += response;
468
        }
469
        return stack;
470
    }
471
 
472
    /**
473
     * Display the responses.
474
     * @param {String} content The content to display.
475
     * @param {String} action The action used.
476
     */
477
    async displayResponse(content, action) {
478
        const params = await this.getParamsForAction(action);
479
        const args = {
480
            content: content,
481
            heading: params.heading,
482
            action: action,
483
        };
484
        Templates.render('aiplacement_courseassist/response', args).then((html) => {
485
            this.addResponseToStack(action, html);
486
            const responses = this.getResponseStack();
487
            this.aiDrawerBodyElement.innerHTML = responses;
488
            this.registerResponseEventListeners();
489
            return;
490
        }).catch(Notification.exception);
491
    }
492
 
493
    /**
494
     * Display the error.
495
     */
496
    displayError() {
497
        Templates.render('aiplacement_courseassist/error', {}).then((html) => {
498
            this.addResponseToStack('error', html);
499
            const responses = this.getResponseStack();
500
            this.aiDrawerBodyElement.innerHTML = responses;
501
            this.registerErrorEventListeners();
502
            return;
503
        }).then(() => {
504
            this.removeResponseFromStack('error');
505
            return;
506
        }).catch(Notification.exception);
507
    }
508
 
509
    /**
510
     * Get the text content of the main region.
511
     * @return {String} The text content.
512
     */
513
    getTextContent() {
514
        const mainRegion = document.querySelector(Selectors.ELEMENTS.MAIN_REGION);
515
        return mainRegion.innerText || mainRegion.textContent;
516
    }
517
};
518
 
519
export default AICourseAssist;