import { getLocale, useI18n, translate } from "../../i18n"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { LoaderCircle, LockOpen, Square } from "lucide-react"; import { toast } from "sonner"; import { Button } from "../../components/ui/button"; import { ApiError, apiErrorMessage } from "../../api/client"; import { cancelDescriptionGenerationRun, descriptionGenerationEventsUrl, getDescriptionGenerationRun, listDescriptionGenerationEvents, listDescriptionGenerationRuns, type DescriptionGenerationEvent, type DescriptionGenerationRun, unlockDescriptionGenerationRun, } from "../../api/catalog-databases"; import { useTailFollowing } from "../activityScroll"; import { FleetLedgerDrawer } from "./FleetLedgerShell"; interface Props { open: boolean; databaseId?: string | null; run: DescriptionGenerationRun | null; modelLabel: string; onClose: () => void; onRunUpdate: (run: DescriptionGenerationRun) => void; onTerminal: (run: DescriptionGenerationRun) => void; } const APPARENTLY_STALE_AFTER_MS = 5 * 60 * 1_000; const SAFE_FINAL_ERROR = "The run ended before all targets were processed. Review the event log for safe details."; function statusLabel(run: DescriptionGenerationRun): string { const label = run.status.replaceAll("_", " "); return translate(`${label[0].toUpperCase()}${label.slice(1)}`); } function isTerminal(run?: DescriptionGenerationRun): boolean { return Boolean(run && [ "completed", "completed_with_errors", "cancelled", "failed", "interrupted", ].includes(run.status)); } function isApparentlyStale(run: DescriptionGenerationRun): boolean { if (!["queued", "running"].includes(run.status)) return false; const updatedAt = new Date(run.updatedAt).getTime(); return Number.isFinite(updatedAt) && Date.now() - updatedAt >= APPARENTLY_STALE_AFTER_MS; } function safeFinalError(summary: string | null): string | null { const value = summary?.trim(); if (!value) return null; if ( value.length > 500 || /[\r\n]/.test(value) || /traceback|stack trace|(?:^|\s)at\s+\S+[:(]|prompt|provider\s+payload|source\s+sample|bearer\s+\S+|api[_ -]?key|sk-[a-z0-9_-]+/i.test(value) ) return SAFE_FINAL_ERROR; return value; } function timestamp(value: string | null): string { return value ? new Date(value).toLocaleString(getLocale()) : translate("Not available"); } export function DescriptionGenerationDrawer({ open, databaseId = null, run: initialRun, modelLabel, onClose, onRunUpdate, onTerminal, }: Props) { const { t } = useI18n(); const queryClient = useQueryClient(); const runId = initialRun?.id ?? null; const [events, setEvents] = useState([]); const [action, setAction] = useState<"stop" | "unlock" | null>(null); const lastSequence = useRef(0); const terminalRuns = useRef(new Set()); const finalEventsRun = useRef(null); const runQuery = useQuery({ queryKey: ["description-generation-run", runId], queryFn: () => getDescriptionGenerationRun(runId!), enabled: Boolean(runId), initialData: initialRun ?? undefined, retry: false, refetchInterval: (query) => isTerminal(query.state.data) ? false : 1_000, }); const run = runQuery.data; const eventQuery = useQuery({ queryKey: ["description-generation-events", runId], queryFn: () => listDescriptionGenerationEvents(runId!, lastSequence.current), enabled: Boolean(runId), retry: false, refetchInterval: run && isTerminal(run) ? false : 1_500, }); const historyQuery = useQuery({ queryKey: ["description-generation-runs", 50], queryFn: () => listDescriptionGenerationRuns(50), enabled: open, retry: false, refetchInterval: (query) => query.state.data?.some((item) => !isTerminal(item)) ? 3_000 : false, }); const visibleHistory = useMemo( () => (historyQuery.data ?? []).filter((item) => !databaseId || item.databaseId === databaseId), [databaseId, historyQuery.data, t], ); const latestEventSequence = events.at(-1)?.sequence ?? 0; const { followingTail: followingEventTail, handleScroll: handleEventLogScroll, jumpToTail: jumpToLatestEvent, viewportRef: eventLogRef, } = useTailFollowing({ active: open && Boolean(run), resetKey: runId, tail: latestEventSequence, }); useEffect(() => { if (!open || initialRun || !visibleHistory[0]) return; onRunUpdate(visibleHistory[0]); }, [initialRun, onRunUpdate, open, visibleHistory]); const mergeEvents = useCallback((incoming: DescriptionGenerationEvent[]) => { if (incoming.length === 0) return; setEvents((current) => { const bySequence = new Map(current.map((event) => [event.sequence, event])); for (const event of incoming) bySequence.set(event.sequence, event); const merged = [...bySequence.values()].sort((left, right) => left.sequence - right.sequence); lastSequence.current = merged.at(-1)?.sequence ?? lastSequence.current; return merged; }); }, [t]); useEffect(() => { setEvents([]); lastSequence.current = 0; finalEventsRun.current = null; }, [runId]); useEffect(() => { mergeEvents(eventQuery.data ?? []); }, [eventQuery.data, mergeEvents]); const live = Boolean(run && !isTerminal(run)); useEffect(() => { if ( !runId || !open || !live || !eventQuery.isFetched || typeof EventSource === "undefined" ) return; let source: EventSource | null = null; let reconnectTimer: number | null = null; let disposed = false; const connect = () => { if (disposed) return; const polledSequence = (eventQuery.data ?? []).reduce( (latest, event) => Math.max(latest, event.sequence), 0, ); lastSequence.current = Math.max(lastSequence.current, polledSequence); let currentSource: EventSource; try { currentSource = new EventSource(descriptionGenerationEventsUrl(runId, lastSequence.current)); } catch { return; } source = currentSource; const isCurrent = () => !disposed && source === currentSource; const log = (message: MessageEvent) => { if (!isCurrent()) return; try { mergeEvents([JSON.parse(message.data) as DescriptionGenerationEvent]); } catch { /* ordered polling remains authoritative */ } }; const update = (message: MessageEvent) => { if (!isCurrent()) return; try { queryClient.setQueryData( ["description-generation-run", runId], JSON.parse(message.data) as DescriptionGenerationRun, ); } catch { /* status polling remains authoritative */ } }; const message = (incoming: MessageEvent) => { if (!isCurrent()) return; try { const parsed = JSON.parse(incoming.data) as DescriptionGenerationEvent | DescriptionGenerationRun; if ("sequence" in parsed) mergeEvents([parsed]); else if ("status" in parsed) queryClient.setQueryData(["description-generation-run", runId], parsed); } catch { /* polling remains authoritative */ } }; currentSource.addEventListener("log", log as EventListener); currentSource.addEventListener("run", update as EventListener); currentSource.onmessage = message; currentSource.onerror = () => { if (!isCurrent() || reconnectTimer !== null) return; currentSource.close(); source = null; void eventQuery.refetch().finally(() => { if (disposed || reconnectTimer !== null) return; reconnectTimer = window.setTimeout(() => { reconnectTimer = null; connect(); }, 200); }); }; }; connect(); return () => { disposed = true; source?.close(); if (reconnectTimer !== null) window.clearTimeout(reconnectTimer); }; }, [eventQuery.isFetched, eventQuery.refetch, live, mergeEvents, open, queryClient, runId]); useEffect(() => { if (!run) return; onRunUpdate(run); queryClient.setQueryData( ["description-generation-runs", 50], (current) => { if (!current) return [run]; return current.some((item) => item.id === run.id) ? current.map((item) => item.id === run.id ? run : item) : [run, ...current]; }, ); }, [onRunUpdate, queryClient, run]); useEffect(() => { if (!run || !isTerminal(run) || finalEventsRun.current === run.id) return; finalEventsRun.current = run.id; void eventQuery.refetch(); }, [eventQuery.refetch, run]); useEffect(() => { if (!run || !isTerminal(run) || terminalRuns.current.has(run.id)) return; terminalRuns.current.add(run.id); onTerminal(run); }, [onTerminal, run]); const stop = async () => { if (!run || isTerminal(run)) return; setAction("stop"); try { const next = await cancelDescriptionGenerationRun(run.id); queryClient.setQueryData(["description-generation-run", run.id], next); onRunUpdate(next); await historyQuery.refetch(); } catch (error) { toast.error(t(apiErrorMessage(error))); } finally { setAction(null); } }; const unlock = async () => { if (!run || !isApparentlyStale(run)) return; setAction("unlock"); try { const next = await unlockDescriptionGenerationRun(); queryClient.setQueryData(["description-generation-run", next.id], next); onRunUpdate(next); await historyQuery.refetch(); } catch (error) { toast.error(error instanceof ApiError && error.status === 409 ? t("Unlock was refused because a live description worker is still running.") : apiErrorMessage(error)); } finally { setAction(null); } }; if (!open) return null; if (!run) { return ( {historyQuery.isError ? (
{t(apiErrorMessage(historyQuery.error))}
) : historyQuery.isLoading || (historyQuery.data?.length ?? 0) > 0 ? (

{t("Loading run history…")}

) : (

{t("No description generation runs yet.")}

{t("Start a description generation to create the first history entry.")}

)}
); } const apparentlyStale = isApparentlyStale(run); const finalError = safeFinalError(run.errorSummary); const modelDisplay = modelLabel && modelLabel !== run.modelId ? `${modelLabel} (${run.modelId})` : run.modelId; const progressCounters = [ ["total", t("Total")], ["processed", t("Processed")], ["generated", t("Generated")], ["nonGeneratable", t("Not generated")], ["failed", t("Failed")], ] as const; const footer = ["queued", "running"].includes(run.status) ? (
{apparentlyStale ? (

{t("Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.")}

) : null}
{apparentlyStale ? ( ) : null}
) : undefined; return ( {!isTerminal(run) ? ); }