diff --git a/docs/providers-and-models.md b/docs/providers-and-models.md index 163240de0..d5b6d3db0 100644 --- a/docs/providers-and-models.md +++ b/docs/providers-and-models.md @@ -6,13 +6,49 @@ **Settings → Providers** selects the main model for conversation, planning and final replies. **Settings → Assistive Models** groups Vision (including screenshot -limits and redaction), Speech to text, and Jev (TypeSafe). Configuring an assistive +limits and redaction), Speech to text, Jev (TypeSafe), and SafeSocial. Configuring an assistive model does not replace the active provider. Jev is outside the dynamic provider list; its verification, fast-classification and experimental browser switches are independent opt-ins. See [the settings guide](https://webbrain.one/docs/settings/#multimodal) and [data flow](privacy-and-data-flow.md#optional-jev-typesafe-scheduled-task-verification) for setup and disclosure details. Existing `#multimodal` settings links still work. +### SafeSocial image classifier (experimental) + +**Settings → Assistive Models → SafeSocial** optionally filters Instagram images +and video posters using the local, multilabel +[EfficientNet-Lite0 classifier](https://huggingface.co/webbrain-one/safesocial-trigger-classifier-efficientnet-lite0). +It is off by default and does not replace the chat or screenshot vision model. +Choose categories, an absolute score threshold (default 95%), and blur, hide, +dim, or warning. Every filtered image has a reveal button. Videos without a +poster and video frames are not classified in this first integration. + +Enabling downloads approximately 13.6 MB of model data from Hugging Face. Files +are pinned to revision `d39182d06486b237ba33bc675b9302a206182460`, verified with +SHA-256 and cached in this browser. JavaScript/WASM uses the existing packaged +ONNX runtime; no remote executable code or bundled model weights are added. +Inference runs in a dedicated CPU/WASM worker on both Chrome and Firefox. +Images are fetched from Instagram's image CDNs without cookies and processed +locally; they are not uploaded to Hugging Face or an inference service. + +Turning SafeSocial off cancels pending work, unloads the worker and restores +filtered media. Cached weights remain until **Remove downloaded model** is +clicked while disabled. A failed download or inference leaves media unchanged +and displays an error; there is no mock/keyword fallback. This is an experimental +social-comparison classifier, not a general content-safety or NSFW detector. +Scores can be wrong. English and Turkish copy is provided, with English fallback +for other interface languages. + +Validation: `npm run test:safesocial` covers configuration, URL/caller gates, +cancellation and Chrome/Firefox parity. `npm run test:safesocial:ui` exercises +responsive settings and media lifecycle in both browsers. Set +`SAFESOCIAL_MODEL_DIR` to a directory containing the pinned bundle under +`webbrain-safesocial-model.onnx` and `webbrain-safesocial-model.json` to also run +real ONNX/WASM inference in that browser test without live network downloads. +With the same bundle, `npm run test:safesocial:extension` checks the complete +Chrome MV3 settings/background/offscreen path in an isolated browser profile, +including cached reactivation and model removal. + ## Provider Interface (`providers/base.js`) Every LLM provider implements the `BaseLLMProvider` interface: diff --git a/package.json b/package.json index 9f060779e..886eaa825 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "private": true, "type": "module", "scripts": { - "test": "npm run test:systemone && npm run test:systemone:fast && node --test test/browser-dialogs.mjs && npm run test:firefox-bidi && npm run test:runtime-lifecycle && npm run test:provider-limits && npm run test:accessibility-tree-benchmark && npm run test:toolbar-guard && npm run test:pdf-read && npm run test:pdf-selection && npm run test:social-contract && npm run test:build-unpacked && npm run test:attachment-drop && node test/run.js && node test/selection-scope-restoration.mjs && node scripts/benchmark-offline-relevance.mjs && npm run test:security", + "test": "npm run test:systemone && npm run test:systemone:fast && node --test test/browser-dialogs.mjs && npm run test:firefox-bidi && npm run test:runtime-lifecycle && npm run test:provider-limits && npm run test:accessibility-tree-benchmark && npm run test:toolbar-guard && npm run test:pdf-read && npm run test:pdf-selection && npm run test:social-contract && npm run test:safesocial && npm run test:build-unpacked && npm run test:attachment-drop && node test/run.js && node test/selection-scope-restoration.mjs && node scripts/benchmark-offline-relevance.mjs && npm run test:security", "test:provider-limits": "node test/provider-model-limits.mjs", "test:accessibility-tree-benchmark": "node --test test/llm/lib/accessibility-tree-formats.test.mjs && node test/llm/accessibility-tree-benchmark.mjs --no-exact-tokenizer --check", "test:attachment-drop": "node test/attachment-drop.mjs", @@ -51,7 +51,10 @@ "test:systemone": "node --test test/systemone.mjs", "test:systemone:ui": "node test/systemone-ui.mjs", "test:systemone:fast": "node --test test/systemone-fast.mjs && node test/systemone-fast-dom.mjs && node --test test/jev/fixtures.test.mjs", - "benchmark:jev": "node test/jev/benchmark.mjs" + "benchmark:jev": "node test/jev/benchmark.mjs", + "test:safesocial": "node --test test/safesocial.mjs", + "test:safesocial:ui": "node test/safesocial-ui.mjs", + "test:safesocial:extension": "node test/safesocial-extension.mjs" }, "devDependencies": { "playwright": "^1.48.0", diff --git a/src/chrome/manifest.json b/src/chrome/manifest.json index f0a629035..a9f8d9111 100644 --- a/src/chrome/manifest.json +++ b/src/chrome/manifest.json @@ -89,6 +89,19 @@ "js": ["src/agent/social-media-downloader.js"], "run_at": "document_start", "world": "MAIN" + }, + { + "matches": [ + "https://www.instagram.com/*", + "https://instagram.com/*" + ], + "js": [ + "src/safesocial/content.js" + ], + "css": [ + "src/safesocial/content.css" + ], + "run_at": "document_idle" } ], "options_page": "src/ui/settings.html", diff --git a/src/chrome/src/background.js b/src/chrome/src/background.js index 84e6bb67e..879769a33 100644 --- a/src/chrome/src/background.js +++ b/src/chrome/src/background.js @@ -1,3 +1,4 @@ +import { installSafeSocialBackground } from './safesocial/background.js'; import { ProviderManager } from './providers/manager.js'; import { WEBGPU_COMPASS_TINY_V2_MODEL_ID, @@ -118,6 +119,21 @@ import { * Routes messages between side panel, content scripts, and the agent. */ +// SafeSocial never creates an inference worker until an opted-in request arrives. +let safeSocialHostOpening; +installSafeSocialBackground(chrome, async (command, payload = {}) => { + if (command === 'stop') { + if (safeSocialHostOpening) await safeSocialHostOpening; + if (!await chrome.offscreen.hasDocument()) return { status: 'idle' }; + } else { + safeSocialHostOpening ||= ensureOffscreen().finally(() => { safeSocialHostOpening = null; }); + await safeSocialHostOpening; + } + const result = await chrome.runtime.sendMessage({ target: 'safesocial-host', command, ...payload }); + if (!result?.ok) throw new Error(result?.error || 'Classifier host unavailable.'); + return result; +}); + const providerManager = new ProviderManager(); const apocalypseController = createApocalypseController(chrome); const VISION_OFFSCREEN_URL = chrome.runtime.getURL('src/offscreen/offscreen.html'); diff --git a/src/chrome/src/config-transfer.js b/src/chrome/src/config-transfer.js index d0c90d260..1aac9a21b 100644 --- a/src/chrome/src/config-transfer.js +++ b/src/chrome/src/config-transfer.js @@ -11,6 +11,7 @@ import { USER_MEMORY_STORAGE_KEY, } from './agent/user-memory.js'; import { AUTO_GROUP_TABS_KEY } from './tab-group-preference.js'; +import { normalizeSettings as normalizeSafeSocialSettings, SETTINGS_KEY as SAFE_SOCIAL_SETTINGS_KEY } from './safesocial/config.js'; import { normalizeUiScale, UI_SCALE_STORAGE_KEY } from './ui/ui-scale.js'; export const CONFIG_SCHEMA = 'webbrain-config/1'; @@ -85,6 +86,7 @@ export const DEFAULT_CONFIG_SETTINGS = Object.freeze({ systemOneWatchThreshold: 0.7, systemOneCompletionThreshold: 0.7, typesafeApiKey: '', + [SAFE_SOCIAL_SETTINGS_KEY]: normalizeSafeSocialSettings(), }); export const CONFIG_STORAGE_KEYS = Object.freeze(Object.keys(DEFAULT_CONFIG_SETTINGS)); @@ -190,7 +192,7 @@ function validSettingValue(key, value) { if (STRING_KEYS.has(key)) return typeof value === 'string'; if (ARRAY_KEYS.has(key)) return Array.isArray(value); if (NULLABLE_OBJECT_KEYS.has(key)) return value === null || isPlainObject(value); - if (key === 'providers' || key === USER_MEMORY_STORAGE_KEY) return isPlainObject(value); + if (key === 'providers' || key === USER_MEMORY_STORAGE_KEY || key === SAFE_SOCIAL_SETTINGS_KEY) return isPlainObject(value); return true; } @@ -208,6 +210,14 @@ function normalizeSettings(source, { strict = false } = {}) { settings[key] = normalizeUiScale(value); continue; } + if (key === SAFE_SOCIAL_SETTINGS_KEY) { + if (!isPlainObject(value)) { + if (strict) throw new Error(`Invalid value for configuration setting "${key}".`); + continue; + } + settings[key] = normalizeSafeSocialSettings(value); + continue; + } if (!validSettingValue(key, value)) { if (strict) throw new Error(`Invalid value for configuration setting "${key}".`); continue; @@ -287,6 +297,8 @@ export function parseConfigPatchImport(json) { } settings[key] = key === UI_SCALE_STORAGE_KEY ? normalizeUiScale(value) + : key === SAFE_SOCIAL_SETTINGS_KEY + ? normalizeSafeSocialSettings(value) : key === 'providers' ? sanitizeProviders(value, { strict: true }) : clone(value); diff --git a/src/chrome/src/offscreen/offscreen.html b/src/chrome/src/offscreen/offscreen.html index d53ba0b9f..a19e8f458 100644 --- a/src/chrome/src/offscreen/offscreen.html +++ b/src/chrome/src/offscreen/offscreen.html @@ -24,6 +24,7 @@ AUDIO_PLAYBACK (watch alerts). --> + diff --git a/src/chrome/src/safesocial/background.js b/src/chrome/src/safesocial/background.js new file mode 100644 index 000000000..a4507a45f --- /dev/null +++ b/src/chrome/src/safesocial/background.js @@ -0,0 +1,53 @@ +import { SETTINGS_KEY, CACHE_NAME, normalizeSettings, isInstagramUrl, isMediaUrl, matchingLabels } from './config.js'; + +export function installSafeSocialBackground(api, runHost) { + let generation = 0; + let transition = Promise.resolve(); + const settingsUrl = api.runtime.getURL('src/ui/settings.html'); + api.storage.onChanged.addListener((changes, area) => { + if (area !== 'local' || !changes[SETTINGS_KEY]) return; + generation++; + // Serialize stop against a rapid off/on toggle and pending host creation. + if (changes[SETTINGS_KEY].newValue?.enabled !== true) { + transition = transition.then(() => runHost('stop')).catch(() => {}); + } + }); + async function handle(message, sender) { + if (sender.id !== api.runtime.id) throw new Error('Invalid classifier caller.'); + const trustedSettings = String(sender.url || '').split(/[?#]/)[0] === settingsUrl; + if (message.command === 'classify') { + if (!sender.tab || sender.frameId !== 0 || !isInstagramUrl(sender.url) || !isMediaUrl(message.url)) { + throw new Error('Classifier is limited to Instagram images.'); + } + } else if (!trustedSettings || !['prepare', 'status', 'remove'].includes(message.command)) { + throw new Error('Open SafeSocial settings to manage the classifier.'); + } + await transition; + const epoch = generation; + const settings = normalizeSettings((await api.storage.local.get(SETTINGS_KEY))[SETTINGS_KEY]); + if (message.command === 'remove') { + if (settings.enabled) throw new Error('Disable SafeSocial before removing its model.'); + if (epoch !== generation) return { ok: false, disabled: true, status: 'disabled' }; + // A new enable/prepare must wait until deletion has completed. + const removal = transition.then(async () => { + await runHost('stop'); + await caches.delete(CACHE_NAME); + }); + transition = removal.catch(() => {}); + await removal; + return { ok: true, status: 'idle' }; + } + if (!settings.enabled || epoch !== generation) return { ok: false, disabled: true, status: 'disabled' }; + const result = await runHost(message.command, { url: message.url }); + // A settings change or disabling the feature invalidates every queued result. + if (epoch !== generation) return { ok: false, disabled: true, status: 'disabled' }; + return { ok: true, ...result, ...(message.command === 'classify' + ? { labels: matchingLabels(result.scores, settings), action: settings.action } : {}) }; + } + api.runtime.onMessage.addListener((message, sender, respond) => { + if (message?.target !== 'safesocial') return false; + handle(message, sender).then(respond, error => respond({ ok: false, error: error.message })); + return true; + }); + return { handle }; +} diff --git a/src/chrome/src/safesocial/config.js b/src/chrome/src/safesocial/config.js new file mode 100644 index 000000000..4f066cb7b --- /dev/null +++ b/src/chrome/src/safesocial/config.js @@ -0,0 +1,68 @@ +/** SafeSocial is an optional, local multilabel image classifier, not a chat provider. */ +export const SETTINGS_KEY = 'safeSocialSettings'; +export const CACHE_NAME = 'webbrain-safesocial-v1'; +export const MODEL_ID = 'webbrain-one/safesocial-trigger-classifier-efficientnet-lite0'; +export const MODEL_REVISION = 'd39182d06486b237ba33bc675b9302a206182460'; +export const MODEL_BASE = `https://huggingface.co/${MODEL_ID}/resolve/${MODEL_REVISION}/`; +export const MODEL_FILES = Object.freeze({ + 'safesocial-model.json': { bytes: 6260, sha256: '650152199b9b17426f2c40b03d237c3c6bed88101c069da0f38444ef563cee14' }, + 'model.onnx': { bytes: 13584716, sha256: 'fd37c1cd4aafa2bc318b7d29725fa6be3931d09cc1723bb30be3ee819ee22933' }, +}); +export const LABELS = Object.freeze([ + 'romance_jealousy', 'social_fomo', 'luxury_status', 'travel_lifestyle', + 'body_beauty_comparison', 'achievement_status', 'social_proof_popularity', 'exclusivity_access', +]); +export const DEFAULT_LABELS = Object.freeze(Object.fromEntries(LABELS.map(label => [label, + ['romance_jealousy', 'luxury_status', 'travel_lifestyle'].includes(label)]))); + +export function normalizeSettings(value = {}) { + const threshold = value?.threshold; + return { + enabled: value?.enabled === true, + action: ['blur', 'hide', 'dim', 'warn'].includes(value?.action) ? value.action : 'blur', + // Match the current SafeSocial prototype's absolute probability cutoff. + threshold: typeof threshold === 'number' && Number.isFinite(threshold) + ? Math.min(0.99, Math.max(0.5, threshold)) : 0.95, + labels: Object.fromEntries(LABELS.map(label => [label, + typeof value?.labels?.[label] === 'boolean' ? value.labels[label] : DEFAULT_LABELS[label]])), + }; +} + +export function isInstagramUrl(value) { + try { + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password && !url.port + && ['www.instagram.com', 'instagram.com'].includes(url.hostname); + } catch { return false; } +} + +export function isMediaUrl(value) { + try { + if (typeof value !== 'string' || value.length > 8192) return false; + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password && !url.port + && ['cdninstagram.com', 'fbcdn.net'].some(domain => url.hostname === domain || url.hostname.endsWith(`.${domain}`)); + } catch { return false; } +} + +export function matchingLabels(scores, settings) { + return LABELS.filter(label => settings.labels[label] && Number.isFinite(scores?.[label]) + && scores[label] >= settings.threshold && scores[label] <= 1); +} + +export function centerCrop(width, height, { image_size: size, resize }) { + // Training evaluation resizes the short edge to 256, then center-crops 224. + const crop = Math.min(width, height) * size / resize; + return { x: (width - crop) / 2, y: (height - crop) / 2, size: crop }; +} + +export function normalizedPixels(pixels, { image_size: size, mean, std }) { + const plane = size * size; + const values = new Float32Array(3 * plane); + for (let index = 0; index < plane; index++) { + for (let channel = 0; channel < 3; channel++) { + values[channel * plane + index] = (pixels[index * 4 + channel] / 255 - mean[channel]) / std[channel]; + } + } + return values; +} diff --git a/src/chrome/src/safesocial/content.css b/src/chrome/src/safesocial/content.css new file mode 100644 index 000000000..54495b002 --- /dev/null +++ b/src/chrome/src/safesocial/content.css @@ -0,0 +1,20 @@ +.wb-safesocial-blur { filter: blur(24px) !important; } +.wb-safesocial-hide { opacity: 0 !important; } +.wb-safesocial-dim { opacity: 0.18 !important; } +.wb-safesocial-overlay { + position: fixed !important; z-index: 2147483646 !important; + transform: translate(-50%, -50%) !important; max-width: 90vw; +} +.wb-safesocial-overlay[hidden] { display: none !important; } +.wb-safesocial-overlay button, .wb-safesocial-notice { + font: 13px/1.5 system-ui, sans-serif !important; + color: #fff !important; background: #202833 !important; + border: 1px solid #8995a5 !important; border-radius: 8px !important; + padding: 9px 14px !important; box-shadow: 0 3px 12px #0004 !important; +} +.wb-safesocial-overlay button { cursor: pointer !important; } +.wb-safesocial-overlay button:focus-visible { outline: 3px solid #80c7ff !important; } +.wb-safesocial-notice { + position: fixed !important; bottom: 16px !important; right: 16px !important; + z-index: 2147483646 !important; max-width: min(360px, 85vw) !important; +} diff --git a/src/chrome/src/safesocial/content.js b/src/chrome/src/safesocial/content.js new file mode 100644 index 000000000..a66c8cde7 --- /dev/null +++ b/src/chrome/src/safesocial/content.js @@ -0,0 +1,142 @@ +(() => { + const api = globalThis.browser || chrome; + const KEY = 'safeSocialSettings'; + const SELECTOR = 'article img, main img, article video[poster], main video[poster]'; + const records = new Map(); + const revealed = new WeakMap(); + let enabled = false; + let epoch = 0; + let timer; + let observer; + let busy = false; + let rescan = false; + let strings = { show: 'Show image', filtered: 'SafeSocial', unavailable: 'SafeSocial: classifier unavailable. Open WebBrain settings to retry.' }; + let notice; + const source = media => media.tagName === 'VIDEO' ? media.poster : media.currentSrc || media.src; + + function clear(media, record) { + media.classList.remove('wb-safesocial-blur', 'wb-safesocial-hide', 'wb-safesocial-dim'); + record?.overlay?.remove(); + } + function reset() { + epoch++; + clearTimeout(timer); + observer?.disconnect(); + observer = null; + for (const [media, record] of records) clear(media, record); + records.clear(); + notice?.remove(); notice = null; + } + function visible(media) { + const rect = media.getBoundingClientRect(); + return media.isConnected && rect.width >= 140 && rect.height >= 140 + && rect.bottom > 0 && rect.right > 0 && rect.top < innerHeight && rect.left < innerWidth; + } + function position(media, record) { + if (!record.overlay) return; + const rect = media.getBoundingClientRect(); + record.overlay.hidden = !visible(media); + record.overlay.style.left = `${(Math.max(0, rect.left) + Math.min(rect.right, innerWidth)) / 2}px`; + record.overlay.style.top = `${(Math.max(0, rect.top) + Math.min(rect.bottom, innerHeight)) / 2}px`; + } + function apply(media, record, result) { + if (!result.labels?.length || revealed.get(media) === record.url) return; + if (['blur', 'hide', 'dim'].includes(result.action)) media.classList.add(`wb-safesocial-${result.action}`); + const overlay = document.createElement('div'); + overlay.className = 'wb-safesocial-overlay'; + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = `${strings.filtered} · ${strings.show}`; + button.addEventListener('click', event => { + event.preventDefault(); event.stopPropagation(); + revealed.set(media, record.url); + clear(media, record); + }); + overlay.append(button); + document.body.append(overlay); + record.overlay = overlay; + position(media, record); + } + function showUnavailable() { + if (notice) return; + notice = document.createElement('div'); + notice.className = 'wb-safesocial-notice'; + notice.setAttribute('role', 'status'); + notice.textContent = strings.unavailable; + document.body.append(notice); + } + async function scan() { + if (!enabled || document.hidden) return; + if (busy) { rescan = true; return; } + busy = true; + rescan = false; + const runEpoch = epoch; + try { + for (const [media, record] of records) { + if (!media.isConnected || source(media) !== record.url) { clear(media, record); records.delete(media); } + else position(media, record); + } + // Work only on visible, sufficiently large images and video posters. + for (const media of document.querySelectorAll(SELECTOR)) { + if (!enabled || epoch !== runEpoch || document.hidden) break; + if (!visible(media)) continue; + const url = source(media); + if (!url || revealed.get(media) === url) continue; + const previous = records.get(media); + if (previous?.url === url && (!previous.retryAt || previous.retryAt > Date.now())) continue; + const record = { url }; + records.set(media, record); + let result; + try { result = await api.runtime.sendMessage({ target: 'safesocial', command: 'classify', url }); } + catch { result = { ok: false }; } + if (!enabled || epoch !== runEpoch || !media.isConnected || source(media) !== url) continue; + if (result?.ok) { + notice?.remove(); notice = null; + apply(media, record, result); + } else { + record.retryAt = Date.now() + 30_000; + if (!result?.disabled) showUnavailable(); + } + } + } finally { + busy = false; + if (enabled && (rescan || runEpoch !== epoch)) schedule(); + } + } + function schedule() { + if (!enabled) return; + clearTimeout(timer); + timer = setTimeout(() => { void scan(); }, 200); + } + function configure(value) { + reset(); + enabled = value?.enabled === true; + if (!enabled) return; + observer = new MutationObserver(schedule); + observer.observe(document.documentElement, { + childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'srcset', 'poster'], + }); + schedule(); + } + api.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes[KEY]) configure(changes[KEY].newValue); + }); + addEventListener('scroll', () => { + for (const [media, record] of records) position(media, record); + schedule(); + }, { passive: true }); + addEventListener('resize', schedule); + document.addEventListener('load', schedule, true); + document.addEventListener('visibilitychange', schedule); + let settingsChanged = false; + api.storage.onChanged.addListener((changes, area) => { + if (area === 'local' && changes[KEY]) settingsChanged = true; + }); + api.storage.local.get([KEY, 'wbLocale']).then(stored => { + if (stored.wbLocale === 'tr') strings = { + show: 'Görseli göster', filtered: 'SafeSocial', + unavailable: 'SafeSocial: sınıflandırıcı kullanılamıyor. WebBrain ayarlarından yeniden deneyin.', + }; + if (!settingsChanged) configure(stored[KEY]); + }).catch(() => {}); +})(); diff --git a/src/chrome/src/safesocial/host.js b/src/chrome/src/safesocial/host.js new file mode 100644 index 000000000..058dad015 --- /dev/null +++ b/src/chrome/src/safesocial/host.js @@ -0,0 +1,44 @@ +/** One isolated WASM worker per browser, shared by every Instagram tab. */ +export function createSafeSocialHost({ WorkerClass = globalThis.Worker } = {}) { + let worker; + let nextId = 0; + let state = { status: 'idle', progress: 0 }; + const pending = new Map(); + function stop() { + worker?.terminate(); + worker = null; + for (const item of pending.values()) { clearTimeout(item.timer); item.reject(new Error('Classifier stopped.')); } + pending.clear(); + state = { status: 'idle', progress: 0 }; + } + function start() { + if (worker) return; + const current = worker = new WorkerClass(new URL('./worker.js', import.meta.url), { type: 'module' }); + current.onmessage = ({ data }) => { + if (worker !== current) return; + if (data.state) { state = data.state; return; } + const item = pending.get(data.id); + if (!item) return; + pending.delete(data.id); + clearTimeout(item.timer); + if (data.error) item.reject(new Error(data.error)); + else item.resolve(data.result); + }; + current.onerror = () => { stop(); state = { status: 'error', progress: 0 }; }; + } + return { + async handle(command, payload = {}) { + if (command === 'status') return { ...state }; + if (command === 'stop') { stop(); return { ...state }; } + if (!['prepare', 'classify'].includes(command)) throw new Error('Unknown classifier command.'); + start(); + if (pending.size >= 32) throw new Error('Classifier is busy.'); + const id = ++nextId; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { stop(); state = { status: 'error', progress: 0 }; }, 180_000); + pending.set(id, { resolve, reject, timer }); + worker.postMessage({ id, command, url: payload.url }); + }); + }, + }; +} diff --git a/src/chrome/src/safesocial/offscreen.js b/src/chrome/src/safesocial/offscreen.js new file mode 100644 index 000000000..331ce7e01 --- /dev/null +++ b/src/chrome/src/safesocial/offscreen.js @@ -0,0 +1,12 @@ +import { createSafeSocialHost } from './host.js'; +const host = createSafeSocialHost(); +chrome.runtime.onMessage.addListener((message, sender, respond) => { + if (message?.target !== 'safesocial-host') return false; + // Content scripts and other extension pages must pass the background gate. + if (sender.id !== chrome.runtime.id || sender.tab || + sender.url !== chrome.runtime.getURL('src/background.js')) return false; + host.handle(message.command, message).then( + result => respond({ ok: true, ...result }), error => respond({ ok: false, error: error.message }), + ); + return true; +}); diff --git a/src/chrome/src/safesocial/worker.js b/src/chrome/src/safesocial/worker.js new file mode 100644 index 000000000..59fe602bb --- /dev/null +++ b/src/chrome/src/safesocial/worker.js @@ -0,0 +1,124 @@ +import { CACHE_NAME, MODEL_BASE, MODEL_FILES, LABELS, isMediaUrl, centerCrop, normalizedPixels } from './config.js'; + +let sessionPromise; +let model; +let ort; +let queue = Promise.resolve(); +let queued = 0; +const report = (status, progress = 0) => self.postMessage({ state: { status, progress } }); + +async function checkedBytes(response, limit, onProgress = () => {}) { + if (!response.ok) throw new Error(`Download failed (${response.status}).`); + if (Number(response.headers.get('content-length')) > limit) throw new Error('File is too large.'); + const reader = response.body.getReader(); + const chunks = []; + let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) throw new Error('File is too large.'); + chunks.push(value); + onProgress(size); + } + } finally { await reader.cancel().catch(() => {}); } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; } + return bytes; +} + +async function modelFile(name) { + const spec = MODEL_FILES[name]; + const url = MODEL_BASE + name; + const cache = await caches.open(CACHE_NAME); + const cached = await cache.match(url); + const response = cached || await fetch(url, { credentials: 'omit', signal: AbortSignal.timeout(120_000) }); + const bytes = await checkedBytes(response, spec.bytes, loaded => { + if (!cached && name === 'model.onnx') report('downloading', Math.round(loaded / spec.bytes * 100)); + }); + const hash = [...new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))] + .map(byte => byte.toString(16).padStart(2, '0')).join(''); + if (bytes.length !== spec.bytes || hash !== spec.sha256) { + await cache.delete(url); + throw new Error('Model verification failed. Retry the download.'); + } + if (!cached) await cache.put(url, new Response(bytes)); + return bytes; +} + +async function prepare() { + if (!sessionPromise) { + sessionPromise = (async () => { + report('downloading'); + model = JSON.parse(new TextDecoder().decode(await modelFile('safesocial-model.json'))); + const bytes = await modelFile('model.onnx'); + report('loading', 100); + // Executable JS/WASM stays packaged; only pinned model data comes from HF. + ort = await import('../../vendor/transformers/ort.webgpu.mjs'); + ort.env.wasm.numThreads = 1; + ort.env.wasm.proxy = false; + ort.env.wasm.wasmPaths = { + mjs: new URL('../../vendor/transformers/ort-wasm-simd-threaded.asyncify.mjs', import.meta.url).href, + wasm: new URL('../../vendor/transformers/ort-wasm-simd-threaded.asyncify.wasm', import.meta.url).href, + }; + const session = await ort.InferenceSession.create(bytes, { executionProviders: ['wasm'] }); + report('ready', 100); + return session; + })().catch(error => { sessionPromise = null; report('error'); throw error; }); + } + return sessionPromise; +} + +async function classify(url) { + if (!isMediaUrl(url)) throw new Error('Unsupported media URL.'); + const session = await prepare(); + // Do not turn the classifier into an arbitrary fetch proxy or follow redirects + // from a permitted CDN to an unrelated host. No page cookies are needed. + const response = await fetch(url, { + credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(15_000), + }); + const type = response.headers.get('content-type') || ''; + if (!/^image\/(jpeg|png|webp|avif)(?:;|$)/i.test(type)) throw new Error('Unsupported image format.'); + const bytes = await checkedBytes(response, 12 * 1024 * 1024); + const bitmap = await createImageBitmap(new Blob([bytes], { type })); + let tensor; + let output; + try { + const size = model.preprocessing.image_size; + const canvas = new OffscreenCanvas(size, size); + const context = canvas.getContext('2d', { willReadFrequently: true }); + const crop = centerCrop(bitmap.width, bitmap.height, model.preprocessing); + context.imageSmoothingQuality = 'high'; + context.drawImage(bitmap, crop.x, crop.y, crop.size, crop.size, 0, 0, size, size); + const values = normalizedPixels(context.getImageData(0, 0, size, size).data, model.preprocessing); + tensor = new ort.Tensor('float32', values, [1, 3, size, size]); + output = await session.run({ [model.input_name]: tensor }); + const probabilities = output[model.output_name]?.data; + if (probabilities?.length !== model.labels.length) throw new Error('Unexpected classifier output.'); + const scores = Object.fromEntries(model.labels.map((label, index) => [label, Number(probabilities[index])])); + if (LABELS.some(label => !Number.isFinite(scores[label]) || scores[label] < 0 || scores[label] > 1)) { + throw new Error('Invalid classifier scores.'); + } + return { scores }; + } finally { + bitmap.close(); + tensor?.dispose(); + for (const value of Object.values(output || {})) value.dispose(); + } +} + +self.onmessage = ({ data }) => { + const { id, command, url } = data; + if (queued >= 32) { self.postMessage({ id, error: 'Classifier is busy. Try again shortly.' }); return; } + queued++; + queue = queue.then(async () => { + try { + const result = command === 'prepare' ? (await prepare(), {}) + : command === 'classify' ? await classify(url) : (() => { throw new Error('Unknown classifier command.'); })(); + self.postMessage({ id, result }); + } catch (error) { self.postMessage({ id, error: error.message }); } + finally { queued--; } + }); +}; diff --git a/src/chrome/src/ui/i18n.js b/src/chrome/src/ui/i18n.js index b7cb6dee2..011799a83 100644 --- a/src/chrome/src/ui/i18n.js +++ b/src/chrome/src/ui/i18n.js @@ -3,6 +3,7 @@ // Works identically in Chrome MV3 and Firefox MV2. import en from './locales/en.js'; +import { safeSocialEnglish, safeSocialTranslations } from './locales/safesocial-copy.mjs'; import es from './locales/es.js'; import fr from './locales/fr.js'; import tr from './locales/tr.js'; @@ -33,6 +34,8 @@ const DICTS = Object.fromEntries(Object.entries({ en, es, fr, tr, zh, ru, uk, ar ...dict, ...providerGuideEnglish, ...pdfViewerEnglish, + ...safeSocialEnglish, + ...(safeSocialTranslations[code] || {}), ...(providerGuideTranslations[code] || {}), }])); const LS_KEY = 'wbLocale'; diff --git a/src/chrome/src/ui/locales/safesocial-copy.mjs b/src/chrome/src/ui/locales/safesocial-copy.mjs new file mode 100644 index 000000000..c5bd4f3b6 --- /dev/null +++ b/src/chrome/src/ui/locales/safesocial-copy.mjs @@ -0,0 +1,66 @@ +export const safeSocialEnglish = { + "st.safesocial.heading": "SafeSocial · Image classifier", + "st.safesocial.enable": "Filter Instagram images", + "st.safesocial.desc": "A local classifier for social comparison themes. Choose which categories to soften in your feed.", + "st.safesocial.download": "Off by default. Enabling downloads a 13.6 MB model from Hugging Face once and keeps it in this browser. Images are classified on your device.", + "st.safesocial.model": "EfficientNet-Lite0 · View model on Hugging Face", + "st.safesocial.action": "When an image matches", + "st.safesocial.blur": "Blur", + "st.safesocial.hide": "Hide image", + "st.safesocial.dim": "Dim", + "st.safesocial.warn": "Show warning", + "st.safesocial.threshold": "Score threshold", + "st.safesocial.threshold_desc": "Higher values filter fewer images. Scores are model estimates; they are not a safety guarantee.", + "st.safesocial.categories": "Categories to filter", + "st.safesocial.scope": "Experimental · Instagram images and video cover images only. You can reveal any filtered image. Settings save automatically.", + "st.safesocial.disabled": "Off · No model download or classification. Cached files are kept until you remove them.", + "st.safesocial.idle": "Waiting for an image. Use Retry to prepare the classifier now.", + "st.safesocial.downloading": "Downloading model · {progress}%", + "st.safesocial.loading": "Preparing local classifier…", + "st.safesocial.ready": "Ready · Classification runs locally.", + "st.safesocial.error": "Classifier unavailable. Check your connection and retry.", + "st.safesocial.retry": "Retry / prepare model", + "st.safesocial.remove": "Remove downloaded model", + "st.safesocial.removed": "Downloaded model removed.", + "st.safesocial.label.romance_jealousy": "Romance & jealousy", + "st.safesocial.label.social_fomo": "Social FOMO", + "st.safesocial.label.luxury_status": "Luxury & status", + "st.safesocial.label.travel_lifestyle": "Travel & lifestyle", + "st.safesocial.label.body_beauty_comparison": "Body & beauty comparison", + "st.safesocial.label.achievement_status": "Achievement & success", + "st.safesocial.label.social_proof_popularity": "Popularity", + "st.safesocial.label.exclusivity_access": "Exclusivity & access" +}; +export const safeSocialTranslations = { tr: { + "st.safesocial.heading": "SafeSocial · Görsel sınıflandırıcı", + "st.safesocial.enable": "Instagram görsellerini filtrele", + "st.safesocial.desc": "Sosyal karşılaştırma temalarını cihazınızda sınıflandırır. Akışınızda azaltmak istediğiniz kategorileri seçin.", + "st.safesocial.download": "Varsayılan olarak kapalıdır. Açıldığında Hugging Face’ten 13,6 MB model bir kez indirilir ve bu tarayıcıda saklanır. Görseller cihazınızda sınıflandırılır.", + "st.safesocial.model": "EfficientNet-Lite0 · Modeli Hugging Face’te incele", + "st.safesocial.action": "Görsel eşleştiğinde", + "st.safesocial.blur": "Bulanıklaştır", + "st.safesocial.hide": "Görseli gizle", + "st.safesocial.dim": "Soluklaştır", + "st.safesocial.warn": "Uyarı göster", + "st.safesocial.threshold": "Skor eşiği", + "st.safesocial.threshold_desc": "Yüksek değerler daha az görseli filtreler. Skorlar model tahminidir; güvenlik garantisi değildir.", + "st.safesocial.categories": "Filtrelenecek kategoriler", + "st.safesocial.scope": "Deneysel · Yalnızca Instagram görselleri ve video kapakları. Filtrelenen her görseli tekrar açabilirsiniz. Ayarlar otomatik kaydedilir.", + "st.safesocial.disabled": "Kapalı · Model indirilmez ve sınıflandırma yapılmaz. İndirilen dosyalar siz kaldırana kadar saklanır.", + "st.safesocial.idle": "Görsel bekleniyor. Modeli şimdi hazırlamak için Yeniden dene’ye basın.", + "st.safesocial.downloading": "Model indiriliyor · %{progress}", + "st.safesocial.loading": "Yerel sınıflandırıcı hazırlanıyor…", + "st.safesocial.ready": "Hazır · Sınıflandırma cihazınızda yapılır.", + "st.safesocial.error": "Sınıflandırıcı kullanılamıyor. Bağlantınızı kontrol edip yeniden deneyin.", + "st.safesocial.retry": "Yeniden dene / modeli hazırla", + "st.safesocial.remove": "İndirilen modeli kaldır", + "st.safesocial.removed": "İndirilen model kaldırıldı.", + "st.safesocial.label.romance_jealousy": "Romantizm ve kıskançlık", + "st.safesocial.label.social_fomo": "Sosyal ortamları kaçırma kaygısı", + "st.safesocial.label.luxury_status": "Lüks ve statü", + "st.safesocial.label.travel_lifestyle": "Seyahat ve yaşam tarzı", + "st.safesocial.label.body_beauty_comparison": "Beden ve güzellik karşılaştırması", + "st.safesocial.label.achievement_status": "Başarı", + "st.safesocial.label.social_proof_popularity": "Popülerlik", + "st.safesocial.label.exclusivity_access": "Ayrıcalık ve özel erişim" +} }; diff --git a/src/chrome/src/ui/safesocial-settings.js b/src/chrome/src/ui/safesocial-settings.js new file mode 100644 index 000000000..097b85445 --- /dev/null +++ b/src/chrome/src/ui/safesocial-settings.js @@ -0,0 +1,96 @@ +import { t } from './i18n.js'; +import { LABELS, SETTINGS_KEY, normalizeSettings } from '../safesocial/config.js'; + +const api = globalThis.browser || chrome; +const card = document.getElementById('safesocial-card'); +const toggle = card.querySelector('#safesocial-enabled'); +const action = card.querySelector('#safesocial-action'); +const threshold = card.querySelector('#safesocial-threshold'); +const thresholdValue = card.querySelector('output'); +const status = card.querySelector('#safesocial-status'); +const retry = card.querySelector('#safesocial-retry'); +const remove = card.querySelector('#safesocial-remove'); +let settings = normalizeSettings(); +let requestEpoch = 0; +let polling; +const labels = card.querySelector('#safesocial-labels'); +for (const name of LABELS) { + const label = document.createElement('label'); + label.className = 'safesocial-label'; + const input = document.createElement('input'); + input.type = 'checkbox'; input.name = name; + const text = document.createElement('span'); + text.dataset.i18n = `st.safesocial.label.${name}`; + text.textContent = t(text.dataset.i18n); + label.append(input, text); labels.append(label); +} +const send = command => api.runtime.sendMessage({ target: 'safesocial', command }); +function render() { + toggle.checked = settings.enabled; + action.value = settings.action; + threshold.value = Math.round(settings.threshold * 100); + thresholdValue.textContent = `${threshold.value}%`; + for (const input of labels.querySelectorAll('input')) input.checked = settings.labels[input.name]; + remove.disabled = settings.enabled; + retry.hidden = !settings.enabled; +} +function showState(value) { + if (!settings.enabled) { status.textContent = t('st.safesocial.disabled'); return; } + const name = ['downloading', 'loading', 'ready', 'error'].includes(value?.status) ? value.status : 'idle'; + status.textContent = t(`st.safesocial.${name}`, { progress: value?.progress || 0 }); +} +async function poll() { + const epoch = requestEpoch; + try { const result = await send('status'); if (epoch === requestEpoch) showState(result); } catch { /* retry reports failures */ } +} +async function prepare() { + const epoch = ++requestEpoch; + clearInterval(polling); + if (!settings.enabled) { showState(); return; } + showState({ status: 'loading' }); + polling = setInterval(poll, 1000); + try { + const result = await send('prepare'); + if (epoch !== requestEpoch) return; + if (!result?.ok) throw new Error(result?.error || t('st.safesocial.error')); + showState({ status: 'ready' }); + } catch (error) { + if (epoch === requestEpoch) status.textContent = `${t('st.safesocial.error')} ${error.message}`; + } finally { + if (epoch === requestEpoch) clearInterval(polling); + } +} +let saveQueue = Promise.resolve(); +function save() { + const next = normalizeSettings({ + enabled: toggle.checked, action: action.value, threshold: Number(threshold.value) / 100, + labels: Object.fromEntries([...labels.querySelectorAll('input')].map(input => [input.name, input.checked])), + }); + saveQueue = saveQueue.catch(() => {}).then(() => api.storage.local.set({ [SETTINGS_KEY]: next })) + .catch(error => { status.textContent = error.message; }); +} +for (const input of [toggle, action, threshold, ...labels.querySelectorAll('input')]) input.addEventListener('change', save); +threshold.addEventListener('input', () => { thresholdValue.textContent = `${threshold.value}%`; }); +retry.addEventListener('click', prepare); +remove.addEventListener('click', async () => { + remove.disabled = true; + try { + const result = await send('remove'); + status.textContent = result?.ok ? t('st.safesocial.removed') : t('st.safesocial.error'); + } catch { status.textContent = t('st.safesocial.error'); } + finally { remove.disabled = settings.enabled; } +}); +let changed = false; +api.storage.onChanged.addListener((changes, area) => { + if (area !== 'local' || !changes[SETTINGS_KEY]) return; + changed = true; + settings = normalizeSettings(changes[SETTINGS_KEY].newValue); + render(); void prepare(); +}); +api.storage.local.get(SETTINGS_KEY).then(stored => { + if (changed) return; + settings = normalizeSettings(stored[SETTINGS_KEY]); render(); + // Opening settings is read-only. A prior opt-in can load on the next image. + if (settings.enabled) void poll(); else showState(); +}).catch(error => { status.textContent = error.message; }); +addEventListener('pagehide', () => { requestEpoch++; clearInterval(polling); }); diff --git a/src/chrome/src/ui/settings.html b/src/chrome/src/ui/settings.html index 161d6f31b..f3f40e4fd 100644 --- a/src/chrome/src/ui/settings.html +++ b/src/chrome/src/ui/settings.html @@ -1378,6 +1378,11 @@ :root[data-theme="light"] .btn-secondary:hover { background: rgba(89,55,25,0.06); } :root[data-theme="light"] .btn-sign-out:hover { background: rgba(89,55,25,0.06); } :root[data-theme="light"] .info-box code { background: rgba(89,55,25,0.08); } + .safesocial-labels-fieldset { border: 0; padding: 0; margin: 18px 0; min-width: 0; } + #safesocial-labels { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; margin-top: 10px; } + #safesocial-labels .safesocial-label { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text); } + #safesocial-labels input { width: auto; margin: 0; flex-shrink: 0; } + #safesocial-status { margin-top: 12px; } @@ -2055,6 +2060,49 @@