diff --git a/_locales/en/messages.json b/_locales/en/messages.json index bfc1d731..e2abc0c0 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1869,6 +1869,24 @@ "message": "You have denied the optional permission needed to fetch models for this integration.", "description": "" }, + "prefs_OptionText_auto_summary": { + "message": "Enable automatic AI summarization for message previews" + }, + "prefs_OptionText_auto_summary_Info": { + "message": "If checked, ThunderAI will automatically generate and display AI summaries above email messages when they are opened. Note that this means all messages you preview will immediately be sent to the configured AI service." + }, + "auto_summary_title": { + "message": "ThunderAI Summary" + }, + "auto_summary_generating": { + "message": "Generating AI summary..." + }, + "auto_summary_failed": { + "message": "Failed to generate AI summary. Please confirm your settings and try again." + }, + "auto_summary_prompt": { + "message": "Please provide a concise summary of the following email message. The summary should be 3-5 sentences maximum and capture the main points:\n\n" + }, "customPrompts_export_include_api_settings": { "message": "Do you want to include the API settings in the export? Be aware that also the API Key will be saved in the file!", "description": "" @@ -1897,6 +1915,50 @@ "message": "Spam check in progress...", "description": "" }, + "prefs_OptionText_summarize_auto": { + "message": "Auto-summarize messages", + "description": "" + }, + "prefs_OptionText_summarize_auto_disabled": { + "message": "Disabled", + "description": "" + }, + "prefs_OptionText_summarize_auto_manual": { + "message": "Show summary button", + "description": "" + }, + "prefs_OptionText_summarize_auto_automatic": { + "message": "Generate automatically", + "description": "" + }, + "prefs_OptionText_summarize_auto_Info": { + "message": "Choose whether to automatically generate summaries when viewing messages. Requires an API-based connection (not ChatGPT Web).", + "description": "" + }, + "summarize_title": { + "message": "Summary", + "description": "" + }, + "summarize_generating": { + "message": "Generating summary...", + "description": "" + }, + "summarize_error": { + "message": "Failed to generate summary", + "description": "" + }, + "summarize_click_to_generate": { + "message": "Click here to generate a summary", + "description": "" + }, + "summarize_chatgpt_web_not_supported": { + "message": "Auto-summary requires an API-based connection. Please configure an API connection in ThunderAI settings.", + "description": "" + }, + "summarize_refresh": { + "message": "Refresh summary", + "description": "" + }, "antispam_by": { "message": "Antispam by", "description": "" @@ -1909,4 +1971,4 @@ "message": "Click here! Try ThunderStats!", "description": "" } -} +} \ No newline at end of file diff --git a/js/mzta-compose-script.js b/js/mzta-compose-script.js index 7dfd4168..b5751a2c 100644 --- a/js/mzta-compose-script.js +++ b/js/mzta-compose-script.js @@ -719,6 +719,146 @@ switch (message.command) { document.body.insertBefore(container, document.body.firstChild); return Promise.resolve(true); + case "showSummary": + const generatingBanner = document.getElementById('mzta-summary-generating'); + if(generatingBanner) generatingBanner.remove(); + + const triggerBtn = document.getElementById('mzta-summary-trigger'); + if(triggerBtn) triggerBtn.remove(); + + const summaryBanner = document.getElementById('mzta-summary-banner'); + if(summaryBanner) summaryBanner.remove(); + + const summaryData = message.data; + const summaryContainer = document.createElement('div'); + summaryContainer.id = 'mzta-summary-banner'; + + const isDarkSummary = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + + let bgColorSummary = isDarkSummary ? '#2a2a2a' : '#f0f0f0'; + let textColorSummary = isDarkSummary ? '#e0e0e0' : '#333'; + let borderColorSummary = isDarkSummary ? '#444' : '#ddd'; + let titleColor = isDarkSummary ? '#ff6b6b' : '#d70022'; + + if (summaryData.error) { + bgColorSummary = isDarkSummary ? '#3a1a1a' : '#f7e6e6'; + textColorSummary = isDarkSummary ? '#ffcccc' : '#660000'; + borderColorSummary = '#660000'; + titleColor = isDarkSummary ? '#ffcccc' : '#660000'; + } + + summaryContainer.className = 'thunderai-summary-pane'; + summaryContainer.style.cssText = `background-color: ${bgColorSummary}; color: ${textColorSummary}; padding: 0.5rem; margin-bottom: 1rem; border-radius: 4px; border: 1px solid ${borderColorSummary}; font-family: system-ui, -apple-system, sans-serif; font-size: 14px;`; + + const summaryHeader = document.createElement('div'); + summaryHeader.style.cssText = `display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;`; + + const summaryTitle = document.createElement('div'); + summaryTitle.className = 'thunderai-summary-title'; + summaryTitle.textContent = browser.i18n.getMessage("summarize_title"); + summaryTitle.style.cssText = `font-weight: bold; font-size: 14px; color: ${titleColor};`; + + const refreshBtn = document.createElement('span'); + refreshBtn.textContent = '↻'; + refreshBtn.title = browser.i18n.getMessage("summarize_refresh") || 'Refresh summary'; + refreshBtn.style.cssText = `cursor: pointer; opacity: 0.6; font-size: 16px; transition: opacity 0.2s;`; + refreshBtn.onmouseover = () => refreshBtn.style.opacity = '1'; + refreshBtn.onmouseout = () => refreshBtn.style.opacity = '0.6'; + refreshBtn.onclick = async () => { + refreshBtn.onclick = null; + refreshBtn.style.opacity = '0.6'; + browser.runtime.sendMessage({ + command: "refreshSummary", + headerMessageId: summaryData.headerMessageId + }); + }; + + summaryHeader.appendChild(summaryTitle); + summaryHeader.appendChild(refreshBtn); + summaryContainer.appendChild(summaryHeader); + + const summaryText = document.createElement('div'); + summaryText.className = 'thunderai-summary-content'; + if (summaryData.error) { + summaryText.textContent = summaryData.message || browser.i18n.getMessage("summarize_error"); + } else { + summaryText.textContent = summaryData.summary; + } + summaryText.style.cssText = `font-size: 14px; line-height: 1.4;`; + + summaryContainer.appendChild(summaryText); + + document.body.insertBefore(summaryContainer, document.body.firstChild); + return Promise.resolve(true); + + case "showSummaryGenerating": + const existingGenerating = document.getElementById('mzta-summary-generating'); + if(existingGenerating) return Promise.resolve(true); + + const existingSummary = document.getElementById('mzta-summary-banner'); + if(existingSummary) existingSummary.remove(); + + const existingTrigger = document.getElementById('mzta-summary-trigger'); + if(existingTrigger) existingTrigger.remove(); + + const isDarkGen = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + + let bgColorGen = isDarkGen ? '#2a2a2a' : '#f0f0f0'; + let textColorGen = isDarkGen ? '#e0e0e0' : '#333'; + let borderColorGen = isDarkGen ? '#444' : '#ddd'; + let titleColorGen = isDarkGen ? '#ff6b6b' : '#d70022'; + + const generatingContainer = document.createElement('div'); + generatingContainer.id = 'mzta-summary-generating'; + generatingContainer.className = 'thunderai-summary-pane'; + generatingContainer.style.cssText = `background-color: ${bgColorGen}; color: ${textColorGen}; padding: 0.5rem; margin-bottom: 1rem; border-radius: 4px; border: 1px solid ${borderColorGen}; font-family: system-ui, -apple-system, sans-serif; font-size: 14px;`; + + const generatingTitle = document.createElement('div'); + generatingTitle.className = 'thunderai-summary-title'; + generatingTitle.textContent = browser.i18n.getMessage("summarize_generating"); + generatingTitle.style.cssText = `font-weight: bold; font-size: 14px; margin-bottom: 0.5rem; color: ${titleColorGen};`; + + generatingContainer.appendChild(generatingTitle); + + document.body.insertBefore(generatingContainer, document.body.firstChild); + return Promise.resolve(true); + + case "showSummaryButton": + const existingButton = document.getElementById('mzta-summary-trigger'); + if(existingButton) return Promise.resolve(true); + + const isDarkBtn = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + + let bgColorBtn = isDarkBtn ? '#2a2a2a' : '#f0f0f0'; + let textColorBtn = isDarkBtn ? '#e0e0e0' : '#333'; + let borderColorBtn = isDarkBtn ? '#444' : '#ddd'; + let titleColorBtn = isDarkBtn ? '#ff6b6b' : '#d70022'; + + const triggerContainer = document.createElement('div'); + triggerContainer.id = 'mzta-summary-trigger'; + triggerContainer.className = 'thunderai-summary-pane'; + triggerContainer.style.cssText = `background-color: ${bgColorBtn}; color: ${textColorBtn}; padding: 0.5rem; margin-bottom: 1rem; border-radius: 4px; border: 1px solid ${borderColorBtn}; cursor: pointer; font-family: system-ui, -apple-system, sans-serif; font-size: 14px;`; + + const triggerText = document.createElement('div'); + triggerText.className = 'thunderai-summary-title'; + triggerText.textContent = browser.i18n.getMessage("summarize_click_to_generate"); + triggerText.style.cssText = `font-weight: bold; font-size: 14px; margin-bottom: 0; color: ${titleColorBtn};`; + + triggerContainer.appendChild(triggerText); + triggerContainer.onclick = async () => { + triggerContainer.onclick = null; + triggerContainer.id = 'mzta-summary-generating'; + triggerContainer.style.cursor = 'default'; + triggerText.textContent = browser.i18n.getMessage("summarize_generating"); + browser.runtime.sendMessage({ + command: "triggerSummaryGeneration", + headerMessageId: message.headerMessageId + }); + }; + + document.body.insertBefore(triggerContainer, document.body.firstChild); + return Promise.resolve(true); + default: // do nothing return Promise.resolve(false); @@ -726,4 +866,5 @@ switch (message.command) { } }); -browser.runtime.sendMessage({ command: "checkSpamReport" }); \ No newline at end of file +browser.runtime.sendMessage({ command: "checkSpamReport" }); +browser.runtime.sendMessage({ command: "initSummary" }); \ No newline at end of file diff --git a/js/mzta-summary-cache.js b/js/mzta-summary-cache.js new file mode 100644 index 00000000..cacc2f02 --- /dev/null +++ b/js/mzta-summary-cache.js @@ -0,0 +1,115 @@ +/* + * ThunderAI [https://micz.it/thunderbird-addon-thunderai/] + * Copyright (C) 2024 - 2026 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 taSummaryCache = { + logger: console, + _data_prefix: 'mzta-summary-', + _processing_prefix: 'mzta-summary-processing-', + _max_summaries: 100, + + async setProcessing(data_id) { + const key = this._processing_prefix + data_id; + await browser.storage.session.set({ [key]: true }); + }, + + async isProcessing(data_id) { + const key = this._processing_prefix + data_id; + let output = await browser.storage.session.get(key); + return output[key] || false; + }, + + async saveSummary(data, data_id) { + const key = this._data_prefix + data_id; + await browser.storage.session.set({ [key]: data }); + await browser.storage.session.remove(this._processing_prefix + data_id); + }, + + async saveError(data_id, error_message) { + let data = { + error: true, + message: error_message, + summary_date: new Date(), + headerMessageId: data_id + }; + await this.saveSummary(data, data_id); + return data; + }, + + async loadSummary(data_id) { + const key = this._data_prefix + data_id; + let output = await browser.storage.session.get(key); + return output[key] || null; + }, + + async removeSummary(data_id) { + const key = this._data_prefix + data_id; + await browser.storage.session.remove(key); + await browser.storage.session.remove(this._processing_prefix + data_id); + }, + + async getAllSummaries() { + let allData = await browser.storage.session.get(null); + let summaryData = {}; + + for (const [key, value] of Object.entries(allData)) { + if (key.startsWith(this._data_prefix)) { + summaryData[key.replace(this._data_prefix, '')] = value; + } + } + + return summaryData; + }, + + async clearSummaries() { + let allData = await browser.storage.session.get(null); + let keysToDelete = Object.keys(allData).filter(key => key.startsWith(this._data_prefix) || key.startsWith(this._processing_prefix)); + + for (let key of keysToDelete) { + await browser.storage.session.remove(key); + } + }, + + async truncSummaries() { + let data = await this.getAllSummaries(); + let sortedData = this.sortSummariesByDate(data); + let keys = Object.keys(sortedData); + + if (keys.length > this._max_summaries) { + for (let i = this._max_summaries; i < keys.length; i++) { + await browser.storage.session.remove(this._data_prefix + keys[i]); + } + } + }, + + sortSummariesByDate(data) { + if (!data) return {}; + const summaryKeys = Object.keys(data); + summaryKeys.sort((a, b) => { + const dateA = new Date(data[a].summary_date); + const dateB = new Date(data[b].summary_date); + return dateB - dateA; + }); + + let sortedSummaries = {}; + summaryKeys.forEach((key) => { + sortedSummaries[key] = data[key]; + }); + + return sortedSummaries; + } +}; \ No newline at end of file diff --git a/mzta-background.js b/mzta-background.js index 18cfea21..47f192ae 100644 --- a/mzta-background.js +++ b/mzta-background.js @@ -63,6 +63,7 @@ import { getSpecialPrompts } from './js/mzta-prompts.js'; import { taSpamReport } from './js/mzta-spamreport.js'; +import { taSummaryCache } from './js/mzta-summary-cache.js'; import { taWorkingStatus } from './js/mzta-working-status.js'; import { addTags_getExclusionList, @@ -110,10 +111,10 @@ browser.composeScripts.register({ // Register the message display script for all newly opened message tabs. messenger.messageDisplayScripts.register({ - js: [{ file: "js/mzta-compose-script.js" }], + js: [{ file: "js/mzta-compose-script.js" }] }); -// Inject script and CSS in all already open message tabs. +// Inject script in all already open message tabs. let openTabs = await messenger.tabs.query(); let messageTabs = openTabs.filter( tab => ["mail", "messageDisplay"].includes(tab.type) @@ -234,6 +235,59 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => { // handler function. if (message && message.hasOwnProperty("command")){ switch (message.command) { + case 'initSummary': + async function _initSummary() { + try { + let tabId = sender.tab.id; + let prefs = await browser.storage.sync.get({ summarize_auto: 0 }); + if (prefs.summarize_auto === 0) return; + + let message = await browser.messageDisplay.getDisplayedMessage(tabId); + if (!message) return; + + let cachedSummary = await taSummaryCache.loadSummary(message.headerMessageId); + if (cachedSummary && !cachedSummary.error) { + browser.tabs.sendMessage(tabId, { command: "showSummary", data: cachedSummary }); + return; + } + + if (await taSummaryCache.isProcessing(message.headerMessageId)) { + browser.tabs.sendMessage(tabId, { command: "showSummaryGenerating" }); + return; + } + + if (prefs.summarize_auto === 1) { + browser.tabs.sendMessage(tabId, { command: "showSummaryButton", headerMessageId: message.headerMessageId }); + } else if (prefs.summarize_auto === 2) { + _generateSummaryForMessage(message.headerMessageId, tabId); + } + } catch (e) { + taLog.error("Error in initSummary: " + e); + } + } + _initSummary(); + break; + case 'triggerSummaryGeneration': + async function _triggerSummaryGeneration(message) { + let tabId = sender.tab.id; + await _generateSummaryForMessage(message.headerMessageId, tabId); + } + _triggerSummaryGeneration(message); + break; + case 'generate_summary': + async function _generate_summary(message) { + await _generateSummaryForMessage(message.headerMessageId, message.tabId); + } + _generate_summary(message); + break; + case 'refreshSummary': + async function _refreshSummary(message) { + let tabId = sender.tab.id; + await taSummaryCache.removeSummary(message.headerMessageId); + await _generateSummaryForMessage(message.headerMessageId, tabId); + } + _refreshSummary(message); + break; // case 'chatgpt_open': // openChatGPT(message.prompt,message.action,message.tabId); // return true; @@ -387,10 +441,86 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => { break; } } - // Return false if the message was not handled by this listener. return false; }); +async function _generateSummaryForMessage(headerMessageId, tabId) { + try { + let prefs = await browser.storage.sync.get({ + connection_type: prefs_default.connection_type, + do_debug: prefs_default.do_debug, + default_chatgpt_lang: prefs_default.default_chatgpt_lang, + ...getDynamicSettingsDefaults(['use_specific_integration', 'connection_type']) + }); + + let cachedSummary = await taSummaryCache.loadSummary(headerMessageId); + if (cachedSummary && !cachedSummary.error) { + browser.tabs.sendMessage(tabId, { command: "showSummary", data: cachedSummary }); + return; + } + + if (await taSummaryCache.isProcessing(headerMessageId)) { + browser.tabs.sendMessage(tabId, { command: "showSummaryGenerating" }); + return; + } + + await taSummaryCache.setProcessing(headerMessageId); + browser.tabs.sendMessage(tabId, { command: "showSummaryGenerating" }); + + const messageResult = await browser.messages.query({ headerMessageId: headerMessageId }); + if (!messageResult || messageResult.messages.length === 0) { + await taSummaryCache.saveError(headerMessageId, "Message not found"); + browser.tabs.sendMessage(tabId, { command: "showSummary", data: { error: true, message: "Message not found" } }); + return; + } + + const fullMessage = await browser.messages.getFull(messageResult.messages[0].id); + const mailBody = getMailBody(fullMessage); + let bodyText = htmlBodyToPlainText(mailBody.html); + if (bodyText.length === 0) { + bodyText = mailBody.text.replace(/\s+/g, ' ').trim(); + } + + const promptText = browser.i18n.getMessage('auto_summary_prompt') + bodyText; + + const connectionType = getConnectionType(prefs, {}, 'summarize'); + + if (connectionType === 'chatgpt_web') { + const errorMsg = browser.i18n.getMessage('summarize_chatgpt_web_not_supported'); + await taSummaryCache.saveError(headerMessageId, errorMsg); + browser.tabs.sendMessage(tabId, { command: "showSummary", data: { error: true, message: errorMsg } }); + return; + } + + const cmd = new mzta_specialCommand({ + prompt: promptText, + llm: connectionType, + do_debug: prefs.do_debug, + config: {} + }); + + await cmd.initWorker(); + const aiResponse = await cmd.sendPrompt(); + let cleanedSummary = aiResponse.replace(/```[\s\S]*?```/g, ''); + cleanedSummary = cleanedSummary.replace(/[\*#_~`]/g, ''); + cleanedSummary = cleanedSummary.replace(/\s+/g, ' ').trim(); + cleanedSummary = cleanedSummary.replace(/^Summary:\s*/i, ''); + + const summaryData = { + summary: cleanedSummary, + summary_date: new Date(), + headerMessageId: headerMessageId + }; + await taSummaryCache.saveSummary(summaryData, headerMessageId); + browser.tabs.sendMessage(tabId, { command: "showSummary", data: summaryData }); + + } catch (error) { + console.error("[ThunderAI] Error generating summary:", error); + await taSummaryCache.saveError(headerMessageId, error.message || String(error)); + browser.tabs.sendMessage(tabId, { command: "showSummary", data: { error: true, message: error.message || "Failed to generate summary" } }); + } +} + // Listen for messages from ThunderAI-Sparks browser.runtime.onMessageExternal.addListener((message, sender, sendResponse) => { switch (message.action) { @@ -1343,4 +1473,6 @@ async function processEmails(args) { taWorkingStatus.stopWorking(); } -browser.messages.onNewMailReceived.addListener(newEmailListener, !prefs_init.add_tags_auto_only_inbox); \ No newline at end of file + + +browser.messages.onNewMailReceived.addListener(newEmailListener, !prefs_init.add_tags_auto_only_inbox); diff --git a/options/mzta-options-default.js b/options/mzta-options-default.js index f2b5eb83..a961db22 100644 --- a/options/mzta-options-default.js +++ b/options/mzta-options-default.js @@ -134,6 +134,7 @@ export const prefs_default = { spamfilter: false, spamfilter_threshold: 70, spamfilter_enabled_accounts: [], + summarize_auto: 0, // 0: disabled, 1: manual button, 2: automatic spamfilter_show_msg_panel: true, summarize: false, ...generated_prefs diff --git a/pages/summarize/mzta-summarize.html b/pages/summarize/mzta-summarize.html index a8db111d..ddb5cf41 100644 --- a/pages/summarize/mzta-summarize.html +++ b/pages/summarize/mzta-summarize.html @@ -26,6 +26,19 @@

__MSG_Summarize_PageTitle__

+ + __MSG_prefs_OptionText_summarize_auto__ + + + + diff --git a/pages/summarize/mzta-summarize.js b/pages/summarize/mzta-summarize.js index b41d9084..5ecf11dd 100644 --- a/pages/summarize/mzta-summarize.js +++ b/pages/summarize/mzta-summarize.js @@ -194,8 +194,11 @@ function saveOptions(e) { options[element.id] = element.value.trim(); break; case 'select-one': - // console.log(">>>>>>>>>> Saving option [select-one]: " + element.id + " = " + element.value); - options[element.id] = element.value; + if (element.id === 'summarize_auto') { + options[element.id] = parseInt(element.value, 10); + } else { + options[element.id] = element.value; + } break; case 'textarea': options[element.id] = normalizeStringList(element.value); @@ -231,10 +234,13 @@ async function restoreOptions() { break; default: if (element.tagName === 'SELECT') { - let default_select_value = ''; - const restoreValue = result[element.id] || default_select_value; + let default_select_value = 0; + if (element.id === 'summarize_auto') { + default_select_value = prefs_default.summarize_auto; + } + const restoreValue = result[element.id] ?? default_select_value; // Check if option exists - let optionExists = Array.from(element.options).some(opt => opt.value === restoreValue); + let optionExists = Array.from(element.options).some(opt => opt.value === String(restoreValue)); // If it doesn't exist and restoreValue is not empty, create it if (!optionExists && restoreValue !== '') { let newOption = new Option(restoreValue, restoreValue); @@ -243,7 +249,7 @@ async function restoreOptions() { // Set value element.value = restoreValue; if (element.value === '') { - element.selectedIndex = -1; + element.selectedIndex = 0; } if (element.tomselect) { element.tomselect.setValue(element.value, true);