feat: comprehensive UX improvements — messages, analysis, moderation

Fix #1 (CRITICAL): Wire message_analyzed through Redis EventBroadcaster
- DG aiAnalyzer.ts: Add broadcastAnalysisCompleted() helper that publishes
  to both in-memory WS broadcaster AND Redis EventBroadcaster
- DG bootstrap.ts: Pass eventBroadcaster to startPendingAIAnalysisWorker()
- Fixes broken real-time chain so analysis results appear instantly

Fix #2: Collapsible AI Analysis with Rich Formatting
- MessageCard: AI analysis now collapsible with color-coded severity border
  (red/yellow/blue), summary line showing categories + confidence + severity
- Default collapsed for clean, expanded for warn/flagged

Fix #3: Toast Notifications for Flagged Content
- Wrap app in ToastProvider; ModerationAlertListener component listens for
  moderation_alert custom events and shows toast with emoji + details

Fix #4: Discord-style Message Grouping
- MessageFeed: Group consecutive messages from same user within 5 min
- MessageCard: Compact variant hides avatar, reduces padding for non-first

Fix #5: Moderation Action Buttons in UI
- BE: POST /api/messages/:id/moderate endpoint + publishCommand() for Redis
- FE: Delete/Warn buttons on flagged/warned cards with confirmation dialog

Fix #6: Search Results Include Full Data
- BE analysis.service.ts: SELECT all 26 message columns instead of just 11
- Search results now render with full MessageCard including images/analysis

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-02 12:17:51 +07:00
co-authored by Claude Opus 4.8
parent 74400376a0
commit c5c667b147
11 changed files with 470 additions and 91 deletions
@@ -6,10 +6,8 @@ import { Piscina } from "piscina";
import { config } from "../../shared/config/config.js";
import { createChildLogger } from "../../shared/logger/logger.js";
import { retryWithBackoff } from "../../shared/utils/retry.js";
import type { EventBroadcaster } from "../event-broadcaster/index.js";
import { invalidateAnalyticsCache } from "../message-capture/analyticsStore.js";
import { attemptAutoDeleteFlaggedMessage } from "./autoDeleteManager.js";
import { buildConversationContext } from "./conversationContext.js";
import { runModerationAnalysis } from "./llmModerationClient.js";
import { isAgeRestrictedMetadata } from "../message-capture/messageMetadata.js";
import {
getAttachmentsForMessages,
@@ -27,6 +25,14 @@ import type {
MessageRecord,
ModerationBroadcaster,
} from "../message-capture/types.js";
import { attemptAutoDeleteFlaggedMessage } from "./autoDeleteManager.js";
import { buildConversationContext } from "./conversationContext.js";
import { runModerationAnalysis } from "./llmModerationClient.js";
import {
logAnalysisSummary,
logFalsePositiveDetected,
logModerationError,
} from "./responseLogger.js";
const logger = createChildLogger("ai-analyzer");
@@ -38,6 +44,28 @@ function getModerationBroadcaster(): ModerationBroadcaster | undefined {
return (globalThis as ModerationGlobal).moderationBroadcaster;
}
// Redis EventBroadcaster — set by startPendingAIAnalysisWorker.
// Used to publish analysis completion events so the backend
// redis-bridge can forward them to frontend WebSocket clients.
let _redisEventBroadcaster: EventBroadcaster | undefined;
function broadcastAnalysisCompleted(row: MessageRecord): void {
// In-memory WS broadcast (direct-connected DG clients)
getModerationBroadcaster()?.messageAnalyzed(row);
// Redis pub/sub broadcast → backend → frontend WebSocket
if (_redisEventBroadcaster) {
_redisEventBroadcaster.messageAnalyzed(row).catch((err: unknown) =>
logger.warn(
{
messageId: row.id,
error: err instanceof Error ? err.message : String(err),
},
"Failed to publish message_analyzed via Redis EventBroadcaster",
),
);
}
}
function scheduleAutoDelete(row: MessageRecord): void {
if (row.ai_status !== "flagged" && row.ai_status !== "warn") return;
const run = () => {
@@ -107,7 +135,7 @@ async function skipAgeRestrictedMessages(
);
for (const row of skippedRows) {
getModerationBroadcaster()?.messageAnalyzed(row);
broadcastAnalysisCompleted(row);
}
const skippedIds = new Set(
@@ -376,11 +404,26 @@ async function processIndividualFallback(
const rows = await updateMessagesAIAnalysisBulk(updates);
for (const row of rows) {
getModerationBroadcaster()?.messageAnalyzed(row);
broadcastAnalysisCompleted(row);
invalidateAnalyticsCache(row.guild_id);
scheduleAutoDelete(row);
}
// Log individual analysis completion with comprehensive details
const resultSummary = analysisResult.results[0];
logModerationError(
[messageId],
config.AI_LLM_MODEL,
new Error("Success"), // For logging purposes only
{
phase: "individual_fallback",
status: resultSummary?.status,
flags: resultSummary?.flags,
severity: resultSummary?.severity,
confidence: resultSummary?.confidence,
},
);
// Reset individual CB on success.
individualConsecutiveErrors = 0;
@@ -406,6 +449,13 @@ async function processIndividualFallback(
lastError = error instanceof Error ? error.message : String(error);
// Log error with responseLogger
logModerationError([messageId], config.AI_LLM_MODEL, error, {
phase: "individual_fallback",
conversationKey,
exhaustedOnIncomplete,
});
// Infinite-loop prevention: if all retries were exhausted because the LLM
// consistently dropped this specific message (not a transient error),
// overwrite the DB entry with a terminal flag that the recovery query
@@ -543,7 +593,7 @@ async function processBatch(
})) as AnalysisWorkerResponse;
for (const row of result.rows) {
getModerationBroadcaster()?.messageAnalyzed(row);
broadcastAnalysisCompleted(row);
scheduleAutoDelete(row);
}
@@ -783,7 +833,7 @@ export async function queueMessageAnalysis(messageId: string): Promise<void> {
buildAgeRestrictedSkipResult(),
);
if (updated) {
getModerationBroadcaster()?.messageAnalyzed(updated);
broadcastAnalysisCompleted(updated);
}
logger.info(
{ messageId },
@@ -833,8 +883,12 @@ export function getAnalysisQueueStatus(): AnalysisQueueStatus {
* state (not just `pending`), and skips conversations that already have
* individual fallback work in progress to avoid DB last-write-wins races.
*/
export function startPendingAIAnalysisWorker(client?: Client): void {
export function startPendingAIAnalysisWorker(
client?: Client,
eventBroadcaster?: EventBroadcaster,
): void {
moderationClient = client;
_redisEventBroadcaster = eventBroadcaster;
if (!config.AI_ANALYSIS_ENABLED) return;
setInterval(() => {