diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts index 6aca44a..e1e6f02 100644 --- a/packages/shared/src/config/index.ts +++ b/packages/shared/src/config/index.ts @@ -146,6 +146,21 @@ export const configSchema = z .int() .positive() .default(60000), + // ── AI Model (new unified keys) ─────────────────────────────────── + AI_MODEL_FAST_CLASSIFIER_ENABLED: z + .string() + .optional() + .transform((v) => v === "true") + .default(true) + .describe("Enable Layer 1 fast heuristic classifier"), + AI_MODEL_LLM_TIMEOUT_MS: z.coerce + .number() + .int() + .positive() + .default(30000) + .describe("Timeout for individual LLM moderation calls"), + + // ── AI Analysis Timing ────────────────────────────────────────────── AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500), diff --git a/services/discord-gateway/src/modules/ai-moderation/ai-analysis-worker.ts b/services/discord-gateway/src/modules/ai-moderation/ai-analysis-worker.ts new file mode 100644 index 0000000..386fb24 --- /dev/null +++ b/services/discord-gateway/src/modules/ai-moderation/ai-analysis-worker.ts @@ -0,0 +1,500 @@ +/** + * ai-analysis-worker.ts + * + * Two-pass AI moderation analysis worker (Piscina-compatible). + * + * ## Pipeline + * + * Message → Layer 1 (fast classifier / heuristic) + * │ + * ├─ clear match → final result (no LLM call) + * └─ ambiguous → Layer 2 (LLM evaluator) + * + * Layer 1 runs synchronously in-memory. Layer 2 calls the LLM API via + * the existing moderation pipeline (moderationOrchestrator). + * + * This file replaces the old `aiAnalysisWorker.ts` (archived) with a + * simpler, unified worker that combines both layers. + */ + +import { createChildLogger } from "@bete/shared/logger"; +import { config } from "../../shared/config/config.js"; +import { initializeDatabase } from "../../shared/database/drizzle.js"; +import { messageStore } from "../message-capture/messageStore.js"; +import type { MessageRecord } from "../message-capture/types.js"; +import { buildConversationContext } from "./conversationContext.js"; +import { classifyMessage } from "./fastClassifier.js"; +import type { Layer1Result } from "./fastClassifier.js"; +import { runModerationAnalysis } from "./moderationOrchestrator.js"; + +const logger = createChildLogger("ai-analysis-worker"); + +let dbInitialized = false; +let dbInitPromise: Promise | null = null; + +async function ensureDb(): Promise { + if (dbInitialized) return; + if (!dbInitPromise) { + dbInitPromise = initializeDatabase().then(() => { + dbInitialized = true; + }); + } + await dbInitPromise; +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface AnalysisInput { + batch: MessageBatch; + config: WorkerConfig; +} + +export interface AnalysisResult { + messageId: string; + status: "clean" | "warn" | "flagged" | "error"; + flags: string[]; + categories: string[]; + severity: "none" | "low" | "medium" | "high" | "critical"; + confidence: number; + recommendedAction: + | "none" + | "monitor" + | "warn" + | "review" + | "delete" + | "escalate"; + toxicityScore: number; + harmScore: number; + jailbreakScore: number; + safetyScore: number; + explanation: string; + correctedFlags?: string[]; +} + +export interface WorkerConfig { + aiLlmApiKey: string; + aiLlmBaseUrl: string; + aiLlmModel: string; + aiLlmTimeoutMs: number; +} + +export interface MessageBatch { + conversationKey: string; + messages: MessageRecord[]; + contextMessages: string[]; +} + +// Worker job types (Piscina entry point) +type WorkerJob = + | { type: "batch"; conversationKey: string; messages: MessageRecord[] } + | { + type: "individual"; + message: MessageRecord; + skipNormalAnalysis: boolean; + }; + +type BatchOkResponse = { + ok: true; + conversationKey: string; + rows: MessageRecord[]; +}; +type BatchErrorResponse = { + ok: false; + conversationKey: string; + rows: MessageRecord[]; + error: string; +}; +type IndividualOkResponse = { ok: true; results: AnalysisResult[] }; +type IndividualErrorResponse = { + ok: false; + results: AnalysisResult[]; + error: string; +}; + +type WorkerResponse = + | BatchOkResponse + | BatchErrorResponse + | IndividualOkResponse + | IndividualErrorResponse; + +// --------------------------------------------------------------------------- +// Default export — Piscina worker entry point +// --------------------------------------------------------------------------- + +export default async function workerRouter( + job: WorkerJob, +): Promise { + if (!config.AI_LLM_API_KEY) { + const errorMsg = + "AI_LLM_API_KEY is missing from environment. Worker cannot process moderation requests without credentials."; + logger.error({ error: errorMsg }, "AI_LLM_API_KEY is missing from environment"); + + if (job.type === "batch") { + return { + ok: false, + conversationKey: job.conversationKey, + rows: [], + error: errorMsg, + }; + } + return { ok: false, results: [], error: errorMsg }; + } + + try { + await ensureDb(); + } catch (dbError) { + const msg = dbError instanceof Error ? dbError.message : String(dbError); + if (job.type === "batch") { + return { + ok: false, + conversationKey: job.conversationKey, + rows: [], + error: `Database init failed: ${msg}`, + }; + } + return { + ok: false, + results: [], + error: `Database init failed: ${msg}`, + }; + } + + try { + if (job.type === "batch") { + return await processBatch(job); + } + return await processIndividual(job); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + logger.error( + { type: job.type, error: errorMessage, stack: errorStack }, + "Worker job failed", + ); + if (job.type === "batch") { + return { + ok: false, + conversationKey: job.conversationKey, + rows: [], + error: errorMessage, + }; + } + return { ok: false, results: [], error: errorMessage }; + } +} + +// --------------------------------------------------------------------------- +// Two-pass pipeline +// --------------------------------------------------------------------------- + +/** + * Runs the two-pass pipeline on a single message: + * 1. Layer 1 — fast heuristic classifier + * 2. Layer 2 (if cascade) — LLM-based evaluator + * + * Returns the combined AnalysisResult. + */ +async function runTwoPassPipeline( + message: MessageRecord, + contextText: string, + attachments: Awaited>, +): Promise { + // ── Layer 1: Fast classifier ────────────────────────────────────────── + const layer1Result: Layer1Result = classifyMessage(message); + + logger.debug( + { + messageId: message.id, + layer1Flags: layer1Result.flags, + cascade: layer1Result.cascadeToLayer2, + }, + "Layer 1 classification complete", + ); + + if (!layer1Result.cascadeToLayer2) { + // Layer 1 result is final — no LLM call needed + return buildResultFromLayer1(message.id, layer1Result); + } + + // ── Layer 2: LLM-based evaluation ───────────────────────────────────── + try { + const moderationResult = await runModerationAnalysis({ + targets: [message], + contextText, + attachments, + }); + + if (moderationResult.results.length === 0) { + return buildFallbackResult(message.id, "No LLM result returned"); + } + + const llmResult = moderationResult.results[0]; + return mergeLayers(layer1Result, llmResult); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.warn( + { messageId: message.id, error: errorMsg }, + "Layer 2 (LLM) analysis failed — falling back to Layer 1 result", + ); + // Fallback to Layer 1 with reduced confidence + const fallback = buildResultFromLayer1(message.id, layer1Result); + fallback.confidence = Math.min(fallback.confidence, 0.4); + return fallback; + } +} + +// --------------------------------------------------------------------------- +// Result builders +// --------------------------------------------------------------------------- + +function buildResultFromLayer1( + messageId: string, + layer1: Layer1Result, +): AnalysisResult { + const status = layer1.severity === "none" ? "clean" as const : "flagged" as const; + const recommendedAction = mapSeverityToAction(layer1.severity); + + return { + messageId, + status, + flags: layer1.flags, + categories: layer1.flags, + severity: layer1.severity === "high" ? "high" as const : layer1.severity === "medium" ? "medium" as const : "low" as const, + confidence: layer1.confidence, + recommendedAction, + toxicityScore: layer1.toxicityScore, + harmScore: layer1.harmScore, + jailbreakScore: 0, + safetyScore: 0, + explanation: layer1.explanation, + }; +} + +function buildFallbackResult( + messageId: string, + reason: string, +): AnalysisResult { + return { + messageId, + status: "error", + flags: ["analysis_incomplete"], + categories: ["analysis_incomplete"], + severity: "none", + confidence: 0, + recommendedAction: "review", + toxicityScore: 0, + harmScore: 0, + jailbreakScore: 0, + safetyScore: 0, + explanation: reason, + }; +} + +function mergeLayers( + layer1: Layer1Result, + llmResult: AnalysisResult, +): AnalysisResult { + // Merge flags from both layers (deduplicate) + const flagSet = new Set([...layer1.flags, ...(llmResult.flags || [])]); + + // Take the max severity + const severityOrder = ["none", "low", "medium", "high", "critical"] as const; + const l1Idx = severityOrder.indexOf(layer1.severity); + const l2Idx = severityOrder.indexOf( + (llmResult.severity ?? "none") as (typeof severityOrder)[number], + ); + const finalSeverity = severityOrder[Math.max(l1Idx, l2Idx)]; + + // Combined confidence: weighted average favoring LLM when available + const combinedConfidence = + 0.3 * layer1.confidence + 0.7 * (llmResult.confidence ?? 0.5); + + // Combine scores (take max per dimension) + const toxicityScore = Math.max( + layer1.toxicityScore, + llmResult.toxicityScore ?? 0, + ); + const harmScore = Math.max(layer1.harmScore, llmResult.harmScore ?? 0); + + return { + messageId: llmResult.messageId, + status: llmResult.status === "error" ? "error" as const : llmResult.status ?? "clean" as const, + flags: Array.from(flagSet), + categories: [ + ...new Set([ + ...layer1.flags, + ...(llmResult.categories ?? []), + ]), + ], + severity: finalSeverity, + confidence: Math.min(combinedConfidence, 1), + recommendedAction: llmResult.recommendedAction ?? mapSeverityToAction(finalSeverity), + toxicityScore, + harmScore, + jailbreakScore: llmResult.jailbreakScore ?? 0, + safetyScore: llmResult.safetyScore ?? 0, + explanation: llmResult.explanation ?? layer1.explanation, + }; +} + +function mapSeverityToAction( + severity: "none" | "low" | "medium" | "high" | "critical", +): AnalysisResult["recommendedAction"] { + switch (severity) { + case "none": + return "none"; + case "low": + return "monitor"; + case "medium": + return "review"; + case "high": + return "delete"; + case "critical": + return "escalate"; + } +} + +// --------------------------------------------------------------------------- +// Batch handler +// --------------------------------------------------------------------------- + +async function processBatch(job: { + type: "batch"; + conversationKey: string; + messages: MessageRecord[]; +}): Promise { + const { conversationKey, messages } = job; + const firstMessage = messages[0]; + if (!firstMessage) return { ok: true, conversationKey, rows: [] }; + + // Fetch context + const contextBefore = await messageStore.getConversationContextBefore({ + channelId: firstMessage.channel_id, + threadId: firstMessage.thread_id, + beforeCreatedAt: firstMessage.created_at, + limit: config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT, + }); + + const contextLines = buildConversationContext({ + contextBefore, + targets: messages, + maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS, + }); + const contextText = contextLines.join("\n"); + + // Fetch attachments + const targetIds = messages.map((m) => m.id); + const contextIds = contextBefore.map((m) => m.id); + const allMessageIds = [...targetIds, ...contextIds]; + const attachments = + await messageStore.getAttachmentsForMessages(allMessageIds); + + // Run two-pass pipeline for each message + const analysisResults = await Promise.all( + messages.map(async (msg) => { + try { + return await runTwoPassPipeline(msg, contextText, attachments); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error( + { messageId: msg.id, error: errorMsg }, + "Two-pass pipeline failed for message", + ); + return buildFallbackResult(msg.id, errorMsg); + } + }), + ); + + // Save results to DB + const updates = analysisResults.map((result) => ({ + messageId: result.messageId, + result: { + status: result.status, + flags: JSON.stringify(result.flags), + score: result.toxicityScore, + analysis: result.explanation, + categories: result.categories, + severity: result.severity, + confidence: result.confidence, + recommendedAction: result.recommendedAction, + analyzedAt: Date.now(), + error: result.status === "error" ? result.explanation : null, + }, + })); + + let allRows: MessageRecord[] = []; + if (updates.length > 0) { + allRows = await messageStore.updateMessagesAIAnalysisBulk(updates); + } + + logger.info( + { + total: messages.length, + saved: allRows.length, + conversationKey, + }, + "Two-pass batch analysis complete", + ); + + return { ok: true, conversationKey, rows: allRows }; +} + +// --------------------------------------------------------------------------- +// Individual fallback handler +// --------------------------------------------------------------------------- + +async function processIndividual(job: { + type: "individual"; + message: MessageRecord; + skipNormalAnalysis: boolean; +}): Promise { + const { message, skipNormalAnalysis } = job; + + if (skipNormalAnalysis) { + // Use Layer 1 only (fast) + const layer1Result = classifyMessage(message); + + if (!layer1Result.cascadeToLayer2) { + // Layer 1 is sufficient + return { + ok: true, + results: [buildResultFromLayer1(message.id, layer1Result)], + }; + } + } + + // Full analysis (context + two-pass) + const contextBefore = await messageStore.getConversationContextBefore({ + channelId: message.channel_id, + threadId: message.thread_id, + beforeCreatedAt: message.created_at, + limit: config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT, + }); + + const contextLines = buildConversationContext({ + contextBefore, + targets: [message], + maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS, + }); + const contextText = contextLines.join("\n"); + + const contextIds = contextBefore.map((m) => m.id); + const attachments = await messageStore.getAttachmentsForMessages([ + message.id, + ...contextIds, + ]); + + try { + const result = await runTwoPassPipeline(message, contextText, attachments); + return { ok: true, results: [result] }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + logger.error( + { messageId: message.id, error: errorMsg }, + "Individual two-pass analysis failed", + ); + return { ok: true, results: [buildFallbackResult(message.id, errorMsg)] }; + } +} \ No newline at end of file diff --git a/services/discord-gateway/src/modules/ai-moderation/aiAnalysisWorker.ts b/services/discord-gateway/src/modules/ai-moderation/archive/aiAnalysisWorker.ts similarity index 100% rename from services/discord-gateway/src/modules/ai-moderation/aiAnalysisWorker.ts rename to services/discord-gateway/src/modules/ai-moderation/archive/aiAnalysisWorker.ts diff --git a/services/discord-gateway/src/modules/ai-moderation/circuitBreaker.ts b/services/discord-gateway/src/modules/ai-moderation/circuitBreaker.ts index 6bffb5d..7fe1ec1 100644 --- a/services/discord-gateway/src/modules/ai-moderation/circuitBreaker.ts +++ b/services/discord-gateway/src/modules/ai-moderation/circuitBreaker.ts @@ -11,9 +11,9 @@ import type { MessageRecord } from "../message-capture/types.js"; function getAnalysisWorkerUrl(): URL { const candidates = [ - new URL("./aiAnalysisWorker.js", import.meta.url), - new URL("../aiAnalysisWorker.js", import.meta.url), - new URL("./aiAnalysisWorker.ts", import.meta.url), + new URL("./ai-analysis-worker.js", import.meta.url), + new URL("../ai-analysis-worker.js", import.meta.url), + new URL("./ai-analysis-worker.ts", import.meta.url), ]; for (const candidate of candidates) { diff --git a/services/discord-gateway/src/modules/ai-moderation/fastClassifier.ts b/services/discord-gateway/src/modules/ai-moderation/fastClassifier.ts new file mode 100644 index 0000000..cc30e3b --- /dev/null +++ b/services/discord-gateway/src/modules/ai-moderation/fastClassifier.ts @@ -0,0 +1,399 @@ +/** + * fastClassifier.ts + * + * Layer 1 — Synchronous heuristic classifier for the two-pass moderation pipeline. + * Runs BEFORE any LLM call. Catches obvious spam, NSFW patterns, repeated characters, + * and other low-hanging fruit with zero network cost. + * + * When a strong heuristic match is found, `cascadeToLayer2` is `false` and the + * result is used as the final verdict. Otherwise the message proceeds to the LLM. + */ + +import type { MessageRecord } from "../message-capture/types.js"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface Layer1Result { + flags: string[]; + severity: "none" | "low" | "medium" | "high"; + toxicityScore: number; + harmScore: number; + cascadeToLayer2: boolean; + confidence: number; + explanation: string; +} + +// --------------------------------------------------------------------------- +// Pattern definitions +// --------------------------------------------------------------------------- + +interface Pattern { + name: string; + test: (content: string, mentions: number) => boolean; + severity: "low" | "medium" | "high"; + score: number; // contribution to toxicity/harm score + category: "toxicity" | "harm" | "spam" | "safety"; +} + +// ── Zalgo / zero-width detection ───────────────────────────────────────── + +const ZALGO_RE = + /[̀-ͯ҃-҉ؐ-ًؚ-ٰٟۖ-ۜ۟-ۤۧ-۪ۨ-ܑۭܰ-݊ަ-ްँ-ः़ा-्॑-॔ॢ-ॣঁ-ঃ়া-ৄে-ৈো-্ৗৢ-ৣ৾ਁ-ਃ਼ਾ-ੂੇ-ੈੋ-੍ੑੰ-ੱੵઁ-ઃ઼ા-ૅે-ૉો-્ૢ-ૣૺ-૿ଁ-ଃ଼ା-ୄେ-ୈୋ-୍ୖ-ୗୢ-ୣஂா-ூெ-ைொ-்ௗఀ-ఃా-ౄె-ైొ-్ౕ-ౖౢ-ౣಁ-ಃ಼ಾ-ೄೆ-ೈೊ-್ೕ-ೖೢ-ೣഀ-ഃ഻-഼ാ-ൄെ-ൈൊ-്ൗൢ-ൣඁ-ඃ්ා-ුූෘ-ෟෲ-ෳัิ-ฺ็-๎ັິ-ູົ-ຼ່-ໍ༘-༹༙༵༷༾-༿ཱ-྄྆-྇ྍ-ྗྙ-ྼ࿆ါ-ှၖ-ၙၞ-ၠၢ-ၤၧ-ၭၱ-ၴႂ-ႍႏႚ-ႝ፝-፟ᜒ-᜔ᜲ-᜴ᝒ-ᝓᝲ-ᝳ឴-៓៝᠋-᠍ᢩᤠ-ᤫᤰ-᤻ᨗ-ᨛᩕ-ᩞ᩠-᩿᩼᪰-᪾ᬀ-ᬄ᬴-᭄᭫-᭳ᮀ-ᮁᮢ-ᮥᮨ-ᮩ᮫-ᮭ᯦-᯳ᰤ-᰷᳐-᳔᳒-᳨᳭ᳲ-᳴᳷-᳹᷀-᷿​-‏
-  -⃐-⃰⳯-⵿⳱ⷠ-〪ⷿ-゙〯-゚꙯-꙲ꙴ-꙽ꚞ-ꚟ꛰-꛱ꠂ꠆ꠋꠣ-ꠧ꠬ꢀ-ꢁꢴ-ꣅ꣠-꣱ꣿ-꤉ꤦ-꤭ꥇ-꥓ꥠ-ꥼꦀ-ꦃ꦳-꧀ꧥꨩ-ꨶꩃꩌꩍꩻ-ꩽꪰꪲ-ꪴꪷ-ꪸꪾ-꪿꫁ꫫ-ꫯꫵ-꫶ꯣ-ꯪ꯬꯭ﬞ︀-️︠-︯-]|­|͏|؜ᅟᅠ឴឵᠎ -   ⁠-⁤⁦-ㅤᅠ￰-￸\U000e0001\U000e0020-\U000e007f/; + +const ZERO_WIDTH_RE = /[​-‍⁠­؜]/; + +// ── URL / invite / phone / email / crypto patterns ───────────────────── + +const URL_RE = /https?:\/\/[^\s"]+/gi; +const INVITE_RE = /(?:discord\.(?:gg|com\/invite)|dsc\.gg)\/[a-zA-Z0-9_-]+/gi; +const INVITE_CODE_RE = /(?:^|\s)([a-zA-Z0-9_-]{6,12})(?:\s|$)/g; +const PHONE_RE = + /(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{3,4}[-.\s]?\d{3,4}/g; +const EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/gi; +const CRYPTO_RE = + /(?:0x[a-fA-F0-9]{40}|bc1[a-z0-9]{39,59}|1[a-km-zA-HJ-NP-Z1-9]{25,34}|3[a-km-zA-HJ-NP-Z1-9]{25,34})/g; +const IP_RE = /\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g; + +// ── Spam / low-quality patterns ───────────────────────────────────────── + +const REPEATED_CHAR_RE = /(.)\1{8,}/; // 9+ repeated chars +const REPEATED_WORD_RE = /\b(\w{3,})\b\s*\b\1\b\s*\b\1\b/; // same word 3x +const EXCESSIVE_CAPS_RE = /[A-Z]{6,}/; +const EXCESSIVE_EMOJI_RE = + /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/gu; +const BASE64_RE = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +const PHISHING_RE = + /(?:free\s*(?:nitro|gift|prime|steam|vbucks?)|click\s*(?:here|this)\s*(?:to|for)\s*(?:claim|win|verify)|login\s*:\s*\w+\s*password\s*:\s*\w+)/gi; + +// ── Toxicity patterns ─────────────────────────────────────────────────── + +const HARASSMENT_RE = + /\b(?:fuck|shit|asshole|bitch|dickhead|cunt|motherfucker|bastard|piss\s*off|screw\s*you|go\s*(?:to\s*)?hell|kys|kill\s*(?:yourself| urself))\b/i; +const HATE_SPEECH_RE = + /\b(?:nazi|white\s*supremacy|heil|racial\s*purity|race\s*war)\b/i; + +// ── Harm patterns ─────────────────────────────────────────────────────── + +const SELF_HARM_RE = + /\b(?:kill\s*(?:myself|me)|end\s*(?:my|the)\s*(?:life|own)|suicide|want\s*(?:to\s*)?die|cut\s*(?:myself|my\s*wrists)|harm\s*myself)\b/i; +const VIOLENCE_RE = + /\b(?:shoot|stab|bomb|massacre|terrorist|behead|torture|murder)\b/i; + +// ── Safety patterns ───────────────────────────────────────────────────── + +const PERSONAL_INFO_RE = + /\b(?:\d{3}-\d{2}-\d{4}|(?:\d{3}\s?){2}\d{4})\b/; // SSN / IDs +const SEXTORTION_RE = + /\b(?:nudes?\s*(?:pic|photo|video|send|trade)|cp\s*(?:content|link|loli|shotacon)|underage|minor\s*(?:girl|boy|content))\b/i; +const GROOMING_RE = + /\b(?:how\s*old\s*are\s*you|are\s*you\s*(?:alone|home\s*alone)|dm\s*me\s*(?:baby|honey|sweetie|cutie)|send\s*(?:nudes|pics))\b/i; + +// ── Mass-mention patterns ─────────────────────────────────────────────── + +const EVERYONE_MENTION = /@everyone/g; +const HERE_MENTION = /@here/g; +const ROLE_MENTION = /<@&(\d+)>/g; + +// ── Pattern registry (ordered roughly by specificity) ─────────────────── + +const PATTERNS: Pattern[] = [ + // ── High severity ───────────────────────────────────────────────── + { + name: "self_harm", + test: (c) => SELF_HARM_RE.test(c), + severity: "high", + score: 0.9, + category: "harm", + }, + { + name: "violence_threat", + test: (c) => VIOLENCE_RE.test(c), + severity: "high", + score: 0.85, + category: "harm", + }, + { + name: "sextortion", + test: (c) => SEXTORTION_RE.test(c), + severity: "high", + score: 0.95, + category: "safety", + }, + { + name: "grooming", + test: (c) => GROOMING_RE.test(c), + severity: "high", + score: 0.9, + category: "safety", + }, + { + name: "hate_speech", + test: (c) => HATE_SPEECH_RE.test(c), + severity: "high", + score: 0.85, + category: "toxicity", + }, + { + name: "harassment", + test: (c) => HARASSMENT_RE.test(c), + severity: "medium", + score: 0.6, + category: "toxicity", + }, + { + name: "phishing", + test: (c) => PHISHING_RE.test(c), + severity: "high", + score: 0.85, + category: "harm", + }, + // ── Spam / low quality ──────────────────────────────────────────── + { + name: "mass_everyone_mention", + test: (_c, mentions) => mentions >= 3, + severity: "medium", + score: 0.65, + category: "spam", + }, + { + name: "excessive_caps", + test: (c) => { + const caps = (c.match(EXCESSIVE_CAPS_RE) || []).join(""); + return caps.length > 0 && caps.length / Math.max(c.length, 1) > 0.5; + }, + severity: "low", + score: 0.3, + category: "spam", + }, + { + name: "repeated_characters", + test: (c) => REPEATED_CHAR_RE.test(c), + severity: "low", + score: 0.25, + category: "spam", + }, + { + name: "repeated_words", + test: (c) => REPEATED_WORD_RE.test(c), + severity: "low", + score: 0.25, + category: "spam", + }, + { + name: "zalgo_text", + test: (c) => ZALGO_RE.test(c) || ZERO_WIDTH_RE.test(c), + severity: "medium", + score: 0.6, + category: "spam", + }, + { + name: "excessive_emojis", + test: (c) => { + const emojiCount = (c.match(EXCESSIVE_EMOJI_RE) || []).length; + const textLen = c.replace(EXCESSIVE_EMOJI_RE, "").trim().length; + return emojiCount >= 5 && textLen < emojiCount; + }, + severity: "low", + score: 0.2, + category: "spam", + }, + { + name: "base64_gibberish", + test: (c) => c.length >= 20 && BASE64_RE.test(c.trim()), + severity: "low", + score: 0.2, + category: "spam", + }, + // ── Medium severity ─────────────────────────────────────────────── + { + name: "personal_info", + test: (c) => PERSONAL_INFO_RE.test(c), + severity: "medium", + score: 0.6, + category: "safety", + }, + { + name: "discord_invite", + test: (c) => INVITE_RE.test(c), + severity: "low", + score: 0.2, + category: "spam", + }, + { + name: "url_only", + test: (c) => { + const urls = c.match(URL_RE); + if (!urls) return false; + const textWithoutUrls = c.replace(URL_RE, "").trim(); + return urls.length >= 3 && textWithoutUrls.length === 0; + }, + severity: "low", + score: 0.25, + category: "spam", + }, + { + name: "phone_number", + test: (c) => PHONE_RE.test(c), + severity: "medium", + score: 0.5, + category: "safety", + }, + { + name: "email_address", + test: (c) => EMAIL_RE.test(c), + severity: "low", + score: 0.3, + category: "safety", + }, + { + name: "crypto_address", + test: (c) => CRYPTO_RE.test(c), + severity: "medium", + score: 0.5, + category: "spam", + }, + { + name: "ip_address_sharing", + test: (c) => IP_RE.test(c), + severity: "low", + score: 0.3, + category: "safety", + }, +]; + +// --------------------------------------------------------------------------- +// Pattern matcher — count mentions +// --------------------------------------------------------------------------- + +function countMentions(content: string): number { + let count = 0; + const everyoneMatches = content.match(EVERYONE_MENTION); + if (everyoneMatches) count += everyoneMatches.length; + const hereMatches = content.match(HERE_MENTION); + if (hereMatches) count += hereMatches.length; + const roleMatches = content.match(ROLE_MENTION); + if (roleMatches) count += roleMatches.length; + return count; +} + +// --------------------------------------------------------------------------- +// Main classifier +// --------------------------------------------------------------------------- + +/** + * Runs Layer 1 heuristic classification on a message. + * Returns a `Layer1Result` with matched flags and a decision on + * whether to cascade to Layer 2 (LLM). + */ +export function classifyMessage(message: MessageRecord): Layer1Result { + const content = message.edited_content ?? message.content; + if (!content || content.trim().length === 0) { + return { + flags: [], + severity: "none", + toxicityScore: 0, + harmScore: 0, + cascadeToLayer2: true, // empty content still needs metadata check + confidence: 0, + explanation: "No text content to classify", + }; + } + + const mentions = countMentions(content); + const matchedFlags: string[] = []; + const severityWeights: Record = { + low: 1, + medium: 2, + high: 3, + }; + + let maxSeverityWeight = 0; + let totalToxicityScore = 0; + let totalHarmScore = 0; + let totalSafetyScore = 0; + let totalSpamScore = 0; + const matchedPatternDetails: string[] = []; + + for (const pattern of PATTERNS) { + if (pattern.test(content, mentions)) { + matchedFlags.push(pattern.name); + matchedPatternDetails.push(pattern.name); + + const weight = severityWeights[pattern.severity] || 1; + maxSeverityWeight = Math.max(maxSeverityWeight, weight); + + switch (pattern.category) { + case "toxicity": + totalToxicityScore += pattern.score; + break; + case "harm": + totalHarmScore += pattern.score; + break; + case "safety": + totalSafetyScore += pattern.score; + break; + case "spam": + totalSpamScore += pattern.score; + break; + } + } + } + + // ── Determine final severity ─────────────────────────────────────── + + let finalSeverity: "none" | "low" | "medium" | "high" = "none"; + if (maxSeverityWeight >= 3) finalSeverity = "high"; + else if (maxSeverityWeight >= 2) finalSeverity = "medium"; + else if (maxSeverityWeight >= 1) finalSeverity = "low"; + + // ── Determine if we should cascade ───────────────────────────────── + // Cascade to Layer 2 whenever: + // 1. No high-severity match was found, OR + // 2. Only spam/low-quality patterns matched (need LLM for nuance) + // Do NOT cascade when a clear high-severity harm/safety/toxicity match + // was found — the heuristic is sufficient. + + const hasHighSeverityPattern = matchedFlags.some((f) => { + const p = PATTERNS.find((p) => p.name === f); + return p && p.severity === "high"; + }); + const hasOnlyLowSeveritySpam = matchedFlags.every((f) => { + const p = PATTERNS.find((p) => p.name === f); + return p && p.category === "spam" && p.severity !== "high"; + }); + + // Cascade if no matches, only spam, or low/medium severity toxicity/safety + const cascadeToLayer2 = + matchedFlags.length === 0 || + hasOnlyLowSeveritySpam || + (finalSeverity !== "high" && hasHighSeverityPattern === false); + + // ── Compute final scores (clamped 0-1) ───────────────────────────── + + const toxicityScore = Math.min(totalToxicityScore, 1); + const harmScore = Math.min(totalHarmScore, 1); + + // ── Confidence ───────────────────────────────────────────────────── + + const confidence = cascadeToLayer2 + ? 0.4 + 0.1 * matchedFlags.length // low confidence when punting to LLM + : 0.6 + 0.4 * (1 - matchedFlags.length / PATTERNS.length); + + const explanation = + matchedFlags.length > 0 + ? `Layer 1 matched: ${matchedPatternDetails.join(", ")}` + : "No heuristic patterns matched"; + + return { + flags: matchedFlags, + severity: finalSeverity, + toxicityScore, + harmScore, + cascadeToLayer2, + confidence: Math.min(confidence, 0.99), + explanation, + }; +} \ No newline at end of file diff --git a/services/discord-gateway/src/modules/ai-moderation/index.ts b/services/discord-gateway/src/modules/ai-moderation/index.ts index 7af2731..026e745 100644 --- a/services/discord-gateway/src/modules/ai-moderation/index.ts +++ b/services/discord-gateway/src/modules/ai-moderation/index.ts @@ -2,3 +2,8 @@ export { startPendingAIAnalysisWorker } from "./aiAnalyzer.js"; export { runModerationAnalysis } from "./moderationOrchestrator.js"; export { buildSystemPrompt } from "./moderationPrompt.js"; export { runSimpleTextFallback } from "./simpleFallback.js"; + +// ── New two-pass pipeline exports ────────────────────────────────────────── +export { classifyMessage } from "./fastClassifier.js"; +export type { Layer1Result } from "./fastClassifier.js"; +export type { AnalysisInput, AnalysisResult, WorkerConfig, MessageBatch } from "./ai-analysis-worker.js"; \ No newline at end of file diff --git a/services/frontend/src/app/(dashboard)/messages/page.tsx b/services/frontend/src/app/(dashboard)/messages/page.tsx index 12f2805..0f38500 100644 --- a/services/frontend/src/app/(dashboard)/messages/page.tsx +++ b/services/frontend/src/app/(dashboard)/messages/page.tsx @@ -20,7 +20,6 @@ import { SelectValue, } from "@/components/ui/select"; import { - useGuilds, useImages, useLoadMore, useMessageDetail, @@ -56,8 +55,16 @@ export default function MessagesPage() { const ws = useWebSocket(); const { data: channels = [] } = useTextChannels(guildId); - const { data: messages, isLoading, error, refetch } = useMessages(guildId, selectedChannel || undefined); - const { data: cursorData } = useMessagesHasMore(guildId, selectedChannel || undefined); + const { + data: messages, + isLoading, + error, + refetch, + } = useMessages(guildId, selectedChannel || undefined); + const { data: cursorData } = useMessagesHasMore( + guildId, + selectedChannel || undefined, + ); const loadMoreMut = useLoadMore(); const { data: images } = useImages(guildId); const { data: reviews } = useReview(selectedChannel || undefined); @@ -132,8 +139,10 @@ export default function MessagesPage() { All channels - {channels.map((ch: any) => ( - # {ch.name} + {channels.map((ch) => ( + + # {ch.name} + ))} diff --git a/services/frontend/src/app/(dashboard)/recordings/page.tsx b/services/frontend/src/app/(dashboard)/recordings/page.tsx index a68a795..874c0b6 100644 --- a/services/frontend/src/app/(dashboard)/recordings/page.tsx +++ b/services/frontend/src/app/(dashboard)/recordings/page.tsx @@ -10,8 +10,6 @@ import type { VoiceRecording } from "@/lib/types"; type RecordingsTab = "library" | "stats"; -type RecordingsTab = "library" | "stats"; - export default function RecordingsPage() { const { data: recordings, isLoading, error, refetch } = useRecordings(); const [playingId, setPlayingId] = useState(null); diff --git a/services/frontend/src/components/analysis/search-panel.tsx b/services/frontend/src/components/analysis/search-panel.tsx index c5b63ed..02ba6f3 100644 --- a/services/frontend/src/components/analysis/search-panel.tsx +++ b/services/frontend/src/components/analysis/search-panel.tsx @@ -64,8 +64,8 @@ export function SearchPanel() {

{results.length === 0 ? ( ) : (
diff --git a/services/frontend/src/components/dashboard/channel-detail-section.tsx b/services/frontend/src/components/dashboard/channel-detail-section.tsx new file mode 100644 index 0000000..0f77bc2 --- /dev/null +++ b/services/frontend/src/components/dashboard/channel-detail-section.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { ArrowLeft, Clock, Hash, Sparkles } from "lucide-react"; + +import { DetailStat, ErrorState, LoadingSkeleton } from "@/components/shared"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { useChannelDetail } from "@/hooks"; + +export function ChannelDetailSection({ + channelId, + onBack, +}: { + channelId: string; + onBack: () => void; +}) { + const { data: channel, isLoading } = useChannelDetail(channelId); + if (isLoading) return ; + if (!channel) return ; + + return ( +
+ + + +
+

+ + {channel.channel_name ?? channel.channel_id.slice(0, 8)} +

+

+ {channel.channel_id} +

+
+
+ + + +
+ {channel.culture_summary && ( +
+
+ +

+ Channel Culture +

+
+

+ “{channel.culture_summary}” +

+
+ )} + {channel.recent_messages.length > 0 && ( +
+

+ Recent + Messages +

+
+ {channel.recent_messages.slice(0, 5).map((msg) => ( +
+
+ + {msg.username} + + + {new Date(msg.created_at).toLocaleString()} + +
+

{msg.content}

+
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/services/frontend/src/components/dashboard/channels-section.tsx b/services/frontend/src/components/dashboard/channels-section.tsx new file mode 100644 index 0000000..db4b97b --- /dev/null +++ b/services/frontend/src/components/dashboard/channels-section.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { ChevronRight, Hash, Search } from "lucide-react"; +import { useState } from "react"; + +import { EmptyState, LoadingSkeleton } from "@/components/shared"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { useChannels } from "@/hooks"; + +export function ChannelsSection({ + guildId, + onSelect, +}: { + guildId: string; + onSelect: (id: string) => void; +}) { + const [search, setSearch] = useState(""); + const { + data: channels, + isLoading, + refetch, + } = useChannels(guildId, search || undefined); + + return ( +
+
+ + setSearch(e.target.value)} + className="pl-9 h-9" + /> +
+ {isLoading ? ( + + ) : !channels || channels.length === 0 ? ( + + ) : ( +
+ {channels.map((ch) => ( + onSelect(ch.channel_id)} + > + +
+
+
+ +

+ {ch.channel_name ?? ch.channel_id.slice(0, 8)} +

+
+

+ {ch.total_messages} messages + {ch.flagged_count > 0 + ? ` · ${ch.flagged_count} flagged` + : ""} +

+
+ +
+ {ch.culture_summary && ( +

+ “{ch.culture_summary}” +

+ )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/services/frontend/src/components/dashboard/index.ts b/services/frontend/src/components/dashboard/index.ts new file mode 100644 index 0000000..d496c3a --- /dev/null +++ b/services/frontend/src/components/dashboard/index.ts @@ -0,0 +1,5 @@ +export { ChannelDetailSection } from "./channel-detail-section"; +export { ChannelsSection } from "./channels-section"; +export { StatsSection } from "./stats-section"; +export { UserDetailSection } from "./user-detail-section"; +export { UsersSection } from "./users-section"; diff --git a/services/frontend/src/components/dashboard/stats-section.tsx b/services/frontend/src/components/dashboard/stats-section.tsx new file mode 100644 index 0000000..27b46ee --- /dev/null +++ b/services/frontend/src/components/dashboard/stats-section.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { + AlertCircle, + Clock, + Hash, + Shield, + Sparkles, + Users, +} from "lucide-react"; + +import { ErrorState, LoadingSkeleton, StatCard } from "@/components/shared"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { useStats } from "@/hooks"; +import { formatNumber } from "@/lib/format"; + +export function StatsSection() { + const { data: stats, isLoading, error, refetch } = useStats(); + if (error) return ; + if (isLoading || !stats) + return ( +
+ +
+ ); + + return ( +
+
+ + + + + + + + +
+
+ + + + Top Channels + + + + {stats.top_channels.length === 0 ? ( +

+ No channel data yet. +

+ ) : ( +
+ {stats.top_channels.map((ch) => { + const max = stats.top_channels[0].message_count; + const pct = max > 0 ? (ch.message_count / max) * 100 : 0; + return ( +
+
+ + #{ch.channel_name ?? ch.channel_id.slice(0, 8)} + + + {formatNumber(ch.message_count)} + +
+ +
+ ); + })} +
+ )} +
+
+ + + + Moderation + Queue + + + +
+ {[ + { + label: "Pending", + value: stats.moderation_overview.pending, + cls: "bg-muted/50", + }, + { + label: "Processing", + value: stats.moderation_overview.processing, + cls: "bg-yellow-500/10 text-yellow-500", + }, + { + label: "Errors", + value: stats.moderation_overview.error, + cls: "bg-destructive/10 text-destructive", + }, + ].map(({ label, value, cls }) => ( +
+
+ {value} +
+
{label}
+
+ ))} +
+
+
+
+
+ ); +} diff --git a/services/frontend/src/components/dashboard/user-detail-section.tsx b/services/frontend/src/components/dashboard/user-detail-section.tsx new file mode 100644 index 0000000..c8cf18e --- /dev/null +++ b/services/frontend/src/components/dashboard/user-detail-section.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { ArrowLeft, Clock, Sparkles } from "lucide-react"; +import Image from "next/image"; + +import { DetailStat, ErrorState, LoadingSkeleton } from "@/components/shared"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { useUserDetail } from "@/hooks"; + +export function UserDetailSection({ + userId, + onBack, +}: { + userId: string; + onBack: () => void; +}) { + const { data: user, isLoading } = useUserDetail(userId); + if (isLoading) return ; + if (!user) return ; + + return ( +
+ + + +
+
+ {user.avatar_url ? ( + + ) : ( + (user.username ?? "?").charAt(0).toUpperCase() + )} +
+
+

+ {user.username ?? "Unknown"} +

+

+ {user.user_id} +

+
+
+
+ + + + +
+ {user.profile_summary && ( +
+
+ +

+ AI Profile +

+
+

{user.profile_summary}

+
+ )} + {user.recent_messages.length > 0 && ( +
+

+ Recent + Messages +

+
+ {user.recent_messages.slice(0, 5).map((msg) => ( +
+

+ + {new Date(msg.created_at).toLocaleString()} +

+

{msg.content}

+
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/services/frontend/src/components/dashboard/users-section.tsx b/services/frontend/src/components/dashboard/users-section.tsx new file mode 100644 index 0000000..3477ce0 --- /dev/null +++ b/services/frontend/src/components/dashboard/users-section.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { ChevronRight, Search, Users } from "lucide-react"; +import Image from "next/image"; +import { useState } from "react"; + +import { EmptyState, LoadingSkeleton } from "@/components/shared"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { useUsers } from "@/hooks"; + +export function UsersSection({ onSelect }: { onSelect: (id: string) => void }) { + const [search, setSearch] = useState(""); + const { data: users, isLoading } = useUsers(search || undefined); + + return ( +
+
+ + setSearch(e.target.value)} + className="pl-9 h-9" + /> +
+ {isLoading ? ( + + ) : !users || users.length === 0 ? ( + + ) : ( +
+ {users.map((u) => ( + onSelect(u.user_id)} + > + +
+
+ {u.avatar_url ? ( + + ) : ( + (u.username ?? "?").charAt(0).toUpperCase() + )} +
+
+

+ {u.username ?? "Unknown"} +

+

+ {u.total_messages} messages + {u.flagged_count > 0 && ( + + {u.flagged_count} flagged + + )} +

+
+ +
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/services/frontend/src/components/layout/mobile-nav.tsx b/services/frontend/src/components/layout/mobile-nav.tsx index 045b394..e234b12 100644 --- a/services/frontend/src/components/layout/mobile-nav.tsx +++ b/services/frontend/src/components/layout/mobile-nav.tsx @@ -2,7 +2,8 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { mobileNavItems, isActivePath } from "@/lib/navigation"; + +import { isActivePath, mobileNavItems } from "@/lib/navigation"; import { cn } from "@/lib/utils"; export function MobileNav() { diff --git a/services/frontend/src/components/messages/images-grid.tsx b/services/frontend/src/components/messages/images-grid.tsx new file mode 100644 index 0000000..15e5997 --- /dev/null +++ b/services/frontend/src/components/messages/images-grid.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { ImageIcon } from "lucide-react"; +import { Card } from "@/components/ui/card"; +import type { MessageRecord } from "@/lib/types"; +import { extractFirstImage } from "./message-card"; + +export function ImagesGrid({ + images, + onSelect, +}: { + images: MessageRecord[]; + onSelect: (id: string) => void; +}) { + if (!images || images.length === 0) { + return ( +
+ +

No images yet.

+
+ ); + } + + return ( +
+ {images.map((msg) => { + const imgUrl = extractFirstImage(msg.metadata); + return ( + onSelect(msg.id)} + > +
+ {imgUrl ? ( + {msg.content + ) : ( +
+ No image +
+ )} + {msg.content && ( +
+

+ {msg.username}: {msg.content} +

+
+ )} +
+
+ ); + })} +
+ ); +} diff --git a/services/frontend/src/components/messages/message-card.tsx b/services/frontend/src/components/messages/message-card.tsx index 30269a3..c498e07 100644 --- a/services/frontend/src/components/messages/message-card.tsx +++ b/services/frontend/src/components/messages/message-card.tsx @@ -1,87 +1,160 @@ "use client"; -import { cn } from "@/lib/utils"; +import { Hash, RefreshCw } from "lucide-react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { safeParseJsonArray } from "@/lib/format"; import type { MessageRecord } from "@/lib/types"; +import { cn } from "@/lib/utils"; +import { AiStatusBadge } from "./ai-status-badge"; -interface MessageCardProps { +export function MessageCard({ + message: msg, + onClick, + onReanalyze, +}: { message: MessageRecord; - selected?: boolean; - onClick?: (id: string) => void; -} - -const severityDot: Record = { - clean: "bg-emerald-500 shadow-[0_0_6px] shadow-emerald-500/60", - pending: "bg-text-secondary/30", - warn: "bg-accent-amber shadow-[0_0_6px] shadow-accent-amber/60", - flagged: "bg-accent-purple shadow-[0_0_6px] shadow-accent-purple/60", - error: "bg-destructive/60", -}; - -function formatRelativeTime(timestamp: number): string { - const diff = Date.now() - timestamp; - const mins = Math.floor(diff / 60000); - if (mins < 1) return "just now"; - if (mins < 60) return `${mins}m`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h`; - const days = Math.floor(hours / 24); - return `${days}d`; -} - -export function MessageCard({ message, selected, onClick }: MessageCardProps) { - const status = message.ai_status || "pending"; + onClick: (id: string) => void; + onReanalyze: (id: string) => void; +}) { + const severity = ( + { + low: "border-l-cyan-500/40", + medium: "border-l-amber-500/60", + high: "border-l-orange-500/70", + critical: "border-l-red-500/80", + } as Record + )[msg.ai_severity ?? ""]; return ( - +
- - + + ); } + +export function extractFirstImage( + metadata: string | null | undefined, +): string | null { + if (!metadata) return null; + try { + const m = JSON.parse(metadata); + const atts: Array<{ url: string; contentType?: string }> = + m.attachments ?? []; + return atts.find((a) => a.contentType?.startsWith("image/"))?.url ?? null; + } catch { + return null; + } +} diff --git a/services/frontend/src/components/messages/message-detail-view.tsx b/services/frontend/src/components/messages/message-detail-view.tsx new file mode 100644 index 0000000..84136a5 --- /dev/null +++ b/services/frontend/src/components/messages/message-detail-view.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { ExternalLink, Sparkles } from "lucide-react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; +import { formatBytes, safeParseJsonArray } from "@/lib/format"; +import type { MessageRecord } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +function MiniStat({ + label, + value, + destructive, + capitalize, +}: { + label: string; + value: string; + destructive?: boolean; + capitalize?: boolean; +}) { + return ( + + +

{label}

+

+ {value} +

+
+
+ ); +} + +export function MessageDetailView({ + message, + attachments, +}: { + message: MessageRecord; + attachments: { + id: string; + filename: string; + type: string; + size: number; + uploaded_url?: string | null; + discord_url?: string | null; + }[]; +}) { + return ( +
+
+ + + + {message.username.charAt(0).toUpperCase()} + + +
+
+ {message.username} + + {new Date(message.created_at).toLocaleString()} + + {message.type === "deleted" && ( + + deleted + + )} + {message.type === "edited" && ( + + edited + + )} +
+

+ {message.content} +

+
+
+ {message.ai_analysis && ( +
+
+ +

+ AI Analysis +

+
+

{message.ai_analysis}

+
+ )} + {message.ai_moderation_flags && message.ai_moderation_flags !== "[]" && ( +
+

+ Moderation Flags +

+
+ {safeParseJsonArray(message.ai_moderation_flags).map((f) => ( + + {f} + + ))} +
+
+ )} +
+ {message.ai_status && ( + + )} + {message.ai_severity && message.ai_severity !== "none" && ( + + )} + {message.ai_confidence != null && ( + + )} + {message.ai_recommended_action && + message.ai_recommended_action !== "none" && ( + + )} +
+ {attachments.length > 0 && ( +
+

+ Attachments ({attachments.length}) +

+ +
+ )} +
+ ); +} diff --git a/services/frontend/src/components/messages/review-list.tsx b/services/frontend/src/components/messages/review-list.tsx new file mode 100644 index 0000000..ed5624a --- /dev/null +++ b/services/frontend/src/components/messages/review-list.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Flag } from "lucide-react"; +import type { MessageRecord } from "@/lib/types"; +import { MessageCard } from "./message-card"; + +export function ReviewList({ + reviews, + onSelect, + onReanalyze, +}: { + reviews: MessageRecord[]; + onSelect: (id: string) => void; + onReanalyze: (id: string) => void; +}) { + if (!reviews || reviews.length === 0) { + return ( +
+ +

+ No flagged messages to review. +

+
+ ); + } + + return ( +
+ {reviews.map((msg) => ( + + ))} +
+ ); +} diff --git a/services/frontend/src/components/recordings/recording-list.tsx b/services/frontend/src/components/recordings/recording-list.tsx new file mode 100644 index 0000000..f9e164a --- /dev/null +++ b/services/frontend/src/components/recordings/recording-list.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { Download, Headphones, Trash2 } from "lucide-react"; + +import { EmptyState, LoadingSkeleton } from "@/components/shared"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + useDeleteRecording, + useRecordings, + useRecordingsWsSync, +} from "@/hooks"; +import { formatBytes } from "@/lib/format"; +import type { WsHook } from "@/lib/ws-hook"; + +interface RecordingListProps { + ws: WsHook; +} + +export function RecordingList({ ws }: RecordingListProps) { + const { data: recordings, isLoading } = useRecordings(); + const deleteMut = useDeleteRecording(); + + useRecordingsWsSync(ws); + + return ( + + + + + Voice Recordings + + + + {isLoading ? ( + + ) : !recordings || recordings.length === 0 ? ( + + ) : ( +
+ {recordings.map((rec) => ( +
+ + + + {(rec.username ?? "?").charAt(0).toUpperCase()} + + +
+

{rec.username}

+

+ {rec.channel_name ?? rec.channel_id ?? "Unknown channel"} —{" "} + {new Date(rec.created_at).toLocaleString()} +

+
+ + {formatBytes(rec.size_bytes)} + + {rec.download_url && ( + + )} + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/services/frontend/src/components/shared/detail-stat.tsx b/services/frontend/src/components/shared/detail-stat.tsx new file mode 100644 index 0000000..daa0a73 --- /dev/null +++ b/services/frontend/src/components/shared/detail-stat.tsx @@ -0,0 +1,42 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { formatNumber } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +interface DetailStatProps { + label: string; + value: number; + variant?: "default" | "danger" | "success"; + suffix?: string; +} + +const valueColor = { + default: "", + danger: "text-red-400", + success: "text-emerald-400", +}; + +/** + * Small stat label used inside detail views. + */ +export function DetailStat({ + label, + value, + variant = "default", + suffix, +}: DetailStatProps) { + return ( + + +

+ {label} +

+

+ {formatNumber(value)} + {suffix} +

+
+
+ ); +} diff --git a/services/frontend/src/components/shared/index.ts b/services/frontend/src/components/shared/index.ts index 3703758..fd1a6c0 100644 --- a/services/frontend/src/components/shared/index.ts +++ b/services/frontend/src/components/shared/index.ts @@ -1,4 +1,6 @@ +export { DetailStat } from "./detail-stat"; export { EmptyState } from "./empty-state"; export { ErrorBoundary } from "./error-boundary"; export { ErrorState } from "./error-state"; export { LoadingSkeleton } from "./loading-skeleton"; +export { StatCard } from "./stat-card"; diff --git a/services/frontend/src/components/shared/stat-card.tsx b/services/frontend/src/components/shared/stat-card.tsx new file mode 100644 index 0000000..3073a50 --- /dev/null +++ b/services/frontend/src/components/shared/stat-card.tsx @@ -0,0 +1,78 @@ +import type { LucideIcon } from "lucide-react"; + +import { Card, CardContent } from "@/components/ui/card"; +import { formatNumber } from "@/lib/format"; +import { cn } from "@/lib/utils"; + +interface StatCardProps { + label: string; + value: number; + icon: LucideIcon; + variant?: "default" | "danger" | "success" | "warning"; +} + +const variantStyles = { + default: "from-cyan-500/10 to-teal-500/5 border-cyan-500/20", + danger: "from-red-500/10 to-rose-500/5 border-red-500/20", + success: "from-emerald-500/10 to-green-500/5 border-emerald-500/20", + warning: "from-amber-500/10 to-yellow-500/5 border-amber-500/20", +}; + +const iconBg = { + default: "bg-cyan-500/15 text-cyan-400", + danger: "bg-red-500/15 text-red-400", + success: "bg-emerald-500/15 text-emerald-400", + warning: "bg-amber-500/15 text-amber-400", +}; + +const valueColor = { + default: "", + danger: "text-red-400", + success: "text-emerald-400", + warning: "text-amber-400", +}; + +/** + * Metric card used across dashboard and landing pages. + */ +export function StatCard({ + label, + value, + icon: Icon, + variant = "default", +}: StatCardProps) { + return ( + + +
+
+

+ {label} +

+

+ {formatNumber(value)} +

+
+
+ +
+
+
+
+ ); +} diff --git a/services/frontend/src/lib/navigation.ts b/services/frontend/src/lib/navigation.ts index 7853440..f43438f 100644 --- a/services/frontend/src/lib/navigation.ts +++ b/services/frontend/src/lib/navigation.ts @@ -1,23 +1,26 @@ import { + Headphones, LayoutDashboard, + type LucideIcon, MessageSquare, Mic, - Headphones, + Music, + Search, Settings, - type LucideIcon, } from "lucide-react"; export interface NavItem { href: string; label: string; icon: LucideIcon; -} - -export interface NavItemWithMatch extends NavItem { + /** The pathname prefix that indicates this item is active */ matchPrefix: string; } -export const navItems: NavItemWithMatch[] = [ +/** + * Primary navigation items shown in the sidebar. + */ +export const navItems: NavItem[] = [ { href: "/dashboard", label: "Dashboard", @@ -36,12 +39,24 @@ export const navItems: NavItemWithMatch[] = [ icon: Mic, matchPrefix: "/voice", }, + { + href: "/media", + label: "Media", + icon: Music, + matchPrefix: "/media", + }, { href: "/recordings", label: "Recordings", icon: Headphones, matchPrefix: "/recordings", }, + { + href: "/analysis", + label: "Search", + icon: Search, + matchPrefix: "/analysis", + }, { href: "/settings", label: "Settings", @@ -50,14 +65,16 @@ export const navItems: NavItemWithMatch[] = [ }, ]; -export const mobileNavItems: NavItemWithMatch[] = navItems.filter((item) => - ["/dashboard", "/messages", "/voice", "/recordings"].includes(item.href), +/** + * Mobile bottom bar items (subset of primary nav). + */ +export const mobileNavItems: NavItem[] = navItems.filter((item) => + ["/dashboard", "/messages", "/voice", "/media"].includes(item.href), ); -export function isActivePath( - pathname: string, - matchPrefix: string, -): boolean { +export type NavItemId = (typeof navItems)[number]["href"]; + +export function isActivePath(pathname: string, matchPrefix: string): boolean { if (matchPrefix === "/dashboard") return pathname === "/dashboard"; return pathname.startsWith(matchPrefix); }