Merge pull request #30 from wagesj45/codex/upgrade-rule-generation-ui-with-multi-action-support
Add rule actions tab
This commit is contained in:
commit
0858b52309
5 changed files with 212 additions and 36 deletions
|
@ -97,6 +97,9 @@ Sortana requests the following Thunderbird permissions:
|
||||||
- `storage` – store configuration and cached classification results.
|
- `storage` – store configuration and cached classification results.
|
||||||
- `messagesRead` – read message contents for classification.
|
- `messagesRead` – read message contents for classification.
|
||||||
- `messagesMove` – move messages when a rule specifies a target folder.
|
- `messagesMove` – move messages when a rule specifies a target folder.
|
||||||
|
- `messagesUpdate` – change message properties such as tags and junk status.
|
||||||
|
- `messagesTagsList` – retrieve existing message tags for rule actions.
|
||||||
|
- `accountsRead` – list accounts and folders for move actions.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|
|
@ -33,7 +33,13 @@ async function sha256Hex(str) {
|
||||||
const store = await browser.storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "aiRules"]);
|
const store = await browser.storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "aiRules"]);
|
||||||
logger.setDebug(store.debugLogging);
|
logger.setDebug(store.debugLogging);
|
||||||
await AiClassifier.setConfig(store);
|
await AiClassifier.setConfig(store);
|
||||||
aiRules = Array.isArray(store.aiRules) ? store.aiRules : [];
|
aiRules = Array.isArray(store.aiRules) ? store.aiRules.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 });
|
||||||
|
return { criterion: r.criterion, actions };
|
||||||
|
}) : [];
|
||||||
logger.aiLog("configuration loaded", {debug: true}, store);
|
logger.aiLog("configuration loaded", {debug: true}, store);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.aiLog("failed to load config", {level: 'error'}, err);
|
logger.aiLog("failed to load config", {level: 'error'}, err);
|
||||||
|
@ -73,7 +79,13 @@ if (typeof messenger !== "undefined" && messenger.messages?.onNewMailReceived) {
|
||||||
logger.aiLog("onNewMailReceived", {debug: true}, messages);
|
logger.aiLog("onNewMailReceived", {debug: true}, messages);
|
||||||
if (!aiRules.length) {
|
if (!aiRules.length) {
|
||||||
const { aiRules: stored } = await browser.storage.local.get("aiRules");
|
const { aiRules: stored } = await browser.storage.local.get("aiRules");
|
||||||
aiRules = Array.isArray(stored) ? stored : [];
|
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 });
|
||||||
|
return { criterion: r.criterion, actions };
|
||||||
|
}) : [];
|
||||||
}
|
}
|
||||||
for (const msg of (messages?.messages || messages || [])) {
|
for (const msg of (messages?.messages || messages || [])) {
|
||||||
const id = msg.id ?? msg;
|
const id = msg.id ?? msg;
|
||||||
|
@ -84,11 +96,14 @@ if (typeof messenger !== "undefined" && messenger.messages?.onNewMailReceived) {
|
||||||
const cacheKey = await sha256Hex(`${id}|${rule.criterion}`);
|
const cacheKey = await sha256Hex(`${id}|${rule.criterion}`);
|
||||||
const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey);
|
const matched = await AiClassifier.classifyText(text, rule.criterion, cacheKey);
|
||||||
if (matched) {
|
if (matched) {
|
||||||
if (rule.tag) {
|
for (const act of (rule.actions || [])) {
|
||||||
await messenger.messages.update(id, {tags: [rule.tag]});
|
if (act.type === 'tag' && act.tagKey) {
|
||||||
}
|
await messenger.messages.update(id, {tags: [act.tagKey]});
|
||||||
if (rule.moveTo) {
|
} else if (act.type === 'move' && act.folder) {
|
||||||
await messenger.messages.move([id], rule.moveTo);
|
await messenger.messages.move([id], act.folder);
|
||||||
|
} else if (act.type === 'junk') {
|
||||||
|
await messenger.messages.update(id, {junk: !!act.junk});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,5 +23,12 @@
|
||||||
"page": "options/options.html",
|
"page": "options/options.html",
|
||||||
"open_in_tab": true
|
"open_in_tab": true
|
||||||
},
|
},
|
||||||
"permissions": [ "storage", "messagesRead", "accountsRead" ]
|
"permissions": [
|
||||||
|
"storage",
|
||||||
|
"messagesRead",
|
||||||
|
"messagesMove",
|
||||||
|
"messagesUpdate",
|
||||||
|
"messagesTagsList",
|
||||||
|
"accountsRead"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -78,6 +78,19 @@
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
background: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.active {
|
||||||
|
background: #007acc;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
#rules-container {
|
#rules-container {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
@ -93,6 +106,10 @@
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border: none;
|
border: none;
|
||||||
|
@ -132,7 +149,13 @@
|
||||||
<img src="../resources/img/full-logo.png" alt="AI Filter Logo">
|
<img src="../resources/img/full-logo.png" alt="AI Filter Logo">
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<nav style="display:flex; gap:10px; justify-content:center; margin-top:10px;">
|
||||||
|
<button type="button" data-tab="settings" class="tab-button">Settings</button>
|
||||||
|
<button type="button" data-tab="rules" class="tab-button">Rules</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
<div id="settings-tab" class="tab">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="endpoint">Endpoint:</label>
|
<label for="endpoint">Endpoint:</label>
|
||||||
<input type="text" id="endpoint" placeholder="https://api.example.com">
|
<input type="text" id="endpoint" placeholder="https://api.example.com">
|
||||||
|
@ -213,10 +236,14 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr>
|
</div>
|
||||||
|
|
||||||
|
<div id="rules-tab" class="tab" style="display:none">
|
||||||
<h2>Classification Rules</h2>
|
<h2>Classification Rules</h2>
|
||||||
<div id="rules-container"></div>
|
<div id="rules-container"></div>
|
||||||
<button id="add-rule" type="button">Add Rule</button>
|
<button id="add-rule" type="button">Add Rule</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="options.js"></script>
|
<script src="options.js"></script>
|
||||||
|
|
|
@ -10,6 +10,16 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
'debugLogging',
|
'debugLogging',
|
||||||
'aiRules'
|
'aiRules'
|
||||||
]);
|
]);
|
||||||
|
const tabButtons = document.querySelectorAll('.tab-button');
|
||||||
|
const tabs = document.querySelectorAll('.tab');
|
||||||
|
tabButtons.forEach(btn => btn.addEventListener('click', () => {
|
||||||
|
tabButtons.forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
tab.style.display = tab.id === `${btn.dataset.tab}-tab` ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
tabButtons[0]?.click();
|
||||||
logger.setDebug(defaults.debugLogging === true);
|
logger.setDebug(defaults.debugLogging === true);
|
||||||
const DEFAULT_AI_PARAMS = {
|
const DEFAULT_AI_PARAMS = {
|
||||||
max_tokens: 4096,
|
max_tokens: 4096,
|
||||||
|
@ -66,6 +76,26 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
if (el) el.value = val;
|
if (el) el.value = val;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tagList = [];
|
||||||
|
let folderList = [];
|
||||||
|
try {
|
||||||
|
tagList = await messenger.messages.tags.list();
|
||||||
|
} catch (e) {
|
||||||
|
logger.aiLog('failed to list tags', {level:'error'}, e);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const accounts = await messenger.accounts.list(true);
|
||||||
|
const collect = (f, prefix='') => {
|
||||||
|
folderList.push({ id: f.id ?? f.path, name: prefix + f.name });
|
||||||
|
(f.subFolders || []).forEach(sf => collect(sf, prefix + f.name + '/'));
|
||||||
|
};
|
||||||
|
for (const acct of accounts) {
|
||||||
|
(acct.folders || []).forEach(f => collect(f, `${acct.name}/`));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.aiLog('failed to list folders', {level:'error'}, e);
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_SYSTEM = 'Determine whether the email satisfies the user\'s criterion.';
|
const DEFAULT_SYSTEM = 'Determine whether the email satisfies the user\'s criterion.';
|
||||||
const systemBox = document.getElementById('system-instructions');
|
const systemBox = document.getElementById('system-instructions');
|
||||||
systemBox.value = defaults.customSystemPrompt || DEFAULT_SYSTEM;
|
systemBox.value = defaults.customSystemPrompt || DEFAULT_SYSTEM;
|
||||||
|
@ -76,6 +106,70 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const rulesContainer = document.getElementById('rules-container');
|
const rulesContainer = document.getElementById('rules-container');
|
||||||
const addRuleBtn = document.getElementById('add-rule');
|
const addRuleBtn = document.getElementById('add-rule');
|
||||||
|
|
||||||
|
function createActionRow(action = {type: 'tag'}) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'action-row';
|
||||||
|
|
||||||
|
const typeSelect = document.createElement('select');
|
||||||
|
['tag','move','junk'].forEach(t => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t;
|
||||||
|
opt.textContent = t;
|
||||||
|
typeSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
typeSelect.value = action.type;
|
||||||
|
|
||||||
|
const paramSpan = document.createElement('span');
|
||||||
|
|
||||||
|
function updateParams() {
|
||||||
|
paramSpan.innerHTML = '';
|
||||||
|
if (typeSelect.value === 'tag') {
|
||||||
|
const sel = document.createElement('select');
|
||||||
|
sel.className = 'tag-select';
|
||||||
|
for (const t of tagList) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = t.key;
|
||||||
|
opt.textContent = t.tag;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
sel.value = action.tagKey || '';
|
||||||
|
paramSpan.appendChild(sel);
|
||||||
|
} else if (typeSelect.value === 'move') {
|
||||||
|
const sel = document.createElement('select');
|
||||||
|
sel.className = 'folder-select';
|
||||||
|
for (const f of folderList) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = f.id;
|
||||||
|
opt.textContent = f.name;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
sel.value = action.folder || '';
|
||||||
|
paramSpan.appendChild(sel);
|
||||||
|
} else if (typeSelect.value === 'junk') {
|
||||||
|
const sel = document.createElement('select');
|
||||||
|
sel.className = 'junk-select';
|
||||||
|
sel.appendChild(new Option('mark junk','true'));
|
||||||
|
sel.appendChild(new Option('mark not junk','false'));
|
||||||
|
sel.value = String(action.junk ?? true);
|
||||||
|
paramSpan.appendChild(sel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typeSelect.addEventListener('change', updateParams);
|
||||||
|
updateParams();
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.textContent = 'Remove';
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.addEventListener('click', () => row.remove());
|
||||||
|
|
||||||
|
row.appendChild(typeSelect);
|
||||||
|
row.appendChild(paramSpan);
|
||||||
|
row.appendChild(removeBtn);
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
function renderRules(rules = []) {
|
function renderRules(rules = []) {
|
||||||
rulesContainer.innerHTML = '';
|
rulesContainer.innerHTML = '';
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
|
@ -86,45 +180,63 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
critInput.type = 'text';
|
critInput.type = 'text';
|
||||||
critInput.placeholder = 'Criterion';
|
critInput.placeholder = 'Criterion';
|
||||||
critInput.value = rule.criterion || '';
|
critInput.value = rule.criterion || '';
|
||||||
|
critInput.className = 'criterion';
|
||||||
|
|
||||||
const tagInput = document.createElement('input');
|
const actionsContainer = document.createElement('div');
|
||||||
tagInput.type = 'text';
|
actionsContainer.className = 'rule-actions';
|
||||||
tagInput.placeholder = 'Tag (e.g. $label1)';
|
|
||||||
tagInput.value = rule.tag || '';
|
|
||||||
|
|
||||||
const moveInput = document.createElement('input');
|
for (const act of (rule.actions || [])) {
|
||||||
moveInput.type = 'text';
|
actionsContainer.appendChild(createActionRow(act));
|
||||||
moveInput.placeholder = 'Folder URL';
|
}
|
||||||
moveInput.value = rule.moveTo || '';
|
|
||||||
|
|
||||||
const actionsDiv = document.createElement('div');
|
const addAction = document.createElement('button');
|
||||||
actionsDiv.className = 'rule-actions';
|
addAction.textContent = 'Add Action';
|
||||||
|
addAction.type = 'button';
|
||||||
|
addAction.addEventListener('click', () => actionsContainer.appendChild(createActionRow()));
|
||||||
|
|
||||||
const delBtn = document.createElement('button');
|
const delBtn = document.createElement('button');
|
||||||
delBtn.textContent = 'Delete';
|
delBtn.textContent = 'Delete Rule';
|
||||||
delBtn.type = 'button';
|
delBtn.type = 'button';
|
||||||
delBtn.addEventListener('click', () => div.remove());
|
delBtn.addEventListener('click', () => div.remove());
|
||||||
|
|
||||||
actionsDiv.appendChild(delBtn);
|
|
||||||
|
|
||||||
div.appendChild(critInput);
|
div.appendChild(critInput);
|
||||||
div.appendChild(tagInput);
|
div.appendChild(actionsContainer);
|
||||||
div.appendChild(moveInput);
|
div.appendChild(addAction);
|
||||||
div.appendChild(actionsDiv);
|
div.appendChild(delBtn);
|
||||||
|
|
||||||
rulesContainer.appendChild(div);
|
rulesContainer.appendChild(div);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addRuleBtn.addEventListener('click', () => {
|
addRuleBtn.addEventListener('click', () => {
|
||||||
renderRules([...rulesContainer.querySelectorAll('.rule')].map(el => ({
|
const data = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => {
|
||||||
criterion: el.children[0].value,
|
const criterion = ruleEl.querySelector('.criterion').value;
|
||||||
tag: el.children[1].value,
|
const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => {
|
||||||
moveTo: el.children[2].value
|
const type = row.querySelector('select').value;
|
||||||
})).concat([{ criterion: '', tag: '', moveTo: '' }]));
|
if (type === 'tag') {
|
||||||
|
return { type, tagKey: row.querySelector('.tag-select').value };
|
||||||
|
}
|
||||||
|
if (type === 'move') {
|
||||||
|
return { type, folder: row.querySelector('.folder-select').value };
|
||||||
|
}
|
||||||
|
if (type === 'junk') {
|
||||||
|
return { type, junk: row.querySelector('.junk-select').value === 'true' };
|
||||||
|
}
|
||||||
|
return { type };
|
||||||
|
});
|
||||||
|
return { criterion, actions };
|
||||||
|
});
|
||||||
|
data.push({ criterion: '', actions: [] });
|
||||||
|
renderRules(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
renderRules(defaults.aiRules || []);
|
renderRules((defaults.aiRules || []).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 });
|
||||||
|
return { criterion: r.criterion, actions };
|
||||||
|
}));
|
||||||
|
|
||||||
document.getElementById('save').addEventListener('click', async () => {
|
document.getElementById('save').addEventListener('click', async () => {
|
||||||
const endpoint = document.getElementById('endpoint').value;
|
const endpoint = document.getElementById('endpoint').value;
|
||||||
|
@ -140,11 +252,23 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const debugLogging = debugToggle.checked;
|
const debugLogging = debugToggle.checked;
|
||||||
const rules = [...rulesContainer.querySelectorAll('.rule')].map(el => ({
|
const rules = [...rulesContainer.querySelectorAll('.rule')].map(ruleEl => {
|
||||||
criterion: el.children[0].value,
|
const criterion = ruleEl.querySelector('.criterion').value;
|
||||||
tag: el.children[1].value,
|
const actions = [...ruleEl.querySelectorAll('.action-row')].map(row => {
|
||||||
moveTo: el.children[2].value
|
const type = row.querySelector('select').value;
|
||||||
})).filter(r => r.criterion);
|
if (type === 'tag') {
|
||||||
|
return { type, tagKey: row.querySelector('.tag-select').value };
|
||||||
|
}
|
||||||
|
if (type === 'move') {
|
||||||
|
return { type, folder: row.querySelector('.folder-select').value };
|
||||||
|
}
|
||||||
|
if (type === 'junk') {
|
||||||
|
return { type, junk: row.querySelector('.junk-select').value === 'true' };
|
||||||
|
}
|
||||||
|
return { type };
|
||||||
|
});
|
||||||
|
return { criterion, actions };
|
||||||
|
}).filter(r => r.criterion);
|
||||||
await browser.storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, aiRules: rules });
|
await browser.storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, aiRules: rules });
|
||||||
try {
|
try {
|
||||||
await AiClassifier.setConfig({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging });
|
await AiClassifier.setConfig({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging });
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue