From bcac4ad01709c85002e2f8123406bacffcf1d4cd Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sat, 19 Jul 2025 21:58:37 -0500 Subject: [PATCH 1/9] Improve debug tab layout --- options/options.html | 15 +++++++++------ options/options.js | 22 ++++++++++++++++++++-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/options/options.html b/options/options.html index 3618a20..ddc5ee0 100644 --- a/options/options.html +++ b/options/options.html @@ -154,6 +154,11 @@ Aggressive token reduction +
+ +
@@ -220,11 +225,6 @@
-
- -
@@ -290,7 +290,10 @@ Debug

-                
+ diff --git a/options/options.js b/options/options.js index 3402dcf..d881d6a 100644 --- a/options/options.js +++ b/options/options.js @@ -70,6 +70,7 @@ document.addEventListener('DOMContentLoaded', async () => { await applyTheme(themeSelect.value); const payloadDisplay = document.getElementById('payload-display'); const diffDisplay = document.getElementById('diff-display'); + const diffContainer = document.getElementById('diff-container'); let lastFullText = defaults.lastFullText || ''; let lastPromptText = defaults.lastPromptText || ''; @@ -83,7 +84,16 @@ document.addEventListener('DOMContentLoaded', async () => { dmp.Diff_EditCost = 4; const diffs = dmp.diff_main(lastFullText, lastPromptText); dmp.diff_cleanupEfficiency(diffs); - diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); + const hasDiff = diffs.some(d => d[0] !== 0); + if (hasDiff) { + diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); + diffContainer.classList.remove('is-hidden'); + } else { + diffDisplay.innerHTML = ''; + diffContainer.classList.add('is-hidden'); + } + } else { + diffContainer.classList.add('is-hidden'); } themeSelect.addEventListener('change', async () => { markDirty(); @@ -751,9 +761,17 @@ document.addEventListener('DOMContentLoaded', async () => { dmp.Diff_EditCost = 4; const diffs = dmp.diff_main(lastFullText, lastPromptText); dmp.diff_cleanupEfficiency(diffs); - diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); + const hasDiff = diffs.some(d => d[0] !== 0); + if (hasDiff) { + diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); + diffContainer.classList.remove('is-hidden'); + } else { + diffDisplay.innerHTML = ''; + diffContainer.classList.add('is-hidden'); + } } else { diffDisplay.innerHTML = ''; + diffContainer.classList.add('is-hidden'); } } } From 9cad2674e3841fbc40d237f7233fbd6784f61760 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sat, 19 Jul 2025 22:44:04 -0500 Subject: [PATCH 2/9] Capture raw message text for debug --- README.md | 2 +- background.js | 31 ++++++++++++++++++++++++++----- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 82649d0..57a0285 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ message meets a specified criterion. - **Advanced parameters** – tune generation settings like temperature, top‑p and more from the options page. - **Markdown conversion** – optionally convert HTML bodies to Markdown before sending them to the AI service. - **Debug logging** – optional colorized logs help troubleshoot interactions with the AI service. -- **Debug tab** – view the last request payload and message diff with live updates. +- **Debug tab** – view the last request payload and a diff between the unaltered message text and the final prompt. - **Light/Dark themes** – automatically match Thunderbird's appearance with optional manual override. - **Automatic rules** – create rules that tag, move, copy, forward, reply, delete, archive, mark read/unread or flag/unflag messages based on AI classification. Rules can optionally apply only to unread messages and can ignore messages outside a chosen age range. - **Rule ordering** – drag rules to prioritize them and optionally stop processing after a match. diff --git a/background.js b/background.js index 58a5de5..fc585ff 100644 --- a/background.js +++ b/background.js @@ -210,17 +210,38 @@ function collectText(part, bodyParts, attachments) { } } -function buildEmailText(full) { +function collectRawText(part, bodyParts, attachments) { + if (part.parts && part.parts.length) { + for (const p of part.parts) collectRawText(p, bodyParts, attachments); + return; + } + const ct = (part.contentType || "text/plain").toLowerCase(); + const cd = (part.headers?.["content-disposition"]?.[0] || "").toLowerCase(); + const body = String(part.body || ""); + if (cd.includes("attachment") || !ct.startsWith("text/")) { + const nameMatch = /filename\s*=\s*"?([^";]+)/i.exec(cd) || /name\s*=\s*"?([^";]+)/i.exec(part.headers?.["content-type"]?.[0] || ""); + const name = nameMatch ? nameMatch[1] : ""; + attachments.push(`${name} (${ct}, ${part.size || byteSize(body)} bytes)`); + } else if (ct.startsWith("text/html")) { + const doc = new DOMParser().parseFromString(body, 'text/html'); + bodyParts.push(doc.body.textContent || ""); + } else { + bodyParts.push(body); + } +} + +function buildEmailText(full, applyTransforms = true) { const bodyParts = []; const attachments = []; - collectText(full, bodyParts, attachments); + const collect = applyTransforms ? collectText : collectRawText; + collect(full, bodyParts, attachments); const headers = Object.entries(full.headers || {}) .map(([k, v]) => `${k}: ${v.join(' ')}`) .join('\n'); const attachInfo = `Attachments: ${attachments.length}` + (attachments.length ? "\n" + attachments.map(a => ` - ${a}`).join('\n') : ""); let combined = `${headers}\n${attachInfo}\n\n${bodyParts.join('\n')}`.trim(); - if (tokenReduction) { + if (applyTransforms && tokenReduction) { const seen = new Set(); combined = combined.split('\n').filter(l => { if (seen.has(l)) return false; @@ -228,7 +249,7 @@ function buildEmailText(full) { return true; }).join('\n'); } - return sanitizeString(combined); + return applyTransforms ? sanitizeString(combined) : combined; } function updateTimingStats(elapsed) { @@ -262,8 +283,8 @@ async function processMessage(id) { updateActionIcon(); try { const full = await messenger.messages.getFull(id); + const originalText = buildEmailText(full, false); let text = buildEmailText(full); - const originalText = text; if (tokenReduction && maxTokens > 0) { const limit = Math.floor(maxTokens * 0.9); if (text.length > limit) { From c622c07c66a6936c4c8d3bb039b1f0bba4692384 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Mon, 18 Aug 2025 19:39:35 -0500 Subject: [PATCH 3/9] Supporting v140+ After testing in Betterbird v140, updating manifest. --- manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest.json b/manifest.json index a3c9f7c..e7cb9d8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,13 +1,13 @@ { "manifest_version": 2, "name": "Sortana", - "version": "2.1.2", + "version": "2.2.0", "default_locale": "en-US", "applications": { "gecko": { "id": "ai-filter@jordanwages", "strict_min_version": "128.0", - "strict_max_version": "139.*" + "strict_max_version": "140.*" } }, "icons": { From 0f2f148b71913b89489cf72ab6bc0efc263c1064 Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Tue, 6 Jan 2026 20:45:31 -0600 Subject: [PATCH 4/9] Normalize completions endpoint base --- AGENTS.md | 5 ++++- README.md | 11 ++++++----- modules/AiClassifier.js | 41 +++++++++++++++++++++++++++++++++++++---- options/options.html | 1 + options/options.js | 18 ++++++++++++++++-- 5 files changed, 64 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 401f962..e2a1696 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,10 @@ This file provides guidelines for codex agents contributing to the Sortana proje There are currently no automated tests for this project. If you add tests in the future, specify the commands to run them here. For now, verification must happen manually in Thunderbird. Do **not** run the `ps1` build script or the SVG processing script. +## Endpoint Notes + +Sortana targets the `/v1/completions` API. The endpoint value stored in settings is a base URL; the full request URL is constructed by appending `/v1/completions` (adding a slash when needed) and defaulting to `https://` if no scheme is provided. + ## Documentation Additional documentation exists outside this repository. @@ -73,4 +77,3 @@ Toolbar and menu icons reside under `resources/img` and are provided in 16, 32 and 64 pixel variants. When changing these icons, pass a dictionary mapping the sizes to the paths in `browserAction.setIcon` or `messageDisplayAction.setIcon`. Use `resources/svg2img.ps1` to regenerate PNGs from the SVG sources. - diff --git a/README.md b/README.md index 57a0285..2f8f204 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ Sortana is an experimental Thunderbird add-on that integrates an AI-powered filter rule. It allows you to classify email messages by sending their contents to a configurable -HTTP endpoint. The endpoint should respond with JSON indicating whether the -message meets a specified criterion. +HTTP endpoint. Sortana uses the `/v1/completions` API; the options page stores a base +URL and appends `/v1/completions` when sending requests. The endpoint should respond +with JSON indicating whether the message meets a specified criterion. ## Features -- **Configurable endpoint** – set the classification service URL on the options page. +- **Configurable endpoint** – set the classification service base URL on the options page. - **Prompt templates** – choose between several model formats or provide your own custom template. - **Custom system prompts** – tailor the instructions sent to the model for more precise results. - **Persistent result caching** – classification results and reasoning are saved to disk so messages aren't re-evaluated across restarts. @@ -72,7 +73,8 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e ## Usage -1. Open the add-on's options and set the URL of your classification service. +1. Open the add-on's options and set the base URL of your classification service + (Sortana will append `/v1/completions`). 2. Use the **Classification Rules** section to add a criterion and optional actions such as tagging, moving, copying, forwarding, replying, deleting or archiving a message when it matches. Drag rules to @@ -158,4 +160,3 @@ how Thunderbird's WebExtension and experiment APIs can be extended. Their code provided invaluable guidance during development. - Icons from [cc0-icons.jonh.eu](https://cc0-icons.jonh.eu/) are used under the CC0 license. - diff --git a/modules/AiClassifier.js b/modules/AiClassifier.js index 8313654..f5a3bff 100644 --- a/modules/AiClassifier.js +++ b/modules/AiClassifier.js @@ -15,6 +15,8 @@ try { Services = undefined; } +const COMPLETIONS_PATH = "/v1/completions"; + const SYSTEM_PREFIX = `You are an email-classification assistant. Read the email below and the classification criterion provided by the user. `; @@ -28,7 +30,8 @@ Return ONLY a JSON object on a single line of the form: Do not add any other keys, text, or formatting.`; -let gEndpoint = "http://127.0.0.1:5000/v1/classify"; +let gEndpointBase = "http://127.0.0.1:5000"; +let gEndpoint = buildEndpointUrl(gEndpointBase); let gTemplateName = "openai"; let gCustomTemplate = ""; let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT; @@ -39,6 +42,28 @@ let gAiParams = Object.assign({}, DEFAULT_AI_PARAMS); let gCache = new Map(); let gCacheLoaded = false; +function normalizeEndpointBase(endpoint) { + if (typeof endpoint !== "string") { + return ""; + } + let base = endpoint.trim(); + if (!base) { + return ""; + } + base = base.replace(/\/v1\/completions\/?$/i, ""); + return base; +} + +function buildEndpointUrl(endpointBase) { + const base = normalizeEndpointBase(endpointBase); + if (!base) { + return ""; + } + const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`; + const needsSlash = withScheme.endsWith("/"); + return `${withScheme}${needsSlash ? "" : "/"}v1/completions`; +} + function sha256HexSync(str) { try { const hasher = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); @@ -158,8 +183,12 @@ function loadTemplateSync(name) { } async function setConfig(config = {}) { - if (config.endpoint) { - gEndpoint = config.endpoint; + if (typeof config.endpoint === "string") { + const base = normalizeEndpointBase(config.endpoint); + if (base) { + gEndpointBase = base; + } + gEndpoint = buildEndpointUrl(gEndpointBase); } if (config.templateName) { gTemplateName = config.templateName; @@ -187,6 +216,10 @@ async function setConfig(config = {}) { } else { gTemplateText = await loadTemplate(gTemplateName); } + if (!gEndpoint) { + gEndpoint = buildEndpointUrl(gEndpointBase); + } + aiLog(`[AiClassifier] Endpoint base set to ${gEndpointBase}`, {debug: true}); aiLog(`[AiClassifier] Endpoint set to ${gEndpoint}`, {debug: true}); aiLog(`[AiClassifier] Template set to ${gTemplateName}`, {debug: true}); } @@ -344,4 +377,4 @@ async function init() { await loadCache(); } -export { classifyText, setConfig, removeCacheEntries, clearCache, getReason, getCachedResult, buildCacheKey, getCacheSize, init }; +export { buildEndpointUrl, normalizeEndpointBase, classifyText, setConfig, removeCacheEntries, clearCache, getReason, getCachedResult, buildCacheKey, getCacheSize, init }; diff --git a/options/options.html b/options/options.html index ddc5ee0..59ebc83 100644 --- a/options/options.html +++ b/options/options.html @@ -73,6 +73,7 @@
+

diff --git a/options/options.js b/options/options.js index d881d6a..30fa5f3 100644 --- a/options/options.js +++ b/options/options.js @@ -99,7 +99,21 @@ document.addEventListener('DOMContentLoaded', async () => { markDirty(); await applyTheme(themeSelect.value); }); - document.getElementById('endpoint').value = defaults.endpoint || 'http://127.0.0.1:5000/v1/completions'; + const endpointInput = document.getElementById('endpoint'); + const endpointPreview = document.getElementById('endpoint-preview'); + const fallbackEndpoint = 'http://127.0.0.1:5000'; + const storedEndpoint = defaults.endpoint || fallbackEndpoint; + const endpointBase = AiClassifier.normalizeEndpointBase(storedEndpoint) || storedEndpoint; + endpointInput.value = endpointBase; + + function updateEndpointPreview() { + const resolved = AiClassifier.buildEndpointUrl(endpointInput.value); + endpointPreview.textContent = resolved + ? `Resolved endpoint: ${resolved}` + : 'Resolved endpoint: (invalid)'; + } + endpointInput.addEventListener('input', updateEndpointPreview); + updateEndpointPreview(); const templates = { openai: browser.i18n.getMessage('template.openai'), @@ -806,7 +820,7 @@ document.addEventListener('DOMContentLoaded', async () => { initialized = true; document.getElementById('save').addEventListener('click', async () => { - const endpoint = document.getElementById('endpoint').value; + const endpoint = endpointInput.value.trim(); const templateName = templateSelect.value; const customTemplateText = customTemplate.value; const customSystemPrompt = systemBox.value; From 2178de9a90ba4ea8c846d839f138de183c0186a2 Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Tue, 6 Jan 2026 21:07:55 -0600 Subject: [PATCH 5/9] Add bash build script --- AGENTS.md | 1 + README.md | 7 +++-- build-xpi.sh | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100755 build-xpi.sh diff --git a/AGENTS.md b/AGENTS.md index e2a1696..23080ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ This file provides guidelines for codex agents contributing to the Sortana proje - `resources/`: Images and other static files. - `prompt_templates/`: Prompt template files for the AI service. - `build-xpi.ps1`: PowerShell script to package the extension. +- `build-xpi.sh`: Bash script to package the extension. ## Coding Style diff --git a/README.md b/README.md index 2f8f204..a58a799 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ with JSON indicating whether the message meets a specified criterion. - **View reasoning** – inspect why rules matched via the Details popup. - **Cache management** – clear cached results from the context menu or options page. - **Queue & timing stats** – monitor processing time on the Maintenance tab. -- **Packaging script** – `build-xpi.ps1` builds an XPI ready for installation. +- **Packaging scripts** – `build-xpi.ps1` (PowerShell) or `build-xpi.sh` (bash) build an XPI ready for installation. - **Maintenance tab** – view rule counts, cache entries and clear cached results from the options page. ### Cache Storage @@ -65,8 +65,9 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e 1. Ensure PowerShell is available (for Windows) or adapt the script for other environments. 2. The Bulma stylesheet (v1.0.3) is already included as `options/bulma.css`. -3. Run `powershell ./build-xpi.ps1` from the repository root. The script reads - the version from `manifest.json` and creates an XPI in the `release` folder. +3. Run `powershell ./build-xpi.ps1` or `./build-xpi.sh` from the repository root. + The script reads the version from `manifest.json` and creates an XPI in the + `release` folder. 4. Install the generated XPI in Thunderbird via the Add-ons Manager. During development you can also load the directory as a temporary add-on. 5. To regenerate PNG icons from the SVG sources, run `resources/svg2img.ps1`. diff --git a/build-xpi.sh b/build-xpi.sh new file mode 100755 index 0000000..20c6e15 --- /dev/null +++ b/build-xpi.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +release_dir="$script_dir/release" +manifest="$script_dir/manifest.json" + +if [[ ! -f "$manifest" ]]; then + echo "manifest.json not found at $manifest" >&2 + exit 1 +fi + +if ! command -v zip >/dev/null 2>&1; then + echo "zip is required to build the XPI." >&2 + exit 1 +fi + +if command -v jq >/dev/null 2>&1; then + version="$(jq -r '.version // empty' "$manifest")" +else + if ! command -v python3 >/dev/null 2>&1; then + echo "python3 is required to read manifest.json without jq." >&2 + exit 1 + fi + version="$(python3 - <<'PY' +import json +import sys +with open(sys.argv[1], 'r', encoding='utf-8') as f: + data = json.load(f) +print(data.get('version', '') or '') +PY +"$manifest")" +fi + +if [[ -z "$version" ]]; then + echo "No version found in manifest.json" >&2 + exit 1 +fi + +mkdir -p "$release_dir" + +xpi_name="sortana-$version.xpi" +zip_path="$release_dir/ai-filter-$version.zip" +xpi_path="$release_dir/$xpi_name" + +rm -f "$zip_path" "$xpi_path" + +mapfile -d '' files < <( + find "$script_dir" -type f \ + ! -name '*.sln' \ + ! -name '*.ps1' \ + ! -name '*.sh' \ + ! -path "$release_dir/*" \ + ! -path "$script_dir/.vs/*" \ + ! -path "$script_dir/.git/*" \ + -printf '%P\0' +) + +if [[ ${#files[@]} -eq 0 ]]; then + echo "No files found to package." >&2 + exit 0 +fi + +for rel in "${files[@]}"; do + full="$script_dir/$rel" + size=$(stat -c '%s' "$full") + echo "Zipping: $rel <- $full ($size bytes)" +done + +( + cd "$script_dir" + printf '%s\n' "${files[@]}" | zip -q -9 -@ "$zip_path" +) + +mv -f "$zip_path" "$xpi_path" + +echo "Built XPI at: $xpi_path" From af6702bceb57fa65fda3d47c218d42b40cc83bfe Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Tue, 6 Jan 2026 21:42:57 -0600 Subject: [PATCH 6/9] Add prompt reduction badge to debug diff --- options/options.html | 5 ++- options/options.js | 88 +++++++++++++++++++++++++++----------------- 2 files changed, 59 insertions(+), 34 deletions(-) diff --git a/options/options.html b/options/options.html index 59ebc83..b118ee0 100644 --- a/options/options.html +++ b/options/options.html @@ -292,7 +292,10 @@

                 
             
diff --git a/options/options.js b/options/options.js index 30fa5f3..046c674 100644 --- a/options/options.js +++ b/options/options.js @@ -71,6 +71,7 @@ document.addEventListener('DOMContentLoaded', async () => { const payloadDisplay = document.getElementById('payload-display'); const diffDisplay = document.getElementById('diff-display'); const diffContainer = document.getElementById('diff-container'); + const promptReductionLabel = document.getElementById('prompt-reduction'); let lastFullText = defaults.lastFullText || ''; let lastPromptText = defaults.lastPromptText || ''; @@ -79,22 +80,6 @@ document.addEventListener('DOMContentLoaded', async () => { if (lastPayload) { payloadDisplay.textContent = lastPayload; } - if (lastFullText && lastPromptText && diff_match_patch) { - const dmp = new diff_match_patch(); - dmp.Diff_EditCost = 4; - const diffs = dmp.diff_main(lastFullText, lastPromptText); - dmp.diff_cleanupEfficiency(diffs); - const hasDiff = diffs.some(d => d[0] !== 0); - if (hasDiff) { - diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); - diffContainer.classList.remove('is-hidden'); - } else { - diffDisplay.innerHTML = ''; - diffContainer.classList.add('is-hidden'); - } - } else { - diffContainer.classList.add('is-hidden'); - } themeSelect.addEventListener('change', async () => { markDirty(); await applyTheme(themeSelect.value); @@ -164,6 +149,51 @@ document.addEventListener('DOMContentLoaded', async () => { const tokenReductionToggle = document.getElementById('token-reduction'); tokenReductionToggle.checked = defaults.tokenReduction === true; + function tokenSavingEnabled() { + return htmlToggle.checked + || stripUrlToggle.checked + || altTextToggle.checked + || collapseWhitespaceToggle.checked + || tokenReductionToggle.checked; + } + + function updatePromptReductionLabel(hasDiff) { + if (!promptReductionLabel) return; + if (!hasDiff || !tokenSavingEnabled() || !lastFullText || !lastPromptText) { + promptReductionLabel.classList.add('is-hidden'); + return; + } + const baseLength = lastFullText.length; + const promptLength = lastPromptText.length; + const percentSaved = baseLength > 0 + ? Math.max(0, Math.round((1 - (promptLength / baseLength)) * 100)) + : 0; + promptReductionLabel.textContent = `Prompt Token Reduction: ${percentSaved}%`; + promptReductionLabel.classList.remove('is-hidden'); + } + + function updateDiffDisplay() { + if (lastFullText && lastPromptText && diff_match_patch) { + const dmp = new diff_match_patch(); + dmp.Diff_EditCost = 4; + const diffs = dmp.diff_main(lastFullText, lastPromptText); + dmp.diff_cleanupEfficiency(diffs); + const hasDiff = diffs.some(d => d[0] !== 0); + if (hasDiff) { + diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); + diffContainer.classList.remove('is-hidden'); + } else { + diffDisplay.innerHTML = ''; + diffContainer.classList.add('is-hidden'); + } + updatePromptReductionLabel(hasDiff); + } else { + diffDisplay.innerHTML = ''; + diffContainer.classList.add('is-hidden'); + updatePromptReductionLabel(false); + } + } + const debugTabToggle = document.getElementById('show-debug-tab'); const debugTabBtn = document.getElementById('debug-tab-button'); function updateDebugTab() { @@ -174,6 +204,14 @@ document.addEventListener('DOMContentLoaded', async () => { debugTabToggle.addEventListener('change', () => { updateDebugTab(); markDirty(); }); updateDebugTab(); + updateDiffDisplay(); + + [htmlToggle, stripUrlToggle, altTextToggle, collapseWhitespaceToggle, tokenReductionToggle].forEach(toggle => { + toggle.addEventListener('change', () => { + updatePromptReductionLabel(!diffContainer.classList.contains('is-hidden')); + }); + }); + const aiParams = Object.assign({}, DEFAULT_AI_PARAMS, defaults.aiParams || {}); for (const [key, val] of Object.entries(aiParams)) { @@ -770,23 +808,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (latest.lastFullText !== lastFullText || latest.lastPromptText !== lastPromptText) { lastFullText = latest.lastFullText || ''; lastPromptText = latest.lastPromptText || ''; - if (lastFullText && lastPromptText && diff_match_patch) { - const dmp = new diff_match_patch(); - dmp.Diff_EditCost = 4; - const diffs = dmp.diff_main(lastFullText, lastPromptText); - dmp.diff_cleanupEfficiency(diffs); - const hasDiff = diffs.some(d => d[0] !== 0); - if (hasDiff) { - diffDisplay.innerHTML = dmp.diff_prettyHtml(diffs); - diffContainer.classList.remove('is-hidden'); - } else { - diffDisplay.innerHTML = ''; - diffContainer.classList.add('is-hidden'); - } - } else { - diffDisplay.innerHTML = ''; - diffContainer.classList.add('is-hidden'); - } + updateDiffDisplay(); } } } catch {} From 9269225a0c5da9e13a024677be8375694ac7d93a Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Tue, 6 Jan 2026 22:01:20 -0600 Subject: [PATCH 7/9] Add session error log and transient error icon --- README.md | 7 +++-- background.js | 46 +++++++++++++++++++++-------- options/options.html | 27 +++++++++++++++++ options/options.js | 70 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a58a799..87a3565 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,9 @@ with JSON indicating whether the message meets a specified criterion. - **Rule enable/disable** – temporarily turn a rule off without removing it. - **Account & folder filters** – limit rules to specific accounts or folders. - **Context menu** – apply AI rules from the message list or the message display action button. -- **Status icons** – toolbar icons show when classification is in progress and briefly display success states. If a failure occurs the icon turns red until you dismiss the notification. -- **Error notification** – failed classification displays a notification with a button to clear the error and reset the icon. +- **Status icons** – toolbar icons show when classification is in progress and briefly display success states. If a failure occurs the icon turns red briefly before returning to normal. +- **Error notification** – failed classification displays a notification in Thunderbird. +- **Session error log** – the Errors tab (visible only when errors occur) shows errors recorded since the last add-on start. - **View reasoning** – inspect why rules matched via the Details popup. - **Cache management** – clear cached results from the context menu or options page. - **Queue & timing stats** – monitor processing time on the Maintenance tab. @@ -88,7 +89,7 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e open a compose window using the account that received the message. 3. Save your settings. New mail will be evaluated automatically using the configured rules. -4. If the toolbar icon shows a red X, click the notification's **Dismiss** button to clear the error. +4. If the toolbar icon shows a red X, it will clear after a few seconds. Open the Errors tab in Options to review the latest failures. ### Example Filters diff --git a/background.js b/background.js index fc585ff..e16d5a3 100644 --- a/background.js +++ b/background.js @@ -19,6 +19,7 @@ let queue = Promise.resolve(); let queuedCount = 0; let processing = false; let iconTimer = null; +let errorTimer = null; let timingStats = { count: 0, mean: 0, m2: 0, total: 0, last: -1 }; let currentStart = 0; let logGetTiming = true; @@ -33,8 +34,11 @@ let userTheme = 'auto'; let currentTheme = 'light'; let detectSystemTheme; let errorPending = false; +let errorLog = []; let showDebugTab = false; const ERROR_NOTIFICATION_ID = 'sortana-error'; +const ERROR_ICON_TIMEOUT = 4500; +const MAX_ERROR_LOG = 50; function normalizeRules(rules) { return Array.isArray(rules) ? rules.map(r => { @@ -108,11 +112,33 @@ function showTransientIcon(factory, delay = 1500) { async function clearError() { errorPending = false; - await storage.local.set({ errorPending: false }); + clearTimeout(errorTimer); await browser.notifications.clear(ERROR_NOTIFICATION_ID); updateActionIcon(); } +function recordError(context, err) { + const message = err instanceof Error ? err.message : String(err || 'Unknown error'); + const detail = err instanceof Error ? err.stack : ''; + errorLog.unshift({ + time: Date.now(), + context, + message, + detail + }); + if (errorLog.length > MAX_ERROR_LOG) { + errorLog.length = MAX_ERROR_LOG; + } + errorPending = true; + updateActionIcon(); + clearTimeout(errorTimer); + errorTimer = setTimeout(() => { + errorPending = false; + updateActionIcon(); + }, ERROR_ICON_TIMEOUT); + browser.runtime.sendMessage({ type: 'sortana:errorLogUpdated', count: errorLog.length }).catch(() => {}); +} + function refreshMenuIcons() { browser.menus.update('apply-ai-rules-list', { icons: iconPaths('eye') }); browser.menus.update('apply-ai-rules-display', { icons: iconPaths('eye') }); @@ -382,16 +408,14 @@ async function processMessage(id) { const elapsed = Date.now() - currentStart; currentStart = 0; updateTimingStats(elapsed); - await storage.local.set({ classifyStats: timingStats, errorPending: true }); - errorPending = true; + await storage.local.set({ classifyStats: timingStats }); logger.aiLog("failed to apply AI rules", { level: 'error' }, e); - setIcon(ICONS.error()); + recordError("Failed to apply AI rules", e); browser.notifications.create(ERROR_NOTIFICATION_ID, { type: 'basic', iconUrl: browser.runtime.getURL('resources/img/logo.png'), title: 'Sortana Error', - message: 'Failed to apply AI rules', - buttons: [{ title: 'Dismiss' }] + message: 'Failed to apply AI rules' }); } } @@ -451,7 +475,7 @@ async function clearCacheForMessages(idsInput) { } try { - const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "errorPending", "showDebugTab"]); + const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "showDebugTab"]); logger.setDebug(store.debugLogging); await AiClassifier.setConfig(store); userTheme = store.theme || 'auto'; @@ -465,7 +489,6 @@ async function clearCacheForMessages(idsInput) { if (store.aiParams && typeof store.aiParams.max_tokens !== 'undefined') { maxTokens = parseInt(store.aiParams.max_tokens) || maxTokens; } - errorPending = store.errorPending === true; showDebugTab = store.showDebugTab === true; const savedStats = await storage.local.get('classifyStats'); if (savedStats.classifyStats && typeof savedStats.classifyStats === 'object') { @@ -524,10 +547,6 @@ async function clearCacheForMessages(idsInput) { if (changes.showDebugTab) { showDebugTab = changes.showDebugTab.newValue === true; } - if (changes.errorPending) { - errorPending = changes.errorPending.newValue === true; - updateActionIcon(); - } if (changes.theme) { userTheme = changes.theme.newValue || 'auto'; currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme; @@ -720,6 +739,8 @@ async function clearCacheForMessages(idsInput) { } } else if (msg?.type === "sortana:getQueueCount") { return { count: queuedCount + (processing ? 1 : 0) }; + } else if (msg?.type === "sortana:getErrorLog") { + return { errors: errorLog.slice() }; } else if (msg?.type === "sortana:getTiming") { const t = timingStats; const std = t.count > 1 ? Math.sqrt(t.m2 / (t.count - 1)) : 0; @@ -751,6 +772,7 @@ async function clearCacheForMessages(idsInput) { // Catch any unhandled rejections window.addEventListener("unhandledrejection", ev => { logger.aiLog("Unhandled promise rejection", { level: 'error' }, ev.reason); + recordError("Unhandled promise rejection", ev.reason); }); browser.notifications.onClicked.addListener(id => { diff --git a/options/options.html b/options/options.html index b118ee0..fd8700c 100644 --- a/options/options.html +++ b/options/options.html @@ -51,6 +51,7 @@
  • Settings
  • Rules
  • Maintenance
  • + @@ -285,6 +286,32 @@ + +