diff --git a/background.js b/background.js index de6b595..17154b0 100644 --- a/background.js +++ b/background.js @@ -231,7 +231,8 @@ async function clearCacheForMessages(idsInput) { if (browser.messageDisplayScripts?.registerScripts) { try { await browser.messageDisplayScripts.registerScripts([ - { js: [browser.runtime.getURL("resources/clearCacheButton.js")] } + { js: [browser.runtime.getURL("resources/clearCacheButton.js")] }, + { js: [browser.runtime.getURL("resources/reasonButton.js")] } ]); } catch (e) { logger.aiLog("failed to register message display script", { level: 'warn' }, e); @@ -313,6 +314,36 @@ async function clearCacheForMessages(idsInput) { } catch (e) { logger.aiLog("failed to clear cache from message script", { level: 'error' }, e); } + } else if (msg?.type === "sortana:getReasons") { + try { + const id = msg.id; + const hdr = await messenger.messages.get(id); + const subject = hdr?.subject || ""; + if (!aiRules.length) { + const { aiRules: stored } = await storage.local.get("aiRules"); + aiRules = Array.isArray(stored) ? stored.map(r => { + if (r.actions) return r; + const actions = []; + if (r.tag) actions.push({ type: 'tag', tagKey: r.tag }); + if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo }); + const rule = { criterion: r.criterion, actions }; + if (r.stopProcessing) rule.stopProcessing = true; + return rule; + }) : []; + } + const reasons = []; + for (const rule of aiRules) { + const key = await sha256Hex(`${id}|${rule.criterion}`); + const reason = AiClassifier.getReason(key); + if (reason) { + reasons.push({ criterion: rule.criterion, reason }); + } + } + return { subject, reasons }; + } catch (e) { + logger.aiLog("failed to collect reasons", { level: 'error' }, e); + return { subject: '', reasons: [] }; + } } else { logger.aiLog("Unknown message type, ignoring", {level: 'warn'}, msg?.type); } diff --git a/modules/AiClassifier.js b/modules/AiClassifier.js index cec4a31..4180973 100644 --- a/modules/AiClassifier.js +++ b/modules/AiClassifier.js @@ -49,6 +49,8 @@ let gAiParams = { let gCache = new Map(); let gCacheLoaded = false; +let gReasonCache = new Map(); +let gReasonCacheLoaded = false; async function loadCache() { if (gCacheLoaded) { @@ -94,6 +96,50 @@ async function saveCache(updatedKey, updatedValue) { } } +async function loadReasonCache() { + if (gReasonCacheLoaded) { + return; + } + aiLog(`[AiClassifier] Loading reason cache`, {debug: true}); + try { + const { aiReasonCache } = await storage.local.get("aiReasonCache"); + if (aiReasonCache) { + for (let [k, v] of Object.entries(aiReasonCache)) { + aiLog(`[AiClassifier] ⮡ Loaded reason '${k}'`, {debug: true}); + gReasonCache.set(k, v); + } + aiLog(`[AiClassifier] Loaded ${gReasonCache.size} reason entries`, {debug: true}); + } else { + aiLog(`[AiClassifier] Reason cache is empty`, {debug: true}); + } + } catch (e) { + aiLog(`Failed to load reason cache`, {level: 'error'}, e); + } + gReasonCacheLoaded = true; +} + +function loadReasonCacheSync() { + if (!gReasonCacheLoaded) { + if (!Services?.tm?.spinEventLoopUntil) { + throw new Error("loadReasonCacheSync requires Services"); + } + let done = false; + loadReasonCache().finally(() => { done = true; }); + Services.tm.spinEventLoopUntil(() => done); + } +} + +async function saveReasonCache(updatedKey, updatedValue) { + if (typeof updatedKey !== "undefined") { + aiLog(`[AiClassifier] ⮡ Persisting reason '${updatedKey}'`, {debug: true}); + } + try { + await storage.local.set({ aiReasonCache: Object.fromEntries(gReasonCache) }); + } catch (e) { + aiLog(`Failed to save reason cache`, {level: 'error'}, e); + } +} + async function loadTemplate(name) { try { const url = typeof browser !== "undefined" && browser.runtime?.getURL @@ -185,6 +231,17 @@ function getCachedResult(cacheKey) { return null; } +function getReason(cacheKey) { + if (!gReasonCacheLoaded) { + if (Services?.tm?.spinEventLoopUntil) { + loadReasonCacheSync(); + } else { + return null; + } + } + return cacheKey ? gReasonCache.get(cacheKey) || null : null; +} + function buildPayload(text, criterion) { let payloadObj = Object.assign({ prompt: buildPrompt(text, criterion) @@ -199,7 +256,8 @@ function parseMatch(result) { const cleanedText = rawText.replace(/[\s\S]*?<\/think>/gi, "").trim(); aiLog('[AiClassifier] ⮡ Cleaned Response Text:', {debug: true}, cleanedText); const obj = JSON.parse(cleanedText); - return obj.matched === true || obj.match === true; + const matched = obj.matched === true || obj.match === true; + return { matched, reason: thinkText }; } function cacheResult(cacheKey, matched) { @@ -210,6 +268,14 @@ function cacheResult(cacheKey, matched) { } } +function cacheReason(cacheKey, reason) { + if (cacheKey) { + aiLog(`[AiClassifier] Caching reason '${cacheKey}'`, {debug: true}); + gReasonCache.set(cacheKey, reason); + saveReasonCache(cacheKey, reason); + } +} + async function removeCacheEntries(keys = []) { if (!Array.isArray(keys)) { keys = [keys]; @@ -223,9 +289,14 @@ async function removeCacheEntries(keys = []) { removed = true; aiLog(`[AiClassifier] Removed cache entry '${key}'`, {debug: true}); } + if (gReasonCache.delete(key)) { + removed = true; + aiLog(`[AiClassifier] Removed reason entry '${key}'`, {debug: true}); + } } if (removed) { await saveCache(); + await saveReasonCache(); } } @@ -233,6 +304,9 @@ function classifyTextSync(text, criterion, cacheKey = null) { if (!Services?.tm?.spinEventLoopUntil) { throw new Error("classifyTextSync requires Services"); } + if (!gReasonCacheLoaded) { + loadReasonCacheSync(); + } const cached = getCachedResult(cacheKey); if (cached !== null) { return cached; @@ -255,7 +329,9 @@ function classifyTextSync(text, criterion, cacheKey = null) { const json = await response.json(); aiLog(`[AiClassifier] Received response:`, {debug: true}, json); result = parseMatch(json); - cacheResult(cacheKey, result); + cacheResult(cacheKey, result.matched); + cacheReason(cacheKey, result.reason); + result = result.matched; } else { aiLog(`HTTP status ${response.status}`, {level: 'warn'}); result = false; @@ -275,6 +351,9 @@ async function classifyText(text, criterion, cacheKey = null) { if (!gCacheLoaded) { await loadCache(); } + if (!gReasonCacheLoaded) { + await loadReasonCache(); + } const cached = getCachedResult(cacheKey); if (cached !== null) { return cached; @@ -298,13 +377,14 @@ async function classifyText(text, criterion, cacheKey = null) { const result = await response.json(); aiLog(`[AiClassifier] Received response:`, {debug: true}, result); - const matched = parseMatch(result); - cacheResult(cacheKey, matched); - return matched; + const parsed = parseMatch(result); + cacheResult(cacheKey, parsed.matched); + cacheReason(cacheKey, parsed.reason); + return parsed.matched; } catch (e) { aiLog(`HTTP request failed`, {level: 'error'}, e); return false; } } -export { classifyText, classifyTextSync, setConfig, removeCacheEntries }; +export { classifyText, classifyTextSync, setConfig, removeCacheEntries, getReason }; diff --git a/reasoning.html b/reasoning.html new file mode 100644 index 0000000..bb1577d --- /dev/null +++ b/reasoning.html @@ -0,0 +1,17 @@ + + + + + AI Reasoning + + + +
+
+

+
+
+
+ + + diff --git a/reasoning.js b/reasoning.js new file mode 100644 index 0000000..eb0b9da --- /dev/null +++ b/reasoning.js @@ -0,0 +1,27 @@ +document.addEventListener('DOMContentLoaded', async () => { + const params = new URLSearchParams(location.search); + const id = parseInt(params.get('mid'), 10); + if (!id) return; + try { + const { subject, reasons } = await browser.runtime.sendMessage({ type: 'sortana:getReasons', id }); + document.getElementById('subject').textContent = subject; + const container = document.getElementById('rules'); + for (const r of reasons) { + const article = document.createElement('article'); + article.className = 'message mb-4'; + const header = document.createElement('div'); + header.className = 'message-header'; + header.innerHTML = `

${r.criterion}

`; + const body = document.createElement('div'); + body.className = 'message-body'; + const pre = document.createElement('pre'); + pre.textContent = r.reason; + body.appendChild(pre); + article.appendChild(header); + article.appendChild(body); + container.appendChild(article); + } + } catch (e) { + console.error('failed to load reasons', e); + } +}); diff --git a/resources/img/brain.png b/resources/img/brain.png new file mode 100644 index 0000000..7d9e7d6 --- /dev/null +++ b/resources/img/brain.png @@ -0,0 +1,44 @@ + + + + +Object not found! + + + + + +

Object not found!

+

+ + + The requested URL was not found on this server. + + + + If you entered the URL manually please check your + spelling and try again. + + + +

+

+If you think this is a server error, please contact +the webmaster. + +

+ +

Error 404

+
+ openmoji.org
+ Apache +
+ + + diff --git a/resources/reasonButton.js b/resources/reasonButton.js new file mode 100644 index 0000000..0206ab9 --- /dev/null +++ b/resources/reasonButton.js @@ -0,0 +1,34 @@ +(function() { + function addButton() { + const toolbar = document.querySelector("#header-view-toolbar") || + document.querySelector("#mail-toolbox toolbar"); + if (!toolbar || document.getElementById('sortana-reason-button')) return; + const button = document.createXULElement ? + document.createXULElement('toolbarbutton') : + document.createElement('button'); + button.id = 'sortana-reason-button'; + button.setAttribute('label', 'Show Reasoning'); + button.className = 'toolbarbutton-1'; + const icon = browser.runtime.getURL('resources/img/brain.png'); + if (button.setAttribute) { + button.setAttribute('image', icon); + } else { + button.style.backgroundImage = `url(${icon})`; + button.style.backgroundSize = 'contain'; + } + button.addEventListener('command', async () => { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs[0]?.id; + const msgs = tabId ? await browser.messageDisplay.getDisplayedMessages(tabId) : []; + if (!msgs.length) return; + const url = browser.runtime.getURL(`reasoning.html?mid=${msgs[0].id}`); + browser.tabs.create({ url }); + }); + toolbar.appendChild(button); + } + if (document.readyState === 'complete' || document.readyState === 'interactive') { + addButton(); + } else { + document.addEventListener('DOMContentLoaded', addButton, { once: true }); + } +})();