diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/AGENTS.md b/AGENTS.md index 11876f7..1dcb02d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,6 @@ This file provides guidelines for codex agents contributing to the Sortana proje - `options/`: The options page HTML, JavaScript and bundled Bulma CSS (v1.0.3). - `details.html` and `details.js`: View AI reasoning and clear cache for a message. - `resources/`: Images and other static files. -- `prompt_templates/`: Prompt template files for the AI service (openai, qwen, mistral, harmony). - `build-xpi.ps1`: PowerShell script to package the extension. - `build-xpi.sh`: Bash script to package the extension. - `resources/svg2img.ps1`: PowerShell script to regenerate themed PNG icons from SVGs. @@ -35,10 +34,10 @@ There are currently no automated tests for this project. If you add tests in the ## Endpoint Notes -Sortana targets the `/v1/completions` API. The endpoint value stored in settings is a base URL; the full request URL is constructed by appending `/v1/completions` (adding a slash when needed) and defaulting to `https://` if no scheme is provided. +Sortana targets the OpenAI-compatible `/v1/chat/completions` API. The endpoint value stored in settings is a base URL; the full request URL is constructed by appending `/v1/chat/completions` (adding a slash when needed) and defaulting to `https://` if no scheme is provided. The options page can query `/v1/models` from the same base URL to populate the Model dropdown; selecting **None** omits the `model` field from the request payload. Advanced options allow an optional API key plus `OpenAI-Organization` and `OpenAI-Project` headers; these headers are only sent when values are provided. -Responses are expected to include a JSON object with `match` (or `matched`) plus a short `reason` string; the parser extracts the last JSON object in the response text and ignores any surrounding commentary. +Requests use `messages` with system and user roles. Responses are expected to include text in `choices[0].message.content` containing a JSON object with `match` (or `matched`) plus a short `reason` string; the parser extracts the last JSON object in that content and ignores any surrounding commentary. ## Documentation diff --git a/README.md b/README.md index fe67874..a0625d8 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,12 @@ Sortana is an experimental Thunderbird add-on that integrates an AI-powered filter rule. It allows you to classify email messages by sending their contents to a configurable -HTTP endpoint. Sortana uses the `/v1/completions` API; the options page stores a base -URL and appends `/v1/completions` when sending requests. The endpoint should respond -with JSON indicating whether the message meets a specified criterion, including a -short reasoning summary. -Responses are parsed by extracting the last JSON object in the response text and +HTTP endpoint. Sortana uses the OpenAI-compatible `/v1/chat/completions` API; the options page stores a base +URL and appends `/v1/chat/completions` when sending requests. Requests contain a system +message and a user message. The endpoint should respond +with text in `choices[0].message.content` containing JSON indicating whether the +message meets a specified criterion, including a short reasoning summary. +Responses are parsed by extracting the last JSON object in that content and expecting a `match` (or `matched`) boolean plus a `reason` string. ## Features @@ -16,10 +17,10 @@ expecting a `match` (or `matched`) boolean plus a `reason` string. - **Configurable endpoint** – set the classification service base URL on the options page. - **Model selection** – load available models from the endpoint and choose one (or omit the model field). - **Optional OpenAI auth headers** – provide an API key plus optional organization/project headers when needed. -- **Prompt templates** – choose between OpenAI/ChatML, Qwen, Mistral, Harmony (gpt-oss), or provide your own custom template. -- **Custom system prompts** – tailor the instructions sent to the model for more precise results. +- **Custom system prompts** – tailor the instructions sent as the system message for more precise results. - **Persistent result caching** – classification results and reasoning are saved to disk so messages aren't re-evaluated across restarts. - **Advanced parameters** – tune generation settings like temperature, top‑p and more from the options page. + Provider-specific advanced fields are forwarded as configured and may be rejected by strict OpenAI endpoints. - **Markdown conversion** – optionally convert HTML bodies to Markdown before sending them to the AI service. - **Debug logging** – optional colorized logs help troubleshoot interactions with the AI service. - **Debug tab** – view the last request payload and a diff between the unaltered message text and the final prompt. @@ -83,7 +84,7 @@ Sortana is implemented entirely with documented MailExtension/WebExtension APIs. ## Usage 1. Open the add-on's options and set the base URL of your classification service - (Sortana will append `/v1/completions`). Use the Model dropdown to load + (Sortana will append `/v1/chat/completions`). Use the Model dropdown to load `/v1/models` and select a model or choose **None** to omit the `model` field. Advanced settings include optional API key, organization, and project headers for OpenAI-hosted endpoints. diff --git a/_locales/en-US/messages.json b/_locales/en-US/messages.json index 017e1a3..472956a 100644 --- a/_locales/en-US/messages.json +++ b/_locales/en-US/messages.json @@ -4,16 +4,8 @@ "doesntMatch": { "message": "doesn't match" }, "options.title": { "message": "AI Filter Options" }, "options.endpoint": { "message": "Endpoint" }, - "options.template": { "message": "Prompt template" }, - "options.customTemplate": { "message": "Custom template" }, "options.systemInstructions": { "message": "System instructions" }, "options.reset": { "message": "Reset to default" }, - "options.placeholders": { "message": "Placeholders: {{system}}, {{email}}, {{query}}" }, - "template.openai": { "message": "OpenAI / ChatML" }, - "template.qwen": { "message": "Qwen" }, - "template.mistral": { "message": "Mistral" }, - "template.harmony": { "message": "Harmony (gpt-oss)" }, - "template.custom": { "message": "Custom" }, "options.save": { "message": "Save" }, "options.debugLogging": { "message": "Enable debug logging" }, "options.htmlToMarkdown": { "message": "Convert HTML body to Markdown" }, diff --git a/ai-filter.sln b/ai-filter.sln index 7ded341..52e32fe 100644 --- a/ai-filter.sln +++ b/ai-filter.sln @@ -39,14 +39,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "en-US", "en-US", "{8BEA7793 _locales\en-US\messages.json = _locales\en-US\messages.json EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "prompt_templates", "prompt_templates", "{86516D53-50D4-4FE2-9D8A-977A8F5EBDBD}" - ProjectSection(SolutionItems) = preProject - prompt_templates\mistral.txt = prompt_templates\mistral.txt - prompt_templates\openai.txt = prompt_templates\openai.txt - prompt_templates\qwen.txt = prompt_templates\qwen.txt - prompt_templates\harmony.txt = prompt_templates\harmony.txt - EndProjectSection -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{68A87938-5C2B-49F5-8AAA-8A34FBBFD854}" ProjectSection(SolutionItems) = preProject resources\svg2img.ps1 = resources\svg2img.ps1 diff --git a/background.js b/background.js index b2efc89..5cfc112 100644 --- a/background.js +++ b/background.js @@ -484,7 +484,7 @@ async function clearCacheForMessages(idsInput) { } try { - const store = await storage.local.get(["endpoint", "model", "apiKey", "openaiOrganization", "openaiProject", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "showDebugTab"]); + const store = await storage.local.get(["endpoint", "model", "apiKey", "openaiOrganization", "openaiProject", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "tokenReduction", "aiRules", "theme", "showDebugTab"]); logger.setDebug(store.debugLogging); await AiClassifier.setConfig(store); userTheme = store.theme || 'auto'; @@ -514,15 +514,13 @@ async function clearCacheForMessages(idsInput) { aiRules = normalizeRules(newRules); logger.aiLog("aiRules updated from storage change", { debug: true }, aiRules); } - if (changes.endpoint || changes.model || changes.apiKey || changes.openaiOrganization || changes.openaiProject || changes.templateName || changes.customTemplate || changes.customSystemPrompt || changes.aiParams || changes.debugLogging) { + if (changes.endpoint || changes.model || changes.apiKey || changes.openaiOrganization || changes.openaiProject || changes.customSystemPrompt || changes.aiParams || changes.debugLogging) { const config = {}; if (changes.endpoint) config.endpoint = changes.endpoint.newValue; if (changes.model) config.model = changes.model.newValue; if (changes.apiKey) config.apiKey = changes.apiKey.newValue; if (changes.openaiOrganization) config.openaiOrganization = changes.openaiOrganization.newValue; if (changes.openaiProject) config.openaiProject = changes.openaiProject.newValue; - if (changes.templateName) config.templateName = changes.templateName.newValue; - if (changes.customTemplate) config.customTemplate = changes.customTemplate.newValue; if (changes.customSystemPrompt) config.customSystemPrompt = changes.customSystemPrompt.newValue; if (changes.aiParams) { config.aiParams = changes.aiParams.newValue; diff --git a/manifest.json b/manifest.json index 5dd0e72..139e60e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Sortana", - "version": "2.4.3", + "version": "3.0.0", "default_locale": "en-US", "applications": { "gecko": { diff --git a/modules/AiClassifier.js b/modules/AiClassifier.js index dbbde92..583eec0 100644 --- a/modules/AiClassifier.js +++ b/modules/AiClassifier.js @@ -4,7 +4,7 @@ import { DEFAULT_AI_PARAMS } from "./defaultParams.js"; const storage = (globalThis.messenger ?? globalThis.browser).storage; -const COMPLETIONS_PATH = "/v1/completions"; +const CHAT_COMPLETIONS_PATH = "/v1/chat/completions"; const MODELS_PATH = "/v1/models"; const SYSTEM_PREFIX = `You are an email-classification assistant. @@ -22,10 +22,7 @@ Do not add any other keys, text, or formatting.`; let gEndpointBase = "http://127.0.0.1:5000"; let gEndpoint = buildEndpointUrl(gEndpointBase); -let gTemplateName = "openai"; -let gCustomTemplate = ""; let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT; -let gTemplateText = ""; let gAiParams = Object.assign({}, DEFAULT_AI_PARAMS); let gModel = ""; @@ -44,7 +41,7 @@ function normalizeEndpointBase(endpoint) { if (!base) { return ""; } - base = base.replace(/\/v1\/(completions|models)\/?$/i, ""); + base = base.replace(/\/v1\/(chat\/completions|completions|models)\/?$/i, ""); return base; } @@ -55,7 +52,7 @@ function buildEndpointUrl(endpointBase) { } const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`; const needsSlash = withScheme.endsWith("/"); - const path = COMPLETIONS_PATH.replace(/^\//, ""); + const path = CHAT_COMPLETIONS_PATH.replace(/^\//, ""); return `${withScheme}${needsSlash ? "" : "/"}${path}`; } @@ -147,21 +144,6 @@ async function saveCache(updatedKey, updatedValue) { } -async function loadTemplate(name) { - try { - const url = typeof browser !== "undefined" && browser.runtime?.getURL - ? browser.runtime.getURL(`prompt_templates/${name}.txt`) - : `resource://aifilter/prompt_templates/${name}.txt`; - const res = await fetch(url); - if (res.ok) { - return await res.text(); - } - } catch (e) { - aiLog(`Failed to load template '${name}':`, {level: 'error'}, e); - } - return ""; -} - async function setConfig(config = {}) { if (typeof config.endpoint === "string") { const base = normalizeEndpointBase(config.endpoint); @@ -170,12 +152,6 @@ async function setConfig(config = {}) { } gEndpoint = buildEndpointUrl(gEndpointBase); } - if (config.templateName) { - gTemplateName = config.templateName; - } - if (typeof config.customTemplate === "string") { - gCustomTemplate = config.customTemplate; - } if (typeof config.customSystemPrompt === "string") { gCustomSystemPrompt = config.customSystemPrompt; } @@ -201,17 +177,11 @@ async function setConfig(config = {}) { if (typeof config.debugLogging === "boolean") { setDebug(config.debugLogging); } - if (gTemplateName === "custom") { - gTemplateText = gCustomTemplate; - } else { - gTemplateText = await loadTemplate(gTemplateName); - } if (!gEndpoint) { gEndpoint = buildEndpointUrl(gEndpointBase); } aiLog(`[AiClassifier] Endpoint base set to ${gEndpointBase}`, {debug: true}); aiLog(`[AiClassifier] Endpoint set to ${gEndpoint}`, {debug: true}); - aiLog(`[AiClassifier] Template set to ${gTemplateName}`, {debug: true}); } function buildAuthHeaders() { @@ -234,13 +204,7 @@ function buildSystemPrompt() { function buildPrompt(body, criterion) { aiLog(`[AiClassifier] Building prompt with criterion: "${criterion}"`, {debug: true}); - const data = { - system: buildSystemPrompt(), - email: body, - query: criterion, - }; - let template = gTemplateText || ""; - return template.replace(/{{\s*(\w+)\s*}}/g, (m, key) => data[key] || ""); + return `**Email Contents**\n\`\`\`\n${body}\n\`\`\`\nClassification Criterion: ${criterion}`; } function getCachedResult(cacheKey) { @@ -265,7 +229,10 @@ function getReason(cacheKey) { function buildPayload(text, criterion) { let payloadObj = Object.assign({ - prompt: buildPrompt(text, criterion) + messages: [ + { role: "system", content: buildSystemPrompt() }, + { role: "user", content: buildPrompt(text, criterion) } + ] }, gAiParams); if (gModel) { payloadObj.model = gModel; @@ -337,7 +304,11 @@ function extractLastJsonObject(text) { } function parseMatch(result) { - const rawText = result.choices?.[0]?.text || ""; + const rawText = result.choices?.[0]?.message?.content; + if (typeof rawText !== "string") { + reportParseError("Chat response missing text content.", JSON.stringify(result).slice(0, 800)); + return { matched: false, reason: "" }; + } const candidate = extractLastJsonObject(rawText); if (!candidate) { reportParseError("No JSON object found in AI response.", rawText.slice(0, 800)); diff --git a/options/dataTransfer.js b/options/dataTransfer.js index 393b533..2c0b26e 100644 --- a/options/dataTransfer.js +++ b/options/dataTransfer.js @@ -7,8 +7,6 @@ const KEY_GROUPS = { 'apiKey', 'openaiOrganization', 'openaiProject', - 'templateName', - 'customTemplate', 'customSystemPrompt', 'aiParams', 'debugLogging', diff --git a/options/options.html b/options/options.html index cfc370d..cd3d581 100644 --- a/options/options.html +++ b/options/options.html @@ -92,23 +92,6 @@
-Placeholders: {{system}}, {{email}}, {{query}}
-