Merge branch 'summary_on_message_issue580' of github.com:micz/ThunderAI into summary_on_message_issue580

This commit is contained in:
Mic 2026-03-27 22:13:07 +01:00
commit 29ed9e6df6
13 changed files with 207 additions and 62 deletions

View file

@ -5,6 +5,7 @@
<h2>Version 4.1.0 - ??/??/2026</h2>
<ul>
<li>Antispam information are now permanently saved for each message [<a href="https://github.com/micz/ThunderAI/issues/675">#675</a>].</li>
<li>...</li>
</ul>
<h2>Version 4.0.3 - 20/03/2026</h2>

View file

@ -235,6 +235,10 @@
"message": "Close",
"description": ""
},
"webchat_save_as_summary": {
"message": "Save as Summary",
"description": "Button label in the webchat window to save the AI response as a message summary"
},
"chatgpt_textarea_not_found_error": {
"message": "It seems that the ChatGPT page is taking too long to load. If it finishes loading, click the button on the right. If the problem persists, please check the service status.",
"description": ""
@ -1388,17 +1392,13 @@
"description": ""
},
"spamfilter_no_reports": {
"message": "No messages have screened for spam yet. Here you'll find a list of the last 100 spam reports for the current session only.",
"message": "No messages have screened for spam yet. Here you'll find a list of the last 100 spam reports.",
"description": ""
},
"SpamReport_Title": {
"message": "Spam Filter Reports",
"description": ""
},
"SpamReport_infoline": {
"message": "This information is saved only for the current session.",
"description": ""
},
"Date": {
"message": "Date",
"description": ""

View file

@ -458,6 +458,29 @@ class MessagesArea extends HTMLElement {
selectionInfo.style.display = "block"; // show selection info
}
// Save as Summary button (only shown for summary webchat sessions)
if(promptData.prompt_info?.headerMessageId) {
const saveSummaryButton = document.createElement('button');
saveSummaryButton.textContent = browser.i18n.getMessage("webchat_save_as_summary");
saveSummaryButton.classList.add('action_btn');
saveSummaryButton.addEventListener('click', async () => {
let finalText = removeAloneBRs(fullTextHTMLAtAssignment);
const selectedHTML = this.getCurrentSelectionHTML();
if(selectedHTML != "") {
finalText = removeAloneBRs(selectedHTML);
}
await browser.runtime.sendMessage({
command: "chatgpt_saveSummary",
text: finalText,
headerMessageId: promptData.prompt_info.headerMessageId,
tabId: promptData.prompt_info.summaryTabId || promptData.tabId,
});
browser.runtime.sendMessage({command: "chatgpt_close", window_id: (await browser.windows.getCurrent()).id});
});
actionButtons.appendChild(saveSummaryButton);
selectionInfo.style.display = "block";
}
// diff viewer button
if(promptData.prompt_info?.use_diff_viewer == "1") {
const diffvButton = document.createElement('button');

View file

@ -95,7 +95,7 @@ These are generated programmatically at the bottom of `mzta-options-default.js`
| `spamfilter_enabled_accounts` | `[]` | Accounts where spam filter is active |
| `spamfilter_show_msg_panel` | `true` | Show info panel on spam detection |
| `summarize` | `false` | Enable email summarization |
| `summarize_auto` | `0` | Auto-summarize mode: `0` = disabled, `1` = manual (show "click to generate" button), `2` = automatic (generate on message open) |
| `summarize_auto` | `1` | Auto-summarize mode: `0` = disabled, `1` = manual (show "click to generate" button), `2` = automatic (generate on message open) |
| `summarize_display_mode` | `'inline'` | Where to display summaries: `'inline'` = message pane banner, `'webchat'` = AI chat window. Note: `summarize_auto = 2` always uses inline regardless of this setting. |
| `summarize_max_display_length` | `0` | Maximum characters shown in inline summary before truncation. `0` = no limit (show full text). When set, text is truncated at a word boundary and a "See more"/"See less" toggle link is shown. |

View file

@ -959,8 +959,23 @@ switch (message.command) {
const summaryText = document.createElement('div');
summaryText.className = 'thunderai-summary-content';
const hasHtml = !!summaryData.summary_html;
// Helper to set summary content using DOMParser (innerHTML is blocked in Thunderbird content scripts)
function setSummaryHtml(element, html) {
element.textContent = '';
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
while (doc.body.firstChild) {
element.appendChild(doc.body.firstChild);
}
element.querySelectorAll('p').forEach(p => { p.style.marginBlockStart = '0'; });
}
if (summaryData.error) {
summaryText.textContent = summaryData.message || browser.i18n.getMessage("summarize_error");
} else if (hasHtml) {
setSummaryHtml(summaryText, summaryData.summary_html);
} else {
summaryText.textContent = summaryData.summary;
}
@ -971,58 +986,94 @@ switch (message.command) {
const maxLen = summaryData.maxDisplayLength || 0;
const fullText = summaryData.summary;
if (!summaryData.error && maxLen > 0 && fullText && fullText.length > maxLen) {
let cutPos = fullText.lastIndexOf(' ', maxLen);
if (cutPos <= 0) cutPos = maxLen;
const truncated = fullText.substring(0, cutPos) + '\u2026';
summaryText.textContent = truncated;
// Set up animated expand/collapse via max-height transition
summaryText.style.overflow = 'hidden';
summaryText.style.transition = 'max-height 0.2s ease';
// Measure truncated height after layout
requestAnimationFrame(() => {
const collapsedHeight = summaryText.scrollHeight;
summaryText.style.maxHeight = collapsedHeight + 'px';
});
if (!hasHtml) {
// Plain text: truncate by character position
let cutPos = fullText.lastIndexOf(' ', maxLen);
if (cutPos <= 0) cutPos = maxLen;
const truncated = fullText.substring(0, cutPos) + '\u2026';
summaryText.textContent = truncated;
const toggleLink = document.createElement('a');
toggleLink.textContent = browser.i18n.getMessage("summarize_see_more") || "See more";
toggleLink.href = '#';
toggleLink.style.cssText = 'display: inline-block; margin-top: 4px; font-size: 13px; color: ' +
(isDarkSummary ? '#6db3f2' : '#1a5fa8') + '; cursor: pointer; text-decoration: underline;';
let expanded = false;
toggleLink.addEventListener('click', (e) => {
e.preventDefault();
if (!expanded) {
// Expand: set full text, measure, animate to full height
summaryText.textContent = fullText;
const fullHeight = summaryText.scrollHeight;
summaryText.style.maxHeight = fullHeight + 'px';
toggleLink.textContent = browser.i18n.getMessage("summarize_see_less") || "See less";
} else {
// Collapse: measure current truncated height, then animate down
summaryText.textContent = truncated;
// Force layout to get the target height before animating
// Measure truncated height after layout
requestAnimationFrame(() => {
const collapsedHeight = summaryText.scrollHeight;
summaryText.textContent = fullText;
// Set explicit current height so transition has a starting point
summaryText.style.maxHeight = summaryText.scrollHeight + 'px';
requestAnimationFrame(() => {
summaryText.style.maxHeight = collapsedHeight + 'px';
});
// Swap text after transition ends
summaryText.addEventListener('transitionend', function handler() {
summaryText.removeEventListener('transitionend', handler);
summaryText.textContent = truncated;
});
toggleLink.textContent = browser.i18n.getMessage("summarize_see_more") || "See more";
}
expanded = !expanded;
});
summaryText.style.maxHeight = collapsedHeight + 'px';
});
summaryTextWrapper.appendChild(toggleLink);
const toggleLink = document.createElement('a');
toggleLink.textContent = browser.i18n.getMessage("summarize_see_more") || "See more";
toggleLink.href = '#';
toggleLink.style.cssText = 'display: inline-block; margin-top: 4px; font-size: 13px; color: ' +
(isDarkSummary ? '#6db3f2' : '#1a5fa8') + '; cursor: pointer; text-decoration: underline;';
let expanded = false;
toggleLink.addEventListener('click', (e) => {
e.preventDefault();
if (!expanded) {
// Expand: set full text, measure, animate to full height
summaryText.textContent = fullText;
const fullHeight = summaryText.scrollHeight;
summaryText.style.maxHeight = fullHeight + 'px';
toggleLink.textContent = browser.i18n.getMessage("summarize_see_less") || "See less";
} else {
// Collapse: measure current truncated height, then animate down
summaryText.textContent = truncated;
// Force layout to get the target height before animating
const collapsedHeight = summaryText.scrollHeight;
summaryText.textContent = fullText;
// Set explicit current height so transition has a starting point
summaryText.style.maxHeight = summaryText.scrollHeight + 'px';
requestAnimationFrame(() => {
summaryText.style.maxHeight = collapsedHeight + 'px';
});
// Swap text after transition ends
summaryText.addEventListener('transitionend', function handler() {
summaryText.removeEventListener('transitionend', handler);
summaryText.textContent = truncated;
});
toggleLink.textContent = browser.i18n.getMessage("summarize_see_more") || "See more";
}
expanded = !expanded;
});
summaryTextWrapper.appendChild(toggleLink);
} else {
// HTML content: use max-height to collapse, preserve full HTML
const collapsedMaxHeight = '4.2em'; // ~3 lines collapsed
summaryText.style.maxHeight = collapsedMaxHeight;
const toggleLink = document.createElement('a');
toggleLink.textContent = browser.i18n.getMessage("summarize_see_more") || "See more";
toggleLink.href = '#';
toggleLink.style.cssText = 'display: inline-block; margin-top: 4px; font-size: 13px; color: ' +
(isDarkSummary ? '#6db3f2' : '#1a5fa8') + '; cursor: pointer; text-decoration: underline;';
let expanded = false;
toggleLink.addEventListener('click', (e) => {
e.preventDefault();
if (!expanded) {
summaryText.style.maxHeight = summaryText.scrollHeight + 'px';
toggleLink.textContent = browser.i18n.getMessage("summarize_see_less") || "See less";
} else {
summaryText.style.maxHeight = collapsedMaxHeight;
toggleLink.textContent = browser.i18n.getMessage("summarize_see_more") || "See more";
}
expanded = !expanded;
});
// Only show toggle if content is actually taller than collapsed height
requestAnimationFrame(() => {
if (summaryText.scrollHeight > summaryText.clientHeight) {
summaryTextWrapper.appendChild(toggleLink);
} else {
summaryText.style.maxHeight = '';
summaryText.style.overflow = '';
}
});
}
}
const summaryBody = document.createElement('div');

View file

@ -52,6 +52,16 @@
0: Do not use the diff viewer
1: Use the diff viewer
================ DYNAMIC PROPERTIES (set at runtime via prompt_info)
headerMessageId (set by _openSummaryWebchat in mzta-background.js):
When present, the webchat UI shows a "Save as Summary" button to capture
the AI response and save it as an inline summary for the message identified
by this headerMessageId.
summaryTabId (set by _openSummaryWebchat in mzta-background.js):
The tab ID of the message display tab to update with the saved summary.
================ USER PROPERTIES
Enabled (enabled attribute):
0: Disabled

View file

@ -198,6 +198,7 @@ export class taStorage {
let now = Date.now();
record[taStorage.FIELD_SUMMARY] = {
summary: summary_data.summary,
summary_html: summary_data.summary_html || '',
error: summary_data.error || false,
message: summary_data.message || '',
summary_date: summary_data.summary_date instanceof Date

View file

@ -80,6 +80,7 @@ export class taSummaryStore {
return {
headerMessageId: data_id,
summary: summary.summary,
summary_html: summary.summary_html || '',
error: summary.error || false,
message: summary.message || '',
summary_date: new Date(summary.summary_date || summary.ts),

View file

@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<script src="api_webchat/markdown-it.min.js"></script>
<script src="mzta-background.js" type="module"></script>
</head>

View file

@ -220,7 +220,9 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => {
async function _initSummary() {
try {
let tabId = sender.tab.id;
let prefs = await browser.storage.sync.get({ summarize_auto: prefs_default.summarize_auto, summarize_display_mode: prefs_default.summarize_display_mode, summarize_max_display_length: prefs_default.summarize_max_display_length });
let prefs = await browser.storage.sync.get({ summarize: prefs_default.summarize, summarize_auto: prefs_default.summarize_auto, summarize_display_mode: prefs_default.summarize_display_mode, summarize_max_display_length: prefs_default.summarize_max_display_length });
if (!prefs.summarize) return;
let message = await browser.messageDisplay.getDisplayedMessage(tabId);
if (!message) return;
@ -282,13 +284,47 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => {
async function _refreshSummary(message) {
let tabId = sender.tab.id;
await summaryStore.removeSummary(message.headerMessageId);
await _generateSummaryForMessage(message.headerMessageId, tabId);
let prefs_refresh = await browser.storage.sync.get({ summarize_display_mode: prefs_default.summarize_display_mode });
if (prefs_refresh.summarize_display_mode === 'webchat') {
await _openSummaryWebchat(message.headerMessageId, tabId);
} else {
await _generateSummaryForMessage(message.headerMessageId, tabId);
}
}
_refreshSummary(message);
break;
case 'removeSummary':
summaryStore.removeSummary(message.headerMessageId);
break;
case 'chatgpt_saveSummary':
async function _saveSummaryFromWebchat(msg) {
try {
let summaryHtml = msg.text.trim();
let cleanedSummary = cleanSummaryText(msg.text);
const summaryData = {
summary: cleanedSummary,
summary_html: summaryHtml,
summary_date: new Date(),
headerMessageId: msg.headerMessageId
};
await summaryStore.saveSummary(summaryData, msg.headerMessageId);
let prefs_summary = await browser.storage.sync.get({
summarize_max_display_length: prefs_default.summarize_max_display_length
});
try {
browser.tabs.sendMessage(msg.tabId, {
command: "showSummary",
data: { ...summaryData, maxDisplayLength: prefs_summary.summarize_max_display_length }
});
} catch (e) {
taLog.error("Error sending showSummary to tab: " + e);
}
} catch (error) {
console.error("[ThunderAI] Error saving summary from webchat:", error);
}
}
_saveSummaryFromWebchat(message);
break;
// case 'chatgpt_open':
// openChatGPT(message.prompt,message.action,message.tabId);
// return true;
@ -458,6 +494,17 @@ messenger.runtime.onMessage.addListener((message, sender, sendResponse) => {
return false;
});
// Clean summary text by stripping HTML, markdown, and formatting artifacts.
// Used by both inline summary generation and webchat summary save.
function cleanSummaryText(text) {
let cleaned = text.replace(/<\/?[^>]+(>|$)/g, ''); // strip HTML tags
cleaned = cleaned.replace(/```[\s\S]*?```/g, '');
cleaned = cleaned.replace(/[\*#_~`]/g, '');
cleaned = cleaned.replace(/\s+/g, ' ').trim();
cleaned = cleaned.replace(/^Summary:\s*/i, '');
return cleaned;
}
async function _generateSummaryForMessage(headerMessageId, tabId) {
try {
let prefs = await browser.storage.sync.get({
@ -514,13 +561,13 @@ async function _generateSummaryForMessage(headerMessageId, tabId) {
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, '');
let cleanedSummary = cleanSummaryText(aiResponse);
const md = window.markdownit();
let summaryHtml = md.render(aiResponse);
const summaryData = {
summary: cleanedSummary,
summary_html: summaryHtml,
summary_date: new Date(),
headerMessageId: headerMessageId
};
@ -665,7 +712,17 @@ async function _openSummaryWebchat(headerMessageId, tabId) {
const curr_message = messageResult.messages[0];
const curr_message_full = await browser.messages.getFull(curr_message.id);
const connectionType = getConnectionType(await browser.storage.sync.get(prefs_default), {}, 'summarize');
if (connectionType === 'chatgpt_web') {
const errorMsg = browser.i18n.getMessage('summarize_chatgpt_web_not_supported');
await summaryStore.saveError(headerMessageId, errorMsg);
browser.tabs.sendMessage(tabId, { command: "showSummary", data: { error: true, message: errorMsg } });
return;
}
const { promptText, promptInfo } = await taPromptUtils.buildSummaryPrompt([{ message: curr_message, fullMessage: curr_message_full }]);
promptInfo.headerMessageId = headerMessageId;
promptInfo.summaryTabId = tabId;
openChatGPT(promptText, promptInfo.action, tabId, promptInfo.name, promptInfo.need_custom_text, promptInfo);
} catch (error) {

View file

@ -137,10 +137,10 @@ export const prefs_default = {
spamfilter: false,
spamfilter_threshold: 70,
spamfilter_enabled_accounts: [],
summarize_auto: 0, // 0: disabled, 1: manual button, 2: automatic
summarize: false,
summarize_auto: 1, // 0: disabled, 1: manual button, 2: automatic
summarize_display_mode: 'inline', // 'inline' or 'webchat'
summarize_max_display_length: 0, // 0 = no limit, otherwise max chars shown inline
spamfilter_show_msg_panel: true,
summarize: false,
...generated_prefs
}

View file

@ -9,6 +9,7 @@
<div id="miczRelNotes"><h1>ThunderAI Release Notes</h1>
<h2>Version 4.1.0 - ??/??/2026</h2>
<ul>
<li>Antispam information are now permanently saved for each message [<a href="https://github.com/micz/ThunderAI/issues/675">#675</a>].</li>
<li>...</li>
</ul>
<h2>Version 4.0.3 - 20/03/2026</h2>

View file

@ -66,8 +66,7 @@
<button type="button" id="accounts_deselect_all">__MSG_DeselectAll__</button>
</div>
<div id="spamfitler_reports_container">
<div><span class="section_title">__MSG_SpamReport_Title__</span>
<br><span class="infoline">__MSG_SpamReport_infoline__</span></div>
<div><span class="section_title">__MSG_SpamReport_Title__</span></div>
<table id="report_data">
<thead>
<tr>