313 lines
12 KiB
TypeScript
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);
|
|
}
|