Files
ALwrity/frontend/src/hooks/usePolling.ts

313 lines
12 KiB
TypeScript

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<T = any> {
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<T = any> {
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<T = any>(
pollFunction: (taskId: string) => Promise<TaskStatusResponse<T>>,
options: UsePollingOptions<T> = {}
): UsePollingReturn<T> {
const {
interval = 5000, // 5 seconds default - increased to reduce load
onProgress,
onComplete,
onError
} = options;
const [isPolling, setIsPolling] = useState(false);
const [currentStatus, setCurrentStatus] = useState<string>('idle');
const [progressMessages, setProgressMessages] = useState<Array<{ timestamp: string; message: string }>>([]);
const [result, setResult] = useState<T | null>(null);
const [error, setError] = useState<string | null>(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<NodeJS.Timeout | null>(null);
const attemptsRef = useRef(0);
const currentTaskIdRef = useRef<string | null>(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<TaskStatusResponse> => {
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<TaskStatusResponse>;
// 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<TaskStatusResponse>;
// eslint-disable-next-line react-hooks/rules-of-hooks
return usePolling(wrapped, options);
}
export function useVideoGenerationPolling(options: UsePollingOptions = {}) {
return usePolling(pollVideoTaskStatus, options);
}