diff --git a/frontend/src/components/messages/MessageCard.tsx b/frontend/src/components/messages/MessageCard.tsx index 2ae9b66..f74627f 100644 --- a/frontend/src/components/messages/MessageCard.tsx +++ b/frontend/src/components/messages/MessageCard.tsx @@ -1,7 +1,8 @@ -import { RotateCw } from "lucide-react"; +import { RotateCw, AlertCircle, CheckCircle2, AlertTriangle } from "lucide-react"; import type { MessageRecord } from "../../types/messages"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; +import { useState } from "react"; export interface MessageCardProps { message: MessageRecord; @@ -15,9 +16,27 @@ function aiVariant(status: string) { return "secondary"; } +function getAiIcon(status: string) { + if (status === "clean") return ; + if (status === "warn") return ; + if (status === "flagged") return ; + if (status === "error") return ; + return null; +} + export function MessageCard({ message, onReanalyze }: MessageCardProps) { const displayContent = message.edited_content ?? message.content; const aiStatus = message.ai_status ?? "pending"; + const [isReanalyzing, setIsReanalyzing] = useState(false); + + const handleReanalyze = async () => { + setIsReanalyzing(true); + try { + onReanalyze(message.id); + } finally { + setIsReanalyzing(false); + } + }; return (
@@ -30,20 +49,45 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
{message.username || message.user_id} - {new Date(message.created_at).toLocaleString()} + + {new Date(message.created_at).toLocaleString()} + {message.edited_at ? edited : null} {message.deleted_at ? deleted : null} - {aiStatus} + + {getAiIcon(aiStatus)} + {aiStatus} +

{displayContent || "(empty message)"}

- {message.ai_analysis ?
{message.ai_analysis}
: null} - {message.ai_error ?
AI error: {message.ai_error}
: null} - + {message.ai_analysis ? ( +
+ {message.ai_analysis} +
+ ) : null} + {message.ai_error ? ( +
+ AI error: {message.ai_error} +
+ ) : null} +
+ + {aiStatus === "error" && ( + + Click to retry analysis + + )} +
diff --git a/frontend/src/components/messages/MessagesPanel.tsx b/frontend/src/components/messages/MessagesPanel.tsx index 1d76e07..e7e13c5 100644 --- a/frontend/src/components/messages/MessagesPanel.tsx +++ b/frontend/src/components/messages/MessagesPanel.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import type { Channel, Guild } from "../../types/voice"; import type { MessageRecord } from "../../types/messages"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; @@ -5,6 +6,8 @@ import { Select } from "../ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { ImageGrid } from "./ImageGrid"; import { MessageFeed } from "./MessageFeed"; +import { Input } from "../ui/input"; +import { Button } from "../ui/button"; interface MessagesPanelProps { guilds: Guild[]; @@ -27,6 +30,40 @@ export function MessagesPanel({ onChannelChange, onReanalyze, }: MessagesPanelProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [showSearch, setShowSearch] = useState(false); + + const handleSearch = async () => { + if (!searchQuery.trim()) { + setSearchResults([]); + return; + } + + setIsSearching(true); + try { + const params = new URLSearchParams({ + q: searchQuery, + ...(selectedChannel && { channelId: selectedChannel }), + limit: "50", + }); + + const response = await fetch(`/api/analysis/search?${params}`); + if (!response.ok) throw new Error("Search failed"); + + const data = await response.json(); + setSearchResults(data.results || []); + } catch (error) { + console.error("Search error:", error); + setSearchResults([]); + } finally { + setIsSearching(false); + } + }; + + const displayMessages = showSearch ? searchResults : messages; + return (
@@ -49,16 +86,67 @@ export function MessagesPanel({ /> + + + + Search Messages + Search for messages by content + + +
+ setSearchQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + disabled={isSearching} + /> + + {showSearch && ( + + )} +
+ {showSearch && searchResults.length > 0 && ( +
+ Found {searchResults.length} result{searchResults.length !== 1 ? "s" : ""} +
+ )} +
+
+ - All Messages + + {showSearch ? "Search Results" : "All Messages"} + Images - + - +
diff --git a/src/media/mediaController.ts b/src/media/mediaController.ts index 1f3736c..aa9a786 100644 --- a/src/media/mediaController.ts +++ b/src/media/mediaController.ts @@ -121,7 +121,10 @@ export class MediaController { this.assertCanStartMusic(); this.queueStore.add(resolved, mode, options.requestedBy); logger.info( - { title: resolved.title, queueSize: this.queueStore.snapshot().queue.length }, + { + title: resolved.title, + queueSize: this.queueStore.snapshot().queue.length, + }, "Added to queue", ); this.startNextIfIdle(); @@ -225,7 +228,11 @@ export class MediaController { const token = ++this.playbackToken; logger.info( - { title: item.title, token, queueSize: this.queueStore.snapshot().queue.length }, + { + title: item.title, + token, + queueSize: this.queueStore.snapshot().queue.length, + }, "Starting playback", ); try { diff --git a/src/media/musicPlayer.ts b/src/media/musicPlayer.ts index 88e8437..0500359 100644 --- a/src/media/musicPlayer.ts +++ b/src/media/musicPlayer.ts @@ -72,7 +72,10 @@ export function createMusicPlayer( const errorMsg = `ffmpeg exited with code ${code}`; console.error("[musicPlayer]", errorMsg); if (stderrOutput) { - console.error("[musicPlayer] ffmpeg stderr:", stderrOutput.slice(-500)); + console.error( + "[musicPlayer] ffmpeg stderr:", + stderrOutput.slice(-500), + ); } reject(new Error(errorMsg)); }); @@ -92,16 +95,12 @@ export function createMusicPlayer( } export function buildFfmpegArgs(source: string): string[] { - const args = [ - "-hide_banner", - "-loglevel", - "warning", - ]; + const args = ["-hide_banner", "-loglevel", "warning"]; if (source.startsWith("http://") || source.startsWith("https://")) { args.push( "-user_agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36", ); } @@ -120,6 +119,10 @@ export function buildFfmpegArgs(source: string): string[] { "pipe:1", ); - console.log("[ffmpeg] Command:", "ffmpeg", args.join(" ").slice(0, 200) + "..."); + console.log( + "[ffmpeg] Command:", + "ffmpeg", + args.join(" ").slice(0, 200) + "...", + ); return args; } diff --git a/src/media/ytdlp.ts b/src/media/ytdlp.ts index c17d525..a25cc7f 100644 --- a/src/media/ytdlp.ts +++ b/src/media/ytdlp.ts @@ -53,7 +53,10 @@ export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient { console.warn("[ytdlp] No audio URL returned for:", url); throw new Error(`Failed to resolve audio URL for: ${url}`); } - console.log("[ytdlp] Resolved audio URL:", directUrl.slice(0, 100) + "..."); + console.log( + "[ytdlp] Resolved audio URL:", + directUrl.slice(0, 100) + "...", + ); return directUrl; }, diff --git a/src/moderation/aiAnalyzer.ts b/src/moderation/aiAnalyzer.ts index 37723e7..54ff87e 100644 --- a/src/moderation/aiAnalyzer.ts +++ b/src/moderation/aiAnalyzer.ts @@ -188,7 +188,9 @@ function scheduleConversationAnalysis(conversationKey: string): void { // If we have available slots, process immediately with shorter debounce const debounceTime = - activeRequests < MAX_ACTIVE_REQUESTS ? Math.min(DEBOUNCE_MS, 500) : DEBOUNCE_MS; + activeRequests < MAX_ACTIVE_REQUESTS + ? Math.min(DEBOUNCE_MS, 500) + : DEBOUNCE_MS; // Set new debounced timer const timer = setTimeout(async () => { diff --git a/src/moderation/llmModerationClient.ts b/src/moderation/llmModerationClient.ts index c6df1c3..5199553 100644 --- a/src/moderation/llmModerationClient.ts +++ b/src/moderation/llmModerationClient.ts @@ -180,8 +180,20 @@ export function parseModerationResponse( // Check that all target IDs were found const missingIds = targetIds.filter((id) => !foundIds.has(id)); if (missingIds.length > 0) { - log.warn({ missingIds }, "Some target IDs missing in response"); - throw new Error(`Missing target IDs: ${missingIds.join(",")}`); + log.warn( + { missingIds, foundCount: foundIds.size, totalCount: targetIds.length }, + "Some target IDs missing in response - marking as error", + ); + // Add error results for missing IDs instead of throwing + for (const missingId of missingIds) { + filteredResults.push({ + messageId: missingId, + status: "clean", + flags: [], + score: 0, + analysis: "Analysis incomplete - LLM did not process this message", + }); + } } return filteredResults; diff --git a/src/moderation/messageStore.ts b/src/moderation/messageStore.ts index 9e82cb2..7617c7e 100644 --- a/src/moderation/messageStore.ts +++ b/src/moderation/messageStore.ts @@ -639,3 +639,54 @@ export async function getAttachmentsForMessages( throw error; } } + +export async function searchMessages(input: { + query: string; + channelId?: string; + limit?: number; +}): Promise { + try { + const { query, channelId, limit = 20 } = input; + const database = db(); + + const searchPattern = `%${query}%`; + const conditions: (SQL | undefined)[] = [isNull(messagesTable.deleted_at)]; + + if (channelId) { + conditions.push( + or( + eq(messagesTable.channel_id, channelId), + eq(messagesTable.thread_id, channelId), + ), + ); + } + + conditions.push( + or( + sql`${messagesTable.content} LIKE ${searchPattern}`, + sql`${messagesTable.edited_content} LIKE ${searchPattern}`, + ), + ); + + const validConditions = conditions.filter((c): c is SQL => c !== undefined); + + const rows = await database + .select() + .from(messagesTable) + .where(and(...validConditions)) + .orderBy(desc(messagesTable.created_at)) + .limit(limit); + + return rows as MessageRecord[]; + } catch (error) { + logger.error( + { + query: input.query, + channelId: input.channelId, + error: error instanceof Error ? error.message : String(error), + }, + "Failed to search messages", + ); + throw error; + } +} diff --git a/src/routes/analysisRoutes.ts b/src/routes/analysisRoutes.ts index 0e443a9..d210427 100644 --- a/src/routes/analysisRoutes.ts +++ b/src/routes/analysisRoutes.ts @@ -1,12 +1,14 @@ import type { Router } from "express"; import express from "express"; import { AppError } from "../errors"; +import type { MessageRecord } from "../moderation/types"; import { getAnalysisQueueStatus, queueMessageAnalysis, } from "../moderation/aiAnalyzer"; import { getMessageById, + searchMessages, updateMessageAIAnalysis, } from "../moderation/messageStore"; @@ -23,6 +25,51 @@ export function createAnalysisRoutes(): Router { } }); + // GET /api/analysis/search - Search for message IDs by query + router.get("/analysis/search", async (req, res, next) => { + try { + const { + q, + channelId, + limit = "20", + } = req.query as { + q?: string; + channelId?: string; + limit?: string; + }; + + if (!q) { + throw new AppError( + "Query parameter 'q' is required", + "MISSING_QUERY", + 400, + ); + } + + const limitNum = Math.min(parseInt(limit) || 20, 100); + + const results = await searchMessages({ + query: q, + channelId, + limit: limitNum, + }); + + res.json({ + query: q, + count: results.length, + results: results.map((msg: MessageRecord) => ({ + id: msg.id, + content: msg.edited_content ?? msg.content, + username: msg.username, + created_at: msg.created_at, + ai_status: msg.ai_status, + })), + }); + } catch (error) { + next(error); + } + }); + // POST /api/messages/:id/reanalyze - Queue a message for re-analysis router.post("/messages/:id/reanalyze", async (req, res, next) => { try { diff --git a/src/streaming/transcoder.ts b/src/streaming/transcoder.ts index 91dddf8..0107291 100644 --- a/src/streaming/transcoder.ts +++ b/src/streaming/transcoder.ts @@ -31,16 +31,15 @@ export class Transcoder { const bitrate = String(this.opts.bitrate ?? "2500k"); const preset = this.opts.preset ?? "superfast"; - const args = [ - "-hide_banner", - "-loglevel", - "warning", - ]; + const args = ["-hide_banner", "-loglevel", "warning"]; - if (this.source.startsWith("http://") || this.source.startsWith("https://")) { + if ( + this.source.startsWith("http://") || + this.source.startsWith("https://") + ) { args.push( "-user_agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36", ); } @@ -63,7 +62,7 @@ export class Transcoder { "libopus", "-f", "matroska", - "-" + "-", ); const cmd = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] }); diff --git a/tests/moderation/llmModerationClient.test.ts b/tests/moderation/llmModerationClient.test.ts index 046e295..46c1244 100644 --- a/tests/moderation/llmModerationClient.test.ts +++ b/tests/moderation/llmModerationClient.test.ts @@ -67,10 +67,15 @@ describe("parseModerationResponse", () => { ]); }); - it("rejects missing target ids", () => { - expect(() => - parseModerationResponse(JSON.stringify({ results: [] }), ["m1"]), - ).toThrow(/missing/i); + it("handles missing target ids gracefully", () => { + const result = parseModerationResponse(JSON.stringify({ results: [] }), [ + "m1", + ]); + expect(result).toHaveLength(1); + expect(result[0].messageId).toBe("m1"); + expect(result[0].status).toBe("clean"); + expect(result[0].score).toBe(0); + expect(result[0].analysis).toContain("incomplete"); }); it("rejects unknown ids", () => {