Initial Commit
This commit is contained in:
parent
950050ea38
commit
eb02c6f3bd
13 changed files with 718 additions and 0 deletions
8
_locales/en-US/messages.json
Normal file
8
_locales/en-US/messages.json
Normal file
|
@ -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" }
|
||||||
|
}
|
64
ai-filter.sln
Normal file
64
ai-filter.sln
Normal file
|
@ -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
|
69
background.js
Normal file
69
background.js
Normal file
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
75
build-xpi.ps1
Normal file
75
build-xpi.ps1
Normal file
|
@ -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"
|
50
content/filterEditor.js
Normal file
50
content/filterEditor.js
Normal file
|
@ -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(
|
||||||
|
`<html:input class="search-value-textbox flexinput ai-filter-textbox" inherits="disabled"
|
||||||
|
onchange="this.parentNode.setAttribute('value', this.value); this.parentNode.value=this.value;">
|
||||||
|
</html:input>`
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
})();
|
80
experiment/DomContentScript/implementation.js
Normal file
80
experiment/DomContentScript/implementation.js
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
25
experiment/DomContentScript/schema.json
Normal file
25
experiment/DomContentScript/schema.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
89
experiment/api.js
Normal file
89
experiment/api.js
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
25
experiment/schema.json
Normal file
25
experiment/schema.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
32
manifest.json
Normal file
32
manifest.json
Normal file
|
@ -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" ]
|
||||||
|
}
|
175
modules/ExpressionSearchFilter.jsm
Normal file
175
modules/ExpressionSearchFilter.jsm
Normal file
|
@ -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(/<think>[\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 };
|
12
options/options.html
Normal file
12
options/options.html
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>AI Filter Options</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<label>Endpoint: <input id="endpoint" type="text"></label><br>
|
||||||
|
<button id="save">Save</button>
|
||||||
|
<script src="options.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
14
options/options.js
Normal file
14
options/options.js
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
Loading…
Add table
Add a link
Reference in a new issue