Add initial VibeGuard browser extension
This commit is contained in:
parent
f251f0f8ce
commit
9c65a61ebb
43 changed files with 125275 additions and 2 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
public/models/toxicity/onnx/*.onnx filter=lfs diff=lfs merge=lfs -text
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -528,3 +528,9 @@ FodyWeavers.xsd
|
||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
*.vsix
|
||||||
|
|
||||||
|
|
||||||
|
# VibeGuard build artifacts
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
.DS_Store
|
||||||
|
|
|
||||||
42
README.md
42
README.md
|
|
@ -1,3 +1,41 @@
|
||||||
# vibeguard
|
# VibeGuard
|
||||||
|
|
||||||
A browser add-on that hides vibe-killing toxic posts.
|
VibeGuard is a local-first browser extension that hides toxic social-media posts. Content is classified on-device; post text is never sent to a classification service.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npm run build:firefox
|
||||||
|
npm run build:chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
Each build writes a browser-specific package under `dist/<mode>/`. The packaged Apache 2.0 model is committed through Git LFS. Use the pinned-revision workflow below when updating it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m venv .model-venv
|
||||||
|
. .model-venv/bin/activate
|
||||||
|
python3 -m pip install -r tools/requirements-model.txt
|
||||||
|
|
||||||
|
# Inspect architecture, labels, tokenizer, and resolved source revision.
|
||||||
|
python3 tools/convert_model.py inspect \
|
||||||
|
--model wagesj45/toxic-comment-classifier
|
||||||
|
|
||||||
|
# Use the immutable commit printed by inspection for a release artifact.
|
||||||
|
python3 tools/convert_model.py prepare \
|
||||||
|
--model wagesj45/toxic-comment-classifier \
|
||||||
|
--revision <resolved-huggingface-commit>
|
||||||
|
python3 tools/convert_model.py validate
|
||||||
|
```
|
||||||
|
|
||||||
|
Preparation writes the Transformers.js-compatible files and `model-manifest.json` under `public/models/toxicity/`. The manifest records the source revision, Apache 2.0 license, toxic/non-toxic label indices, maximum sequence length, and int8 quantization format. Source PyTorch/safetensors weights are never copied into the extension.
|
||||||
|
|
||||||
|
If the model uses generic labels such as `LABEL_0` and `LABEL_1`, pass `--toxic-index` and `--non-toxic-index` to `prepare`; the command refuses to guess an ambiguous mapping.
|
||||||
|
|
||||||
|
The first release targets Reddit, X/Twitter, and Facebook. Site selectors are isolated under `src/content/parsers/` because these sites frequently change their DOM structures.
|
||||||
|
|
||||||
|
## Runtime architecture
|
||||||
|
|
||||||
|
Firefox uses a persistent MV2 background page and inference worker. Chromium uses an MV3 service worker as a router, an offscreen document, and a worker-backed classifier. Both builds share one bounded, prioritized inference queue and in-memory text-result cache.
|
||||||
|
|
|
||||||
4452
package-lock.json
generated
Normal file
4452
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
29
package.json
Normal file
29
package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"name": "vibeguard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "A local-first browser extension that hides toxic social-media posts.",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "npm run build:chromium",
|
||||||
|
"build:firefox": "vite build --mode firefox && VIBEGUARD_CLASSIC_ENTRY=content vite build --mode firefox && VIBEGUARD_CLASSIC_ENTRY=background vite build --mode firefox",
|
||||||
|
"build:chromium": "vite build --mode chromium && VIBEGUARD_CLASSIC_ENTRY=content vite build --mode chromium",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"model:inspect": "python3 tools/convert_model.py inspect",
|
||||||
|
"model:prepare": "python3 tools/convert_model.py prepare",
|
||||||
|
"model:validate": "python3 tools/convert_model.py validate"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@huggingface/transformers": "^3.7.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/chrome": "^0.0.287",
|
||||||
|
"@types/firefox-webext-browser": "^120.0.4",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.0.7",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
public/manifest.chromium.json
Normal file
12
public/manifest.chromium.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "VibeGuard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Hide toxic social-media posts locally.",
|
||||||
|
"permissions": ["storage", "tabs", "offscreen"],
|
||||||
|
"host_permissions": ["https://*.reddit.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://*.facebook.com/*"],
|
||||||
|
"background": { "service_worker": "background.js", "type": "module" },
|
||||||
|
"content_scripts": [{ "matches": ["https://*.reddit.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://*.facebook.com/*"], "js": ["content.js"], "run_at": "document_idle" }],
|
||||||
|
"options_page": "options.html",
|
||||||
|
"web_accessible_resources": [{ "resources": ["inference.js", "models/*"], "matches": ["<all_urls>"] }]
|
||||||
|
}
|
||||||
11
public/manifest.firefox.json
Normal file
11
public/manifest.firefox.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"manifest_version": 2,
|
||||||
|
"name": "VibeGuard",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Hide toxic social-media posts locally.",
|
||||||
|
"permissions": ["storage", "tabs", "https://*.reddit.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://*.facebook.com/*"],
|
||||||
|
"background": { "scripts": ["background.js"], "persistent": true },
|
||||||
|
"content_scripts": [{ "matches": ["https://*.reddit.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://*.facebook.com/*"], "js": ["content.js"], "run_at": "document_idle" }],
|
||||||
|
"options_ui": { "page": "options.html", "open_in_tab": true },
|
||||||
|
"web_accessible_resources": ["inference.js", "models/*"]
|
||||||
|
}
|
||||||
37
public/models/toxicity/config.json
Normal file
37
public/models/toxicity/config.json
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"activation": "gelu",
|
||||||
|
"architectures": [
|
||||||
|
"DistilBertForSequenceClassification"
|
||||||
|
],
|
||||||
|
"attention_dropout": 0.1,
|
||||||
|
"bos_token_id": null,
|
||||||
|
"dim": 768,
|
||||||
|
"dropout": 0.1,
|
||||||
|
"dtype": "float32",
|
||||||
|
"eos_token_id": null,
|
||||||
|
"hidden_dim": 3072,
|
||||||
|
"id2label": {
|
||||||
|
"0": "not_toxic",
|
||||||
|
"1": "toxic"
|
||||||
|
},
|
||||||
|
"initializer_range": 0.02,
|
||||||
|
"label2id": {
|
||||||
|
"not_toxic": 0,
|
||||||
|
"toxic": 1
|
||||||
|
},
|
||||||
|
"max_position_embeddings": 512,
|
||||||
|
"model_type": "distilbert",
|
||||||
|
"n_heads": 12,
|
||||||
|
"n_layers": 6,
|
||||||
|
"output_past": true,
|
||||||
|
"pad_token_id": 0,
|
||||||
|
"problem_type": "single_label_classification",
|
||||||
|
"qa_dropout": 0.1,
|
||||||
|
"seq_classif_dropout": 0.2,
|
||||||
|
"sinusoidal_pos_embds": false,
|
||||||
|
"tie_weights_": true,
|
||||||
|
"tie_word_embeddings": true,
|
||||||
|
"transformers_version": "5.15.1",
|
||||||
|
"use_cache": false,
|
||||||
|
"vocab_size": 119547
|
||||||
|
}
|
||||||
17
public/models/toxicity/model-manifest.json
Normal file
17
public/models/toxicity/model-manifest.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"source": "wagesj45/toxic-comment-classifier",
|
||||||
|
"revision": "a7d2df2ead42f0bce00b330939574a02266772f5",
|
||||||
|
"architecture": "DistilBertForSequenceClassification",
|
||||||
|
"labels": {
|
||||||
|
"toxic": 1,
|
||||||
|
"nonToxic": 0,
|
||||||
|
"names": {
|
||||||
|
"not_toxic": 0,
|
||||||
|
"toxic": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"maxLength": 512,
|
||||||
|
"quantization": "int8-dynamic",
|
||||||
|
"runtime": "onnxruntime-web-wasm",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
}
|
||||||
BIN
public/models/toxicity/onnx/model_quantized.onnx
(Stored with Git LFS)
Normal file
BIN
public/models/toxicity/onnx/model_quantized.onnx
(Stored with Git LFS)
Normal file
Binary file not shown.
119702
public/models/toxicity/tokenizer.json
Normal file
119702
public/models/toxicity/tokenizer.json
Normal file
File diff suppressed because it is too large
Load diff
15
public/models/toxicity/tokenizer_config.json
Normal file
15
public/models/toxicity/tokenizer_config.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"backend": "tokenizers",
|
||||||
|
"cls_token": "[CLS]",
|
||||||
|
"do_lower_case": false,
|
||||||
|
"is_local": false,
|
||||||
|
"local_files_only": false,
|
||||||
|
"mask_token": "[MASK]",
|
||||||
|
"model_max_length": 512,
|
||||||
|
"pad_token": "[PAD]",
|
||||||
|
"sep_token": "[SEP]",
|
||||||
|
"strip_accents": null,
|
||||||
|
"tokenize_chinese_chars": true,
|
||||||
|
"tokenizer_class": "BertTokenizer",
|
||||||
|
"unk_token": "[UNK]"
|
||||||
|
}
|
||||||
2
public/offscreen.html
Normal file
2
public/offscreen.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html><head><meta charset="utf-8"><title>VibeGuard inference</title></head><body><script type="module" src="/offscreen.js"></script></body></html>
|
||||||
7
public/options.html
Normal file
7
public/options.html
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>VibeGuard settings</title></head><body><main><h1>VibeGuard settings</h1><form id="settings">
|
||||||
|
<label for="threshold">Toxicity threshold: <output id="threshold-value">80%</output></label><input id="threshold" type="range" min="0" max="1" step=".01">
|
||||||
|
<label for="filter-mode">Filtering mode</label><select id="filter-mode"><option value="collapse">Collapse with Show button</option><option value="hide">Hide completely</option></select>
|
||||||
|
<label><input id="show-score" type="checkbox"> Show toxicity score</label>
|
||||||
|
<fieldset><legend>Supported sites</legend><label><input id="site-reddit" type="checkbox"> Reddit</label><label><input id="site-twitter" type="checkbox"> X/Twitter</label><label><input id="site-facebook" type="checkbox"> Facebook</label></fieldset>
|
||||||
|
<button type="submit">Save settings</button> <span id="status" role="status"></span></form></main><script type="module" src="/options.js"></script></body></html>
|
||||||
18
src/background/chromium.ts
Normal file
18
src/background/chromium.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { createRuntime } from "./runtime";
|
||||||
|
import type { InferenceRequest, InferenceResult } from "../shared/types";
|
||||||
|
|
||||||
|
const OFFSCREEN_URL = "offscreen.html";
|
||||||
|
|
||||||
|
async function ensureOffscreen(): Promise<void> {
|
||||||
|
const contexts = await chrome.runtime.getContexts?.({ contextTypes: ["OFFSCREEN_DOCUMENT"] as never });
|
||||||
|
if (!contexts || contexts.length === 0) await chrome.offscreen.createDocument({ url: OFFSCREEN_URL, reasons: ["WORKERS"] as never, justification: "Run the local toxicity classifier in a durable worker." });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runInOffscreen(requests: InferenceRequest[]): Promise<InferenceResult[]> {
|
||||||
|
await ensureOffscreen();
|
||||||
|
const response = await chrome.runtime.sendMessage({ type: "OFFSCREEN_INFER", requests });
|
||||||
|
if (response?.error) throw new Error(response.error);
|
||||||
|
return response?.results ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
createRuntime(runInOffscreen);
|
||||||
14
src/background/firefox.ts
Normal file
14
src/background/firefox.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { createRuntime } from "./runtime";
|
||||||
|
import type { InferenceResult } from "../shared/types";
|
||||||
|
|
||||||
|
const worker = new Worker(chrome.runtime.getURL("inference.js"), { type: "module" });
|
||||||
|
|
||||||
|
createRuntime((requests) => new Promise((resolve, reject) => {
|
||||||
|
const listener = (event: MessageEvent<{ results?: InferenceResult[]; error?: string }>) => {
|
||||||
|
worker.removeEventListener("message", listener);
|
||||||
|
if (event.data.error) reject(new Error(event.data.error));
|
||||||
|
else resolve(event.data.results ?? []);
|
||||||
|
};
|
||||||
|
worker.addEventListener("message", listener);
|
||||||
|
worker.postMessage({ requests });
|
||||||
|
}));
|
||||||
15
src/background/offscreen.ts
Normal file
15
src/background/offscreen.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import type { RuntimeMessage } from "../shared/types";
|
||||||
|
|
||||||
|
const worker = new Worker(chrome.runtime.getURL("inference.js"), { type: "module" });
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((message: RuntimeMessage, _sender, sendResponse) => {
|
||||||
|
if (message.type !== "OFFSCREEN_INFER") return false;
|
||||||
|
const listener = (event: MessageEvent<{ results?: unknown; error?: string }>) => {
|
||||||
|
worker.removeEventListener("message", listener);
|
||||||
|
if (event.data.error) sendResponse({ error: event.data.error });
|
||||||
|
else sendResponse({ results: event.data.results });
|
||||||
|
};
|
||||||
|
worker.addEventListener("message", listener);
|
||||||
|
worker.postMessage({ requests: message.requests });
|
||||||
|
return true;
|
||||||
|
});
|
||||||
25
src/background/runtime.ts
Normal file
25
src/background/runtime.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { InferenceQueue } from "../inference/queue";
|
||||||
|
import { loadSettings, saveSettings } from "../shared/settings";
|
||||||
|
import type { InferenceRequest, InferenceResult, RuntimeMessage } from "../shared/types";
|
||||||
|
|
||||||
|
export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promise<InferenceResult[]>): void {
|
||||||
|
let sequence = 0;
|
||||||
|
const queue = new InferenceQueue(async (requests) => {
|
||||||
|
const requestId = ++sequence;
|
||||||
|
return runBatch(requests);
|
||||||
|
});
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((message: RuntimeMessage, sender, sendResponse) => {
|
||||||
|
if (message.type === "INFER") {
|
||||||
|
const request: InferenceRequest = { ...message.request, tabId: message.request.tabId ?? sender.tab?.id };
|
||||||
|
queue.enqueue(request).then((result) => sendResponse({ type: "INFERENCE_RESULT", result })).catch((error) => sendResponse({ error: String(error) }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (message.type === "GET_SETTINGS") { loadSettings().then((settings) => sendResponse({ type: "SETTINGS", settings })); return true; }
|
||||||
|
if (message.type === "SET_SETTINGS") { saveSettings(message.settings).then((settings) => sendResponse({ type: "SETTINGS", settings })); return true; }
|
||||||
|
if (message.type === "PING") { sendResponse({ type: "PONG", pending: queue.pendingCount }); }
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
chrome.tabs?.onRemoved?.addListener((tabId) => queue.invalidate(tabId));
|
||||||
|
}
|
||||||
41
src/content/filter.ts
Normal file
41
src/content/filter.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type { InferenceResult, Settings } from "../shared/types";
|
||||||
|
|
||||||
|
const HIDDEN = "data-vibeguard-hidden";
|
||||||
|
const ORIGINAL_DISPLAY = "data-vibeguard-display";
|
||||||
|
|
||||||
|
export function applyResult(element: Element, result: InferenceResult, settings: Settings): void {
|
||||||
|
const shouldFilter = result.label === "toxic" && result.probability >= settings.threshold;
|
||||||
|
if (!shouldFilter) { restore(element); return; }
|
||||||
|
|
||||||
|
element.setAttribute(HIDDEN, "true");
|
||||||
|
if (settings.filterMode === "hide") {
|
||||||
|
element.setAttribute(ORIGINAL_DISPLAY, (element as HTMLElement).style.display);
|
||||||
|
(element as HTMLElement).style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = element as HTMLElement;
|
||||||
|
if (!html.dataset.vibeguardOriginalDisplay) html.dataset.vibeguardOriginalDisplay = html.style.display;
|
||||||
|
html.style.display = "none";
|
||||||
|
let placeholder = element.nextElementSibling;
|
||||||
|
if (!placeholder?.matches("[data-vibeguard-placeholder]")) {
|
||||||
|
placeholder = document.createElement("div");
|
||||||
|
placeholder.setAttribute("data-vibeguard-placeholder", "true");
|
||||||
|
element.insertAdjacentElement("afterend", placeholder);
|
||||||
|
}
|
||||||
|
placeholder.className = "vibeguard-placeholder";
|
||||||
|
placeholder.textContent = `Content hidden as toxic${settings.showScore ? ` (${Math.round(result.probability * 100)}%)` : ""}`;
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = "Show";
|
||||||
|
button.addEventListener("click", () => { restore(element); placeholder?.remove(); });
|
||||||
|
placeholder.append(" ", button);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restore(element: Element): void {
|
||||||
|
const html = element as HTMLElement;
|
||||||
|
html.style.display = html.dataset.vibeguardOriginalDisplay ?? "";
|
||||||
|
delete html.dataset.vibeguardOriginalDisplay;
|
||||||
|
element.removeAttribute(HIDDEN);
|
||||||
|
element.nextElementSibling?.matches("[data-vibeguard-placeholder]") && element.nextElementSibling.remove();
|
||||||
|
}
|
||||||
48
src/content/main.ts
Normal file
48
src/content/main.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { createParser, siteForLocation } from "./parsers";
|
||||||
|
import { applyResult } from "./filter";
|
||||||
|
import { loadSettings } from "../shared/settings";
|
||||||
|
import { hashText } from "../shared/hash";
|
||||||
|
import { priorityFor } from "../inference/queue";
|
||||||
|
import type { InferenceRequest, RuntimeMessage, Settings } from "../shared/types";
|
||||||
|
|
||||||
|
const site = siteForLocation();
|
||||||
|
if (site) void start(site);
|
||||||
|
|
||||||
|
async function start(activeSite: NonNullable<typeof site>): Promise<void> {
|
||||||
|
let settings = await requestSettings();
|
||||||
|
if (!settings.enabledSites[activeSite]) return;
|
||||||
|
const parser = createParser(activeSite);
|
||||||
|
const navigationId = crypto.randomUUID();
|
||||||
|
const seen = new WeakSet<Element>();
|
||||||
|
|
||||||
|
const process = (posts: ReturnType<typeof parser.discover>): void => {
|
||||||
|
posts.forEach((post) => {
|
||||||
|
if (seen.has(post.element)) return;
|
||||||
|
seen.add(post.element);
|
||||||
|
const request: InferenceRequest = {
|
||||||
|
id: post.id, text: post.text, site: activeSite, navigationId,
|
||||||
|
priority: priorityFor(isVisible(post.element), document.visibilityState === "visible"),
|
||||||
|
requestId: crypto.randomUUID()
|
||||||
|
};
|
||||||
|
void chrome.runtime.sendMessage<RuntimeMessage, { type: "INFERENCE_RESULT"; result: Parameters<typeof applyResult>[1] }>( { type: "INFER", request })
|
||||||
|
.then((response) => { if (response?.result) applyResult(post.element, response.result, settings); });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
process(parser.discover(document));
|
||||||
|
const stop = parser.observe(process);
|
||||||
|
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||||
|
if (area === "local" && changes["vibeguard.settings"]?.newValue) settings = await loadSettings();
|
||||||
|
});
|
||||||
|
window.addEventListener("pagehide", () => { stop(); parser.dispose(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestSettings(): Promise<Settings> {
|
||||||
|
const response = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });
|
||||||
|
return response?.settings ?? loadSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVisible(element: Element): boolean {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.bottom >= 0 && rect.top <= window.innerHeight;
|
||||||
|
}
|
||||||
40
src/content/parsers/base.ts
Normal file
40
src/content/parsers/base.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { IPostParser, NormalizedPost, SupportedSite } from "../../shared/types";
|
||||||
|
|
||||||
|
export abstract class SelectorParser implements IPostParser {
|
||||||
|
abstract readonly site: SupportedSite;
|
||||||
|
protected abstract readonly selectors: string[];
|
||||||
|
|
||||||
|
discover(root: Document | Element): NormalizedPost[] {
|
||||||
|
const nodes = root.querySelectorAll(this.selectors.join(","));
|
||||||
|
return Array.from(nodes).flatMap((element, index) => {
|
||||||
|
const text = this.extractText(element);
|
||||||
|
if (!text) return [];
|
||||||
|
const id = this.getId(element, index);
|
||||||
|
element.setAttribute("data-vibeguard-id", id);
|
||||||
|
return [{ id, text, element, site: this.site }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
observe(onPosts: (posts: NormalizedPost[]) => void): () => void {
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
mutation.addedNodes.forEach((node) => {
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE) onPosts(this.discover(node as Element));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {}
|
||||||
|
|
||||||
|
protected extractText(element: Element): string {
|
||||||
|
return (element.getAttribute("data-vibeguard-text") ?? element.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getId(element: Element, index: number): string {
|
||||||
|
const nativeId = element.getAttribute("data-testid") ?? element.getAttribute("data-id") ?? element.id;
|
||||||
|
return `${this.site}:${nativeId || `${index}-${this.extractText(element).slice(0, 32)}`}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/content/parsers/facebook.ts
Normal file
6
src/content/parsers/facebook.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { SelectorParser } from "./base";
|
||||||
|
|
||||||
|
export class FacebookParser extends SelectorParser {
|
||||||
|
readonly site = "facebook" as const;
|
||||||
|
protected readonly selectors = ["div[role='article']"];
|
||||||
|
}
|
||||||
17
src/content/parsers/index.ts
Normal file
17
src/content/parsers/index.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { IPostParser, SupportedSite } from "../../shared/types";
|
||||||
|
import { FacebookParser } from "./facebook";
|
||||||
|
import { RedditParser } from "./reddit";
|
||||||
|
import { TwitterParser } from "./twitter";
|
||||||
|
|
||||||
|
export function siteForLocation(hostname = location.hostname): SupportedSite | undefined {
|
||||||
|
if (hostname === "reddit.com" || hostname.endsWith(".reddit.com")) return "reddit";
|
||||||
|
if (hostname === "x.com" || hostname.endsWith(".x.com") || hostname === "twitter.com" || hostname.endsWith(".twitter.com")) return "twitter";
|
||||||
|
if (hostname === "facebook.com" || hostname.endsWith(".facebook.com")) return "facebook";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createParser(site: SupportedSite): IPostParser {
|
||||||
|
if (site === "reddit") return new RedditParser();
|
||||||
|
if (site === "twitter") return new TwitterParser();
|
||||||
|
return new FacebookParser();
|
||||||
|
}
|
||||||
6
src/content/parsers/reddit.ts
Normal file
6
src/content/parsers/reddit.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { SelectorParser } from "./base";
|
||||||
|
|
||||||
|
export class RedditParser extends SelectorParser {
|
||||||
|
readonly site = "reddit" as const;
|
||||||
|
protected readonly selectors = ["shreddit-post", "shreddit-comment", "article[data-testid='post-container']", "div[data-testid='comment']"];
|
||||||
|
}
|
||||||
6
src/content/parsers/twitter.ts
Normal file
6
src/content/parsers/twitter.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { SelectorParser } from "./base";
|
||||||
|
|
||||||
|
export class TwitterParser extends SelectorParser {
|
||||||
|
readonly site = "twitter" as const;
|
||||||
|
protected readonly selectors = ["article[data-testid='tweet']"];
|
||||||
|
}
|
||||||
22
src/inference/cache.ts
Normal file
22
src/inference/cache.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { hashText, normalizeText } from "../shared/hash";
|
||||||
|
import type { InferenceResult } from "../shared/types";
|
||||||
|
|
||||||
|
export class ResultCache {
|
||||||
|
private readonly values = new Map<string, InferenceResult>();
|
||||||
|
|
||||||
|
get(text: string): InferenceResult | undefined {
|
||||||
|
return this.values.get(hashText(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
set(text: string, result: InferenceResult): void {
|
||||||
|
this.values.set(hashText(normalizeText(text)), result);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.values.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.values.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/inference/classifier.ts
Normal file
46
src/inference/classifier.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { pipeline, type TextClassificationPipeline } from "@huggingface/transformers";
|
||||||
|
import { hashText } from "../shared/hash";
|
||||||
|
import type { InferenceRequest, InferenceResult } from "../shared/types";
|
||||||
|
import { loadModelManifest, modelBaseUrl, type ModelManifest } from "./model-metadata";
|
||||||
|
|
||||||
|
let classifier: TextClassificationPipeline | undefined;
|
||||||
|
let classifierPromise: Promise<TextClassificationPipeline> | undefined;
|
||||||
|
let manifest: ModelManifest | undefined;
|
||||||
|
|
||||||
|
export async function classify(requests: InferenceRequest[]): Promise<InferenceResult[]> {
|
||||||
|
const modelManifest = manifest ??= await loadModelManifest();
|
||||||
|
classifier ??= await loadClassifier();
|
||||||
|
const invoke = classifier as unknown as (texts: string[], options: Record<string, unknown>) => Promise<Array<Array<{ label: string; score: number }> | { label: string; score: number }>>;
|
||||||
|
const outputs = await invoke(requests.map((request) => request.text), { top_k: undefined, max_length: modelManifest.maxLength });
|
||||||
|
return requests.map((request, index) => {
|
||||||
|
const output = Array.isArray(outputs[index]) ? outputs[index] : [outputs[index]];
|
||||||
|
const toxic = output.find((item) => resolveOutputIndex(item?.label, modelManifest) === modelManifest.labels.toxic);
|
||||||
|
if (!toxic) throw new Error(`Classifier output did not contain toxic label index ${modelManifest.labels.toxic}`);
|
||||||
|
const probability = Math.max(0, Math.min(1, Number(toxic?.score ?? 0)));
|
||||||
|
return {
|
||||||
|
requestId: request.requestId,
|
||||||
|
id: request.id,
|
||||||
|
textHash: hashText(request.text),
|
||||||
|
label: probability >= 0.5 ? "toxic" : "not_toxic",
|
||||||
|
probability,
|
||||||
|
navigationId: request.navigationId,
|
||||||
|
modelRevision: modelManifest.revision
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadClassifier(): Promise<TextClassificationPipeline> {
|
||||||
|
classifierPromise ??= (pipeline as unknown as (task: string, model: string, options: { device: string }) => Promise<TextClassificationPipeline>)("text-classification", modelBaseUrl(), { device: "wasm" });
|
||||||
|
return classifierPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveOutputIndex(label: string | undefined, metadata: ModelManifest): number | undefined {
|
||||||
|
if (!label) return undefined;
|
||||||
|
const normalized = label.toLowerCase().replace(/[\s-]+/g, "_");
|
||||||
|
if (normalized === "toxic") return metadata.labels.toxic;
|
||||||
|
if (normalized === "not_toxic" || normalized === "non_toxic" || normalized === "non-toxic") return metadata.labels.nonToxic;
|
||||||
|
const configured = metadata.labels.names[normalized] ?? metadata.labels.names[label];
|
||||||
|
if (configured !== undefined) return configured;
|
||||||
|
const match = normalized.match(/^label_(\d+)$/);
|
||||||
|
return match ? Number(match[1]) : undefined;
|
||||||
|
}
|
||||||
36
src/inference/model-metadata.ts
Normal file
36
src/inference/model-metadata.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
export interface ModelManifest {
|
||||||
|
source: string;
|
||||||
|
revision: string;
|
||||||
|
architecture: string;
|
||||||
|
labels: {
|
||||||
|
toxic: number;
|
||||||
|
nonToxic: number;
|
||||||
|
names: Record<string, number>;
|
||||||
|
};
|
||||||
|
maxLength: number;
|
||||||
|
quantization: string;
|
||||||
|
runtime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifestPromise: Promise<ModelManifest> | undefined;
|
||||||
|
|
||||||
|
export function modelBaseUrl(): string {
|
||||||
|
return chrome.runtime.getURL("models/toxicity/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadModelManifest(): Promise<ModelManifest> {
|
||||||
|
manifestPromise ??= fetch(`${modelBaseUrl()}model-manifest.json`)
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) throw new Error(`VibeGuard model metadata unavailable (${response.status})`);
|
||||||
|
return response.json() as Promise<ModelManifest>;
|
||||||
|
})
|
||||||
|
.then(validateManifest);
|
||||||
|
return manifestPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateManifest(value: ModelManifest): ModelManifest {
|
||||||
|
if (!value || typeof value.source !== "string" || typeof value.revision !== "string") throw new Error("Invalid VibeGuard model manifest");
|
||||||
|
if (!Number.isInteger(value.labels?.toxic) || !Number.isInteger(value.labels?.nonToxic) || value.labels.toxic === value.labels.nonToxic || !value.labels?.names) throw new Error("Model manifest has no binary label mapping");
|
||||||
|
if (!Number.isInteger(value.maxLength) || value.maxLength < 8) throw new Error("Model manifest has an invalid maximum length");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
100
src/inference/queue.ts
Normal file
100
src/inference/queue.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
import type { InferenceRequest, InferenceResult, QueuePriority } from "../shared/types";
|
||||||
|
import { hashText } from "../shared/hash";
|
||||||
|
import { ResultCache } from "./cache";
|
||||||
|
|
||||||
|
type Runner = (requests: InferenceRequest[]) => Promise<InferenceResult[]>;
|
||||||
|
|
||||||
|
interface Pending {
|
||||||
|
request: InferenceRequest;
|
||||||
|
resolve: (result: InferenceResult) => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InferenceQueue {
|
||||||
|
private readonly pending: Pending[] = [];
|
||||||
|
private scheduled = false;
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly run: Runner,
|
||||||
|
private readonly cache = new ResultCache(),
|
||||||
|
private readonly maxSize = 500,
|
||||||
|
private readonly batchSize = 8,
|
||||||
|
private readonly debounceMs = 20
|
||||||
|
) {}
|
||||||
|
|
||||||
|
enqueue(request: InferenceRequest): Promise<InferenceResult> {
|
||||||
|
const cached = this.cache.get(request.text);
|
||||||
|
if (cached) {
|
||||||
|
return Promise.resolve({ ...cached, requestId: request.requestId, id: request.id, navigationId: request.navigationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.pending.length >= this.maxSize) this.dropLowestPriority();
|
||||||
|
return new Promise<InferenceResult>((resolve, reject) => {
|
||||||
|
this.pending.push({ request, resolve, reject });
|
||||||
|
this.pending.sort((a, b) => a.request.priority - b.request.priority);
|
||||||
|
this.schedule();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(tabId?: number, navigationId?: string): void {
|
||||||
|
for (let index = this.pending.length - 1; index >= 0; index -= 1) {
|
||||||
|
const request = this.pending[index]?.request;
|
||||||
|
if (!request) continue;
|
||||||
|
if ((tabId === undefined || request.tabId === tabId) && (navigationId === undefined || request.navigationId === navigationId)) {
|
||||||
|
this.pending[index]?.reject(new Error("Inference request invalidated"));
|
||||||
|
this.pending.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get pendingCount(): number { return this.pending.length; }
|
||||||
|
get cacheSize(): number { return this.cache.size; }
|
||||||
|
|
||||||
|
private schedule(): void {
|
||||||
|
if (this.scheduled || this.running) return;
|
||||||
|
this.scheduled = true;
|
||||||
|
setTimeout(() => { this.scheduled = false; void this.flush(); }, this.debounceMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async flush(): Promise<void> {
|
||||||
|
if (this.running || this.pending.length === 0) return;
|
||||||
|
this.running = true;
|
||||||
|
const batch = this.pending.splice(0, this.batchSize);
|
||||||
|
try {
|
||||||
|
const results = await this.run(batch.map((item) => item.request));
|
||||||
|
const byRequest = new Map(results.map((result) => [result.requestId, result]));
|
||||||
|
for (const item of batch) {
|
||||||
|
const result = byRequest.get(item.request.requestId);
|
||||||
|
if (!result) item.reject(new Error("Classifier returned no result"));
|
||||||
|
else { this.cache.set(item.request.text, result); item.resolve(result); }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
batch.forEach((item) => item.reject(error));
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
if (this.pending.length > 0) this.schedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dropLowestPriority(): void {
|
||||||
|
let worstIndex = 0;
|
||||||
|
for (let index = 1; index < this.pending.length; index += 1) {
|
||||||
|
const current = this.pending[index]?.request.priority ?? 0;
|
||||||
|
const worst = this.pending[worstIndex]?.request.priority ?? 0;
|
||||||
|
if (current > worst) worstIndex = index;
|
||||||
|
}
|
||||||
|
this.pending[worstIndex]?.reject(new Error("Inference queue is full"));
|
||||||
|
this.pending.splice(worstIndex, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestKey(request: InferenceRequest): string {
|
||||||
|
return `${request.tabId ?? "global"}:${request.navigationId}:${request.id}:${hashText(request.text)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function priorityFor(visible: boolean, activeTab: boolean): QueuePriority {
|
||||||
|
if (visible && activeTab) return 0;
|
||||||
|
if (activeTab) return 1;
|
||||||
|
return visible ? 2 : 3;
|
||||||
|
}
|
||||||
11
src/inference/worker.ts
Normal file
11
src/inference/worker.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { classify } from "./classifier";
|
||||||
|
import type { InferenceRequest } from "../shared/types";
|
||||||
|
|
||||||
|
self.onmessage = async (event: MessageEvent<{ requests: InferenceRequest[] }>) => {
|
||||||
|
try {
|
||||||
|
const results = await classify(event.data.requests);
|
||||||
|
self.postMessage({ results });
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({ error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
37
src/options/main.ts
Normal file
37
src/options/main.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { loadSettings, saveSettings } from "../shared/settings";
|
||||||
|
import type { FilterMode, Settings } from "../shared/types";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
const form = document.querySelector<HTMLFormElement>("#settings");
|
||||||
|
const threshold = document.querySelector<HTMLInputElement>("#threshold");
|
||||||
|
const thresholdValue = document.querySelector<HTMLElement>("#threshold-value");
|
||||||
|
const mode = document.querySelector<HTMLSelectElement>("#filter-mode");
|
||||||
|
const showScore = document.querySelector<HTMLInputElement>("#show-score");
|
||||||
|
const status = document.querySelector<HTMLElement>("#status");
|
||||||
|
|
||||||
|
void loadSettings().then((settings) => {
|
||||||
|
threshold!.value = String(settings.threshold);
|
||||||
|
mode!.value = settings.filterMode;
|
||||||
|
showScore!.checked = settings.showScore;
|
||||||
|
for (const site of ["reddit", "twitter", "facebook"] as const) document.querySelector<HTMLInputElement>(`#site-${site}`)!.checked = settings.enabledSites[site];
|
||||||
|
updateThresholdLabel();
|
||||||
|
});
|
||||||
|
|
||||||
|
threshold?.addEventListener("input", updateThresholdLabel);
|
||||||
|
form?.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const settings: Partial<Settings> = {
|
||||||
|
threshold: Number(threshold?.value), filterMode: mode?.value as FilterMode, showScore: showScore?.checked,
|
||||||
|
enabledSites: {
|
||||||
|
reddit: document.querySelector<HTMLInputElement>("#site-reddit")!.checked,
|
||||||
|
twitter: document.querySelector<HTMLInputElement>("#site-twitter")!.checked,
|
||||||
|
facebook: document.querySelector<HTMLInputElement>("#site-facebook")!.checked
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await saveSettings(settings);
|
||||||
|
if (status) { status.textContent = "Saved"; setTimeout(() => { status.textContent = ""; }, 1500); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateThresholdLabel(): void {
|
||||||
|
if (thresholdValue && threshold) thresholdValue.textContent = `${Math.round(Number(threshold.value) * 100)}%`;
|
||||||
|
}
|
||||||
8
src/options/style.css
Normal file
8
src/options/style.css
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
:root { font: 16px system-ui, sans-serif; color: #18212f; background: #f7f8fb; }
|
||||||
|
body { max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
|
||||||
|
main { background: white; border-radius: .75rem; padding: 1.5rem; box-shadow: 0 2px 14px #18212f18; }
|
||||||
|
label, fieldset { display: block; margin: 1rem 0; }
|
||||||
|
fieldset { border: 0; padding: 0; }
|
||||||
|
button { padding: .5rem 1rem; border: 0; border-radius: .4rem; background: #4355db; color: white; cursor: pointer; }
|
||||||
|
.vibeguard-placeholder { padding: .75rem; margin: .25rem 0; border: 1px solid #d5d9e8; background: #f1f3f9; color: #5c6475; }
|
||||||
|
.vibeguard-placeholder button { padding: .25rem .5rem; margin-left: .5rem; }
|
||||||
13
src/shared/hash.ts
Normal file
13
src/shared/hash.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
export function normalizeText(text: string): string {
|
||||||
|
return text.replace(/\s+/g, " ").trim().toLocaleLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashText(text: string): string {
|
||||||
|
const normalized = normalizeText(text);
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let index = 0; index < normalized.length; index += 1) {
|
||||||
|
hash ^= normalized.charCodeAt(index);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return (hash >>> 0).toString(16).padStart(8, "0");
|
||||||
|
}
|
||||||
47
src/shared/settings.ts
Normal file
47
src/shared/settings.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { DEFAULT_SETTINGS, type Settings } from "./types";
|
||||||
|
|
||||||
|
const KEY = "vibeguard.settings";
|
||||||
|
|
||||||
|
export async function loadSettings(): Promise<Settings> {
|
||||||
|
const stored = await getStorageItem(KEY);
|
||||||
|
return mergeSettings(stored[KEY] as Partial<Settings> | undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSettings(update: Partial<Settings>): Promise<Settings> {
|
||||||
|
const settings = mergeSettings({ ...(await loadSettings()), ...update });
|
||||||
|
await setStorageItem({ [KEY]: settings });
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStorageItem(key: string): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
chrome.storage.local.get(key, (items) => {
|
||||||
|
const error = chrome.runtime.lastError;
|
||||||
|
if (error) reject(new Error(error.message));
|
||||||
|
else resolve(items);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStorageItem(items: Record<string, unknown>): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
chrome.storage.local.set(items, () => {
|
||||||
|
const error = chrome.runtime.lastError;
|
||||||
|
if (error) reject(new Error(error.message));
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeSettings(input?: Partial<Settings>): Settings {
|
||||||
|
return {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
...input,
|
||||||
|
threshold: clamp(Number(input?.threshold ?? DEFAULT_SETTINGS.threshold), 0, 1),
|
||||||
|
enabledSites: { ...DEFAULT_SETTINGS.enabledSites, ...input?.enabledSites }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : min;
|
||||||
|
}
|
||||||
64
src/shared/types.ts
Normal file
64
src/shared/types.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
export type SupportedSite = "reddit" | "twitter" | "facebook";
|
||||||
|
export type QueuePriority = 0 | 1 | 2 | 3;
|
||||||
|
export type FilterMode = "collapse" | "hide";
|
||||||
|
|
||||||
|
export interface NormalizedPost {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
element: Element;
|
||||||
|
site: SupportedSite;
|
||||||
|
tabId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostDescriptor {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
site: SupportedSite;
|
||||||
|
tabId?: number;
|
||||||
|
navigationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InferenceRequest extends PostDescriptor {
|
||||||
|
priority: QueuePriority;
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InferenceResult {
|
||||||
|
requestId: string;
|
||||||
|
id: string;
|
||||||
|
textHash: string;
|
||||||
|
label: "toxic" | "not_toxic";
|
||||||
|
probability: number;
|
||||||
|
navigationId: string;
|
||||||
|
modelRevision?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
threshold: number;
|
||||||
|
filterMode: FilterMode;
|
||||||
|
showScore: boolean;
|
||||||
|
enabledSites: Record<SupportedSite, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
|
threshold: 0.8,
|
||||||
|
filterMode: "collapse",
|
||||||
|
showScore: true,
|
||||||
|
enabledSites: { reddit: true, twitter: true, facebook: true }
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IPostParser {
|
||||||
|
readonly site: SupportedSite;
|
||||||
|
discover(root: Document | Element): NormalizedPost[];
|
||||||
|
observe(onPosts: (posts: NormalizedPost[]) => void): () => void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeMessage =
|
||||||
|
| { type: "INFER"; request: InferenceRequest }
|
||||||
|
| { type: "INFERENCE_RESULT"; result: InferenceResult }
|
||||||
|
| { type: "GET_SETTINGS" }
|
||||||
|
| { type: "SET_SETTINGS"; settings: Partial<Settings> }
|
||||||
|
| { type: "SETTINGS"; settings: Settings }
|
||||||
|
| { type: "OFFSCREEN_INFER"; requests: InferenceRequest[] }
|
||||||
|
| { type: "PING" };
|
||||||
16
tests/filter.test.ts
Normal file
16
tests/filter.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { applyResult, restore } from "../src/content/filter";
|
||||||
|
import { DEFAULT_SETTINGS } from "../src/shared/types";
|
||||||
|
|
||||||
|
describe("content filtering", () => {
|
||||||
|
it("collapses toxic content and restores it", () => {
|
||||||
|
const element = document.createElement("article");
|
||||||
|
document.body.append(element);
|
||||||
|
applyResult(element, { requestId: "1", id: "1", textHash: "x", label: "toxic", probability: .91, navigationId: "n" }, DEFAULT_SETTINGS);
|
||||||
|
expect(element.style.display).toBe("none");
|
||||||
|
expect(document.querySelector("[data-vibeguard-placeholder]")).not.toBeNull();
|
||||||
|
restore(element);
|
||||||
|
expect(element.style.display).toBe("");
|
||||||
|
expect(document.querySelector("[data-vibeguard-placeholder]")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
9
tests/hash.test.ts
Normal file
9
tests/hash.test.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { hashText, normalizeText } from "../src/shared/hash";
|
||||||
|
|
||||||
|
describe("text hashing", () => {
|
||||||
|
it("normalizes whitespace and casing", () => {
|
||||||
|
expect(normalizeText(" Hello\n WORLD ")).toBe("hello world");
|
||||||
|
expect(hashText("hello world")).toBe(hashText("HELLO\nWORLD"));
|
||||||
|
});
|
||||||
|
});
|
||||||
28
tests/model-metadata.test.ts
Normal file
28
tests/model-metadata.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { validateManifest } from "../src/inference/model-metadata";
|
||||||
|
import { resolveOutputIndex } from "../src/inference/classifier";
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
source: "wagesj45/toxic-comment-classifier",
|
||||||
|
revision: "abc123",
|
||||||
|
architecture: "DistilBertForSequenceClassification",
|
||||||
|
labels: { toxic: 1, nonToxic: 0, names: { toxic: 1, non_toxic: 0 } },
|
||||||
|
maxLength: 512,
|
||||||
|
quantization: "int8-dynamic",
|
||||||
|
runtime: "onnxruntime-web-wasm"
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("model contract", () => {
|
||||||
|
it("accepts a binary manifest and resolves common output labels", () => {
|
||||||
|
expect(validateManifest(manifest)).toEqual(manifest);
|
||||||
|
expect(resolveOutputIndex("LABEL_1", manifest)).toBe(1);
|
||||||
|
expect(resolveOutputIndex("not-toxic", manifest)).toBe(0);
|
||||||
|
expect(resolveOutputIndex("toxic", manifest)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects incomplete metadata", () => {
|
||||||
|
expect(() => validateManifest({ ...manifest, labels: { toxic: 1, nonToxic: 1, names: {} } })).toThrow();
|
||||||
|
expect(() => validateManifest({ ...manifest, maxLength: 0 })).toThrow();
|
||||||
|
expect(() => validateManifest({ ...manifest, labels: undefined as unknown as typeof manifest.labels })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
23
tests/queue.test.ts
Normal file
23
tests/queue.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { InferenceQueue } from "../src/inference/queue";
|
||||||
|
import type { InferenceRequest } from "../src/shared/types";
|
||||||
|
|
||||||
|
const request = (id: string, priority: 0 | 1 | 2 | 3): InferenceRequest => ({
|
||||||
|
id, text: `text ${id}`, site: "reddit", priority, requestId: id, navigationId: "nav"
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("InferenceQueue", () => {
|
||||||
|
it("batches requests and caches equivalent text", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const queue = new InferenceQueue(async (requests) => {
|
||||||
|
calls += 1;
|
||||||
|
return requests.map((item) => ({ requestId: item.requestId, id: item.id, textHash: "hash", label: "toxic" as const, probability: .9, navigationId: item.navigationId }));
|
||||||
|
}, undefined, 10, 8, 0);
|
||||||
|
const first = await queue.enqueue(request("a", 0));
|
||||||
|
const second = await queue.enqueue({ ...request("b", 0), text: "text a" });
|
||||||
|
expect(first.probability).toBe(.9);
|
||||||
|
expect(second.requestId).toBe("b");
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
expect(queue.cacheSize).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
172
tools/convert_model.py
Normal file
172
tools/convert_model.py
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Prepare a pinned Hugging Face binary classifier for VibeGuard."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
DEFAULT_MODEL = "wagesj45/toxic-comment-classifier"
|
||||||
|
DEFAULT_OUTPUT = Path("public/models/toxicity")
|
||||||
|
|
||||||
|
|
||||||
|
def make_parser() -> argparse.ArgumentParser:
|
||||||
|
command = argparse.ArgumentParser(description=__doc__)
|
||||||
|
command.add_argument("action", choices=("inspect", "prepare", "validate"))
|
||||||
|
command.add_argument("--model", default=DEFAULT_MODEL)
|
||||||
|
command.add_argument("--revision", help="Immutable HF commit; required by prepare")
|
||||||
|
command.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||||
|
command.add_argument("--toxic-index", type=int)
|
||||||
|
command.add_argument("--non-toxic-index", type=int)
|
||||||
|
command.add_argument("--max-length", type=int)
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def load_source(model: str, revision: str | None) -> tuple[Path, dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
from huggingface_hub import model_info, snapshot_download
|
||||||
|
except ImportError as error:
|
||||||
|
raise SystemExit("Install tools with: python3 -m pip install -r tools/requirements-model.txt") from error
|
||||||
|
kwargs: dict[str, Any] = {"repo_id": model}
|
||||||
|
if revision: kwargs["revision"] = revision
|
||||||
|
source = Path(snapshot_download(**kwargs))
|
||||||
|
info = None
|
||||||
|
try:
|
||||||
|
info = model_info(model, revision=revision)
|
||||||
|
except Exception as error: # A pinned, complete local snapshot is still usable offline.
|
||||||
|
print(f"Warning: Hugging Face metadata unavailable; using local snapshot metadata: {error}", file=sys.stderr)
|
||||||
|
card = getattr(info, "cardData", None) or {}
|
||||||
|
license_id = card.get("license")
|
||||||
|
if not license_id and (source / "LICENSE").exists():
|
||||||
|
license_text = (source / "LICENSE").read_text(errors="replace").lower()
|
||||||
|
if "apache license" in license_text and "version 2.0" in license_text:
|
||||||
|
license_id = "apache-2.0"
|
||||||
|
return source, {"source": model, "revision": revision or getattr(info, "sha", "unknown"), "license": license_id}
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_source(source: Path) -> dict[str, Any]:
|
||||||
|
config = json.loads((source / "config.json").read_text())
|
||||||
|
labels = {str(key): str(value) for key, value in (config.get("id2label") or {}).items()}
|
||||||
|
return {
|
||||||
|
"architecture": (config.get("architectures") or [config.get("model_type", "unknown")])[0],
|
||||||
|
"modelType": config.get("model_type"),
|
||||||
|
"numLabels": config.get("num_labels") or len(labels),
|
||||||
|
"id2label": labels,
|
||||||
|
"maxLength": config.get("max_position_embeddings"),
|
||||||
|
"files": sorted(path.name for path in source.iterdir()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_labels(details: dict[str, Any], toxic_override: int | None, non_toxic_override: int | None) -> tuple[int, int, dict[str, int]]:
|
||||||
|
names = {str(index): value.lower().replace("-", "_").replace(" ", "_") for index, value in details["id2label"].items()}
|
||||||
|
indexed = {name: int(index) for index, name in names.items()}
|
||||||
|
toxic = toxic_override if toxic_override is not None else next((index for name, index in indexed.items() if name in ("toxic", "toxicity")), None)
|
||||||
|
non_toxic = non_toxic_override if non_toxic_override is not None else next((index for name, index in indexed.items() if name in ("non_toxic", "nontoxic", "clean", "not_toxic")), None)
|
||||||
|
if toxic is None or non_toxic is None or toxic == non_toxic:
|
||||||
|
raise ValueError("Could not resolve distinct labels; pass --toxic-index and --non-toxic-index")
|
||||||
|
return toxic, non_toxic, indexed
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(args: argparse.Namespace) -> None:
|
||||||
|
if not args.revision: raise SystemExit("prepare requires --revision for reproducible release artifacts")
|
||||||
|
source, source_metadata = load_source(args.model, args.revision)
|
||||||
|
license_id = str(source_metadata.get("license") or "").lower()
|
||||||
|
if license_id != "apache-2.0": raise SystemExit(f"Expected Apache-2.0 model metadata, found license={source_metadata.get('license')!r}")
|
||||||
|
details = inspect_source(source)
|
||||||
|
if details["numLabels"] != 2: raise SystemExit(f"Expected a binary classifier, found {details['numLabels']}")
|
||||||
|
toxic, non_toxic, names = resolve_labels(details, args.toxic_index, args.non_toxic_index)
|
||||||
|
max_length = args.max_length or details["maxLength"] or 512
|
||||||
|
with tempfile.TemporaryDirectory(prefix="vibeguard-model-") as temporary:
|
||||||
|
export_dir = Path(temporary) / "onnx"
|
||||||
|
float_model = export_dir / "model.onnx"
|
||||||
|
export_onnx(source, float_model, max_length)
|
||||||
|
quantized = export_dir / "model_quantized.onnx"
|
||||||
|
quantize(float_model, quantized)
|
||||||
|
args.output.mkdir(parents=True, exist_ok=True)
|
||||||
|
for name in ("tokenizer.json", "tokenizer_config.json", "special_tokens_map.json", "vocab.txt", "merges.txt"):
|
||||||
|
if (source / name).exists(): shutil.copy2(source / name, args.output / name)
|
||||||
|
shutil.copy2(source / "config.json", args.output / "config.json")
|
||||||
|
(args.output / "onnx").mkdir(exist_ok=True)
|
||||||
|
shutil.copy2(quantized, args.output / "onnx" / "model_quantized.onnx")
|
||||||
|
manifest = {"source": args.model, "revision": source_metadata["revision"], "architecture": details["architecture"], "labels": {"toxic": toxic, "nonToxic": non_toxic, "names": names}, "maxLength": max_length, "quantization": "int8-dynamic", "runtime": "onnxruntime-web-wasm", "license": "Apache-2.0"}
|
||||||
|
(args.output / "model-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||||
|
print(json.dumps(manifest, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def quantize(source: Path, destination: Path) -> None:
|
||||||
|
try:
|
||||||
|
from onnxruntime.quantization import QuantType, quantize_dynamic
|
||||||
|
except ImportError as error:
|
||||||
|
raise SystemExit("Install tools with: python3 -m pip install -r tools/requirements-model.txt") from error
|
||||||
|
quantize_dynamic(str(source), str(destination), weight_type=QuantType.QInt8, per_channel=True, reduce_range=True)
|
||||||
|
|
||||||
|
|
||||||
|
def export_onnx(source: Path, destination: Path, max_length: int) -> None:
|
||||||
|
try:
|
||||||
|
import torch
|
||||||
|
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||||
|
except ImportError as error:
|
||||||
|
raise SystemExit("Install model tooling with: python3 -m pip install -r tools/requirements-model.txt") from error
|
||||||
|
|
||||||
|
tokenizer = AutoTokenizer.from_pretrained(str(source), local_files_only=True)
|
||||||
|
model = AutoModelForSequenceClassification.from_pretrained(str(source), local_files_only=True)
|
||||||
|
model.eval()
|
||||||
|
encoded = tokenizer("VibeGuard export calibration text.", return_tensors="pt", truncation=True, max_length=min(max_length, 32), padding="max_length")
|
||||||
|
|
||||||
|
class ClassifierWrapper(torch.nn.Module):
|
||||||
|
def __init__(self, wrapped: torch.nn.Module) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.wrapped = wrapped
|
||||||
|
|
||||||
|
def forward(self, input_ids: Any, attention_mask: Any) -> Any:
|
||||||
|
return self.wrapped(input_ids=input_ids, attention_mask=attention_mask).logits
|
||||||
|
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with torch.no_grad():
|
||||||
|
torch.onnx.export(
|
||||||
|
ClassifierWrapper(model),
|
||||||
|
(encoded["input_ids"], encoded["attention_mask"]),
|
||||||
|
str(destination),
|
||||||
|
input_names=["input_ids", "attention_mask"],
|
||||||
|
output_names=["logits"],
|
||||||
|
dynamic_axes={"input_ids": {0: "batch", 1: "sequence"}, "attention_mask": {0: "batch", 1: "sequence"}, "logits": {0: "batch"}},
|
||||||
|
opset_version=17,
|
||||||
|
do_constant_folding=True,
|
||||||
|
dynamo=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate(args: argparse.Namespace) -> None:
|
||||||
|
manifest_path = args.output / "model-manifest.json"
|
||||||
|
model_path = args.output / "onnx" / "model_quantized.onnx"
|
||||||
|
if not manifest_path.is_file() or not model_path.is_file(): raise SystemExit(f"Missing generated model files under {args.output}")
|
||||||
|
manifest = json.loads(manifest_path.read_text())
|
||||||
|
if manifest.get("quantization") != "int8-dynamic" or str(manifest.get("license")).lower() != "apache-2.0": raise SystemExit("Unexpected model quantization or license")
|
||||||
|
try:
|
||||||
|
import onnx
|
||||||
|
onnx.checker.check_model(str(model_path))
|
||||||
|
except ImportError as error:
|
||||||
|
raise SystemExit("Install tools with: python3 -m pip install -r tools/requirements-model.txt") from error
|
||||||
|
print(f"Validated {model_path} ({model_path.stat().st_size / 1024 / 1024:.1f} MiB)")
|
||||||
|
|
||||||
|
|
||||||
|
def run(command: list[str]) -> None:
|
||||||
|
print("+", " ".join(command))
|
||||||
|
subprocess.run(command, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = make_parser().parse_args()
|
||||||
|
if args.action == "inspect":
|
||||||
|
source, metadata = load_source(args.model, args.revision)
|
||||||
|
print(json.dumps({**metadata, **inspect_source(source)}, indent=2))
|
||||||
|
elif args.action == "prepare": prepare(args)
|
||||||
|
else: validate(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": main()
|
||||||
5
tools/requirements-model.txt
Normal file
5
tools/requirements-model.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
huggingface_hub>=0.27,<1
|
||||||
|
transformers>=4.48,<5
|
||||||
|
torch>=2.1,<3
|
||||||
|
onnx>=1.17,<2
|
||||||
|
onnxruntime>=1.20,<2
|
||||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "WebWorker"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["chrome"]
|
||||||
|
},
|
||||||
|
"include": ["src", "tests", "vite.config.ts"]
|
||||||
|
}
|
||||||
54
vite.config.ts
Normal file
54
vite.config.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { defineConfig, type Plugin } from "vite";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => ({
|
||||||
|
plugins: [manifestPlugin(mode)],
|
||||||
|
build: {
|
||||||
|
outDir: `dist/${mode}`,
|
||||||
|
// Background scripts and content scripts are classic scripts in Manifest V2.
|
||||||
|
// They are rebuilt individually below as self-contained IIFEs, after the
|
||||||
|
// regular ESM build has produced the worker and HTML-page bundles.
|
||||||
|
emptyOutDir: !process.env.VIBEGUARD_CLASSIC_ENTRY,
|
||||||
|
rollupOptions: {
|
||||||
|
input: inputsFor(mode),
|
||||||
|
output: {
|
||||||
|
entryFileNames: "[name].js",
|
||||||
|
chunkFileNames: "chunks/[name]-[hash].js",
|
||||||
|
assetFileNames: "assets/[name]-[hash][extname]",
|
||||||
|
format: process.env.VIBEGUARD_CLASSIC_ENTRY ? "iife" : "es",
|
||||||
|
inlineDynamicImports: Boolean(process.env.VIBEGUARD_CLASSIC_ENTRY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
include: ["tests/**/*.test.ts"]
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
function inputsFor(mode: string): Record<string, string> {
|
||||||
|
const classicEntry = process.env.VIBEGUARD_CLASSIC_ENTRY;
|
||||||
|
if (classicEntry === "content") return { content: "src/content/main.ts" };
|
||||||
|
if (classicEntry === "background") {
|
||||||
|
return { background: mode === "firefox" ? "src/background/firefox.ts" : "src/background/chromium.ts" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: "src/content/main.ts",
|
||||||
|
background: mode === "firefox" ? "src/background/firefox.ts" : "src/background/chromium.ts",
|
||||||
|
inference: "src/inference/worker.ts",
|
||||||
|
options: "src/options/main.ts",
|
||||||
|
offscreen: "src/background/offscreen.ts"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifestPlugin(mode: string): Plugin {
|
||||||
|
return {
|
||||||
|
name: "vibeguard-manifest",
|
||||||
|
generateBundle() {
|
||||||
|
const filename = mode === "firefox" ? "public/manifest.firefox.json" : "public/manifest.chromium.json";
|
||||||
|
this.emitFile({ type: "asset", fileName: "manifest.json", source: readFileSync(filename, "utf8") });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue