Merge pull request #46 from wagesj45/codex/implement-ai-metadata-storage-and-ui

Add reasoning cache and viewer
This commit is contained in:
Jordan Wages 2025-06-27 01:39:50 -05:00 committed by GitHub
commit 0e6478269e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 196 additions and 7 deletions

View file

@ -231,7 +231,8 @@ async function clearCacheForMessages(idsInput) {
if (browser.messageDisplayScripts?.registerScripts) { if (browser.messageDisplayScripts?.registerScripts) {
try { try {
await browser.messageDisplayScripts.registerScripts([ 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) { } catch (e) {
logger.aiLog("failed to register message display script", { level: 'warn' }, e); logger.aiLog("failed to register message display script", { level: 'warn' }, e);
@ -313,6 +314,36 @@ async function clearCacheForMessages(idsInput) {
} catch (e) { } catch (e) {
logger.aiLog("failed to clear cache from message script", { level: 'error' }, 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 { } else {
logger.aiLog("Unknown message type, ignoring", {level: 'warn'}, msg?.type); logger.aiLog("Unknown message type, ignoring", {level: 'warn'}, msg?.type);
} }

View file

@ -49,6 +49,8 @@ let gAiParams = {
let gCache = new Map(); let gCache = new Map();
let gCacheLoaded = false; let gCacheLoaded = false;
let gReasonCache = new Map();
let gReasonCacheLoaded = false;
async function loadCache() { async function loadCache() {
if (gCacheLoaded) { 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) { async function loadTemplate(name) {
try { try {
const url = typeof browser !== "undefined" && browser.runtime?.getURL const url = typeof browser !== "undefined" && browser.runtime?.getURL
@ -185,6 +231,17 @@ function getCachedResult(cacheKey) {
return null; 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) { function buildPayload(text, criterion) {
let payloadObj = Object.assign({ let payloadObj = Object.assign({
prompt: buildPrompt(text, criterion) prompt: buildPrompt(text, criterion)
@ -199,7 +256,8 @@ function parseMatch(result) {
const cleanedText = rawText.replace(/<think>[\s\S]*?<\/think>/gi, "").trim(); const cleanedText = rawText.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
aiLog('[AiClassifier] ⮡ Cleaned Response Text:', {debug: true}, cleanedText); aiLog('[AiClassifier] ⮡ Cleaned Response Text:', {debug: true}, cleanedText);
const obj = JSON.parse(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) { 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 = []) { async function removeCacheEntries(keys = []) {
if (!Array.isArray(keys)) { if (!Array.isArray(keys)) {
keys = [keys]; keys = [keys];
@ -223,9 +289,14 @@ async function removeCacheEntries(keys = []) {
removed = true; removed = true;
aiLog(`[AiClassifier] Removed cache entry '${key}'`, {debug: 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) { if (removed) {
await saveCache(); await saveCache();
await saveReasonCache();
} }
} }
@ -233,6 +304,9 @@ function classifyTextSync(text, criterion, cacheKey = null) {
if (!Services?.tm?.spinEventLoopUntil) { if (!Services?.tm?.spinEventLoopUntil) {
throw new Error("classifyTextSync requires Services"); throw new Error("classifyTextSync requires Services");
} }
if (!gReasonCacheLoaded) {
loadReasonCacheSync();
}
const cached = getCachedResult(cacheKey); const cached = getCachedResult(cacheKey);
if (cached !== null) { if (cached !== null) {
return cached; return cached;
@ -255,7 +329,9 @@ function classifyTextSync(text, criterion, cacheKey = null) {
const json = await response.json(); const json = await response.json();
aiLog(`[AiClassifier] Received response:`, {debug: true}, json); aiLog(`[AiClassifier] Received response:`, {debug: true}, json);
result = parseMatch(json); result = parseMatch(json);
cacheResult(cacheKey, result); cacheResult(cacheKey, result.matched);
cacheReason(cacheKey, result.reason);
result = result.matched;
} else { } else {
aiLog(`HTTP status ${response.status}`, {level: 'warn'}); aiLog(`HTTP status ${response.status}`, {level: 'warn'});
result = false; result = false;
@ -275,6 +351,9 @@ async function classifyText(text, criterion, cacheKey = null) {
if (!gCacheLoaded) { if (!gCacheLoaded) {
await loadCache(); await loadCache();
} }
if (!gReasonCacheLoaded) {
await loadReasonCache();
}
const cached = getCachedResult(cacheKey); const cached = getCachedResult(cacheKey);
if (cached !== null) { if (cached !== null) {
return cached; return cached;
@ -298,13 +377,14 @@ async function classifyText(text, criterion, cacheKey = null) {
const result = await response.json(); const result = await response.json();
aiLog(`[AiClassifier] Received response:`, {debug: true}, result); aiLog(`[AiClassifier] Received response:`, {debug: true}, result);
const matched = parseMatch(result); const parsed = parseMatch(result);
cacheResult(cacheKey, matched); cacheResult(cacheKey, parsed.matched);
return matched; cacheReason(cacheKey, parsed.reason);
return parsed.matched;
} catch (e) { } catch (e) {
aiLog(`HTTP request failed`, {level: 'error'}, e); aiLog(`HTTP request failed`, {level: 'error'}, e);
return false; return false;
} }
} }
export { classifyText, classifyTextSync, setConfig, removeCacheEntries }; export { classifyText, classifyTextSync, setConfig, removeCacheEntries, getReason };

17
reasoning.html Normal file
View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>AI Reasoning</title>
<link rel="stylesheet" href="options/bulma.css">
</head>
<body>
<section class="section">
<div class="container">
<h1 class="title" id="subject"></h1>
<div id="rules"></div>
</div>
</section>
<script src="reasoning.js"></script>
</body>
</html>

27
reasoning.js Normal file
View file

@ -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 = `<p>${r.criterion}</p>`;
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);
}
});

34
resources/reasonButton.js Normal file
View file

@ -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 });
}
})();