diff --git a/CHANGELOG.md b/CHANGELOG.md index d4665973..e3a87a08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@
  • [OpenAI Comp API] Added a new integration method to use a local LLM via an API compatible with OpenAI's API specifications [#126].
  • Custom Prompts storage space incremented to 5MB, using storage.local. Added also the total occupied space at the bottom of the Custom Prompts page [#129].
  • [ChatGPT Web] Updated the information in the option page.
  • +
  • A new dynamic menu for selecting prompts has been added. In addition to clicking the ThunderAI button, you can now use the CTRL+ALT+A keyboard shortcut [#130].
  • +
  • Added an option to order alphabetically the prompts in the menu.
  • ...
  • Version 2.1.4 - 11/09/2024

    diff --git a/README.md b/README.md index fea84788..6e6d1440 100644 --- a/README.md +++ b/README.md @@ -56,5 +56,6 @@ Are you using this addon in your Thunderbird? The specific references are described in the corresponding source files. diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 6d2347ee..d5d5f2b2 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -327,6 +327,14 @@ "message": "To use this integration, you need to set up a local server compatible with the OpenAI API, like LM Studio. Once the server is running, enter its address in the designated field within the application. To ensure proper communication between ThunderAI and the local server remember to set the CORS settings correctly.", "description": "" }, + "prefsInfoDesc_5": { + "message": "Remember that you can open the ThunderAI menu using the keyboard shortcut CTRL+ALT+A.", + "description": "" + }, + "prefsInfoDesc_6": { + "message": "You can change the shortcut clicking on the cogwheel icon at the top right of this page and choosing \"Manage Extension Shortcut\".", + "description": "" + }, "prefsDonation_1": { "message": "Do you like this addon?", "description": "" @@ -542,5 +550,25 @@ "StorageSpace": { "message": "Total storage space occupied", "description": "" + }, + "SearchPrompt": { + "message": "Search prompts", + "description": "" + }, + "prefs_OptionText_dynamic_menu_force_enter": { + "message": "Menu: immediate prompt send", + "description": "" + }, + "prefs_OptionText_dynamic_menu_force_enter_info": { + "message": "If checked, using the keyboard shortcut CTRL+ALT+A will automatically send the hightlighted prompt from the menu. Otherwise, the prompt name will be displayed to the user, requiring another press of the Enter key to send it.", + "description": "" + }, + "prefs_OptionText_dynamic_menu_order_alphabet": { + "message": "Menu: order alphabetically", + "description": "" + }, + "prefs_OptionText_dynamic_menu_order_alphabet_info": { + "message": "If checked, the prompts in the menu will be ordered alphabetically.", + "description": "" } } \ No newline at end of file diff --git a/js/mzta-menus.js b/js/mzta-menus.js index 9b709e19..a6c8fd96 100644 --- a/js/mzta-menus.js +++ b/js/mzta-menus.js @@ -19,7 +19,7 @@ // Some original methods are derived from https://github.com/ali-raheem/Aify/blob/cfadf52f576b7be3720b5b73af7c8d3129c054da/plugin/html/actions.js import { getPrompts } from './mzta-prompts.js'; -import { getLanguageDisplayName, getMenuContextCompose, getMenuContextDisplay } from './mzta-utils.js' +import { getLanguageDisplayName, getMenuContextCompose, getMenuContextDisplay, i18nConditionalGet } from './mzta-utils.js' export class mzta_Menus { @@ -33,6 +33,10 @@ export class mzta_Menus { //{ id: 'ItemC', act: (info, tab) => { console.log('ItemC', info, tab, info.menuItemId); alert('ItemC') } }, ]; + shortcutMenu = [ + //{ id: 'ItemD', label: 'LabelD' }, + ]; + constructor(openChatGPT) { this.menu_context_compose = getMenuContextCompose(); this.menu_context_display = getMenuContextDisplay(); @@ -45,6 +49,7 @@ export class mzta_Menus { async initialize() { this.allPrompts = []; this.rootMenu = []; + this.shortcutMenu = []; this.menu_listeners = {}; this.allPrompts = await getPrompts(true); this.allPrompts.sort((a, b) => a.name.localeCompare(b.name)); @@ -55,7 +60,7 @@ export class mzta_Menus { async reload(){ await browser.menus.removeAll().catch(error => { - console.error("[ThunderAI] ERROR removing the menus: ", error); + console.error("[ThunderAI] ERROR removing the menus: ", error); }); this.removeClickListener(); this.loadMenus(); @@ -140,10 +145,23 @@ export class mzta_Menus { } } + loadShortcutMenu() { + this.shortcutMenu = []; + this.allPrompts.forEach((prompt) => { + this.addShortcutMenu(prompt); + }); + } + + addShortcutMenu(prompt) { + let curr_menu_entry = {id: prompt.id, label: i18nConditionalGet(prompt.name), type: prompt.type}; + this.shortcutMenu.push(curr_menu_entry); + } + async loadMenus() { await this.initialize(); await this.addMenu(this.rootMenu); this.addClickListener(); + this.loadShortcutMenu(); } listener(info, tab) { @@ -219,4 +237,23 @@ export class mzta_Menus { } } + + async executeMenuAction(id) { + // Retrieve the action callback from the menu listeners using the provided ID + const action = this.menu_listeners[id]; + + if (action) { + try { + // Execute the action callback + await action(); + } catch (error) { + // Log any errors that occur during execution + console.error(`Error executing action for menu item ${id}:`, error); + } + } else { + // Warn if no action is found for the provided ID + console.warn(`No action found for menu item ID: ${id}`); + } + } + } \ No newline at end of file diff --git a/js/mzta-store.js b/js/mzta-store.js new file mode 100644 index 00000000..1a12b15a --- /dev/null +++ b/js/mzta-store.js @@ -0,0 +1,32 @@ +/* + * ThunderAI [https://micz.it/thunderbird-addon-thunderai/] + * Copyright (C) 2024 Mic (m@micz.it) + + * This program 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. + + * This program 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 this program. If not, see . + */ + + +export const taStore = { + + async setSessionData (key, value) { + let obj = {}; + obj[key] = value; + await browser.storage.session.set(obj); + }, + + async getSessionData (key) { + let output = await browser.storage.session.get(key); + return output[key]; + }, +}; \ No newline at end of file diff --git a/js/tb115_segmenter/tb115_loader.js b/js/tb115_segmenter/tb115_loader.js new file mode 100644 index 00000000..f5bbf28d --- /dev/null +++ b/js/tb115_segmenter/tb115_loader.js @@ -0,0 +1,65 @@ +/* + * ThunderAI [https://micz.it/thunderbird-addon-thunderai/] + * Copyright (C) 2024 Mic (m@micz.it) + + * This program 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. + + * This program 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 this program. If not, see . + */ +// Firefox 115 and, consequently, Thunderbird 115 are not compatible with Intl.Segmenter. +// ChatGPT is using Intl.Segmenter, so we have to do something about it or it won't work... + +console.log("[ThunderAI] Intl.Segmenter: " + Intl.Segmenter); + +(function() { + if('Segmenter' in Intl){ + console.log('[ThunderAI] TB128+ detected. Intl.Segmenter is already supported.'); + return; + } + // Creates a + + + \ No newline at end of file diff --git a/popup/mzta-popup.js b/popup/mzta-popup.js new file mode 100644 index 00000000..8372c74a --- /dev/null +++ b/popup/mzta-popup.js @@ -0,0 +1,252 @@ +/* + * ThunderAI [https://micz.it/thunderbird-addon-thunderai/] + * Copyright (C) 2024 Mic (m@micz.it) + + * This program 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. + + * This program 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 this program. If not, see . + */ + +import { taStore } from "../js/mzta-store.js"; +import { taLogger } from "../js/mzta-logger.js"; + +let menuSendImmediately = false; +let taLog = console; + +document.addEventListener('DOMContentLoaded', async () => { + let prefs = await browser.storage.sync.get({do_debug: false, dynamic_menu_force_enter: false}); + taLog = new taLogger("mzta-popup",prefs.do_debug); + let reponse = await browser.runtime.sendMessage({command: "popup_menu_ready"}); + taLog.log("Preparing data to load the popup menu: " + JSON.stringify(reponse)); + let tabId = await taStore.getSessionData("lastShortcutTabId"); + let tabType = await taStore.getSessionData("lastShortcutTabType"); + let filtering = await taStore.getSessionData("lastShortcutFiltering"); + let _prompts_data = await taStore.getSessionData("lastShortcutPromptsData"); + taLog.log("_prompts_data: " + JSON.stringify(_prompts_data)); + let active_prompts = filterPromptsForTab(_prompts_data, filtering); + taLog.log("active_prompts: " + JSON.stringify(active_prompts)); + menuSendImmediately = prefs.dynamic_menu_force_enter; + searchPrompt(active_prompts, tabId, tabType); + i18n.updateDocument(); +}, { once: true }); + +async function searchPrompt(allPrompts, tabId, tabType){ + taLog.log("tabType: " + tabType); + + let prefs_order = await browser.storage.sync.get({dynamic_menu_order_alphabet: true}); + + if(prefs_order.dynamic_menu_order_alphabet){ + allPrompts.sort((a, b) => a.label.localeCompare(b.label)); + } + + // console.log(">>>>>>>>> allPrompts: " + JSON.stringify(allPrompts)); + + let input = document.getElementById('mzta_search_input'); + let autocompleteList = document.getElementById('mzta_autocomplete-items'); + let autocompleteListLoading = document.getElementById('mzta_autocomplete-items-loading'); + let _spacer_div = document.getElementById('_spacer_div'); + let banner = document.getElementById('mzta_search_banner'); + + // Initialize variables to track focus and selection + let currentFocus = -1; // Tracks the currently highlighted item + let selectedId = null; // Tracks the ID of the selected item + + // Function to filter and display autocomplete suggestions + input.addEventListener('input', function() { + const query = this.value.trim().toLowerCase(); + // console.log(">>>>>>>>>>>> query: " + query); + autocompleteList.innerHTML = ''; // Clear previous suggestions + currentFocus = -1; // Reset the highlighted index + selectedId = null; // Reset the selected ID since input has changed + + // Uncomment the following lines if you want to hide suggestions when input is empty + /* + if (query === '') { + autocompleteList.style.display = 'none'; + return; + } + */ + + // Filter data based on the query + const filteredData = allPrompts.filter(item => + item.label.toLowerCase().includes(query) + ); + taLog.log("filteredData: " + JSON.stringify(filteredData)); + + if (filteredData.length === 0) { + autocompleteList.style.display = 'none'; + _spacer_div.style.display = 'none'; + return; + } + + // Prepend numbers to the first 10 items + Array.from(filteredData).slice(0, 10).forEach((item, index) => { + const number = (index < 9) ? (index + 1).toString() : '0'; + // Check if the number is already prepended to avoid duplication + if (!item.numberPrepended) { + item.label = `${number}. ${item.label}`; + item.numberPrepended = 'true'; // Mark as prepended + } + }); + + // console.log(">>>>>>>>>>>>> filteredData: " + JSON.stringify(filteredData)); + + // Create a div for each filtered result + filteredData.forEach(item => { + const itemDiv = document.createElement('div'); + itemDiv.classList.add('mzta_autocomplete-item'); + itemDiv.textContent = item.label; + itemDiv.setAttribute('data-id', item.id); + + // Add a mousedown event to select the item + itemDiv.addEventListener('mousedown', function(e) { // Use mousedown instead of click + e.preventDefault(); // Prevents the input from losing focus + input.value = item.label; + selectedId = item.id; // Store the selected item's ID + taLog.log('mousedown selectedId:', selectedId); + autocompleteList.style.display = 'none'; + _spacer_div.style.display = 'none'; + sendPrompt(selectedId, tabId); + }); + + // Add a select_prompt event to select the item + itemDiv.addEventListener('select_prompt', function(e) { // Use select_prompt instead of click + e.preventDefault(); // Prevents the input from losing focus + input.value = item.label; + selectedId = item.id; // Store the selected item's ID + // console.log('>>>>>>>>>>>>> select_prompt selectedId:', selectedId); + autocompleteList.style.display = 'none'; + _spacer_div.style.display = 'none'; + if(menuSendImmediately){ + sendPrompt(selectedId, tabId); + } + }); + + autocompleteList.appendChild(itemDiv); + }); + + autocompleteListLoading.style.display = 'none'; + autocompleteList.style.display = 'block'; + _spacer_div.style.display = 'block'; + }); + + // Add a keydown event listener to handle arrow navigation and selection + input.addEventListener('keydown', function (e) { + + const items = autocompleteList.getElementsByClassName('mzta_autocomplete-item'); + if ((autocompleteList.style.display === 'none' || items.length === 0) + && (e.key !== 'Enter') + && !['1','2','3','4','5','6','7','8','9','0'].includes(e.key)) + { + return; // Do nothing if the autocomplete list is not visible + } + + // Handle number key presses (1-9,0) to select the corresponding item directly + if (['1','2','3','4','5','6','7','8','9','0'].includes(e.key)) { + // Map '1' to index 0, '2' to 1, ..., '9' to 8, '0' to 9 + const numIndex = (e.key === '0') ? 9 : parseInt(e.key, 10) - 1; + if (items[numIndex]) { + e.preventDefault(); // Prevent any default behavior + // Dispatch a select_prompt event to simulate a click/select action + items[numIndex].dispatchEvent(new Event('select_prompt')); + return; // Exit after handling the number key + } + } + + if (e.key === 'ArrowDown') { + // Navigate down the list + currentFocus++; + if (currentFocus >= items.length) currentFocus = 0; // Wrap to the first item + addActive(items); + e.preventDefault(); // Prevent cursor from moving to the end + } else if (e.key === 'ArrowUp') { + // Navigate up the list + currentFocus--; + if (currentFocus < 0) currentFocus = items.length - 1; // Wrap to the last item + addActive(items); + e.preventDefault(); // Prevent cursor from moving to the start + } else if (e.key === 'Enter') { + // console.log(">>>>>>>>>>>>>> keydown == enter selectedId: " + selectedId); + if (selectedId) { + // If an item is already selected, call sendPrompt with the selected ID + e.preventDefault(); + sendPrompt(selectedId, tabId); // Call your sendPrompt function + //banner.remove(); // Remove the banner after sending the prompt + } else { + // If no item is selected yet, select the highlighted item + // Select the highlighted item, or the first item if none is highlighted + e.preventDefault(); // Prevent form submission if inside a form + if (currentFocus > -1) { + if (items[currentFocus]) { + items[currentFocus].dispatchEvent(new Event('select_prompt')); // Trigger the select_prompt event + } + } else if (items.length > 0) { + // If no item is highlighted, select the first item + items[0].dispatchEvent(new Event('select_prompt')); + } + } + } + }); + + // Function to add the "active" class to the current item + function addActive(items) { + removeActive(items); // Remove the "active" class from all items + if (currentFocus >= items.length) currentFocus = 0; + if (currentFocus < 0) currentFocus = items.length - 1; + items[currentFocus].classList.add('mzta_autocomplete-item-active'); // Add "active" class to the current item + // Ensure the active item is visible within the scrollable list + items[currentFocus].scrollIntoView({ + behavior: 'auto', // You can change to 'smooth' if you prefer smooth scrolling + block: 'nearest', // Align the item to the nearest edge of the visible area + }); + } + + // Function to remove the "active" class from all items + function removeActive(items) { + for (let i = 0; i < items.length; i++) { + items[i].classList.remove('mzta_autocomplete-item-active'); + } + } + +document.body.insertBefore(banner, document.body.firstChild); + setTimeout(() => { + input.dispatchEvent(new InputEvent('input', { bubbles: true })); + input.focus(); + }, 100); +} + + +function sendPrompt(prompt_id, tabId){ + taLog.log("sendPrompt: " + prompt_id); + browser.runtime.sendMessage({command: "shortcut_do_prompt", tabId: tabId, promptId: prompt_id}); +} + +function filterPromptsForTab(prompts_data, filtering){ + // If filtering is 0, return the original array without any filters (btw it should not happen) + if (filtering === 0) { + return prompts_data; + } + + // Define the types to include based on the value of filtering + let allowedTypes; + if (filtering === 1) { + allowedTypes = ["0", "1"]; + } else if (filtering === 2) { + allowedTypes = ["0", "2"]; + } else { + // If filtering has an unexpected value, return the original data + return prompts_data; + } + + // Filter the array based on the allowed types + return prompts_data.filter(prompt => allowedTypes.includes(prompt.type)); +} \ No newline at end of file