Add site definition worker inference
This commit is contained in:
parent
134157bc71
commit
3ccdaaf953
17 changed files with 250 additions and 50 deletions
|
|
@ -57,7 +57,9 @@ Transformers.js / ONNX Runtime Web
|
||||||
WASM CPU
|
WASM CPU
|
||||||
```
|
```
|
||||||
|
|
||||||
WebGPU inference may be investigated later, but CPU/WASM should be preferred initially to maximize browser compatibility and avoid unnecessary GPU utilization.
|
CPU/WASM is used for browser compatibility and to avoid unnecessary GPU utilization.
|
||||||
|
|
||||||
|
CPU WASM inference uses ONNX Runtime's browser-selected defaults.
|
||||||
|
|
||||||
## Site Integration
|
## Site Integration
|
||||||
|
|
||||||
|
|
@ -314,7 +316,6 @@ Downloading the model separately may substantially reduce extension package size
|
||||||
Possible later improvements include:
|
Possible later improvements include:
|
||||||
|
|
||||||
* Additional supported websites.
|
* Additional supported websites.
|
||||||
* WebGPU acceleration.
|
|
||||||
* Improved batching and scheduling.
|
* Improved batching and scheduling.
|
||||||
* Persistent classification cache.
|
* Persistent classification cache.
|
||||||
* Per-site thresholds.
|
* Per-site thresholds.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ VibeGuard is a local-first browser extension that hides toxic social-media posts
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Local toxicity classification using Transformers.js and a quantized ONNX model.
|
- Local toxicity classification using Transformers.js and a quantized ONNX model.
|
||||||
|
- Local CPU/WASM inference with ONNX Runtime.
|
||||||
- Configurable toxicity threshold and filtering mode: collapse posts with a Show button or hide them completely.
|
- Configurable toxicity threshold and filtering mode: collapse posts with a Show button or hide them completely.
|
||||||
- Optional toxicity scores on filtered-post placeholders.
|
- Optional toxicity scores on filtered-post placeholders.
|
||||||
- Declarative site definitions with URL patterns, selectors, and stable identifiers.
|
- Declarative site definitions with URL patterns, selectors, and stable identifiers.
|
||||||
|
|
@ -66,6 +67,8 @@ Supported page
|
||||||
|
|
||||||
The model is loaded once by the shared inference backend for each browser context. Content scripts send normalized text to the background runtime and apply the returned score to the matching DOM element. Results are cached in memory to avoid repeating work when dynamic sites recreate elements.
|
The model is loaded once by the shared inference backend for each browser context. Content scripts send normalized text to the background runtime and apply the returned score to the matching DOM element. Results are cached in memory to avoid repeating work when dynamic sites recreate elements.
|
||||||
|
|
||||||
|
Inference runs locally through ONNX Runtime's WASM backend using the bundled quantized model.
|
||||||
|
|
||||||
## Options page
|
## Options page
|
||||||
|
|
||||||
Open the extension’s options page to change the threshold, filtering mode, and score display. The Site definitions section manages JSON subscriptions, which refresh automatically once per week and can also be refreshed manually. Local overrides can be imported from or exported to JSON and are validated before they are saved. A failed or invalid subscription update leaves the last-known-good definitions active.
|
Open the extension’s options page to change the threshold, filtering mode, and score display. The Site definitions section manages JSON subscriptions, which refresh automatically once per week and can also be refreshed manually. Local overrides can be imported from or exported to JSON and are validated before they are saved. A failed or invalid subscription update leaves the last-known-good definitions active.
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,7 @@
|
||||||
import { createRuntime } from "./runtime";
|
import { createRuntime } from "./runtime";
|
||||||
import type { InferenceResult } from "../shared/types";
|
import { InferenceWorkerHost } from "./inference-worker";
|
||||||
|
|
||||||
const worker = new Worker(chrome.runtime.getURL("inference.js"), { type: "module" });
|
|
||||||
const modelBaseUrl = chrome.runtime.getURL("models/toxicity/");
|
const modelBaseUrl = chrome.runtime.getURL("models/toxicity/");
|
||||||
|
const inferenceWorker = new InferenceWorkerHost(() => new Worker(chrome.runtime.getURL("inference.js"), { type: "module" }), modelBaseUrl);
|
||||||
|
|
||||||
createRuntime((requests) => new Promise((resolve, reject) => {
|
createRuntime((requests) => inferenceWorker.run(requests));
|
||||||
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, modelBaseUrl });
|
|
||||||
}));
|
|
||||||
|
|
|
||||||
47
src/background/inference-worker.ts
Normal file
47
src/background/inference-worker.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import type { InferenceRequest, InferenceResult } from "../shared/types";
|
||||||
|
|
||||||
|
export interface InferenceWorker {
|
||||||
|
addEventListener(type: "message" | "error", listener: EventListener): void;
|
||||||
|
removeEventListener(type: "message" | "error", listener: EventListener): void;
|
||||||
|
postMessage(message: { requests: InferenceRequest[]; modelBaseUrl: string }): void;
|
||||||
|
terminate(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkerFactory = () => InferenceWorker;
|
||||||
|
type WorkerReply = { results?: InferenceResult[]; error?: string };
|
||||||
|
|
||||||
|
/** Keeps ONNX Runtime configuration immutable by replacing the worker on change. */
|
||||||
|
export class InferenceWorkerHost {
|
||||||
|
private worker: InferenceWorker | undefined;
|
||||||
|
constructor(private readonly createWorker: WorkerFactory, private readonly modelBaseUrl: string) {}
|
||||||
|
|
||||||
|
run(requests: InferenceRequest[]): Promise<InferenceResult[]> {
|
||||||
|
if (!this.worker) this.worker = this.createWorker();
|
||||||
|
const worker = this.worker;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const complete = (): void => {
|
||||||
|
worker.removeEventListener("message", onMessage);
|
||||||
|
worker.removeEventListener("error", onError);
|
||||||
|
};
|
||||||
|
const onMessage: EventListener = (event) => {
|
||||||
|
const reply = (event as MessageEvent<WorkerReply>).data;
|
||||||
|
complete();
|
||||||
|
if (reply.error) reject(new Error(reply.error));
|
||||||
|
else resolve(reply.results ?? []);
|
||||||
|
};
|
||||||
|
const onError: EventListener = (event) => {
|
||||||
|
complete();
|
||||||
|
const error = event as ErrorEvent;
|
||||||
|
reject(new Error(error.message || "Inference worker failed"));
|
||||||
|
};
|
||||||
|
worker.addEventListener("message", onMessage);
|
||||||
|
worker.addEventListener("error", onError);
|
||||||
|
worker.postMessage({ requests, modelBaseUrl: this.modelBaseUrl });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.worker?.terminate();
|
||||||
|
this.worker = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
|
import { InferenceWorkerHost } from "./inference-worker";
|
||||||
import type { RuntimeMessage } from "../shared/types";
|
import type { RuntimeMessage } from "../shared/types";
|
||||||
|
|
||||||
const worker = new Worker(chrome.runtime.getURL("inference.js"), { type: "module" });
|
|
||||||
const modelBaseUrl = chrome.runtime.getURL("models/toxicity/");
|
const modelBaseUrl = chrome.runtime.getURL("models/toxicity/");
|
||||||
|
const inferenceWorker = new InferenceWorkerHost(() => new Worker(chrome.runtime.getURL("inference.js"), { type: "module" }), modelBaseUrl);
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener((message: RuntimeMessage, _sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((message: RuntimeMessage, _sender, sendResponse) => {
|
||||||
if (message.type !== "OFFSCREEN_INFER") return false;
|
if (message.type !== "OFFSCREEN_INFER") return false;
|
||||||
const listener = (event: MessageEvent<{ results?: unknown; error?: string }>) => {
|
inferenceWorker.run(message.requests)
|
||||||
worker.removeEventListener("message", listener);
|
.then((results) => sendResponse({ results }))
|
||||||
if (event.data.error) sendResponse({ error: event.data.error });
|
.catch((error: unknown) => sendResponse({ error: error instanceof Error ? error.message : String(error) }));
|
||||||
else sendResponse({ results: event.data.results });
|
|
||||||
};
|
|
||||||
worker.addEventListener("message", listener);
|
|
||||||
worker.postMessage({ requests: message.requests, modelBaseUrl });
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,7 @@ import { refreshSubscriptions, subscriptionsAreDue } from "../shared/subscriptio
|
||||||
import type { InferenceRequest, InferenceResult, RuntimeMessage } from "../shared/types";
|
import type { InferenceRequest, InferenceResult, RuntimeMessage } from "../shared/types";
|
||||||
|
|
||||||
export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promise<InferenceResult[]>): void {
|
export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promise<InferenceResult[]>): void {
|
||||||
let sequence = 0;
|
const queue = new InferenceQueue(runBatch);
|
||||||
const queue = new InferenceQueue(async (requests) => {
|
|
||||||
const requestId = ++sequence;
|
|
||||||
return runBatch(requests);
|
|
||||||
});
|
|
||||||
|
|
||||||
const refreshIfDue = async (): Promise<void> => {
|
const refreshIfDue = async (): Promise<void> => {
|
||||||
const settings = await loadSettings();
|
const settings = await loadSettings();
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,23 @@ export class DefinitionEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectDefinition(definitions: SiteDefinition[], currentUrl = location.href, root: Document = document): SiteDefinition | undefined {
|
export function selectDefinition(definitions: SiteDefinition[], currentUrl = location.href, root: Document = document): SiteDefinition | undefined {
|
||||||
return definitions.find((definition) => matchesAnyUrlPattern(definition.urlPatterns, currentUrl) && definition.requiredSelectors.every((selector) => root.querySelector(selector) !== null));
|
return definitions.find((definition) => matchesDefinition(definition, currentUrl, root));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select a definition from the URL alone. Useful while an SPA is still hydrating. */
|
||||||
|
export function selectDefinitionForUrl(definitions: SiteDefinition[], currentUrl = location.href): SiteDefinition | undefined {
|
||||||
|
// A broad <all_urls> definition (such as Mastodon) still needs its marker
|
||||||
|
// selectors; otherwise it would claim every page during startup.
|
||||||
|
return definitions.find((definition) => definition.urlPatterns.some((pattern) => pattern !== "<all_urls>") && matchesAnyUrlPattern(definition.urlPatterns, currentUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a definition might apply after an SPA finishes rendering its markers. */
|
||||||
|
export function hasPotentialDefinitionForUrl(definitions: SiteDefinition[], currentUrl = location.href): boolean {
|
||||||
|
return definitions.some((definition) => matchesAnyUrlPattern(definition.urlPatterns, currentUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesDefinition(definition: SiteDefinition, currentUrl: string, root: Document): boolean {
|
||||||
|
return matchesAnyUrlPattern(definition.urlPatterns, currentUrl) && definition.requiredSelectors.every((selector) => root.querySelector(selector) !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchesAnyUrlPattern(patterns: string[], url: string): boolean {
|
export function matchesAnyUrlPattern(patterns: string[], url: string): boolean {
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,9 @@
|
||||||
"https://threads.net/*",
|
"https://threads.net/*",
|
||||||
"https://www.threads.net/*"
|
"https://www.threads.net/*"
|
||||||
],
|
],
|
||||||
"requiredSelectors": ["[role=\"article\"], article"],
|
"requiredSelectors": ["[role=\"article\"], article, div[data-pagelet^=\"threads_post_page_\"]"],
|
||||||
"post": {
|
"post": {
|
||||||
"rootSelectors": ["[role=\"article\"]", "article"],
|
"rootSelectors": ["[role=\"article\"]", "article", "div[data-pagelet^=\"threads_post_page_\"]"],
|
||||||
"textSelectors": ["span[dir=\"auto\"]"],
|
"textSelectors": ["span[dir=\"auto\"]"],
|
||||||
"excludedSelectors": ["button", "[role=\"button\"]", "time", "svg", "a span", "time span", "[role=\"button\"] span"],
|
"excludedSelectors": ["button", "[role=\"button\"]", "time", "svg", "a span", "time span", "[role=\"button\"] span"],
|
||||||
"permalinkSelectors": ["a[href*=\"/post/\"]"]
|
"permalinkSelectors": ["a[href*=\"/post/\"]"]
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { effectiveDefinitions } from "./definitions";
|
import { effectiveDefinitions } from "./definitions";
|
||||||
import { DefinitionEngine, selectDefinition } from "./definition-engine";
|
import { DefinitionEngine, hasPotentialDefinitionForUrl, selectDefinition, selectDefinitionForUrl } from "./definition-engine";
|
||||||
import { applyResult, hasAppliedFilter, removeOrphanedPlaceholders, restore } from "./filter";
|
import { applyResult, hasAppliedFilter, removeOrphanedPlaceholders, restore } from "./filter";
|
||||||
import { loadSettings } from "../shared/settings";
|
import { loadSettings } from "../shared/settings";
|
||||||
import { hashText } from "../shared/hash";
|
import { hashText } from "../shared/hash";
|
||||||
import { priorityFor } from "../inference/queue";
|
import { priorityFor } from "../inference/queue";
|
||||||
import type { InferenceRequest, PageStatus, RuntimeMessage, Settings } from "../shared/types";
|
import type { InferenceRequest, InferenceResponse, PageStatus, RuntimeMessage, Settings } from "../shared/types";
|
||||||
|
|
||||||
void start();
|
void start();
|
||||||
|
|
||||||
|
|
@ -45,10 +45,37 @@ async function start(): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
function runDefinition(settings: Settings, sequence: number): () => void {
|
function runDefinition(settings: Settings, sequence: number): () => void {
|
||||||
const definition = selectDefinition(effectiveDefinitions(settings));
|
const definitions = effectiveDefinitions(settings);
|
||||||
if (!definition) return () => {};
|
let stopCurrentDefinition = noop;
|
||||||
console.debug("[VibeGuard] Supported page detected", { definitionId: definition.id, definitionName: definition.name, url: location.href });
|
let stopped = false;
|
||||||
const parser = new DefinitionEngine(definition);
|
const definitionObserver = new MutationObserver(() => {
|
||||||
|
if (stopped || stopCurrentDefinition !== noop) return;
|
||||||
|
const definition = selectDefinition(definitions);
|
||||||
|
if (definition) {
|
||||||
|
stopCurrentDefinition = startDefinition(definition);
|
||||||
|
definitionObserver.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialDefinition = selectDefinition(definitions);
|
||||||
|
if (initialDefinition) {
|
||||||
|
stopCurrentDefinition = startDefinition(initialDefinition);
|
||||||
|
} else if (hasPotentialDefinitionForUrl(definitions)) {
|
||||||
|
// Social sites can add their marker elements after document_idle. This also
|
||||||
|
// covers instance-based sites (such as Mastodon) whose URL pattern is broad.
|
||||||
|
definitionObserver.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
definitionObserver.disconnect();
|
||||||
|
stopCurrentDefinition();
|
||||||
|
};
|
||||||
|
|
||||||
|
function startDefinition(definition: ReturnType<typeof selectDefinition>): () => void {
|
||||||
|
if (!definition) return noop;
|
||||||
|
console.debug("[VibeGuard] Supported page detected", { definitionId: definition.id, definitionName: definition.name, url: location.href });
|
||||||
|
const parser = new DefinitionEngine(definition);
|
||||||
const navigationId = crypto.randomUUID();
|
const navigationId = crypto.randomUUID();
|
||||||
const fingerprints = new WeakMap<Element, string>();
|
const fingerprints = new WeakMap<Element, string>();
|
||||||
const results = new Map<string, Parameters<typeof applyResult>[1]>();
|
const results = new Map<string, Parameters<typeof applyResult>[1]>();
|
||||||
|
|
@ -104,11 +131,23 @@ function runDefinition(settings: Settings, sequence: number): () => void {
|
||||||
priority: priorityFor(isVisible(post.element), document.visibilityState === "visible"),
|
priority: priorityFor(isVisible(post.element), document.visibilityState === "visible"),
|
||||||
requestId: crypto.randomUUID()
|
requestId: crypto.randomUUID()
|
||||||
};
|
};
|
||||||
void chrome.runtime.sendMessage<RuntimeMessage, { type: "INFERENCE_RESULT"; result: Parameters<typeof applyResult>[1] }>({ type: "INFER", request })
|
void chrome.runtime.sendMessage<RuntimeMessage, InferenceResponse>({ type: "INFER", request })
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
|
if (response && "error" in response) {
|
||||||
|
console.error("[VibeGuard] Post analysis failed", { definitionId: definition.id, id: post.id, requestId: request.requestId, error: response.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (stopped || sequence !== currentReloadSequence || !response?.result || !post.element.isConnected) return;
|
if (stopped || sequence !== currentReloadSequence || !response?.result || !post.element.isConnected) return;
|
||||||
results.set(post.id, response.result);
|
results.set(post.id, response.result);
|
||||||
reconcileFilters();
|
reconcileFilters();
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error("[VibeGuard] Post analysis failed", {
|
||||||
|
definitionId: definition.id,
|
||||||
|
id: post.id,
|
||||||
|
requestId: request.requestId,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
reconcileFilters();
|
reconcileFilters();
|
||||||
|
|
@ -116,17 +155,21 @@ function runDefinition(settings: Settings, sequence: number): () => void {
|
||||||
|
|
||||||
process(parser.discover(document), "initial");
|
process(parser.discover(document), "initial");
|
||||||
const stopObserver = parser.observe((posts) => process(posts, "mutation"));
|
const stopObserver = parser.observe((posts) => process(posts, "mutation"));
|
||||||
return () => {
|
return () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
stopObserver();
|
stopObserver();
|
||||||
parser.dispose();
|
parser.dispose();
|
||||||
};
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const noop = (): void => {};
|
||||||
|
|
||||||
let currentReloadSequence = 0;
|
let currentReloadSequence = 0;
|
||||||
|
|
||||||
async function getPageStatus(settings: Settings): Promise<PageStatus> {
|
async function getPageStatus(settings: Settings): Promise<PageStatus> {
|
||||||
const definition = selectDefinition(effectiveDefinitions({ ...settings, disabledDefinitionIds: [] }));
|
const definitions = effectiveDefinitions({ ...settings, disabledDefinitionIds: [] });
|
||||||
|
const definition = selectDefinition(definitions) ?? selectDefinitionForUrl(definitions);
|
||||||
if (!definition) {
|
if (!definition) {
|
||||||
console.debug("[VibeGuard] No matching definition for page status", { url: location.href });
|
console.debug("[VibeGuard] No matching definition for page status", { url: location.href });
|
||||||
return { supported: false, paused: false };
|
return { supported: false, paused: false };
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import type { InferenceRequest, InferenceResult } from "../shared/types";
|
||||||
import { loadModelManifest, type ModelManifest } from "./model-metadata";
|
import { loadModelManifest, type ModelManifest } from "./model-metadata";
|
||||||
|
|
||||||
let classifier: TextClassificationPipeline | undefined;
|
let classifier: TextClassificationPipeline | undefined;
|
||||||
let classifierPromise: Promise<TextClassificationPipeline> | undefined;
|
|
||||||
let manifest: ModelManifest | undefined;
|
let manifest: ModelManifest | undefined;
|
||||||
|
|
||||||
// Transformers.js disables local files in browser workers by default. Our model
|
// Transformers.js disables local files in browser workers by default. Our model
|
||||||
|
|
@ -44,8 +43,7 @@ export async function classify(requests: InferenceRequest[], modelBaseUrl: strin
|
||||||
async function loadClassifier(modelBaseUrl: string): Promise<TextClassificationPipeline> {
|
async function loadClassifier(modelBaseUrl: string): Promise<TextClassificationPipeline> {
|
||||||
if (!env.backends.onnx.wasm) throw new Error("ONNX WASM backend is unavailable.");
|
if (!env.backends.onnx.wasm) throw new Error("ONNX WASM backend is unavailable.");
|
||||||
env.backends.onnx.wasm.wasmPaths = { wasm: new URL("../../ort/ort-wasm-simd-threaded.jsep.wasm", modelBaseUrl).href };
|
env.backends.onnx.wasm.wasmPaths = { wasm: new URL("../../ort/ort-wasm-simd-threaded.jsep.wasm", modelBaseUrl).href };
|
||||||
classifierPromise ??= (pipeline as unknown as (task: string, model: string, options: { device: string; local_files_only: boolean }) => Promise<TextClassificationPipeline>)("text-classification", modelBaseUrl, { device: "wasm", local_files_only: true });
|
return (pipeline as unknown as (task: string, model: string, options: { device: "wasm"; dtype: "q8"; local_files_only: boolean }) => Promise<TextClassificationPipeline>)("text-classification", modelBaseUrl, { device: "wasm", dtype: "q8", local_files_only: true });
|
||||||
return classifierPromise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveOutputIndex(label: string | undefined, metadata: ModelManifest): number | undefined {
|
export function resolveOutputIndex(label: string | undefined, metadata: ModelManifest): number | undefined {
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ form?.addEventListener("submit", async (event) => {
|
||||||
} catch (error) { showStatus(error instanceof Error ? error.message : String(error), true); }
|
} catch (error) { showStatus(error instanceof Error ? error.message : String(error), true); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
importButton?.addEventListener("click", () => definitionFile?.click());
|
importButton?.addEventListener("click", () => definitionFile?.click());
|
||||||
definitionFile?.addEventListener("change", async () => {
|
definitionFile?.addEventListener("change", async () => {
|
||||||
const file = definitionFile.files?.[0];
|
const file = definitionFile.files?.[0];
|
||||||
|
|
|
||||||
|
|
@ -42,18 +42,23 @@ function setStorageItem(items: Record<string, unknown>): Promise<void> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeSettings(input?: Partial<Settings>): Settings {
|
type StoredSettings = Partial<Settings> & { cpuThreads?: unknown; inferenceDevice?: unknown };
|
||||||
const subscriptions = input?.subscriptions?.length ? input.subscriptions : [DEFAULT_SUBSCRIPTION];
|
|
||||||
|
export function mergeSettings(input?: StoredSettings): Settings {
|
||||||
|
// CPU thread selection was removed. Do not carry the legacy value forward
|
||||||
|
// when an existing installation saves its settings.
|
||||||
|
const { cpuThreads: _legacyCpuThreads, inferenceDevice: _legacyInferenceDevice, ...current } = input ?? {};
|
||||||
|
const subscriptions = current.subscriptions?.length ? current.subscriptions : [DEFAULT_SUBSCRIPTION];
|
||||||
const definitionConfiguration = validateDefinitionConfiguration({
|
const definitionConfiguration = validateDefinitionConfiguration({
|
||||||
customDefinitions: input?.customDefinitions ?? DEFAULT_SETTINGS.customDefinitions,
|
customDefinitions: current.customDefinitions ?? DEFAULT_SETTINGS.customDefinitions,
|
||||||
disabledDefinitionIds: input?.disabledDefinitionIds ?? DEFAULT_SETTINGS.disabledDefinitionIds,
|
disabledDefinitionIds: current.disabledDefinitionIds ?? DEFAULT_SETTINGS.disabledDefinitionIds,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
subscriptionCollections: input?.subscriptionCollections ?? DEFAULT_SETTINGS.subscriptionCollections
|
subscriptionCollections: current.subscriptionCollections ?? DEFAULT_SETTINGS.subscriptionCollections
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
...input,
|
...current,
|
||||||
threshold: clamp(Number(input?.threshold ?? DEFAULT_SETTINGS.threshold), 0, 1),
|
threshold: clamp(Number(current.threshold ?? DEFAULT_SETTINGS.threshold), 0, 1),
|
||||||
customDefinitions: definitionConfiguration.valid ? definitionConfiguration.value.customDefinitions : DEFAULT_SETTINGS.customDefinitions,
|
customDefinitions: definitionConfiguration.valid ? definitionConfiguration.value.customDefinitions : DEFAULT_SETTINGS.customDefinitions,
|
||||||
disabledDefinitionIds: definitionConfiguration.valid ? definitionConfiguration.value.disabledDefinitionIds : DEFAULT_SETTINGS.disabledDefinitionIds,
|
disabledDefinitionIds: definitionConfiguration.valid ? definitionConfiguration.value.disabledDefinitionIds : DEFAULT_SETTINGS.disabledDefinitionIds,
|
||||||
subscriptions: definitionConfiguration.valid ? definitionConfiguration.value.subscriptions : [DEFAULT_SUBSCRIPTION],
|
subscriptions: definitionConfiguration.valid ? definitionConfiguration.value.subscriptions : [DEFAULT_SUBSCRIPTION],
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,10 @@ export interface InferenceResult {
|
||||||
modelDurationMs?: number;
|
modelDurationMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type InferenceResponse =
|
||||||
|
| { type: "INFERENCE_RESULT"; result: InferenceResult }
|
||||||
|
| { error: string };
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
threshold: number;
|
threshold: number;
|
||||||
filterMode: FilterMode;
|
filterMode: FilterMode;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { DefinitionEngine, matchesUrlPattern, selectDefinition } from "../src/content/definition-engine";
|
import { DefinitionEngine, hasPotentialDefinitionForUrl, matchesUrlPattern, selectDefinition, selectDefinitionForUrl } from "../src/content/definition-engine";
|
||||||
import { BUILTIN_DEFINITIONS } from "../src/content/definitions";
|
import { BUILTIN_DEFINITIONS } from "../src/content/definitions";
|
||||||
import type { SiteDefinition } from "../src/shared/types";
|
import type { SiteDefinition } from "../src/shared/types";
|
||||||
|
|
||||||
|
|
@ -84,6 +84,17 @@ describe("DefinitionEngine", () => {
|
||||||
expect(posts.map((post) => post.text)).toEqual(["first threads post with visible text", "a reply in the conversation"]);
|
expect(posts.map((post) => post.text)).toEqual(["first threads post with visible text", "a reply in the conversation"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("discovers Threads detail-page posts from pagelet containers", () => {
|
||||||
|
document.body.innerHTML = readFileSync("tests/fixtures/threads-post-page.html", "utf8");
|
||||||
|
const threads = BUILTIN_DEFINITIONS.find((candidate) => candidate.id === "threads")!;
|
||||||
|
const posts = new DefinitionEngine(threads).discover(document);
|
||||||
|
expect(posts.map((post) => post.id)).toEqual([
|
||||||
|
"threads:http://localhost:3000/@alice/post/ABC123",
|
||||||
|
"threads:http://localhost:3000/@bob/post/DEF456"
|
||||||
|
]);
|
||||||
|
expect(posts.map((post) => post.text)).toEqual(["the main post on the threads detail page", "a reply on the threads detail page"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("selects Threads on current and legacy domains", () => {
|
it("selects Threads on current and legacy domains", () => {
|
||||||
const threads = BUILTIN_DEFINITIONS.find((candidate) => candidate.id === "threads")!;
|
const threads = BUILTIN_DEFINITIONS.find((candidate) => candidate.id === "threads")!;
|
||||||
document.body.innerHTML = '<div role="article" data-pressable-container="true"></div>';
|
document.body.innerHTML = '<div role="article" data-pressable-container="true"></div>';
|
||||||
|
|
@ -92,4 +103,23 @@ describe("DefinitionEngine", () => {
|
||||||
}
|
}
|
||||||
expect(selectDefinition([threads], "https://example.com/home")).toBeUndefined();
|
expect(selectDefinition([threads], "https://example.com/home")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("can identify a specific site while its SPA markers are still loading", () => {
|
||||||
|
const threads = BUILTIN_DEFINITIONS.find((candidate) => candidate.id === "threads")!;
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
expect(selectDefinition([threads], "https://www.threads.com/")).toBeUndefined();
|
||||||
|
expect(selectDefinitionForUrl([BUILTIN_DEFINITIONS[0]!, threads], "https://www.threads.com/")).toBe(threads);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps broad definitions pending until their hydration markers appear", () => {
|
||||||
|
const mastodon = BUILTIN_DEFINITIONS.find((candidate) => candidate.id === "mastodon")!;
|
||||||
|
const threads = BUILTIN_DEFINITIONS.find((candidate) => candidate.id === "threads")!;
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
expect(selectDefinition([mastodon], "https://mastodon.example/@user")).toBeUndefined();
|
||||||
|
expect(selectDefinitionForUrl([mastodon], "https://mastodon.example/@user")).toBeUndefined();
|
||||||
|
expect(hasPotentialDefinitionForUrl([mastodon], "https://mastodon.example/@user")).toBe(true);
|
||||||
|
expect(hasPotentialDefinitionForUrl([threads], "https://example.com/")).toBe(false);
|
||||||
|
document.body.innerHTML = '<div class="status"><div class="status__content">Ready</div></div>';
|
||||||
|
expect(selectDefinition([mastodon], "https://mastodon.example/@user")).toBe(mastodon);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
10
tests/fixtures/threads-post-page.html
vendored
Normal file
10
tests/fixtures/threads-post-page.html
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<main>
|
||||||
|
<div data-pagelet="threads_post_page_0">
|
||||||
|
<a href="/@alice/post/ABC123"><span dir="auto">12h</span></a>
|
||||||
|
<span dir="auto">The main post on the Threads detail page</span>
|
||||||
|
</div>
|
||||||
|
<div data-pagelet="threads_post_page_1">
|
||||||
|
<a href="/@bob/post/DEF456"><span dir="auto">1h</span></a>
|
||||||
|
<span dir="auto">A reply on the Threads detail page</span>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
50
tests/inference-worker.test.ts
Normal file
50
tests/inference-worker.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { InferenceWorkerHost, type InferenceWorker } from "../src/background/inference-worker";
|
||||||
|
import type { InferenceRequest } from "../src/shared/types";
|
||||||
|
|
||||||
|
const request: InferenceRequest = { id: "post", text: "text", site: "threads", navigationId: "nav", priority: 0, requestId: "request" };
|
||||||
|
class FakeWorker implements InferenceWorker {
|
||||||
|
readonly listeners = new Map<string, EventListener[]>();
|
||||||
|
readonly requests: Array<{ requests: InferenceRequest[]; modelBaseUrl: string }> = [];
|
||||||
|
terminated = false;
|
||||||
|
|
||||||
|
addEventListener(type: "message" | "error", listener: EventListener): void {
|
||||||
|
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEventListener(type: "message" | "error", listener: EventListener): void {
|
||||||
|
this.listeners.set(type, (this.listeners.get(type) ?? []).filter((candidate) => candidate !== listener));
|
||||||
|
}
|
||||||
|
|
||||||
|
postMessage(message: { requests: InferenceRequest[]; modelBaseUrl: string }): void {
|
||||||
|
this.requests.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
terminate(): void { this.terminated = true; }
|
||||||
|
|
||||||
|
reply(): void {
|
||||||
|
const event = new MessageEvent("message", { data: { results: [{ requestId: request.requestId, id: request.id, textHash: "hash", label: "not_toxic", probability: .1, navigationId: request.navigationId }] } });
|
||||||
|
this.listeners.get("message")?.forEach((listener) => listener(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("InferenceWorkerHost", () => {
|
||||||
|
it("reuses its worker", async () => {
|
||||||
|
const workers: FakeWorker[] = [];
|
||||||
|
const host = new InferenceWorkerHost(() => {
|
||||||
|
const worker = new FakeWorker();
|
||||||
|
workers.push(worker);
|
||||||
|
return worker;
|
||||||
|
}, "chrome-extension://id/models/toxicity/");
|
||||||
|
|
||||||
|
const first = host.run([request]);
|
||||||
|
workers[0]!.reply();
|
||||||
|
await first;
|
||||||
|
const second = host.run([request]);
|
||||||
|
workers[0]!.reply();
|
||||||
|
await second;
|
||||||
|
expect(workers).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(workers[0]!.requests).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { setDefinitionPaused } from "../src/shared/settings";
|
import { mergeSettings, setDefinitionPaused } from "../src/shared/settings";
|
||||||
import { DEFAULT_SETTINGS } from "../src/shared/types";
|
import { DEFAULT_SETTINGS } from "../src/shared/types";
|
||||||
|
|
||||||
describe("definition pause settings", () => {
|
describe("definition pause settings", () => {
|
||||||
|
|
@ -18,3 +18,10 @@ describe("definition pause settings", () => {
|
||||||
expect(settings.disabledDefinitionIds).toEqual(["mastodon"]);
|
expect(settings.disabledDefinitionIds).toEqual(["mastodon"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("legacy settings", () => {
|
||||||
|
it("removes legacy inference settings", () => {
|
||||||
|
expect(mergeSettings({ cpuThreads: 99 })).not.toHaveProperty("cpuThreads");
|
||||||
|
expect(mergeSettings({ inferenceDevice: "webgpu" } as never)).not.toHaveProperty("inferenceDevice");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue