feat: add searchMessages function and corresponding API endpoint for message queries

This commit is contained in:
MythEclipse
2026-05-18 06:39:12 +07:00
parent 69ba7b497f
commit f3c915eacd
11 changed files with 299 additions and 38 deletions
@@ -1,7 +1,8 @@
import { RotateCw } from "lucide-react"; import { RotateCw, AlertCircle, CheckCircle2, AlertTriangle } from "lucide-react";
import type { MessageRecord } from "../../types/messages"; import type { MessageRecord } from "../../types/messages";
import { Badge } from "../ui/badge"; import { Badge } from "../ui/badge";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { useState } from "react";
export interface MessageCardProps { export interface MessageCardProps {
message: MessageRecord; message: MessageRecord;
@@ -15,9 +16,27 @@ function aiVariant(status: string) {
return "secondary"; return "secondary";
} }
function getAiIcon(status: string) {
if (status === "clean") return <CheckCircle2 className="h-4 w-4" />;
if (status === "warn") return <AlertTriangle className="h-4 w-4" />;
if (status === "flagged") return <AlertCircle className="h-4 w-4" />;
if (status === "error") return <AlertCircle className="h-4 w-4" />;
return null;
}
export function MessageCard({ message, onReanalyze }: MessageCardProps) { export function MessageCard({ message, onReanalyze }: MessageCardProps) {
const displayContent = message.edited_content ?? message.content; const displayContent = message.edited_content ?? message.content;
const aiStatus = message.ai_status ?? "pending"; const aiStatus = message.ai_status ?? "pending";
const [isReanalyzing, setIsReanalyzing] = useState(false);
const handleReanalyze = async () => {
setIsReanalyzing(true);
try {
onReanalyze(message.id);
} finally {
setIsReanalyzing(false);
}
};
return ( return (
<article className="rounded-2xl border border-border bg-card p-4 shadow-sm"> <article className="rounded-2xl border border-border bg-card p-4 shadow-sm">
@@ -30,20 +49,45 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
<div className="min-w-0 flex-1 space-y-3"> <div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{message.username || message.user_id}</span> <span className="font-medium">{message.username || message.user_id}</span>
<span className="text-xs text-muted-foreground">{new Date(message.created_at).toLocaleString()}</span> <span className="text-xs text-muted-foreground">
{new Date(message.created_at).toLocaleString()}
</span>
{message.edited_at ? <Badge variant="outline">edited</Badge> : null} {message.edited_at ? <Badge variant="outline">edited</Badge> : null}
{message.deleted_at ? <Badge variant="destructive">deleted</Badge> : null} {message.deleted_at ? <Badge variant="destructive">deleted</Badge> : null}
<Badge variant={aiVariant(aiStatus)}>{aiStatus}</Badge> <Badge variant={aiVariant(aiStatus)} className="flex items-center gap-1">
{getAiIcon(aiStatus)}
{aiStatus}
</Badge>
</div> </div>
<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">
{displayContent || "(empty message)"} {displayContent || "(empty message)"}
</p> </p>
{message.ai_analysis ? <div className="rounded-xl bg-muted p-3 text-sm text-muted-foreground">{message.ai_analysis}</div> : null} {message.ai_analysis ? (
{message.ai_error ? <div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">AI error: {message.ai_error}</div> : null} <div className="rounded-xl bg-muted p-3 text-sm text-muted-foreground">
<Button size="sm" variant="outline" onClick={() => onReanalyze(message.id)} disabled={aiStatus === "pending"}> {message.ai_analysis}
<RotateCw className="h-3.5 w-3.5" /> </div>
Re-analyze ) : null}
</Button> {message.ai_error ? (
<div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">
AI error: {message.ai_error}
</div>
) : null}
<div className="flex gap-2">
<Button
size="sm"
variant={aiStatus === "error" ? "destructive" : "outline"}
onClick={handleReanalyze}
disabled={aiStatus === "pending" || isReanalyzing}
>
<RotateCw className={`h-3.5 w-3.5 ${isReanalyzing ? "animate-spin" : ""}`} />
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
</Button>
{aiStatus === "error" && (
<span className="text-xs text-destructive self-center">
Click to retry analysis
</span>
)}
</div>
</div> </div>
</div> </div>
</article> </article>
@@ -1,3 +1,4 @@
import { useState } from "react";
import type { Channel, Guild } from "../../types/voice"; import type { Channel, Guild } from "../../types/voice";
import type { MessageRecord } from "../../types/messages"; import type { MessageRecord } from "../../types/messages";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
import { ImageGrid } from "./ImageGrid"; import { ImageGrid } from "./ImageGrid";
import { MessageFeed } from "./MessageFeed"; import { MessageFeed } from "./MessageFeed";
import { Input } from "../ui/input";
import { Button } from "../ui/button";
interface MessagesPanelProps { interface MessagesPanelProps {
guilds: Guild[]; guilds: Guild[];
@@ -27,6 +30,40 @@ export function MessagesPanel({
onChannelChange, onChannelChange,
onReanalyze, onReanalyze,
}: MessagesPanelProps) { }: MessagesPanelProps) {
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<MessageRecord[]>([]);
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 ( return (
<div className="grid gap-6"> <div className="grid gap-6">
<Card> <Card>
@@ -49,16 +86,67 @@ export function MessagesPanel({
/> />
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle>Search Messages</CardTitle>
<CardDescription>Search for messages by content</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex gap-2">
<Input
placeholder="Search message content..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
disabled={isSearching}
/>
<Button onClick={handleSearch} disabled={isSearching || !searchQuery.trim()}>
{isSearching ? "Searching..." : "Search"}
</Button>
{showSearch && (
<Button
variant="outline"
onClick={() => {
setShowSearch(false);
setSearchResults([]);
setSearchQuery("");
}}
>
Clear
</Button>
)}
</div>
{showSearch && searchResults.length > 0 && (
<div className="text-sm text-muted-foreground">
Found {searchResults.length} result{searchResults.length !== 1 ? "s" : ""}
</div>
)}
</CardContent>
</Card>
<Tabs defaultValue="all"> <Tabs defaultValue="all">
<TabsList> <TabsList>
<TabsTrigger value="all">All Messages</TabsTrigger> <TabsTrigger value="all">
{showSearch ? "Search Results" : "All Messages"}
</TabsTrigger>
<TabsTrigger value="images">Images</TabsTrigger> <TabsTrigger value="images">Images</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="all"> <TabsContent value="all">
<MessageFeed messages={messages} onReanalyze={onReanalyze} emptyText={selectedChannel ? "No captures yet." : "Select a channel to view captures."} /> <MessageFeed
messages={displayMessages}
onReanalyze={onReanalyze}
emptyText={
showSearch
? "No messages found matching your search."
: selectedChannel
? "No captures yet."
: "Select a channel to view captures."
}
/>
</TabsContent> </TabsContent>
<TabsContent value="images"> <TabsContent value="images">
<ImageGrid messages={messages} /> <ImageGrid messages={displayMessages} />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
+9 -2
View File
@@ -121,7 +121,10 @@ export class MediaController {
this.assertCanStartMusic(); this.assertCanStartMusic();
this.queueStore.add(resolved, mode, options.requestedBy); this.queueStore.add(resolved, mode, options.requestedBy);
logger.info( logger.info(
{ title: resolved.title, queueSize: this.queueStore.snapshot().queue.length }, {
title: resolved.title,
queueSize: this.queueStore.snapshot().queue.length,
},
"Added to queue", "Added to queue",
); );
this.startNextIfIdle(); this.startNextIfIdle();
@@ -225,7 +228,11 @@ export class MediaController {
const token = ++this.playbackToken; const token = ++this.playbackToken;
logger.info( logger.info(
{ title: item.title, token, queueSize: this.queueStore.snapshot().queue.length }, {
title: item.title,
token,
queueSize: this.queueStore.snapshot().queue.length,
},
"Starting playback", "Starting playback",
); );
try { try {
+11 -8
View File
@@ -72,7 +72,10 @@ export function createMusicPlayer(
const errorMsg = `ffmpeg exited with code ${code}`; const errorMsg = `ffmpeg exited with code ${code}`;
console.error("[musicPlayer]", errorMsg); console.error("[musicPlayer]", errorMsg);
if (stderrOutput) { if (stderrOutput) {
console.error("[musicPlayer] ffmpeg stderr:", stderrOutput.slice(-500)); console.error(
"[musicPlayer] ffmpeg stderr:",
stderrOutput.slice(-500),
);
} }
reject(new Error(errorMsg)); reject(new Error(errorMsg));
}); });
@@ -92,16 +95,12 @@ export function createMusicPlayer(
} }
export function buildFfmpegArgs(source: string): string[] { export function buildFfmpegArgs(source: string): string[] {
const args = [ const args = ["-hide_banner", "-loglevel", "warning"];
"-hide_banner",
"-loglevel",
"warning",
];
if (source.startsWith("http://") || source.startsWith("https://")) { if (source.startsWith("http://") || source.startsWith("https://")) {
args.push( args.push(
"-user_agent", "-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", "pipe:1",
); );
console.log("[ffmpeg] Command:", "ffmpeg", args.join(" ").slice(0, 200) + "..."); console.log(
"[ffmpeg] Command:",
"ffmpeg",
args.join(" ").slice(0, 200) + "...",
);
return args; return args;
} }
+4 -1
View File
@@ -53,7 +53,10 @@ export function createYtDlp(dependencies: YtDlpDependencies = {}): YtDlpClient {
console.warn("[ytdlp] No audio URL returned for:", url); console.warn("[ytdlp] No audio URL returned for:", url);
throw new Error(`Failed to resolve audio URL 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; return directUrl;
}, },
+3 -1
View File
@@ -188,7 +188,9 @@ function scheduleConversationAnalysis(conversationKey: string): void {
// If we have available slots, process immediately with shorter debounce // If we have available slots, process immediately with shorter debounce
const debounceTime = 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 // Set new debounced timer
const timer = setTimeout(async () => { const timer = setTimeout(async () => {
+14 -2
View File
@@ -180,8 +180,20 @@ export function parseModerationResponse(
// Check that all target IDs were found // Check that all target IDs were found
const missingIds = targetIds.filter((id) => !foundIds.has(id)); const missingIds = targetIds.filter((id) => !foundIds.has(id));
if (missingIds.length > 0) { if (missingIds.length > 0) {
log.warn({ missingIds }, "Some target IDs missing in response"); log.warn(
throw new Error(`Missing target IDs: ${missingIds.join(",")}`); { 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; return filteredResults;
+51
View File
@@ -639,3 +639,54 @@ export async function getAttachmentsForMessages(
throw error; throw error;
} }
} }
export async function searchMessages(input: {
query: string;
channelId?: string;
limit?: number;
}): Promise<MessageRecord[]> {
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;
}
}
+47
View File
@@ -1,12 +1,14 @@
import type { Router } from "express"; import type { Router } from "express";
import express from "express"; import express from "express";
import { AppError } from "../errors"; import { AppError } from "../errors";
import type { MessageRecord } from "../moderation/types";
import { import {
getAnalysisQueueStatus, getAnalysisQueueStatus,
queueMessageAnalysis, queueMessageAnalysis,
} from "../moderation/aiAnalyzer"; } from "../moderation/aiAnalyzer";
import { import {
getMessageById, getMessageById,
searchMessages,
updateMessageAIAnalysis, updateMessageAIAnalysis,
} from "../moderation/messageStore"; } 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 // POST /api/messages/:id/reanalyze - Queue a message for re-analysis
router.post("/messages/:id/reanalyze", async (req, res, next) => { router.post("/messages/:id/reanalyze", async (req, res, next) => {
try { try {
+7 -8
View File
@@ -31,16 +31,15 @@ export class Transcoder {
const bitrate = String(this.opts.bitrate ?? "2500k"); const bitrate = String(this.opts.bitrate ?? "2500k");
const preset = this.opts.preset ?? "superfast"; const preset = this.opts.preset ?? "superfast";
const args = [ const args = ["-hide_banner", "-loglevel", "warning"];
"-hide_banner",
"-loglevel",
"warning",
];
if (this.source.startsWith("http://") || this.source.startsWith("https://")) { if (
this.source.startsWith("http://") ||
this.source.startsWith("https://")
) {
args.push( args.push(
"-user_agent", "-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", "libopus",
"-f", "-f",
"matroska", "matroska",
"-" "-",
); );
const cmd = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] }); const cmd = spawn("ffmpeg", args, { stdio: ["ignore", "pipe", "pipe"] });
+9 -4
View File
@@ -67,10 +67,15 @@ describe("parseModerationResponse", () => {
]); ]);
}); });
it("rejects missing target ids", () => { it("handles missing target ids gracefully", () => {
expect(() => const result = parseModerationResponse(JSON.stringify({ results: [] }), [
parseModerationResponse(JSON.stringify({ results: [] }), ["m1"]), "m1",
).toThrow(/missing/i); ]);
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", () => { it("rejects unknown ids", () => {