import { useActionQuery } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { useEffect, useRef } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; import { useAuth } from "@/components/auth/AuthProvider"; import { subscribeProviderCorpusJobSync } from "@/lib/provider-corpus-job-sync"; type ProviderCorpusJobStatus = | "running" | "paused" | "quota_wait" | "completed" | "failed"; type ProviderCorpusJobEntry = { job: { id: string; name: string; mode: string; status: ProviderCorpusJobStatus; provider: string; updatedAt: string; }; coverage: { pagesProcessed: number; batchesProcessed: number; itemsProcessed: number; matchedItems: number; totalHits: number; storedHits: number; truncatedHits: boolean; }; error: string | null; nextResumeAt: string | null; }; type ProviderCorpusJobsResponse = { jobs: ProviderCorpusJobEntry[]; total: number; }; const STORAGE_KEY = "analytics.providerCorpusJobNotifier.seen.v1"; const RECENT_WINDOW_MS = 24 * 60 * 60 * 1_000; const ACTIVE_JOB_POLL_MS = 15_000; export function providerCorpusJobsRefetchInterval(query: { state: { data?: unknown }; }): number | false { const jobs = (query.state.data as ProviderCorpusJobsResponse | undefined) ?.jobs; return jobs?.some((entry) => entry.job.status === "running") ? ACTIVE_JOB_POLL_MS : false; } export function ProviderCorpusJobNotifier() { const { auth, isLoading } = useAuth(); const t = useT(); const navigate = useNavigate(); const seenRef = useRef | null>(null); const { data, refetch } = useActionQuery( "provider-corpus-jobs", { operation: "list", limit: 20 }, { enabled: Boolean(auth) && !isLoading, refetchInterval: providerCorpusJobsRefetchInterval, refetchIntervalInBackground: false, retry: false, }, ); useEffect(() => { if (!auth || isLoading) return; return subscribeProviderCorpusJobSync(() => void refetch()); }, [auth, isLoading, refetch]); useEffect(() => { const jobs = (data as ProviderCorpusJobsResponse | undefined)?.jobs ?? []; if (!jobs.length) return; const seen = seenRef.current ?? readSeenJobs(); seenRef.current = seen; let changed = false; for (const entry of jobs) { if (!shouldNotify(entry)) continue; const key = notificationKey(entry); if (seen[entry.job.id] === key) continue; showJobToast(entry, () => navigate("/ask"), t); seen[entry.job.id] = key; changed = true; } if (changed) writeSeenJobs(seen); }, [data, navigate, t]); return null; } function shouldNotify(entry: ProviderCorpusJobEntry): boolean { if (!["completed", "quota_wait", "failed"].includes(entry.job.status)) { return false; } const updatedAt = Date.parse(entry.job.updatedAt); return ( Number.isFinite(updatedAt) && Date.now() - updatedAt <= RECENT_WINDOW_MS ); } function notificationKey(entry: ProviderCorpusJobEntry): string { return [ entry.job.status, entry.job.updatedAt, entry.coverage.itemsProcessed, entry.coverage.totalHits, entry.coverage.storedHits, entry.nextResumeAt ?? "", entry.error ?? "", ].join(":"); } function showJobToast( entry: ProviderCorpusJobEntry, openAsk: () => void, // i18n-ignore TypeScript helper signature t: ReturnType, // i18n-ignore TypeScript helper type ) { const title = entry.job.status === "completed" ? t("providerCorpusNotifier.completed") : entry.job.status === "quota_wait" ? t("providerCorpusNotifier.quotaWait") : t("providerCorpusNotifier.failed"); const description = [ `${entry.job.name} (${entry.job.provider})`, coverageSummary(entry, t), entry.nextResumeAt ? t("providerCorpusNotifier.resumeAfter", { date: formatDateTime(entry.nextResumeAt), }) : "", entry.error ?? "", t("providerCorpusNotifier.jobId", { id: entry.job.id }), ] .filter(Boolean) .join(" ยท "); const options = { id: `provider-corpus-job-${entry.job.id}-${entry.job.status}`, description, duration: entry.job.status === "completed" ? 14_000 : 20_000, action: { label: t("providerCorpusNotifier.openAsk"), onClick: openAsk, }, }; if (entry.job.status === "failed") { toast.error(title, options); } else if (entry.job.status === "completed") { toast.success(title, options); } else { toast.message(title, options); } } function coverageSummary( entry: ProviderCorpusJobEntry, t: ReturnType, // i18n-ignore TypeScript helper type ): string { const units = entry.coverage.batchesProcessed > 0 ? t("providerCorpusNotifier.batches", { count: entry.coverage.batchesProcessed, }) : t("providerCorpusNotifier.pages", { count: entry.coverage.pagesProcessed, }); const hits = entry.coverage.truncatedHits ? t("providerCorpusNotifier.truncatedHits", { stored: entry.coverage.storedHits, total: entry.coverage.totalHits, }) : t("providerCorpusNotifier.hits", { count: entry.coverage.totalHits, }); return t("providerCorpusNotifier.coverageSummary", { items: entry.coverage.itemsProcessed, matched: entry.coverage.matchedItems, hits, units, }); } function formatDateTime(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }); } function readSeenJobs(): Record { if (typeof window === "undefined") return {}; try { const raw = window.localStorage.getItem(STORAGE_KEY); const parsed = raw ? (JSON.parse(raw) as unknown) : {}; return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record) : {}; } catch { return {}; } } function writeSeenJobs(seen: Record) { if (typeof window === "undefined") return; const entries = Object.entries(seen).slice(-100); try { window.localStorage.setItem( STORAGE_KEY, JSON.stringify(Object.fromEntries(entries)), ); } catch { // Best effort only; duplicate toasts are preferable to hiding job results. } }