Add SystemOne classification support and release version 4.0.0
This commit is contained in:
parent
76195f9a92
commit
9a5404cb62
16 changed files with 923 additions and 579 deletions
|
|
@ -1,7 +1,10 @@
|
|||
"use strict";
|
||||
import { validateSettings, migratedInputLimit } from '../modules/classificationConfig.js';
|
||||
import { validCacheEntry } from '../modules/AiClassifier.js';
|
||||
const storage = (globalThis.messenger ?? browser).storage;
|
||||
const KEY_GROUPS = {
|
||||
settings: [
|
||||
'apiType', 'decisionThreshold', 'inputCharacterLimit', 'tokenReduction',
|
||||
'endpoint',
|
||||
'model',
|
||||
'apiKey',
|
||||
|
|
@ -36,12 +39,15 @@ export async function exportData(categories) {
|
|||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function importData(file, categories) {
|
||||
export async function importData(file, categories = Object.keys(KEY_GROUPS)) {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text);
|
||||
const data = {};
|
||||
for (const key of collectKeys(categories)) {
|
||||
if (key in parsed) data[key] = parsed[key];
|
||||
}
|
||||
if (categories.includes('settings') && data.inputCharacterLimit === undefined) data.inputCharacterLimit = migratedInputLimit(data);
|
||||
validateSettings(data);
|
||||
if (data.aiCache !== undefined) data.aiCache = Object.fromEntries(Object.entries(data.aiCache || {}).filter(([key, entry]) => validCacheEntry(key, entry)));
|
||||
await storage.local.set(data);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,11 +64,25 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<p class="help is-danger mb-3" id="settings-error" role="alert"></p>
|
||||
|
||||
<div id="settings-tab" class="tab-content">
|
||||
<h2 class="title is-4">
|
||||
<span class="icon is-small"><img data-icon="settings" data-size="16" src="../resources/img/settings-light-16.png" alt=""></span>
|
||||
<span>Settings</span>
|
||||
</h2>
|
||||
<div class="field">
|
||||
<label class="label" for="api-type">API type</label>
|
||||
<div class="select"><select id="api-type">
|
||||
<option value="chat-completions">Chat Completions</option>
|
||||
<option value="systemone">SystemOne</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div class="field systemone-only">
|
||||
<label class="label" for="decision-threshold">Default match probability threshold (%)</label>
|
||||
<input class="input" id="decision-threshold" type="number" min="0" max="100" step="any">
|
||||
<p class="help">Match when probability is at least this value. Rules may override it. Evaluate thresholds on your own email examples.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="endpoint">Endpoint</label>
|
||||
<div class="control">
|
||||
|
|
@ -186,7 +200,12 @@
|
|||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="max_tokens">Max tokens</label>
|
||||
<label class="label" for="input-character-limit">Input character limit</label>
|
||||
<input class="input" id="input-character-limit" type="number" min="1" step="1">
|
||||
<p class="help">Applied with aggressive reduction. Characters are not tokens; leave room for instructions within your model's context limit.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="max_tokens">Maximum output tokens</label>
|
||||
<div class="control">
|
||||
<input class="input" type="number" id="max_tokens">
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
const dataTransfer = await import(browser.runtime.getURL('options/dataTransfer.js'));
|
||||
const { detectSystemTheme } = await import(browser.runtime.getURL('modules/themeUtils.js'));
|
||||
const { DEFAULT_AI_PARAMS } = await import(browser.runtime.getURL('modules/defaultParams.js'));
|
||||
const { validateSettings, migratedInputLimit } = await import(browser.runtime.getURL('modules/classificationConfig.js'));
|
||||
const defaults = await storage.local.get([
|
||||
'apiType', 'decisionThreshold', 'inputCharacterLimit',
|
||||
'endpoint',
|
||||
'customSystemPrompt',
|
||||
'model',
|
||||
|
|
@ -86,6 +88,23 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
markDirty();
|
||||
await applyTheme(themeSelect.value);
|
||||
});
|
||||
const apiTypeInput = document.getElementById('api-type');
|
||||
apiTypeInput.value = defaults.apiType || 'chat-completions';
|
||||
const thresholdInput = document.getElementById('decision-threshold');
|
||||
thresholdInput.value = (defaults.decisionThreshold ?? 0.5) * 100;
|
||||
const inputLimit = document.getElementById('input-character-limit');
|
||||
inputLimit.value = migratedInputLimit(defaults);
|
||||
const settingsError = document.getElementById('settings-error');
|
||||
function updateApiControls() {
|
||||
const decision = apiTypeInput.value === 'systemone';
|
||||
document.querySelectorAll('.systemone-only').forEach(el => el.classList.toggle('is-hidden', !decision));
|
||||
for (const id of ['system-instructions', 'openai-organization', 'openai-project', ...Object.keys(DEFAULT_AI_PARAMS)]) {
|
||||
document.getElementById(id)?.closest('.field')?.classList.toggle('is-hidden', decision);
|
||||
}
|
||||
document.getElementById('reset-system').classList.toggle('is-hidden', decision);
|
||||
updateEndpointPreview();
|
||||
}
|
||||
apiTypeInput.addEventListener('change', updateApiControls);
|
||||
const endpointInput = document.getElementById('endpoint');
|
||||
const endpointPreview = document.getElementById('endpoint-preview');
|
||||
const fallbackEndpoint = 'http://127.0.0.1:5000';
|
||||
|
|
@ -94,13 +113,13 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
endpointInput.value = endpointBase;
|
||||
|
||||
function updateEndpointPreview() {
|
||||
const resolved = AiClassifier.buildEndpointUrl(endpointInput.value);
|
||||
const resolved = AiClassifier.buildEndpointUrl(endpointInput.value, apiTypeInput.value);
|
||||
endpointPreview.textContent = resolved
|
||||
? `Resolved endpoint: ${resolved}`
|
||||
: 'Resolved endpoint: (invalid)';
|
||||
}
|
||||
endpointInput.addEventListener('input', updateEndpointPreview);
|
||||
updateEndpointPreview();
|
||||
updateApiControls();
|
||||
|
||||
const modelSelect = document.getElementById('model-select');
|
||||
const refreshModelsBtn = document.getElementById('refresh-models');
|
||||
|
|
@ -148,11 +167,11 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
const organization = openaiOrgInput?.value.trim();
|
||||
if (organization) {
|
||||
if (organization && apiTypeInput.value === 'chat-completions') {
|
||||
headers["OpenAI-Organization"] = organization;
|
||||
}
|
||||
const project = openaiProjectInput?.value.trim();
|
||||
if (project) {
|
||||
if (project && apiTypeInput.value === 'chat-completions') {
|
||||
headers["OpenAI-Project"] = project;
|
||||
}
|
||||
return headers;
|
||||
|
|
@ -467,7 +486,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
const perDayEl = document.getElementById('per-day');
|
||||
let timingLogged = false;
|
||||
ruleCountEl.textContent = (defaults.aiRules || []).length;
|
||||
cacheCountEl.textContent = defaults.aiCache ? Object.keys(defaults.aiCache).length : 0;
|
||||
cacheCountEl.textContent = '…';
|
||||
|
||||
function createActionRow(action = {type: 'tag'}) {
|
||||
const row = document.createElement('div');
|
||||
|
|
@ -861,14 +880,25 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
body.appendChild(folderBox);
|
||||
|
||||
article.appendChild(header);
|
||||
const thresholdField = document.createElement('label');
|
||||
thresholdField.className = 'field systemone-only';
|
||||
thresholdField.classList.toggle('is-hidden', apiTypeInput.value !== 'systemone');
|
||||
thresholdField.textContent = 'Match probability threshold (%) — blank uses default';
|
||||
const override = document.createElement('input');
|
||||
override.className = 'input decision-threshold';
|
||||
override.type = 'number';
|
||||
override.min = '0'; override.max = '100'; override.step = 'any';
|
||||
override.value = rule.decisionThreshold === undefined ? '' : rule.decisionThreshold * 100;
|
||||
thresholdField.appendChild(override);
|
||||
body.appendChild(thresholdField);
|
||||
article.appendChild(body);
|
||||
|
||||
rulesContainer.appendChild(article);
|
||||
}
|
||||
}
|
||||
|
||||
addRuleBtn.addEventListener('click', () => {
|
||||
const data = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => {
|
||||
function readRules() {
|
||||
return [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => {
|
||||
const criterion = ruleEl.querySelector('.criterion').value;
|
||||
const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => {
|
||||
const type = row.querySelector('select').value;
|
||||
|
|
@ -890,8 +920,11 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
if (type === 'flag') {
|
||||
return { type, flagged: row.querySelector('.flag-select').value === 'true' };
|
||||
}
|
||||
if (type === 'delete' || type === 'archive') {
|
||||
return { type };
|
||||
if (type === 'forward') {
|
||||
return { type, address: row.querySelector('.forward-input').value.trim() };
|
||||
}
|
||||
if (type === 'reply') {
|
||||
return { type, replyType: row.querySelector('.reply-select').value };
|
||||
}
|
||||
return { type };
|
||||
});
|
||||
|
|
@ -903,6 +936,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
const accounts = [...(ruleEl.querySelector('.account-select')?.selectedOptions || [])].map(o => o.value);
|
||||
const folders = [...(ruleEl.querySelector('.folder-filter-select')?.selectedOptions || [])].map(o => o.value);
|
||||
const rule = { criterion, actions, enabled };
|
||||
const override = ruleEl.querySelector('.decision-threshold').value;
|
||||
if (override.trim() !== '') rule.decisionThreshold = Number(override) / 100;
|
||||
if (unreadOnly) rule.unreadOnly = true;
|
||||
if (stopProcessing) rule.stopProcessing = true;
|
||||
if (!isNaN(minAgeDays)) rule.minAgeDays = minAgeDays;
|
||||
|
|
@ -911,6 +946,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
if (folders.length) rule.folders = folders;
|
||||
return rule;
|
||||
});
|
||||
}
|
||||
addRuleBtn.addEventListener('click', () => {
|
||||
const data = readRules();
|
||||
data.push({ criterion: '', actions: [], unreadOnly: false, stopProcessing: false, enabled: true, accounts: [], folders: [] });
|
||||
renderRules(data);
|
||||
});
|
||||
|
|
@ -927,6 +965,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
if (r.moveTo) actions.push({ type: 'move', folder: r.moveTo });
|
||||
if (r.copyTarget || r.copyTo) actions.push({ type: 'copy', copyTarget: r.copyTarget || r.copyTo });
|
||||
const rule = { criterion: r.criterion, actions };
|
||||
if (r.decisionThreshold !== undefined) rule.decisionThreshold = r.decisionThreshold;
|
||||
if (r.stopProcessing) rule.stopProcessing = true;
|
||||
if (r.unreadOnly) rule.unreadOnly = true;
|
||||
if (typeof r.minAgeDays === 'number') rule.minAgeDays = r.minAgeDays;
|
||||
|
|
@ -996,8 +1035,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
}
|
||||
|
||||
try {
|
||||
const { aiCache } = await storage.local.get('aiCache');
|
||||
cacheCountEl.textContent = aiCache ? Object.keys(aiCache).length : 0;
|
||||
const { count } = await browser.runtime.sendMessage({ type: 'sortana:getCacheSize' });
|
||||
cacheCountEl.textContent = count;
|
||||
} catch {
|
||||
cacheCountEl.textContent = '?';
|
||||
}
|
||||
|
|
@ -1044,14 +1083,22 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
document.getElementById('import-data').addEventListener('click', () => importInput.click());
|
||||
importInput.addEventListener('change', async () => {
|
||||
if (importInput.files.length) {
|
||||
await dataTransfer.importData(importInput.files[0], selectedCategories());
|
||||
location.reload();
|
||||
try {
|
||||
await dataTransfer.importData(importInput.files[0], selectedCategories());
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
window.alert(`Import failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
initialized = true;
|
||||
|
||||
document.getElementById('save').addEventListener('click', async () => {
|
||||
settingsError.textContent = '';
|
||||
const apiType = apiTypeInput.value;
|
||||
const decisionThreshold = thresholdInput.value.trim() === '' ? NaN : Number(thresholdInput.value) / 100;
|
||||
const inputCharacterLimit = Number(inputLimit.value);
|
||||
const endpoint = endpointInput.value.trim();
|
||||
const model = modelSelect?.value || '';
|
||||
const apiKey = apiKeyInput?.value.trim() || '';
|
||||
|
|
@ -1068,63 +1115,25 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
}
|
||||
const debugLogging = debugToggle.checked;
|
||||
const htmlToMarkdown = htmlToggle.checked;
|
||||
const rules = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => {
|
||||
const criterion = ruleEl.querySelector('.criterion').value;
|
||||
const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => {
|
||||
const type = row.querySelector('select').value;
|
||||
if (type === 'tag') {
|
||||
return { type, tagKey: row.querySelector('.tag-select').value };
|
||||
}
|
||||
if (type === 'move') {
|
||||
return { type, folder: row.querySelector('.folder-select').value };
|
||||
}
|
||||
if (type === 'copy') {
|
||||
return { type, copyTarget: row.querySelector('.folder-select').value };
|
||||
}
|
||||
if (type === 'junk') {
|
||||
return { type, junk: row.querySelector('.junk-select').value === 'true' };
|
||||
}
|
||||
if (type === 'read') {
|
||||
return { type, read: row.querySelector('.read-select').value === 'true' };
|
||||
}
|
||||
if (type === 'flag') {
|
||||
return { type, flagged: row.querySelector('.flag-select').value === 'true' };
|
||||
}
|
||||
if (type === 'forward') {
|
||||
return { type, address: row.querySelector('.forward-input').value.trim() };
|
||||
}
|
||||
if (type === 'reply') {
|
||||
return { type, replyType: row.querySelector('.reply-select').value };
|
||||
}
|
||||
return { type };
|
||||
});
|
||||
const stopProcessing = ruleEl.querySelector('.stop-processing')?.checked;
|
||||
const unreadOnly = ruleEl.querySelector('.unread-only')?.checked;
|
||||
const enabled = ruleEl.dataset.enabled !== 'false';
|
||||
const minAgeDays = parseFloat(ruleEl.querySelector('.min-age')?.value);
|
||||
const maxAgeDays = parseFloat(ruleEl.querySelector('.max-age')?.value);
|
||||
const accounts = [...(ruleEl.querySelector('.account-select')?.selectedOptions || [])].map(o => o.value);
|
||||
const folders = [...(ruleEl.querySelector('.folder-filter-select')?.selectedOptions || [])].map(o => o.value);
|
||||
const rule = { criterion, actions, enabled };
|
||||
if (unreadOnly) rule.unreadOnly = true;
|
||||
if (stopProcessing) rule.stopProcessing = true;
|
||||
if (!isNaN(minAgeDays)) rule.minAgeDays = minAgeDays;
|
||||
if (!isNaN(maxAgeDays)) rule.maxAgeDays = maxAgeDays;
|
||||
if (accounts.length) rule.accounts = accounts;
|
||||
if (folders.length) rule.folders = folders;
|
||||
return rule;
|
||||
}).filter(r => r.criterion);
|
||||
const invalidThreshold = [...document.querySelectorAll('.decision-threshold'), thresholdInput, inputLimit].find(el => !el.checkValidity());
|
||||
if (invalidThreshold) {
|
||||
settingsError.textContent = 'Enter thresholds from 0% to 100% and a positive whole-number character limit.';
|
||||
return;
|
||||
}
|
||||
const rules = readRules().filter(r => r.criterion);
|
||||
const stripUrlParams = stripUrlToggle.checked;
|
||||
const altTextImages = altTextToggle.checked;
|
||||
const collapseWhitespace = collapseWhitespaceToggle.checked;
|
||||
const tokenReduction = tokenReductionToggle.checked;
|
||||
const showDebugTab = debugTabToggle.checked;
|
||||
const theme = themeSelect.value;
|
||||
await storage.local.set({ endpoint, model, apiKey, openaiOrganization, openaiProject, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, tokenReduction, aiRules: rules, theme, showDebugTab });
|
||||
try { validateSettings({ apiType, decisionThreshold, inputCharacterLimit, aiRules: rules }); }
|
||||
catch (error) { settingsError.textContent = error.message; return; }
|
||||
await storage.local.set({ apiType, decisionThreshold, inputCharacterLimit, 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);
|
||||
try {
|
||||
await AiClassifier.setConfig({ endpoint, model, apiKey, openaiOrganization, openaiProject, customSystemPrompt, aiParams: aiParamsSave, debugLogging });
|
||||
await AiClassifier.setConfig({ apiType, decisionThreshold, inputCharacterLimit, endpoint, model, apiKey, openaiOrganization, openaiProject, customSystemPrompt, aiParams: aiParamsSave, debugLogging });
|
||||
logger.setDebug(debugLogging);
|
||||
} catch (e) {
|
||||
logger.aiLog('[options] failed to apply config', {level: 'error'}, e);
|
||||
|
|
|
|||
Loading…
Reference in a new issue