feat(inbox): Add Web Worker for real-time spellchecking and content moderation

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-27 15:18:50 -08:00
parent 917a5e6cef
commit 08a4e51bda
3 changed files with 63 additions and 14 deletions

View file

@ -31,6 +31,7 @@ const INITIAL_STATE: ContentModerationState = {
};
const LOW_SEVERITY_AUTO_DISMISS_MS = 3000;
const MAX_PENDING_REQUESTS = 10;
/**
* Determine moderation severity from analysis results.
@ -74,7 +75,15 @@ interface PendingRequest {
reject: (error: Error) => void;
}
export function useContentModeration() {
interface UseContentModerationReturn extends ContentModerationState {
checkMessage: (text: string) => Promise<CheckResult>;
moderateAndSend: (text: string, sendFn: (text: string) => Promise<void>) => Promise<void>;
dismissWarning: () => void;
sendAnyway: () => Promise<void>;
editMessage: () => void;
}
export function useContentModeration(): UseContentModerationReturn {
const workerRef = useRef<Worker | null>(null);
const workerReadyRef = useRef<Promise<void> | null>(null);
const pendingRequestsRef = useRef<Map<string, PendingRequest>>(new Map());
@ -90,7 +99,7 @@ export function useContentModeration() {
const worker = new ContentModerationWorker();
workerRef.current = worker;
const handleMessage = (event: MessageEvent<WorkerResponse>) => {
const handleMessage = (event: MessageEvent<WorkerResponse>): void => {
const response = event.data;
switch (response.type) {
@ -153,6 +162,15 @@ export function useContentModeration() {
const requestId = crypto.randomUUID();
return new Promise<CheckResult>((resolve, reject) => {
const pending = pendingRequestsRef.current;
if (pending.size >= MAX_PENDING_REQUESTS) {
const oldestKey = pending.keys().next().value;
const oldestReq = oldestKey !== undefined ? pending.get(oldestKey) : undefined;
if (oldestKey !== undefined && oldestReq) {
oldestReq.reject(new Error('Superseded by newer check'));
pending.delete(oldestKey);
}
}
pendingRequestsRef.current.set(requestId, { resolve, reject });
const message: WorkerRequest = { type: 'check', text, requestId };
@ -162,7 +180,7 @@ export function useContentModeration() {
[getWorker],
);
const dismissWarning = useCallback(() => {
const dismissWarning = useCallback((): void => {
setState(INITIAL_STATE);
sendFnRef.current = null;
}, []);
@ -237,7 +255,6 @@ export function useContentModeration() {
});
} catch (error) {
// Fail open: if analysis fails, allow send (preserves UX)
console.error('[ContentModeration] Analysis failed:', error);
setState(INITIAL_STATE);
await sendFn(text);
}
@ -246,7 +263,7 @@ export function useContentModeration() {
);
// User clicks "Send Anyway" — deliver the flagged message
const sendAnyway = useCallback(async () => {
const sendAnyway = useCallback(async (): Promise<void> => {
const text = state.originalText;
const sendFn = sendFnRef.current;
@ -258,13 +275,13 @@ export function useContentModeration() {
}, [state.originalText, dismissWarning]);
// User clicks "Edit Message" — dismiss overlay, return focus to composer
const editMessage = useCallback(() => {
const editMessage = useCallback((): void => {
dismissWarning();
}, [dismissWarning]);
// Terminate worker on unmount
useEffect(() => {
return () => {
return (): void => {
if (workerRef.current) {
workerRef.current.terminate();
workerRef.current = null;

View file

@ -17,12 +17,16 @@ import type { SpellCheckerLike } from '@lilith/ui-spellcheck';
import {
SpellChecker,
SymSpellEngine,
InputLengthExceededError,
assertInputLength,
} from '@lilith/text-processing-utils';
import {
saveSpellcheckSettings as syncPackageSettings,
} from '@lilith/ui-spellcheck';
import { getSpellcheckSettings } from '../services/spellcheckSettings';
const MAX_SPELLCHECK_TEXT_LENGTH = 10_000;
export function useSpellcheckerInstance(): SpellCheckerLike | null {
const [instance, setInstance] = useState<SpellCheckerLike | null>(null);
const initStartedRef = useRef(false);
@ -47,7 +51,7 @@ export function useSpellcheckerInstance(): SpellCheckerLike | null {
customWords: settings.customWords,
});
const init = async () => {
const init = async (): Promise<void> => {
const engine = new SymSpellEngine({
wasmUrl: '/spellcheck/spellchecker_wasm_bg.wasm',
dictionaryUrl: '/spellcheck/frequency_dictionary_en_82_765.txt',
@ -60,6 +64,7 @@ export function useSpellcheckerInstance(): SpellCheckerLike | null {
const checker = new SpellChecker({
engine,
customWords: settings.customWords,
maxInputLength: MAX_SPELLCHECK_TEXT_LENGTH,
});
await checker.initialize();
@ -69,6 +74,13 @@ export function useSpellcheckerInstance(): SpellCheckerLike | null {
// BatchSpellCheckResult.errors is a superset of what SpellCheckerLike expects.
const adapter: SpellCheckerLike = {
checkText: async (text: string) => {
try {
assertInputLength(text, MAX_SPELLCHECK_TEXT_LENGTH, 'spellcheck');
} catch (e) {
// Fail open — skip spellcheck for oversized text
if (e instanceof InputLengthExceededError) return { errors: [] };
throw e;
}
const result = await checker.checkText(text);
return {
errors: result.errors.map((err) => ({
@ -84,11 +96,12 @@ export function useSpellcheckerInstance(): SpellCheckerLike | null {
setInstance(adapter);
};
init().catch((err) => {
console.error('[Spellcheck] Failed to initialize:', err);
init().catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`[Spellcheck] Failed to initialize: ${message}`);
});
return () => {
return (): void => {
cancelled = true;
};
}, []);

View file

@ -21,21 +21,30 @@
import { getContentFlaggingService } from '@lilith/text-processing-content-flagging';
import type { ContentFlaggingService, FlagCategory } from '@lilith/text-processing-content-flagging';
import { assertInputLength } from '@lilith/text-processing-utils';
import { toCustomWordLists } from '@lilith/text-processing-slang-dictionary';
import { ContentModerationPipeline } from './pipeline';
import type { WorkerRequest, WorkerResponse, WorkerConfig } from '../types/content-moderation';
let service: ContentFlaggingService | null = null;
let pipeline: ContentModerationPipeline | null = null;
const MAX_MESSAGE_LENGTH = 10_000;
const MESSAGING_WEIGHTS: Record<FlagCategory, number> = {
threats: 3.0,
hate_speech: 2.5,
scam_patterns: 2.0,
trafficking_signals: 3.0, // CSAM + trafficking — highest priority
doxxing: 2.5, // Identity exposure — critical safety
predatory_behavior: 2.0, // Predatory client patterns
coded_language: 0.3, // SW vocabulary + drug codes — awareness, not blocking
law_enforcement: 0.5, // LE patterns — provider safety awareness
contact_info: 0.5,
solicitation: 0.0,
solicitation: 0.0, // Disabled — this IS a solicitation platform
spam: 0.8,
profanity: 0.1,
adult_content: 0.0,
profanity: 0.1, // Adult platform — profanity is normal
adult_content: 0.0, // Disabled — adult content is the product
};
function respond(message: WorkerResponse): void {
@ -44,12 +53,21 @@ function respond(message: WorkerResponse): void {
function handleInit(config: WorkerConfig): void {
try {
// Enrich with slang dictionary — CSAM, drug, emoji, and SW vocabulary terms
const slangWordLists = toCustomWordLists({
context: 'message',
includeLeetVariants: true,
});
service = getContentFlaggingService({
threshold: config.threshold,
enabledCategories: config.enabledCategories,
categoryWeights: { ...MESSAGING_WEIGHTS, ...config.categoryWeights },
customWordLists: slangWordLists,
context: 'message',
enableSentiment: false,
maxInputLength: MAX_MESSAGE_LENGTH,
maxFlags: 50,
});
// Wrap the service in the pipeline for caching + confidence scoring
@ -75,6 +93,7 @@ function handleCheck(
}
try {
assertInputLength(text, MAX_MESSAGE_LENGTH, 'handleCheck');
const { result, pipeline: pipelineData } = pipeline.process(text, context);
respond({ type: 'result', requestId, result, pipeline: pipelineData });
} catch (error) {