Still WIP

This commit is contained in:
spushk 2026-05-13 14:08:53 +05:00 • committed by spushk
commit 6d28a2f53a
Signed by: spushk
GPG key ID: 7797B761E105E8AA
25 changed files with 690 additions and 383 deletions

2
.gitignore vendored
View file

@ -6,4 +6,4 @@ extension/.output
extension/.wxt
extension/node_modules
extension/pnpm-lock.yaml
.export_venv
.envrc

View file

@ -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"

117
README.md
View file

@ -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 `<repo>/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)

View file

@ -1,2 +0,0 @@
optimum-onnx[onnxruntime]
accelerate

View file

@ -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;
}

View file

@ -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);
});
}

View file

@ -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<AiText> | null;
handle_lite: Promise<AiTextLite> | null;
handle_run_timer: ReturnType<typeof setTimeout> | null;
handle_lite_run_timer: ReturnType<typeof setTimeout> | 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;
}
}

View file

@ -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<Element, WeakStack> = 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<Node, Element> = 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();
}

View file

@ -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");
},
});

View file

@ -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<typeof setTimeout> | 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<Element, { text: string }>;
badge: WeakMap<Node, Element>;
} = {
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);
}

View file

@ -1,23 +1,23 @@
<script lang="ts">
import "./app.css";
import { extensionConfig } from "@/utils/config";
import { EXTENSION_CONFIG_STORE } from "@/utils/config";
class Config<Key extends keyof typeof extensionConfig> {
class Config<Key extends keyof typeof EXTENSION_CONFIG_STORE> {
key: Key;
value: any;
insync = false;
constructor(key: Key) {
this.key = key;
this.value = $state((extensionConfig[key] as any).fallback);
this.value = $state((EXTENSION_CONFIG_STORE[key] as any).fallback);
this.load();
$effect(() => {
const value = $state.snapshot(this.value);
const timeout = setTimeout(() => {
if (!this.insync || this.value == value) return;
extensionConfig[this.key].setValue(value).catch((e) => {
EXTENSION_CONFIG_STORE[this.key].setValue(value).catch((e) => {
console.error("Failed to write state back to storage", e);
});
}, 20);
@ -27,14 +27,14 @@
}
async load() {
const config = await extensionConfig[this.key].getValue().catch((e) => {
const config = await EXTENSION_CONFIG_STORE[this.key].getValue().catch((e) => {
console.error("Failed to sync state from storage", e);
});
this.value = config;
this.insync = true;
(extensionConfig[this.key] as any).watch((value: any) => {
(EXTENSION_CONFIG_STORE[this.key] as any).watch((value: any) => {
if (value != this.value) this.value = value;
});
}
@ -216,7 +216,7 @@
<fieldset class="fieldset border-base-300 border rounded-box p-2">
<legend class="fieldset-legend"> Do not check on these sites: </legend>
<div class="flex gap-2 flex-wrap overflow-y-auto max-h-30 w-full">
<div class="flex gap-2 flex-wrap overflow-y-auto h-20 max-h-20 w-full">
{#each domain_whitelist.value as domain}
<div class="badge">
{domain}
@ -228,12 +228,12 @@
<input type="radio" name="cfgtabs0" class="tab" aria-label="🛈" />
<div class="tab-content bg-base-100 border-base-300 p-2">
<p>
Firaid-moz webextension v{browser.runtime.getManifest().version}
Firaid-moz v{browser.runtime.getManifest().version}
by Spushk 🦊
</p>
<br />
<p>
The source code is available <a class="link" href={browser.runtime.getManifest().homepage_url}
Source code is available <a class="link" href={browser.runtime.getManifest().homepage_url}
>here</a
> under terms of Apache-2.0 license
</p>

View file

@ -0,0 +1,5 @@
<script>
import { Config } from "./config.svelte";
</script>
<section></section>

View file

@ -0,0 +1,3 @@
<script></script>
<section></section>

View file

@ -0,0 +1,5 @@
<script>
import { Config } from "./config.svelte";
</script>
<section></section>

View file

@ -0,0 +1,40 @@
import { EXTENSION_CONFIG_STORE } from "@/utils/config";
export class Config<Key extends keyof typeof EXTENSION_CONFIG_STORE> {
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);
});
}
}

View file

@ -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<string>;
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 extends keyof typeof EXTENSION_CONFIG_STORE> {
#key: Key;
#value: (typeof EXTENSION_CONFIG_STORE)[Key]["fallback"];
#sync: Promise<void>;
#sync_resolve = () => {};
#sync_reject = () => {};
extensionConfig.domain_whitelist.getValue().then((config) => {
EXTENSION_CONFIG.domain_whitelist = new Set(config);
extensionConfig.domain_whitelist.watch((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.ai_text.getValue().then((config) => {
EXTENSION_CONFIG.ai_text = config;
extensionConfig.ai_text.watch((config) => {
EXTENSION_CONFIG.ai_text = config;
EXTENSION_CONFIG_STORE[key].getValue().then((v) => {
this.#value = v;
this.#sync_resolve();
});
});
extensionConfig.ai_image.getValue().then((config) => {
EXTENSION_CONFIG.ai_image = config;
extensionConfig.ai_image.watch((config) => {
EXTENSION_CONFIG.ai_image = config;
EXTENSION_CONFIG_STORE[key].watch((v) => {
this.#value = v;
});
});
}
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);
}
get sync() {
return this.#sync;
}
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();
});
}
}

View file

@ -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<TraverseTextStack, void, void> {
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,
};

View file

@ -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,
);
});
},
};
};

View file

@ -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;
}

View file

@ -2,6 +2,8 @@
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"useDefineForClassFields": true,
"strict": true,
"strictNullChecks": true,
},
"files": [],
}

Some files were not shown because too many files have changed in this diff Show more