Merge pull request #8 from MythEclipse/feat/ux-improvements-messages-analysis
feat: comprehensive UX improvements — messages, analysis, moderation
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { getDatabase } from "../../shared/database/index.js";
|
|
||||||
import { config } from "../../shared/config/index.js";
|
import { config } from "../../shared/config/index.js";
|
||||||
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
import { createChildLogger } from "../../shared/logger/index.js";
|
import { createChildLogger } from "../../shared/logger/index.js";
|
||||||
|
|
||||||
const logger = createChildLogger("analysis.service");
|
const logger = createChildLogger("analysis.service");
|
||||||
@@ -11,6 +11,17 @@ export interface AnalysisSearchQuery {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Full message columns for search results — matches MessageRecord from client.ts */
|
||||||
|
const FULL_COLUMNS = sql.raw(`
|
||||||
|
id, guild_id, channel_id, thread_id,
|
||||||
|
user_id, username, avatar_url,
|
||||||
|
content, edited_content, created_at, edited_at, deleted_at,
|
||||||
|
type, metadata,
|
||||||
|
ai_status, ai_moderation_flags, ai_moderation_score,
|
||||||
|
ai_analysis, ai_categories, ai_severity, ai_confidence,
|
||||||
|
ai_recommended_action, ai_analyzed_at, ai_error
|
||||||
|
`);
|
||||||
|
|
||||||
export class AnalysisService {
|
export class AnalysisService {
|
||||||
async search(query: AnalysisSearchQuery) {
|
async search(query: AnalysisSearchQuery) {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
@@ -25,8 +36,7 @@ export class AnalysisService {
|
|||||||
let sqlQuery;
|
let sqlQuery;
|
||||||
if (channelId && guildId) {
|
if (channelId && guildId) {
|
||||||
sqlQuery = sql`
|
sqlQuery = sql`
|
||||||
SELECT id, guild_id, channel_id, user_id, username, avatar_url,
|
SELECT ${FULL_COLUMNS}
|
||||||
content, type, created_at, ai_status, ai_severity, ai_confidence
|
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE guild_id = ${guildId}
|
WHERE guild_id = ${guildId}
|
||||||
AND channel_id = ${channelId}
|
AND channel_id = ${channelId}
|
||||||
@@ -36,8 +46,7 @@ export class AnalysisService {
|
|||||||
`;
|
`;
|
||||||
} else if (channelId) {
|
} else if (channelId) {
|
||||||
sqlQuery = sql`
|
sqlQuery = sql`
|
||||||
SELECT id, guild_id, channel_id, user_id, username, avatar_url,
|
SELECT ${FULL_COLUMNS}
|
||||||
content, type, created_at, ai_status, ai_severity, ai_confidence
|
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE channel_id = ${channelId}
|
WHERE channel_id = ${channelId}
|
||||||
AND content ILIKE ${searchPattern}
|
AND content ILIKE ${searchPattern}
|
||||||
@@ -46,8 +55,7 @@ export class AnalysisService {
|
|||||||
`;
|
`;
|
||||||
} else if (guildId) {
|
} else if (guildId) {
|
||||||
sqlQuery = sql`
|
sqlQuery = sql`
|
||||||
SELECT id, guild_id, channel_id, user_id, username, avatar_url,
|
SELECT ${FULL_COLUMNS}
|
||||||
content, type, created_at, ai_status, ai_severity, ai_confidence
|
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE guild_id = ${guildId}
|
WHERE guild_id = ${guildId}
|
||||||
AND content ILIKE ${searchPattern}
|
AND content ILIKE ${searchPattern}
|
||||||
@@ -56,8 +64,7 @@ export class AnalysisService {
|
|||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
sqlQuery = sql`
|
sqlQuery = sql`
|
||||||
SELECT id, guild_id, channel_id, user_id, username, avatar_url,
|
SELECT ${FULL_COLUMNS}
|
||||||
content, type, created_at, ai_status, ai_severity, ai_confidence
|
|
||||||
FROM messages
|
FROM messages
|
||||||
WHERE content ILIKE ${searchPattern}
|
WHERE content ILIKE ${searchPattern}
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
|
|||||||
@@ -91,5 +91,72 @@ export function createMessagesRouter(): Router {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// POST /api/messages/:id/moderate — Trigger moderation action via DG
|
||||||
|
router.post(
|
||||||
|
"/messages/:id/moderate",
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const id = req.params.id;
|
||||||
|
if (!id) {
|
||||||
|
res.status(400).json({ error: "MISSING_ID" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { actionType, reason } = (req.body ?? {}) as {
|
||||||
|
actionType?: string;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowedActions = [
|
||||||
|
"delete_message",
|
||||||
|
"warn_user",
|
||||||
|
"kick_user",
|
||||||
|
"ban_user",
|
||||||
|
"mute_user",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!actionType || !allowedActions.includes(actionType)) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: "INVALID_ACTION",
|
||||||
|
message: `actionType must be one of: ${allowedActions.join(", ")}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the message to get guild/user context
|
||||||
|
const pool = getPool();
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT id, guild_id, channel_id, thread_id, user_id, content
|
||||||
|
FROM messages WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
res.status(404).json({ error: "MESSAGE_NOT_FOUND" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = rows[0] as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Publish command to DG via Redis
|
||||||
|
const { publishCommand } = await import("../../ws/redis-bridge.js");
|
||||||
|
await publishCommand({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
type: "moderation:action",
|
||||||
|
payload: {
|
||||||
|
messageId: id,
|
||||||
|
guildId: String(msg.guild_id ?? ""),
|
||||||
|
channelId: (msg.thread_id as string) || String(msg.channel_id ?? ""),
|
||||||
|
userId: String(msg.user_id ?? ""),
|
||||||
|
actionType,
|
||||||
|
reason: reason ?? "Manual moderation from dashboard",
|
||||||
|
requestedAt: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info({ id, actionType, reason }, "Moderation action dispatched");
|
||||||
|
res.json({ ok: true, actionType, messageId: id });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ const SUBSCRIPTIONS: ChannelMapping[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
let subscriber: Redis | null = null;
|
let subscriber: Redis | null = null;
|
||||||
|
let publisher: Redis | null = null;
|
||||||
|
|
||||||
function createSubscriber(): Redis {
|
function createRedisInstance(): Redis {
|
||||||
if (config.REDIS_URL) {
|
if (config.REDIS_URL) {
|
||||||
return new Redis(config.REDIS_URL, { keyPrefix: "" });
|
return new Redis(config.REDIS_URL, { keyPrefix: "" });
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,35 @@ function createSubscriber(): Redis {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSubscriber(): Redis {
|
||||||
|
return createRedisInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublisher(): Redis {
|
||||||
|
if (!publisher) {
|
||||||
|
publisher = createRedisInstance();
|
||||||
|
}
|
||||||
|
return publisher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish a command to the Discord Gateway via Redis.
|
||||||
|
* The DG's commandHandler listens on "backend:command" channel.
|
||||||
|
*/
|
||||||
|
export async function publishCommand(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const pub = getPublisher();
|
||||||
|
const envelope = {
|
||||||
|
type: "command",
|
||||||
|
data: payload,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
source: "backend",
|
||||||
|
};
|
||||||
|
await pub.publish("backend:command", JSON.stringify(envelope));
|
||||||
|
logger.debug({ payload }, "Published command to DG");
|
||||||
|
}
|
||||||
|
|
||||||
function handleSubscriptionMessage(channel: string, message: string): void {
|
function handleSubscriptionMessage(channel: string, message: string): void {
|
||||||
const mapping = SUBSCRIPTIONS.find((m) => m.channel === channel);
|
const mapping = SUBSCRIPTIONS.find((m) => m.channel === channel);
|
||||||
if (!mapping) {
|
if (!mapping) {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export async function initializeDiscordGateway() {
|
|||||||
logger.info({ user: client.user?.tag }, "Bot logged in");
|
logger.info({ user: client.user?.tag }, "Bot logged in");
|
||||||
setEventBroadcaster(eventBroadcaster);
|
setEventBroadcaster(eventBroadcaster);
|
||||||
registerMessageCapture(client);
|
registerMessageCapture(client);
|
||||||
startPendingAIAnalysisWorker(client);
|
startPendingAIAnalysisWorker(client, eventBroadcaster);
|
||||||
|
|
||||||
// Start command handler after Discord is ready
|
// Start command handler after Discord is ready
|
||||||
commandHandler.start(client, voiceController);
|
commandHandler.start(client, voiceController);
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import { Piscina } from "piscina";
|
|||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
import { createChildLogger } from "../../shared/logger/logger.js";
|
import { createChildLogger } from "../../shared/logger/logger.js";
|
||||||
import { retryWithBackoff } from "../../shared/utils/retry.js";
|
import { retryWithBackoff } from "../../shared/utils/retry.js";
|
||||||
|
import type { EventBroadcaster } from "../event-broadcaster/index.js";
|
||||||
import { invalidateAnalyticsCache } from "../message-capture/analyticsStore.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 { isAgeRestrictedMetadata } from "../message-capture/messageMetadata.js";
|
||||||
import {
|
import {
|
||||||
getAttachmentsForMessages,
|
getAttachmentsForMessages,
|
||||||
@@ -27,6 +25,14 @@ import type {
|
|||||||
MessageRecord,
|
MessageRecord,
|
||||||
ModerationBroadcaster,
|
ModerationBroadcaster,
|
||||||
} from "../message-capture/types.js";
|
} 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");
|
const logger = createChildLogger("ai-analyzer");
|
||||||
|
|
||||||
@@ -38,6 +44,28 @@ function getModerationBroadcaster(): ModerationBroadcaster | undefined {
|
|||||||
return (globalThis as ModerationGlobal).moderationBroadcaster;
|
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 {
|
function scheduleAutoDelete(row: MessageRecord): void {
|
||||||
if (row.ai_status !== "flagged" && row.ai_status !== "warn") return;
|
if (row.ai_status !== "flagged" && row.ai_status !== "warn") return;
|
||||||
const run = () => {
|
const run = () => {
|
||||||
@@ -107,7 +135,7 @@ async function skipAgeRestrictedMessages(
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const row of skippedRows) {
|
for (const row of skippedRows) {
|
||||||
getModerationBroadcaster()?.messageAnalyzed(row);
|
broadcastAnalysisCompleted(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
const skippedIds = new Set(
|
const skippedIds = new Set(
|
||||||
@@ -376,11 +404,26 @@ async function processIndividualFallback(
|
|||||||
|
|
||||||
const rows = await updateMessagesAIAnalysisBulk(updates);
|
const rows = await updateMessagesAIAnalysisBulk(updates);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
getModerationBroadcaster()?.messageAnalyzed(row);
|
broadcastAnalysisCompleted(row);
|
||||||
invalidateAnalyticsCache(row.guild_id);
|
invalidateAnalyticsCache(row.guild_id);
|
||||||
scheduleAutoDelete(row);
|
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.
|
// Reset individual CB on success.
|
||||||
individualConsecutiveErrors = 0;
|
individualConsecutiveErrors = 0;
|
||||||
|
|
||||||
@@ -406,6 +449,13 @@ async function processIndividualFallback(
|
|||||||
|
|
||||||
lastError = error instanceof Error ? error.message : String(error);
|
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
|
// Infinite-loop prevention: if all retries were exhausted because the LLM
|
||||||
// consistently dropped this specific message (not a transient error),
|
// consistently dropped this specific message (not a transient error),
|
||||||
// overwrite the DB entry with a terminal flag that the recovery query
|
// overwrite the DB entry with a terminal flag that the recovery query
|
||||||
@@ -543,7 +593,7 @@ async function processBatch(
|
|||||||
})) as AnalysisWorkerResponse;
|
})) as AnalysisWorkerResponse;
|
||||||
|
|
||||||
for (const row of result.rows) {
|
for (const row of result.rows) {
|
||||||
getModerationBroadcaster()?.messageAnalyzed(row);
|
broadcastAnalysisCompleted(row);
|
||||||
scheduleAutoDelete(row);
|
scheduleAutoDelete(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,7 +833,7 @@ export async function queueMessageAnalysis(messageId: string): Promise<void> {
|
|||||||
buildAgeRestrictedSkipResult(),
|
buildAgeRestrictedSkipResult(),
|
||||||
);
|
);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
getModerationBroadcaster()?.messageAnalyzed(updated);
|
broadcastAnalysisCompleted(updated);
|
||||||
}
|
}
|
||||||
logger.info(
|
logger.info(
|
||||||
{ messageId },
|
{ messageId },
|
||||||
@@ -833,8 +883,12 @@ export function getAnalysisQueueStatus(): AnalysisQueueStatus {
|
|||||||
* state (not just `pending`), and skips conversations that already have
|
* state (not just `pending`), and skips conversations that already have
|
||||||
* individual fallback work in progress to avoid DB last-write-wins races.
|
* 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;
|
moderationClient = client;
|
||||||
|
_redisEventBroadcaster = eventBroadcaster;
|
||||||
if (!config.AI_ANALYSIS_ENABLED) return;
|
if (!config.AI_ANALYSIS_ENABLED) return;
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { LivePanel } from "./features/live";
|
|||||||
import { useMediaControl } from "./features/live/hooks/useMediaControl";
|
import { useMediaControl } from "./features/live/hooks/useMediaControl";
|
||||||
import { useVoiceControl } from "./features/live/hooks/useVoiceControl";
|
import { useVoiceControl } from "./features/live/hooks/useVoiceControl";
|
||||||
import { MessagesPanel } from "./features/messages";
|
import { MessagesPanel } from "./features/messages";
|
||||||
|
import { ModerationAlertListener } from "./features/messages/components/ModerationAlertListener";
|
||||||
import {
|
import {
|
||||||
mergeMessages,
|
mergeMessages,
|
||||||
useMessages,
|
useMessages,
|
||||||
@@ -93,8 +94,25 @@ export default function App() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onMessageAnalyzed: (m) =>
|
onMessageAnalyzed: (m) => {
|
||||||
messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
|
const msg = m as MessageRecord;
|
||||||
|
messages.setMessages((prev) => mergeMessages(prev, [msg]));
|
||||||
|
// Show toast for moderation alerts (warn/flagged)
|
||||||
|
const status = msg.ai_status;
|
||||||
|
if (status === "flagged" || status === "warn") {
|
||||||
|
const username = msg.username || msg.user_id || "unknown";
|
||||||
|
const severity = msg.ai_severity || "";
|
||||||
|
const categories = msg.ai_categories || "";
|
||||||
|
const brief =
|
||||||
|
msg.ai_analysis?.slice(0, 80) ??
|
||||||
|
`Message ${status === "flagged" ? "flagged" : "warned"} by AI`;
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("moderation_alert", {
|
||||||
|
detail: { type: status, username, severity, categories, brief },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
onAttachmentUploaded: () =>
|
onAttachmentUploaded: () =>
|
||||||
messages
|
messages
|
||||||
.fetchMessages(monitorGuildId || undefined)
|
.fetchMessages(monitorGuildId || undefined)
|
||||||
@@ -216,6 +234,7 @@ export default function App() {
|
|||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||||
/>
|
/>
|
||||||
|
<ModerationAlertListener />
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Pencil,
|
Pencil,
|
||||||
RotateCw,
|
RotateCw,
|
||||||
@@ -10,6 +12,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Fragment, useMemo, useState } from "react";
|
import { Fragment, useMemo, useState } from "react";
|
||||||
import type { MessageRecord } from "../../../shared/api/client";
|
import type { MessageRecord } from "../../../shared/api/client";
|
||||||
|
import { moderateMessage } from "../../../shared/api/client";
|
||||||
import { Badge, Button, Skeleton } from "../../../shared/ui";
|
import { Badge, Button, Skeleton } from "../../../shared/ui";
|
||||||
|
|
||||||
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
|
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
|
||||||
@@ -65,6 +68,7 @@ function renderContentWithCustomEmojis(content: string): React.ReactNode {
|
|||||||
interface MessageCardProps {
|
interface MessageCardProps {
|
||||||
message: MessageRecord;
|
message: MessageRecord;
|
||||||
onReanalyze: (id: string) => Promise<void>;
|
onReanalyze: (id: string) => Promise<void>;
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageMetadata {
|
interface MessageMetadata {
|
||||||
@@ -127,7 +131,11 @@ function formatTimeAgo(ts: number): string {
|
|||||||
return new Date(ts).toLocaleDateString();
|
return new Date(ts).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
export function MessageCard({
|
||||||
|
message,
|
||||||
|
onReanalyze,
|
||||||
|
compact,
|
||||||
|
}: MessageCardProps) {
|
||||||
const metadata = useMemo(
|
const metadata = useMemo(
|
||||||
() => parseMetadata(message.metadata),
|
() => parseMetadata(message.metadata),
|
||||||
[message.metadata],
|
[message.metadata],
|
||||||
@@ -143,6 +151,26 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
|||||||
const confidence =
|
const confidence =
|
||||||
message.ai_confidence ?? message.ai_moderation_score ?? null;
|
message.ai_confidence ?? message.ai_moderation_score ?? null;
|
||||||
const [isReanalyzing, setIsReanalyzing] = useState(false);
|
const [isReanalyzing, setIsReanalyzing] = useState(false);
|
||||||
|
const [showAnalysis, setShowAnalysis] = useState(
|
||||||
|
aiStatus === "warn" || aiStatus === "flagged",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build a human-readable analysis summary from categories + confidence + severity
|
||||||
|
const analysisSummary = useMemo(() => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (categories.length > 0) {
|
||||||
|
parts.push(categories.slice(0, 3).join(", "));
|
||||||
|
if (categories.length > 3) parts.push(`+${categories.length - 3} more`);
|
||||||
|
}
|
||||||
|
if (message.ai_severity && message.ai_severity !== "none") {
|
||||||
|
parts.push(message.ai_severity);
|
||||||
|
}
|
||||||
|
if (confidence != null) {
|
||||||
|
parts.push(`${Math.round(confidence * 100)}% confidence`);
|
||||||
|
}
|
||||||
|
if (parts.length === 0) return "View AI analysis";
|
||||||
|
return parts.join(" · ");
|
||||||
|
}, [categories, message.ai_severity, confidence]);
|
||||||
|
|
||||||
const stickers = metadata.stickers ?? [];
|
const stickers = metadata.stickers ?? [];
|
||||||
const attachments = metadata.attachments ?? [];
|
const attachments = metadata.attachments ?? [];
|
||||||
@@ -164,71 +192,77 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
className={`group rounded-2xl border border-border bg-card p-4 shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${message.deleted_at ? "opacity-60" : ""}`}
|
className={`group rounded-2xl border border-border bg-card ${compact ? "px-4 py-1.5" : "p-4"} shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${message.deleted_at ? "opacity-60" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="flex gap-3">
|
<div className={`flex ${compact ? "gap-2" : "gap-3"}`}>
|
||||||
<img
|
{!compact && (
|
||||||
src={
|
<img
|
||||||
message.avatar_url ??
|
src={
|
||||||
"https://cdn.discordapp.com/embed/avatars/0.png"
|
message.avatar_url ??
|
||||||
}
|
"https://cdn.discordapp.com/embed/avatars/0.png"
|
||||||
alt=""
|
}
|
||||||
className="h-10 w-10 shrink-0 rounded-full object-cover ring-1 ring-border"
|
alt=""
|
||||||
/>
|
className="h-10 w-10 shrink-0 rounded-full object-cover ring-1 ring-border"
|
||||||
<div className="min-w-0 flex-1 space-y-2.5">
|
/>
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
)}
|
||||||
<span className="font-semibold text-foreground">
|
<div
|
||||||
{message.username || message.user_id}
|
className={`min-w-0 flex-1 ${compact ? "space-y-1" : "space-y-2.5"}`}
|
||||||
</span>
|
>
|
||||||
<span
|
{!compact && (
|
||||||
className="text-xs text-muted-foreground"
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
title={new Date(message.created_at).toLocaleString()}
|
<span className="font-semibold text-foreground">
|
||||||
>
|
{message.username || message.user_id}
|
||||||
{formatTimeAgo(message.created_at)}
|
|
||||||
</span>
|
|
||||||
{message.edited_at && (
|
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
<Pencil className="h-3 w-3" /> edited
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
<span
|
||||||
{message.deleted_at && (
|
className="text-xs text-muted-foreground"
|
||||||
<span className="flex items-center gap-1 text-xs text-destructive">
|
title={new Date(message.created_at).toLocaleString()}
|
||||||
<Trash2 className="h-3 w-3" /> deleted
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="ml-auto flex items-center gap-1.5">
|
|
||||||
<Badge
|
|
||||||
variant={aiVariant(aiStatus)}
|
|
||||||
className="flex items-center gap-1 text-xs"
|
|
||||||
>
|
>
|
||||||
{aiStatus === "clean" && (
|
{formatTimeAgo(message.created_at)}
|
||||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
</span>
|
||||||
)}
|
{message.edited_at && (
|
||||||
{aiStatus === "warn" && (
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<AlertTriangle className="h-3.5 w-3.5" />
|
<Pencil className="h-3 w-3" /> edited
|
||||||
)}
|
</span>
|
||||||
{aiStatus === "flagged" && (
|
)}
|
||||||
<AlertCircle className="h-3.5 w-3.5" />
|
{message.deleted_at && (
|
||||||
)}
|
<span className="flex items-center gap-1 text-xs text-destructive">
|
||||||
{aiStatus === "error" && (
|
<Trash2 className="h-3 w-3" /> deleted
|
||||||
<AlertCircle className="h-3.5 w-3.5" />
|
</span>
|
||||||
)}
|
)}
|
||||||
{aiStatus}
|
<div className="ml-auto flex items-center gap-1.5">
|
||||||
</Badge>
|
|
||||||
{message.ai_severity && message.ai_severity !== "none" && (
|
|
||||||
<Badge
|
<Badge
|
||||||
className={`text-xs ${severityColor(message.ai_severity)}`}
|
variant={aiVariant(aiStatus)}
|
||||||
|
className="flex items-center gap-1 text-xs"
|
||||||
>
|
>
|
||||||
{message.ai_severity}
|
{aiStatus === "clean" && (
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{aiStatus === "warn" && (
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{aiStatus === "flagged" && (
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{aiStatus === "error" && (
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{aiStatus}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
{message.ai_severity && message.ai_severity !== "none" && (
|
||||||
{confidence != null && (
|
<Badge
|
||||||
<Badge variant="outline" className="text-xs tabular-nums">
|
className={`text-xs ${severityColor(message.ai_severity)}`}
|
||||||
{Math.round(confidence * 100)}%
|
>
|
||||||
</Badge>
|
{message.ai_severity}
|
||||||
)}
|
</Badge>
|
||||||
|
)}
|
||||||
|
{confidence != null && (
|
||||||
|
<Badge variant="outline" className="text-xs tabular-nums">
|
||||||
|
{Math.round(confidence * 100)}%
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{displayContent ? (
|
{displayContent ? (
|
||||||
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
|
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
|
||||||
@@ -304,8 +338,39 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{message.ai_analysis ? (
|
{message.ai_analysis ? (
|
||||||
<div className="rounded-xl bg-muted/60 p-3 text-sm text-muted-foreground leading-relaxed">
|
<div
|
||||||
{message.ai_analysis}
|
className={`rounded-xl border-l-2 p-3 ${
|
||||||
|
aiStatus === "flagged"
|
||||||
|
? "border-l-red-500 bg-red-500/5"
|
||||||
|
: aiStatus === "warn"
|
||||||
|
? "border-l-yellow-500 bg-yellow-500/5"
|
||||||
|
: "border-l-blue-500 bg-blue-500/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAnalysis(!showAnalysis)}
|
||||||
|
className="flex w-full items-center justify-between gap-2 text-left text-xs"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-foreground/80">
|
||||||
|
{aiStatus === "flagged"
|
||||||
|
? "🚨"
|
||||||
|
: aiStatus === "warn"
|
||||||
|
? "⚠️"
|
||||||
|
: "ℹ️"}{" "}
|
||||||
|
{analysisSummary}
|
||||||
|
</span>
|
||||||
|
{showAnalysis ? (
|
||||||
|
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{showAnalysis && (
|
||||||
|
<div className="mt-2 border-t border-border pt-2 text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||||
|
{message.ai_analysis}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -333,6 +398,52 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
|
|||||||
Click to retry analysis
|
Click to retry analysis
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Moderation action buttons for flagged/warned messages */}
|
||||||
|
{(aiStatus === "flagged" || aiStatus === "warn") && (
|
||||||
|
<div className="flex items-center gap-1.5 border-l border-border pl-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Delete message from ${message.username}?\n\n"${(message.edited_content ?? message.content).slice(0, 120)}"`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
moderateMessage(
|
||||||
|
message.id,
|
||||||
|
"delete_message",
|
||||||
|
"Manual moderation from dashboard",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3 mr-1" /> Delete
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Warn user ${message.username} for this message?`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
moderateMessage(
|
||||||
|
message.id,
|
||||||
|
"warn_user",
|
||||||
|
"Manual moderation from dashboard",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-3 w-3 mr-1" /> Warn
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import type { MessageRecord } from "../../../shared/api/client";
|
import type { MessageRecord } from "../../../shared/api/client";
|
||||||
import { ScrollArea } from "../../../shared/ui";
|
import { ScrollArea } from "../../../shared/ui";
|
||||||
import { MessageCard, MessageCardSkeleton } from "./MessageCard";
|
import { MessageCard, MessageCardSkeleton } from "./MessageCard";
|
||||||
@@ -13,6 +13,32 @@ export interface MessageFeedProps {
|
|||||||
loadingMore?: boolean;
|
loadingMore?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Messages from the same user within 5 minutes are visually grouped. */
|
||||||
|
const GROUP_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
interface MessageGroup {
|
||||||
|
messages: MessageRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupMessages(messages: MessageRecord[]): MessageGroup[] {
|
||||||
|
const groups: MessageGroup[] = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const lastGroup = groups[groups.length - 1];
|
||||||
|
if (
|
||||||
|
lastGroup &&
|
||||||
|
lastGroup.messages[0].user_id === msg.user_id &&
|
||||||
|
lastGroup.messages[lastGroup.messages.length - 1].created_at -
|
||||||
|
msg.created_at <
|
||||||
|
GROUP_WINDOW_MS
|
||||||
|
) {
|
||||||
|
lastGroup.messages.push(msg);
|
||||||
|
} else {
|
||||||
|
groups.push({ messages: [msg] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
export function MessageFeed({
|
export function MessageFeed({
|
||||||
messages,
|
messages,
|
||||||
onReanalyze,
|
onReanalyze,
|
||||||
@@ -40,6 +66,8 @@ export function MessageFeed({
|
|||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [onLoadMore, hasMore]);
|
}, [onLoadMore, hasMore]);
|
||||||
|
|
||||||
|
const groupedMessages = useMemo(() => groupMessages(messages), [messages]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
||||||
@@ -63,13 +91,20 @@ export function MessageFeed({
|
|||||||
return (
|
return (
|
||||||
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{messages.map((message) => (
|
{groupedMessages.map((group) =>
|
||||||
<MessageCard
|
group.messages.map((message, idx) => {
|
||||||
key={message.id}
|
const isFirstInGroup = idx === 0;
|
||||||
message={message}
|
const isCompact = !isFirstInGroup;
|
||||||
onReanalyze={onReanalyze}
|
return (
|
||||||
/>
|
<MessageCard
|
||||||
))}
|
key={message.id}
|
||||||
|
message={message}
|
||||||
|
onReanalyze={onReanalyze}
|
||||||
|
compact={isCompact}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Infinite-scroll sentinel */}
|
{/* Infinite-scroll sentinel */}
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// ─── Moderation alert toast listener ───────────────────────────────────────
|
||||||
|
// Listens for "moderation_alert" custom events dispatched from WebSocket
|
||||||
|
// message_analyzed handler, and shows toast notifications for flagged/warned
|
||||||
|
// messages so moderators don't miss important alerts.
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useToast } from "../../../shared/ui";
|
||||||
|
|
||||||
|
interface AlertDetail {
|
||||||
|
type: "flagged" | "warn";
|
||||||
|
username: string;
|
||||||
|
severity: string;
|
||||||
|
categories: string;
|
||||||
|
brief: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModerationAlertListener() {
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const { type, username, severity, categories, brief } = (
|
||||||
|
e as CustomEvent<AlertDetail>
|
||||||
|
).detail;
|
||||||
|
|
||||||
|
const emoji = type === "flagged" ? "🚨" : "⚠️";
|
||||||
|
const sevLabel = severity ? `[${severity}]` : "";
|
||||||
|
const catLabel = categories
|
||||||
|
? ` — ${categories.split(",").slice(0, 2).join(", ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
addToast(
|
||||||
|
`${emoji} ${username} ${sevLabel}${catLabel}: ${brief}`,
|
||||||
|
type === "flagged" ? "error" : "warning",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("moderation_alert", handler);
|
||||||
|
return () => window.removeEventListener("moderation_alert", handler);
|
||||||
|
}, [addToast]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
|
import { ToastProvider } from "./shared/ui";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -24,7 +25,9 @@ if (!root) {
|
|||||||
ReactDOM.createRoot(root).render(
|
ReactDOM.createRoot(root).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<App />
|
<ToastProvider>
|
||||||
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -165,6 +165,17 @@ export function reanalyzeMessage(id: string): Promise<void> {
|
|||||||
return request<void>(`/api/messages/${id}/reanalyze`, { method: "POST" });
|
return request<void>(`/api/messages/${id}/reanalyze`, { method: "POST" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function moderateMessage(
|
||||||
|
id: string,
|
||||||
|
actionType: string,
|
||||||
|
reason?: string,
|
||||||
|
): Promise<{ ok: boolean }> {
|
||||||
|
return request<{ ok: boolean }>(`/api/messages/${id}/moderate`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ actionType, reason }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Guilds / Config ─────────────────────────────────────────────────────────
|
// ─── Guilds / Config ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getGuilds(): Promise<Guild[]> {
|
export function getGuilds(): Promise<Guild[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user