init
This commit is contained in:
commit
f2b8bdca98
18 changed files with 2068 additions and 0 deletions
15
extension/modules/inference-models.ts
Normal file
15
extension/modules/inference-models.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { resolve } from "node:path";
|
||||
import { defineWxtModule } from "wxt/modules";
|
||||
|
||||
export default defineWxtModule((wxt) => {
|
||||
wxt.hook("build:publicAssets", (_, assets) => {
|
||||
assets.push({
|
||||
absoluteSrc: resolve("node_modules/inference-ai-text/inference-ai-text_bg.wasm"),
|
||||
relativeDest: "inference-ai-text_bg.wasm",
|
||||
});
|
||||
assets.push({
|
||||
absoluteSrc: resolve("node_modules/inference-ai-text-lite/inference-ai-text-lite_bg.wasm"),
|
||||
relativeDest: "inference-ai-text-lite_bg.wasm",
|
||||
});
|
||||
});
|
||||
});
|
||||
37
extension/package.json
Normal file
37
extension/package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "firaid-moz",
|
||||
"version": "0.0.1",
|
||||
"description": "This extension will search and highlight AI-generated text and images with small badges while you surfing. Our keen and friendly nose shall give you a heads-up, so you can dodge the slop 🦊. By the way, none data will ever leave your browser - this extension works absolutely locally. We also have cool features like 'Microslop-mode' and 'PII protection'",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wxt",
|
||||
"dev:firefox": "wxt -b firefox",
|
||||
"build:chrome": "wxt build --mv3",
|
||||
"build:firefox": "wxt build -b firefox --mv3",
|
||||
"zip:chrome": "wxt zip --mv3",
|
||||
"zip:firefox": "wxt zip -b firefox --mv3",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Svyatoslav Pushkarev <spushkarevwork@yandex.ru>",
|
||||
"license": "Apache-2.0",
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"dependencies": {
|
||||
"@webext-core/messaging": "^2.3.0",
|
||||
"inference-ai-text": "file:../wasm/inference-models/pkg/inference-ai-text",
|
||||
"inference-ai-text-lite": "file:../wasm/inference-models/pkg/inference-ai-text-lite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tsconfig/svelte": "^5.0.7",
|
||||
"@wxt-dev/module-svelte": "^2.0.4",
|
||||
"daisyui": "^5.5.19",
|
||||
"svelte": "^5.50.0",
|
||||
"svelte-check": "^4.3.6",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"wxt": "^0.20.14"
|
||||
}
|
||||
}
|
||||
7
extension/src/app.config.ts
Normal file
7
extension/src/app.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { defineAppConfig } from "#imports";
|
||||
|
||||
declare module "wxt/utils/define-app-config" {
|
||||
export interface WxtAppConfig {}
|
||||
}
|
||||
|
||||
export default defineAppConfig({});
|
||||
BIN
extension/src/assets/favicon.png
Normal file
BIN
extension/src/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
156
extension/src/entrypoints/background.ts
Normal file
156
extension/src/entrypoints/background.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
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;
|
||||
}
|
||||
114
extension/src/entrypoints/content.ts
Normal file
114
extension/src/entrypoints/content.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { EXTENSION_CONFIG, ExtensionConfigAiImage, ExtensionConfigAiText } from "@/utils/config";
|
||||
import { traverse_text } from "@/utils/domtraverse";
|
||||
|
||||
export default defineContentScript({
|
||||
matches: ["http://*/*", "https://*/*"],
|
||||
main() {
|
||||
const trigger = setTimeout(() => {
|
||||
text_ai_traverse();
|
||||
}, 1000);
|
||||
|
||||
document.addEventListener("load", () => {
|
||||
clearTimeout(trigger);
|
||||
|
||||
text_ai_traverse();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
})
|
||||
.catch((e) => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
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() {
|
||||
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.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 || human) {
|
||||
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 = "🦊: confused";
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
223
extension/src/entrypoints/popup/App.svelte
Normal file
223
extension/src/entrypoints/popup/App.svelte
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
<script lang="ts">
|
||||
import "./app.css";
|
||||
|
||||
import { extensionConfig } from "@/utils/config";
|
||||
|
||||
class Config<Key extends keyof typeof extensionConfig> {
|
||||
key: Key;
|
||||
value: (typeof extensionConfig)[Key]["fallback"];
|
||||
|
||||
constructor(key: Key) {
|
||||
this.key = key;
|
||||
this.value = $state(extensionConfig[key].fallback);
|
||||
this.load();
|
||||
|
||||
$effect(() => {
|
||||
console.log("RESYNC");
|
||||
extensionConfig[key].setValue(this.value as any);
|
||||
});
|
||||
}
|
||||
|
||||
async load() {
|
||||
const config = await extensionConfig[this.key].getValue();
|
||||
|
||||
this.value = config;
|
||||
|
||||
extensionConfig[this.key].watch((value) => {
|
||||
if (value != this.value) this.value = value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const config_ai_text = new Config("ai_text");
|
||||
$inspect(config_ai_text.value);
|
||||
|
||||
const config_ai_image = new Config("ai_image");
|
||||
$inspect(config_ai_image.value);
|
||||
|
||||
const config_ai = new Config("ai");
|
||||
$inspect(config_ai.value);
|
||||
</script>
|
||||
|
||||
<main class="min-w-115">
|
||||
<div class="tabs tabs-box min-h-50 max-h-max">
|
||||
<div class="tab pr-4">🦊</div>
|
||||
<input type="radio" name="cfgtabs0" class="tab" aria-label="General" checked={true} />
|
||||
<div class="tab-content bg-base-100 border-base-300 p-2"></div>
|
||||
<input type="radio" name="cfgtabs0" class="tab" aria-label="AI detector" />
|
||||
<div class="tab-content bg-base-100 border-base-300 p-2">
|
||||
<fieldset class="fieldset border-base-300 border rounded-box p-2">
|
||||
<legend class="fieldset-legend">AI Text detection</legend>
|
||||
<label class="label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
bind:checked={config_ai_text.value.enabled}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
<label class="label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
bind:checked={config_ai_text.value.lite}
|
||||
/>
|
||||
Lite version (faster, less accurate)
|
||||
</label>
|
||||
<label class="label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
bind:checked={config_ai_text.value.show_ai}
|
||||
/>
|
||||
Show "AI generated" warnings
|
||||
<span
|
||||
style="border-radius:4px;text-align:center;font-family:sans;font-size:10px;background-color:#ffc36d;color:#fff"
|
||||
>
|
||||
🤖: 99%
|
||||
</span>
|
||||
</label>
|
||||
<label class="label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
bind:checked={config_ai_text.value.show_human}
|
||||
/>
|
||||
Show "Human produced" labels
|
||||
<span
|
||||
style="border-radius:4px;text-align:center;font-family:sans;font-size:10px;background-color:#8fe1a1;color:#fff"
|
||||
>
|
||||
👨💼: 99%
|
||||
</span>
|
||||
</label>
|
||||
<label class="label font-bold" for="textaimodelconf"> Report: </label>
|
||||
<label class="label">
|
||||
Any suspicion
|
||||
<input
|
||||
id="textaimodelconf01"
|
||||
type="range"
|
||||
class="range range-xs"
|
||||
min="0.1"
|
||||
max="0.8"
|
||||
step="0.1"
|
||||
bind:value={config_ai_text.value.threshold}
|
||||
/>
|
||||
When confident
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset border-base-300 border rounded-box p-2">
|
||||
<legend class="fieldset-legend">AI Image detection</legend>
|
||||
<label class="label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
bind:checked={config_ai_image.value.enabled}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
<label class="label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
bind:checked={config_ai_image.value.lite}
|
||||
/>
|
||||
Lite version (faster, less accurate)
|
||||
</label>
|
||||
<label class="label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
bind:checked={config_ai_image.value.show_ai}
|
||||
/>
|
||||
Show "AI generated" warnings
|
||||
<span
|
||||
style="border-radius:4px;text-align:center;font-family:sans;font-size:10px;background-color:#ffc36d;color:#fff"
|
||||
>
|
||||
🤖: 99%
|
||||
</span>
|
||||
</label>
|
||||
<label class="label">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
bind:checked={config_ai_image.value.show_human}
|
||||
/>
|
||||
Show "Human produced" labels
|
||||
<span
|
||||
style="border-radius:4px;text-align:center;font-family:sans;font-size:10px;background-color:#8fe1a1;color:#fff"
|
||||
>
|
||||
👨💼: 99%
|
||||
</span>
|
||||
</label>
|
||||
<label class="label font-bold" for="textaimodelconf"> Report: </label>
|
||||
<label class="label">
|
||||
Any suspicion
|
||||
<input
|
||||
id="textaimodelconf01"
|
||||
type="range"
|
||||
class="range range-xs"
|
||||
min="0.1"
|
||||
max="0.8"
|
||||
step="0.1"
|
||||
bind:value={config_ai_image.value.threshold}
|
||||
/>
|
||||
When confident
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset class="fieldset border-base-300 border rounded-box p-2">
|
||||
<legend class="fieldset-legend"> Do not check on these sites: </legend>
|
||||
<div class="overflow-y-auto max-h-30 w-full">
|
||||
{#each Object.entries(config_ai.value.domain_blacklist) as domain (domain[0])}
|
||||
<div class="badge">
|
||||
{domain[0]}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<input type="radio" name="cfgtabs0" class="tab" aria-label="License, citations" />
|
||||
<div class="tab-content bg-base-100 border-base-300 p-2">
|
||||
<p>
|
||||
The source code of this extension is available <a
|
||||
class="link"
|
||||
href={browser.runtime.getManifest().homepage_url}>here</a
|
||||
> under terms of Apache-2.0 license
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
The following people/organizations made this fun extention possible to create by training and
|
||||
providing ML models on <a class="link" href="https://huggingface.co">huggingface</a>:
|
||||
</p>
|
||||
<br />
|
||||
<div class="csl-bib-body">
|
||||
<div data-csl-entry-id="dugan-etal-2024-raid" class="csl-entry">
|
||||
Dugan, L., Hwang, A., Trhlı́k, F., Zhu, A., Ludan, J. M., Xu, H., Ippolito, D., &
|
||||
Callison-Burch, C. (2024). RAID: A Shared Benchmark for Robust Evaluation of
|
||||
Machine-Generated Text Detectors. <i
|
||||
>Proceedings of the 62nd Annual Meeting of the Association for Computational
|
||||
Linguistics (Volume 1: Long Papers)</i
|
||||
>, 12463–12492.
|
||||
<a class="link" href="https://aclanthology.org/2024.acl-long.674"
|
||||
>https://aclanthology.org/2024.acl-long.674</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<div class="csl-bib-body">
|
||||
<div data-csl-entry-id="drayson2025machine" class="csl-entry">
|
||||
Drayson, G., Yilmaz, E., & Lampos, V. (2025). Machine-generated text detection
|
||||
prevents language model collapse. <i>ArXiv Preprint ArXiv:2502.15654</i>.
|
||||
</div>
|
||||
</div>
|
||||
<br />
|
||||
<p>
|
||||
... and many many of awesome people who develop and maintain more then 1700 cool things this
|
||||
simple extension requires
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
Let's not forget <b>how many shoulders</b> we're standing on 😉
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
2
extension/src/entrypoints/popup/app.css
Normal file
2
extension/src/entrypoints/popup/app.css
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
7
extension/src/entrypoints/popup/app.ts
Normal file
7
extension/src/entrypoints/popup/app.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { mount } from "svelte";
|
||||
import App from "./App.svelte";
|
||||
import "./app.css";
|
||||
|
||||
export default mount(App, {
|
||||
target: document.getElementById("app")!,
|
||||
});
|
||||
12
extension/src/entrypoints/popup/index.html
Normal file
12
extension/src/entrypoints/popup/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="manifest.type" content="action" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./app.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
158
extension/src/utils/config.ts
Normal file
158
extension/src/utils/config.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { ExtensionConfigSchema } from "#imports";
|
||||
import { defineExtensionStorage, ExtensionStorage } from "@webext-core/storage";
|
||||
|
||||
export interface ExtensionConfigAiText {
|
||||
enabled: boolean;
|
||||
lite: boolean;
|
||||
show_human: boolean;
|
||||
show_ai: boolean;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export interface ExtensionConfigAiImage {
|
||||
enabled: boolean;
|
||||
lite: boolean;
|
||||
show_human: boolean;
|
||||
show_ai: boolean;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export interface ExtensionConfigAi {
|
||||
/// shall contain domain qualified name, separated by dots
|
||||
/// indexed by domain name
|
||||
/// e.g. {
|
||||
/// "github.com": ["github", "com"]
|
||||
/// }
|
||||
domain_blacklist: {
|
||||
[domain: string]: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// export interface ExtensionConfigSchema {
|
||||
// version: string;
|
||||
// ai_text: ExtensionConfigAiText;
|
||||
// ai_image: ExtensionConfigAiImage;
|
||||
// }
|
||||
|
||||
//export const extensionConfig = defineExtensionStorage<ExtensionConfigSchema>(browser.storage.local);
|
||||
|
||||
export const extensionConfig = {
|
||||
ai: storage.defineItem<ExtensionConfigAi>("local:config:ai", {
|
||||
fallback: {
|
||||
domain_blacklist: {},
|
||||
},
|
||||
init: () => ({
|
||||
domain_blacklist: {},
|
||||
}),
|
||||
version: 1,
|
||||
migrations: {},
|
||||
}),
|
||||
ai_text: storage.defineItem<ExtensionConfigAiText>("local:config:ai_text", {
|
||||
fallback: {
|
||||
enabled: true,
|
||||
lite: true,
|
||||
show_ai: true,
|
||||
show_human: false,
|
||||
threshold: 0.6,
|
||||
},
|
||||
init: () => ({
|
||||
enabled: true,
|
||||
lite: true,
|
||||
show_ai: true,
|
||||
show_human: false,
|
||||
threshold: 0.6,
|
||||
}),
|
||||
version: 1,
|
||||
migrations: {},
|
||||
}),
|
||||
ai_image: storage.defineItem<ExtensionConfigAiImage>("local:config:ai_image", {
|
||||
fallback: {
|
||||
enabled: true,
|
||||
lite: true,
|
||||
show_ai: true,
|
||||
show_human: false,
|
||||
threshold: 0.6,
|
||||
},
|
||||
init: () => ({
|
||||
enabled: true,
|
||||
lite: true,
|
||||
show_ai: true,
|
||||
show_human: true,
|
||||
threshold: 0.6,
|
||||
}),
|
||||
version: 1,
|
||||
migrations: {},
|
||||
}),
|
||||
} as const;
|
||||
|
||||
export const EXTENSION_CONFIG: {
|
||||
ai: ExtensionConfigAi;
|
||||
ai_text: ExtensionConfigAiText;
|
||||
ai_image: ExtensionConfigAiImage;
|
||||
} = {
|
||||
ai: extensionConfig.ai.fallback,
|
||||
ai_text: extensionConfig.ai_text.fallback,
|
||||
ai_image: extensionConfig.ai_image.fallback,
|
||||
};
|
||||
|
||||
extensionConfig.ai.getValue().then((config) => {
|
||||
EXTENSION_CONFIG.ai = config;
|
||||
|
||||
extensionConfig.ai.watch((config) => {
|
||||
EXTENSION_CONFIG.ai = config;
|
||||
});
|
||||
});
|
||||
|
||||
extensionConfig.ai_text.getValue().then((config) => {
|
||||
EXTENSION_CONFIG.ai_text = config;
|
||||
|
||||
extensionConfig.ai_text.watch((config) => {
|
||||
EXTENSION_CONFIG.ai_text = config;
|
||||
});
|
||||
});
|
||||
|
||||
extensionConfig.ai_image.getValue().then((config) => {
|
||||
EXTENSION_CONFIG.ai_image = config;
|
||||
|
||||
extensionConfig.ai_image.watch((config) => {
|
||||
EXTENSION_CONFIG.ai_image = config;
|
||||
});
|
||||
});
|
||||
|
||||
// extensionConfig.getItem("version").then(async (v) => {
|
||||
// const current_version = browser.runtime.getManifest().version;
|
||||
// if (v != current_version) {
|
||||
// switch (v) {
|
||||
// default:
|
||||
// await config_migrate_fresh();
|
||||
// }
|
||||
// await extensionConfig.setItem("version", current_version);
|
||||
// }
|
||||
// });
|
||||
|
||||
// async function config_migrate_fresh() {
|
||||
// await extensionConfig.clear();
|
||||
// await extensionConfig.setItem("ai_text", {
|
||||
// enabled: true,
|
||||
// lite: true,
|
||||
// show_ai: true,
|
||||
// show_human: false,
|
||||
// threshold: 0.6,
|
||||
// });
|
||||
// await extensionConfig.setItem("ai_image", {
|
||||
// enabled: true,
|
||||
// lite: true,
|
||||
// show_ai: true,
|
||||
// show_human: false,
|
||||
// threshold: 0.6,
|
||||
// });
|
||||
// await extensionConfig.setItem("fun_githief", {
|
||||
// enabled: false,
|
||||
// });
|
||||
// await extensionConfig.setItem("fun_microslop", {
|
||||
// enabled: false,
|
||||
// });
|
||||
// await extensionConfig.setItem("fun_huyandex", {
|
||||
// enabled: false,
|
||||
// });
|
||||
// }
|
||||
211
extension/src/utils/domtraverse.ts
Normal file
211
extension/src/utils/domtraverse.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
/// Extracts meaningful portions of text from DOM, yielding the top matched nodes
|
||||
///
|
||||
/// e.g.
|
||||
///
|
||||
/// ```html
|
||||
/// <div>
|
||||
/// my hair are NOT
|
||||
/// <span>
|
||||
/// blue!
|
||||
/// </span>
|
||||
/// </div>
|
||||
/// ```
|
||||
///
|
||||
/// Shall yield a reference to the `div` with text "my hair are NOT blue!"
|
||||
|
||||
const IS_DEV = import.meta.env.MODE == "development";
|
||||
|
||||
const TRAVERSE_STACK_BANS: Set<string | null | undefined> = new Set([
|
||||
"IMG",
|
||||
"FIGURE",
|
||||
"SVG",
|
||||
"VIDEO",
|
||||
"META",
|
||||
]);
|
||||
const TRAVERSE_STACK_ROOTS: Set<string | null | undefined> = new Set([
|
||||
"DIV",
|
||||
"ARTICLE",
|
||||
"SECTION",
|
||||
"MAIN",
|
||||
"HEADER",
|
||||
"BLOCKQUOTE",
|
||||
]);
|
||||
const TRAVERSE_ALLOWED_ELEMENT_TAGS: Set<string | null | undefined> = new Set([
|
||||
...TRAVERSE_STACK_ROOTS,
|
||||
"SPAN",
|
||||
"P",
|
||||
"H1",
|
||||
"H2",
|
||||
"H3",
|
||||
"H4",
|
||||
"H5",
|
||||
"H6",
|
||||
"H7",
|
||||
"H8",
|
||||
"UL",
|
||||
"OL",
|
||||
"LI",
|
||||
"DEL",
|
||||
"B",
|
||||
"I",
|
||||
"STRONG",
|
||||
"EM",
|
||||
]);
|
||||
export function* traverse_text(soft_max_words: number, hard_min_words: number) {
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
// sanity check
|
||||
if (!node.isConnected) {
|
||||
if (IS_DEV) {
|
||||
node.parentElement?.setAttribute("data-firaid-moz-traverse", "REJECT-!IS_CONNECTED");
|
||||
}
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
if (node.parentNode?.nodeType != Node.ELEMENT_NODE) {
|
||||
if (IS_DEV) {
|
||||
node.parentElement?.setAttribute("data-firaid-moz-traverse", "REJECT-!ELEMENT_NODE");
|
||||
}
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
|
||||
// filter elements
|
||||
if (!TRAVERSE_ALLOWED_ELEMENT_TAGS.has(node.parentElement?.tagName)) {
|
||||
if (IS_DEV) {
|
||||
node.parentElement?.setAttribute("data-firaid-moz-traverse", "REJECT-!ALLOWED_TAG");
|
||||
}
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
|
||||
// TODO: figure out more performant way to check this.
|
||||
// Ignore anything inside of banned elements.
|
||||
//
|
||||
// For now I'll just limit the depth to 16
|
||||
let checking = node.parentElement;
|
||||
for (let i = 0; i < 16; i++) {
|
||||
if (!checking || checking.tagName == "BODY") {
|
||||
break;
|
||||
}
|
||||
if (TRAVERSE_STACK_BANS.has(checking.tagName)) {
|
||||
if (IS_DEV) {
|
||||
node.parentElement?.setAttribute(
|
||||
"data-firaid-moz-traverse",
|
||||
"REJECT-BANNED_ANCESTOR",
|
||||
);
|
||||
}
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
checking = checking.parentElement;
|
||||
}
|
||||
|
||||
// drop with nonsense content
|
||||
if ((node.textContent?.trim()?.length || 0) < 8) {
|
||||
if (IS_DEV) {
|
||||
node.parentElement?.setAttribute("data-firaid-moz-traverse", "REJECT-SHORT_TEXT");
|
||||
}
|
||||
return NodeFilter.FILTER_REJECT;
|
||||
}
|
||||
|
||||
// finally good ones
|
||||
if (IS_DEV) {
|
||||
node.parentElement?.setAttribute("data-firaid-moz-traverse", "ACCEPT");
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
},
|
||||
});
|
||||
|
||||
let stack: { root: Node; first_node: Node; last_node: Node; text: string; words: number } | null = null;
|
||||
|
||||
while (walker.nextNode()) {
|
||||
const node = walker.currentNode;
|
||||
|
||||
let rootable_parent = node.parentElement;
|
||||
while (
|
||||
rootable_parent &&
|
||||
rootable_parent.tagName != "HTML" &&
|
||||
!TRAVERSE_STACK_ROOTS.has(rootable_parent.tagName)
|
||||
) {
|
||||
rootable_parent = rootable_parent.parentElement;
|
||||
}
|
||||
if (!rootable_parent) {
|
||||
if (IS_DEV) {
|
||||
node.parentElement?.setAttribute("data-firaid-moz-stack", "REJECTED-!ROOTABLE_PARENT");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let nodeText = node.textContent?.trim();
|
||||
if (!nodeText) {
|
||||
if (IS_DEV) {
|
||||
node.parentElement?.setAttribute("data-firaid-moz-stack", "REJECTED-!TEXT");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let nodeTextWords = count_words_naive(nodeText);
|
||||
|
||||
if (IS_DEV) {
|
||||
node.parentElement?.setAttribute("data-firaid-moz-stack", "VISITED");
|
||||
}
|
||||
|
||||
if (!stack) {
|
||||
stack = {
|
||||
root: rootable_parent,
|
||||
first_node: node,
|
||||
last_node: node,
|
||||
text: nodeText,
|
||||
words: nodeTextWords,
|
||||
};
|
||||
} else if (stack.root.contains(rootable_parent)) {
|
||||
if (IS_DEV) {
|
||||
node.parentElement?.setAttribute("data-firaid-moz-stack", "MEMBER");
|
||||
}
|
||||
stack.text += " " + nodeText;
|
||||
stack.words += nodeTextWords;
|
||||
stack.last_node = node;
|
||||
} else {
|
||||
if (stack.words >= hard_min_words) {
|
||||
yield stack;
|
||||
if (IS_DEV) {
|
||||
stack.root.parentElement?.setAttribute("data-firaid-moz-stack", "ROOT");
|
||||
}
|
||||
} else if (IS_DEV) {
|
||||
stack.root.parentElement?.setAttribute("data-firaid-moz-stack", "SKIP-SHORT_TEXT");
|
||||
}
|
||||
stack = {
|
||||
root: rootable_parent,
|
||||
first_node: node,
|
||||
last_node: node,
|
||||
text: nodeText,
|
||||
words: nodeTextWords,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stack.words > soft_max_words) {
|
||||
if (stack.words >= hard_min_words) {
|
||||
if (IS_DEV) {
|
||||
stack.root.parentElement?.setAttribute("data-firaid-moz-stack", "ROOT");
|
||||
}
|
||||
} else if (IS_DEV) {
|
||||
stack.root.parentElement?.setAttribute("data-firaid-moz-stack", "SKIP-SHORT_TEXT");
|
||||
}
|
||||
stack = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function count_words_naive(text: string): number {
|
||||
const is_space = /\s/;
|
||||
|
||||
let count = 0;
|
||||
let word_marker = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (is_space.test(text[i])) {
|
||||
word_marker = false;
|
||||
} else if (!word_marker) {
|
||||
count += 1;
|
||||
word_marker = true;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
25
extension/src/utils/logging.ts
Normal file
25
extension/src/utils/logging.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { sendMessage } from "@/utils/messaging";
|
||||
|
||||
export enum ExtLogLevel {
|
||||
Debug,
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
export const ExtConsole = (sender: string) => {
|
||||
return {
|
||||
debug(...args: any[]) {
|
||||
sendMessage("log", { level: ExtLogLevel.Debug, sender, args });
|
||||
},
|
||||
log(...args: any[]) {
|
||||
sendMessage("log", { level: ExtLogLevel.Info, sender, args });
|
||||
},
|
||||
warn(...args: any[]) {
|
||||
sendMessage("log", { level: ExtLogLevel.Warning, sender, args });
|
||||
},
|
||||
error(...args: any[]) {
|
||||
sendMessage("log", { level: ExtLogLevel.Error, sender, args });
|
||||
},
|
||||
};
|
||||
};
|
||||
9
extension/src/utils/messaging.ts
Normal file
9
extension/src/utils/messaging.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineExtensionMessaging } from "@webext-core/messaging";
|
||||
import { ExtLogLevel } from "@/utils/logging";
|
||||
|
||||
interface ProtocolMap {
|
||||
inference_aiText_classify(data: { text: string }): { prob_ai: number; prob_human: number };
|
||||
log(data: { level: ExtLogLevel; sender: string; args: any[] }): void;
|
||||
}
|
||||
|
||||
export const { sendMessage, onMessage } = defineExtensionMessaging<ProtocolMap>();
|
||||
7
extension/tsconfig.json
Normal file
7
extension/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./.wxt/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"useDefineForClassFields": true,
|
||||
},
|
||||
"files": [],
|
||||
}
|
||||
5
extension/web-ext.config.ts
Normal file
5
extension/web-ext.config.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { defineWebExtConfig } from "wxt";
|
||||
|
||||
export default defineWebExtConfig({
|
||||
chromiumArgs: ["--enable-features=Vulkan"],
|
||||
});
|
||||
21
extension/wxt.config.ts
Normal file
21
extension/wxt.config.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { defineConfig } from "wxt";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// See https://wxt.dev/api/config.html
|
||||
export default defineConfig({
|
||||
srcDir: "src",
|
||||
modules: ["@wxt-dev/module-svelte"],
|
||||
manifest: {
|
||||
permissions: ["storage", "activeTab", "contextMenus", "pageCapture", "offscreen"],
|
||||
web_accessible_resources: [
|
||||
{
|
||||
matches: ["<all_urls>"],
|
||||
resources: ["/inference-ai-text_bg.wasm", "/inference-ai-text-lite_bg.wasm"],
|
||||
},
|
||||
],
|
||||
homepage_url: "https://source.spushk.ru/pub/firaid-moz",
|
||||
},
|
||||
vite: () => ({
|
||||
plugins: [tailwindcss()],
|
||||
}),
|
||||
});
|
||||
1
extension/wxt.env.d.ts
vendored
Normal file
1
extension/wxt.env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="svelte" />
|
||||
Loading…
Reference in a new issue