feat: add "Retry All Errors" batch reanalyze button

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 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-03 00:17:51 +07:00
co-authored by Claude Opus 4.8
parent d81aaf2b4a
commit 6da8a32c9b
7 changed files with 156 additions and 3 deletions
@@ -216,6 +216,50 @@ export class MessagesRepository {
return mapMessageRow(rows[0] as Record<string, unknown>); return mapMessageRow(rows[0] as Record<string, unknown>);
} }
/**
* 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<number> {
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<boolean> { async delete(id: string): Promise<boolean> {
const pool = getPool(); const pool = getPool();
const { rowCount } = await pool.query( const { rowCount } = await pool.query(
@@ -9,6 +9,7 @@ import {
handleGetMessagesByChannel, handleGetMessagesByChannel,
handleListMessages, handleListMessages,
} from "./messages.controller.js"; } from "./messages.controller.js";
import { messagesService } from "./messages.service.js";
const logger = createChildLogger("messages.routes"); const logger = createChildLogger("messages.routes");
@@ -28,7 +29,30 @@ export function createMessagesRouter(): Router {
// (uses /detail/ prefix to avoid collision with :channelId route above) // (uses /detail/ prefix to avoid collision with :channelId route above)
router.get("/messages/detail/:id", handleGetMessageById); 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( router.post(
"/messages/:id/reanalyze", "/messages/:id/reanalyze",
asyncHandler(async (req: Request, res: Response) => { asyncHandler(async (req: Request, res: Response) => {
@@ -45,6 +45,21 @@ export class MessagesService {
logger.debug({ channelId, query }, "Getting attachments by channel"); logger.debug({ channelId, query }, "Getting attachments by channel");
return messagesRepository.getAttachmentsByChannel(channelId, query); 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(); export const messagesService = new MessagesService();
+1
View File
@@ -207,6 +207,7 @@ export default function App() {
guildName={monitorGuildName} guildName={monitorGuildName}
messages={messages.messages} messages={messages.messages}
onReanalyze={messages.reanalyze} onReanalyze={messages.reanalyze}
onReanalyzeAllErrors={messages.reanalyzeAllErrors}
onLoadMore={messages.loadMore} onLoadMore={messages.loadMore}
hasMore={messages.hasMore} hasMore={messages.hasMore}
loadingMore={messages.loadingMore} loadingMore={messages.loadingMore}
@@ -1,6 +1,6 @@
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import type { MessageRecord } from "../../../shared/api/client"; 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; const PAGE_SIZE = 100;
@@ -91,6 +91,29 @@ export function useMessages() {
await reanalyzeMessage(id); await reanalyzeMessage(id);
}, []); }, []);
const reanalyzeAllErrors = useCallback(
async (): Promise<number> => {
// 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 { return {
messages, messages,
setMessages, setMessages,
@@ -99,6 +122,7 @@ export function useMessages() {
error, error,
fetchMessages, fetchMessages,
reanalyze, reanalyze,
reanalyzeAllErrors,
loadMore, loadMore,
hasMore, hasMore,
}; };
@@ -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 { useMemo, useState } from "react";
import type { MessageRecord } from "../../shared/api/client"; import type { MessageRecord } from "../../shared/api/client";
import { import {
@@ -21,6 +21,7 @@ interface MessagesPanelProps {
guildName: string | null; guildName: string | null;
messages: MessageRecord[]; messages: MessageRecord[];
onReanalyze: (id: string) => Promise<void>; onReanalyze: (id: string) => Promise<void>;
onReanalyzeAllErrors?: () => Promise<number>;
onLoadMore?: () => void; onLoadMore?: () => void;
hasMore?: boolean; hasMore?: boolean;
loadingMore?: boolean; loadingMore?: boolean;
@@ -32,6 +33,7 @@ export function MessagesPanel({
guildName, guildName,
messages, messages,
onReanalyze, onReanalyze,
onReanalyzeAllErrors,
onLoadMore, onLoadMore,
hasMore, hasMore,
loadingMore, loadingMore,
@@ -42,6 +44,8 @@ export function MessagesPanel({
const [showSearch, setShowSearch] = useState(false); const [showSearch, setShowSearch] = useState(false);
const [aiFilter, setAiFilter] = useState<AiFilter>("all"); const [aiFilter, setAiFilter] = useState<AiFilter>("all");
const [viewTab, setViewTab] = useState<"all" | "images">("all"); const [viewTab, setViewTab] = useState<"all" | "images">("all");
const [retryingAll, setRetryingAll] = useState(false);
const [retriedCount, setRetriedCount] = useState<number | null>(null);
const handleSearch = async () => { const handleSearch = async () => {
if (!searchQuery.trim()) { if (!searchQuery.trim()) {
@@ -187,6 +191,36 @@ export function MessagesPanel({
<X className="mr-1 h-3 w-3" /> Clear <X className="mr-1 h-3 w-3" /> Clear
</Button> </Button>
)} )}
{stats.error > 0 && onReanalyzeAllErrors && (
<Button
variant="destructive"
size="sm"
disabled={retryingAll}
onClick={async () => {
setRetryingAll(true);
setRetriedCount(null);
try {
const count = await onReanalyzeAllErrors();
setRetriedCount(count);
} finally {
setRetryingAll(false);
}
}}
>
<RotateCw
className={`mr-1.5 h-3.5 w-3.5 ${retryingAll ? "animate-spin" : ""}`}
/>
{retryingAll
? "Retrying..."
: `Retry All Errors (${stats.error})`}
</Button>
)}
{retriedCount !== null && (
<span className="text-xs text-green-400">
{retriedCount} message{retriedCount !== 1 ? "s" : ""} queued for
re-analysis
</span>
)}
<div className="ml-auto flex items-center gap-1.5"> <div className="ml-auto flex items-center gap-1.5">
<Filter className="h-4 w-4 text-muted-foreground" /> <Filter className="h-4 w-4 text-muted-foreground" />
{( {(
@@ -167,6 +167,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 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( export function moderateMessage(
id: string, id: string,
actionType: string, actionType: string,