From eb02c6f3bdd59a7deb5877449798c76884c17493 Mon Sep 17 00:00:00 2001 From: Jordan Wages Date: Sun, 15 Jun 2025 21:55:35 -0500 Subject: [PATCH] Initial Commit --- _locales/en-US/messages.json | 8 + ai-filter.sln | 64 +++++++ background.js | 69 +++++++ build-xpi.ps1 | 75 ++++++++ content/filterEditor.js | 50 +++++ experiment/DomContentScript/implementation.js | 80 ++++++++ experiment/DomContentScript/schema.json | 25 +++ experiment/api.js | 89 +++++++++ experiment/schema.json | 25 +++ manifest.json | 32 ++++ modules/ExpressionSearchFilter.jsm | 175 ++++++++++++++++++ options/options.html | 12 ++ options/options.js | 14 ++ 13 files changed, 718 insertions(+) create mode 100644 _locales/en-US/messages.json create mode 100644 ai-filter.sln create mode 100644 background.js create mode 100644 build-xpi.ps1 create mode 100644 content/filterEditor.js create mode 100644 experiment/DomContentScript/implementation.js create mode 100644 experiment/DomContentScript/schema.json create mode 100644 experiment/api.js create mode 100644 experiment/schema.json create mode 100644 manifest.json create mode 100644 modules/ExpressionSearchFilter.jsm create mode 100644 options/options.html create mode 100644 options/options.js diff --git a/_locales/en-US/messages.json b/_locales/en-US/messages.json new file mode 100644 index 0000000..02b6d40 --- /dev/null +++ b/_locales/en-US/messages.json @@ -0,0 +1,8 @@ +{ + "classification": { "message": "AI classification" }, + "matches": { "message": "matches" }, + "doesntMatch": { "message": "doesn't match" }, + "options.title": { "message": "AI Filter Options" }, + "options.endpoint": { "message": "Endpoint" }, + "options.save": { "message": "Save" } +} diff --git a/ai-filter.sln b/ai-filter.sln new file mode 100644 index 0000000..619e643 --- /dev/null +++ b/ai-filter.sln @@ -0,0 +1,64 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35707.178 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}" + ProjectSection(SolutionItems) = preProject + background.js = background.js + build-xpi.ps1 = build-xpi.ps1 + manifest.json = manifest.json + EndProjectSection + ProjectSection(FolderGlobals) = preProject + Q_5_4Users_4Jordan_4Documents_4Gitea_4thunderbird-ai-filter_4src_4manifest_1json__JsonSchema = + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "experiment", "experiment", "{F2C8C786-FA23-4B63-934C-8CAA39D9BF95}" + ProjectSection(SolutionItems) = preProject + experiment\api.js = experiment\api.js + experiment\schema.json = experiment\schema.json + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "options", "options", "{7372FCA6-B0BC-4968-A748-7ABB17A7929A}" + ProjectSection(SolutionItems) = preProject + options\options.html = options\options.html + options\options.js = options\options.js + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "modules", "modules", "{75ED3C1E-D3C7-4546-9F2E-AC85859DDF4B}" + ProjectSection(SolutionItems) = preProject + modules\ExpressionSearchFilter.jsm = modules\ExpressionSearchFilter.jsm + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_locales", "_locales", "{D446E5C6-BDDE-4091-BD1A-EC57170003CF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "en-US", "en-US", "{8BEA7793-3336-40ED-AB96-7FFB09FEB0F6}" + ProjectSection(SolutionItems) = preProject + _locales\en-US\messages.json = _locales\en-US\messages.json + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DomContentScript", "DomContentScript", "{B334FFB0-4BD2-496E-BDC4-786620E019DA}" + ProjectSection(SolutionItems) = preProject + experiment\DomContentScript\implementation.js = experiment\DomContentScript\implementation.js + experiment\DomContentScript\schema.json = experiment\DomContentScript\schema.json + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "content", "content", "{028FDA4B-AC3E-4A0E-9291-978E213F9B78}" + ProjectSection(SolutionItems) = preProject + content\filterEditor.js = content\filterEditor.js + EndProjectSection +EndProject +Global + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {F2C8C786-FA23-4B63-934C-8CAA39D9BF95} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70} + {7372FCA6-B0BC-4968-A748-7ABB17A7929A} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70} + {75ED3C1E-D3C7-4546-9F2E-AC85859DDF4B} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70} + {D446E5C6-BDDE-4091-BD1A-EC57170003CF} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70} + {8BEA7793-3336-40ED-AB96-7FFB09FEB0F6} = {D446E5C6-BDDE-4091-BD1A-EC57170003CF} + {B334FFB0-4BD2-496E-BDC4-786620E019DA} = {F2C8C786-FA23-4B63-934C-8CAA39D9BF95} + {028FDA4B-AC3E-4A0E-9291-978E213F9B78} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70} + EndGlobalSection +EndGlobal diff --git a/background.js b/background.js new file mode 100644 index 0000000..7508a17 --- /dev/null +++ b/background.js @@ -0,0 +1,69 @@ +/* + * Runs in the **WebExtension (addon)** context. + * For this minimal working version we only expose an async helper + * so UI pages / devtools panels can test the classifier without + * needing Thunderbird’s filter engine. + * + * Note: the filter-engine itself NEVER calls this file – the + * synchronous work is all done in experiment/api.js (chrome side). + */ + +"use strict"; + +// Startup +console.log("[ai-filter] background.js loaded – ready to classify"); +(async () => { + try { + const store = await browser.storage.local.get(["endpoint"]); + await browser.aiFilter.initConfig(store); + console.log("[ai-filter] configuration loaded", store); + try { + await browser.DomContentScript.registerWindow( + "chrome://messenger/content/FilterEditor.xhtml", + "resource://aifilter/content/filterEditor.js" + ); + console.log("[ai-filter] registered FilterEditor content script"); + } catch (e) { + console.error("[ai-filter] failed to register content script", e); + } + } catch (err) { + console.error("[ai-filter] failed to load config:", err); + } +})(); + +// Listen for messages from UI/devtools +browser.runtime.onMessage.addListener((msg) => { + console.log("[ai-filter] onMessage received:", msg); + + if (msg?.type === "aiFilter:test") { + const { text = "", criterion = "" } = msg; + console.log("[ai-filter] aiFilter:test – text:", text); + console.log("[ai-filter] aiFilter:test – criterion:", criterion); + + try { + console.log("[ai-filter] Calling browser.aiFilter.classify()"); + const result = browser.aiFilter.classify(text, criterion); + console.log("[ai-filter] classify() returned:", result); + return { match: result }; + } + catch (err) { + console.error("[ai-filter] Error in classify():", err); + // rethrow so the caller sees the failure + throw err; + } + } + else { + console.warn("[ai-filter] Unknown message type, ignoring:", msg?.type); + } +}); + +// Catch any unhandled rejections +window.addEventListener("unhandledrejection", ev => { + console.error("[ai-filter] Unhandled promise rejection:", ev.reason); +}); + +browser.runtime.onInstalled.addListener(async ({ reason }) => { + if (reason === "install") { + await browser.runtime.openOptionsPage(); + } +}); diff --git a/build-xpi.ps1 b/build-xpi.ps1 new file mode 100644 index 0000000..92a9474 --- /dev/null +++ b/build-xpi.ps1 @@ -0,0 +1,75 @@ +<# +.SYNOPSIS + Bullet-proof packager: uses .NET ZipFile to preserve folders. + +.DESCRIPTION + • Reads version from manifest.json (no comments allowed) + • Gathers all files under the project (excludes .sln, .ps1, release/, .vs/, .git/) + • Creates a .zip with each entry’s name set to its relative path + • Renames .zip → .xpi +#> + +# 1) Locate +$ScriptDir = Split-Path $MyInvocation.MyCommand.Path +$ReleaseDir = Join-Path $ScriptDir 'release' +$Manifest = Join-Path $ScriptDir 'manifest.json' + +# 2) Prep release folder +if (-not (Test-Path $ReleaseDir)) { + New-Item -ItemType Directory -Path $ReleaseDir | Out-Null +} + +# 3) Read manifest.json (must be pure JSON) +$version = (Get-Content $Manifest -Raw | ConvertFrom-Json).version +if (-not $version) { + Write-Error "No version found in manifest.json"; exit 1 +} + +# 4) Define output names & clean up +$xpiName = "ai-filter-$version.xpi" +$zipPath = Join-Path $ReleaseDir "ai-filter-$version.zip" +$xpiPath = Join-Path $ReleaseDir $xpiName + +Remove-Item -Path $zipPath,$xpiPath -Force -ErrorAction SilentlyContinue + +# 5) Collect files to include +$allFiles = Get-ChildItem -Path $ScriptDir -Recurse -File | + Where-Object { + $_.Extension -notin '.sln','.ps1' -and + $_.FullName -notmatch '\\release\\' -and + $_.FullName -notmatch '\\.vs\\' -and + $_.FullName -notmatch '\\.git\\' + } + +foreach ($file in $allFiles) { + $size = (Get-Item $file.FullName).Length + Write-Host "Zipping: $entryName ← $($file.FullName) ($size bytes)" +} + +if ($allFiles.Count -eq 0) { + Write-Warning "No files found to package."; exit 0 +} + +# 6) Load .NET ZipFile +Add-Type -AssemblyName System.IO.Compression.FileSystem + +# 7) Create zip and add each file with its relative path +$zip = [System.IO.Compression.ZipFile]::Open($zipPath, 'Create') +foreach ($file in $allFiles) { + # Compute entry name (relative, forward-slashed) + $rel = $file.FullName.Substring($ScriptDir.Length + 1).TrimStart('\') + $entryName = $rel.Replace('\', '/') + + [System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $zip, + $file.FullName, + $entryName, + [System.IO.Compression.CompressionLevel]::Optimal + ) +} +$zip.Dispose() + +# 8) Rename zip → xpi +Rename-Item -Path $zipPath -NewName $xpiName -Force + +Write-Host "✅ Built XPI at: $xpiPath" diff --git a/content/filterEditor.js b/content/filterEditor.js new file mode 100644 index 0000000..cbd56bd --- /dev/null +++ b/content/filterEditor.js @@ -0,0 +1,50 @@ +(function() { + function patch(container) { + if (!container || container.getAttribute("ai-filter-patched") === "true") { + return; + } + while (container.firstChild) { + container.firstChild.remove(); + } + let frag = window.MozXULElement.parseXULToFragment( + ` + ` + ); + container.appendChild(frag); + if (container.hasAttribute("value")) { + container.firstChild.value = container.getAttribute("value"); + } + container.classList.add("flexelementcontainer"); + container.setAttribute("ai-filter-patched", "true"); + } + + function check(node) { + if (!(node instanceof Element)) { + return; + } + if ( + node.classList.contains("search-value-custom") && + node.getAttribute("searchAttribute") === "aifilter#classification" + ) { + patch(node); + } + node + .querySelectorAll('.search-value-custom[searchAttribute="aifilter#classification"]') + .forEach(patch); + } + + const observer = new MutationObserver(mutations => { + for (let mutation of mutations) { + if (mutation.type === "childList") { + mutation.addedNodes.forEach(check); + } else if (mutation.type === "attributes") { + check(mutation.target); + } + } + }); + + const termList = document.getElementById("searchTermList") || document; + observer.observe(termList, { childList: true, attributes: true, subtree: true }); + check(termList); +})(); diff --git a/experiment/DomContentScript/implementation.js b/experiment/DomContentScript/implementation.js new file mode 100644 index 0000000..541facd --- /dev/null +++ b/experiment/DomContentScript/implementation.js @@ -0,0 +1,80 @@ + + +var { AppConstants } = ChromeUtils.importESModule("resource://gre/modules/AppConstants.sys.mjs"); +var DomContent_ESM = parseInt(AppConstants.MOZ_APP_VERSION, 10) >= 128; + +var { ExtensionCommon } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionCommon.sys.mjs" +); + +var { ExtensionUtils } = DomContent_ESM + ? ChromeUtils.importESModule("resource://gre/modules/ExtensionUtils.sys.mjs") + : ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm"); + +var { ExtensionError } = ExtensionUtils; + +var registeredWindows = new Map(); + +var DomContentScript = class extends ExtensionCommon.ExtensionAPI { + constructor(extension) { + super(extension); + + this._windowListener = { + // nsIWindowMediatorListener functions + onOpenWindow(appWindow) { + // A new window has opened. + let domWindow = appWindow.docShell.domWindow; + + /** + * Set up listeners to run the callbacks on the given window. + * + * @param aWindow {nsIDOMWindow} The window to set up. + * @param aID {String} Optional. ID of the new caller that has registered right now. + */ + domWindow.addEventListener( + "DOMContentLoaded", + function() { + // do stuff + let windowChromeURL = domWindow.document.location.href; + if (registeredWindows.has(windowChromeURL)) { + let jsPath = registeredWindows.get(windowChromeURL); + Services.scriptloader.loadSubScript(jsPath, domWindow, "UTF-8"); + } + }, + { once: true } + ); + }, + + onCloseWindow(appWindow) { + // One of the windows has closed. + let domWindow = appWindow.docShell.domWindow; // we don't need to do anything (script only loads once) + }, + }; + + Services.wm.addListener(this._windowListener); + + } + + + + + + onShutdown(isAppShutdown) { + if (isAppShutdown) { + return; // the application gets unloaded anyway + } + Services.wm.removeListener(this._windowListener); + } + + getAPI(context) { + /** API IMPLEMENTATION **/ + return { + DomContentScript: { + // only returns something, if a user pref value is set + registerWindow: async function (windowUrl,jsPath) { + registeredWindows.set(windowUrl,jsPath); + } + }, + }; + } +}; diff --git a/experiment/DomContentScript/schema.json b/experiment/DomContentScript/schema.json new file mode 100644 index 0000000..32d779c --- /dev/null +++ b/experiment/DomContentScript/schema.json @@ -0,0 +1,25 @@ +[ + { + "namespace": "DomContentScript", + "functions": [ + { + "name": "registerWindow", + "type": "function", + "async": true, + "description": "Register a script for onDOMContentLoaded", + "parameters": [ + { + "name": "windowUrl", + "type": "string", + "description": "chrome URL of the window " + }, + { + "name": "jsPath", + "type": "string", + "description": "chrome URL of the script" + } + ] + } + ] + } +] diff --git a/experiment/api.js b/experiment/api.js new file mode 100644 index 0000000..eb056cf --- /dev/null +++ b/experiment/api.js @@ -0,0 +1,89 @@ +var { ExtensionCommon } = ChromeUtils.importESModule("resource://gre/modules/ExtensionCommon.sys.mjs"); +var { Services } = globalThis || ChromeUtils.importESModule("resource://gre/modules/Services.sys.mjs"); +var { MailServices } = ChromeUtils.importESModule("resource:///modules/MailServices.sys.mjs"); + +console.log("[ai-filter][api] Experiment API module loaded"); + +var resProto = Cc["@mozilla.org/network/protocol;1?name=resource"] + .getService(Ci.nsISubstitutingProtocolHandler); + +function registerResourceUrl(extension, namespace) { + console.log(`[ai-filter][api] registerResourceUrl called for namespace="${namespace}"`); + if (resProto.hasSubstitution(namespace)) { + console.log(`[ai-filter][api] namespace="${namespace}" already registered, skipping`); + return; + } + let uri = Services.io.newURI(".", null, extension.rootURI); + console.log(`[ai-filter][api] setting substitution for "${namespace}" → ${uri.spec}`); + resProto.setSubstitutionWithFlags(namespace, uri, resProto.ALLOW_CONTENT_ACCESS); +} + +var gTerm; +var AIFilterMod; + +var aiFilter = class extends ExtensionCommon.ExtensionAPI { + async onStartup() { + console.log("[ai-filter][api] onStartup()"); + let { extension } = this; + + registerResourceUrl(extension, "aifilter"); + + + try { + console.log("[ai-filter][api] importing ExpressionSearchFilter.jsm"); + AIFilterMod = ChromeUtils.import("resource://aifilter/modules/ExpressionSearchFilter.jsm"); + console.log("[ai-filter][api] ExpressionSearchFilter.jsm import succeeded"); + } + catch (err) { + console.error("[ai-filter][api] failed to import ExpressionSearchFilter.jsm:", err); + } + } + + onShutdown(isAppShutdown) { + console.log("[ai-filter][api] onShutdown(), isAppShutdown =", isAppShutdown); + if (!isAppShutdown && resProto.hasSubstitution("aifilter")) { + console.log("[ai-filter][api] removing substitution for namespace='aifilter'"); + resProto.setSubstitution("aifilter", null); + } + } + + getAPI(context) { + console.log("[ai-filter][api] getAPI()"); + return { + aiFilter: { + initConfig: async (config) => { + try { + if (AIFilterMod?.AIFilter?.setConfig) { + AIFilterMod.AIFilter.setConfig(config); + console.log("[ai-filter][api] configuration applied", config); + } + } catch (err) { + console.error("[ai-filter][api] failed to apply config:", err); + } + }, + classify: (msg) => { + console.log("[ai-filter][api] classify() called with msg:", msg); + try { + if (!gTerm) { + console.log("[ai-filter][api] instantiating new ClassificationTerm"); + let mod = AIFilterMod || ChromeUtils.import("resource://aifilter/modules/ExpressionSearchFilter.jsm"); + gTerm = new mod.ClassificationTerm(); + } + console.log("[ai-filter][api] calling gTerm.match()"); + let matchResult = gTerm.match( + msg.msgHdr, + msg.value, + Ci.nsMsgSearchOp.Contains + ); + console.log("[ai-filter][api] gTerm.match() returned:", matchResult); + return matchResult; + } + catch (err) { + console.error("[ai-filter][api] error in classify():", err); + throw err; + } + } + } + }; + } +}; diff --git a/experiment/schema.json b/experiment/schema.json new file mode 100644 index 0000000..991abc4 --- /dev/null +++ b/experiment/schema.json @@ -0,0 +1,25 @@ +[ + { + "namespace": "aiFilter", + "functions": [ + { + "name": "initConfig", + "type": "function", + "async": true, + "parameters": [ + { "name": "config", "type": "any" } + ] + }, + { + "name": "classify", + "type": "function", + "parameters": [ + { + "name": "msg", + "type": "any" + } + ] + } + ] + } +] diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..c881f06 --- /dev/null +++ b/manifest.json @@ -0,0 +1,32 @@ +{ + "manifest_version": 2, + "name": "AI Filter", + "version": "0.1", + "default_locale": "en-US", + "applications": { "gecko": { "id": "ai-filter@example" } }, + "background": { "scripts": [ "background.js" ] }, + "experiment_apis": { + "aiFilter": { + "schema": "experiment/schema.json", + "parent": { + "scopes": [ "addon_parent" ], + "paths": [ [ "aiFilter" ] ], + "script": "experiment/api.js", + "events": [ "startup" ] + } + }, + "DomContentScript": { + "schema": "experiment/DomContentScript/schema.json", + "parent": { + "scopes": [ "addon_parent" ], + "paths": [ [ "DomContentScript" ] ], + "script": "experiment/DomContentScript/implementation.js" + } + } + }, + "options_ui": { + "page": "options/options.html", + "open_in_tab": false + }, + "permissions": [ "storage" ] +} diff --git a/modules/ExpressionSearchFilter.jsm b/modules/ExpressionSearchFilter.jsm new file mode 100644 index 0000000..1c04e2d --- /dev/null +++ b/modules/ExpressionSearchFilter.jsm @@ -0,0 +1,175 @@ +"use strict"; +var { ExtensionParent } = ChromeUtils.importESModule("resource://gre/modules/ExtensionParent.sys.mjs"); +var { MailServices } = ChromeUtils.importESModule("resource:///modules/MailServices.sys.mjs"); +var { Services } = globalThis || ChromeUtils.importESModule("resource://gre/modules/Services.sys.mjs"); +var { NetUtil } = ChromeUtils.importESModule("resource://gre/modules/NetUtil.sys.mjs"); +var { MimeParser } = ChromeUtils.importESModule("resource:///modules/mimeParser.sys.mjs"); + +var EXPORTED_SYMBOLS = ["AIFilter", "ClassificationTerm"]; + +class CustomerTermBase { + constructor(nameId, operators) { + this.extension = ExtensionParent.GlobalManager.getExtension("ai-filter@example"); + this.id = "aifilter#" + nameId; + this.name = this.extension.localeData.localizeMessage(nameId); + this.operators = operators; + this.cache = new Map(); + + console.log(`[ai-filter][ExpressionSearchFilter] Initialized term base "${this.id}"`); + } + + getEnabled() { + console.log(`[ai-filter][ExpressionSearchFilter] getEnabled() called on "${this.id}"`); + return true; + } + + getAvailable() { + console.log(`[ai-filter][ExpressionSearchFilter] getAvailable() called on "${this.id}"`); + return true; + } + + getAvailableOperators() { + console.log(`[ai-filter][ExpressionSearchFilter] getAvailableOperators() called on "${this.id}"`); + return this.operators; + } + + getAvailableValues() { + console.log(`[ai-filter][ExpressionSearchFilter] getAvailableValues() called on "${this.id}"`); + return null; + } + + get attrib() { + console.log(`[ai-filter][ExpressionSearchFilter] attrib getter called for "${this.id}"`); + + //return Ci.nsMsgSearchAttrib.Custom; + } +} + +function getPlainText(msgHdr) { + console.log(`[ai-filter][ExpressionSearchFilter] Extracting plain text for message ID ${msgHdr.messageId}`); + let folder = msgHdr.folder; + if (!folder.getMsgInputStream) return ""; + let reusable = {}; + let stream = folder.getMsgInputStream(msgHdr, reusable); + let data = NetUtil.readInputStreamToString(stream, msgHdr.messageSize); + if (!reusable.value) stream.close(); + let parser = Cc["@mozilla.org/parserutils;1"].getService(Ci.nsIParserUtils); + return parser.convertToPlainText(data, + Ci.nsIDocumentEncoder.OutputLFLineBreak | + Ci.nsIDocumentEncoder.OutputNoScriptContent | + Ci.nsIDocumentEncoder.OutputNoFramesContent | + Ci.nsIDocumentEncoder.OutputBodyOnly, 0); +} + +let gEndpoint = "http://127.0.0.1:5000/v1/classify"; +function setConfig(config = {}) { + if (config.endpoint) { + gEndpoint = config.endpoint; + } + console.log(`[ai-filter][ExpressionSearchFilter] Endpoint set to ${gEndpoint}`); +} + +function buildPrompt(body, criterion) { + console.log(`[ai-filter][ExpressionSearchFilter] Building prompt with criterion: "${criterion}"`); + return `<|im_start|>system +You are an email-classification assistant. +Read the email below and the classification criterion provided by the user. + +Return ONLY a JSON object on a single line of the form: +{"match": true} - if the email satisfies the criterion +{"match": false} - otherwise + +Do not add any other keys, text, or formatting.<|im_end|> +<|im_start|>user +**Email Contents** +\`\`\` +${body} +\`\`\` +Classification Criteria: ${criterion}<|im_end|> +<|im_start|>assistant`; +} + +class ClassificationTerm extends CustomerTermBase { + constructor() { + super("classification", [Ci.nsMsgSearchOp.Matches, Ci.nsMsgSearchOp.DoesntMatch]); + console.log(`[ai-filter][ExpressionSearchFilter] ClassificationTerm constructed`); + } + + needsBody() { return true; } + + match(msgHdr, value, op) { + const opName = op === Ci.nsMsgSearchOp.Matches ? "matches" : + op === Ci.nsMsgSearchOp.DoesntMatch ? "doesn't match" : `unknown (${op})`; + console.log(`[ai-filter][ExpressionSearchFilter] Matching message ${msgHdr.messageId} using op "${opName}" and value "${value}"`); + + let key = msgHdr.messageId + "|" + op + "|" + value; + if (this.cache.has(key)) { + console.log(`[ai-filter][ExpressionSearchFilter] Cache hit for key: ${key}`); + return this.cache.get(key); + } + + let body = getPlainText(msgHdr); + let payload = JSON.stringify({ + prompt: buildPrompt(body, value), + max_tokens: 4096, + temperature: 1.31, + top_p: 1, + seed: -1, + repetition_penalty: 1.0, + top_k: 0, + min_p: 0.2, + presence_penalty: 0, + frequency_penalty: 0, + typical_p: 1, + tfs: 1 + }); + + + console.log(`[ai-filter][ExpressionSearchFilter] Sending classification request to ${gEndpoint}`); + + let matched = false; + try { + let xhr = new XMLHttpRequest(); + xhr.open("POST", gEndpoint, false); // synchronous request + xhr.setRequestHeader("Content-Type", "application/json"); + xhr.send(payload); + + if (xhr.status < 200 || xhr.status >= 300) { + console.warn(`[ai-filter][ExpressionSearchFilter] HTTP status ${xhr.status}`); + } else { + const result = JSON.parse(xhr.responseText); + const rawText = result.choices?.[0]?.text || ""; + const cleanedText = rawText.replace(/[\s\S]*?<\/think>/gi, "").trim(); + const obj = JSON.parse(cleanedText); + matched = obj.matched === true || obj.match === true; + console.log(`[ai-filter][ExpressionSearchFilter] Received response:`, result); + + console.log(`[ai-filter][ExpressionSearchFilter] Caching:`, key); + this.cache.set(key, matched); + } + } catch (e) { + console.error(`[ai-filter][ExpressionSearchFilter] HTTP request failed:`, e); + } + + if (op === Ci.nsMsgSearchOp.DoesntMatch) { + matched = !matched; + console.log(`[ai-filter][ExpressionSearchFilter] Operator is "doesn't match" → inverting to ${matched}`); + } + + console.log(`[ai-filter][ExpressionSearchFilter] Final match result: ${matched}`); + return matched; + } +} + +(function register() { + console.log(`[ai-filter][ExpressionSearchFilter] Registering custom filter term...`); + let term = new ClassificationTerm(); + if (!MailServices.filters.getCustomTerm(term.id)) { + MailServices.filters.addCustomTerm(term); + console.log(`[ai-filter][ExpressionSearchFilter] Registered term: ${term.id}`); + } else { + console.log(`[ai-filter][ExpressionSearchFilter] Term already registered: ${term.id}`); + } +})(); + +var AIFilter = { setConfig }; diff --git a/options/options.html b/options/options.html new file mode 100644 index 0000000..cddd30a --- /dev/null +++ b/options/options.html @@ -0,0 +1,12 @@ + + + + + AI Filter Options + + +
+ + + + diff --git a/options/options.js b/options/options.js new file mode 100644 index 0000000..8c03ef8 --- /dev/null +++ b/options/options.js @@ -0,0 +1,14 @@ +document.addEventListener('DOMContentLoaded', async () => { + let { endpoint = 'http://127.0.0.1:5000/v1/classify' } = await browser.storage.local.get(['endpoint']); + document.getElementById('endpoint').value = endpoint; +}); + +document.getElementById('save').addEventListener('click', async () => { + const endpoint = document.getElementById('endpoint').value; + await browser.storage.local.set({ endpoint }); + try { + await browser.aiFilter.initConfig({ endpoint }); + } catch (e) { + console.error('[ai-filter][options] failed to apply config', e); + } +});