diff --git a/.gitignore b/.gitignore index 3a03209..af677a8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ extension/.output extension/.wxt extension/node_modules extension/pnpm-lock.yaml -.export_venv +.envrc diff --git a/Makefile.toml b/Makefile.toml index a6d4fe7..374e6d1 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -64,7 +64,7 @@ dependencies = [ ] [tasks.inference_model_ai_text] -env = {_MK_MODEL_AI_TEXT = "onnx-community/modernbert-ai-detection-raid-mage-ONNX",_MK_MODEL_AI_TEXT_ATTENTION = "yes",_MK_MODEL_AI_TEXT_TYPES = "no"} +env = {_MK_MODEL_AI_TEXT = "onnx-community/modernbert-ai-detection-raid-mage-ONNX", _MK_MODEL_AI_TEXT_ONNX = "onnx/model_fp16.onnx", _MK_MODEL_AI_TEXT_ATTENTION = "yes", _MK_MODEL_AI_TEXT_TYPES = "no"} cwd = "wasm/inference-models" install_crate = "wasm-pack" command = "wasm-pack" @@ -75,12 +75,11 @@ args = [ "--target", "web", "--out-dir", "pkg/inference-ai-text", "--out-name", "inference-ai-text", - "--features", "ai_text", - "-vvv" + "--features", "ai_text" ] [tasks.inference_model_ai_text_lite] -env = {_MK_MODEL_AI_TEXT = "onnx-community/e5-small-lora-ai-generated-detector-ONNX",_MK_MODEL_AI_TEXT_ATTENTION = "yes",_MK_MODEL_AI_TEXT_TYPES = "yes"} +env = {_MK_MODEL_AI_TEXT = "onnx-community/e5-small-lora-ai-generated-detector-ONNX", _MK_MODEL_AI_TEXT_ONNX = "onnx/model_fp16.onnx", _MK_MODEL_AI_TEXT_ATTENTION = "yes", _MK_MODEL_AI_TEXT_TYPES = "yes"} cwd = "wasm/inference-models" install_crate = "wasm-pack" command = "wasm-pack" diff --git a/README.md b/README.md index 6fd5ae7..9a1c29d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,28 @@ # firaid-moz -> I don't know why I named it this way + +> [!NOTE] +> This software is provided under terms of BUSL 1.1 license in most permissive mode, +> with exceptions for some automation. +> Please, consult the [LICENSE](LICENSE.md) if you're about to perform AI-assisted analysis + +--- + +**Disclaimer:** + +The software provided as-is, without warranty of any kind, express or implied. +This project is an educational proof-of-concept experiment. + +To clarify: + +- I am not responsible for your judgement on content origin (AI/human/assisted) +- I am not responsible for your attitude towards AI/human/assisted content +- I am not responsible for any consequences of such a judgement + +Classifications performed by this extension are not based on private or social opinions. This extension does not rely on reputations, lists, filters, etc. + +The only method used by this extension to determine origin of content is a bunch of linear algebra in a black-box, aka "ML model", which decisions may or may not be biased - depending on training dataset, which is out of scope of this project. + +--- A simple local webextension, which can run a bunch of small ML models to infer some properties of DOM content, such as: @@ -8,61 +31,93 @@ A simple local webextension, which can run a bunch of small ML models to infer s ## AI/Human classification -> **NONE** of ML/AI/whatever models is 100% accurate by the very fundamental design. Any ML model *pretends* to do some job with certain degree of accuracy. So this extension *pretends* to know which content is AI-generated and which is not - with a certain degree of accuracy. +> **NONE** of ML/AI/whatever models is 100% accurate by the very fundamental design. Every "AI" simply *pretends* to posess some information. So this extension *pretends* to know which content is AI-generated and which is not - with a certain degree of accuracy. > ->If you don't completely trust the "AI slop" - you should not completely trust this extension also! +> If you don't trust the "AI slop" - you should not completely trust this extension also! > -> False-positives? - Possible +> False-positives - Possible > -> False-negatives? - Probably +> False-negatives - Probably > -> This extension - like **every other AI** - is a **tool** which aims to help you and **not** replace your own discretion. +> User discretion - Highly advised -No, I do not hate the AI. It's cool. I hate the *slop* - when the entire "technical guide" article is nothing more then a bunch of AI ejacuation. But how do I know that slop is a slop? +I hate the *slop* - when the entire "technical guide" article is nothing more then a bunch of AI ejacuation. But how do I know that slop is a slop? Yeah.. I have to read it. And I had _enough_ of reading. So I want to see a warning badge like "its AI" - to help me navigate through text/image content. -AI/Human classification can be done in four different modes (sorted in order of preference for the potato hardware): +AI/Human classification can be done in three modes: -0. _(potato)_: No classification -1. _(normal)_: Lightweight model, which can have "no decision at all" in some cases -2. _(high)_: Lightweight model still does its job, but when its decision is weak - heavy model takes the task -3. _(ultra)_: Heavy and more accurate model performs always, lightweight model is disabled +1. _(lite)_: Lightweight model +2. _(mixed)_: Lightweight model runs first, but when its decision is weak - heavier model takes the task. This is usually faster, but requires both lite and heavy models to be loaded thus eating lots of RAM +3. _(hard)_: Heavier and more accurate model takes all tasks, lightweight model is disabled -The _normal_ mode requires ~150Mb of RAM for text classification and ~200Mb of RAM for image classification. +The _lite_ mode requires ~80Mb of RAM for text classification -The _ultra_ mode requires ~700Mb of RAM for text classification and ~500Mb of RAM for image classification. +The _mixed_ mode requires ~380Mb of RAM for text classification -## `Microslop` mode +The _hard_ mode requires ~300Mb of RAM for text classification -> Slop-sloppity slop, micro slop slop slop, microslop microslop microslop +## Installation -That's easy. Turn is on and every time the page contains word "microsoft" (except for the URLs) - this extention will replace it with "microslop" +### Building from source -## `Githief` mode +This webextension can be built from source. The following instrumentation is required: -You know what they do with your "licensed" and "private repository" code. +- `cargo` + `cargo-make` + `wasm-pack` (cargo-make will install it automatically) +- `pnpm` -Turn this on - and all "github" (except for urls) shall turn into "githief" +To build both firefox and chrome release versions, run the `release` task: -## `Хуяндекс` mode +```bash +cargo make -p production release +``` -At 2025 the "yandex" corporation was caught in bypassing the data permissions on Android smartphones to collect and possibly provide all kinds of your personal information to the third-parties; to track you; to deanon you; thus violating your consent and privacy. +If you do not have `wasm-pack` (cargo) installed, cargo-make will install it for you. -You can read more about it [here](https://localmess.github.io/) +#### Models -They stopped that practice after disclosure, yes, but they did it nontheless. +To only build models, clone the repo, and run `models` task within `production` environment: -So you can turn on this feature and every occurence of word "яндекс" shall be replaced for "хуяндекс" (russian wordplay, something like "fuck-yandex") and every occurence of word "yandex" (except for urls) shall be replaced with your custom word (I did not figure out proper wordplay for it yet). +```bash +cargo make -p production models +``` ---- +This will compile all implemented models into WASM packages, which are later used by webextension. -## A bit of technical details +NPM-ready packages for both ai-text models will be located under `/wasm/pkg` directory -## Contributing +##### Using different models for inference -Wooow, I am so very glad thay you're reading this section! Thank you in advance for interest to contributing. +You can build the [text classification](./wasm/model-ai-text) with any ONNX model from huggingface. -As you can see - this is not a github, but there is a [mirror](https://github.com/yiffyrusdev/spushks-firaid-moz), which my friend was so kind to provide. You can open issues and make PRs there - we'll merge them, preserving your identity. +Following env variables are required: -Also, if you sumbit any code, it is considered Apache-2.0 +- `_MK_MODEL_AI_TEXT`: name of the huggingface repository (e.g. "onnx-community/text-ai-detection") +- `_MK_MODEL_AI_TEXT_ONNX`: name of the onnx model file under the repository (e.g. "onnx/model.onnx") + +Following env variables are optional: + +- `_MK_MODEL_AI_TEXT_ATTENTION`: set to `yes` or `1` if your model uses attention mask +- `_MK_MODEL_AI_TEXT_TYPES`: set to `yes` or `1` if your model uses token types tensor + +Not every model will work, and not every model which works will compute valid results. +Major constraints are: + +- FP32 or FP16 weights (quantizations, int8, etc will be available when implemented by burn/burn-onnx) +- Sane size, 500Mb is your soft ceiling (browsers would likely refuse to load enormous WASM) +- Two label classification, where label `[0]` is Human and `[1]` is AI +- Output is `[0, 2]` sized tensor of *logits* + +If you encounter compilation error, and compiler complains about invalid `forward` function arguments, +most likely you set the wrong `_MK_MODEL_AI_TEXT_ATTENTION` and `_MK_MODEL_AI_TEXT_TYPES` values. + +#### Extension + +> You must build models before running this + +To only build the extension, clone the repo, build models, then you can run following tasks: + +- `extension`: build and zip firefox and chrome extensions +- `extension_moz`: only build firefox extension +- `extension_chro`: only build chrome extension +- `dev`: run wxt dev server (chrome variant) diff --git a/exportenv_requirements.txt b/exportenv_requirements.txt deleted file mode 100644 index d47a54f..0000000 --- a/exportenv_requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -optimum-onnx[onnxruntime] -accelerate diff --git a/extension/src/entrypoints/background.ts b/extension/src/entrypoints/background.ts deleted file mode 100644 index c9212ec..0000000 --- a/extension/src/entrypoints/background.ts +++ /dev/null @@ -1,154 +0,0 @@ -import inference_ai_text, { AiText, Capability as AiTextCapability } from "inference-ai-text"; -import inference_ai_text_lite, { - AiText as AiTextLite, - Capability as AiTextLiteCapability, -} from "inference-ai-text-lite"; - -import { onMessage } from "@/utils/messaging"; -import { ExtLogLevel } from "@/utils/logging"; -import { ExtensionConfigAiText } from "@/utils/config"; - -export default defineBackground({ - type: "module", - main() { - setup_inference_ai_text(); - setup_ext_console(); - }, -}); - -function setup_ext_console() { - onMessage("log", ({ data }) => { - switch (data.level) { - case ExtLogLevel.Debug: - console.debug(`[${data.sender}]`, ...data.args); - break; - case ExtLogLevel.Info: - console.log(`[${data.sender}]`, ...data.args); - break; - case ExtLogLevel.Warning: - console.warn(`[${data.sender}]`, ...data.args); - break; - case ExtLogLevel.Error: - console.error(`[${data.sender}]`, ...data.args); - break; - } - }); -} - -const AI_TEXT_STATE: { - handle: AiText | null; - queue_inference_timer: number | null; - have_normal_model: boolean; - have_lite_model: boolean; -} = { - have_normal_model: false, - have_lite_model: false, - handle: null, - queue_inference_timer: null, -}; - -function setup_inference_ai_text() { - extensionConfig.ai_text.getValue().then((config) => { - configure_ai_text(config); - }); - extensionConfig.ai_text.watch((config) => { - configure_ai_text(config); - }); - - onMessage("inference_aiText_classify", ({ data: { text } }) => { - return new Promise(async (resolve, reject) => { - if (!AI_TEXT_STATE.handle) { - reject({ - reason: "TextAi inference is disabled", - errors: [], - }); - return; - } - - console.debug("TEXT", text); - - AI_TEXT_STATE.handle - .queue(text) - .then((r) => { - // we're passing that into outer space, can not play with reference - resolve({ prob_ai: r.prob_ai, prob_human: r.prob_human }); - }) - .catch((e) => { - reject({ - reason: "inference error", - errors: [e], - }); - }); - - // Run inference if reported queue size reached threshold, - // or schedule inference to run anyway after some time - let stats = AI_TEXT_STATE.handle.stats(); - if (stats.queue_size > stats.queue_limit / 2) { - AI_TEXT_STATE.handle.run_queued_inference().catch((e) => { - console.error("Queue-threshold initiated inference failed", e); - }); - if (AI_TEXT_STATE.queue_inference_timer) { - clearTimeout(AI_TEXT_STATE.queue_inference_timer); - } - AI_TEXT_STATE.queue_inference_timer = null; - } else if (!AI_TEXT_STATE.queue_inference_timer) { - AI_TEXT_STATE.queue_inference_timer = setTimeout(() => { - if (AI_TEXT_STATE.handle) { - AI_TEXT_STATE.queue_inference_timer = null; - AI_TEXT_STATE.handle.run_queued_inference().catch((e) => { - console.error("Timed initiated inference failed", e); - }); - } - }, 500); - } - }); - }); -} - -async function configure_ai_text(config: ExtensionConfigAiText) { - // if (config.enabled) { - // const loader = config.lite ? inference_ai_text_lite : inference_ai_text; - // const handler = config.lite ? AiTextLite : AiText; - // const capability = config.lite ? AiTextLiteCapability : AiTextCapability; - // const have = config.lite ? AI_TEXT_STATE.have_lite_model : AI_TEXT_STATE.have_normal_model; - // const set_have = config.lite - // ? () => (AI_TEXT_STATE.have_lite_model = true) - // : () => (AI_TEXT_STATE.have_normal_model = true); - // if (!have) { - // await loader(); - // if (await guess_has_webgpu()) { - // console.warn( - // "WebGPU detected, but use is not currently possible due to burn APIs being sync. Check the issue progress in burn and burn-onnx repos. In the mean time will use NdArray anyway.", - // ); - // //await capability.assert_ndarray(); - // await capability.assert_webgpu(); - // } else { - // await capability.assert_ndarray(); - // } - // set_have(); - // } - // AI_TEXT_STATE.handle = new handler(); - // } else { - // AI_TEXT_STATE.handle = null; - // if (AI_TEXT_STATE.queue_inference_timer) { - // clearTimeout(AI_TEXT_STATE.queue_inference_timer); - // } - // } -} - -async function guess_has_webgpu() { - console.debug("Best effort to check whether WebGPU is available"); - if ("gpu" in navigator && navigator.gpu) { - console.debug("guess_webgpu: navigator has GPU"); - try { - if (await (navigator.gpu as any).requestAdapter()) { - console.debug("guess_webgpu: adapter seems fine"); - return true; - } - } catch (e) { - console.warn("guess_webgpu: adapter fail", e); - } - } - - return false; -} diff --git a/extension/src/entrypoints/background/index.ts b/extension/src/entrypoints/background/index.ts new file mode 100644 index 0000000..60e3122 --- /dev/null +++ b/extension/src/entrypoints/background/index.ts @@ -0,0 +1,39 @@ +import text_inference, { queue as text_queue } from "./text_inference"; + +import { onMessage } from "@/utils/messaging"; +import { ExtLogLevel } from "@/utils/logging"; +import { ExtensionConfigAiText } from "@/utils/config"; + +export default defineBackground({ + type: "module", + main() { + setup_ext_console(); + setup_text_inference(); + }, +}); + +function setup_ext_console() { + onMessage("log", ({ data }) => { + switch (data.level) { + case ExtLogLevel.Debug: + console.debug(`[${data.sender}]`, ...data.args); + break; + case ExtLogLevel.Info: + console.log(`[${data.sender}]`, ...data.args); + break; + case ExtLogLevel.Warning: + console.warn(`[${data.sender}]`, ...data.args); + break; + case ExtLogLevel.Error: + console.error(`[${data.sender}]`, ...data.args); + break; + } + }); +} + +function setup_text_inference() { + text_inference(); + onMessage("inference_aiText_classify", ({ data }) => { + return text_queue(data.text); + }); +} diff --git a/extension/src/entrypoints/background/text_inference.ts b/extension/src/entrypoints/background/text_inference.ts new file mode 100644 index 0000000..d77fd5a --- /dev/null +++ b/extension/src/entrypoints/background/text_inference.ts @@ -0,0 +1,184 @@ +import { ExtensionConfigAiText, SyncingExtensionConfig } from "@/utils/config"; +import inference_ai_text, { AiText, AiTextInference, Capability } from "inference-ai-text"; +import inference_ai_text_lite, { + AiText as AiTextLite, + Capability as CapabilityLite, +} from "inference-ai-text-lite"; +import { has_wgpu } from "@/utils/system"; + +const CONFIG = new SyncingExtensionConfig("ai_text"); + +const STATE: { + handle: Promise | null; + handle_lite: Promise | null; + handle_run_timer: ReturnType | null; + handle_lite_run_timer: ReturnType | null; + wasm_load: boolean; + wasm_load_lite: boolean; +} = { + handle: null, + handle_lite: null, + handle_run_timer: null, + handle_lite_run_timer: null, + wasm_load: false, + wasm_load_lite: false, +}; + +export default function setup() { + CONFIG.sync.then(() => { + reload(CONFIG.value); + CONFIG.watch(reload); + }); +} + +export async function queue(text: string): Promise<{ prob_ai: number; prob_human: number }> { + await CONFIG.sync; + + if (!CONFIG.value.enabled) { + console.debug("text inference disabled"); + return new Promise((_, rej) => + rej({ + reason: "disabled", + }), + ); + } + + const handle = await STATE.handle; + const handle_lite = await STATE.handle_lite; + + const result_promise: Promise<{ prob_ai: number; prob_human: number }> = new Promise(async (res, rej) => { + let result: AiTextInference | null = null; + + switch (CONFIG.value.mode) { + case "mixed": + result = (await handle_lite?.queue(text)) || null; + break; + case "lite": + result = (await handle_lite?.queue(text)) || null; + break; + case "hard": + result = (await handle?.queue(text)) || null; + break; + } + + if (!result) { + console.warn("Configured inference model was not available"); + rej({ + reason: "no_model", + }); + return; + } + + // mixed case + if (CONFIG.value.mode == "mixed" && result.prob_ai + result.prob_human < 0.8) { + console.debug("mixed mode: first inference was not confident"); + const second_result = (await (await STATE.handle)?.queue(text)) || null; + + if ( + second_result && + second_result.prob_ai + second_result.prob_human >= result.prob_ai + result.prob_human + ) { + result = second_result; + } + } + + if (!result) + rej({ + reason: "no_result", + }); + else res(result); + }); + + const stats = handle?.stats(); + const stats_lite = handle_lite?.stats(); + + // normal model inference queue check / run + if (STATE.handle_run_timer) { + clearTimeout(STATE.handle_run_timer); + } + + if (stats && stats.queue_size > stats.queue_limit / 2) { + // immediate inference on threshold + handle?.run_queued_inference().catch((e) => { + console.error("Failed to run inference", e); + }); + } else { + STATE.handle_run_timer = setTimeout(() => { + handle?.run_queued_inference().catch((e) => { + console.error("Failed to run inference", e); + }); + }, 500); + } + + // lite model inference queue chech / run + if (STATE.handle_lite_run_timer) { + clearTimeout(STATE.handle_lite_run_timer); + } + + if (stats_lite && stats_lite.queue_size > stats_lite.queue_limit / 2) { + // immediate inference on threshold + handle_lite?.run_queued_inference().catch((e) => { + console.error("Failed to run inference (lite)", e); + }); + } else { + STATE.handle_lite_run_timer = setTimeout(() => { + handle_lite?.run_queued_inference().catch((e) => { + console.error("Failed to run inference (lite)", e); + }); + }, 500); + } + + return result_promise; +} + +function reload(config: ExtensionConfigAiText) { + if (!config.enabled) return; + + const load = async () => { + if (!STATE.wasm_load) { + await inference_ai_text(); + if (await has_wgpu()) { + console.warn( + "WebGPU seems available, but burn currently does not support GPU on WASM. See the () issue. Use NdArray backend anyway", + ); + Capability.assert_ndarray(); + ///Capability.assert_webgpu(); + } else { + Capability.assert_ndarray(); + } + STATE.wasm_load = true; + } + return new AiText(); + }; + const load_lite = async () => { + if (!STATE.wasm_load_lite) { + await inference_ai_text_lite(); + if (await has_wgpu()) { + console.warn( + "WebGPU seems available, but burn currently does not support GPU on WASM. See the () issue. Use NdArray backend anyway", + ); + CapabilityLite.assert_ndarray(); + ///CapabilityLite.assert_webgpu(); + } else { + CapabilityLite.assert_ndarray(); + } + STATE.wasm_load_lite = true; + } + return new AiTextLite(); + }; + + switch (config.mode) { + case "mixed": + STATE.handle = load(); + STATE.handle_lite = load_lite(); + break; + case "lite": + STATE.handle = null; + STATE.handle_lite = load_lite(); + break; + case "hard": + STATE.handle_lite = null; + STATE.handle = load(); + break; + } +} diff --git a/extension/src/entrypoints/content.ts b/extension/src/entrypoints/content.ts deleted file mode 100644 index 435a1e4..0000000 --- a/extension/src/entrypoints/content.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { EXTENSION_CONFIG, ExtensionConfigAiImage, ExtensionConfigAiText } from "@/utils/config"; -import { traverse_text } from "@/utils/domtraverse"; - -const console = ExtConsole("content"); - -export default defineContentScript({ - matches: ["http://*/*", "https://*/*"], - main() { - DOM_OBSERVATORY.observer = new MutationObserver((_) => { - if (DOM_OBSERVATORY.timer) { - clearTimeout(DOM_OBSERVATORY.timer); - } - DOM_OBSERVATORY.timer = setTimeout(() => { - console.debug("retraverse, cause: mutation"); - text_ai_traverse(); - }, 1000); - }); - - setTimeout(() => { - text_ai_traverse(); - - DOM_OBSERVATORY.observer?.observe(document.body, { - subtree: true, - characterData: true, - }); - }, 1000); - }, -}); - -const DOM_OBSERVATORY = { - observer: null as MutationObserver | null, - timer: null as number | null, -}; - -const TRAVERSE_VIEWPORT_OBSERVER = new IntersectionObserver( - (entries, observer) => { - for (const entry of entries) { - if (entry.isIntersecting) { - const stack = TRAVERSE_PARENT_TO_STACK.get(entry.target); - - if (stack) { - sendMessage("inference_aiText_classify", { text: stack.text }).then((result) => { - observer.unobserve(entry.target); - - if ( - EXTENSION_CONFIG.ai_text.show_ai && - result.prob_ai >= EXTENSION_CONFIG.ai_text.threshold && - result.prob_ai >= result.prob_human - ) { - ai_set_badge(entry.target, result.prob_ai, undefined); - } else if ( - EXTENSION_CONFIG.ai_text.show_human && - result.prob_human >= EXTENSION_CONFIG.ai_text.threshold && - result.prob_human >= result.prob_ai - ) { - ai_set_badge(entry.target, undefined, result.prob_human); - } else { - ai_clear_badge(entry.target); - } - }); - } - } - } - }, - { - rootMargin: "500px", - threshold: 0.1, - }, -); - -interface WeakStack { - text: string; - words: number; - first_node?: Node; - last_node?: Node; - root?: Node; -} -const TRAVERSE_PARENT_TO_STACK: WeakMap = new WeakMap(); -function text_ai_traverse() { - console.debug("text_ai_traverse: traverse DOM"); - for (const stack of traverse_text(600, 20)) { - let target = stack.first_node.parentElement || stack.root.parentElement; - if (target) { - ai_set_badge(target); - TRAVERSE_PARENT_TO_STACK.set(target, stack); - TRAVERSE_VIEWPORT_OBSERVER.observe(target); - } - } -} - -const SET_BADGES: WeakMap = new WeakMap(); -function ai_set_badge(node: Node, ai?: number, human?: number) { - const badge = document.createElement("span"); - - badge.setAttribute(TRAVERSE_BEHAVOUR_ATTR, TRAVERSE_BEHAVOUR_IGNORE_MAGIC); - badge.style.display = "inline"; - badge.style.borderRadius = "4px"; - badge.style.textAlign = "center"; - badge.style.fontFamily = "sans"; - badge.style.fontSize = "12px"; - badge.style.padding = "1px"; - badge.style.marginRight = "4px"; - if (ai) { - badge.style.backgroundColor = "#ffc36d"; - badge.style.color = "#fff"; - badge.textContent = `🤖: ${Math.trunc(ai * 100)}%`; - } else if (human) { - badge.style.backgroundColor = "#8fe1a1"; - badge.style.color = "#fff"; - badge.textContent = `👨‍💼: ${Math.trunc(human * 100)}%`; - } else { - badge.style.backgroundColor = "#ddd"; - badge.style.color = "#fff"; - badge.textContent = "• • • • • •"; - } - - ai_clear_badge(node); - const ins_badge = node.insertBefore(badge, node.firstChild); - SET_BADGES.set(node, ins_badge); -} - -function ai_clear_badge(node: Node) { - SET_BADGES.get(node)?.remove(); -} diff --git a/extension/src/entrypoints/content/index.ts b/extension/src/entrypoints/content/index.ts new file mode 100644 index 0000000..7375e7b --- /dev/null +++ b/extension/src/entrypoints/content/index.ts @@ -0,0 +1,23 @@ +import { SyncingExtensionConfig } from "@/utils/config"; +import text_class, { traverse as text_traverse } from "./text_class"; + +const console = ExtConsole("content"); + +const CONFIG_DOMAIN_WHITELIST = new SyncingExtensionConfig("domain_whitelist"); + +export default defineContentScript({ + matches: ["http://*/*", "https://*/*"], + main() { + const text_set = () => { + text_class(); + text_traverse(); + }; + let text_timer = setTimeout(text_set, 1000); + window.onload = () => { + clearTimeout(text_timer); + text_set(); + }; + + console.log("ready"); + }, +}); diff --git a/extension/src/entrypoints/content/text_class.ts b/extension/src/entrypoints/content/text_class.ts new file mode 100644 index 0000000..3a15d61 --- /dev/null +++ b/extension/src/entrypoints/content/text_class.ts @@ -0,0 +1,125 @@ +import { TRAVERSE_BEHAVOUR_ATTR, TRAVERSE_BEHAVOUR_IGNORE_MAGIC, traverse_text } from "@/utils/domtraverse"; +import { SyncingExtensionConfig } from "@/utils/config"; +import { ExtConsole } from "@/utils/logging"; +import { sendMessage } from "@/utils/messaging"; + +const console = ExtConsole("contentscript::text_class"); + +const CONFIG = new SyncingExtensionConfig("ai_text"); + +const DOM_OBSERVATORY: { observer: MutationObserver; timer: ReturnType | null } = { + observer: new MutationObserver((_) => { + if (DOM_OBSERVATORY.timer) clearTimeout(DOM_OBSERVATORY.timer); + DOM_OBSERVATORY.timer = setTimeout(() => { + console.debug("observatory trigger"); + traverse(); + }, 1000); + }), + timer: null, +}; + +const VIEWPORT_OBSERVATORY: { observer: IntersectionObserver } = { + observer: new IntersectionObserver((entries, observer) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const stack = STATE.stack.get(entry.target); + if (stack) { + sendMessage("inference_aiText_classify", { text: stack.text }) + .then((resp) => { + console.debug("received inf response", resp); + observer.unobserve(entry.target); + apply_class(entry.target, resp.prob_ai, resp.prob_human); + }) + .catch((e) => { + console.warn("inference failed", e, "\nentry will be rescheduled by observer"); + }); + } else { + console.warn("viewport_observatory triggered on node, but the stack is absent"); + observer.unobserve(entry.target); + } + } + } + }), +}; + +const STATE: { + stack: WeakMap; + badge: WeakMap; +} = { + stack: new WeakMap(), + badge: new WeakMap(), +}; + +export default function setup() { + DOM_OBSERVATORY.observer.observe(document.body, { + subtree: true, + characterData: true, + }); +} + +export function traverse() { + CONFIG.sync.then(() => { + if (!CONFIG.value.enabled) { + console.debug("disabled"); + return; + } + + console.debug("traverse start"); + for (const stack of traverse_text(600, 20)) { + const target = stack.first_node.parentElement || stack.root.parentElement; + if (target) { + set_badge(target); + STATE.stack.set(target, { text: stack.text }); + VIEWPORT_OBSERVATORY.observer.observe(target); + } else { + console.warn("Stack didn't have target"); + } + } + }); +} + +function apply_class(target: Node, prob_ai: number, prob_human: number) { + CONFIG.sync.then(() => { + if (CONFIG.value.show_ai && prob_ai >= CONFIG.value.threshold && prob_ai >= prob_human) { + set_badge(target, { type: "ai", prob: prob_ai }); + } else if (CONFIG.value.show_human && prob_human >= CONFIG.value.threshold && prob_human >= prob_ai) { + set_badge(target, { type: "human", prob: prob_human }); + } else { + clear_badge(target); + } + }); +} + +function set_badge(target: Node, data?: { type: "ai" | "human"; prob: number }) { + if (STATE.badge.has(target)) STATE.badge.delete(target); + + let badge = document.createElement("mark"); + badge.setAttribute(TRAVERSE_BEHAVOUR_ATTR, TRAVERSE_BEHAVOUR_IGNORE_MAGIC); + badge.style.display = "inline"; + badge.style.borderRadius = "4px"; + badge.style.textAlign = "center"; + badge.style.fontFamily = "sans"; + badge.style.fontSize = "12px"; + badge.style.padding = "1px"; + badge.style.marginRight = "4px"; + if (data?.type === "ai") { + badge.style.backgroundColor = "#ffc36d"; + badge.style.color = "#fff"; + badge.textContent = `🤖: ${Math.trunc(data.prob * 100)}%`; + } else if (data?.type === "human") { + badge.style.backgroundColor = "#8fe1a1"; + badge.style.color = "#fff"; + badge.textContent = `👨‍💼: ${Math.trunc(data.prob * 100)}%`; + } else { + badge.style.backgroundColor = "#ddd"; + badge.style.color = "#fff"; + badge.textContent = "🦊 •••• "; + } + + badge = target.insertBefore(badge, target.firstChild); + STATE.badge.set(target, badge); +} + +function clear_badge(target: Node) { + STATE.badge.delete(target); +} diff --git a/extension/src/entrypoints/popup/App.svelte b/extension/src/entrypoints/popup/App.svelte index 8eb283f..bd1a8af 100644 --- a/extension/src/entrypoints/popup/App.svelte +++ b/extension/src/entrypoints/popup/App.svelte @@ -1,23 +1,23 @@ + +
diff --git a/extension/src/entrypoints/popup/ConfigText.svelte b/extension/src/entrypoints/popup/ConfigText.svelte new file mode 100644 index 0000000..5ad1ff5 --- /dev/null +++ b/extension/src/entrypoints/popup/ConfigText.svelte @@ -0,0 +1,3 @@ + + +
\ No newline at end of file diff --git a/extension/src/entrypoints/popup/General.svelte b/extension/src/entrypoints/popup/General.svelte new file mode 100644 index 0000000..86b155c --- /dev/null +++ b/extension/src/entrypoints/popup/General.svelte @@ -0,0 +1,5 @@ + + +
diff --git a/extension/src/entrypoints/popup/config.svelte.ts b/extension/src/entrypoints/popup/config.svelte.ts new file mode 100644 index 0000000..fcc9542 --- /dev/null +++ b/extension/src/entrypoints/popup/config.svelte.ts @@ -0,0 +1,40 @@ +import { EXTENSION_CONFIG_STORE } from "@/utils/config"; + +export class Config { + key: Key; + value: (typeof EXTENSION_CONFIG_STORE)[Key]["fallback"]; + insync = false; + + constructor(key: Key) { + this.key = key; + this.value = $state(EXTENSION_CONFIG_STORE[this.key].fallback); + + EXTENSION_CONFIG_STORE[this.key] + .getValue() + .then((value) => { + this.value = value; + this.insync = true; + }) + .catch((e) => { + console.error("Failed to sync store config into popup", e); + }); + + EXTENSION_CONFIG_STORE[this.key].watch((value) => { + if (value != this.value) this.value = value; + }); + + $effect(() => { + const value = $state.snapshot(this.value); + + if (!this.insync) return; + + const timeout = setTimeout(() => { + EXTENSION_CONFIG_STORE[this.key].setValue(value as any).catch((e) => { + console.error("Failed to sync popup config into store", e); + }); + }, 50); + + return () => clearTimeout(timeout); + }); + } +} diff --git a/extension/src/utils/config.ts b/extension/src/utils/config.ts index 4b41183..c589665 100644 --- a/extension/src/utils/config.ts +++ b/extension/src/utils/config.ts @@ -14,7 +14,7 @@ export interface ExtensionConfigAiImage { threshold: number; } -export const extensionConfig = { +export const EXTENSION_CONFIG_STORE = { domain_whitelist: storage.defineItem("local:config:domain_whitelist", { fallback: [], init: () => ["asd.asd", "lkj.asd", "oiua.asd", "oi1u2e.aoisdu", "did0.asdiho"], @@ -59,36 +59,57 @@ export const extensionConfig = { }), }; -export const EXTENSION_CONFIG: { - domain_whitelist: Set; - ai_text: ExtensionConfigAiText; - ai_image: ExtensionConfigAiImage; -} = { - domain_whitelist: new Set(extensionConfig.domain_whitelist.fallback), - ai_text: extensionConfig.ai_text.fallback, - ai_image: extensionConfig.ai_image.fallback, -}; +export class SyncingExtensionConfig { + #key: Key; + #value: (typeof EXTENSION_CONFIG_STORE)[Key]["fallback"]; + #sync: Promise; + #sync_resolve = () => {}; + #sync_reject = () => {}; -extensionConfig.domain_whitelist.getValue().then((config) => { - EXTENSION_CONFIG.domain_whitelist = new Set(config); + constructor(key: Key) { + this.#key = key; + this.#value = EXTENSION_CONFIG_STORE[key].fallback; + this.#sync = new Promise((res, rej) => { + this.#sync_resolve = res; + this.#sync_reject = rej; + }); - extensionConfig.domain_whitelist.watch((config) => { - EXTENSION_CONFIG.domain_whitelist = new Set(config); - }); -}); + EXTENSION_CONFIG_STORE[key].getValue().then((v) => { + this.#value = v; + this.#sync_resolve(); + }); -extensionConfig.ai_text.getValue().then((config) => { - EXTENSION_CONFIG.ai_text = config; + EXTENSION_CONFIG_STORE[key].watch((v) => { + this.#value = v; + }); + } - extensionConfig.ai_text.watch((config) => { - EXTENSION_CONFIG.ai_text = config; - }); -}); + watch( + callback: ( + new_value: (typeof EXTENSION_CONFIG_STORE)[Key]["fallback"], + old_value: (typeof EXTENSION_CONFIG_STORE)[Key]["fallback"], + ) => void, + ) { + EXTENSION_CONFIG_STORE[this.#key].watch(callback); + } -extensionConfig.ai_image.getValue().then((config) => { - EXTENSION_CONFIG.ai_image = config; + get sync() { + return this.#sync; + } - extensionConfig.ai_image.watch((config) => { - EXTENSION_CONFIG.ai_image = config; - }); -}); + get value() { + return this.#value; + } + + set value(value) { + this.#value = value; + this.#sync = new Promise((res, rej) => { + this.#sync_resolve = res; + this.#sync_reject = rej; + }); + + EXTENSION_CONFIG_STORE[this.#key].setValue(value as any).then(() => { + this.#sync_resolve(); + }); + } +} diff --git a/extension/src/utils/domtraverse.ts b/extension/src/utils/domtraverse.ts index 1a49e0b..6de6357 100644 --- a/extension/src/utils/domtraverse.ts +++ b/extension/src/utils/domtraverse.ts @@ -48,7 +48,17 @@ const KNOWN_STACK_ROOTS: WeakMap< } > = new WeakMap(); -export function* traverse_text(soft_max_words: number, hard_min_words: number) { +export type TraverseTextStack = { + root: Node; + first_node: Node; + text: string; + words: number; +}; + +export function* traverse_text( + soft_max_words: number, + hard_min_words: number, +): Generator { const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { // sanity check @@ -112,12 +122,12 @@ export function* traverse_text(soft_max_words: number, hard_min_words: number) { } // drop with nonsense content - // if (!node.textContent?.trim()) { - // if (IS_DEV) { - // node.parentElement?.setAttribute("data-firaid-moz-traverse", "REJECT-NO_TEXT"); - // } - // return NodeFilter.FILTER_REJECT; - // } + if (!node.textContent?.trim()) { + if (IS_DEV) { + node.parentElement?.setAttribute("data-firaid-moz-traverse", "REJECT-NO_TEXT"); + } + return NodeFilter.FILTER_REJECT; + } // finally good ones if (IS_DEV) { @@ -127,7 +137,7 @@ export function* traverse_text(soft_max_words: number, hard_min_words: number) { }, }); - let stack: { root: Node; first_node: Node; last_node: Node; text: string; words: number } | null = null; + let stack: TraverseTextStack | null = null; while (walker.nextNode()) { const node = walker.currentNode; @@ -154,14 +164,12 @@ export function* traverse_text(soft_max_words: number, hard_min_words: number) { stack = { root: rootable_parent, first_node: node, - last_node: node, text: nodeText, words: nodeTextWords, }; } else if (stack.root.contains(rootable_parent)) { stack.text += " " + nodeText; stack.words += nodeTextWords; - stack.last_node = node; } else { if (stack.words >= hard_min_words) { const known = KNOWN_STACK_ROOTS.get(stack.root); @@ -176,7 +184,6 @@ export function* traverse_text(soft_max_words: number, hard_min_words: number) { stack = { root: rootable_parent, first_node: node, - last_node: node, text: nodeText, words: nodeTextWords, }; diff --git a/extension/src/utils/logging.ts b/extension/src/utils/logging.ts index 09a122f..d7156a5 100644 --- a/extension/src/utils/logging.ts +++ b/extension/src/utils/logging.ts @@ -10,16 +10,44 @@ export enum ExtLogLevel { export const ExtConsole = (sender: string) => { return { debug(...args: any[]) { - sendMessage("log", { level: ExtLogLevel.Debug, sender, args }); + sendMessage("log", { level: ExtLogLevel.Debug, sender, args }).catch((e) => { + console.debug( + "[firaid-moz] (sorry to mess with your site logs): ERROR: can not send message through ExtConsole:", + e, + `\n original message was from [${sender}]:`, + ...args, + ); + }); }, log(...args: any[]) { - sendMessage("log", { level: ExtLogLevel.Info, sender, args }); + sendMessage("log", { level: ExtLogLevel.Info, sender, args }).catch((e) => { + console.debug( + "[firaid-moz] (sorry to mess with your site logs): ERROR: can not send message through ExtConsole:", + e, + `\n original message was from [${sender}]:`, + ...args, + ); + }); }, warn(...args: any[]) { - sendMessage("log", { level: ExtLogLevel.Warning, sender, args }); + sendMessage("log", { level: ExtLogLevel.Warning, sender, args }).catch((e) => { + console.debug( + "[firaid-moz] (sorry to mess with your site logs): ERROR: can not send message through ExtConsole:", + e, + `\n original message was from [${sender}]:`, + ...args, + ); + }); }, error(...args: any[]) { - sendMessage("log", { level: ExtLogLevel.Error, sender, args }); + sendMessage("log", { level: ExtLogLevel.Error, sender, args }).catch((e) => { + console.debug( + "[firaid-moz] (sorry to mess with your site logs): ERROR: can not send message through ExtConsole:", + e, + `\n original message was from [${sender}]:`, + ...args, + ); + }); }, }; }; diff --git a/extension/src/utils/system.ts b/extension/src/utils/system.ts new file mode 100644 index 0000000..b9c1dca --- /dev/null +++ b/extension/src/utils/system.ts @@ -0,0 +1,16 @@ +export async function has_wgpu() { + console.debug("Best effort to check whether WebGPU is available"); + if ("gpu" in navigator && navigator.gpu) { + console.debug("guess_webgpu: navigator has GPU"); + try { + if (await (navigator.gpu as any).requestAdapter()) { + console.debug("guess_webgpu: adapter seems fine"); + return true; + } + } catch (e) { + console.warn("guess_webgpu: adapter fail", e); + } + } + + return false; +} diff --git a/extension/tsconfig.json b/extension/tsconfig.json index 1c23ef1..8a2f81b 100644 --- a/extension/tsconfig.json +++ b/extension/tsconfig.json @@ -2,6 +2,8 @@ "extends": "./.wxt/tsconfig.json", "compilerOptions": { "useDefineForClassFields": true, + "strict": true, + "strictNullChecks": true, }, "files": [], } diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 61bbedc..5897bd1 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "nightly-2026-03-01" +channel = "nightly-2026-03-10" targets = ["wasm32-unknown-unknown"] diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml index 8a12cf2..c73a6c1 100644 --- a/wasm/Cargo.toml +++ b/wasm/Cargo.toml @@ -12,11 +12,11 @@ edition = "2024" model-ai-text.path = "./model-ai-text" model-ai-text.version = "=0.0.1-rc.1" ## Wasm -wasm-bindgen.version = "=0.2.114" +wasm-bindgen.version = "=0.2.117" wasm-bindgen-futures.version = "^0.4" js-sys.version = "^0.3" web-sys.version = "^0.3" -gloo.version = "^0.11" +gloo.version = "^0.12" wasm-tracing.version = "^2" console_error_panic_hook.version = "^0.1" ## Logging @@ -33,15 +33,15 @@ tokenizers.version = "^0.22" tokenizers.default-features = false tokenizers.features = ["unstable_wasm"] burn.git = "https://github.com/tracel-ai/burn.git" -burn.rev = "3a0e604cb14e445cd5a3686fbe06d0e11c76f05a" +burn.rev = "053f030d234d0edf0c6c88935a9e7a880b1a5e0e" burn.default-features = false burn.features = ["std"] burn-store.git = "https://github.com/tracel-ai/burn.git" -burn-store.rev = "3a0e604cb14e445cd5a3686fbe06d0e11c76f05a" +burn-store.rev = "053f030d234d0edf0c6c88935a9e7a880b1a5e0e" burn-store.default-features = false burn-store.features = ["std"] burn-onnx.git = "https://github.com/tracel-ai/burn-onnx.git" -burn-onnx.rev = "17a0864e02c19669b143e4227038363bb5194589" +burn-onnx.rev = "ceed7e4ea3250dcdb9c90c38e446f30696ad2372" burn-onnx.default-features = false ## HF hf-hub.version = "^0.5" diff --git a/wasm/inference-models/Cargo.toml b/wasm/inference-models/Cargo.toml index 6d1b016..1936144 100644 --- a/wasm/inference-models/Cargo.toml +++ b/wasm/inference-models/Cargo.toml @@ -15,6 +15,7 @@ model-ai-text.optional = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true js-sys.workspace = true +js-sys.features = ["futures"] gloo.workspace = true wasm-tracing.workspace = true console_error_panic_hook.workspace = true @@ -22,7 +23,7 @@ console_error_panic_hook.workspace = true tracing.workspace = true ## Inf burn.workspace = true -burn.features = ["ndarray", "wgpu", "webgpu"] +burn.features = ["ndarray", "simd", "wgpu", "webgpu"] burn-store.workspace = true burn-store.features = ["burnpack"] tokenizers.workspace = true diff --git a/wasm/model-ai-text/Cargo.toml b/wasm/model-ai-text/Cargo.toml index eb227f0..f6c0f52 100644 --- a/wasm/model-ai-text/Cargo.toml +++ b/wasm/model-ai-text/Cargo.toml @@ -12,6 +12,7 @@ burn.workspace = true burn-store.workspace = true burn-store.features = ["burnpack"] tokenizers.workspace = true +half.version = "^2" ## Seri serde.workspace = true serde_json.workspace = true diff --git a/wasm/model-ai-text/build.rs b/wasm/model-ai-text/build.rs index 9e21adf..3221f63 100644 --- a/wasm/model-ai-text/build.rs +++ b/wasm/model-ai-text/build.rs @@ -1,6 +1,9 @@ use burn_onnx::ModelGen; use serde::{Deserialize, Deserializer}; -use std::{env, path::Path}; +use std::{ + env, + path::{Path, PathBuf}, +}; use tokenizers::Tokenizer; #[derive(Deserialize)] @@ -55,7 +58,7 @@ impl TokenizerConfig { { // This is configuration sanity check. if v > (LENGTH_CAP * 3) as u64 { - println!( + eprintln!( "WARN: the parsed `max_length` {v} is well above {LENGTH_CAP}: falling back to {}", TokenizerConfig::max_length_default() ); @@ -71,7 +74,7 @@ impl TokenizerConfig { { // This is configuration sanity check. if v > (LENGTH_CAP * 3) as i64 { - println!( + eprintln!( "WARN: the parsed `max_length` {v} is well above {LENGTH_CAP}: falling back to {}", TokenizerConfig::max_length_default() ); @@ -87,7 +90,7 @@ impl TokenizerConfig { { // This is configuration sanity check. if v > (LENGTH_CAP * 3) as f64 { - println!( + eprintln!( "WARN: the parsed `max_length` {v} is well above {LENGTH_CAP}: falling back to {}", TokenizerConfig::max_length_default() ); @@ -105,11 +108,28 @@ impl TokenizerConfig { fn main() { println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-env-changed=_MK_MODEL_AI_TEXT"); + println!("cargo:rerun-if-env-changed=_MK_MODEL_AI_TEXT_ONNX"); println!("cargo:rerun-if-env-changed=_MK_MODEL_AI_TEXT_ATTENTION"); println!("cargo:rerun-if-env-changed=_MK_MODEL_AI_TEXT_TYPES"); let model_repo = std::env::var("_MK_MODEL_AI_TEXT") - .unwrap_or_else(|_| "onnx-community/e5-small-lora-ai-generated-detector-ONNX".into()); + .inspect_err(|_| { + eprintln!("_MK_MODEL_AI_TEXT env variable required to point to HF model repository") + }) + .expect("_MK_MODEL_AI_TEXT is set to huggingface model repo"); + let model_file = + std::env::var("_MK_MODEL_AI_TEXT_ONNX").inspect_err(|_| { + eprintln!("_MK_MODEL_AI_TEXT_ONNX env variable required to point to onnx file under HF model repository"); + }).expect("_MK_MODEL_AI_TEXT_FILE is set to .onnx path at huggingface model repo"); + + let model_name = model_file + .rsplit_once('/') + .map(|(_, m)| m) + .unwrap_or(model_file.as_str()) + .rsplit_once('.') + .map(|(name, _)| name) + .unwrap_or(model_file.as_str()); + let has_attention = std::env::var("_MK_MODEL_AI_TEXT_ATTENTION") .is_ok_and(|v| v == "true" || v == "yes" || v == "1"); let has_types = std::env::var("_MK_MODEL_AI_TEXT_TYPES") @@ -126,9 +146,7 @@ fn main() { // dwnl model let repo = api.model(model_repo); - let model = repo - .get("onnx/model.onnx") - .expect("ai_text: model.onnx fetch"); + let model = repo.get(&model_file).expect("ai_text: model.onnx fetch"); let config_json = repo.get("config.json").expect("ai_text: config.json fetch"); let tokenizer_json = repo .get("tokenizer.json") @@ -148,6 +166,21 @@ fn main() { .out_dir(burned_dir.to_str().unwrap()) .run_from_script(); + // They put model into .rs, which depends on env variables set, to we move + // model into well-known location. + let loc = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR")) + .join(&burned_dir) + .join(model_name) + .with_extension("rs"); + let to = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR")) + .join(&burned_dir) + .join("model.rs"); + + std::fs::copy(&loc, &to) + .and_then(|_| std::fs::remove_file(&loc)) + .inspect_err(|e| eprintln!("While moving {loc:?} to {to:?}: {e:?}")) + .expect("To move .rs to model.rs"); + // construct tokenizer AoT let tokenizer = { let mut tokenizer = Tokenizer::from_file(tokenizer_json)