From 6da8a32c9babf7f037a11da77570d97ed85f57fc Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Wed, 3 Jun 2026 00:17:51 +0700 Subject: [PATCH] feat: add "Retry All Errors" batch reanalyze button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - POST /api/messages/reanalyze-batch — bulk reset error messages to pending - messages.repository.reanalyzeErrorBatch() — scoped by guildId/channelId/messageIds - messages.service.reanalyzeErrorBatch() — validation layer Frontend: - reanalyzeErrorBatch() API client function - useMessages.reanalyzeAllErrors() — optimistic state + batch call - MessagesPanel: destructive button visible when error count > 0 - Shows confirmation text with queued count Co-Authored-By: Claude Opus 4.8 --- .../modules/messages/messages.repository.ts | 44 +++++++++++++++++++ .../src/modules/messages/messages.routes.ts | 26 ++++++++++- .../src/modules/messages/messages.service.ts | 15 +++++++ services/frontend/src/App.tsx | 1 + .../features/messages/hooks/useMessages.ts | 26 ++++++++++- .../frontend/src/features/messages/index.tsx | 36 ++++++++++++++- services/frontend/src/shared/api/client.ts | 11 +++++ 7 files changed, 156 insertions(+), 3 deletions(-) diff --git a/services/backend/src/modules/messages/messages.repository.ts b/services/backend/src/modules/messages/messages.repository.ts index 559d61a..639810c 100644 --- a/services/backend/src/modules/messages/messages.repository.ts +++ b/services/backend/src/modules/messages/messages.repository.ts @@ -216,6 +216,50 @@ export class MessagesRepository { return mapMessageRow(rows[0] as Record); } + /** + * Bulk-reset ai_status from 'error' to 'pending' so the DG recovery worker + * picks them up on its next poll cycle. + * + * Accepts optional scope filters (guildId, channelId) or a list of explicit + * message IDs. Returns the count of rows that were actually updated. + */ + async reanalyzeErrorBatch(opts: { + guildId?: string; + channelId?: string; + messageIds?: string[]; + }): Promise { + const pool = getPool(); + const clauses: string[] = ["ai_status = 'error'"]; + const params: (string | number)[] = []; + let p = 1; + + if (opts.messageIds && opts.messageIds.length > 0) { + const placeholders = opts.messageIds.map((_, i) => `$${p + i}`); + clauses.push(`id IN (${placeholders.join(", ")})`); + params.push(...opts.messageIds); + p += opts.messageIds.length; + } + if (opts.guildId) { + clauses.push(`guild_id = $${p}`); + params.push(opts.guildId); + p++; + } + if (opts.channelId) { + clauses.push(`channel_id = $${p}`); + params.push(opts.channelId); + p++; + } + + const where = clauses.join(" AND "); + const { rowCount } = await pool.query( + `UPDATE messages SET ai_status = 'pending' WHERE ${where}`, + params, + ); + + logger.info({ count: rowCount ?? 0, ...opts }, "Batch reanalyze triggered"); + return rowCount ?? 0; + } + async delete(id: string): Promise { const pool = getPool(); const { rowCount } = await pool.query( diff --git a/services/backend/src/modules/messages/messages.routes.ts b/services/backend/src/modules/messages/messages.routes.ts index 52251d8..6cdd5a7 100644 --- a/services/backend/src/modules/messages/messages.routes.ts +++ b/services/backend/src/modules/messages/messages.routes.ts @@ -9,6 +9,7 @@ import { handleGetMessagesByChannel, handleListMessages, } from "./messages.controller.js"; +import { messagesService } from "./messages.service.js"; const logger = createChildLogger("messages.routes"); @@ -28,7 +29,30 @@ export function createMessagesRouter(): Router { // (uses /detail/ prefix to avoid collision with :channelId route above) router.get("/messages/detail/:id", handleGetMessageById); - // POST /api/messages/:id/reanalyze - Mark message for re-analysis + // POST /api/messages/reanalyze-batch — Bulk retry all errored messages + // MUST be registered BEFORE /messages/:id/reanalyze so "reanalyze-batch" + // is not captured as an :id param. + router.post( + "/messages/reanalyze-batch", + asyncHandler(async (req: Request, res: Response) => { + const { guildId, channelId, messageIds } = (req.body ?? {}) as { + guildId?: string; + channelId?: string; + messageIds?: string[]; + }; + + const count = await messagesService.reanalyzeErrorBatch({ + guildId, + channelId, + messageIds, + }); + + logger.info({ count, guildId, channelId }, "Batch reanalyze completed"); + res.status(200).json({ ok: true, count }); + }), + ); + + // POST /api/messages/:id/reanalyze - Mark single message for re-analysis router.post( "/messages/:id/reanalyze", asyncHandler(async (req: Request, res: Response) => { diff --git a/services/backend/src/modules/messages/messages.service.ts b/services/backend/src/modules/messages/messages.service.ts index 62298bf..dc82fae 100644 --- a/services/backend/src/modules/messages/messages.service.ts +++ b/services/backend/src/modules/messages/messages.service.ts @@ -45,6 +45,21 @@ export class MessagesService { logger.debug({ channelId, query }, "Getting attachments by channel"); return messagesRepository.getAttachmentsByChannel(channelId, query); } + + async reanalyzeErrorBatch(opts: { + guildId?: string; + channelId?: string; + messageIds?: string[]; + }) { + if (!opts.guildId && !opts.channelId && (!opts.messageIds || opts.messageIds.length === 0)) { + throw new ValidationError( + "At least one of guildId, channelId, or messageIds[] is required", + ); + } + + logger.info(opts, "Batch reanalyzing errored messages"); + return messagesRepository.reanalyzeErrorBatch(opts); + } } export const messagesService = new MessagesService(); diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index abd912f..df31cf4 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -207,6 +207,7 @@ export default function App() { guildName={monitorGuildName} messages={messages.messages} onReanalyze={messages.reanalyze} + onReanalyzeAllErrors={messages.reanalyzeAllErrors} onLoadMore={messages.loadMore} hasMore={messages.hasMore} loadingMore={messages.loadingMore} diff --git a/services/frontend/src/features/messages/hooks/useMessages.ts b/services/frontend/src/features/messages/hooks/useMessages.ts index 96151c0..9421f2e 100644 --- a/services/frontend/src/features/messages/hooks/useMessages.ts +++ b/services/frontend/src/features/messages/hooks/useMessages.ts @@ -1,6 +1,6 @@ import { useCallback, useRef, useState } from "react"; import type { MessageRecord } from "../../../shared/api/client"; -import { listMessages, reanalyzeMessage } from "../../../shared/api/client"; +import { listMessages, reanalyzeErrorBatch, reanalyzeMessage } from "../../../shared/api/client"; const PAGE_SIZE = 100; @@ -91,6 +91,29 @@ export function useMessages() { await reanalyzeMessage(id); }, []); + const reanalyzeAllErrors = useCallback( + async (): Promise => { + // Optimistically mark all error messages as pending + setMessages((prev) => + prev.map((message) => + message.ai_status === "error" + ? { + ...message, + ai_status: "pending" as const, + ai_error: null, + ai_analysis: null, + } + : message, + ), + ); + const { count } = await reanalyzeErrorBatch({ + guildId: currentGuild.current ?? undefined, + }); + return count; + }, + [], + ); + return { messages, setMessages, @@ -99,6 +122,7 @@ export function useMessages() { error, fetchMessages, reanalyze, + reanalyzeAllErrors, loadMore, hasMore, }; diff --git a/services/frontend/src/features/messages/index.tsx b/services/frontend/src/features/messages/index.tsx index d6ca247..89200f3 100644 --- a/services/frontend/src/features/messages/index.tsx +++ b/services/frontend/src/features/messages/index.tsx @@ -1,4 +1,4 @@ -import { Filter, Search, X } from "lucide-react"; +import { Filter, RotateCw, Search, X } from "lucide-react"; import { useMemo, useState } from "react"; import type { MessageRecord } from "../../shared/api/client"; import { @@ -21,6 +21,7 @@ interface MessagesPanelProps { guildName: string | null; messages: MessageRecord[]; onReanalyze: (id: string) => Promise; + onReanalyzeAllErrors?: () => Promise; onLoadMore?: () => void; hasMore?: boolean; loadingMore?: boolean; @@ -32,6 +33,7 @@ export function MessagesPanel({ guildName, messages, onReanalyze, + onReanalyzeAllErrors, onLoadMore, hasMore, loadingMore, @@ -42,6 +44,8 @@ export function MessagesPanel({ const [showSearch, setShowSearch] = useState(false); const [aiFilter, setAiFilter] = useState("all"); const [viewTab, setViewTab] = useState<"all" | "images">("all"); + const [retryingAll, setRetryingAll] = useState(false); + const [retriedCount, setRetriedCount] = useState(null); const handleSearch = async () => { if (!searchQuery.trim()) { @@ -187,6 +191,36 @@ export function MessagesPanel({ Clear )} + {stats.error > 0 && onReanalyzeAllErrors && ( + + )} + {retriedCount !== null && ( + + {retriedCount} message{retriedCount !== 1 ? "s" : ""} queued for + re-analysis + + )}
{( diff --git a/services/frontend/src/shared/api/client.ts b/services/frontend/src/shared/api/client.ts index c5ea715..df9a9aa 100644 --- a/services/frontend/src/shared/api/client.ts +++ b/services/frontend/src/shared/api/client.ts @@ -167,6 +167,17 @@ export function reanalyzeMessage(id: string): Promise { return request(`/api/messages/${id}/reanalyze`, { method: "POST" }); } +export function reanalyzeErrorBatch(opts: { + guildId?: string; + channelId?: string; + messageIds?: string[]; +}): Promise<{ ok: boolean; count: number }> { + return request<{ ok: boolean; count: number }>( + "/api/messages/reanalyze-batch", + { method: "POST", body: JSON.stringify(opts) }, + ); +} + export function moderateMessage( id: string, actionType: string,