Migrate classifier to chat completions

This commit is contained in:
Jordan Wages 2026-09-06 23:29:59 -05:00
commit 76195f9a92
15 changed files with 73 additions and 192 deletions

0
.codex Normal file
View file

View file

@ -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). - `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. - `details.html` and `details.js`: View AI reasoning and clear cache for a message.
- `resources/`: Images and other static files. - `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.ps1`: PowerShell script to package the extension.
- `build-xpi.sh`: Bash 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. - `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 ## 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. 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. 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 ## Documentation

View file

@ -4,11 +4,12 @@
Sortana is an experimental Thunderbird add-on that integrates an AI-powered filter rule. 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 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 HTTP endpoint. Sortana uses the OpenAI-compatible `/v1/chat/completions` API; the options page stores a base
URL and appends `/v1/completions` when sending requests. The endpoint should respond URL and appends `/v1/chat/completions` when sending requests. Requests contain a system
with JSON indicating whether the message meets a specified criterion, including a message and a user message. The endpoint should respond
short reasoning summary. with text in `choices[0].message.content` containing JSON indicating whether the
Responses are parsed by extracting the last JSON object in the response text and 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. expecting a `match` (or `matched`) boolean plus a `reason` string.
## Features ## 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. - **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). - **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. - **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 as the system message for more precise results.
- **Custom system prompts** tailor the instructions sent to the model for more precise results.
- **Persistent result caching** classification results and reasoning are saved to disk so messages aren't re-evaluated across restarts. - **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, topp and more from the options page. - **Advanced parameters** tune generation settings like temperature, topp 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. - **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 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. - **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 ## Usage
1. Open the add-on's options and set the base URL of your classification service 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. `/v1/models` and select a model or choose **None** to omit the `model` field.
Advanced settings include optional API key, organization, and project headers Advanced settings include optional API key, organization, and project headers
for OpenAI-hosted endpoints. for OpenAI-hosted endpoints.

View file

@ -4,16 +4,8 @@
"doesntMatch": { "message": "doesn't match" }, "doesntMatch": { "message": "doesn't match" },
"options.title": { "message": "AI Filter Options" }, "options.title": { "message": "AI Filter Options" },
"options.endpoint": { "message": "Endpoint" }, "options.endpoint": { "message": "Endpoint" },
"options.template": { "message": "Prompt template" },
"options.customTemplate": { "message": "Custom template" },
"options.systemInstructions": { "message": "System instructions" }, "options.systemInstructions": { "message": "System instructions" },
"options.reset": { "message": "Reset to default" }, "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.save": { "message": "Save" },
"options.debugLogging": { "message": "Enable debug logging" }, "options.debugLogging": { "message": "Enable debug logging" },
"options.htmlToMarkdown": { "message": "Convert HTML body to Markdown" }, "options.htmlToMarkdown": { "message": "Convert HTML body to Markdown" },

View file

@ -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 _locales\en-US\messages.json = _locales\en-US\messages.json
EndProjectSection EndProjectSection
EndProject 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}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "resources", "resources", "{68A87938-5C2B-49F5-8AAA-8A34FBBFD854}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
resources\svg2img.ps1 = resources\svg2img.ps1 resources\svg2img.ps1 = resources\svg2img.ps1

View file

@ -484,7 +484,7 @@ async function clearCacheForMessages(idsInput) {
} }
try { 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); logger.setDebug(store.debugLogging);
await AiClassifier.setConfig(store); await AiClassifier.setConfig(store);
userTheme = store.theme || 'auto'; userTheme = store.theme || 'auto';
@ -514,15 +514,13 @@ async function clearCacheForMessages(idsInput) {
aiRules = normalizeRules(newRules); aiRules = normalizeRules(newRules);
logger.aiLog("aiRules updated from storage change", { debug: true }, aiRules); 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 = {}; const config = {};
if (changes.endpoint) config.endpoint = changes.endpoint.newValue; if (changes.endpoint) config.endpoint = changes.endpoint.newValue;
if (changes.model) config.model = changes.model.newValue; if (changes.model) config.model = changes.model.newValue;
if (changes.apiKey) config.apiKey = changes.apiKey.newValue; if (changes.apiKey) config.apiKey = changes.apiKey.newValue;
if (changes.openaiOrganization) config.openaiOrganization = changes.openaiOrganization.newValue; if (changes.openaiOrganization) config.openaiOrganization = changes.openaiOrganization.newValue;
if (changes.openaiProject) config.openaiProject = changes.openaiProject.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.customSystemPrompt) config.customSystemPrompt = changes.customSystemPrompt.newValue;
if (changes.aiParams) { if (changes.aiParams) {
config.aiParams = changes.aiParams.newValue; config.aiParams = changes.aiParams.newValue;

View file

@ -1,7 +1,7 @@
{ {
"manifest_version": 2, "manifest_version": 2,
"name": "Sortana", "name": "Sortana",
"version": "2.4.3", "version": "3.0.0",
"default_locale": "en-US", "default_locale": "en-US",
"applications": { "applications": {
"gecko": { "gecko": {

View file

@ -4,7 +4,7 @@ import { DEFAULT_AI_PARAMS } from "./defaultParams.js";
const storage = (globalThis.messenger ?? globalThis.browser).storage; 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 MODELS_PATH = "/v1/models";
const SYSTEM_PREFIX = `You are an email-classification assistant. 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 gEndpointBase = "http://127.0.0.1:5000";
let gEndpoint = buildEndpointUrl(gEndpointBase); let gEndpoint = buildEndpointUrl(gEndpointBase);
let gTemplateName = "openai";
let gCustomTemplate = "";
let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT; let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT;
let gTemplateText = "";
let gAiParams = Object.assign({}, DEFAULT_AI_PARAMS); let gAiParams = Object.assign({}, DEFAULT_AI_PARAMS);
let gModel = ""; let gModel = "";
@ -44,7 +41,7 @@ function normalizeEndpointBase(endpoint) {
if (!base) { if (!base) {
return ""; return "";
} }
base = base.replace(/\/v1\/(completions|models)\/?$/i, ""); base = base.replace(/\/v1\/(chat\/completions|completions|models)\/?$/i, "");
return base; return base;
} }
@ -55,7 +52,7 @@ function buildEndpointUrl(endpointBase) {
} }
const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`; const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`;
const needsSlash = withScheme.endsWith("/"); const needsSlash = withScheme.endsWith("/");
const path = COMPLETIONS_PATH.replace(/^\//, ""); const path = CHAT_COMPLETIONS_PATH.replace(/^\//, "");
return `${withScheme}${needsSlash ? "" : "/"}${path}`; 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 = {}) { async function setConfig(config = {}) {
if (typeof config.endpoint === "string") { if (typeof config.endpoint === "string") {
const base = normalizeEndpointBase(config.endpoint); const base = normalizeEndpointBase(config.endpoint);
@ -170,12 +152,6 @@ async function setConfig(config = {}) {
} }
gEndpoint = buildEndpointUrl(gEndpointBase); gEndpoint = buildEndpointUrl(gEndpointBase);
} }
if (config.templateName) {
gTemplateName = config.templateName;
}
if (typeof config.customTemplate === "string") {
gCustomTemplate = config.customTemplate;
}
if (typeof config.customSystemPrompt === "string") { if (typeof config.customSystemPrompt === "string") {
gCustomSystemPrompt = config.customSystemPrompt; gCustomSystemPrompt = config.customSystemPrompt;
} }
@ -201,17 +177,11 @@ async function setConfig(config = {}) {
if (typeof config.debugLogging === "boolean") { if (typeof config.debugLogging === "boolean") {
setDebug(config.debugLogging); setDebug(config.debugLogging);
} }
if (gTemplateName === "custom") {
gTemplateText = gCustomTemplate;
} else {
gTemplateText = await loadTemplate(gTemplateName);
}
if (!gEndpoint) { if (!gEndpoint) {
gEndpoint = buildEndpointUrl(gEndpointBase); gEndpoint = buildEndpointUrl(gEndpointBase);
} }
aiLog(`[AiClassifier] Endpoint base set to ${gEndpointBase}`, {debug: true}); aiLog(`[AiClassifier] Endpoint base set to ${gEndpointBase}`, {debug: true});
aiLog(`[AiClassifier] Endpoint set to ${gEndpoint}`, {debug: true}); aiLog(`[AiClassifier] Endpoint set to ${gEndpoint}`, {debug: true});
aiLog(`[AiClassifier] Template set to ${gTemplateName}`, {debug: true});
} }
function buildAuthHeaders() { function buildAuthHeaders() {
@ -234,13 +204,7 @@ function buildSystemPrompt() {
function buildPrompt(body, criterion) { function buildPrompt(body, criterion) {
aiLog(`[AiClassifier] Building prompt with criterion: "${criterion}"`, {debug: true}); aiLog(`[AiClassifier] Building prompt with criterion: "${criterion}"`, {debug: true});
const data = { return `**Email Contents**\n\`\`\`\n${body}\n\`\`\`\nClassification Criterion: ${criterion}`;
system: buildSystemPrompt(),
email: body,
query: criterion,
};
let template = gTemplateText || "";
return template.replace(/{{\s*(\w+)\s*}}/g, (m, key) => data[key] || "");
} }
function getCachedResult(cacheKey) { function getCachedResult(cacheKey) {
@ -265,7 +229,10 @@ function getReason(cacheKey) {
function buildPayload(text, criterion) { function buildPayload(text, criterion) {
let payloadObj = Object.assign({ let payloadObj = Object.assign({
prompt: buildPrompt(text, criterion) messages: [
{ role: "system", content: buildSystemPrompt() },
{ role: "user", content: buildPrompt(text, criterion) }
]
}, gAiParams); }, gAiParams);
if (gModel) { if (gModel) {
payloadObj.model = gModel; payloadObj.model = gModel;
@ -337,7 +304,11 @@ function extractLastJsonObject(text) {
} }
function parseMatch(result) { 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); const candidate = extractLastJsonObject(rawText);
if (!candidate) { if (!candidate) {
reportParseError("No JSON object found in AI response.", rawText.slice(0, 800)); reportParseError("No JSON object found in AI response.", rawText.slice(0, 800));

View file

@ -7,8 +7,6 @@ const KEY_GROUPS = {
'apiKey', 'apiKey',
'openaiOrganization', 'openaiOrganization',
'openaiProject', 'openaiProject',
'templateName',
'customTemplate',
'customSystemPrompt', 'customSystemPrompt',
'aiParams', 'aiParams',
'debugLogging', 'debugLogging',

View file

@ -92,23 +92,6 @@
<p class="help" id="model-help"></p> <p class="help" id="model-help"></p>
</div> </div>
<div class="field">
<label class="label" for="template">Prompt template</label>
<div class="control">
<div class="select is-fullwidth">
<select id="template"></select>
</div>
</div>
</div>
<div id="custom-template-container" class="field is-hidden">
<label class="label">Custom template</label>
<div class="control">
<textarea class="textarea" id="custom-template" rows="6" placeholder="Enter your custom template here..."></textarea>
</div>
<p class="help">Placeholders: {{system}}, {{email}}, {{query}}</p>
</div>
<div class="field"> <div class="field">
<label class="label" for="system-instructions">System instructions</label> <label class="label" for="system-instructions">System instructions</label>
<div class="control"> <div class="control">

View file

@ -7,8 +7,6 @@ document.addEventListener('DOMContentLoaded', async () => {
const { DEFAULT_AI_PARAMS } = await import(browser.runtime.getURL('modules/defaultParams.js')); const { DEFAULT_AI_PARAMS } = await import(browser.runtime.getURL('modules/defaultParams.js'));
const defaults = await storage.local.get([ const defaults = await storage.local.get([
'endpoint', 'endpoint',
'templateName',
'customTemplate',
'customSystemPrompt', 'customSystemPrompt',
'model', 'model',
'apiKey', 'apiKey',
@ -163,21 +161,47 @@ document.addEventListener('DOMContentLoaded', async () => {
async function fetchModels(preferredModel = '') { async function fetchModels(preferredModel = '') {
if (!modelSelect || !refreshModelsBtn) return; if (!modelSelect || !refreshModelsBtn) return;
const modelsUrl = AiClassifier.buildModelsUrl(endpointInput.value); const modelsUrl = AiClassifier.buildModelsUrl(endpointInput.value);
const selectedModel = preferredModel || modelSelect.value;
if (!modelsUrl) { if (!modelsUrl) {
logger.aiLog('[options] model refresh skipped: invalid endpoint', { level: 'warn' }, {
endpoint: endpointInput.value
});
setModelHelp('Set a valid endpoint to load models.', true); setModelHelp('Set a valid endpoint to load models.', true);
populateModelOptions([], preferredModel || modelSelect.value); populateModelOptions([], selectedModel);
return; return;
} }
refreshModelsBtn.disabled = true; refreshModelsBtn.disabled = true;
setModelHelp('Loading models...'); setModelHelp('Loading models...');
const headers = buildAuthHeaders();
logger.aiLog('[options] loading models', {}, {
endpoint: endpointInput.value,
modelsUrl,
selectedModel,
hasAuthorization: typeof headers.Authorization === 'string',
hasOrganization: typeof headers['OpenAI-Organization'] === 'string',
hasProject: typeof headers['OpenAI-Project'] === 'string'
});
try { try {
const response = await fetch(modelsUrl, { method: 'GET', headers: buildAuthHeaders() }); const response = await fetch(modelsUrl, { method: 'GET', headers });
logger.aiLog('[options] model refresh response received', {}, {
status: response.status,
ok: response.ok,
contentType: response.headers.get('content-type') || ''
});
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status}`); throw new Error(`HTTP ${response.status}`);
} }
const data = await response.json(); const data = await response.json();
logger.aiLog('[options] model refresh payload parsed', {}, {
payloadType: Array.isArray(data) ? 'array' : typeof data,
hasDataArray: Array.isArray(data?.data),
hasModelsArray: Array.isArray(data?.models),
topLevelKeys: data && typeof data === 'object' && !Array.isArray(data)
? Object.keys(data)
: []
});
let models = []; let models = [];
if (Array.isArray(data?.data)) { if (Array.isArray(data?.data)) {
models = data.data.map(model => model?.id ?? model?.name ?? model?.model ?? '').filter(Boolean); models = data.data.map(model => model?.id ?? model?.name ?? model?.model ?? '').filter(Boolean);
@ -187,12 +211,23 @@ document.addEventListener('DOMContentLoaded', async () => {
models = data.map(model => model?.id ?? model?.name ?? model?.model ?? model).filter(Boolean); models = data.map(model => model?.id ?? model?.name ?? model?.model ?? model).filter(Boolean);
} }
models = [...new Set(models)]; models = [...new Set(models)];
populateModelOptions(models, preferredModel || modelSelect.value); logger.aiLog('[options] model refresh parsed models', {}, {
count: models.length,
models
});
populateModelOptions(models, selectedModel);
setModelHelp(models.length ? `Loaded ${models.length} model${models.length === 1 ? '' : 's'}.` : 'No models returned.'); setModelHelp(models.length ? `Loaded ${models.length} model${models.length === 1 ? '' : 's'}.` : 'No models returned.');
} catch (e) { } catch (e) {
logger.aiLog('[options] failed to load models', { level: 'warn' }, e); logger.aiLog('[options] failed to load models', { level: 'warn' }, e);
setModelHelp('Failed to load models. Check the endpoint and network.', true); const message = String(e?.message || e || '');
populateModelOptions([], preferredModel || modelSelect.value); const isLikelyCors = e instanceof TypeError || /Failed to fetch|NetworkError|Load failed/i.test(message);
setModelHelp(
isLikelyCors
? 'Failed to load models. The server responded, but Thunderbird may have blocked access to the response. Check CORS on the AI server and verify the endpoint is reachable from the add-on.'
: 'Failed to load models. Check the endpoint, HTTP status, and network.',
true
);
populateModelOptions([], selectedModel);
} finally { } finally {
refreshModelsBtn.disabled = false; refreshModelsBtn.disabled = false;
} }
@ -200,35 +235,10 @@ document.addEventListener('DOMContentLoaded', async () => {
populateModelOptions([], storedModel); populateModelOptions([], storedModel);
refreshModelsBtn?.addEventListener('click', () => { refreshModelsBtn?.addEventListener('click', () => {
logger.aiLog('[options] model refresh button clicked');
fetchModels(modelSelect.value); fetchModels(modelSelect.value);
}); });
const templates = {
openai: browser.i18n.getMessage('template.openai'),
qwen: browser.i18n.getMessage('template.qwen'),
mistral: browser.i18n.getMessage('template.mistral'),
harmony: browser.i18n.getMessage('template.harmony'),
custom: browser.i18n.getMessage('template.custom')
};
const templateSelect = document.getElementById('template');
for (const [value, label] of Object.entries(templates)) {
const opt = document.createElement('option');
opt.value = value;
opt.textContent = label;
templateSelect.appendChild(opt);
}
templateSelect.value = defaults.templateName || 'openai';
const customBox = document.getElementById('custom-template-container');
const customTemplate = document.getElementById('custom-template');
customTemplate.value = defaults.customTemplate || '';
function updateVisibility() {
customBox.classList.toggle('is-hidden', templateSelect.value !== 'custom');
}
templateSelect.addEventListener('change', updateVisibility);
updateVisibility();
const advancedBox = document.getElementById('advanced-options'); const advancedBox = document.getElementById('advanced-options');
const advancedBtn = document.getElementById('toggle-advanced'); const advancedBtn = document.getElementById('toggle-advanced');
advancedBtn.addEventListener('click', () => { advancedBtn.addEventListener('click', () => {
@ -1047,8 +1057,6 @@ document.addEventListener('DOMContentLoaded', async () => {
const apiKey = apiKeyInput?.value.trim() || ''; const apiKey = apiKeyInput?.value.trim() || '';
const openaiOrganization = openaiOrgInput?.value.trim() || ''; const openaiOrganization = openaiOrgInput?.value.trim() || '';
const openaiProject = openaiProjectInput?.value.trim() || ''; const openaiProject = openaiProjectInput?.value.trim() || '';
const templateName = templateSelect.value;
const customTemplateText = customTemplate.value;
const customSystemPrompt = systemBox.value; const customSystemPrompt = systemBox.value;
const aiParamsSave = {}; const aiParamsSave = {};
for (const key of Object.keys(DEFAULT_AI_PARAMS)) { for (const key of Object.keys(DEFAULT_AI_PARAMS)) {
@ -1112,10 +1120,11 @@ document.addEventListener('DOMContentLoaded', async () => {
const tokenReduction = tokenReductionToggle.checked; const tokenReduction = tokenReductionToggle.checked;
const showDebugTab = debugTabToggle.checked; const showDebugTab = debugTabToggle.checked;
const theme = themeSelect.value; const theme = themeSelect.value;
await storage.local.set({ endpoint, model, apiKey, openaiOrganization, openaiProject, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, tokenReduction, aiRules: rules, theme, showDebugTab }); await storage.local.set({ endpoint, model, apiKey, openaiOrganization, openaiProject, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, tokenReduction, aiRules: rules, theme, showDebugTab });
await storage.local.remove(['templateName', 'customTemplate']);
await applyTheme(theme); await applyTheme(theme);
try { try {
await AiClassifier.setConfig({ endpoint, model, apiKey, openaiOrganization, openaiProject, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging }); await AiClassifier.setConfig({ endpoint, model, apiKey, openaiOrganization, openaiProject, customSystemPrompt, aiParams: aiParamsSave, debugLogging });
logger.setDebug(debugLogging); logger.setDebug(debugLogging);
} catch (e) { } catch (e) {
logger.aiLog('[options] failed to apply config', {level: 'error'}, e); logger.aiLog('[options] failed to apply config', {level: 'error'}, e);

View file

@ -1,21 +0,0 @@
<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI.
Knowledge cutoff: 2024-06
Current date: 2025-06-28
Reasoning: medium
# Valid channels: analysis, commentary, final. Channel must be included for every message.<|end|>
<|start|>developer<|message|># Instructions
{{system}}<|end|>
<|start|>user<|message|>**Email Contents**
```
{{email}}
```
Classification Criterion: {{query}}
Remember, return ONLY a JSON object on a single line of the form:
{"match": true, "reason": "<short explanation>"} - if the email satisfies the criterion
{"match": false, "reason": "<short explanation>"} - otherwise
Do not add any other keys, text, or formatting.<|end|>
<|start|>assistant

View file

@ -1,12 +0,0 @@
[INST] {{system}}
Email:
{{email}}
Criterion: {{query}}
Remember, return ONLY a JSON object on a single line of the form:
{"match": true, "reason": "<short explanation>"} - if the email satisfies the criterion
{"match": false, "reason": "<short explanation>"} - otherwise
Do not add any other keys, text, or formatting.
[/INST]

View file

@ -1,14 +0,0 @@
<|im_start|>system
{{system}}<|im_end|>
<|im_start|>user
**Email Contents**
```
{{email}}
```
Classification Criterion: {{query}}
Remember, return ONLY a JSON object on a single line of the form:
{"match": true, "reason": "<short explanation>"} - if the email satisfies the criterion
{"match": false, "reason": "<short explanation>"} - otherwise
Do not add any other keys, text, or formatting.<|im_end|>
<|im_start|>assistant

View file

@ -1,15 +0,0 @@
<|im_start|>system
{{system}}
<|im_end|>
<|im_start|>user
Email:
{{email}}
Criterion: {{query}}
Remember, return ONLY a JSON object on a single line of the form:
{"match": true, "reason": "<short explanation>"} - if the email satisfies the criterion
{"match": false, "reason": "<short explanation>"} - otherwise
Do not add any other keys, text, or formatting.
<|im_end|>
<|im_start|>assistant