feat(messaging/inbox): Add content moderation overlay UI, confidence scoring pipeline, and tests for moderation hook

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Lilith 2026-02-27 15:46:44 -08:00
parent d0aed1d79b
commit 8b0a5c4ff4
9 changed files with 679 additions and 0 deletions

View file

@ -1,3 +1,5 @@
/** @jsxImportSource react */
/**
* ContentModerationOverlay - Warning overlay for flagged message content
*
@ -32,6 +34,11 @@ const CATEGORY_LABELS: Record<FlagCategory, string> = {
spam: 'Spam',
profanity: 'Profanity',
adult_content: 'Adult Content',
trafficking_signals: 'Trafficking Signals',
doxxing: 'Doxxing',
predatory_behavior: 'Predatory Behavior',
coded_language: 'Coded Language',
law_enforcement: 'Law Enforcement',
};
const SEVERITY_CONFIG: Record<

View file

@ -46,6 +46,11 @@ function makeResult(
threats: 0,
adult_content: 0,
scam_patterns: 0,
trafficking_signals: 0,
doxxing: 0,
predatory_behavior: 0,
coded_language: 0,
law_enforcement: 0,
};
for (const flag of flags) {

View file

@ -21,7 +21,12 @@ import type { ConfidenceFactor, RecommendedAction, ModerationContext } from './t
const CATEGORY_SEVERITY: Record<FlagCategory, number> = {
threats: 1.0,
hate_speech: 0.9,
trafficking_signals: 1.0, // CSAM + trafficking — maximum severity
doxxing: 0.9, // Identity exposure — critical safety
scam_patterns: 0.7,
predatory_behavior: 0.7, // Predatory client patterns
law_enforcement: 0.3, // LE patterns — awareness
coded_language: 0.2, // SW vocabulary + drug codes — low severity
spam: 0.4,
contact_info: 0.3,
profanity: 0.1,

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Streaming Dashboard - Standalone Dev</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,24 @@
{
"name": "@lilith/streaming-standalone",
"private": true,
"type": "module",
"scripts": {
"dev": "vite"
},
"dependencies": {
"msw": "^2.12.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.1.3",
"styled-components": "6.3.8",
"framer-motion": "^11.0.0",
"tslib": "^2.8.1"
},
"devDependencies": {
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}

View file

@ -0,0 +1,349 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.12.4'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}

View file

@ -0,0 +1,135 @@
/** @jsxImportSource react */
/**
* Streaming Dashboard Standalone Entry Point
*
* Thin shell that runs the streaming dashboard without the full dev cluster.
* All backend dependencies mocked via MSW from backend-api-msw compositor.
*
* The dashboard is simpler than marketplace no i18n, no deployment config.
* We provide: MSW, QueryClient, ThemeProvider, Router, ToastProvider.
*/
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeProvider } from '@lilith/ui-styled-components'
import { darkTheme } from '@lilith/ui-theme'
import { BrowserRouter, Routes, Route, NavLink } from '@lilith/ui-router'
import { ToastProvider } from '@lilith/ui-feedback'
import styled from '@lilith/ui-styled-components'
import { setupWorker } from 'msw/browser'
// MSW handlers: composed from feature-owned mocks via backend-api-msw
import { allHandlers } from '../../backend-api-msw/src'
// Dashboard pages
import { StreamDashboardPage } from '@/pages/StreamDashboardPage'
import { SessionHistoryPage } from '@/pages/SessionHistoryPage'
import { AnalyticsPage } from '@/pages/AnalyticsPage'
import { ChatbotConfigPage } from '@/pages/ChatbotConfigPage'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5_000,
retry: false,
},
},
})
const worker = setupWorker(...allHandlers)
// --- Layout ---
const AppShell = styled.div`
display: flex;
flex-direction: column;
min-height: 100vh;
background: ${({ theme }) => theme.colors.background.primary};
color: ${({ theme }) => theme.colors.text.primary};
`
const NavBar = styled.nav`
display: flex;
gap: 0;
background: ${({ theme }) => theme.colors.surface};
border-bottom: 1px solid ${({ theme }) => theme.colors.border.default};
padding: 0 1rem;
`
const NavItem = styled(NavLink)`
padding: 0.75rem 1.25rem;
color: ${({ theme }) => theme.colors.text.secondary};
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
&:hover {
color: ${({ theme }) => theme.colors.text.primary};
}
&.active {
color: ${({ theme }) => theme.colors.primary.main};
border-bottom-color: ${({ theme }) => theme.colors.primary.main};
}
`
const PageContent = styled.main`
flex: 1;
`
function App() {
return (
<AppShell>
<NavBar>
<NavItem to="/" end>Dashboard</NavItem>
<NavItem to="/history">History</NavItem>
<NavItem to="/analytics">Analytics</NavItem>
<NavItem to="/chatbot">Chatbot</NavItem>
</NavBar>
<PageContent>
<Routes>
<Route path="/" element={<StreamDashboardPage />} />
<Route path="/history" element={<SessionHistoryPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/chatbot" element={<ChatbotConfigPage />} />
</Routes>
</PageContent>
</AppShell>
)
}
// Start MSW then mount the app
;(async () => {
await worker.start({
onUnhandledRequest: 'bypass',
serviceWorker: {
url: '/mockServiceWorker.js',
},
})
// Seed a mock auth token so the StreamingApi includes Authorization headers
if (!localStorage.getItem('auth_token')) {
localStorage.setItem('auth_token', 'mock-standalone-token')
}
const root = document.getElementById('root')
if (!root) throw new Error('Root element not found')
createRoot(root).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={darkTheme}>
<ToastProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</ToastProvider>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
)
})()

View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"paths": {
"@/*": ["../frontend-dashboard/src/*"]
}
},
"include": ["src", "../frontend-dashboard/src"]
}

View file

@ -0,0 +1,121 @@
/**
* Streaming Standalone - Vite Configuration
*
* Serves the streaming dashboard without the full dev cluster.
* All backend dependencies are mocked via MSW.
*
* Based on marketplace standalone's proven env-driven pattern with
* lilithPackageResolver + bunStoreResolver plugins.
*/
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
import { platformResolveAliases } from '@lilith/build-core'
import { lilithPackageResolver } from '../../profile/frontend-showcase/src/lib/vite-plugins/lilith-package-resolver'
import { bunStoreResolver } from '../../profile/frontend-showcase/src/lib/vite-plugins/bun-store-resolver'
const standaloneRoot = __dirname
const rootNodeModules = path.resolve(__dirname, '../../../../node_modules')
const uiPackagesRoot = path.resolve(
__dirname,
'../../../../../../../@packages/@ts/@ui-react/packages'
)
const dashboardSrc = path.resolve(__dirname, '../frontend-dashboard/src')
const standaloneNodeModules = path.resolve(standaloneRoot, 'node_modules')
export default defineConfig({
cacheDir: '.vite-cache',
plugins: [
lilithPackageResolver({
uiPackagesRoot,
rootNodeModules,
}),
bunStoreResolver({
rootNodeModules,
versionPins: {
'framer-motion': 11,
'motion-dom': 11,
'motion-utils': 11,
'react-router-dom': 7,
'tslib': 2,
},
}),
react(),
],
resolve: {
alias: [
// Dashboard app source
{ find: /^@\//, replacement: dashboardSrc + '/' },
// Workspace packages not in root node_modules
{ find: '@lilith/msw-handlers', replacement: path.resolve(__dirname, '../../../@packages/@testing/msw-handlers/src') },
// msw for handler files outside this directory (bare import only)
{ find: /^msw$/, replacement: path.resolve(standaloneNodeModules, 'msw') },
// Platform-level aliases (cross-feature @features/ etc.)
...Object.entries(platformResolveAliases()).map(([find, replacement]) => ({ find, replacement })),
],
dedupe: [
'@lilith/ui-styled-components',
'@lilith/ui-router',
'@lilith/ui-motion',
'@lilith/ui-theme',
'react',
'react-dom',
'react-router-dom',
'styled-components',
],
},
optimizeDeps: {
include: ['styled-components', 'react-router-dom'],
exclude: [
'@lilith/ui-typography',
'@lilith/ui-primitives',
'@lilith/ui-animated',
'@lilith/ui-feedback',
'@lilith/ui-icons',
'@lilith/ui-layout',
'@lilith/ui-motion',
'@lilith/ui-router',
'@lilith/ui-styled-components',
'@lilith/ui-theme',
'@lilith/ui-charts',
'@lilith/ui-analytics',
'@lilith/ui-data',
'@lilith/ui-forms',
'@lilith/ui-realtime',
],
// Externalize @lilith/* during dep pre-bundling — Vite plugins resolve these at serve-time
esbuildOptions: {
plugins: [{
name: 'externalize-lilith',
setup(build) {
build.onResolve({ filter: /^@lilith\// }, (args) => ({
path: args.path,
external: true,
}))
},
}],
},
},
server: {
port: 5130,
strictPort: false,
fs: {
allow: [
standaloneRoot,
rootNodeModules,
uiPackagesRoot,
dashboardSrc,
// Cross-feature imports via @features/ alias
path.resolve(__dirname, '../../'),
// MSW handler sources
path.resolve(__dirname, '../shared/msw/src'),
path.resolve(__dirname, '../backend-api-msw/src'),
// SSO MSW handlers
path.resolve(__dirname, '../../sso/shared/msw'),
// msw-handlers package
path.resolve(__dirname, '../../../@packages/@testing/msw-handlers/src'),
],
},
},
})