Compare commits
No commits in common. "main" and "h43lwv-codex/add-timing-stats-to-maintenance-tab" have entirely different histories.
main
...
h43lwv-cod
27
AGENTS.md
|
|
@ -5,14 +5,12 @@ This file provides guidelines for codex agents contributing to the Sortana proje
|
|||
## Repository Overview
|
||||
|
||||
- `background.js`: Handles startup tasks and coordinates message passing within the extension.
|
||||
- `modules/`: Contains reusable JavaScript modules such as `AiClassifier.js`,
|
||||
`defaultParams.js` and `themeUtils.js`.
|
||||
- `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.
|
||||
- `modules/`: Holds reusable JavaScript modules for the extension.
|
||||
- `content/`: Scripts for modifying Thunderbird windows (e.g., the filter editor).
|
||||
- `options/`: The options page HTML and JavaScript.
|
||||
- `resources/`: Images and other static files.
|
||||
- `prompt_templates/`: Prompt template files for the AI service (openai, qwen, mistral, harmony).
|
||||
- `prompt_templates/`: Prompt template files for the AI service.
|
||||
- `build-xpi.ps1`: PowerShell script to package the extension.
|
||||
- `build-xpi.sh`: Bash script to package the extension.
|
||||
|
||||
## Coding Style
|
||||
|
||||
|
|
@ -29,14 +27,7 @@ This file provides guidelines for codex agents contributing to the Sortana proje
|
|||
|
||||
## Testing
|
||||
|
||||
There are currently no automated tests for this project. If you add tests in the future, specify the commands to run them here. For now, verification must happen manually in Thunderbird. Do **not** run the `ps1` build script or the SVG processing script.
|
||||
|
||||
## 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.
|
||||
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.
|
||||
There are currently no automated tests for this project. If you add tests in the future, specify the commands to run them here. For now, verification must happen manually in Thunderbird.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
|
@ -50,7 +41,7 @@ Additional documentation exists outside this repository.
|
|||
- Thunderbird Add-on Store Policies
|
||||
- [Third Party Library Usage](https://extensionworkshop.com/documentation/publish/third-party-library-usage/)
|
||||
- Third Party Libraries
|
||||
- [Bulma.css v1.0.3](https://github.com/jgthms/bulma/blob/1.0.3/css/bulma.css)
|
||||
- [Bulma.css](https://github.com/jgthms/bulma)
|
||||
- Issue tracker: [Thunderbird tracker on Bugzilla](https://bugzilla.mozilla.org/describecomponents.cgi?product=Thunderbird)
|
||||
|
||||
|
||||
|
|
@ -75,9 +66,3 @@ text extracted from all text parts.
|
|||
properties. Any legacy `aiReasonCache` data is merged into `aiCache` the first
|
||||
time the add-on loads after an update.
|
||||
|
||||
### Icon Set Usage
|
||||
|
||||
Toolbar and menu icons reside under `resources/img` and are provided in 16, 32
|
||||
and 64 pixel variants. When changing these icons, pass a dictionary mapping the
|
||||
sizes to the paths in `browserAction.setIcon` or `messageDisplayAction.setIcon`.
|
||||
Use `resources/svg2img.ps1` to regenerate PNGs from the SVG sources.
|
||||
|
|
|
|||
102
README.md
|
|
@ -4,38 +4,26 @@
|
|||
|
||||
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
|
||||
expecting a `match` (or `matched`) boolean plus a `reason` string.
|
||||
HTTP endpoint. The endpoint should respond with JSON indicating whether the
|
||||
message meets a specified criterion.
|
||||
|
||||
## Features
|
||||
|
||||
- **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.
|
||||
- **AI classification rule** – adds the "AI classification" term with
|
||||
`matches` and `doesn't match` operators.
|
||||
- **Configurable endpoint** – set the classification service URL on the options page.
|
||||
- **Prompt templates** – choose between several model formats or provide your own custom template.
|
||||
- **Custom system prompts** – tailor the instructions sent to the model for more precise results.
|
||||
- **Filter editor integration** – patches Thunderbird's filter editor to accept
|
||||
text criteria for AI classification.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Light/Dark themes** – automatically match Thunderbird's appearance with optional manual override.
|
||||
- **Automatic rules** – create rules that tag, move, copy, forward, reply, delete, archive, mark read/unread or flag/unflag messages based on AI classification. Rules can optionally apply only to unread messages and can ignore messages outside a chosen age range.
|
||||
- **Automatic rules** – create rules that tag or move new messages based on AI classification.
|
||||
- **Rule ordering** – drag rules to prioritize them and optionally stop processing after a match.
|
||||
- **Rule enable/disable** – temporarily turn a rule off without removing it.
|
||||
- **Account & folder filters** – limit rules to specific accounts or folders.
|
||||
- **Context menu** – apply AI rules from the message list or the message display action button.
|
||||
- **Status icons** – toolbar icons show when classification is in progress and briefly display success states. If a failure occurs the icon turns red briefly before returning to normal.
|
||||
- **Error notification** – failed classification displays a notification in Thunderbird.
|
||||
- **Session error log** – the Errors tab (visible only when errors occur) shows errors recorded since the last add-on start.
|
||||
- **View reasoning** – inspect why rules matched via the Details popup.
|
||||
- **Cache management** – clear cached results from the context menu or options page.
|
||||
- **Queue & timing stats** – monitor processing time on the Maintenance tab.
|
||||
- **Packaging scripts** – `build-xpi.ps1` (PowerShell) or `build-xpi.sh` (bash) build an XPI ready for installation.
|
||||
- **Status icons** – toolbar icons show when classification is in progress and briefly display success or error states.
|
||||
- **Packaging script** – `build-xpi.ps1` builds an XPI ready for installation.
|
||||
- **Maintenance tab** – view rule counts, cache entries and clear cached results from the options page.
|
||||
|
||||
### Cache Storage
|
||||
|
|
@ -49,55 +37,42 @@ containing `matched` and `reason` fields. Older installations with a separate
|
|||
|
||||
Sortana is implemented entirely with standard WebExtension scripts—no custom experiment code is required:
|
||||
|
||||
- `background.js` loads saved settings, manages the classification queue and listens for new messages.
|
||||
- `modules/AiClassifier.js` implements the classification logic and cache handling.
|
||||
- `options/` contains the HTML and JavaScript for configuring the endpoint and rules.
|
||||
- `details.html` / `details.js` present cached reasoning for a message.
|
||||
- `background.js` loads saved settings and listens for new messages.
|
||||
- `modules/ExpressionSearchFilter.jsm` implements the AI filter and performs the
|
||||
HTTP request.
|
||||
- `options/` contains the HTML and JavaScript for configuring the endpoint and
|
||||
rules.
|
||||
- `_locales/` holds localized strings used throughout the UI.
|
||||
|
||||
### Key Files
|
||||
|
||||
| Path | Purpose |
|
||||
| --------------------------------------- | ---------------------------------------------- |
|
||||
| `manifest.json` | Extension manifest and entry points. |
|
||||
| `background.js` | Startup tasks and classification queue management. |
|
||||
| `modules/AiClassifier.js` | Core classification logic and cache handling. |
|
||||
| `manifest.json` | Extension manifest and entry points. |
|
||||
| `background.js` | Startup tasks and message handling. |
|
||||
| `modules/ExpressionSearchFilter.jsm` | Custom filter term and AI request logic. |
|
||||
| `options/options.html` and `options.js` | Endpoint and rule configuration UI. |
|
||||
| `details.html` and `details.js` | View stored reasoning for a message. |
|
||||
| `logger.js` | Colorized logging with optional debug mode. |
|
||||
| `logger.js` and `modules/logger.jsm` | Colorized logging with optional debug mode. |
|
||||
|
||||
## Building
|
||||
|
||||
1. Ensure PowerShell is available (for Windows) or adapt the script for other
|
||||
environments.
|
||||
2. The Bulma stylesheet (v1.0.3) is already included as `options/bulma.css`.
|
||||
3. Run `powershell ./build-xpi.ps1` or `./build-xpi.sh` from the repository root.
|
||||
The script reads the version from `manifest.json` and creates an XPI in the
|
||||
`release` folder.
|
||||
2. Ensure the Bulma stylesheet (v1.0.4) is saved as `options/bulma.css`. You can
|
||||
download it from <https://github.com/jgthms/bulma/blob/1.0.4/css/bulma.css>.
|
||||
3. Run `powershell ./build-xpi.ps1` from the repository root. The script reads
|
||||
the version from `manifest.json` and creates an XPI in the `release` folder.
|
||||
4. Install the generated XPI in Thunderbird via the Add-ons Manager. During
|
||||
development you can also load the directory as a temporary add-on.
|
||||
5. To regenerate PNG icons from the SVG sources, run `resources/svg2img.ps1`.
|
||||
|
||||
## 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
|
||||
`/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.
|
||||
2. Use the **Classification Rules** section to add a criterion and optional
|
||||
actions such as tagging, moving, copying, forwarding, replying,
|
||||
deleting or archiving a message when it matches. Drag rules to
|
||||
reorder them, check *Only apply to unread messages* to skip read mail,
|
||||
set optional minimum or maximum message age limits, select the accounts or
|
||||
folders a rule should apply to. Use the
|
||||
slashed-circle/check button to disable or re-enable a rule. The small
|
||||
circle buttons for optional conditions show a filled dot when active, and
|
||||
check *Stop after match* to halt further processing. Forward and reply actions
|
||||
open a compose window using the account that received the message.
|
||||
1. Open the add-on's options and set the URL of your classification service.
|
||||
2. Use the **Classification Rules** section to add a criterion and optional
|
||||
actions such as tagging or moving a message when it matches. Drag rules to
|
||||
reorder them and check *Stop after match* to halt further processing.
|
||||
3. Save your settings. New mail will be evaluated automatically using the
|
||||
configured rules.
|
||||
4. If the toolbar icon shows a red X, it will clear after a few seconds. Open the Errors tab in Options to review the latest failures.
|
||||
|
||||
### Example Filters
|
||||
|
||||
|
|
@ -125,7 +100,7 @@ Here are some useful and fun example criteria you can use in your filters. Filte
|
|||
For when you're ready to filter based on vibes.
|
||||
|
||||
You can define as many filters as you'd like, each using a different prompt and
|
||||
triggering tags, moves, copies, deletes, archives, read/unread changes or flag updates based on the model's classification.
|
||||
triggering tags, moves, or actions based on the model's classification.
|
||||
|
||||
## Required Permissions
|
||||
|
||||
|
|
@ -133,14 +108,11 @@ Sortana requests the following Thunderbird permissions:
|
|||
|
||||
- `storage` – store configuration and cached classification results.
|
||||
- `messagesRead` – read message contents for classification.
|
||||
- `messagesMove` – move or copy messages when a rule specifies a target folder.
|
||||
- `messagesUpdate` – change message properties such as tags, junk status, read/unread state and flags.
|
||||
- `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 or copy actions.
|
||||
- `accountsRead` – list accounts and folders for move actions.
|
||||
- `menus` – add context menu commands.
|
||||
- `tabs` – open new tabs and query the active tab.
|
||||
- `notifications` – display error notifications.
|
||||
- `compose` – create reply and forward compose windows for matching rules.
|
||||
|
||||
## Thunderbird Add-on Store Disclosures
|
||||
|
||||
|
|
@ -149,17 +121,12 @@ requires disclosure of third party libraries that are included in the add-on. Ev
|
|||
the disclosure is only required for add-on review, they'll be listed here as well. Sortana
|
||||
uses the following third party libraries:
|
||||
|
||||
- [Bulma.css v1.0.3](https://github.com/jgthms/bulma/blob/1.0.3/css/bulma.css)
|
||||
- MIT License
|
||||
- [turndown v7.2.0](https://github.com/mixmark-io/turndown/tree/v7.2.0)
|
||||
- MIT License
|
||||
- [diff](https://github.com/google/diff-match-patch/blob/62f2e689f498f9c92dbc588c58750addec9b1654/javascript/diff_match_patch_uncompressed.js)
|
||||
- Apache-2.0 license
|
||||
- [Bulma.css v1.0.4](https://github.com/jgthms/bulma/blob/1.0.4/css/bulma.css)
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the terms of the GNU General Public License
|
||||
version 3. See `LICENSE` for the full text. Third party libraries are licensed seperately.
|
||||
version 3. See `LICENSE` for the full text.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
|
|
@ -169,4 +136,3 @@ Sortana builds upon knowledge gained from open-source projects. In particular,
|
|||
how Thunderbird's WebExtension and experiment APIs can be extended. Their code
|
||||
provided invaluable guidance during development.
|
||||
|
||||
- Icons from [cc0-icons.jonh.eu](https://cc0-icons.jonh.eu/) are used under the CC0 license.
|
||||
|
|
|
|||
|
|
@ -12,27 +12,7 @@
|
|||
"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" },
|
||||
"options.stripUrlParams": { "message": "Remove URL tracking parameters" },
|
||||
"options.altTextImages": { "message": "Replace images with alt text" },
|
||||
"options.collapseWhitespace": { "message": "Collapse long whitespace" },
|
||||
"options.tokenReduction": { "message": "Aggressive token reduction" }
|
||||
,"action.read": { "message": "read" }
|
||||
,"action.flag": { "message": "flag" }
|
||||
,"action.copy": { "message": "copy" }
|
||||
,"action.delete": { "message": "delete" }
|
||||
,"action.archive": { "message": "archive" }
|
||||
,"action.forward": { "message": "forward" }
|
||||
,"action.reply": { "message": "reply" }
|
||||
,"param.markRead": { "message": "mark read" }
|
||||
,"param.markUnread": { "message": "mark unread" }
|
||||
,"param.flag": { "message": "flag" }
|
||||
,"param.unflag": { "message": "unflag" }
|
||||
,"param.address": { "message": "address" }
|
||||
,"param.replyAll": { "message": "reply all" }
|
||||
,"param.replySender": { "message": "reply sender" }
|
||||
"options.debugLogging": { "message": "Enable debug logging" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ EndProject
|
|||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "modules", "modules", "{75ED3C1E-D3C7-4546-9F2E-AC85859DDF4B}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
modules\AiClassifier.js = modules\AiClassifier.js
|
||||
modules\ExpressionSearchFilter.jsm = modules\ExpressionSearchFilter.jsm
|
||||
modules\logger.jsm = modules\logger.jsm
|
||||
modules\messageUtils.jsm = modules\messageUtils.jsm
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_locales", "_locales", "{D446E5C6-BDDE-4091-BD1A-EC57170003CF}"
|
||||
|
|
@ -44,45 +47,17 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "prompt_templates", "prompt_
|
|||
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
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{F266602F-1755-4A95-A11B-6C90C701C5BF}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
resources\img\average-16.png = resources\img\average-16.png
|
||||
resources\img\average-32.png = resources\img\average-32.png
|
||||
resources\img\average-64.png = resources\img\average-64.png
|
||||
resources\img\check-16.png = resources\img\check-16.png
|
||||
resources\img\check-32.png = resources\img\check-32.png
|
||||
resources\img\check-64.png = resources\img\check-64.png
|
||||
resources\img\circle-16.png = resources\img\circle-16.png
|
||||
resources\img\circle-32.png = resources\img\circle-32.png
|
||||
resources\img\circle-64.png = resources\img\circle-64.png
|
||||
resources\img\circledots-16.png = resources\img\circledots-16.png
|
||||
resources\img\circledots-32.png = resources\img\circledots-32.png
|
||||
resources\img\circledots-64.png = resources\img\circledots-64.png
|
||||
resources\img\clipboarddata-16.png = resources\img\clipboarddata-16.png
|
||||
resources\img\clipboarddata-32.png = resources\img\clipboarddata-32.png
|
||||
resources\img\clipboarddata-64.png = resources\img\clipboarddata-64.png
|
||||
resources\img\download-16.png = resources\img\download-16.png
|
||||
resources\img\download-32.png = resources\img\download-32.png
|
||||
resources\img\download-64.png = resources\img\download-64.png
|
||||
resources\img\eye-16.png = resources\img\eye-16.png
|
||||
resources\img\eye-32.png = resources\img\eye-32.png
|
||||
resources\img\eye-64.png = resources\img\eye-64.png
|
||||
resources\img\flag-16.png = resources\img\flag-16.png
|
||||
resources\img\flag-32.png = resources\img\flag-32.png
|
||||
resources\img\flag-64.png = resources\img\flag-64.png
|
||||
resources\img\full-logo-white.png = resources\img\full-logo-white.png
|
||||
resources\img\brain.png = resources\img\brain.png
|
||||
resources\img\busy.png = resources\img\busy.png
|
||||
resources\img\done.png = resources\img\done.png
|
||||
resources\img\error.png = resources\img\error.png
|
||||
resources\img\full-logo.png = resources\img\full-logo.png
|
||||
resources\img\gear-16.png = resources\img\gear-16.png
|
||||
resources\img\gear-32.png = resources\img\gear-32.png
|
||||
resources\img\gear-64.png = resources\img\gear-64.png
|
||||
resources\img\logo.png = resources\img\logo.png
|
||||
resources\img\logo128.png = resources\img\logo128.png
|
||||
resources\img\logo16.png = resources\img\logo16.png
|
||||
|
|
@ -90,45 +65,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "img", "img", "{F266602F-175
|
|||
resources\img\logo48.png = resources\img\logo48.png
|
||||
resources\img\logo64.png = resources\img\logo64.png
|
||||
resources\img\logo96.png = resources\img\logo96.png
|
||||
resources\img\reply-16.png = resources\img\reply-16.png
|
||||
resources\img\reply-32.png = resources\img\reply-32.png
|
||||
resources\img\reply-64.png = resources\img\reply-64.png
|
||||
resources\img\settings-16.png = resources\img\settings-16.png
|
||||
resources\img\settings-32.png = resources\img\settings-32.png
|
||||
resources\img\settings-64.png = resources\img\settings-64.png
|
||||
resources\img\trash-16.png = resources\img\trash-16.png
|
||||
resources\img\trash-32.png = resources\img\trash-32.png
|
||||
resources\img\trash-64.png = resources\img\trash-64.png
|
||||
resources\img\upload-16.png = resources\img\upload-16.png
|
||||
resources\img\upload-32.png = resources\img\upload-32.png
|
||||
resources\img\upload-64.png = resources\img\upload-64.png
|
||||
resources\img\x-16.png = resources\img\x-16.png
|
||||
resources\img\x-32.png = resources\img\x-32.png
|
||||
resources\img\x-64.png = resources\img\x-64.png
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "js", "js", "{21D2A42C-3F85-465C-9141-C106AFD92B68}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
resources\js\diff_match_patch_uncompressed.js = resources\js\diff_match_patch_uncompressed.js
|
||||
resources\js\turndown.js = resources\js\turndown.js
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "svg", "svg", "{D4E9C905-4884-488E-B763-5BD39049C1B1}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
resources\svg\average.svg = resources\svg\average.svg
|
||||
resources\svg\check.svg = resources\svg\check.svg
|
||||
resources\svg\circle.svg = resources\svg\circle.svg
|
||||
resources\svg\circledots.svg = resources\svg\circledots.svg
|
||||
resources\svg\clipboarddata.svg = resources\svg\clipboarddata.svg
|
||||
resources\svg\download.svg = resources\svg\download.svg
|
||||
resources\svg\eye.svg = resources\svg\eye.svg
|
||||
resources\svg\flag.svg = resources\svg\flag.svg
|
||||
resources\svg\gear.svg = resources\svg\gear.svg
|
||||
resources\svg\reply.svg = resources\svg\reply.svg
|
||||
resources\svg\settings.svg = resources\svg\settings.svg
|
||||
resources\svg\trash.svg = resources\svg\trash.svg
|
||||
resources\svg\upload.svg = resources\svg\upload.svg
|
||||
resources\svg\x.svg = resources\svg\x.svg
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Global
|
||||
|
|
@ -143,7 +79,5 @@ Global
|
|||
{86516D53-50D4-4FE2-9D8A-977A8F5EBDBD} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}
|
||||
{68A87938-5C2B-49F5-8AAA-8A34FBBFD854} = {BCC6E6D2-343B-4C48-854D-5FE3BBC3CB70}
|
||||
{F266602F-1755-4A95-A11B-6C90C701C5BF} = {68A87938-5C2B-49F5-8AAA-8A34FBBFD854}
|
||||
{21D2A42C-3F85-465C-9141-C106AFD92B68} = {68A87938-5C2B-49F5-8AAA-8A34FBBFD854}
|
||||
{D4E9C905-4884-488E-B763-5BD39049C1B1} = {68A87938-5C2B-49F5-8AAA-8A34FBBFD854}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
|
|||
878
background.js
|
|
@ -26,7 +26,7 @@ if (-not $version) {
|
|||
}
|
||||
|
||||
# 4) Define output names & clean up
|
||||
$xpiName = "sortana-$version.xpi"
|
||||
$xpiName = "ai-filter-$version.xpi"
|
||||
$zipPath = Join-Path $ReleaseDir "ai-filter-$version.zip"
|
||||
$xpiPath = Join-Path $ReleaseDir $xpiName
|
||||
|
||||
|
|
|
|||
77
build-xpi.sh
|
|
@ -1,77 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
release_dir="$script_dir/release"
|
||||
manifest="$script_dir/manifest.json"
|
||||
|
||||
if [[ ! -f "$manifest" ]]; then
|
||||
echo "manifest.json not found at $manifest" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v zip >/dev/null 2>&1; then
|
||||
echo "zip is required to build the XPI." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
version="$(jq -r '.version // empty' "$manifest")"
|
||||
else
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "python3 is required to read manifest.json without jq." >&2
|
||||
exit 1
|
||||
fi
|
||||
version="$(python3 - <<'PY'
|
||||
import json
|
||||
import sys
|
||||
with open(sys.argv[1], 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
print(data.get('version', '') or '')
|
||||
PY
|
||||
"$manifest")"
|
||||
fi
|
||||
|
||||
if [[ -z "$version" ]]; then
|
||||
echo "No version found in manifest.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$release_dir"
|
||||
|
||||
xpi_name="sortana-$version.xpi"
|
||||
zip_path="$release_dir/ai-filter-$version.zip"
|
||||
xpi_path="$release_dir/$xpi_name"
|
||||
|
||||
rm -f "$zip_path" "$xpi_path"
|
||||
|
||||
mapfile -d '' files < <(
|
||||
find "$script_dir" -type f \
|
||||
! -name '*.sln' \
|
||||
! -name '*.ps1' \
|
||||
! -name '*.sh' \
|
||||
! -path "$release_dir/*" \
|
||||
! -path "$script_dir/.vs/*" \
|
||||
! -path "$script_dir/.git/*" \
|
||||
-printf '%P\0'
|
||||
)
|
||||
|
||||
if [[ ${#files[@]} -eq 0 ]]; then
|
||||
echo "No files found to package." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for rel in "${files[@]}"; do
|
||||
full="$script_dir/$rel"
|
||||
size=$(stat -c '%s' "$full")
|
||||
echo "Zipping: $rel <- $full ($size bytes)"
|
||||
done
|
||||
|
||||
(
|
||||
cd "$script_dir"
|
||||
printf '%s\n' "${files[@]}" | zip -q -9 -@ "$zip_path"
|
||||
)
|
||||
|
||||
mv -f "$zip_path" "$xpi_path"
|
||||
|
||||
echo "Built XPI at: $xpi_path"
|
||||
|
|
@ -15,6 +15,6 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script type="module" src="details.js"></script>
|
||||
<script src="details.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
53
details.js
|
|
@ -1,51 +1,23 @@
|
|||
const aiLog = (await import(browser.runtime.getURL("logger.js"))).aiLog;
|
||||
const storage = (globalThis.messenger ?? browser).storage;
|
||||
const { detectSystemTheme } = await import(browser.runtime.getURL('modules/themeUtils.js'));
|
||||
const { theme } = await storage.local.get('theme');
|
||||
const mode = (theme || 'auto') === 'auto'
|
||||
? await detectSystemTheme()
|
||||
: theme;
|
||||
document.documentElement.dataset.theme = mode;
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
let id = parseInt(params.get('mid'), 10);
|
||||
|
||||
const qMid = parseInt(new URLSearchParams(location.search).get("mid"), 10);
|
||||
if (!isNaN(qMid)) {
|
||||
loadMessage(qMid);
|
||||
} else {
|
||||
const { ids } = await browser.runtime.sendMessage({
|
||||
type: "sortana:getDisplayedMessages",
|
||||
});
|
||||
if (ids && ids[0]) {
|
||||
loadMessage(ids[0]);
|
||||
} else {
|
||||
if (!id) {
|
||||
try {
|
||||
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
|
||||
const tabId = tabs[0]?.id;
|
||||
const msgs = tabId ? await browser.messageDisplay.getDisplayedMessages(tabId) : [];
|
||||
let id = msgs[0]?.id;
|
||||
if (id) {
|
||||
loadMessage(id);
|
||||
}
|
||||
else {
|
||||
aiLog("Details popup: no displayed message found");
|
||||
}
|
||||
id = msgs[0]?.id;
|
||||
} catch (e) {
|
||||
console.error('failed to determine message id', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessage(id) {
|
||||
const storage = (globalThis.messenger ?? browser).storage;
|
||||
const logMod = await import(browser.runtime.getURL('logger.js'));
|
||||
const { debugLogging } = await storage.local.get('debugLogging');
|
||||
logMod.setDebug(debugLogging === true);
|
||||
const log = logMod.aiLog;
|
||||
|
||||
log('details page loaded', { debug: true });
|
||||
if (!id) return;
|
||||
try {
|
||||
log('requesting message details', {}, id);
|
||||
const { subject, results } = await browser.runtime.sendMessage({ type: 'sortana:getDetails', id });
|
||||
log('received details', { debug: true }, { subject, results });
|
||||
document.getElementById('subject').textContent = subject;
|
||||
const container = document.getElementById('rules');
|
||||
for (const r of results) {
|
||||
log('rendering rule result', { debug: true }, r);
|
||||
const article = document.createElement('article');
|
||||
const color = r.matched === true ? 'is-success' : 'is-danger';
|
||||
article.className = `message ${color} mb-4`;
|
||||
|
|
@ -65,11 +37,10 @@ async function loadMessage(id) {
|
|||
container.appendChild(article);
|
||||
}
|
||||
document.getElementById('clear').addEventListener('click', async () => {
|
||||
log('clearing cache for message', {}, id);
|
||||
await browser.runtime.sendMessage({ type: 'sortana:clearCacheForMessage', id });
|
||||
window.close();
|
||||
});
|
||||
} catch (e) {
|
||||
log('failed to load details', { level: 'error' }, e);
|
||||
console.error('failed to load details', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Sortana",
|
||||
"version": "2.4.2",
|
||||
"version": "2.0.0",
|
||||
"default_locale": "en-US",
|
||||
"applications": {
|
||||
"gecko": {
|
||||
"id": "ai-filter@jordanwages",
|
||||
"strict_min_version": "128.0",
|
||||
"strict_max_version": "140.*"
|
||||
"strict_max_version": "*"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
"default_icon": "resources/img/logo32.png"
|
||||
},
|
||||
"message_display_action": {
|
||||
"default_icon": "resources/img/logo.png",
|
||||
"default_icon": "resources/img/brain.png",
|
||||
"default_title": "Details",
|
||||
"default_label": "Details",
|
||||
"default_popup": "details.html"
|
||||
|
|
@ -32,18 +32,14 @@
|
|||
"page": "options/options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"permissions": [
|
||||
"storage",
|
||||
"messagesRead",
|
||||
"messagesMove",
|
||||
"messagesUpdate",
|
||||
"messagesTagsList",
|
||||
"accountsRead",
|
||||
"menus",
|
||||
"notifications",
|
||||
"scripting",
|
||||
"tabs",
|
||||
"theme",
|
||||
"compose"
|
||||
]
|
||||
"permissions": [
|
||||
"storage",
|
||||
"messagesRead",
|
||||
"messagesMove",
|
||||
"messagesUpdate",
|
||||
"messagesTagsList",
|
||||
"accountsRead",
|
||||
"menus",
|
||||
"scripting"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use strict";
|
||||
import { aiLog, setDebug } from "../logger.js";
|
||||
import { DEFAULT_AI_PARAMS } from "./defaultParams.js";
|
||||
|
||||
const storage = (globalThis.messenger ?? globalThis.browser).storage;
|
||||
|
||||
|
|
@ -15,9 +14,6 @@ try {
|
|||
Services = undefined;
|
||||
}
|
||||
|
||||
const COMPLETIONS_PATH = "/v1/completions";
|
||||
const MODELS_PATH = "/v1/models";
|
||||
|
||||
const SYSTEM_PREFIX = `You are an email-classification assistant.
|
||||
Read the email below and the classification criterion provided by the user.
|
||||
`;
|
||||
|
|
@ -26,61 +22,34 @@ const DEFAULT_CUSTOM_SYSTEM_PROMPT = "Determine whether the email satisfies the
|
|||
|
||||
const SYSTEM_SUFFIX = `
|
||||
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
|
||||
{"match": true} - if the email satisfies the criterion
|
||||
{"match": false} - otherwise
|
||||
|
||||
Do not add any other keys, text, or formatting.`;
|
||||
|
||||
let gEndpointBase = "http://127.0.0.1:5000";
|
||||
let gEndpoint = buildEndpointUrl(gEndpointBase);
|
||||
let gEndpoint = "http://127.0.0.1:5000/v1/classify";
|
||||
let gTemplateName = "openai";
|
||||
let gCustomTemplate = "";
|
||||
let gCustomSystemPrompt = DEFAULT_CUSTOM_SYSTEM_PROMPT;
|
||||
let gTemplateText = "";
|
||||
|
||||
let gAiParams = Object.assign({}, DEFAULT_AI_PARAMS);
|
||||
let gModel = "";
|
||||
let gApiKey = "";
|
||||
let gOpenaiOrganization = "";
|
||||
let gOpenaiProject = "";
|
||||
let gAiParams = {
|
||||
max_tokens: 4096,
|
||||
temperature: 0.6,
|
||||
top_p: 0.95,
|
||||
seed: -1,
|
||||
repetition_penalty: 1.0,
|
||||
top_k: 20,
|
||||
min_p: 0,
|
||||
presence_penalty: 0,
|
||||
frequency_penalty: 0,
|
||||
typical_p: 1,
|
||||
tfs: 1,
|
||||
};
|
||||
|
||||
let gCache = new Map();
|
||||
let gCacheLoaded = false;
|
||||
|
||||
function normalizeEndpointBase(endpoint) {
|
||||
if (typeof endpoint !== "string") {
|
||||
return "";
|
||||
}
|
||||
let base = endpoint.trim();
|
||||
if (!base) {
|
||||
return "";
|
||||
}
|
||||
base = base.replace(/\/v1\/(completions|models)\/?$/i, "");
|
||||
return base;
|
||||
}
|
||||
|
||||
function buildEndpointUrl(endpointBase) {
|
||||
const base = normalizeEndpointBase(endpointBase);
|
||||
if (!base) {
|
||||
return "";
|
||||
}
|
||||
const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`;
|
||||
const needsSlash = withScheme.endsWith("/");
|
||||
const path = COMPLETIONS_PATH.replace(/^\//, "");
|
||||
return `${withScheme}${needsSlash ? "" : "/"}${path}`;
|
||||
}
|
||||
|
||||
function buildModelsUrl(endpointBase) {
|
||||
const base = normalizeEndpointBase(endpointBase);
|
||||
if (!base) {
|
||||
return "";
|
||||
}
|
||||
const withScheme = /^https?:\/\//i.test(base) ? base : `https://${base}`;
|
||||
const needsSlash = withScheme.endsWith("/");
|
||||
const path = MODELS_PATH.replace(/^\//, "");
|
||||
return `${withScheme}${needsSlash ? "" : "/"}${path}`;
|
||||
}
|
||||
|
||||
function sha256HexSync(str) {
|
||||
try {
|
||||
const hasher = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
|
||||
|
|
@ -103,6 +72,10 @@ async function sha256Hex(str) {
|
|||
return sha256HexSync(str);
|
||||
}
|
||||
|
||||
function buildCacheKeySync(id, criterion) {
|
||||
return sha256HexSync(`${id}|${criterion}`);
|
||||
}
|
||||
|
||||
async function resolveHeaderId(id) {
|
||||
if (typeof id === "number" && typeof messenger?.messages?.get === "function") {
|
||||
try {
|
||||
|
|
@ -120,7 +93,7 @@ async function resolveHeaderId(id) {
|
|||
async function buildCacheKey(id, criterion) {
|
||||
const resolvedId = await resolveHeaderId(id);
|
||||
if (Services) {
|
||||
return sha256HexSync(`${resolvedId}|${criterion}`);
|
||||
return buildCacheKeySync(resolvedId, criterion);
|
||||
}
|
||||
return sha256Hex(`${resolvedId}|${criterion}`);
|
||||
}
|
||||
|
|
@ -160,6 +133,16 @@ async function loadCache() {
|
|||
gCacheLoaded = true;
|
||||
}
|
||||
|
||||
function loadCacheSync() {
|
||||
if (!gCacheLoaded) {
|
||||
if (!Services?.tm?.spinEventLoopUntil) {
|
||||
throw new Error("loadCacheSync requires Services");
|
||||
}
|
||||
let done = false;
|
||||
loadCache().finally(() => { done = true; });
|
||||
Services.tm.spinEventLoopUntil(() => done);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCache(updatedKey, updatedValue) {
|
||||
if (typeof updatedKey !== "undefined") {
|
||||
|
|
@ -200,12 +183,8 @@ function loadTemplateSync(name) {
|
|||
}
|
||||
|
||||
async function setConfig(config = {}) {
|
||||
if (typeof config.endpoint === "string") {
|
||||
const base = normalizeEndpointBase(config.endpoint);
|
||||
if (base) {
|
||||
gEndpointBase = base;
|
||||
}
|
||||
gEndpoint = buildEndpointUrl(gEndpointBase);
|
||||
if (config.endpoint) {
|
||||
gEndpoint = config.endpoint;
|
||||
}
|
||||
if (config.templateName) {
|
||||
gTemplateName = config.templateName;
|
||||
|
|
@ -223,18 +202,6 @@ async function setConfig(config = {}) {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (typeof config.model === "string") {
|
||||
gModel = config.model.trim();
|
||||
}
|
||||
if (typeof config.apiKey === "string") {
|
||||
gApiKey = config.apiKey.trim();
|
||||
}
|
||||
if (typeof config.openaiOrganization === "string") {
|
||||
gOpenaiOrganization = config.openaiOrganization.trim();
|
||||
}
|
||||
if (typeof config.openaiProject === "string") {
|
||||
gOpenaiProject = config.openaiProject.trim();
|
||||
}
|
||||
if (typeof config.debugLogging === "boolean") {
|
||||
setDebug(config.debugLogging);
|
||||
}
|
||||
|
|
@ -245,28 +212,10 @@ async function setConfig(config = {}) {
|
|||
} 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() {
|
||||
const headers = {};
|
||||
if (gApiKey) {
|
||||
headers.Authorization = `Bearer ${gApiKey}`;
|
||||
}
|
||||
if (gOpenaiOrganization) {
|
||||
headers["OpenAI-Organization"] = gOpenaiOrganization;
|
||||
}
|
||||
if (gOpenaiProject) {
|
||||
headers["OpenAI-Project"] = gOpenaiProject;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function buildSystemPrompt() {
|
||||
return SYSTEM_PREFIX + (gCustomSystemPrompt || DEFAULT_CUSTOM_SYSTEM_PROMPT) + SYSTEM_SUFFIX;
|
||||
}
|
||||
|
|
@ -284,7 +233,11 @@ function buildPrompt(body, criterion) {
|
|||
|
||||
function getCachedResult(cacheKey) {
|
||||
if (!gCacheLoaded) {
|
||||
return null;
|
||||
if (Services?.tm?.spinEventLoopUntil) {
|
||||
loadCacheSync();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (cacheKey && gCache.has(cacheKey)) {
|
||||
aiLog(`[AiClassifier] Cache hit for key: ${cacheKey}`, {debug: true});
|
||||
|
|
@ -296,7 +249,11 @@ function getCachedResult(cacheKey) {
|
|||
|
||||
function getReason(cacheKey) {
|
||||
if (!gCacheLoaded) {
|
||||
return null;
|
||||
if (Services?.tm?.spinEventLoopUntil) {
|
||||
loadCacheSync();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const entry = gCache.get(cacheKey);
|
||||
return cacheKey && entry ? entry.reason || null : null;
|
||||
|
|
@ -306,101 +263,18 @@ function buildPayload(text, criterion) {
|
|||
let payloadObj = Object.assign({
|
||||
prompt: buildPrompt(text, criterion)
|
||||
}, gAiParams);
|
||||
if (gModel) {
|
||||
payloadObj.model = gModel;
|
||||
}
|
||||
return JSON.stringify(payloadObj);
|
||||
}
|
||||
|
||||
function reportParseError(message, detail) {
|
||||
try {
|
||||
const runtime = (globalThis.browser ?? globalThis.messenger)?.runtime;
|
||||
if (!runtime?.sendMessage) {
|
||||
return;
|
||||
}
|
||||
runtime.sendMessage({
|
||||
type: "sortana:recordError",
|
||||
context: "AI response parsing",
|
||||
message,
|
||||
detail
|
||||
}).catch(() => {});
|
||||
} catch (e) {
|
||||
aiLog("Failed to report parse error", { level: "warn" }, e);
|
||||
}
|
||||
}
|
||||
|
||||
function extractLastJsonObject(text) {
|
||||
let last = null;
|
||||
let start = -1;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const ch = text[i];
|
||||
if (inString) {
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\"") {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "\"") {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "{") {
|
||||
if (depth === 0) {
|
||||
start = i;
|
||||
}
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "}" && depth > 0) {
|
||||
depth -= 1;
|
||||
if (depth === 0 && start !== -1) {
|
||||
last = text.slice(start, i + 1);
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return last;
|
||||
}
|
||||
|
||||
function parseMatch(result) {
|
||||
const rawText = result.choices?.[0]?.text || "";
|
||||
const candidate = extractLastJsonObject(rawText);
|
||||
if (!candidate) {
|
||||
reportParseError("No JSON object found in AI response.", rawText.slice(0, 800));
|
||||
return { matched: false, reason: "" };
|
||||
}
|
||||
|
||||
let obj;
|
||||
try {
|
||||
obj = JSON.parse(candidate);
|
||||
} catch (e) {
|
||||
reportParseError("Failed to parse JSON from AI response.", candidate.slice(0, 800));
|
||||
return { matched: false, reason: "" };
|
||||
}
|
||||
|
||||
const matchValue = Object.prototype.hasOwnProperty.call(obj, "match") ? obj.match : obj.matched;
|
||||
const matched = matchValue === true;
|
||||
if (matchValue !== true && matchValue !== false) {
|
||||
reportParseError("AI response missing valid match boolean.", candidate.slice(0, 800));
|
||||
}
|
||||
|
||||
const reasonValue = obj.reason ?? obj.reasoning ?? obj.explaination;
|
||||
const reason = typeof reasonValue === "string" ? reasonValue : "";
|
||||
|
||||
return { matched, reason };
|
||||
const thinkText = rawText.match(/<think>[\s\S]*?<\/think>/gi)?.join('') || '';
|
||||
aiLog('[AiClassifier] ⮡ Reasoning:', {debug: true}, thinkText);
|
||||
const cleanedText = rawText.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
|
||||
aiLog('[AiClassifier] ⮡ Cleaned Response Text:', {debug: true}, cleanedText);
|
||||
const obj = JSON.parse(cleanedText);
|
||||
const matched = obj.matched === true || obj.match === true;
|
||||
return { matched, reason: thinkText };
|
||||
}
|
||||
|
||||
function cacheEntry(cacheKey, matched, reason) {
|
||||
|
|
@ -456,6 +330,48 @@ async function getCacheSize() {
|
|||
return gCache.size;
|
||||
}
|
||||
|
||||
function classifyTextSync(text, criterion, cacheKey = null) {
|
||||
if (!Services?.tm?.spinEventLoopUntil) {
|
||||
throw new Error("classifyTextSync requires Services");
|
||||
}
|
||||
const cached = getCachedResult(cacheKey);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const payload = buildPayload(text, criterion);
|
||||
|
||||
aiLog(`[AiClassifier] Sending classification request to ${gEndpoint}`, {debug: true});
|
||||
|
||||
let result;
|
||||
let done = false;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(gEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: payload,
|
||||
});
|
||||
if (response.ok) {
|
||||
const json = await response.json();
|
||||
aiLog(`[AiClassifier] Received response:`, {debug: true}, json);
|
||||
result = parseMatch(json);
|
||||
cacheEntry(cacheKey, result.matched, result.reason);
|
||||
result = result.matched;
|
||||
} else {
|
||||
aiLog(`HTTP status ${response.status}`, {level: 'warn'});
|
||||
result = false;
|
||||
}
|
||||
} catch (e) {
|
||||
aiLog(`HTTP request failed`, {level: 'error'}, e);
|
||||
result = false;
|
||||
} finally {
|
||||
done = true;
|
||||
}
|
||||
})();
|
||||
Services.tm.spinEventLoopUntil(() => done);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function classifyText(text, criterion, cacheKey = null) {
|
||||
if (!gCacheLoaded) {
|
||||
|
|
@ -467,11 +383,6 @@ async function classifyText(text, criterion, cacheKey = null) {
|
|||
}
|
||||
|
||||
const payload = buildPayload(text, criterion);
|
||||
try {
|
||||
await storage.local.set({ lastPayload: JSON.parse(payload) });
|
||||
} catch (e) {
|
||||
aiLog('failed to save last payload', { level: 'warn' }, e);
|
||||
}
|
||||
|
||||
aiLog(`[AiClassifier] Sending classification request to ${gEndpoint}`, {debug: true});
|
||||
aiLog(`[AiClassifier] Classification request payload:`, { debug: true }, payload);
|
||||
|
|
@ -479,7 +390,7 @@ async function classifyText(text, criterion, cacheKey = null) {
|
|||
try {
|
||||
const response = await fetch(gEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...buildAuthHeaders() },
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: payload,
|
||||
});
|
||||
|
||||
|
|
@ -503,4 +414,4 @@ async function init() {
|
|||
await loadCache();
|
||||
}
|
||||
|
||||
export { buildEndpointUrl, buildModelsUrl, normalizeEndpointBase, classifyText, setConfig, removeCacheEntries, clearCache, getReason, getCachedResult, buildCacheKey, getCacheSize, init };
|
||||
export { classifyText, classifyTextSync, setConfig, removeCacheEntries, clearCache, getReason, getCachedResult, buildCacheKey, buildCacheKeySync, getCacheSize, init };
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
export const DEFAULT_AI_PARAMS = {
|
||||
max_tokens: 4096,
|
||||
temperature: 0.6,
|
||||
top_p: 0.95,
|
||||
seed: -1,
|
||||
repetition_penalty: 1.0,
|
||||
top_k: 20,
|
||||
min_p: 0,
|
||||
presence_penalty: 0,
|
||||
frequency_penalty: 0,
|
||||
typical_p: 1,
|
||||
tfs: 1,
|
||||
};
|
||||
|
||||
26
modules/logger.jsm
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
var EXPORTED_SYMBOLS = ['aiLog', 'setDebug'];
|
||||
let debugEnabled = false;
|
||||
|
||||
function setDebug(value) {
|
||||
debugEnabled = !!value;
|
||||
}
|
||||
|
||||
function getCaller() {
|
||||
try {
|
||||
let stack = new Error().stack.split('\n');
|
||||
if (stack.length >= 3) {
|
||||
return stack[2].trim().replace(/^@?\s*\(?/,'').replace(/^at\s+/, '');
|
||||
}
|
||||
} catch (e) {}
|
||||
return '';
|
||||
}
|
||||
|
||||
function aiLog(message, opts = {}, ...args) {
|
||||
const { level = 'log', debug = false } = opts;
|
||||
if (debug && !debugEnabled) {
|
||||
return;
|
||||
}
|
||||
const caller = getCaller();
|
||||
const prefix = caller ? `[ai-filter][${caller}]` : '[ai-filter]';
|
||||
console[level](`%c${prefix}`, 'color:#1c92d2;font-weight:bold', message, ...args);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
export async function detectSystemTheme() {
|
||||
try {
|
||||
const t = await browser.theme.getCurrent();
|
||||
const scheme = t?.properties?.color_scheme;
|
||||
if (scheme === 'dark' || scheme === 'light') {
|
||||
return scheme;
|
||||
}
|
||||
const color = t?.colors?.frame || t?.colors?.toolbar;
|
||||
if (color && /^#/.test(color)) {
|
||||
const r = parseInt(color.slice(1, 3), 16);
|
||||
const g = parseInt(color.slice(3, 5), 16);
|
||||
const b = parseInt(color.slice(5, 7), 16);
|
||||
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return lum < 0.5 ? 'dark' : 'light';
|
||||
}
|
||||
} catch {}
|
||||
return 'light';
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
"use strict";
|
||||
const storage = (globalThis.messenger ?? browser).storage;
|
||||
const KEY_GROUPS = {
|
||||
settings: [
|
||||
'endpoint',
|
||||
'model',
|
||||
'apiKey',
|
||||
'openaiOrganization',
|
||||
'openaiProject',
|
||||
'templateName',
|
||||
'customTemplate',
|
||||
'customSystemPrompt',
|
||||
'aiParams',
|
||||
'debugLogging',
|
||||
'htmlToMarkdown',
|
||||
'stripUrlParams',
|
||||
'altTextImages',
|
||||
'collapseWhitespace'
|
||||
],
|
||||
rules: ['aiRules'],
|
||||
cache: ['aiCache']
|
||||
};
|
||||
|
||||
function collectKeys(categories = Object.keys(KEY_GROUPS)) {
|
||||
return categories.flatMap(cat => KEY_GROUPS[cat] || []);
|
||||
}
|
||||
|
||||
export async function exportData(categories) {
|
||||
const data = await storage.local.get(collectKeys(categories));
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'sortana-export.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export async function importData(file, categories) {
|
||||
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];
|
||||
}
|
||||
await storage.local.set(data);
|
||||
}
|
||||
|
|
@ -31,65 +31,36 @@
|
|||
.tag {
|
||||
--bulma-tag-h: 318;
|
||||
}
|
||||
#diff-display {
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="section">
|
||||
<div class="container" id="options-container">
|
||||
<figure class="has-text-centered mb-4">
|
||||
<img data-icon="full-logo" src="../resources/img/full-logo.png" alt="AI Filter Logo" style="max-height:40px;">
|
||||
<img src="../resources/img/full-logo.png" alt="AI Filter Logo" style="max-height:40px;">
|
||||
</figure>
|
||||
|
||||
<div class="level mb-4">
|
||||
<div class="level-left">
|
||||
<div class="tabs" id="main-tabs">
|
||||
<ul>
|
||||
<li class="is-active" data-tab="settings"><a><span class="icon is-small"><img data-icon="settings" data-size="16" src="../resources/img/settings-light-16.png" alt=""></span><span>Settings</span></a></li>
|
||||
<li data-tab="rules"><a><span class="icon is-small"><img data-icon="clipboarddata" data-size="16" src="../resources/img/clipboarddata-light-16.png" alt=""></span><span>Rules</span></a></li>
|
||||
<li data-tab="maintenance"><a><span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span><span>Maintenance</span></a></li>
|
||||
<li id="errors-tab-button" class="is-hidden" data-tab="errors"><a><span class="icon is-small"><img data-icon="x" data-size="16" src="../resources/img/x-light-16.png" alt=""></span><span>Errors</span></a></li>
|
||||
<li id="debug-tab-button" class="is-hidden" data-tab="debug"><a><span class="icon is-small"><img data-icon="average" data-size="16" src="../resources/img/average-light-16.png" alt=""></span><span>Debug</span></a></li>
|
||||
<li class="is-active" data-tab="settings"><a>Settings</a></li>
|
||||
<li data-tab="rules"><a>Rules</a></li>
|
||||
<li data-tab="maintenance"><a>Maintenance</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<button class="button is-primary" id="save" disabled>
|
||||
<span class="icon is-small"><img data-icon="flag" data-size="16" src="../resources/img/flag-light-16.png" alt=""></span>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
<button class="button is-primary" id="save" disabled>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="endpoint">Endpoint</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" id="endpoint" placeholder="https://api.example.com">
|
||||
</div>
|
||||
<p class="help" id="endpoint-preview"></p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="model-select">Model</label>
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<div class="select is-fullwidth">
|
||||
<select id="model-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button" id="refresh-models" type="button">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help" id="model-help"></p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
|
@ -116,92 +87,17 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="theme-select">Theme</label>
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select id="theme-select">
|
||||
<option value="auto">Match Thunderbird</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button class="button is-danger" id="reset-system">
|
||||
<span class="icon is-small"><img data-icon="reply" data-size="16" src="../resources/img/reply-light-16.png" alt=""></span>
|
||||
<span>Reset to default</span>
|
||||
</button>
|
||||
<button class="button" id="toggle-advanced" type="button">
|
||||
<span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span>
|
||||
<span>Advanced</span>
|
||||
</button>
|
||||
<button class="button is-danger" id="reset-system">Reset to default</button>
|
||||
<button class="button" id="toggle-advanced" type="button">Advanced</button>
|
||||
</div>
|
||||
|
||||
<div id="advanced-options" class="mt-4 is-hidden">
|
||||
<div class="field">
|
||||
<label class="label" for="api-key">API key</label>
|
||||
<div class="field has-addons">
|
||||
<div class="control is-expanded">
|
||||
<input class="input" type="password" id="api-key" placeholder="sk-...">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button" id="toggle-api-key" type="button">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help">Leave blank for unauthenticated endpoints.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="openai-organization">OpenAI Organization</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" id="openai-organization" placeholder="org-...">
|
||||
</div>
|
||||
<p class="help">Optional header for OpenAI-hosted endpoints.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="openai-project">OpenAI Project</label>
|
||||
<div class="control">
|
||||
<input class="input" type="text" id="openai-project" placeholder="proj_...">
|
||||
</div>
|
||||
<p class="help">Optional header for OpenAI-hosted endpoints.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="debug-logging"> Enable debug logging
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="html-to-markdown"> Convert HTML body to Markdown
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="strip-url-params"> Remove URL tracking parameters
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="alt-text-images"> Replace images with alt text
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="collapse-whitespace"> Collapse long whitespace
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="token-reduction"> Aggressive token reduction
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" id="show-debug-tab"> Show debug information
|
||||
</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="max_tokens">Max tokens</label>
|
||||
<div class="control">
|
||||
|
|
@ -272,110 +168,27 @@
|
|||
</div>
|
||||
|
||||
<div id="rules-tab" class="tab-content is-hidden">
|
||||
<h2 class="title is-4">
|
||||
<span class="icon is-small"><img data-icon="clipboarddata" data-size="16" src="../resources/img/clipboarddata-light-16.png" alt=""></span>
|
||||
<span>Classification Rules</span>
|
||||
</h2>
|
||||
<h2 class="title is-4">Classification Rules</h2>
|
||||
<div id="rules-container"></div>
|
||||
<button class="button is-link" id="add-rule" type="button">Add Rule</button>
|
||||
</div>
|
||||
|
||||
<div id="maintenance-tab" class="tab-content is-hidden">
|
||||
<h2 class="title is-4">
|
||||
<span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span>
|
||||
<span>Maintenance</span>
|
||||
</h2>
|
||||
<h2 class="title is-4">Maintenance</h2>
|
||||
<table class="table is-fullwidth">
|
||||
<tbody>
|
||||
<tr><th>Rule count</th><td id="rule-count"></td></tr>
|
||||
<tr><th>Cache entries</th><td id="cache-count"></td></tr>
|
||||
<tr><th>Queue items</th><td id="queue-count"></td></tr>
|
||||
<tr><th>Current run time</th><td id="current-time">--:--:--</td></tr>
|
||||
<tr><th>Last run time</th><td id="last-time">--:--:--</td></tr>
|
||||
<tr><th>Average run time</th><td id="average-time">--:--:--</td></tr>
|
||||
<tr><th>Total run time</th><td id="total-time">--:--:--</td></tr>
|
||||
<tr><th>Messages per hour</th><td id="per-hour">0</td></tr>
|
||||
<tr><th>Messages per day</th><td id="per-day">0</td></tr>
|
||||
<tr><th>Current run time</th><td id="current-time">--:--</td></tr>
|
||||
<tr><th>Average run time</th><td id="average-time">--:--</td></tr>
|
||||
<tr><th>Total run time</th><td id="total-time">--:--</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="buttons">
|
||||
<button class="button is-danger" id="clear-cache" type="button">
|
||||
<span class="icon is-small"><img data-icon="trash" data-size="16" src="../resources/img/trash-light-16.png" alt=""></span>
|
||||
<span>Clear Cache</span>
|
||||
</button>
|
||||
<button class="button is-warning" id="reset-timing" type="button">
|
||||
<span class="icon is-small"><img data-icon="average" data-size="16" src="../resources/img/average-light-16.png" alt=""></span>
|
||||
<span>Reset Timing Stats</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="field mt-4">
|
||||
<label class="label">Data categories</label>
|
||||
<div class="control">
|
||||
<label class="checkbox mr-3"><input class="transfer-category" type="checkbox" value="settings" checked> Settings</label>
|
||||
<label class="checkbox mr-3"><input class="transfer-category" type="checkbox" value="rules" checked> Rules</label>
|
||||
<label class="checkbox"><input class="transfer-category" type="checkbox" value="cache" checked> Cache</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field is-grouped mt-4">
|
||||
<p class="control">
|
||||
<button class="button" id="export-data" type="button">
|
||||
<span class="icon is-small"><img data-icon="download" data-size="16" src="../resources/img/download-light-16.png" alt=""></span>
|
||||
<span>Export Data</span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button class="button" id="import-data" type="button">
|
||||
<span class="icon is-small"><img data-icon="upload" data-size="16" src="../resources/img/upload-light-16.png" alt=""></span>
|
||||
<span>Import Data</span>
|
||||
</button>
|
||||
<input class="is-hidden" type="file" id="import-file" accept="application/json">
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="errors-tab" class="tab-content is-hidden">
|
||||
<h2 class="title is-4">
|
||||
<span class="icon is-small"><img data-icon="x" data-size="16" src="../resources/img/x-light-16.png" alt=""></span>
|
||||
<span>Session Errors</span>
|
||||
</h2>
|
||||
<div id="errors-empty" class="notification is-success is-light">
|
||||
No errors have been recorded since the last start.
|
||||
</div>
|
||||
<div id="errors-panel" class="is-hidden">
|
||||
<div class="box mb-4">
|
||||
<div class="level">
|
||||
<div class="level-left">
|
||||
<div>
|
||||
<p class="title is-5 mb-1">Error Log</p>
|
||||
<p class="subtitle is-6">Visible only for this session.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right">
|
||||
<span class="tag is-danger is-light" id="errors-count">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="errors-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="debug-tab" class="tab-content is-hidden">
|
||||
<h2 class="title is-4">
|
||||
<span class="icon is-small"><img data-icon="average" data-size="16" src="../resources/img/average-light-16.png" alt=""></span>
|
||||
<span>Debug</span>
|
||||
</h2>
|
||||
<pre id="payload-display"></pre>
|
||||
<div id="diff-container" class="mt-4 is-hidden">
|
||||
<div class="is-flex is-align-items-center is-justify-content-space-between">
|
||||
<label class="label mb-0">Prompt diff</label>
|
||||
<span id="prompt-reduction" class="tag is-info is-light is-hidden">Prompt Token Reduction: 0%</span>
|
||||
</div>
|
||||
<div id="diff-display" class="box content is-family-monospace"></div>
|
||||
</div>
|
||||
<button class="button is-danger" id="clear-cache" type="button">Clear Cache</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<script src="../resources/js/diff_match_patch_uncompressed.js"></script>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -5,8 +5,8 @@ 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
|
||||
{"match": true} - if the email satisfies the criterion
|
||||
{"match": false} - otherwise
|
||||
|
||||
Do not add any other keys, text, or formatting.
|
||||
[/INST]
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
```
|
||||
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
|
||||
{"match": true} - if the email satisfies the criterion
|
||||
{"match": false} - otherwise
|
||||
|
||||
Do not add any other keys, text, or formatting.<|im_end|>
|
||||
<|im_start|>assistant
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ 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
|
||||
{"match": true} - if the email satisfies the criterion
|
||||
{"match": false} - otherwise
|
||||
|
||||
Do not add any other keys, text, or formatting.
|
||||
<|im_end|>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 427 B |
|
Before Width: | Height: | Size: 791 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 416 B |
|
Before Width: | Height: | Size: 794 B |
|
Before Width: | Height: | Size: 1.5 KiB |
BIN
resources/img/brain.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
resources/img/busy.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 307 B |
|
Before Width: | Height: | Size: 449 B |
|
Before Width: | Height: | Size: 940 B |
|
Before Width: | Height: | Size: 300 B |
|
Before Width: | Height: | Size: 450 B |
|
Before Width: | Height: | Size: 927 B |
|
Before Width: | Height: | Size: 389 B |
|
Before Width: | Height: | Size: 722 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 389 B |
|
Before Width: | Height: | Size: 724 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 413 B |
|
Before Width: | Height: | Size: 828 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 408 B |
|
Before Width: | Height: | Size: 813 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 396 B |
|
Before Width: | Height: | Size: 773 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 394 B |
|
Before Width: | Height: | Size: 773 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 392 B |
|
Before Width: | Height: | Size: 768 B |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 393 B |
|
Before Width: | Height: | Size: 764 B |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 320 B |
|
Before Width: | Height: | Size: 537 B |
|
Before Width: | Height: | Size: 969 B |
|
Before Width: | Height: | Size: 314 B |
|
Before Width: | Height: | Size: 543 B |
|
Before Width: | Height: | Size: 989 B |
BIN
resources/img/done.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 340 B |
|
Before Width: | Height: | Size: 556 B |
|
Before Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 345 B |
|
Before Width: | Height: | Size: 571 B |
|
Before Width: | Height: | Size: 1,006 B |
BIN
resources/img/error.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 374 B |
|
Before Width: | Height: | Size: 729 B |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 371 B |
|
Before Width: | Height: | Size: 733 B |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 293 B |
|
Before Width: | Height: | Size: 469 B |
|
Before Width: | Height: | Size: 766 B |
|
Before Width: | Height: | Size: 300 B |
|
Before Width: | Height: | Size: 475 B |
|
Before Width: | Height: | Size: 800 B |
|
Before Width: | Height: | Size: 484 B |
|
Before Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 462 B |
|
Before Width: | Height: | Size: 993 B |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 199 B |
|
Before Width: | Height: | Size: 301 B |
|
Before Width: | Height: | Size: 431 B |
|
Before Width: | Height: | Size: 215 B |
|
Before Width: | Height: | Size: 305 B |
|
Before Width: | Height: | Size: 457 B |
|
Before Width: | Height: | Size: 280 B |
|
Before Width: | Height: | Size: 414 B |
|
Before Width: | Height: | Size: 749 B |