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:
co-authored by
Claude Opus 4.8
parent
d81aaf2b4a
commit
6da8a32c9b
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user