import { useState, useEffect, useCallback, useRef } from 'react'; import { blogWriterApi, TaskStatusResponse } from '../services/blogWriterApi'; import { researchEngineApi } from '../services/researchEngineApi'; import { pollVideoTaskStatus } from '../api/videoStudioApi'; import { triggerSubscriptionError } from '../api/client'; export interface UsePollingOptions { interval?: number; // Polling interval in milliseconds maxAttempts?: number; // Maximum number of polling attempts onProgress?: (message: string) => void; // Callback for progress updates onComplete?: (result: T) => void; // Callback when task completes onError?: (error: string) => void; // Callback when task fails } export interface UsePollingReturn { isPolling: boolean; currentStatus: string; progressMessages: Array<{ timestamp: string; message: string }>; result: T | null; error: string | null; startPolling: (taskId: string) => void; stopPolling: () => void; } export function usePolling( pollFunction: (taskId: string) => Promise>, options: UsePollingOptions = {} ): UsePollingReturn { const { interval = 5000, // 5 seconds default - increased to reduce load onProgress, onComplete, onError } = options; const [isPolling, setIsPolling] = useState(false); const [currentStatus, setCurrentStatus] = useState('idle'); const [progressMessages, setProgressMessages] = useState>([]); const [result, setResult] = useState(null); const [error, setError] = useState(null); // Debug state changes only in development and when state actually changes meaningfully const prevStateRef = useRef({ isPolling: false, currentStatus: 'idle', progressCount: 0 }); useEffect(() => { const currentState = { isPolling, currentStatus, progressCount: progressMessages.length }; const prevState = prevStateRef.current; // Only log if state meaningfully changed (not just a re-render) if (process.env.NODE_ENV === 'development' && (prevState.isPolling !== currentState.isPolling || prevState.currentStatus !== currentState.currentStatus || (prevState.isPolling && currentState.progressCount !== prevState.progressCount))) { console.log('Polling state changed:', currentState); prevStateRef.current = currentState; } }, [isPolling, currentStatus, progressMessages.length]); const intervalRef = useRef(null); const attemptsRef = useRef(0); const currentTaskIdRef = useRef(null); /* const stopPolling = useCallback(() => { // Only log and clear if actually polling (not just cleanup on unmount when idle) const wasPolling = intervalRef.current !== null || isPolling; if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } // Only update state if actually was polling (prevents unnecessary state updates) if (wasPolling) { setIsPolling(false); attemptsRef.current = 0; currentTaskIdRef.current = null; // Only log meaningful stops (when actually stopping active polling) if (process.env.NODE_ENV === 'development') { console.log('stopPolling: Stopped active polling'); } } // Silently handle cleanup when not polling (common on unmount/re-render) }, [isPolling]); */ const stopPolling = useCallback(() => { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } setIsPolling(false); attemptsRef.current = 0; currentTaskIdRef.current = null; }, []); const startPolling = useCallback((taskId: string) => { if (isPolling) { stopPolling(); } currentTaskIdRef.current = taskId; setIsPolling(true); setCurrentStatus('pending'); setProgressMessages([]); setResult(null); setError(null); attemptsRef.current = 0; const poll = async () => { if (!currentTaskIdRef.current) { stopPolling(); return; } try { const status = await pollFunction(currentTaskIdRef.current); setCurrentStatus(status.status); // Update progress messages if (status.progress_messages && status.progress_messages.length > 0) { setProgressMessages(status.progress_messages); // Call onProgress with the latest message for backward compatibility const latestMessage = status.progress_messages[status.progress_messages.length - 1]; onProgress?.(latestMessage.message); } if (status.status === 'completed') { console.info('[usePolling] ✅ Task completed', { taskId: currentTaskIdRef.current }); setResult(status.result ?? null); if (status.result !== undefined) { onComplete?.(status.result); } stopPolling(); return; // Exit early to prevent further processing } else if (status.status === 'failed') { console.error('[usePolling] ❌ Task failed:', status.error); setError(status.error || 'Task failed'); onError?.(status.error || 'Task failed'); // Check if this is a subscription error and trigger modal if (status.error_status === 429 || status.error_status === 402) { console.log('usePolling: Detected subscription error in task status', { error_status: status.error_status, error_data: status.error_data, error: status.error }); // Create a mock error object with the subscription error data const errorData = status.error_data || {}; // Ensure usage_info is properly nested - it might be at the top level or nested const usageInfo = errorData.usage_info || (errorData.current_calls !== undefined ? errorData : null) || errorData; const mockError = { response: { status: status.error_status, data: { error: errorData.error || status.error || 'Subscription limit exceeded', message: errorData.message || errorData.error || status.error || 'You have reached your usage limit.', provider: errorData.provider || usageInfo?.provider || 'unknown', usage_info: usageInfo } } }; console.log('usePolling: Triggering subscription error handler with:', mockError); const handled = await triggerSubscriptionError(mockError); if (!handled) { console.warn('usePolling: Subscription error handler did not handle the error'); } } stopPolling(); return; // Exit early to prevent further processing } attemptsRef.current++; } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'; console.error('Polling error:', errorMessage, err); // Check if this is an axios error with subscription limit status // This is a fallback in case the interceptor doesn't catch it const axiosError = err as any; if (axiosError?.response?.status === 429 || axiosError?.response?.status === 402) { // Trigger subscription error handler (modal will show) // Note: The interceptor may have already called this, but we call it again to be safe const handled = await triggerSubscriptionError(axiosError); if (handled) { const errorMsg = axiosError.response?.data?.message || axiosError.response?.data?.error || 'Subscription limit exceeded'; setError(errorMsg); onError?.(errorMsg); stopPolling(); return; // Exit early - don't continue processing } else { console.warn('[usePolling] Subscription error not handled by global handler'); try { window.dispatchEvent(new CustomEvent('subscription-error', { detail: axiosError })); } catch (eventError) { console.error('usePolling: Failed to dispatch subscription-error event', eventError); } } } // Stop polling for task failures and rate limiting if (errorMessage.includes('404') || errorMessage.includes('Task not found')) { setError('Task not found - it may have expired or been cleaned up'); onError?.('Task not found - it may have expired or been cleaned up'); stopPolling(); } else if (errorMessage.includes('429') || errorMessage.includes('Too Many Requests')) { console.warn('Rate limited - stopping polling to prevent further issues'); setError('Rate limited - please try again later'); onError?.('Rate limited - please try again later'); stopPolling(); } // For other errors (timeouts, network issues), continue polling // The backend will eventually complete or fail, and we'll catch it } }; // Start polling immediately, then at intervals poll(); intervalRef.current = setInterval(poll, interval); }, [isPolling, interval, onProgress, onComplete, onError, pollFunction, stopPolling]); // Cleanup on unmount - only if actually polling // Use a ref to access the latest stopPolling function without triggering re-runs const stopPollingRef = useRef(stopPolling); useEffect(() => { stopPollingRef.current = stopPolling; }, [stopPolling]); useEffect(() => { return () => { // Only call stopPolling if we have an active interval (actually polling) // This prevents unnecessary cleanup calls when component unmounts while idle if (intervalRef.current) { stopPollingRef.current(); } }; }, []); // Empty dependency array ensures this only runs on unmount return { isPolling, currentStatus, progressMessages, result, error, startPolling, stopPolling }; } // Specialized hooks for specific operations export function useResearchPolling(options: UsePollingOptions = {}) { // Use new Research Engine polling endpoint return usePolling( async (taskId: string): Promise => { const response = await researchEngineApi.pollStatus(taskId); // Transform ResearchTaskStatusResponse to TaskStatusResponse return { task_id: taskId, status: response.status as 'pending' | 'running' | 'completed' | 'failed', created_at: new Date().toISOString(), progress_messages: response.progress_messages || [], result: response.result || undefined, error: response.error, error_status: response.error_status, error_data: response.error_data, }; }, options ); } export function useBlogWriterResearchPolling(options: UsePollingOptions = {}) { // Use Blog Writer research polling endpoint - direct import (already imported at top) return usePolling(blogWriterApi.pollResearchStatus, options); } export function useOutlinePolling(options: UsePollingOptions = {}) { return usePolling(blogWriterApi.pollOutlineStatus, options); } export function useMediumGenerationPolling(options: UsePollingOptions = {}) { // Lazy import to avoid circular: poll function from mediumBlogApi const pollFn = (taskId: string) => import('../services/blogWriterApi').then(m => m.mediumBlogApi.pollMediumGeneration(taskId)); // Wrap to satisfy type const wrapped = (taskId: string) => pollFn(taskId) as unknown as Promise; // eslint-disable-next-line react-hooks/rules-of-hooks return usePolling(wrapped, options); } export function useRewritePolling(options: UsePollingOptions = {}) { // Lazy import to avoid circular: poll function from blogWriterApi const pollFn = (taskId: string) => import('../services/blogWriterApi').then(m => m.blogWriterApi.pollRewriteStatus(taskId)); // Wrap to satisfy type const wrapped = (taskId: string) => pollFn(taskId) as unknown as Promise; // eslint-disable-next-line react-hooks/rules-of-hooks return usePolling(wrapped, options); } export function useVideoGenerationPolling(options: UsePollingOptions = {}) { return usePolling(pollVideoTaskStatus, options); }