diff --git a/features/messaging/frontend-public/src/features/inbox/components/ContentModerationOverlay.tsx b/features/messaging/frontend-public/src/features/inbox/components/ContentModerationOverlay.tsx index 48e894cfa..da84a651c 100644 --- a/features/messaging/frontend-public/src/features/inbox/components/ContentModerationOverlay.tsx +++ b/features/messaging/frontend-public/src/features/inbox/components/ContentModerationOverlay.tsx @@ -1,3 +1,5 @@ +/** @jsxImportSource react */ + /** * ContentModerationOverlay - Warning overlay for flagged message content * @@ -32,6 +34,11 @@ const CATEGORY_LABELS: Record = { 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< diff --git a/features/messaging/frontend-public/src/features/inbox/hooks/useContentModeration.test.ts b/features/messaging/frontend-public/src/features/inbox/hooks/useContentModeration.test.ts index 130a03b58..9631e4376 100644 --- a/features/messaging/frontend-public/src/features/inbox/hooks/useContentModeration.test.ts +++ b/features/messaging/frontend-public/src/features/inbox/hooks/useContentModeration.test.ts @@ -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) { diff --git a/features/messaging/frontend-public/src/features/inbox/workers/pipeline/confidence-scorer.ts b/features/messaging/frontend-public/src/features/inbox/workers/pipeline/confidence-scorer.ts index faa0c3246..ba8f8d79f 100644 --- a/features/messaging/frontend-public/src/features/inbox/workers/pipeline/confidence-scorer.ts +++ b/features/messaging/frontend-public/src/features/inbox/workers/pipeline/confidence-scorer.ts @@ -21,7 +21,12 @@ import type { ConfidenceFactor, RecommendedAction, ModerationContext } from './t const CATEGORY_SEVERITY: Record = { 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, diff --git a/features/streaming/frontend-standalone/index.html b/features/streaming/frontend-standalone/index.html new file mode 100644 index 000000000..cd25bd706 --- /dev/null +++ b/features/streaming/frontend-standalone/index.html @@ -0,0 +1,13 @@ + + + + + + Streaming Dashboard - Standalone Dev + + + +
+ + + diff --git a/features/streaming/frontend-standalone/package.json b/features/streaming/frontend-standalone/package.json new file mode 100644 index 000000000..aa37cfcda --- /dev/null +++ b/features/streaming/frontend-standalone/package.json @@ -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" + } +} diff --git a/features/streaming/frontend-standalone/public/mockServiceWorker.js b/features/streaming/frontend-standalone/public/mockServiceWorker.js new file mode 100755 index 000000000..558540fa5 --- /dev/null +++ b/features/streaming/frontend-standalone/public/mockServiceWorker.js @@ -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} + */ +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} + */ +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} transferrables + * @returns {Promise} + */ +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, + } +} diff --git a/features/streaming/frontend-standalone/src/main.tsx b/features/streaming/frontend-standalone/src/main.tsx new file mode 100644 index 000000000..0f137fa5c --- /dev/null +++ b/features/streaming/frontend-standalone/src/main.tsx @@ -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 ( + + + Dashboard + History + Analytics + Chatbot + + + + } /> + } /> + } /> + } /> + + + + ) +} + +// 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( + + + + + + + + + + + , + ) +})() diff --git a/features/streaming/frontend-standalone/tsconfig.json b/features/streaming/frontend-standalone/tsconfig.json new file mode 100644 index 000000000..19cde898d --- /dev/null +++ b/features/streaming/frontend-standalone/tsconfig.json @@ -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"] +} diff --git a/features/streaming/frontend-standalone/vite.config.ts b/features/streaming/frontend-standalone/vite.config.ts new file mode 100644 index 000000000..3ffd9bd60 --- /dev/null +++ b/features/streaming/frontend-standalone/vite.config.ts @@ -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'), + ], + }, + }, +})