WIP: AI Podcast Maker and YouTube Creator Studio integration
This commit is contained in:
71
frontend/src/hooks/useBudgetTracking.ts
Normal file
71
frontend/src/hooks/useBudgetTracking.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface BudgetTrackingState {
|
||||
totalSpent: number;
|
||||
budgetCap: number;
|
||||
operations: Array<{ id: string; cost: number; timestamp: string; description: string }>;
|
||||
}
|
||||
|
||||
export const useBudgetTracking = (initialBudgetCap: number = 50) => {
|
||||
const [budget, setBudget] = useState<BudgetTrackingState>({
|
||||
totalSpent: 0,
|
||||
budgetCap: initialBudgetCap,
|
||||
operations: [],
|
||||
});
|
||||
|
||||
const addCost = useCallback((cost: number, description: string) => {
|
||||
setBudget((prev) => {
|
||||
const newTotal = prev.totalSpent + cost;
|
||||
const operation = {
|
||||
id: `${Date.now()}_${Math.random()}`,
|
||||
cost,
|
||||
timestamp: new Date().toISOString(),
|
||||
description,
|
||||
};
|
||||
|
||||
return {
|
||||
...prev,
|
||||
totalSpent: newTotal,
|
||||
operations: [...prev.operations, operation],
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setBudgetCap = useCallback((cap: number) => {
|
||||
setBudget((prev) => ({ ...prev, budgetCap: cap }));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setBudget({
|
||||
totalSpent: 0,
|
||||
budgetCap: initialBudgetCap,
|
||||
operations: [],
|
||||
});
|
||||
}, [initialBudgetCap]);
|
||||
|
||||
const canAfford = useCallback((estimatedCost: number): boolean => {
|
||||
return budget.totalSpent + estimatedCost <= budget.budgetCap;
|
||||
}, [budget.totalSpent, budget.budgetCap]);
|
||||
|
||||
const getRemaining = useCallback((): number => {
|
||||
return Math.max(0, budget.budgetCap - budget.totalSpent);
|
||||
}, [budget.budgetCap, budget.totalSpent]);
|
||||
|
||||
const getUsagePercentage = useCallback((): number => {
|
||||
if (budget.budgetCap === 0) return 0;
|
||||
return Math.min(100, (budget.totalSpent / budget.budgetCap) * 100);
|
||||
}, [budget.totalSpent, budget.budgetCap]);
|
||||
|
||||
return {
|
||||
totalSpent: budget.totalSpent,
|
||||
budgetCap: budget.budgetCap,
|
||||
remaining: getRemaining(),
|
||||
usagePercentage: getUsagePercentage(),
|
||||
operations: budget.operations,
|
||||
addCost,
|
||||
setBudgetCap,
|
||||
canAfford,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
|
||||
export interface ContentAsset {
|
||||
@@ -49,40 +49,100 @@ const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:800
|
||||
export const useContentAssets = (filters: AssetFilters = {}) => {
|
||||
const { getToken } = useAuth();
|
||||
const [assets, setAssets] = useState<ContentAsset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const isFetchingRef = useRef(false);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Memoize filters to create stable reference - only changes when actual values change
|
||||
const stableFilters = useMemo(() => {
|
||||
return {
|
||||
asset_type: filters.asset_type,
|
||||
source_module: filters.source_module,
|
||||
search: filters.search,
|
||||
tags: filters.tags,
|
||||
favorites_only: filters.favorites_only,
|
||||
limit: filters.limit,
|
||||
offset: filters.offset,
|
||||
};
|
||||
}, [
|
||||
filters.asset_type,
|
||||
filters.source_module,
|
||||
filters.search,
|
||||
filters.tags?.join(','),
|
||||
filters.favorites_only,
|
||||
filters.limit,
|
||||
filters.offset,
|
||||
]);
|
||||
|
||||
// Create stable filter key for comparison
|
||||
const filterKey = useMemo(() => {
|
||||
return JSON.stringify(stableFilters);
|
||||
}, [stableFilters]);
|
||||
|
||||
// Store latest filters in ref for use in fetch function
|
||||
const filtersRef = useRef(stableFilters);
|
||||
useEffect(() => {
|
||||
filtersRef.current = stableFilters;
|
||||
}, [stableFilters]);
|
||||
|
||||
// Fetch function - exposed for manual retry, not called automatically on errors
|
||||
const fetchAssets = useCallback(async () => {
|
||||
// Prevent concurrent fetches
|
||||
if (isFetchingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any pending request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller for this request
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
isFetchingRef.current = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
setLoading(false);
|
||||
isFetchingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Use ref to get latest filters
|
||||
const currentFilters = filtersRef.current;
|
||||
const params = new URLSearchParams();
|
||||
if (filters.asset_type) params.append('asset_type', filters.asset_type);
|
||||
if (filters.source_module) params.append('source_module', filters.source_module);
|
||||
if (filters.search) params.append('search', filters.search);
|
||||
if (filters.tags && filters.tags.length > 0) params.append('tags', filters.tags.join(','));
|
||||
if (filters.favorites_only) params.append('favorites_only', 'true');
|
||||
params.append('limit', String(filters.limit || 100));
|
||||
params.append('offset', String(filters.offset || 0));
|
||||
|
||||
// Add cache busting for fresh data
|
||||
params.append('_t', String(Date.now()));
|
||||
if (currentFilters.asset_type) params.append('asset_type', currentFilters.asset_type);
|
||||
if (currentFilters.source_module) params.append('source_module', currentFilters.source_module);
|
||||
if (currentFilters.search) params.append('search', currentFilters.search);
|
||||
if (currentFilters.tags && currentFilters.tags.length > 0) params.append('tags', currentFilters.tags.join(','));
|
||||
if (currentFilters.favorites_only) params.append('favorites_only', 'true');
|
||||
params.append('limit', String(currentFilters.limit || 100));
|
||||
params.append('offset', String(currentFilters.offset || 0));
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/content-assets/?${params.toString()}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
setError('Rate limit exceeded. Please try again later.');
|
||||
setAssets([]);
|
||||
setTotal(0);
|
||||
setLoading(false);
|
||||
isFetchingRef.current = false;
|
||||
return;
|
||||
}
|
||||
throw new Error(`Failed to fetch assets: ${response.statusText}`);
|
||||
}
|
||||
|
||||
@@ -90,16 +150,34 @@ export const useContentAssets = (filters: AssetFilters = {}) => {
|
||||
setAssets(data.assets);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch assets');
|
||||
// Don't set error for aborted requests
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
if (err instanceof TypeError && err.message.includes('fetch')) {
|
||||
setError('Network error. Please check your connection.');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch assets');
|
||||
}
|
||||
setAssets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
isFetchingRef.current = false;
|
||||
}
|
||||
}, [getToken, filters]);
|
||||
}, [getToken]); // Only depend on getToken, use ref for filters
|
||||
|
||||
// Fetch on mount and when filters change - but only once per filter change
|
||||
// NO automatic retry on errors - user must call refetch() manually
|
||||
useEffect(() => {
|
||||
fetchAssets();
|
||||
}, [fetchAssets]);
|
||||
|
||||
// Cleanup: abort on unmount or filter change
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [filterKey, fetchAssets]); // Include fetchAssets but it's stable due to ref usage
|
||||
|
||||
const toggleFavorite = useCallback(async (assetId: number) => {
|
||||
try {
|
||||
|
||||
372
frontend/src/hooks/usePodcastProjectState.ts
Normal file
372
frontend/src/hooks/usePodcastProjectState.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
PodcastAnalysis,
|
||||
PodcastEstimate,
|
||||
Query,
|
||||
Research,
|
||||
Script,
|
||||
Knobs,
|
||||
Job,
|
||||
CreateProjectPayload,
|
||||
} from '../components/PodcastMaker/types';
|
||||
import { BlogResearchResponse, ResearchProvider } from '../services/blogWriterApi';
|
||||
import { podcastApi } from '../services/podcastApi';
|
||||
|
||||
export interface PodcastProjectState {
|
||||
// Project metadata
|
||||
project: { id: string; idea: string; duration: number; speakers: number } | null;
|
||||
|
||||
// Step results
|
||||
analysis: PodcastAnalysis | null;
|
||||
queries: Query[];
|
||||
selectedQueries: Set<string>;
|
||||
research: Research | null;
|
||||
rawResearch: BlogResearchResponse | null;
|
||||
estimate: PodcastEstimate | null;
|
||||
scriptData: Script | null;
|
||||
|
||||
// Render jobs
|
||||
renderJobs: Job[];
|
||||
|
||||
// Settings
|
||||
knobs: Knobs;
|
||||
researchProvider: ResearchProvider;
|
||||
budgetCap: number;
|
||||
|
||||
// UI state
|
||||
showScriptEditor: boolean;
|
||||
showRenderQueue: boolean;
|
||||
|
||||
// Current step tracking
|
||||
currentStep: 'create' | 'analysis' | 'research' | 'script' | 'render' | null;
|
||||
|
||||
// Timestamps
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_KNOBS: Knobs = {
|
||||
voice_emotion: "neutral",
|
||||
voice_speed: 1,
|
||||
resolution: "720p",
|
||||
scene_length_target: 45,
|
||||
sample_rate: 24000,
|
||||
bitrate: "standard",
|
||||
};
|
||||
|
||||
const DEFAULT_STATE: PodcastProjectState = {
|
||||
project: null,
|
||||
analysis: null,
|
||||
queries: [],
|
||||
selectedQueries: new Set(),
|
||||
research: null,
|
||||
rawResearch: null,
|
||||
estimate: null,
|
||||
scriptData: null,
|
||||
renderJobs: [],
|
||||
knobs: DEFAULT_KNOBS,
|
||||
researchProvider: "google",
|
||||
budgetCap: 50,
|
||||
showScriptEditor: false,
|
||||
showRenderQueue: false,
|
||||
currentStep: null,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'podcast_project_state';
|
||||
|
||||
export const usePodcastProjectState = () => {
|
||||
const [state, setState] = useState<PodcastProjectState>(() => {
|
||||
// Initialize from localStorage if available
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
|
||||
// Restore Sets from arrays
|
||||
const restoredState: PodcastProjectState = {
|
||||
...DEFAULT_STATE,
|
||||
...parsed,
|
||||
selectedQueries: parsed.selectedQueries ? new Set(parsed.selectedQueries) : new Set(),
|
||||
renderJobs: parsed.renderJobs || [],
|
||||
};
|
||||
|
||||
return restoredState;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading podcast project state from localStorage:', error);
|
||||
}
|
||||
return DEFAULT_STATE;
|
||||
});
|
||||
|
||||
// Debounce ref for database sync
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Persist state to localStorage on every change
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Convert Sets to arrays for JSON serialization
|
||||
const serializableState = {
|
||||
...state,
|
||||
selectedQueries: Array.from(state.selectedQueries),
|
||||
};
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(serializableState));
|
||||
} catch (error) {
|
||||
console.error('Error saving podcast project state to localStorage:', error);
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
// Sync to database after major steps (debounced)
|
||||
useEffect(() => {
|
||||
if (!state.project || !state.project.id) return;
|
||||
|
||||
// Capture project ID to avoid closure issues
|
||||
const projectId = state.project.id;
|
||||
|
||||
// Clear existing timeout
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce database sync (wait 2 seconds after last change)
|
||||
syncTimeoutRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const dbState = {
|
||||
analysis: state.analysis,
|
||||
queries: state.queries,
|
||||
selected_queries: Array.from(state.selectedQueries),
|
||||
research: state.research,
|
||||
raw_research: state.rawResearch,
|
||||
estimate: state.estimate,
|
||||
script_data: state.scriptData,
|
||||
render_jobs: state.renderJobs,
|
||||
knobs: state.knobs,
|
||||
research_provider: state.researchProvider,
|
||||
show_script_editor: state.showScriptEditor,
|
||||
show_render_queue: state.showRenderQueue,
|
||||
current_step: state.currentStep,
|
||||
status: state.currentStep === 'render' && state.renderJobs.every(j => j.status === 'completed') ? 'completed' : 'in_progress',
|
||||
};
|
||||
|
||||
await podcastApi.saveProject(projectId, dbState);
|
||||
} catch (error) {
|
||||
console.error('Error syncing project to database:', error);
|
||||
// Don't throw - localStorage is still working
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
return () => {
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
state.project,
|
||||
state.analysis,
|
||||
state.queries,
|
||||
state.selectedQueries,
|
||||
state.research,
|
||||
state.rawResearch,
|
||||
state.estimate,
|
||||
state.scriptData,
|
||||
state.renderJobs,
|
||||
state.knobs,
|
||||
state.researchProvider,
|
||||
state.showScriptEditor,
|
||||
state.showRenderQueue,
|
||||
state.currentStep,
|
||||
]);
|
||||
|
||||
// Setters
|
||||
const setProject = useCallback((project: PodcastProjectState['project']) => {
|
||||
setState((prev) => ({ ...prev, project, currentStep: project ? 'analysis' : null, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setAnalysis = useCallback((analysis: PodcastProjectState['analysis']) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
analysis,
|
||||
currentStep: analysis ? 'research' : prev.currentStep,
|
||||
updatedAt: new Date().toISOString()
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setQueries = useCallback((queries: Query[]) => {
|
||||
setState((prev) => ({ ...prev, queries, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setSelectedQueries = useCallback((selectedQueries: Set<string> | ((prev: Set<string>) => Set<string>)) => {
|
||||
setState((prev) => {
|
||||
const newQueries = typeof selectedQueries === 'function' ? selectedQueries(prev.selectedQueries) : selectedQueries;
|
||||
return { ...prev, selectedQueries: newQueries, updatedAt: new Date().toISOString() };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setResearch = useCallback((research: PodcastProjectState['research']) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
research,
|
||||
currentStep: research ? 'script' : prev.currentStep,
|
||||
updatedAt: new Date().toISOString()
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setRawResearch = useCallback((rawResearch: PodcastProjectState['rawResearch']) => {
|
||||
setState((prev) => ({ ...prev, rawResearch, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setEstimate = useCallback((estimate: PodcastProjectState['estimate']) => {
|
||||
setState((prev) => ({ ...prev, estimate, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setScriptData = useCallback((scriptData: PodcastProjectState['scriptData']) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
scriptData,
|
||||
currentStep: scriptData ? 'render' : prev.currentStep,
|
||||
updatedAt: new Date().toISOString()
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setRenderJobs = useCallback((renderJobs: Job[]) => {
|
||||
setState((prev) => ({ ...prev, renderJobs, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const updateRenderJob = useCallback((sceneId: string, updates: Partial<Job>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
renderJobs: prev.renderJobs.map((job) =>
|
||||
job.sceneId === sceneId ? { ...job, ...updates } : job
|
||||
),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const setKnobs = useCallback((knobs: Knobs) => {
|
||||
setState((prev) => ({ ...prev, knobs, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setResearchProvider = useCallback((provider: ResearchProvider) => {
|
||||
setState((prev) => ({ ...prev, researchProvider: provider, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setBudgetCap = useCallback((cap: number) => {
|
||||
setState((prev) => ({ ...prev, budgetCap: cap, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setShowScriptEditor = useCallback((show: boolean) => {
|
||||
setState((prev) => ({ ...prev, showScriptEditor: show, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setShowRenderQueue = useCallback((show: boolean) => {
|
||||
setState((prev) => ({ ...prev, showRenderQueue: show, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
const setCurrentStep = useCallback((step: PodcastProjectState['currentStep']) => {
|
||||
setState((prev) => ({ ...prev, currentStep: step, updatedAt: new Date().toISOString() }));
|
||||
}, []);
|
||||
|
||||
// Reset state
|
||||
const resetState = useCallback(() => {
|
||||
setState(DEFAULT_STATE);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}, []);
|
||||
|
||||
// Initialize project from payload
|
||||
const initializeProject = useCallback(async (payload: CreateProjectPayload, projectId: string) => {
|
||||
// Create project in database
|
||||
try {
|
||||
await podcastApi.createProjectInDb({
|
||||
project_id: projectId,
|
||||
idea: payload.ideaOrUrl,
|
||||
duration: payload.duration,
|
||||
speakers: payload.speakers,
|
||||
budget_cap: payload.budgetCap,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating project in database:', error);
|
||||
// Continue anyway - localStorage fallback
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
project: {
|
||||
id: projectId,
|
||||
idea: payload.ideaOrUrl,
|
||||
duration: payload.duration,
|
||||
speakers: payload.speakers,
|
||||
},
|
||||
knobs: payload.knobs,
|
||||
budgetCap: payload.budgetCap,
|
||||
currentStep: 'analysis',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// Load project from database
|
||||
const loadProjectFromDb = useCallback(async (projectId: string) => {
|
||||
try {
|
||||
const dbProject = await podcastApi.loadProject(projectId);
|
||||
|
||||
// Restore state from database
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
project: {
|
||||
id: dbProject.project_id,
|
||||
idea: dbProject.idea,
|
||||
duration: dbProject.duration,
|
||||
speakers: dbProject.speakers,
|
||||
},
|
||||
analysis: dbProject.analysis,
|
||||
queries: dbProject.queries || [],
|
||||
selectedQueries: new Set(dbProject.selected_queries || []),
|
||||
research: dbProject.research,
|
||||
rawResearch: dbProject.raw_research,
|
||||
estimate: dbProject.estimate,
|
||||
scriptData: dbProject.script_data,
|
||||
renderJobs: dbProject.render_jobs || [],
|
||||
knobs: dbProject.knobs || DEFAULT_KNOBS,
|
||||
researchProvider: dbProject.research_provider || 'google',
|
||||
budgetCap: dbProject.budget_cap || 50,
|
||||
showScriptEditor: dbProject.show_script_editor || false,
|
||||
showRenderQueue: dbProject.show_render_queue || false,
|
||||
currentStep: dbProject.current_step || null,
|
||||
createdAt: dbProject.created_at,
|
||||
updatedAt: dbProject.updated_at,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error loading project from database:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
...state,
|
||||
|
||||
// Setters
|
||||
setProject,
|
||||
setAnalysis,
|
||||
setQueries,
|
||||
setSelectedQueries,
|
||||
setResearch,
|
||||
setRawResearch,
|
||||
setEstimate,
|
||||
setScriptData,
|
||||
setRenderJobs,
|
||||
updateRenderJob,
|
||||
setKnobs,
|
||||
setResearchProvider,
|
||||
setBudgetCap,
|
||||
setShowScriptEditor,
|
||||
setShowRenderQueue,
|
||||
setCurrentStep,
|
||||
|
||||
// Helpers
|
||||
resetState,
|
||||
initializeProject,
|
||||
loadProjectFromDb,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,257 +1,82 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
checkPreflight,
|
||||
PreflightOperation,
|
||||
PreflightCheckResponse,
|
||||
PreflightLimitInfo,
|
||||
} from '../services/billingService';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { checkPreflight, PreflightOperation, PreflightCheckResponse } from '../services/billingService';
|
||||
|
||||
export interface UsePreflightCheckOptions {
|
||||
operation: PreflightOperation;
|
||||
enabled?: boolean; // Whether to perform check on hover
|
||||
debounceMs?: number; // Debounce delay (default: 300ms)
|
||||
cacheTtl?: number; // Cache TTL in ms (default: 5000ms)
|
||||
onBlocked?: (response: PreflightCheckResponse) => void;
|
||||
onAllowed?: (response: PreflightCheckResponse) => void;
|
||||
}
|
||||
|
||||
export interface UsePreflightCheckResult {
|
||||
canProceed: boolean;
|
||||
estimatedCost: number;
|
||||
limitInfo: PreflightLimitInfo | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
checkOnHover: () => void;
|
||||
checkNow: () => void; // Immediate check
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
data: PreflightCheckResponse;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for pre-flight checking operations with cost estimation.
|
||||
*
|
||||
* Features:
|
||||
* - Debounced hover checks (300ms default)
|
||||
* - In-memory caching (5s default TTL)
|
||||
* - Request cancellation on unmount
|
||||
*/
|
||||
export const usePreflightCheck = (
|
||||
options: UsePreflightCheckOptions
|
||||
): UsePreflightCheckResult => {
|
||||
const {
|
||||
operation,
|
||||
enabled = true,
|
||||
debounceMs = 300,
|
||||
cacheTtl = 5000,
|
||||
} = options;
|
||||
|
||||
const [canProceed, setCanProceed] = useState<boolean>(true);
|
||||
const [estimatedCost, setEstimatedCost] = useState<number>(0);
|
||||
const [limitInfo, setLimitInfo] = useState<PreflightLimitInfo | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
export const usePreflightCheck = (options?: UsePreflightCheckOptions) => {
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [lastCheck, setLastCheck] = useState<PreflightCheckResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Cache for pre-flight check results
|
||||
const cacheRef = useRef<Map<string, CacheEntry>>(new Map());
|
||||
|
||||
// Debounce timer ref
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Abort controller for request cancellation
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Generate cache key from operation
|
||||
const getCacheKey = useCallback(() => {
|
||||
return JSON.stringify(operation);
|
||||
}, [operation]);
|
||||
|
||||
// Check if cached result is still valid
|
||||
const getCachedResult = useCallback((): PreflightCheckResponse | null => {
|
||||
const cacheKey = getCacheKey();
|
||||
const cached = cacheRef.current.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
const age = Date.now() - cached.timestamp;
|
||||
if (age < cacheTtl) {
|
||||
return cached.data;
|
||||
}
|
||||
// Cache expired, remove it
|
||||
cacheRef.current.delete(cacheKey);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [getCacheKey, cacheTtl]);
|
||||
|
||||
// Store result in cache
|
||||
const setCache = useCallback((data: PreflightCheckResponse) => {
|
||||
const cacheKey = getCacheKey();
|
||||
cacheRef.current.set(cacheKey, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}, [getCacheKey]);
|
||||
|
||||
// Perform actual pre-flight check
|
||||
const performCheck = useCallback(async (): Promise<void> => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
const cached = getCachedResult();
|
||||
if (cached) {
|
||||
updateState(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any in-flight request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
const currentAbortController = abortControllerRef.current;
|
||||
|
||||
setLoading(true);
|
||||
const check = useCallback(async (operation: PreflightOperation): Promise<PreflightCheckResponse> => {
|
||||
setIsChecking(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
const response = await checkPreflight(operation);
|
||||
setLastCheck(response);
|
||||
|
||||
// Check if request was cancelled
|
||||
if (currentAbortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
setCache(response);
|
||||
|
||||
// Update state
|
||||
updateState(response);
|
||||
} catch (err: any) {
|
||||
// Check if request was cancelled
|
||||
if (currentAbortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMessage = err?.message || 'Pre-flight check failed';
|
||||
setError(errorMessage);
|
||||
setCanProceed(false);
|
||||
setEstimatedCost(0);
|
||||
setLimitInfo(null);
|
||||
} finally {
|
||||
if (!currentAbortController.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [operation, enabled, getCachedResult, setCache]);
|
||||
|
||||
// Update state from response
|
||||
const updateState = useCallback((response: PreflightCheckResponse) => {
|
||||
setCanProceed(response.can_proceed);
|
||||
setEstimatedCost(response.estimated_cost);
|
||||
|
||||
// Get limit info from first operation (for single operation checks)
|
||||
const firstOp = response.operations[0];
|
||||
if (firstOp) {
|
||||
setLimitInfo(firstOp.limit_info);
|
||||
if (!response.can_proceed && firstOp.message) {
|
||||
setError(firstOp.message);
|
||||
if (!response.can_proceed) {
|
||||
setError(response.operations[0]?.message || 'Operation blocked by subscription limits');
|
||||
options?.onBlocked?.(response);
|
||||
} else {
|
||||
setError(null);
|
||||
options?.onAllowed?.(response);
|
||||
}
|
||||
} else {
|
||||
setLimitInfo(null);
|
||||
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err?.response?.data?.detail || err?.message || 'Preflight check failed';
|
||||
setError(errorMessage);
|
||||
|
||||
// Return blocked response on error
|
||||
const blockedResponse: PreflightCheckResponse = {
|
||||
can_proceed: false,
|
||||
estimated_cost: 0,
|
||||
operations: [{
|
||||
provider: operation.provider,
|
||||
operation_type: operation.operation_type,
|
||||
cost: 0,
|
||||
allowed: false,
|
||||
limit_info: null,
|
||||
message: errorMessage,
|
||||
}],
|
||||
total_cost: 0,
|
||||
usage_summary: null,
|
||||
cached: false,
|
||||
};
|
||||
|
||||
setLastCheck(blockedResponse);
|
||||
options?.onBlocked?.(blockedResponse);
|
||||
return blockedResponse;
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}, []);
|
||||
}, [options]);
|
||||
|
||||
// Debounced check for hover events
|
||||
const checkOnHover = useCallback(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
// Check cache first (no debounce for cache hits)
|
||||
const cached = getCachedResult();
|
||||
if (cached) {
|
||||
updateState(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce the actual API call
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
performCheck();
|
||||
}, debounceMs);
|
||||
}, [enabled, debounceMs, getCachedResult, updateState, performCheck]);
|
||||
|
||||
// Immediate check (no debounce)
|
||||
const checkNow = useCallback(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending debounced check
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
|
||||
performCheck();
|
||||
}, [enabled, performCheck]);
|
||||
|
||||
// Reset state
|
||||
const reset = useCallback(() => {
|
||||
setCanProceed(true);
|
||||
setEstimatedCost(0);
|
||||
setLimitInfo(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
|
||||
// Clear debounce timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Cancel any in-flight request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clear debounce timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
// Cancel any in-flight request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
// Extract useful properties from lastCheck
|
||||
const estimatedCost = lastCheck?.estimated_cost ?? 0;
|
||||
const limitInfo = lastCheck?.operations?.[0]?.limit_info ?? null;
|
||||
|
||||
return {
|
||||
canProceed,
|
||||
check,
|
||||
isChecking,
|
||||
lastCheck,
|
||||
error,
|
||||
canProceed: lastCheck?.can_proceed ?? null,
|
||||
estimatedCost,
|
||||
limitInfo,
|
||||
loading,
|
||||
error,
|
||||
checkOnHover,
|
||||
checkNow,
|
||||
reset,
|
||||
loading: isChecking,
|
||||
// For backward compatibility with OperationButton
|
||||
checkOnHover: () => {}, // No-op for now, can be implemented if needed
|
||||
checkNow: () => check(lastCheck?.operations?.[0] ? {
|
||||
provider: lastCheck.operations[0].provider,
|
||||
operation_type: lastCheck.operations[0].operation_type,
|
||||
} as PreflightOperation : {
|
||||
provider: 'gemini',
|
||||
operation_type: 'unknown',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user