Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion docs/providers-and-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions src/chrome/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions src/chrome/src/background.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { installSafeSocialBackground } from './safesocial/background.js';
import { ProviderManager } from './providers/manager.js';
import {
WEBGPU_COMPASS_TINY_V2_MODEL_ID,
Expand Down Expand Up @@ -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');
Expand Down
14 changes: 13 additions & 1 deletion src/chrome/src/config-transfer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/chrome/src/offscreen/offscreen.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
AUDIO_PLAYBACK (watch alerts).
-->
<script src="offscreen.js"></script>
<script type="module" src="../safesocial/offscreen.js"></script>
<script type="module" src="pdf-extraction-host.js"></script>
<script type="module" src="offline-rag-host.js"></script>
<script type="module" src="emergency-download-host.js"></script>
Expand Down
53 changes: 53 additions & 0 deletions src/chrome/src/safesocial/background.js
Original file line number Diff line number Diff line change
@@ -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 };
}
68 changes: 68 additions & 0 deletions src/chrome/src/safesocial/config.js
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 20 additions & 0 deletions src/chrome/src/safesocial/content.css
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading