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