Compare commits

..

No commits in common. "main" and "codex/add-forward-and-reply-actions" have entirely different histories.

36 changed files with 83 additions and 3246 deletions

View file

@ -10,9 +10,8 @@ This file provides guidelines for codex agents contributing to the Sortana proje
- `options/`: The options page HTML, JavaScript and bundled Bulma CSS (v1.0.3). - `options/`: The options page HTML, JavaScript and bundled Bulma CSS (v1.0.3).
- `details.html` and `details.js`: View AI reasoning and clear cache for a message. - `details.html` and `details.js`: View AI reasoning and clear cache for a message.
- `resources/`: Images and other static files. - `resources/`: Images and other static files.
- `prompt_templates/`: Prompt template files for the AI service (openai, qwen, mistral, harmony). - `prompt_templates/`: Prompt template files for the AI service.
- `build-xpi.ps1`: PowerShell script to package the extension. - `build-xpi.ps1`: PowerShell script to package the extension.
- `build-xpi.sh`: Bash script to package the extension.
## Coding Style ## Coding Style
@ -29,12 +28,7 @@ This file provides guidelines for codex agents contributing to the Sortana proje
## Testing ## Testing
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. 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.
## 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.
Responses are expected to include a JSON object with `match` (or `matched`) plus a short `reason` string; the parser extracts the last JSON object in the response text and ignores any surrounding commentary.
## Documentation ## Documentation
@ -79,3 +73,4 @@ 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 and 64 pixel variants. When changing these icons, pass a dictionary mapping the
sizes to the paths in `browserAction.setIcon` or `messageDisplayAction.setIcon`. sizes to the paths in `browserAction.setIcon` or `messageDisplayAction.setIcon`.
Use `resources/svg2img.ps1` to regenerate PNGs from the SVG sources. Use `resources/svg2img.ps1` to regenerate PNGs from the SVG sources.

View file

@ -4,36 +4,27 @@
Sortana is an experimental Thunderbird add-on that integrates an AI-powered filter rule. 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 It allows you to classify email messages by sending their contents to a configurable
HTTP endpoint. Sortana uses the `/v1/completions` API; the options page stores a base HTTP endpoint. The endpoint should respond with JSON indicating whether the
URL and appends `/v1/completions` when sending requests. The endpoint should respond message meets a specified criterion.
with JSON indicating whether the message meets a specified criterion, including a
short reasoning summary.
Responses are parsed by extracting the last JSON object in the response text and
expecting a `match` (or `matched`) boolean plus a `reason` string.
## Features ## Features
- **Configurable endpoint** set the classification service base URL on the options page. - **Configurable endpoint** set the classification service URL on the options page.
- **Prompt templates** choose between OpenAI/ChatML, Qwen, Mistral, Harmony (gpt-oss), or provide your own custom template. - **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. - **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. - **Persistent result caching** classification results and reasoning are saved to disk so messages aren't re-evaluated across restarts.
- **Advanced parameters** tune generation settings like temperature, topp and more from the options page. - **Advanced parameters** tune generation settings like temperature, topp and more from the options page.
- **Markdown conversion** optionally convert HTML bodies to Markdown before sending them to the AI service. - **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 logging** optional colorized logs help troubleshoot interactions with the AI service.
- **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. - **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. - **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.
- **Rule ordering** drag rules to prioritize them and optionally stop processing after a match. - **Rule ordering** drag rules to prioritize them and optionally stop processing after a match.
- **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. - **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 briefly before returning to normal. - **Status icons** toolbar icons show when classification is in progress and briefly display success or error states.
- **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. - **View reasoning** inspect why rules matched via the Details popup.
- **Cache management** clear cached results from the context menu or options page. - **Cache management** clear cached results from the context menu or options page.
- **Queue & timing stats** monitor processing time on the Maintenance tab. - **Queue & timing stats** monitor processing time on the Maintenance tab.
- **Packaging scripts** `build-xpi.ps1` (PowerShell) or `build-xpi.sh` (bash) build an XPI ready for installation. - **Packaging script** `build-xpi.ps1` builds an XPI ready for installation.
- **Maintenance tab** view rule counts, cache entries and clear cached results from the options page. - **Maintenance tab** view rule counts, cache entries and clear cached results from the options page.
### Cache Storage ### Cache Storage
@ -69,30 +60,23 @@ Sortana is implemented entirely with standard WebExtension scripts—no custom e
1. Ensure PowerShell is available (for Windows) or adapt the script for other 1. Ensure PowerShell is available (for Windows) or adapt the script for other
environments. environments.
2. The Bulma stylesheet (v1.0.3) is already included as `options/bulma.css`. 2. The Bulma stylesheet (v1.0.3) is already included as `options/bulma.css`.
3. Run `powershell ./build-xpi.ps1` or `./build-xpi.sh` from the repository root. 3. Run `powershell ./build-xpi.ps1` from the repository root. The script reads
The script reads the version from `manifest.json` and creates an XPI in the the version from `manifest.json` and creates an XPI in the `release` folder.
`release` folder.
4. Install the generated XPI in Thunderbird via the Add-ons Manager. During 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. 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`. 5. To regenerate PNG icons from the SVG sources, run `resources/svg2img.ps1`.
## Usage ## Usage
1. Open the add-on's options and set the base URL of your classification service 1. Open the add-on's options and set the URL of your classification service.
(Sortana will append `/v1/completions`). 2. Use the **Classification Rules** section to add a criterion and optional
2. Use the **Classification Rules** section to add a criterion and optional
actions such as tagging, moving, copying, forwarding, replying, actions such as tagging, moving, copying, forwarding, replying,
deleting or archiving a message when it matches. Drag rules to deleting or archiving a message when it matches. Drag rules to
reorder them, check *Only apply to unread messages* to skip read mail, reorder them, check *Only apply to unread messages* to skip read mail, and
set optional minimum or maximum message age limits, select the accounts or
folders a rule should apply to. Use the
slashed-circle/check button to disable or re-enable a rule. The small
circle buttons for optional conditions show a filled dot when active, and
check *Stop after match* to halt further processing. Forward and reply actions check *Stop after match* to halt further processing. Forward and reply actions
open a compose window using the account that received the message. open a compose window using the account that received the message.
3. Save your settings. New mail will be evaluated automatically using the 3. Save your settings. New mail will be evaluated automatically using the
configured rules. configured rules.
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 ### Example Filters
@ -134,7 +118,6 @@ Sortana requests the following Thunderbird permissions:
- `accountsRead` list accounts and folders for move or copy actions. - `accountsRead` list accounts and folders for move or copy actions.
- `menus` add context menu commands. - `menus` add context menu commands.
- `tabs` open new tabs and query the active tab. - `tabs` open new tabs and query the active tab.
- `notifications` display error notifications.
- `compose` create reply and forward compose windows for matching rules. - `compose` create reply and forward compose windows for matching rules.
## Thunderbird Add-on Store Disclosures ## Thunderbird Add-on Store Disclosures
@ -148,8 +131,6 @@ uses the following third party libraries:
- MIT License - MIT License
- [turndown v7.2.0](https://github.com/mixmark-io/turndown/tree/v7.2.0) - [turndown v7.2.0](https://github.com/mixmark-io/turndown/tree/v7.2.0)
- MIT License - MIT License
- [diff](https://github.com/google/diff-match-patch/blob/62f2e689f498f9c92dbc588c58750addec9b1654/javascript/diff_match_patch_uncompressed.js)
- Apache-2.0 license
## License ## License
@ -165,3 +146,4 @@ how Thunderbird's WebExtension and experiment APIs can be extended. Their code
provided invaluable guidance during development. provided invaluable guidance during development.
- Icons from [cc0-icons.jonh.eu](https://cc0-icons.jonh.eu/) are used under the CC0 license. - Icons from [cc0-icons.jonh.eu](https://cc0-icons.jonh.eu/) are used under the CC0 license.

View file

@ -12,15 +12,13 @@
"template.openai": { "message": "OpenAI / ChatML" }, "template.openai": { "message": "OpenAI / ChatML" },
"template.qwen": { "message": "Qwen" }, "template.qwen": { "message": "Qwen" },
"template.mistral": { "message": "Mistral" }, "template.mistral": { "message": "Mistral" },
"template.harmony": { "message": "Harmony (gpt-oss)" },
"template.custom": { "message": "Custom" }, "template.custom": { "message": "Custom" },
"options.save": { "message": "Save" }, "options.save": { "message": "Save" },
"options.debugLogging": { "message": "Enable debug logging" }, "options.debugLogging": { "message": "Enable debug logging" },
"options.htmlToMarkdown": { "message": "Convert HTML body to Markdown" }, "options.htmlToMarkdown": { "message": "Convert HTML body to Markdown" },
"options.stripUrlParams": { "message": "Remove URL tracking parameters" }, "options.stripUrlParams": { "message": "Remove URL tracking parameters" },
"options.altTextImages": { "message": "Replace images with alt text" }, "options.altTextImages": { "message": "Replace images with alt text" },
"options.collapseWhitespace": { "message": "Collapse long whitespace" }, "options.collapseWhitespace": { "message": "Collapse long whitespace" }
"options.tokenReduction": { "message": "Aggressive token reduction" }
,"action.read": { "message": "read" } ,"action.read": { "message": "read" }
,"action.flag": { "message": "flag" } ,"action.flag": { "message": "flag" }
,"action.copy": { "message": "copy" } ,"action.copy": { "message": "copy" }

View file

@ -44,7 +44,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "prompt_templates", "prompt_
prompt_templates\mistral.txt = prompt_templates\mistral.txt prompt_templates\mistral.txt = prompt_templates\mistral.txt
prompt_templates\openai.txt = prompt_templates\openai.txt prompt_templates\openai.txt = prompt_templates\openai.txt
prompt_templates\qwen.txt = prompt_templates\qwen.txt prompt_templates\qwen.txt = prompt_templates\qwen.txt
prompt_templates\harmony.txt = prompt_templates\harmony.txt
EndProjectSection EndProjectSection
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{68A87938-5C2B-49F5-8AAA-8A34FBBFD854}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{68A87938-5C2B-49F5-8AAA-8A34FBBFD854}"
@ -109,7 +108,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{F266602F-175
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "js", "js", "{21D2A42C-3F85-465C-9141-C106AFD92B68}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "js", "js", "{21D2A42C-3F85-465C-9141-C106AFD92B68}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
resources\js\diff_match_patch_uncompressed.js = resources\js\diff_match_patch_uncompressed.js
resources\js\turndown.js = resources\js\turndown.js resources\js\turndown.js = resources\js\turndown.js
EndProjectSection EndProjectSection
EndProject EndProject

View file

@ -19,7 +19,6 @@ let queue = Promise.resolve();
let queuedCount = 0; let queuedCount = 0;
let processing = false; let processing = false;
let iconTimer = null; let iconTimer = null;
let errorTimer = null;
let timingStats = { count: 0, mean: 0, m2: 0, total: 0, last: -1 }; let timingStats = { count: 0, mean: 0, m2: 0, total: 0, last: -1 };
let currentStart = 0; let currentStart = 0;
let logGetTiming = true; let logGetTiming = true;
@ -27,27 +26,14 @@ let htmlToMarkdown = false;
let stripUrlParams = false; let stripUrlParams = false;
let altTextImages = false; let altTextImages = false;
let collapseWhitespace = false; let collapseWhitespace = false;
let tokenReduction = false;
let maxTokens = 4096;
let TurndownService = null; let TurndownService = null;
let userTheme = 'auto'; let userTheme = 'auto';
let currentTheme = 'light'; let currentTheme = 'light';
let detectSystemTheme; 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) { function normalizeRules(rules) {
return Array.isArray(rules) ? rules.map(r => { return Array.isArray(rules) ? rules.map(r => {
if (r.actions) { if (r.actions) return r;
if (!Array.isArray(r.accounts)) r.accounts = [];
if (!Array.isArray(r.folders)) r.folders = [];
r.enabled = r.enabled !== false;
return r;
}
const actions = []; const actions = [];
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
@ -55,11 +41,6 @@ function normalizeRules(rules) {
const rule = { criterion: r.criterion, actions }; const rule = { criterion: r.criterion, actions };
if (r.stopProcessing) rule.stopProcessing = true; if (r.stopProcessing) rule.stopProcessing = true;
if (r.unreadOnly) rule.unreadOnly = true; if (r.unreadOnly) rule.unreadOnly = true;
if (typeof r.minAgeDays === 'number') rule.minAgeDays = r.minAgeDays;
if (typeof r.maxAgeDays === 'number') rule.maxAgeDays = r.maxAgeDays;
if (Array.isArray(r.accounts)) rule.accounts = r.accounts;
if (Array.isArray(r.folders)) rule.folders = r.folders;
rule.enabled = r.enabled !== false;
return rule; return rule;
}) : []; }) : [];
} }
@ -77,8 +58,7 @@ const ICONS = {
logo: () => 'resources/img/logo.png', logo: () => 'resources/img/logo.png',
circledots: () => iconPaths('circledots'), circledots: () => iconPaths('circledots'),
circle: () => iconPaths('circle'), circle: () => iconPaths('circle'),
average: () => iconPaths('average'), average: () => iconPaths('average')
error: () => iconPaths('x')
}; };
function setIcon(path) { function setIcon(path) {
@ -92,62 +72,19 @@ function setIcon(path) {
function updateActionIcon() { function updateActionIcon() {
let path = ICONS.logo(); let path = ICONS.logo();
if (errorPending) { if (processing || queuedCount > 0) {
path = ICONS.error();
} else if (processing || queuedCount > 0) {
path = ICONS.circledots(); path = ICONS.circledots();
} }
setIcon(path); setIcon(path);
} }
function showTransientIcon(factory, delay = 1500) { function showTransientIcon(factory, delay = 1500) {
if (errorPending) {
return;
}
clearTimeout(iconTimer); clearTimeout(iconTimer);
const path = typeof factory === 'function' ? factory() : factory; const path = typeof factory === 'function' ? factory() : factory;
setIcon(path); setIcon(path);
iconTimer = setTimeout(updateActionIcon, delay); iconTimer = setTimeout(updateActionIcon, delay);
} }
async function clearError() {
errorPending = false;
clearTimeout(errorTimer);
await browser.notifications.clear(ERROR_NOTIFICATION_ID);
updateActionIcon();
}
function recordError(context, err) {
let message = 'Unknown error';
let detail = '';
if (err instanceof Error) {
message = err.message;
detail = err.stack || '';
} else if (err && typeof err === 'object') {
message = typeof err.message === 'string' ? err.message : String(err || 'Unknown error');
detail = typeof err.detail === 'string' ? err.detail : '';
} else {
message = String(err || 'Unknown error');
}
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() { function refreshMenuIcons() {
browser.menus.update('apply-ai-rules-list', { icons: iconPaths('eye') }); browser.menus.update('apply-ai-rules-list', { icons: iconPaths('eye') });
browser.menus.update('apply-ai-rules-display', { icons: iconPaths('eye') }); browser.menus.update('apply-ai-rules-display', { icons: iconPaths('eye') });
@ -163,16 +100,12 @@ function byteSize(str) {
} }
function replaceInlineBase64(text) { function replaceInlineBase64(text) {
return text.replace(/(?:data:[^;]+;base64,)?[A-Za-z0-9+/=\r\n]{100,}/g, return text.replace(/[A-Za-z0-9+/]{100,}={0,2}/g,
m => tokenReduction ? '__BASE64__' : `[base64: ${byteSize(m)} bytes]`); m => `[base64: ${byteSize(m)} bytes]`);
} }
function sanitizeString(text) { function sanitizeString(text) {
let t = String(text); let t = String(text);
if (tokenReduction) {
t = t.replace(/<!--.*?-->/gs, '')
.replace(/url\([^\)]*\)/gi, 'url(__IMG__)');
}
if (stripUrlParams) { if (stripUrlParams) {
t = t.replace(/https?:\/\/[^\s)]+/g, m => { t = t.replace(/https?:\/\/[^\s)]+/g, m => {
const idx = m.indexOf('?'); const idx = m.indexOf('?');
@ -180,7 +113,7 @@ function sanitizeString(text) {
}); });
} }
if (collapseWhitespace) { if (collapseWhitespace) {
t = t.replace(/[\u200B-\u200D\u2060\s]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n'); t = t.replace(/[ \t\u00A0]{2,}/g, ' ').replace(/\n{3,}/g, '\n\n');
} }
return t; return t;
} }
@ -199,26 +132,12 @@ function collectText(part, bodyParts, attachments) {
attachments.push(`${name} (${ct}, ${part.size || byteSize(body)} bytes)`); attachments.push(`${name} (${ct}, ${part.size || byteSize(body)} bytes)`);
} else if (ct.startsWith("text/html")) { } else if (ct.startsWith("text/html")) {
const doc = new DOMParser().parseFromString(body, 'text/html'); const doc = new DOMParser().parseFromString(body, 'text/html');
if (tokenReduction) { if (altTextImages) {
doc.querySelectorAll('script,style').forEach(el => el.remove());
const walker = doc.createTreeWalker(doc, NodeFilter.SHOW_COMMENT);
let node;
while ((node = walker.nextNode())) {
node.parentNode.removeChild(node);
}
doc.querySelectorAll('*').forEach(el => {
for (const attr of Array.from(el.attributes)) {
if (!['href','src','alt'].includes(attr.name)) {
el.removeAttribute(attr.name);
}
}
});
}
doc.querySelectorAll('img').forEach(img => { doc.querySelectorAll('img').forEach(img => {
const alt = img.getAttribute('alt') || ''; const alt = img.getAttribute('alt') || '';
const text = altTextImages ? alt : '__IMG__'; img.replaceWith(doc.createTextNode(alt));
img.replaceWith(doc.createTextNode(text));
}); });
}
if (stripUrlParams) { if (stripUrlParams) {
doc.querySelectorAll('[href]').forEach(a => { doc.querySelectorAll('[href]').forEach(a => {
const href = a.getAttribute('href'); const href = a.getAttribute('href');
@ -245,46 +164,17 @@ function collectText(part, bodyParts, attachments) {
} }
} }
function collectRawText(part, bodyParts, attachments) { function buildEmailText(full) {
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 bodyParts = [];
const attachments = []; const attachments = [];
const collect = applyTransforms ? collectText : collectRawText; collectText(full, bodyParts, attachments);
collect(full, bodyParts, attachments);
const headers = Object.entries(full.headers || {}) const headers = Object.entries(full.headers || {})
.map(([k, v]) => `${k}: ${v.join(' ')}`) .map(([k, v]) => `${k}: ${v.join(' ')}`)
.join('\n'); .join('\n');
const attachInfo = `Attachments: ${attachments.length}` + const attachInfo = `Attachments: ${attachments.length}` +
(attachments.length ? "\n" + attachments.map(a => ` - ${a}`).join('\n') : ""); (attachments.length ? "\n" + attachments.map(a => ` - ${a}`).join('\n') : "");
let combined = `${headers}\n${attachInfo}\n\n${bodyParts.join('\n')}`.trim(); const combined = `${headers}\n${attachInfo}\n\n${bodyParts.join('\n')}`.trim();
if (applyTransforms && tokenReduction) { return sanitizeString(combined);
const seen = new Set();
combined = combined.split('\n').filter(l => {
if (seen.has(l)) return false;
seen.add(l);
return true;
}).join('\n');
}
return applyTransforms ? sanitizeString(combined) : combined;
} }
function updateTimingStats(elapsed) { function updateTimingStats(elapsed) {
@ -318,17 +208,7 @@ async function processMessage(id) {
updateActionIcon(); updateActionIcon();
try { try {
const full = await messenger.messages.getFull(id); const full = await messenger.messages.getFull(id);
const originalText = buildEmailText(full, false); const text = buildEmailText(full);
let text = buildEmailText(full);
if (tokenReduction && maxTokens > 0) {
const limit = Math.floor(maxTokens * 0.9);
if (text.length > limit) {
text = text.slice(0, limit);
}
}
if (showDebugTab) {
await storage.local.set({ lastFullText: originalText, lastPromptText: text });
}
let hdr; let hdr;
let currentTags = []; let currentTags = [];
let alreadyRead = false; let alreadyRead = false;
@ -346,32 +226,9 @@ async function processMessage(id) {
} }
for (const rule of aiRules) { for (const rule of aiRules) {
if (rule.enabled === false) {
continue;
}
if (hdr && Array.isArray(rule.accounts) && rule.accounts.length &&
!rule.accounts.includes(hdr.folder.accountId)) {
continue;
}
if (hdr && Array.isArray(rule.folders) && rule.folders.length &&
!rule.folders.includes(hdr.folder.path)) {
continue;
}
if (rule.unreadOnly && alreadyRead) { if (rule.unreadOnly && alreadyRead) {
continue; continue;
} }
if (hdr && (typeof rule.minAgeDays === 'number' || typeof rule.maxAgeDays === 'number')) {
const msgTime = new Date(hdr.date).getTime();
if (!isNaN(msgTime)) {
const ageDays = (Date.now() - msgTime) / 86400000;
if (typeof rule.minAgeDays === 'number' && ageDays < rule.minAgeDays) {
continue;
}
if (typeof rule.maxAgeDays === 'number' && ageDays > rule.maxAgeDays) {
continue;
}
}
}
const cacheKey = await AiClassifier.buildCacheKey(id, rule.criterion); const cacheKey = await AiClassifier.buildCacheKey(id, rule.criterion);
const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey); const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey);
if (matched) { if (matched) {
@ -419,13 +276,7 @@ async function processMessage(id) {
updateTimingStats(elapsed); updateTimingStats(elapsed);
await storage.local.set({ classifyStats: timingStats }); await storage.local.set({ classifyStats: timingStats });
logger.aiLog("failed to apply AI rules", { level: 'error' }, e); logger.aiLog("failed to apply AI rules", { level: 'error' }, e);
recordError("Failed to apply AI rules", e); showTransientIcon(ICONS.average);
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'
});
} }
} }
async function applyAiRules(idsInput) { async function applyAiRules(idsInput) {
@ -484,7 +335,7 @@ async function clearCacheForMessages(idsInput) {
} }
try { try {
const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "showDebugTab"]); const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "aiRules", "theme"]);
logger.setDebug(store.debugLogging); logger.setDebug(store.debugLogging);
await AiClassifier.setConfig(store); await AiClassifier.setConfig(store);
userTheme = store.theme || 'auto'; userTheme = store.theme || 'auto';
@ -494,11 +345,6 @@ async function clearCacheForMessages(idsInput) {
stripUrlParams = store.stripUrlParams === true; stripUrlParams = store.stripUrlParams === true;
altTextImages = store.altTextImages === true; altTextImages = store.altTextImages === true;
collapseWhitespace = store.collapseWhitespace === true; collapseWhitespace = store.collapseWhitespace === true;
tokenReduction = store.tokenReduction === true;
if (store.aiParams && typeof store.aiParams.max_tokens !== 'undefined') {
maxTokens = parseInt(store.aiParams.max_tokens) || maxTokens;
}
showDebugTab = store.showDebugTab === true;
const savedStats = await storage.local.get('classifyStats'); const savedStats = await storage.local.get('classifyStats');
if (savedStats.classifyStats && typeof savedStats.classifyStats === 'object') { if (savedStats.classifyStats && typeof savedStats.classifyStats === 'object') {
Object.assign(timingStats, savedStats.classifyStats); Object.assign(timingStats, savedStats.classifyStats);
@ -514,25 +360,6 @@ async function clearCacheForMessages(idsInput) {
aiRules = normalizeRules(newRules); aiRules = normalizeRules(newRules);
logger.aiLog("aiRules updated from storage change", { debug: true }, aiRules); logger.aiLog("aiRules updated from storage change", { debug: true }, aiRules);
} }
if (changes.endpoint || changes.templateName || changes.customTemplate || changes.customSystemPrompt || changes.aiParams || changes.debugLogging) {
const config = {};
if (changes.endpoint) config.endpoint = changes.endpoint.newValue;
if (changes.templateName) config.templateName = changes.templateName.newValue;
if (changes.customTemplate) config.customTemplate = changes.customTemplate.newValue;
if (changes.customSystemPrompt) config.customSystemPrompt = changes.customSystemPrompt.newValue;
if (changes.aiParams) {
config.aiParams = changes.aiParams.newValue;
if (changes.aiParams.newValue && typeof changes.aiParams.newValue.max_tokens !== 'undefined') {
maxTokens = parseInt(changes.aiParams.newValue.max_tokens) || maxTokens;
}
}
if (changes.debugLogging) {
config.debugLogging = changes.debugLogging.newValue === true;
logger.setDebug(config.debugLogging);
}
await AiClassifier.setConfig(config);
logger.aiLog("AiClassifier config updated from storage change", { debug: true }, config);
}
if (changes.htmlToMarkdown) { if (changes.htmlToMarkdown) {
htmlToMarkdown = changes.htmlToMarkdown.newValue === true; htmlToMarkdown = changes.htmlToMarkdown.newValue === true;
logger.aiLog("htmlToMarkdown updated from storage change", { debug: true }, htmlToMarkdown); logger.aiLog("htmlToMarkdown updated from storage change", { debug: true }, htmlToMarkdown);
@ -549,13 +376,6 @@ async function clearCacheForMessages(idsInput) {
collapseWhitespace = changes.collapseWhitespace.newValue === true; collapseWhitespace = changes.collapseWhitespace.newValue === true;
logger.aiLog("collapseWhitespace updated from storage change", { debug: true }, collapseWhitespace); logger.aiLog("collapseWhitespace updated from storage change", { debug: true }, collapseWhitespace);
} }
if (changes.tokenReduction) {
tokenReduction = changes.tokenReduction.newValue === true;
logger.aiLog("tokenReduction updated from storage change", { debug: true }, tokenReduction);
}
if (changes.showDebugTab) {
showDebugTab = changes.showDebugTab.newValue === true;
}
if (changes.theme) { if (changes.theme) {
userTheme = changes.theme.newValue || 'auto'; userTheme = changes.theme.newValue || 'auto';
currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme; currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme;
@ -748,11 +568,6 @@ async function clearCacheForMessages(idsInput) {
} }
} else if (msg?.type === "sortana:getQueueCount") { } else if (msg?.type === "sortana:getQueueCount") {
return { count: queuedCount + (processing ? 1 : 0) }; return { count: queuedCount + (processing ? 1 : 0) };
} else if (msg?.type === "sortana:getErrorLog") {
return { errors: errorLog.slice() };
} else if (msg?.type === "sortana:recordError") {
recordError(msg.context || "Sortana Error", { message: msg.message, detail: msg.detail });
return { ok: true };
} else if (msg?.type === "sortana:getTiming") { } else if (msg?.type === "sortana:getTiming") {
const t = timingStats; const t = timingStats;
const std = t.count > 1 ? Math.sqrt(t.m2 / (t.count - 1)) : 0; const std = t.count > 1 ? Math.sqrt(t.m2 / (t.count - 1)) : 0;
@ -784,19 +599,6 @@ async function clearCacheForMessages(idsInput) {
// Catch any unhandled rejections // Catch any unhandled rejections
window.addEventListener("unhandledrejection", ev => { window.addEventListener("unhandledrejection", ev => {
logger.aiLog("Unhandled promise rejection", { level: 'error' }, ev.reason); logger.aiLog("Unhandled promise rejection", { level: 'error' }, ev.reason);
recordError("Unhandled promise rejection", ev.reason);
});
browser.notifications.onClicked.addListener(id => {
if (id === ERROR_NOTIFICATION_ID) {
clearError();
}
});
browser.notifications.onButtonClicked.addListener((id) => {
if (id === ERROR_NOTIFICATION_ID) {
clearError();
}
}); });
browser.runtime.onInstalled.addListener(async ({ reason }) => { browser.runtime.onInstalled.addListener(async ({ reason }) => {

View file

@ -1,77 +0,0 @@
#!/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"

View file

@ -1,13 +1,13 @@
{ {
"manifest_version": 2, "manifest_version": 2,
"name": "Sortana", "name": "Sortana",
"version": "2.2.0", "version": "2.1.1",
"default_locale": "en-US", "default_locale": "en-US",
"applications": { "applications": {
"gecko": { "gecko": {
"id": "ai-filter@jordanwages", "id": "ai-filter@jordanwages",
"strict_min_version": "128.0", "strict_min_version": "128.0",
"strict_max_version": "140.*" "strict_max_version": "139.*"
} }
}, },
"icons": { "icons": {
@ -40,7 +40,6 @@
"messagesTagsList", "messagesTagsList",
"accountsRead", "accountsRead",
"menus", "menus",
"notifications",
"scripting", "scripting",
"tabs", "tabs",
"theme", "theme",

View file

@ -15,8 +15,6 @@ try {
Services = undefined; Services = undefined;
} }
const COMPLETIONS_PATH = "/v1/completions";
const SYSTEM_PREFIX = `You are an email-classification assistant. const SYSTEM_PREFIX = `You are an email-classification assistant.
Read the email below and the classification criterion provided by the user. Read the email below and the classification criterion provided by the user.
`; `;
@ -25,13 +23,12 @@ const DEFAULT_CUSTOM_SYSTEM_PROMPT = "Determine whether the email satisfies the
const SYSTEM_SUFFIX = ` const SYSTEM_SUFFIX = `
Return ONLY a JSON object on a single line of the form: Return ONLY a JSON object on a single line of the form:
{"match": true, "reason": "<short explanation>"} - if the email satisfies the criterion {"match": true} - if the email satisfies the criterion
{"match": false, "reason": "<short explanation>"} - otherwise {"match": false} - otherwise
Do not add any other keys, text, or formatting.`; Do not add any other keys, text, or formatting.`;
let gEndpointBase = "http://127.0.0.1:5000"; let gEndpoint = "http://127.0.0.1:5000/v1/classify";
let gEndpoint = buildEndpointUrl(gEndpointBase);
let gTemplateName = "openai"; let gTemplateName = "openai";
let gCustomTemplate = ""; let gCustomTemplate = "";
let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT; let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT;
@ -42,28 +39,6 @@ let gAiParams = Object.assign({}, DEFAULT_AI_PARAMS);
let gCache = new Map(); let gCache = new Map();
let gCacheLoaded = false; 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) { function sha256HexSync(str) {
try { try {
const hasher = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); const hasher = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
@ -183,12 +158,8 @@ function loadTemplateSync(name) {
} }
async function setConfig(config = {}) { async function setConfig(config = {}) {
if (typeof config.endpoint === "string") { if (config.endpoint) {
const base = normalizeEndpointBase(config.endpoint); gEndpoint = config.endpoint;
if (base) {
gEndpointBase = base;
}
gEndpoint = buildEndpointUrl(gEndpointBase);
} }
if (config.templateName) { if (config.templateName) {
gTemplateName = config.templateName; gTemplateName = config.templateName;
@ -216,10 +187,6 @@ async function setConfig(config = {}) {
} else { } else {
gTemplateText = await loadTemplate(gTemplateName); 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] Endpoint set to ${gEndpoint}`, {debug: true});
aiLog(`[AiClassifier] Template set to ${gTemplateName}`, {debug: true}); aiLog(`[AiClassifier] Template set to ${gTemplateName}`, {debug: true});
} }
@ -266,95 +233,15 @@ function buildPayload(text, criterion) {
return JSON.stringify(payloadObj); return JSON.stringify(payloadObj);
} }
function reportParseError(message, detail) {
try {
const runtime = (globalThis.browser ?? globalThis.messenger)?.runtime;
if (!runtime?.sendMessage) {
return;
}
runtime.sendMessage({
type: "sortana:recordError",
context: "AI response parsing",
message,
detail
}).catch(() => {});
} catch (e) {
aiLog("Failed to report parse error", { level: "warn" }, e);
}
}
function extractLastJsonObject(text) {
let last = null;
let start = -1;
let depth = 0;
let inString = false;
let escape = false;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (inString) {
if (escape) {
escape = false;
continue;
}
if (ch === "\\") {
escape = true;
continue;
}
if (ch === "\"") {
inString = false;
}
continue;
}
if (ch === "\"") {
inString = true;
continue;
}
if (ch === "{") {
if (depth === 0) {
start = i;
}
depth += 1;
continue;
}
if (ch === "}" && depth > 0) {
depth -= 1;
if (depth === 0 && start !== -1) {
last = text.slice(start, i + 1);
start = -1;
}
}
}
return last;
}
function parseMatch(result) { function parseMatch(result) {
const rawText = result.choices?.[0]?.text || ""; const rawText = result.choices?.[0]?.text || "";
const candidate = extractLastJsonObject(rawText); const thinkText = rawText.match(/<think>[\s\S]*?<\/think>/gi)?.join('') || '';
if (!candidate) { aiLog('[AiClassifier] ⮡ Reasoning:', {debug: true}, thinkText);
reportParseError("No JSON object found in AI response.", rawText.slice(0, 800)); const cleanedText = rawText.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
return { matched: false, reason: "" }; aiLog('[AiClassifier] ⮡ Cleaned Response Text:', {debug: true}, cleanedText);
} const obj = JSON.parse(cleanedText);
const matched = obj.matched === true || obj.match === true;
let obj; return { matched, reason: thinkText };
try {
obj = JSON.parse(candidate);
} catch (e) {
reportParseError("Failed to parse JSON from AI response.", candidate.slice(0, 800));
return { matched: false, reason: "" };
}
const matchValue = Object.prototype.hasOwnProperty.call(obj, "match") ? obj.match : obj.matched;
const matched = matchValue === true;
if (matchValue !== true && matchValue !== false) {
reportParseError("AI response missing valid match boolean.", candidate.slice(0, 800));
}
const reasonValue = obj.reason ?? obj.reasoning ?? obj.explaination;
const reason = typeof reasonValue === "string" ? reasonValue : "";
return { matched, reason };
} }
function cacheEntry(cacheKey, matched, reason) { function cacheEntry(cacheKey, matched, reason) {
@ -421,11 +308,6 @@ async function classifyText(text, criterion, cacheKey = null) {
} }
const payload = buildPayload(text, criterion); const payload = buildPayload(text, criterion);
try {
await storage.local.set({ lastPayload: JSON.parse(payload) });
} catch (e) {
aiLog('failed to save last payload', { level: 'warn' }, e);
}
aiLog(`[AiClassifier] Sending classification request to ${gEndpoint}`, {debug: true}); aiLog(`[AiClassifier] Sending classification request to ${gEndpoint}`, {debug: true});
aiLog(`[AiClassifier] Classification request payload:`, { debug: true }, payload); aiLog(`[AiClassifier] Classification request payload:`, { debug: true }, payload);
@ -457,4 +339,4 @@ async function init() {
await loadCache(); await loadCache();
} }
export { buildEndpointUrl, normalizeEndpointBase, classifyText, setConfig, removeCacheEntries, clearCache, getReason, getCachedResult, buildCacheKey, getCacheSize, init }; export { classifyText, setConfig, removeCacheEntries, clearCache, getReason, getCachedResult, buildCacheKey, getCacheSize, init };

View file

@ -31,10 +31,6 @@
.tag { .tag {
--bulma-tag-h: 318; --bulma-tag-h: 318;
} }
#diff-display {
white-space: pre-wrap;
font-family: monospace;
}
</style> </style>
</head> </head>
<body> <body>
@ -51,8 +47,6 @@
<li class="is-active" data-tab="settings"><a><span class="icon is-small"><img data-icon="settings" data-size="16" src="../resources/img/settings-light-16.png" alt=""></span><span>Settings</span></a></li> <li class="is-active" data-tab="settings"><a><span class="icon is-small"><img data-icon="settings" data-size="16" src="../resources/img/settings-light-16.png" alt=""></span><span>Settings</span></a></li>
<li data-tab="rules"><a><span class="icon is-small"><img data-icon="clipboarddata" data-size="16" src="../resources/img/clipboarddata-light-16.png" alt=""></span><span>Rules</span></a></li> <li data-tab="rules"><a><span class="icon is-small"><img data-icon="clipboarddata" data-size="16" src="../resources/img/clipboarddata-light-16.png" alt=""></span><span>Rules</span></a></li>
<li data-tab="maintenance"><a><span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span><span>Maintenance</span></a></li> <li data-tab="maintenance"><a><span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span><span>Maintenance</span></a></li>
<li id="errors-tab-button" class="is-hidden" data-tab="errors"><a><span class="icon is-small"><img data-icon="x" data-size="16" src="../resources/img/x-light-16.png" alt=""></span><span>Errors</span></a></li>
<li id="debug-tab-button" class="is-hidden" data-tab="debug"><a><span class="icon is-small"><img data-icon="average" data-size="16" src="../resources/img/average-light-16.png" alt=""></span><span>Debug</span></a></li>
</ul> </ul>
</div> </div>
</div> </div>
@ -74,7 +68,6 @@
<div class="control"> <div class="control">
<input class="input" type="text" id="endpoint" placeholder="https://api.example.com"> <input class="input" type="text" id="endpoint" placeholder="https://api.example.com">
</div> </div>
<p class="help" id="endpoint-preview"></p>
</div> </div>
<div class="field"> <div class="field">
@ -151,16 +144,6 @@
<input type="checkbox" id="collapse-whitespace"> Collapse long whitespace <input type="checkbox" id="collapse-whitespace"> Collapse long whitespace
</label> </label>
</div> </div>
<div class="field">
<label class="checkbox">
<input type="checkbox" id="token-reduction"> Aggressive token reduction
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" id="show-debug-tab"> Show debug information
</label>
</div>
<div class="field"> <div class="field">
<label class="label" for="max_tokens">Max tokens</label> <label class="label" for="max_tokens">Max tokens</label>
<div class="control"> <div class="control">
@ -285,50 +268,8 @@
</p> </p>
</div> </div>
</div> </div>
<div id="errors-tab" class="tab-content is-hidden">
<h2 class="title is-4">
<span class="icon is-small"><img data-icon="x" data-size="16" src="../resources/img/x-light-16.png" alt=""></span>
<span>Session Errors</span>
</h2>
<div id="errors-empty" class="notification is-success is-light">
No errors have been recorded since the last start.
</div>
<div id="errors-panel" class="is-hidden">
<div class="box mb-4">
<div class="level">
<div class="level-left">
<div>
<p class="title is-5 mb-1">Error Log</p>
<p class="subtitle is-6">Visible only for this session.</p>
</div>
</div>
<div class="level-right">
<span class="tag is-danger is-light" id="errors-count">0</span>
</div>
</div>
</div>
<div id="errors-list"></div>
</div>
</div>
<div id="debug-tab" class="tab-content is-hidden">
<h2 class="title is-4">
<span class="icon is-small"><img data-icon="average" data-size="16" src="../resources/img/average-light-16.png" alt=""></span>
<span>Debug</span>
</h2>
<pre id="payload-display"></pre>
<div id="diff-container" class="mt-4 is-hidden">
<div class="is-flex is-align-items-center is-justify-content-space-between">
<label class="label mb-0">Prompt diff</label>
<span id="prompt-reduction" class="tag is-info is-light is-hidden">Prompt Token Reduction: 0%</span>
</div>
<div id="diff-display" class="box content is-family-monospace"></div>
</div>
</div>
</div> </div>
</section> </section>
<script src="../resources/js/diff_match_patch_uncompressed.js"></script>
<script src="options.js"></script> <script src="options.js"></script>
</body> </body>
</html> </html>

View file

@ -16,14 +16,9 @@ document.addEventListener('DOMContentLoaded', async () => {
'stripUrlParams', 'stripUrlParams',
'altTextImages', 'altTextImages',
'collapseWhitespace', 'collapseWhitespace',
'tokenReduction',
'aiRules', 'aiRules',
'aiCache', 'aiCache',
'theme', 'theme'
'showDebugTab',
'lastPayload',
'lastFullText',
'lastPromptText'
]); ]);
const tabButtons = document.querySelectorAll('#main-tabs li'); const tabButtons = document.querySelectorAll('#main-tabs li');
const tabs = document.querySelectorAll('.tab-content'); const tabs = document.querySelectorAll('.tab-content');
@ -68,43 +63,16 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
await applyTheme(themeSelect.value); await applyTheme(themeSelect.value);
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 || '';
let lastPayload = defaults.lastPayload ? JSON.stringify(defaults.lastPayload, null, 2) : '';
if (lastPayload) {
payloadDisplay.textContent = lastPayload;
}
themeSelect.addEventListener('change', async () => { themeSelect.addEventListener('change', async () => {
markDirty(); markDirty();
await applyTheme(themeSelect.value); await applyTheme(themeSelect.value);
}); });
const endpointInput = document.getElementById('endpoint'); document.getElementById('endpoint').value = defaults.endpoint || 'http://127.0.0.1:5000/v1/completions';
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 = { const templates = {
openai: browser.i18n.getMessage('template.openai'), openai: browser.i18n.getMessage('template.openai'),
qwen: browser.i18n.getMessage('template.qwen'), qwen: browser.i18n.getMessage('template.qwen'),
mistral: browser.i18n.getMessage('template.mistral'), mistral: browser.i18n.getMessage('template.mistral'),
harmony: browser.i18n.getMessage('template.harmony'),
custom: browser.i18n.getMessage('template.custom') custom: browser.i18n.getMessage('template.custom')
}; };
const templateSelect = document.getElementById('template'); const templateSelect = document.getElementById('template');
@ -147,143 +115,6 @@ document.addEventListener('DOMContentLoaded', async () => {
const collapseWhitespaceToggle = document.getElementById('collapse-whitespace'); const collapseWhitespaceToggle = document.getElementById('collapse-whitespace');
collapseWhitespaceToggle.checked = defaults.collapseWhitespace === true; collapseWhitespaceToggle.checked = defaults.collapseWhitespace === true;
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');
const errorTabBtn = document.getElementById('errors-tab-button');
const errorsEmpty = document.getElementById('errors-empty');
const errorsPanel = document.getElementById('errors-panel');
const errorsList = document.getElementById('errors-list');
const errorsCount = document.getElementById('errors-count');
function updateDebugTab() {
const visible = debugTabToggle.checked;
debugTabBtn.classList.toggle('is-hidden', !visible);
}
debugTabToggle.checked = defaults.showDebugTab === true;
debugTabToggle.addEventListener('change', () => { updateDebugTab(); markDirty(); });
updateDebugTab();
function formatErrorTime(value) {
try {
return new Date(value).toLocaleString();
} catch (e) {
return '';
}
}
function renderErrors(entries = []) {
const hasErrors = entries.length > 0;
errorTabBtn.classList.toggle('is-hidden', !hasErrors);
errorsEmpty.classList.toggle('is-hidden', hasErrors);
errorsPanel.classList.toggle('is-hidden', !hasErrors);
errorsList.innerHTML = '';
errorsCount.textContent = String(entries.length);
if (!hasErrors) {
return;
}
entries.forEach(entry => {
const card = document.createElement('article');
card.className = 'message is-danger is-light mb-4';
const header = document.createElement('div');
header.className = 'message-header';
const title = document.createElement('p');
title.textContent = entry.context || 'Error';
const time = document.createElement('span');
time.className = 'is-size-7 has-text-weight-normal';
time.textContent = formatErrorTime(entry.time);
header.appendChild(title);
header.appendChild(time);
const body = document.createElement('div');
body.className = 'message-body';
const summary = document.createElement('p');
summary.className = 'mb-2';
summary.textContent = entry.message || 'Unknown error';
body.appendChild(summary);
if (entry.detail) {
const detail = document.createElement('pre');
detail.className = 'is-family-monospace is-size-7';
detail.textContent = entry.detail;
body.appendChild(detail);
}
card.appendChild(header);
card.appendChild(body);
errorsList.appendChild(card);
});
}
async function loadErrors() {
try {
const response = await browser.runtime.sendMessage({ type: 'sortana:getErrorLog' });
renderErrors(response?.errors || []);
} catch (e) {
renderErrors([]);
}
}
browser.runtime.onMessage.addListener((msg) => {
if (msg?.type === 'sortana:errorLogUpdated') {
loadErrors();
}
});
await loadErrors();
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 || {}); const aiParams = Object.assign({}, DEFAULT_AI_PARAMS, defaults.aiParams || {});
for (const [key, val] of Object.entries(aiParams)) { for (const [key, val] of Object.entries(aiParams)) {
const el = document.getElementById(key); const el = document.getElementById(key);
@ -292,7 +123,6 @@ document.addEventListener('DOMContentLoaded', async () => {
let tagList = []; let tagList = [];
let folderList = []; let folderList = [];
let accountList = [];
try { try {
tagList = await messenger.messages.tags.list(); tagList = await messenger.messages.tags.list();
} catch (e) { } catch (e) {
@ -300,7 +130,6 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
try { try {
const accounts = await messenger.accounts.list(true); const accounts = await messenger.accounts.list(true);
accountList = accounts.map(a => ({ id: a.id, name: a.name }));
const collect = (f, prefix='') => { const collect = (f, prefix='') => {
folderList.push({ id: f.id ?? f.path, name: prefix + f.name }); folderList.push({ id: f.id ?? f.path, name: prefix + f.name });
(f.subFolders || []).forEach(sf => collect(sf, prefix + f.name + '/')); (f.subFolders || []).forEach(sf => collect(sf, prefix + f.name + '/'));
@ -451,39 +280,6 @@ document.addEventListener('DOMContentLoaded', async () => {
return row; return row;
} }
function createConditionButton(label, sectionEl, checkbox, clearFn) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'button is-small is-light';
const icon = document.createElement('img');
icon.width = 16;
icon.height = 16;
icon.className = 'mr-1';
btn.appendChild(icon);
btn.append(label);
let active = checkbox ? checkbox.checked : sectionEl && !sectionEl.classList.contains('is-hidden');
function update() {
btn.classList.toggle('is-active', active);
icon.src = browser.runtime.getURL(`resources/svg/${active ? 'circledot' : 'circle'}.svg`);
if (sectionEl) sectionEl.classList.toggle('is-hidden', !active);
if (checkbox) checkbox.checked = active;
if (!active && typeof clearFn === 'function') {
clearFn();
}
}
btn.addEventListener('click', () => {
active = !active;
markDirty();
update();
});
update();
return btn;
}
function renderRules(rules = []) { function renderRules(rules = []) {
ruleCountEl.textContent = rules.length; ruleCountEl.textContent = rules.length;
rulesContainer.innerHTML = ''; rulesContainer.innerHTML = '';
@ -517,67 +313,16 @@ document.addEventListener('DOMContentLoaded', async () => {
const header = document.createElement('div'); const header = document.createElement('div');
header.className = 'message-header'; header.className = 'message-header';
header.appendChild(critInput);
const leftWrap = document.createElement('div');
leftWrap.style.display = 'flex';
leftWrap.style.alignItems = 'center';
leftWrap.style.flexGrow = '1';
const statusSpan = document.createElement('span');
statusSpan.className = 'rule-status has-text-weight-semibold mr-2';
leftWrap.appendChild(statusSpan);
leftWrap.appendChild(critInput);
header.appendChild(leftWrap);
const btnWrap = document.createElement('div');
btnWrap.style.display = 'flex';
btnWrap.style.gap = '0.25em';
let enabled = rule.enabled !== false;
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = 'button is-small is-light rule-toggle';
const toggleIcon = document.createElement('img');
toggleIcon.width = 16;
toggleIcon.height = 16;
toggleBtn.appendChild(toggleIcon);
const delBtn = document.createElement('button'); const delBtn = document.createElement('button');
delBtn.type = 'button'; delBtn.className = 'delete';
delBtn.className = 'button is-small is-danger is-light rule-delete'; delBtn.setAttribute('aria-label', 'delete');
const delIcon = document.createElement('img');
delIcon.src = browser.runtime.getURL('resources/svg/trash.svg');
delIcon.width = 16;
delIcon.height = 16;
delBtn.appendChild(delIcon);
function updateToggle() {
toggleIcon.src = browser.runtime.getURL(
`resources/svg/${enabled ? 'circleslash' : 'check'}.svg`
);
statusSpan.textContent = enabled ? '' : '(Disabled)';
article.dataset.enabled = String(enabled);
}
toggleBtn.addEventListener('click', () => {
enabled = !enabled;
markDirty();
updateToggle();
});
delBtn.addEventListener('click', () => { delBtn.addEventListener('click', () => {
article.remove(); article.remove();
ruleCountEl.textContent = rulesContainer.querySelectorAll('.rule').length; ruleCountEl.textContent = rulesContainer.querySelectorAll('.rule').length;
markDirty();
}); });
header.appendChild(delBtn);
btnWrap.appendChild(toggleBtn);
btnWrap.appendChild(delBtn);
header.appendChild(btnWrap);
updateToggle();
const actionsContainer = document.createElement('div'); const actionsContainer = document.createElement('div');
actionsContainer.className = 'rule-actions mb-2'; actionsContainer.className = 'rule-actions mb-2';
@ -593,7 +338,7 @@ document.addEventListener('DOMContentLoaded', async () => {
addAction.addEventListener('click', () => actionsContainer.appendChild(createActionRow())); addAction.addEventListener('click', () => actionsContainer.appendChild(createActionRow()));
const stopLabel = document.createElement('label'); const stopLabel = document.createElement('label');
stopLabel.className = 'checkbox mt-2 is-hidden'; stopLabel.className = 'checkbox mt-2';
const stopCheck = document.createElement('input'); const stopCheck = document.createElement('input');
stopCheck.type = 'checkbox'; stopCheck.type = 'checkbox';
stopCheck.className = 'stop-processing'; stopCheck.className = 'stop-processing';
@ -602,7 +347,7 @@ document.addEventListener('DOMContentLoaded', async () => {
stopLabel.append(' Stop after match'); stopLabel.append(' Stop after match');
const unreadLabel = document.createElement('label'); const unreadLabel = document.createElement('label');
unreadLabel.className = 'checkbox mt-2 ml-4 is-hidden'; unreadLabel.className = 'checkbox mt-2 ml-4';
const unreadCheck = document.createElement('input'); const unreadCheck = document.createElement('input');
unreadCheck.type = 'checkbox'; unreadCheck.type = 'checkbox';
unreadCheck.className = 'unread-only'; unreadCheck.className = 'unread-only';
@ -610,121 +355,12 @@ document.addEventListener('DOMContentLoaded', async () => {
unreadLabel.appendChild(unreadCheck); unreadLabel.appendChild(unreadCheck);
unreadLabel.append(' Only apply to unread messages'); unreadLabel.append(' Only apply to unread messages');
const ageBox = document.createElement('div');
ageBox.className = 'field is-grouped mt-2 is-hidden';
const minInput = document.createElement('input');
minInput.type = 'number';
minInput.placeholder = 'Min days';
minInput.className = 'input is-small min-age mr-2';
minInput.style.width = '6em';
if (typeof rule.minAgeDays === 'number') minInput.value = rule.minAgeDays;
const maxInput = document.createElement('input');
maxInput.type = 'number';
maxInput.placeholder = 'Max days';
maxInput.className = 'input is-small max-age';
maxInput.style.width = '6em';
if (typeof rule.maxAgeDays === 'number') maxInput.value = rule.maxAgeDays;
ageBox.appendChild(minInput);
ageBox.appendChild(maxInput);
const acctBox = document.createElement('div');
acctBox.className = 'field mt-2 is-hidden';
const acctLabel = document.createElement('label');
acctLabel.className = 'label';
acctLabel.textContent = 'Accounts';
const acctControl = document.createElement('div');
const acctWrap = document.createElement('div');
acctWrap.className = 'select is-multiple is-small';
const acctSel = document.createElement('select');
acctSel.className = 'account-select';
acctSel.multiple = true;
acctSel.size = Math.min(accountList.length, 4) || 1;
for (const a of accountList) {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.name;
if ((rule.accounts || []).includes(a.id)) opt.selected = true;
acctSel.appendChild(opt);
}
acctWrap.appendChild(acctSel);
acctControl.appendChild(acctWrap);
acctBox.appendChild(acctLabel);
acctBox.appendChild(acctControl);
const folderBox = document.createElement('div');
folderBox.className = 'field mt-2 is-hidden';
const folderLabel = document.createElement('label');
folderLabel.className = 'label';
folderLabel.textContent = 'Folders';
const folderControl = document.createElement('div');
const folderWrap = document.createElement('div');
folderWrap.className = 'select is-multiple is-small';
const folderSel = document.createElement('select');
folderSel.className = 'folder-filter-select';
folderSel.multiple = true;
folderSel.size = Math.min(folderList.length, 6) || 1;
for (const f of folderList) {
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = f.name;
if ((rule.folders || []).includes(f.id)) opt.selected = true;
folderSel.appendChild(opt);
}
folderWrap.appendChild(folderSel);
folderControl.appendChild(folderWrap);
folderBox.appendChild(folderLabel);
folderBox.appendChild(folderControl);
if (typeof rule.minAgeDays === 'number' || typeof rule.maxAgeDays === 'number') {
ageBox.classList.remove('is-hidden');
}
if ((rule.accounts || []).length) {
acctBox.classList.remove('is-hidden');
}
if ((rule.folders || []).length) {
folderBox.classList.remove('is-hidden');
}
const condButtons = document.createElement('div');
condButtons.className = 'field is-grouped is-grouped-multiline mb-2';
function addCond(btn) {
const p = document.createElement('p');
p.className = 'control';
p.appendChild(btn);
condButtons.appendChild(p);
}
addCond(createConditionButton('Stop', null, stopCheck, () => {
stopCheck.checked = false;
}));
addCond(createConditionButton('Unread', null, unreadCheck, () => {
unreadCheck.checked = false;
}));
addCond(createConditionButton('Age', ageBox, null, () => {
minInput.value = '';
maxInput.value = '';
}));
addCond(createConditionButton('Accounts', acctBox, null, () => {
for (const opt of acctSel.options) opt.selected = false;
}));
addCond(createConditionButton('Folders', folderBox, null, () => {
for (const opt of folderSel.options) opt.selected = false;
}));
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'message-body'; body.className = 'message-body';
body.appendChild(actionsContainer); body.appendChild(actionsContainer);
body.appendChild(addAction); body.appendChild(addAction);
const condDivider = document.createElement('hr');
condDivider.className = 'mt-3 mb-2';
body.appendChild(condDivider);
body.appendChild(condButtons);
body.appendChild(stopLabel); body.appendChild(stopLabel);
body.appendChild(unreadLabel); body.appendChild(unreadLabel);
body.appendChild(ageBox);
body.appendChild(acctBox);
body.appendChild(folderBox);
article.appendChild(header); article.appendChild(header);
article.appendChild(body); article.appendChild(body);
@ -763,31 +399,14 @@ document.addEventListener('DOMContentLoaded', async () => {
}); });
const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked; const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked;
const unreadOnly = ruleEl.querySelector('.unread-only')?.checked; const unreadOnly = ruleEl.querySelector('.unread-only')?.checked;
const enabled = ruleEl.dataset.enabled !== 'false'; return { criterion, actions, unreadOnly, stopProcessing };
const minAgeDays = parseFloat(ruleEl.querySelector('.min-age')?.value);
const maxAgeDays = parseFloat(ruleEl.querySelector('.max-age')?.value);
const accounts = [...(ruleEl.querySelector('.account-select')?.selectedOptions || [])].map(o => o.value);
const folders = [...(ruleEl.querySelector('.folder-filter-select')?.selectedOptions || [])].map(o => o.value);
const rule = { criterion, actions, enabled };
if (unreadOnly) rule.unreadOnly = true;
if (stopProcessing) rule.stopProcessing = true;
if (!isNaN(minAgeDays)) rule.minAgeDays = minAgeDays;
if (!isNaN(maxAgeDays)) rule.maxAgeDays = maxAgeDays;
if (accounts.length) rule.accounts = accounts;
if (folders.length) rule.folders = folders;
return rule;
}); });
data.push({ criterion: '', actions: [], unreadOnly: false, stopProcessing: false, enabled: true, accounts: [], folders: [] }); data.push({ criterion: '', actions: [], unreadOnly: false, stopProcessing: false });
renderRules(data); renderRules(data);
}); });
renderRules((defaults.aiRules || []).map(r => { renderRules((defaults.aiRules || []).map(r => {
if (r.actions) { if (r.actions) return r;
if (!Array.isArray(r.accounts)) r.accounts = [];
if (!Array.isArray(r.folders)) r.folders = [];
if (r.enabled !== false) r.enabled = true; else r.enabled = false;
return r;
}
const actions = []; const actions = [];
if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); if (r.tag) actions.push({ type: 'tag', tagKey: r.tag });
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
@ -795,11 +414,6 @@ document.addEventListener('DOMContentLoaded', async () => {
const rule = { criterion: r.criterion, actions }; const rule = { criterion: r.criterion, actions };
if (r.stopProcessing) rule.stopProcessing = true; if (r.stopProcessing) rule.stopProcessing = true;
if (r.unreadOnly) rule.unreadOnly = true; if (r.unreadOnly) rule.unreadOnly = true;
if (typeof r.minAgeDays === 'number') rule.minAgeDays = r.minAgeDays;
if (typeof r.maxAgeDays === 'number') rule.maxAgeDays = r.maxAgeDays;
if (Array.isArray(r.accounts)) rule.accounts = r.accounts;
if (Array.isArray(r.folders)) rule.folders = r.folders;
rule.enabled = r.enabled !== false;
return rule; return rule;
})); }));
@ -867,22 +481,6 @@ document.addEventListener('DOMContentLoaded', async () => {
} catch { } catch {
cacheCountEl.textContent = '?'; cacheCountEl.textContent = '?';
} }
try {
if (debugTabToggle.checked) {
const latest = await storage.local.get(['lastPayload', 'lastFullText', 'lastPromptText']);
const payloadStr = latest.lastPayload ? JSON.stringify(latest.lastPayload, null, 2) : '';
if (payloadStr !== lastPayload) {
lastPayload = payloadStr;
payloadDisplay.textContent = payloadStr;
}
if (latest.lastFullText !== lastFullText || latest.lastPromptText !== lastPromptText) {
lastFullText = latest.lastFullText || '';
lastPromptText = latest.lastPromptText || '';
updateDiffDisplay();
}
}
} catch {}
} }
refreshMaintenance(); refreshMaintenance();
@ -913,7 +511,7 @@ document.addEventListener('DOMContentLoaded', async () => {
initialized = true; initialized = true;
document.getElementById('save').addEventListener('click', async () => { document.getElementById('save').addEventListener('click', async () => {
const endpoint = endpointInput.value.trim(); const endpoint = document.getElementById('endpoint').value;
const templateName = templateSelect.value; const templateName = templateSelect.value;
const customTemplateText = customTemplate.value; const customTemplateText = customTemplate.value;
const customSystemPrompt = systemBox.value; const customSystemPrompt = systemBox.value;
@ -959,27 +557,13 @@ document.addEventListener('DOMContentLoaded', async () => {
}); });
const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked; const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked;
const unreadOnly = ruleEl.querySelector('.unread-only')?.checked; const unreadOnly = ruleEl.querySelector('.unread-only')?.checked;
const enabled = ruleEl.dataset.enabled !== 'false'; return { criterion, actions, unreadOnly, stopProcessing };
const minAgeDays = parseFloat(ruleEl.querySelector('.min-age')?.value);
const maxAgeDays = parseFloat(ruleEl.querySelector('.max-age')?.value);
const accounts = [...(ruleEl.querySelector('.account-select')?.selectedOptions || [])].map(o => o.value);
const folders = [...(ruleEl.querySelector('.folder-filter-select')?.selectedOptions || [])].map(o => o.value);
const rule = { criterion, actions, enabled };
if (unreadOnly) rule.unreadOnly = true;
if (stopProcessing) rule.stopProcessing = true;
if (!isNaN(minAgeDays)) rule.minAgeDays = minAgeDays;
if (!isNaN(maxAgeDays)) rule.maxAgeDays = maxAgeDays;
if (accounts.length) rule.accounts = accounts;
if (folders.length) rule.folders = folders;
return rule;
}).filter(r => r.criterion); }).filter(r => r.criterion);
const stripUrlParams = stripUrlToggle.checked; const stripUrlParams = stripUrlToggle.checked;
const altTextImages = altTextToggle.checked; const altTextImages = altTextToggle.checked;
const collapseWhitespace = collapseWhitespaceToggle.checked; const collapseWhitespace = collapseWhitespaceToggle.checked;
const tokenReduction = tokenReductionToggle.checked;
const showDebugTab = debugTabToggle.checked;
const theme = themeSelect.value; const theme = themeSelect.value;
await storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, tokenReduction, aiRules: rules, theme, showDebugTab }); await storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, aiRules: rules, theme });
await applyTheme(theme); await applyTheme(theme);
try { try {
await AiClassifier.setConfig({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging }); await AiClassifier.setConfig({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging });

View file

@ -1,21 +0,0 @@
<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI.
Knowledge cutoff: 2024-06
Current date: 2025-06-28
Reasoning: medium
# Valid channels: analysis, commentary, final. Channel must be included for every message.<|end|>
<|start|>developer<|message|># Instructions
{{system}}<|end|>
<|start|>user<|message|>**Email Contents**
```
{{email}}
```
Classification Criterion: {{query}}
Remember, return ONLY a JSON object on a single line of the form:
{"match": true, "reason": "<short explanation>"} - if the email satisfies the criterion
{"match": false, "reason": "<short explanation>"} - otherwise
Do not add any other keys, text, or formatting.<|end|>
<|start|>assistant

View file

@ -5,8 +5,8 @@ Email:
Criterion: {{query}} Criterion: {{query}}
Remember, return ONLY a JSON object on a single line of the form: Remember, return ONLY a JSON object on a single line of the form:
{"match": true, "reason": "<short explanation>"} - if the email satisfies the criterion {"match": true} - if the email satisfies the criterion
{"match": false, "reason": "<short explanation>"} - otherwise {"match": false} - otherwise
Do not add any other keys, text, or formatting. Do not add any other keys, text, or formatting.
[/INST] [/INST]

View file

@ -7,8 +7,8 @@
``` ```
Classification Criterion: {{query}} Classification Criterion: {{query}}
Remember, return ONLY a JSON object on a single line of the form: Remember, return ONLY a JSON object on a single line of the form:
{"match": true, "reason": "<short explanation>"} - if the email satisfies the criterion {"match": true} - if the email satisfies the criterion
{"match": false, "reason": "<short explanation>"} - otherwise {"match": false} - otherwise
Do not add any other keys, text, or formatting.<|im_end|> Do not add any other keys, text, or formatting.<|im_end|>
<|im_start|>assistant <|im_start|>assistant

View file

@ -7,8 +7,8 @@ Email:
Criterion: {{query}} Criterion: {{query}}
Remember, return ONLY a JSON object on a single line of the form: Remember, return ONLY a JSON object on a single line of the form:
{"match": true, "reason": "<short explanation>"} - if the email satisfies the criterion {"match": true} - if the email satisfies the criterion
{"match": false, "reason": "<short explanation>"} - otherwise {"match": false} - otherwise
Do not add any other keys, text, or formatting. Do not add any other keys, text, or formatting.
<|im_end|> <|im_end|>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 828 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 408 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 813 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 764 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 B

File diff suppressed because it is too large Load diff

View file

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24">
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"/>
<path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 357 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24">
<path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm-7-3L19 5"/>
</svg>

Before

Width:  |  Height:  |  Size: 317 B

View file

@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" data-attribution="cc0-icons" viewBox="0 0 24 24">
<path d="M21 12H3m9-9v18"/>
</svg>

Before

Width:  |  Height:  |  Size: 250 B