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:
parent
917a5e6cef
commit
08a4e51bda
3 changed files with 63 additions and 14 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
}, []);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue