Scheduled research persona generation
This commit is contained in:
@@ -18,6 +18,7 @@ import WordPressCallbackPage from './components/WordPressCallbackPage/WordPressC
|
||||
import BingCallbackPage from './components/BingCallbackPage/BingCallbackPage';
|
||||
import BingAnalyticsStorage from './components/BingAnalyticsStorage/BingAnalyticsStorage';
|
||||
import ResearchTest from './pages/ResearchTest';
|
||||
import SchedulerDashboard from './pages/SchedulerDashboard';
|
||||
import ProtectedRoute from './components/shared/ProtectedRoute';
|
||||
import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallback';
|
||||
import Landing from './components/Landing/Landing';
|
||||
@@ -27,8 +28,9 @@ import CopilotKitDegradedBanner from './components/shared/CopilotKitDegradedBann
|
||||
import { OnboardingProvider } from './contexts/OnboardingContext';
|
||||
import { SubscriptionProvider, useSubscription } from './contexts/SubscriptionContext';
|
||||
import { CopilotKitHealthProvider } from './contexts/CopilotKitHealthContext';
|
||||
import { useOAuthTokenAlerts } from './hooks/useOAuthTokenAlerts';
|
||||
|
||||
import { setAuthTokenGetter } from './api/client';
|
||||
import { setAuthTokenGetter, setClerkSignOut } from './api/client';
|
||||
import { useOnboarding } from './contexts/OnboardingContext';
|
||||
import { useState, useEffect } from 'react';
|
||||
import ConnectionErrorPage from './components/shared/ConnectionErrorPage';
|
||||
@@ -60,6 +62,13 @@ const InitialRouteHandler: React.FC = () => {
|
||||
hasError: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Poll for OAuth token alerts and show toast notifications
|
||||
// Only enabled when user is authenticated (has subscription)
|
||||
useOAuthTokenAlerts({
|
||||
enabled: subscription?.active === true,
|
||||
interval: 60000, // Poll every 1 minute
|
||||
});
|
||||
|
||||
// Check subscription on mount (non-blocking - don't wait for it to route)
|
||||
useEffect(() => {
|
||||
@@ -266,7 +275,7 @@ const RootRoute: React.FC = () => {
|
||||
// Installs Clerk auth token getter into axios clients and stores user_id
|
||||
// Must render under ClerkProvider
|
||||
const TokenInstaller: React.FC = () => {
|
||||
const { getToken, userId, isSignedIn } = useAuth();
|
||||
const { getToken, userId, isSignedIn, signOut } = useAuth();
|
||||
|
||||
// Store user_id in localStorage when user signs in
|
||||
useEffect(() => {
|
||||
@@ -300,6 +309,15 @@ const TokenInstaller: React.FC = () => {
|
||||
});
|
||||
}, [getToken]);
|
||||
|
||||
// Install Clerk signOut function for handling expired tokens
|
||||
useEffect(() => {
|
||||
if (signOut) {
|
||||
setClerkSignOut(async () => {
|
||||
await signOut();
|
||||
});
|
||||
}
|
||||
}, [signOut]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -407,6 +425,7 @@ const App: React.FC = () => {
|
||||
<Route path="/facebook-writer" element={<ProtectedRoute><FacebookWriter /></ProtectedRoute>} />
|
||||
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
|
||||
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
|
||||
<Route path="/scheduler-dashboard" element={<ProtectedRoute><SchedulerDashboard /></ProtectedRoute>} />
|
||||
<Route path="/pricing" element={<PricingPage />} />
|
||||
<Route path="/research-test" element={<ResearchTest />} />
|
||||
<Route path="/wix-test" element={<WixTestPage />} />
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Global subscription error handler - will be set by the app
|
||||
let globalSubscriptionErrorHandler: ((error: any) => boolean) | null = null;
|
||||
// Can be async to support subscription status refresh
|
||||
let globalSubscriptionErrorHandler: ((error: any) => boolean | Promise<boolean>) | null = null;
|
||||
|
||||
export const setGlobalSubscriptionErrorHandler = (handler: (error: any) => boolean) => {
|
||||
export const setGlobalSubscriptionErrorHandler = (handler: (error: any) => boolean | Promise<boolean>) => {
|
||||
globalSubscriptionErrorHandler = handler;
|
||||
};
|
||||
|
||||
// Export a function to trigger subscription error handler from outside axios interceptors
|
||||
export const triggerSubscriptionError = (error: any) => {
|
||||
export const triggerSubscriptionError = async (error: any) => {
|
||||
const status = error?.response?.status;
|
||||
console.log('triggerSubscriptionError: Received error', {
|
||||
hasHandler: !!globalSubscriptionErrorHandler,
|
||||
@@ -18,7 +19,9 @@ export const triggerSubscriptionError = (error: any) => {
|
||||
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
console.log('triggerSubscriptionError: Calling global subscription error handler');
|
||||
return globalSubscriptionErrorHandler(error);
|
||||
const result = globalSubscriptionErrorHandler(error);
|
||||
// Handle both sync and async handlers
|
||||
return result instanceof Promise ? await result : result;
|
||||
}
|
||||
|
||||
console.warn('triggerSubscriptionError: No global subscription error handler registered');
|
||||
@@ -28,6 +31,13 @@ export const triggerSubscriptionError = (error: any) => {
|
||||
// Optional token getter installed from within the app after Clerk is available
|
||||
let authTokenGetter: (() => Promise<string | null>) | null = null;
|
||||
|
||||
// Optional Clerk sign-out function - set by App.tsx when Clerk is available
|
||||
let clerkSignOut: (() => Promise<void>) | null = null;
|
||||
|
||||
export const setClerkSignOut = (signOutFn: () => Promise<void>) => {
|
||||
clerkSignOut = signOutFn;
|
||||
};
|
||||
|
||||
export const setAuthTokenGetter = (getter: () => Promise<string | null>) => {
|
||||
authTokenGetter = getter;
|
||||
};
|
||||
@@ -170,25 +180,67 @@ apiClient.interceptors.response.use(
|
||||
console.error('Token refresh failed:', retryError);
|
||||
}
|
||||
|
||||
// If retry failed, don't redirect during app initialization (root route)
|
||||
// Only redirect if we're on a protected route and definitely authenticated
|
||||
// If retry failed, token is expired - sign out user and redirect to sign in
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
|
||||
// Don't redirect from root route during app initialization - allow InitialRouteHandler to work
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
// Only redirect if we're definitely not just initializing
|
||||
try { window.location.assign('/'); } catch {}
|
||||
// Token expired - sign out user and redirect to landing/sign-in
|
||||
console.warn('401 Unauthorized - token expired, signing out user');
|
||||
|
||||
// Clear any cached auth data
|
||||
localStorage.removeItem('user_id');
|
||||
localStorage.removeItem('auth_token');
|
||||
|
||||
// Use Clerk signOut if available, otherwise just redirect
|
||||
if (clerkSignOut) {
|
||||
clerkSignOut()
|
||||
.then(() => {
|
||||
// Redirect to landing page after sign out
|
||||
window.location.assign('/');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error during Clerk sign out:', err);
|
||||
// Fallback: redirect anyway
|
||||
window.location.assign('/');
|
||||
});
|
||||
} else {
|
||||
// Fallback: redirect to landing (will show sign-in if Clerk handles it)
|
||||
window.location.assign('/');
|
||||
}
|
||||
} else {
|
||||
console.warn('401 Unauthorized - token refresh failed (during initialization, not redirecting)');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 401 errors that weren't retried (e.g., no authTokenGetter, already retried, etc.)
|
||||
if (error?.response?.status === 401 && (originalRequest._retry || !authTokenGetter)) {
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
// Token expired - sign out user and redirect
|
||||
console.warn('401 Unauthorized - token expired (not retried), signing out user');
|
||||
localStorage.removeItem('user_id');
|
||||
localStorage.removeItem('auth_token');
|
||||
|
||||
if (clerkSignOut) {
|
||||
clerkSignOut()
|
||||
.then(() => window.location.assign('/'))
|
||||
.catch(() => window.location.assign('/'));
|
||||
} else {
|
||||
window.location.assign('/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a subscription-related error and handle it globally
|
||||
if (error.response?.status === 429 || error.response?.status === 402) {
|
||||
console.log('API Client: Detected subscription error, triggering global handler');
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
const wasHandled = globalSubscriptionErrorHandler(error);
|
||||
const result = globalSubscriptionErrorHandler(error);
|
||||
const wasHandled = result instanceof Promise ? await result : result;
|
||||
if (wasHandled) {
|
||||
console.log('API Client: Subscription error handled by global handler');
|
||||
return Promise.reject(error);
|
||||
@@ -245,7 +297,18 @@ aiApiClient.interceptors.response.use(
|
||||
|
||||
// Don't redirect from root route during app initialization
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
try { window.location.assign('/'); } catch {}
|
||||
// Token expired - sign out user and redirect
|
||||
console.warn('401 Unauthorized - token expired, signing out user');
|
||||
localStorage.removeItem('user_id');
|
||||
localStorage.removeItem('auth_token');
|
||||
|
||||
if (clerkSignOut) {
|
||||
clerkSignOut()
|
||||
.then(() => window.location.assign('/'))
|
||||
.catch(() => window.location.assign('/'));
|
||||
} else {
|
||||
window.location.assign('/');
|
||||
}
|
||||
} else {
|
||||
console.warn('401 Unauthorized - token refresh failed (during initialization, not redirecting)');
|
||||
}
|
||||
@@ -255,7 +318,8 @@ aiApiClient.interceptors.response.use(
|
||||
if (error.response?.status === 429 || error.response?.status === 402) {
|
||||
console.log('AI API Client: Detected subscription error, triggering global handler');
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
const wasHandled = globalSubscriptionErrorHandler(error);
|
||||
const result = globalSubscriptionErrorHandler(error);
|
||||
const wasHandled = result instanceof Promise ? await result : result;
|
||||
if (wasHandled) {
|
||||
console.log('AI API Client: Subscription error handled by global handler');
|
||||
return Promise.reject(error);
|
||||
@@ -290,7 +354,7 @@ longRunningApiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
async (error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
// Only redirect on 401 if we're not in onboarding flow or root route
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
@@ -307,7 +371,8 @@ longRunningApiClient.interceptors.response.use(
|
||||
if (error.response?.status === 429 || error.response?.status === 402) {
|
||||
console.log('Long-running API Client: Detected subscription error, triggering global handler');
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
const wasHandled = globalSubscriptionErrorHandler(error);
|
||||
const result = globalSubscriptionErrorHandler(error);
|
||||
const wasHandled = result instanceof Promise ? await result : result;
|
||||
if (wasHandled) {
|
||||
console.log('Long-running API Client: Subscription error handled by global handler');
|
||||
return Promise.reject(error);
|
||||
@@ -342,7 +407,7 @@ pollingApiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
async (error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
// Only redirect on 401 if we're not in onboarding flow or root route
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
@@ -357,18 +422,11 @@ pollingApiClient.interceptors.response.use(
|
||||
}
|
||||
// Check if it's a subscription-related error and handle it globally
|
||||
if (error.response?.status === 429 || error.response?.status === 402) {
|
||||
console.log('Polling API Client: Detected subscription error, triggering global handler', {
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
hasHandler: !!globalSubscriptionErrorHandler
|
||||
});
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
const wasHandled = globalSubscriptionErrorHandler(error);
|
||||
console.log('Polling API Client: Global handler returned', wasHandled);
|
||||
if (wasHandled) {
|
||||
console.log('Polling API Client: Subscription error handled by global handler - modal should be showing');
|
||||
} else {
|
||||
console.warn('Polling API Client: Global handler did not handle subscription error');
|
||||
const result = globalSubscriptionErrorHandler(error);
|
||||
const wasHandled = result instanceof Promise ? await result : result;
|
||||
if (!wasHandled) {
|
||||
console.warn('Polling API Client: Subscription error not handled by global handler');
|
||||
}
|
||||
// Always reject so the polling hook can also handle it
|
||||
return Promise.reject(error);
|
||||
|
||||
181
frontend/src/api/oauthTokenMonitoring.ts
Normal file
181
frontend/src/api/oauthTokenMonitoring.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* OAuth Token Monitoring API Client
|
||||
* Functions for interacting with OAuth token monitoring endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface OAuthTokenStatus {
|
||||
connected: boolean;
|
||||
monitoring_task: {
|
||||
id: number | null;
|
||||
status: string;
|
||||
last_check: string | null;
|
||||
last_success: string | null;
|
||||
last_failure: string | null;
|
||||
failure_reason: string | null;
|
||||
next_check: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface PlatformStatus {
|
||||
[platform: string]: OAuthTokenStatus;
|
||||
}
|
||||
|
||||
export interface OAuthTokenStatusResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
user_id: string;
|
||||
platform_status: PlatformStatus;
|
||||
connected_platforms: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ManualRefreshResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
platform: string;
|
||||
status: string;
|
||||
last_check: string | null;
|
||||
last_success: string | null;
|
||||
last_failure: string | null;
|
||||
failure_reason: string | null;
|
||||
next_check: string | null;
|
||||
execution_result: {
|
||||
success: boolean;
|
||||
error_message: string | null;
|
||||
execution_time_ms: number | null;
|
||||
result_data: any;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExecutionLog {
|
||||
id: number;
|
||||
task_id: number;
|
||||
platform: string;
|
||||
execution_date: string;
|
||||
status: string;
|
||||
result_data: any;
|
||||
error_message: string | null;
|
||||
execution_time_ms: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ExecutionLogsResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
logs: ExecutionLog[];
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateTasksResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data: {
|
||||
tasks_created: number;
|
||||
tasks: Array<{
|
||||
id: number;
|
||||
platform: string;
|
||||
status: string;
|
||||
next_check: string | null;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth token monitoring status for all platforms
|
||||
*/
|
||||
export const getOAuthTokenStatus = async (userId: string): Promise<OAuthTokenStatusResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get<OAuthTokenStatusResponse>(`/api/oauth-tokens/status/${userId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching OAuth token status:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch OAuth token status'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Manually trigger token refresh for a specific platform
|
||||
*/
|
||||
export const manualRefreshToken = async (
|
||||
userId: string,
|
||||
platform: string
|
||||
): Promise<ManualRefreshResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post<ManualRefreshResponse>(
|
||||
`/api/oauth-tokens/refresh/${userId}/${platform}`
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error manually refreshing token:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to refresh token'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get execution logs for OAuth token monitoring
|
||||
*/
|
||||
export const getOAuthTokenExecutionLogs = async (
|
||||
userId: string,
|
||||
platform?: string,
|
||||
limit: number = 50,
|
||||
offset: number = 0
|
||||
): Promise<ExecutionLogsResponse> => {
|
||||
try {
|
||||
const params: any = { limit, offset };
|
||||
if (platform) {
|
||||
params.platform = platform;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<ExecutionLogsResponse>(
|
||||
`/api/oauth-tokens/execution-logs/${userId}`,
|
||||
{ params }
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching execution logs:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch execution logs'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create OAuth token monitoring tasks
|
||||
*/
|
||||
export const createOAuthMonitoringTasks = async (
|
||||
userId: string,
|
||||
platforms?: string[]
|
||||
): Promise<CreateTasksResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post<CreateTasksResponse>(
|
||||
`/api/oauth-tokens/create-tasks/${userId}`,
|
||||
platforms ? { platforms } : {}
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error creating monitoring tasks:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to create monitoring tasks'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -216,6 +216,42 @@ export const generatePlatformPersona = async (platform: string): Promise<any> =>
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if Facebook persona exists for user
|
||||
* Note: user_id is extracted from Clerk JWT token or passed as parameter
|
||||
*/
|
||||
export const checkFacebookPersona = async (userId?: string): Promise<{
|
||||
has_persona: boolean;
|
||||
has_core_persona: boolean;
|
||||
persona: any;
|
||||
onboarding_completed: boolean;
|
||||
}> => {
|
||||
try {
|
||||
// Get user_id from parameter or localStorage
|
||||
const user_id = userId || localStorage.getItem('user_id');
|
||||
if (!user_id) {
|
||||
return {
|
||||
has_persona: false,
|
||||
has_core_persona: false,
|
||||
persona: null,
|
||||
onboarding_completed: false
|
||||
};
|
||||
}
|
||||
|
||||
const response = await apiClient.get(`/api/personas/facebook-persona/check/${user_id}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error checking Facebook persona:', error);
|
||||
// Return safe defaults on error
|
||||
return {
|
||||
has_persona: false,
|
||||
has_core_persona: false,
|
||||
persona: null,
|
||||
onboarding_completed: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a persona
|
||||
*/
|
||||
|
||||
157
frontend/src/api/researchConfig.ts
Normal file
157
frontend/src/api/researchConfig.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Research Configuration API
|
||||
* Fetches provider availability and persona-aware defaults
|
||||
*/
|
||||
|
||||
import { ResearchMode, ResearchProvider } from '../services/blogWriterApi';
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface ProviderAvailability {
|
||||
google_available: boolean;
|
||||
exa_available: boolean;
|
||||
gemini_key_status: 'configured' | 'missing';
|
||||
exa_key_status: 'configured' | 'missing';
|
||||
}
|
||||
|
||||
export interface PersonaDefaults {
|
||||
industry?: string;
|
||||
target_audience?: string;
|
||||
suggested_domains: string[];
|
||||
suggested_exa_category?: string;
|
||||
}
|
||||
|
||||
export interface ResearchPreset {
|
||||
name: string;
|
||||
keywords: string;
|
||||
industry: string;
|
||||
target_audience: string;
|
||||
research_mode: ResearchMode;
|
||||
config: any; // ResearchConfig
|
||||
description?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ResearchPersona {
|
||||
default_industry: string;
|
||||
default_target_audience: string;
|
||||
default_research_mode: ResearchMode;
|
||||
default_provider: ResearchProvider;
|
||||
suggested_keywords: string[];
|
||||
keyword_expansion_patterns: Record<string, string[]>;
|
||||
suggested_exa_domains: string[];
|
||||
suggested_exa_category?: string;
|
||||
research_angles: string[];
|
||||
query_enhancement_rules: Record<string, string>;
|
||||
recommended_presets: ResearchPreset[];
|
||||
research_preferences: Record<string, any>;
|
||||
generated_at?: string;
|
||||
confidence_score?: number;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export interface ResearchConfigResponse {
|
||||
provider_availability: ProviderAvailability;
|
||||
persona_defaults: PersonaDefaults;
|
||||
research_persona?: ResearchPersona;
|
||||
onboarding_completed?: boolean;
|
||||
persona_scheduled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider availability status
|
||||
*/
|
||||
export const getProviderAvailability = async (): Promise<ProviderAvailability> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/research/provider-availability');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('[researchConfig] Error getting provider availability:', error);
|
||||
throw new Error(`Failed to get provider availability: ${error?.response?.statusText || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get persona-aware research defaults
|
||||
*/
|
||||
export const getPersonaDefaults = async (): Promise<PersonaDefaults> => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/research/persona-defaults');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('[researchConfig] Error getting persona defaults:', error);
|
||||
throw new Error(`Failed to get persona defaults: ${error?.response?.statusText || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Request deduplication: cache in-flight requests to prevent duplicate API calls
|
||||
let pendingConfigRequest: Promise<ResearchConfigResponse> | null = null;
|
||||
|
||||
/**
|
||||
* Get complete research configuration
|
||||
*
|
||||
* Uses request deduplication: if multiple components call this simultaneously,
|
||||
* they will share the same promise to prevent duplicate API calls.
|
||||
*/
|
||||
export const getResearchConfig = async (): Promise<ResearchConfigResponse> => {
|
||||
// If a request is already in flight, return the same promise
|
||||
if (pendingConfigRequest) {
|
||||
console.log('[researchConfig] Reusing pending request to avoid duplicate API call');
|
||||
return pendingConfigRequest;
|
||||
}
|
||||
|
||||
// Create new request and cache it
|
||||
pendingConfigRequest = (async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/research/config');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const statusCode = error?.response?.status;
|
||||
const errorMessage = error?.response?.data?.detail || error?.message || 'Unknown error';
|
||||
|
||||
console.error('[researchConfig] Error getting research config:', {
|
||||
status: statusCode,
|
||||
message: errorMessage,
|
||||
fullError: error
|
||||
});
|
||||
|
||||
// Provide more specific error messages based on status code
|
||||
if (statusCode === 500) {
|
||||
throw new Error(`Backend server error: ${errorMessage}. Please check backend logs or try again later.`);
|
||||
} else if (statusCode === 401) {
|
||||
throw new Error('Authentication required. Please sign in again.');
|
||||
} else if (statusCode === 403) {
|
||||
throw new Error('Access denied. Please check your permissions.');
|
||||
} else if (statusCode === 429) {
|
||||
throw new Error('Rate limit exceeded. Please try again later.');
|
||||
} else if (!statusCode && error?.message) {
|
||||
// Network error or other connection issue
|
||||
throw new Error(`Failed to connect to server: ${error.message}`);
|
||||
} else {
|
||||
throw new Error(`Failed to get research config: ${errorMessage}`);
|
||||
}
|
||||
} finally {
|
||||
// Clear the cached request after completion (success or error)
|
||||
pendingConfigRequest = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return pendingConfigRequest;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get or refresh research persona
|
||||
* @param forceRefresh - If true, regenerate persona even if cache is valid
|
||||
*/
|
||||
export const refreshResearchPersona = async (forceRefresh: boolean = false): Promise<ResearchPersona> => {
|
||||
try {
|
||||
const url = `/api/research/research-persona${forceRefresh ? '?force_refresh=true' : ''}`;
|
||||
const response = await apiClient.get(url);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('[researchConfig] Error refreshing research persona:', error?.response?.status || error?.message);
|
||||
// Preserve the original error so subscription errors can be detected
|
||||
// The apiClient interceptor should handle 429 errors, but we preserve the error structure
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
249
frontend/src/api/schedulerDashboard.ts
Normal file
249
frontend/src/api/schedulerDashboard.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Scheduler Dashboard API Client
|
||||
* Provides typed functions for fetching scheduler dashboard data.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
// TypeScript interfaces for scheduler dashboard data
|
||||
export interface SchedulerStats {
|
||||
total_checks: number;
|
||||
tasks_found: number;
|
||||
tasks_executed: number;
|
||||
tasks_failed: number;
|
||||
tasks_skipped: number;
|
||||
last_check: string | null;
|
||||
last_update: string | null;
|
||||
active_executions: number;
|
||||
running: boolean;
|
||||
check_interval_minutes: number;
|
||||
min_check_interval_minutes: number;
|
||||
max_check_interval_minutes: number;
|
||||
intelligent_scheduling: boolean;
|
||||
active_strategies_count: number;
|
||||
last_interval_adjustment: string | null;
|
||||
registered_types: string[];
|
||||
// Cumulative/historical values from database
|
||||
cumulative_total_check_cycles: number;
|
||||
cumulative_tasks_found: number;
|
||||
cumulative_tasks_executed: number;
|
||||
cumulative_tasks_failed: number;
|
||||
}
|
||||
|
||||
export interface SchedulerJob {
|
||||
id: string;
|
||||
trigger_type: string;
|
||||
next_run_time: string | null;
|
||||
user_id: string | null;
|
||||
job_store: string;
|
||||
user_job_store: string;
|
||||
function_name?: string | null;
|
||||
platform?: string; // For OAuth token monitoring tasks
|
||||
task_id?: number; // For OAuth token monitoring tasks
|
||||
is_database_task?: boolean; // Flag to indicate DB task vs APScheduler job
|
||||
frequency?: string; // For OAuth tasks (e.g., 'Weekly')
|
||||
}
|
||||
|
||||
export interface UserIsolation {
|
||||
enabled: boolean;
|
||||
current_user_id: string | null;
|
||||
}
|
||||
|
||||
export interface SchedulerDashboardData {
|
||||
stats: SchedulerStats;
|
||||
jobs: SchedulerJob[];
|
||||
job_count: number;
|
||||
recurring_jobs: number;
|
||||
one_time_jobs: number;
|
||||
user_isolation: UserIsolation;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
export interface TaskInfo {
|
||||
id: number;
|
||||
task_title: string;
|
||||
component_name: string;
|
||||
metric: string;
|
||||
frequency: string;
|
||||
}
|
||||
|
||||
export interface ExecutionLog {
|
||||
id: number;
|
||||
task_id: number | null;
|
||||
user_id: number | string | null;
|
||||
execution_date: string;
|
||||
status: 'success' | 'failed' | 'running' | 'skipped';
|
||||
error_message: string | null;
|
||||
execution_time_ms: number | null;
|
||||
result_data: any;
|
||||
created_at: string;
|
||||
task?: TaskInfo;
|
||||
is_scheduler_log?: boolean; // Flag for scheduler logs vs execution logs
|
||||
event_type?: string;
|
||||
job_id?: string | null;
|
||||
}
|
||||
|
||||
export interface ExecutionLogsResponse {
|
||||
logs: ExecutionLog[];
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
is_scheduler_logs?: boolean; // Flag to indicate if these are scheduler logs
|
||||
}
|
||||
|
||||
export interface SchedulerJobsResponse {
|
||||
jobs: SchedulerJob[];
|
||||
total_jobs: number;
|
||||
recurring_jobs: number;
|
||||
one_time_jobs: number;
|
||||
}
|
||||
|
||||
export interface SchedulerEvent {
|
||||
id: number;
|
||||
event_type: 'check_cycle' | 'interval_adjustment' | 'start' | 'stop' | 'job_scheduled' | 'job_cancelled' | 'job_completed' | 'job_failed';
|
||||
event_date: string | null;
|
||||
check_cycle_number: number | null;
|
||||
check_interval_minutes: number | null;
|
||||
previous_interval_minutes: number | null;
|
||||
new_interval_minutes: number | null;
|
||||
tasks_found: number | null;
|
||||
tasks_executed: number | null;
|
||||
tasks_failed: number | null;
|
||||
tasks_by_type: Record<string, number> | null;
|
||||
check_duration_seconds: number | null;
|
||||
active_strategies_count: number | null;
|
||||
active_executions: number | null;
|
||||
job_id: string | null;
|
||||
job_type: string | null;
|
||||
user_id: string | null;
|
||||
event_data: any;
|
||||
error_message: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface SchedulerEventHistoryResponse {
|
||||
events: SchedulerEvent[];
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler dashboard statistics and current state.
|
||||
*/
|
||||
export const getSchedulerDashboard = async (): Promise<SchedulerDashboardData> => {
|
||||
try {
|
||||
const response = await apiClient.get<SchedulerDashboardData>('/api/scheduler/dashboard');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching scheduler dashboard:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch scheduler dashboard'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get task execution logs from database.
|
||||
*
|
||||
* @param limit - Number of logs to return (1-500, default: 50)
|
||||
* @param offset - Pagination offset (default: 0)
|
||||
* @param status - Filter by status (success, failed, running, skipped)
|
||||
*/
|
||||
export const getExecutionLogs = async (
|
||||
limit: number = 50,
|
||||
offset: number = 0,
|
||||
status?: 'success' | 'failed' | 'running' | 'skipped'
|
||||
): Promise<ExecutionLogsResponse> => {
|
||||
try {
|
||||
const params: any = { limit, offset };
|
||||
if (status) {
|
||||
params.status = status;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<ExecutionLogsResponse>('/api/scheduler/execution-logs', {
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching execution logs:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch execution logs'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get detailed information about all scheduled jobs.
|
||||
*/
|
||||
export const getSchedulerJobs = async (): Promise<SchedulerJobsResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get<SchedulerJobsResponse>('/api/scheduler/jobs');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching scheduler jobs:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch scheduler jobs'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get scheduler event history from database.
|
||||
*
|
||||
* @param limit - Number of events to return (1-1000, default: 100)
|
||||
* @param offset - Pagination offset (default: 0)
|
||||
* @param eventType - Filter by event type (check_cycle, interval_adjustment, start, stop, etc.)
|
||||
*/
|
||||
export const getSchedulerEventHistory = async (
|
||||
limit: number = 100,
|
||||
offset: number = 0,
|
||||
eventType?: 'check_cycle' | 'interval_adjustment' | 'start' | 'stop' | 'job_scheduled' | 'job_cancelled' | 'job_completed' | 'job_failed'
|
||||
): Promise<SchedulerEventHistoryResponse> => {
|
||||
try {
|
||||
const params: any = { limit, offset };
|
||||
if (eventType) {
|
||||
params.event_type = eventType;
|
||||
}
|
||||
|
||||
const response = await apiClient.get<SchedulerEventHistoryResponse>('/api/scheduler/event-history', {
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching scheduler event history:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch scheduler event history'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get recent scheduler logs (restoration, job scheduling, etc.) formatted as execution logs.
|
||||
* These are shown in Execution Logs section when actual execution logs are not available.
|
||||
* Returns only the latest 5 logs (rolling window).
|
||||
*/
|
||||
export const getRecentSchedulerLogs = async (): Promise<ExecutionLogsResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get<ExecutionLogsResponse>('/api/scheduler/recent-scheduler-logs');
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching recent scheduler logs:', error);
|
||||
throw new Error(
|
||||
error.response?.data?.detail ||
|
||||
error.message ||
|
||||
'Failed to fetch recent scheduler logs'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -51,7 +51,8 @@ export interface StyleDetectionResponse {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
// Consistent API URL pattern - no hardcoded localhost fallback
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL || '';
|
||||
|
||||
/**
|
||||
* Analyze content style using AI
|
||||
|
||||
@@ -66,7 +66,7 @@ export interface WordPressHealthResponse {
|
||||
}
|
||||
|
||||
class WordPressAPI {
|
||||
private baseUrl = '/wordpress';
|
||||
private baseUrl = '/api/wordpress';
|
||||
private getAuthToken: (() => Promise<string | null>) | null = null;
|
||||
|
||||
/**
|
||||
@@ -102,7 +102,17 @@ class WordPressAPI {
|
||||
const client = await this.getAuthenticatedClient();
|
||||
const response = await client.get(`${this.baseUrl}/status`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
// Handle 404 gracefully - endpoint may not exist yet
|
||||
if (error?.response?.status === 404) {
|
||||
// Return empty status instead of throwing
|
||||
return {
|
||||
connected: false,
|
||||
sites: [],
|
||||
total_sites: 0
|
||||
};
|
||||
}
|
||||
// Only log non-404 errors
|
||||
console.error('WordPress API: Error getting status:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,16 @@ import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSec
|
||||
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
|
||||
|
||||
export const BlogWriter: React.FC = () => {
|
||||
// Add light theme class to body/html on mount, remove on unmount
|
||||
React.useEffect(() => {
|
||||
document.body.classList.add('blog-writer-page');
|
||||
document.documentElement.classList.add('blog-writer-page');
|
||||
return () => {
|
||||
document.body.classList.remove('blog-writer-page');
|
||||
document.documentElement.classList.remove('blog-writer-page');
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check CopilotKit health status
|
||||
const { isAvailable: copilotKitAvailable } = useCopilotKitHealth({
|
||||
enabled: true, // Enable health checking
|
||||
@@ -313,6 +323,7 @@ export const BlogWriter: React.FC = () => {
|
||||
sections,
|
||||
research,
|
||||
openSEOMetadata: () => setIsSEOMetadataModalOpen(true),
|
||||
navigateToPhase,
|
||||
});
|
||||
|
||||
|
||||
@@ -320,7 +331,14 @@ export const BlogWriter: React.FC = () => {
|
||||
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#1a1a1a',
|
||||
overflow: 'auto'
|
||||
}} className="blog-writer-container">
|
||||
{/* CopilotKit-dependent components - extracted to CopilotKitComponents */}
|
||||
{copilotKitAvailable && (
|
||||
<CopilotKitComponents
|
||||
@@ -349,6 +367,7 @@ export const BlogWriter: React.FC = () => {
|
||||
setFlowAnalysisResults={setFlowAnalysisResults}
|
||||
setContinuityRefresh={setContinuityRefresh}
|
||||
researchPolling={researchPolling}
|
||||
navigateToPhase={navigateToPhase}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -359,6 +378,14 @@ export const BlogWriter: React.FC = () => {
|
||||
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
|
||||
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
|
||||
onModalShow={() => setShowOutlineModal(true)}
|
||||
navigateToPhase={navigateToPhase}
|
||||
onOutlineCreated={(outline, titleOptions) => {
|
||||
// Handle cached outline from CopilotKit action (same as header button)
|
||||
setOutline(outline);
|
||||
if (titleOptions) {
|
||||
setTitleOptions(titleOptions);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<OutlineRefiner
|
||||
outline={outline}
|
||||
@@ -381,31 +408,29 @@ export const BlogWriter: React.FC = () => {
|
||||
seoMetadata={seoMetadata}
|
||||
/>
|
||||
|
||||
{/* Always show HeaderBar when CopilotKit is unavailable, or when research exists */}
|
||||
{(!copilotKitAvailable || research) && (
|
||||
<HeaderBar
|
||||
phases={phases}
|
||||
currentPhase={currentPhase}
|
||||
onPhaseClick={handlePhaseClick}
|
||||
copilotKitAvailable={copilotKitAvailable}
|
||||
actionHandlers={{
|
||||
onResearchAction: handleResearchAction,
|
||||
onOutlineAction: handleOutlineAction,
|
||||
onContentAction: handleContentAction,
|
||||
onSEOAction: handleSEOAction,
|
||||
onApplySEORecommendations: handleApplySEORecommendations,
|
||||
onPublishAction: handlePublishAction,
|
||||
}}
|
||||
hasResearch={!!research}
|
||||
hasOutline={outline.length > 0}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
hasContent={Object.keys(sections).length > 0}
|
||||
contentConfirmed={contentConfirmed}
|
||||
hasSEOAnalysis={!!seoAnalysis}
|
||||
seoRecommendationsApplied={seoRecommendationsApplied}
|
||||
hasSEOMetadata={!!seoMetadata}
|
||||
/>
|
||||
)}
|
||||
{/* Phase navigation header - always visible as default interface */}
|
||||
<HeaderBar
|
||||
phases={phases}
|
||||
currentPhase={currentPhase}
|
||||
onPhaseClick={handlePhaseClick}
|
||||
copilotKitAvailable={copilotKitAvailable}
|
||||
actionHandlers={{
|
||||
onResearchAction: handleResearchAction,
|
||||
onOutlineAction: handleOutlineAction,
|
||||
onContentAction: handleContentAction,
|
||||
onSEOAction: handleSEOAction,
|
||||
onApplySEORecommendations: handleApplySEORecommendations,
|
||||
onPublishAction: handlePublishAction,
|
||||
}}
|
||||
hasResearch={!!research}
|
||||
hasOutline={outline.length > 0}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
hasContent={Object.keys(sections).length > 0}
|
||||
contentConfirmed={contentConfirmed}
|
||||
hasSEOAnalysis={!!seoAnalysis}
|
||||
seoRecommendationsApplied={seoRecommendationsApplied}
|
||||
hasSEOMetadata={!!seoMetadata}
|
||||
/>
|
||||
|
||||
{/* Landing section - extracted to BlogWriterLandingSection */}
|
||||
<BlogWriterLandingSection
|
||||
|
||||
@@ -70,21 +70,12 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
|
||||
backgroundSize: '56% auto',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: '#ffffff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Animated overlay for subtle movement */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(156, 39, 176, 0.05) 100%)'
|
||||
}} />
|
||||
|
||||
{/* Main content container */}
|
||||
<div style={{
|
||||
@@ -109,7 +100,7 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
|
||||
textShadow: '0 4px 8px rgba(0,0,0,0.1)',
|
||||
lineHeight: '1.2'
|
||||
}}>
|
||||
Step1- Research Your Blog Topic
|
||||
AI-First, Contextual, Click through Blog Writer
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,27 +17,24 @@ export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> =
|
||||
navigateToPhase,
|
||||
onResearchComplete,
|
||||
}) => {
|
||||
// Only show landing/initial content when no research exists
|
||||
// Phase navigation header is always visible, so this is just the initial content
|
||||
if (!research) {
|
||||
return (
|
||||
<>
|
||||
{/* Show manual research form when on research phase and CopilotKit unavailable */}
|
||||
{!copilotKitAvailable && currentPhase === 'research' && (
|
||||
<ManualResearchForm onResearchComplete={onResearchComplete} />
|
||||
)}
|
||||
{copilotKitAvailable && (
|
||||
{/* Show landing page for CopilotKit flow or when not on research phase */}
|
||||
{(!copilotKitAvailable && currentPhase !== 'research') || copilotKitAvailable ? (
|
||||
<BlogWriterLanding
|
||||
onStartWriting={() => {
|
||||
// Trigger the copilot to start the research process
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!copilotKitAvailable && currentPhase !== 'research' && (
|
||||
<BlogWriterLanding
|
||||
onStartWriting={() => {
|
||||
// Navigate to research phase when CopilotKit unavailable
|
||||
// Navigate to research phase to start the workflow
|
||||
navigateToPhase('research');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ interface CopilotKitComponentsProps {
|
||||
setFlowAnalysisResults: (results: any) => void;
|
||||
setContinuityRefresh: (refresh: number | ((prev: number) => number)) => void;
|
||||
researchPolling: any;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
|
||||
@@ -49,6 +50,7 @@ export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
|
||||
setFlowAnalysisResults,
|
||||
setContinuityRefresh,
|
||||
researchPolling,
|
||||
navigateToPhase,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@@ -57,12 +59,13 @@ export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
|
||||
onTaskStart={(taskId) => researchPolling.startPolling(taskId)}
|
||||
/>
|
||||
<CustomOutlineForm onOutlineCreated={onOutlineCreated} />
|
||||
<ResearchAction onResearchComplete={onResearchComplete} />
|
||||
<ResearchAction onResearchComplete={onResearchComplete} navigateToPhase={navigateToPhase} />
|
||||
|
||||
<ResearchDataActions
|
||||
research={research}
|
||||
onOutlineCreated={onOutlineCreated}
|
||||
onTitleOptionsSet={onTitleOptionsSet}
|
||||
onTitleOptionsSet={onTitleOptionsSet}
|
||||
navigateToPhase={navigateToPhase}
|
||||
/>
|
||||
<EnhancedOutlineActions
|
||||
outline={outline}
|
||||
@@ -77,6 +80,7 @@ export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
|
||||
onMediumGenerationTriggered={onMediumGenerationTriggered}
|
||||
sections={sections}
|
||||
blogTitle={selectedTitle ?? undefined}
|
||||
navigateToPhase={navigateToPhase}
|
||||
onFlowAnalysisComplete={(analysis) => {
|
||||
console.log('Flow analysis completed:', analysis);
|
||||
setFlowAnalysisCompleted(true);
|
||||
|
||||
@@ -16,14 +16,242 @@ export const WriterCopilotSidebar: React.FC<WriterCopilotSidebarProps> = ({
|
||||
outlineConfirmed,
|
||||
}) => {
|
||||
return (
|
||||
<CopilotSidebar
|
||||
labels={{
|
||||
title: 'ALwrity Co-Pilot',
|
||||
initial: !research
|
||||
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
|
||||
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.',
|
||||
}}
|
||||
suggestions={suggestions}
|
||||
<>
|
||||
<style>{`
|
||||
/* Enterprise CopilotKit Suggestion Styling */
|
||||
|
||||
/* All suggestion chips - base styling */
|
||||
.copilotkit-suggestions button,
|
||||
.copilot-suggestions button,
|
||||
[class*="suggestion"] button,
|
||||
[class*="Suggestion"] button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 250, 251, 0.98) 100%);
|
||||
color: #4b5563;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(255, 255, 255, 0.5) inset;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Shine effect on hover */
|
||||
.copilotkit-suggestions button::before,
|
||||
.copilot-suggestions button::before,
|
||||
[class*="suggestion"] button::before,
|
||||
[class*="Suggestion"] button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
transition: left 0.6s ease;
|
||||
}
|
||||
|
||||
.copilotkit-suggestions button:hover::before,
|
||||
.copilot-suggestions button:hover::before,
|
||||
[class*="suggestion"] button:hover::before,
|
||||
[class*="Suggestion"] button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* Regular suggestions - hover effects */
|
||||
.copilotkit-suggestions button:hover,
|
||||
.copilot-suggestions button:hover,
|
||||
[class*="suggestion"] button:hover:not([class*="next-suggestion"]),
|
||||
[class*="Suggestion"] button:hover:not([class*="next-suggestion"]) {
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.6) inset;
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 1) 0%, rgba(249, 250, 251, 1) 100%);
|
||||
}
|
||||
|
||||
/* "Next:" Suggestions - Premium Enterprise Style */
|
||||
.copilotkit-suggestions button[data-is-next="true"],
|
||||
.copilot-suggestions button[data-is-next="true"],
|
||||
.copilotkit-suggestions button.next-suggestion,
|
||||
.copilot-suggestions button.next-suggestion,
|
||||
.copilotkit-suggestions button[aria-label*="Next:"],
|
||||
.copilot-suggestions button[aria-label*="Next:"] {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%) !important;
|
||||
color: white !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3) !important;
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
|
||||
0 2px 4px rgba(0, 0, 0, 0.1) inset,
|
||||
0 0 20px rgba(102, 126, 234, 0.3) !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
position: relative;
|
||||
animation: nextSuggestionPulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Pulse animation for Next suggestions */
|
||||
@keyframes nextSuggestionPulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
|
||||
0 2px 4px rgba(0, 0, 0, 0.1) inset,
|
||||
0 0 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.25) inset,
|
||||
0 2px 4px rgba(0, 0, 0, 0.1) inset,
|
||||
0 0 30px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Next suggestion hover - enhanced */
|
||||
.copilotkit-suggestions button[data-is-next="true"]:hover,
|
||||
.copilot-suggestions button[data-is-next="true"]:hover,
|
||||
.copilotkit-suggestions button.next-suggestion:hover,
|
||||
.copilot-suggestions button.next-suggestion:hover,
|
||||
.copilotkit-suggestions button[aria-label*="Next:"]:hover,
|
||||
.copilot-suggestions button[aria-label*="Next:"]:hover {
|
||||
transform: translateY(-3px) scale(1.05) !important;
|
||||
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.6),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
||||
0 3px 6px rgba(0, 0, 0, 0.15) inset,
|
||||
0 0 40px rgba(102, 126, 234, 0.6) !important;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 40%, #f093fb 60%, #4facfe 100%) !important;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Next suggestion active */
|
||||
.copilotkit-suggestions button[data-is-next="true"]:active,
|
||||
.copilot-suggestions button[data-is-next="true"]:active,
|
||||
.copilotkit-suggestions button.next-suggestion:active,
|
||||
.copilot-suggestions button.next-suggestion:active,
|
||||
.copilotkit-suggestions button[aria-label*="Next:"]:active,
|
||||
.copilot-suggestions button[aria-label*="Next:"]:active {
|
||||
transform: translateY(-1px) scale(1.02) !important;
|
||||
box-shadow: 0 3px 12px rgba(102, 126, 234, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.25) inset,
|
||||
0 1px 3px rgba(0, 0, 0, 0.1) inset !important;
|
||||
}
|
||||
|
||||
/* Next suggestion focus */
|
||||
.copilotkit-suggestions button[data-is-next="true"]:focus-visible,
|
||||
.copilot-suggestions button[data-is-next="true"]:focus-visible,
|
||||
.copilotkit-suggestions button.next-suggestion:focus-visible,
|
||||
.copilot-suggestions button.next-suggestion:focus-visible,
|
||||
.copilotkit-suggestions button[aria-label*="Next:"]:focus-visible,
|
||||
.copilot-suggestions button[aria-label*="Next:"]:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.4),
|
||||
0 4px 16px rgba(102, 126, 234, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
|
||||
0 0 30px rgba(102, 126, 234, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Match buttons by text content using data attributes or class */
|
||||
/* We'll inject a data attribute via JS to identify Next suggestions */
|
||||
|
||||
/* Regular suggestion active state */
|
||||
.copilotkit-suggestions button:active:not([data-is-next="true"]):not(.next-suggestion),
|
||||
.copilot-suggestions button:active:not([data-is-next="true"]):not(.next-suggestion) {
|
||||
transform: translateY(0) scale(0.98);
|
||||
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
/* Focus states for regular suggestions */
|
||||
.copilotkit-suggestions button:focus-visible:not([data-is-next="true"]):not(.next-suggestion),
|
||||
.copilot-suggestions button:focus-visible:not([data-is-next="true"]):not(.next-suggestion) {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3), 0 4px 12px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
/* Enhanced suggestion container */
|
||||
.copilotkit-suggestions,
|
||||
.copilot-suggestions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4) 0%, rgba(249, 250, 251, 0.6) 100%);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
@media (min-width: 420px) {
|
||||
.copilotkit-suggestions,
|
||||
.copilot-suggestions {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Inject data attributes to identify Next suggestions */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
const observer = new MutationObserver(() => {
|
||||
const suggestionButtons = document.querySelectorAll(
|
||||
'.copilotkit-suggestions button, .copilot-suggestions button, [class*="suggestion"] button'
|
||||
);
|
||||
suggestionButtons.forEach(btn => {
|
||||
const text = btn.textContent || btn.innerText || '';
|
||||
if (text.includes('Next:')) {
|
||||
btn.setAttribute('data-is-next', 'true');
|
||||
btn.classList.add('next-suggestion');
|
||||
} else {
|
||||
btn.removeAttribute('data-is-next');
|
||||
btn.classList.remove('next-suggestion');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Initial run
|
||||
setTimeout(() => {
|
||||
const suggestionButtons = document.querySelectorAll(
|
||||
'.copilotkit-suggestions button, .copilot-suggestions button, [class*="suggestion"] button'
|
||||
);
|
||||
suggestionButtons.forEach(btn => {
|
||||
const text = btn.textContent || btn.innerText || '';
|
||||
if (text.includes('Next:')) {
|
||||
btn.setAttribute('data-is-next', 'true');
|
||||
btn.classList.add('next-suggestion');
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
})();
|
||||
`
|
||||
}}
|
||||
/>
|
||||
|
||||
<CopilotSidebar
|
||||
labels={{
|
||||
title: 'ALwrity Co-Pilot',
|
||||
initial: !research
|
||||
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
|
||||
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.',
|
||||
}}
|
||||
suggestions={suggestions}
|
||||
makeSystemMessage={(context: string, additional?: string) => {
|
||||
const hasResearch = research !== null && research !== undefined;
|
||||
const hasOutline = outline && outline.length > 0;
|
||||
@@ -132,6 +360,7 @@ Available tools:
|
||||
return [toolGuide, additional].filter(Boolean).join('\n\n');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ interface UseBlogWriterCopilotActionsParams {
|
||||
sections: Record<string, string>;
|
||||
research: any;
|
||||
openSEOMetadata: OpenMetadataCb;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
// Consolidates all Copilot actions used by BlogWriter
|
||||
@@ -25,6 +26,7 @@ export function useBlogWriterCopilotActions({
|
||||
sections,
|
||||
research,
|
||||
openSEOMetadata,
|
||||
navigateToPhase,
|
||||
}: UseBlogWriterCopilotActionsParams) {
|
||||
// Maintain the same any-cast pattern for parity with component
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
@@ -35,6 +37,8 @@ export function useBlogWriterCopilotActions({
|
||||
description: 'Confirm that the blog content is ready and move to the next stage (SEO analysis)',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
// Navigate to SEO phase when content is confirmed
|
||||
navigateToPhase?.('seo');
|
||||
const msg = await confirmBlogContent();
|
||||
return msg;
|
||||
},
|
||||
@@ -46,6 +50,9 @@ export function useBlogWriterCopilotActions({
|
||||
description: 'Analyze the blog content for SEO optimization and provide detailed recommendations',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
// Navigate to SEO phase when SEO analysis starts
|
||||
navigateToPhase?.('seo');
|
||||
|
||||
debug.log('[BlogWriter] SEO analysis action', {
|
||||
modalOpen: isSEOAnalysisModalOpen,
|
||||
hasSections: !!sections && Object.keys(sections).length > 0,
|
||||
@@ -73,6 +80,9 @@ export function useBlogWriterCopilotActions({
|
||||
},
|
||||
],
|
||||
handler: async ({ title }: { title?: string }) => {
|
||||
// Navigate to SEO phase when SEO metadata generation starts
|
||||
navigateToPhase?.('seo');
|
||||
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
return 'Please generate blog content first before creating SEO metadata. Use the content generation features to create your blog post.';
|
||||
}
|
||||
|
||||
@@ -13,27 +13,15 @@ interface KeywordInputFormProps {
|
||||
}
|
||||
|
||||
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete, onTaskStart }) => {
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
|
||||
// This component now only provides polling functionality
|
||||
// The keyword input form is handled by ResearchAction component
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Polling handler for research progress */}
|
||||
<ResearchPollingHandler
|
||||
taskId={currentTaskId}
|
||||
onResearchComplete={(result) => {
|
||||
onResearchComplete?.(result);
|
||||
setCurrentTaskId(null);
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.error('Research error:', error);
|
||||
setCurrentTaskId(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
// This component is now a lightweight wrapper
|
||||
// The actual keyword input form is handled by ResearchAction component
|
||||
// Polling is handled by ResearchPollingHandler in ResearchAction
|
||||
// This component exists for backward compatibility but doesn't create unnecessary polling hooks
|
||||
|
||||
// Note: If onTaskStart is called, it should use the researchPolling from parent
|
||||
// (passed via CopilotKitComponents), not create a new polling instance here
|
||||
|
||||
return null; // No UI needed - ResearchAction handles everything
|
||||
};
|
||||
|
||||
export default KeywordInputForm;
|
||||
@@ -50,6 +50,7 @@ interface OutlineFeedbackFormProps {
|
||||
sections?: Record<string, string>;
|
||||
blogTitle?: string;
|
||||
onFlowAnalysisComplete?: (analysis: any) => void;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -225,7 +226,8 @@ const FeedbackForm: React.FC<{
|
||||
|
||||
export const OutlineFeedbackForm: React.FC<OutlineFeedbackFormProps> = ({
|
||||
outline,
|
||||
research,
|
||||
research,
|
||||
navigateToPhase,
|
||||
onOutlineConfirmed,
|
||||
onOutlineRefined,
|
||||
onMediumGenerationStarted,
|
||||
@@ -352,6 +354,9 @@ export const OutlineFeedbackForm: React.FC<OutlineFeedbackFormProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
// Navigate to content phase when outline is confirmed
|
||||
navigateToPhase?.('content');
|
||||
|
||||
onOutlineConfirmed();
|
||||
|
||||
// If research specifies a short/medium blog (<=1000), kick off medium generation
|
||||
|
||||
@@ -8,6 +8,8 @@ interface OutlineGeneratorProps {
|
||||
onTaskStart: (taskId: string) => void;
|
||||
onPollingStart: (taskId: string) => void;
|
||||
onModalShow?: () => void; // Callback to show progress modal immediately
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
onOutlineCreated?: (outline: any[], titleOptions?: any[]) => void; // Callback when outline is created/found (for cached outlines)
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
@@ -16,7 +18,9 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
research,
|
||||
onTaskStart,
|
||||
onPollingStart,
|
||||
onModalShow
|
||||
onModalShow,
|
||||
navigateToPhase,
|
||||
onOutlineCreated
|
||||
}, ref) => {
|
||||
// Expose an imperative method to trigger outline generation directly (bypass LLM)
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -67,6 +71,15 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
|
||||
if (cachedOutline) {
|
||||
console.log('[OutlineGenerator] Using cached outline from CopilotKit action', { sections: cachedOutline.outline.length });
|
||||
|
||||
// Navigate to outline phase when cached outline is found
|
||||
navigateToPhase?.('outline');
|
||||
|
||||
// Update parent state with cached outline (same as header button does)
|
||||
if (onOutlineCreated) {
|
||||
onOutlineCreated(cachedOutline.outline, cachedOutline.title_options);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Outline already exists! ${cachedOutline.outline.length} sections loaded from cache.`,
|
||||
@@ -77,6 +90,9 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
}
|
||||
|
||||
try {
|
||||
// Navigate to outline phase when outline generation starts
|
||||
navigateToPhase?.('outline');
|
||||
|
||||
// Show progress modal immediately when user clicks "Create outline"
|
||||
onModalShow?.();
|
||||
|
||||
|
||||
@@ -51,9 +51,16 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
seoRecommendationsApplied = false,
|
||||
hasSEOMetadata = false,
|
||||
}) => {
|
||||
// Phase Navigation: Default interface for blog writing workflow
|
||||
// - Phase buttons are always clickable and functional (for both CopilotKit and manual flows)
|
||||
// - Action buttons (▶) only appear when CopilotKit is unavailable (manual fallback)
|
||||
// - When CopilotKit is available, users can use either phase buttons or CopilotKit suggestions
|
||||
|
||||
// Determine which action to show for each phase when CopilotKit is unavailable
|
||||
const getActionForPhase = (phaseId: string): { label: string; handler: (() => void) | null } => {
|
||||
if (copilotKitAvailable || !actionHandlers) {
|
||||
// Show action buttons for both CopilotKit and manual flows (dual mode)
|
||||
// Users can use either CopilotKit suggestions or phase navigation buttons
|
||||
if (!actionHandlers) {
|
||||
return { label: '', handler: null };
|
||||
}
|
||||
|
||||
@@ -104,159 +111,317 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
padding: '8px 0',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
{phases.map((phase) => {
|
||||
const isCurrent = phase.current;
|
||||
const isCompleted = phase.completed;
|
||||
const isDisabled = phase.disabled;
|
||||
const action = getActionForPhase(phase.id);
|
||||
|
||||
// Show action button when:
|
||||
// 1. CopilotKit is unavailable
|
||||
// 2. Action handler exists
|
||||
// 3. Phase is not disabled
|
||||
// 4. Show for current phase OR next actionable phase (not completed) OR phases with available actions
|
||||
// For research phase: always show if no research exists
|
||||
// For outline phase: always show if research exists but no outline (like research phase)
|
||||
// For SEO phase: always show if action handler exists (prerequisites are met)
|
||||
const isResearchPhase = phase.id === 'research' && !hasResearch;
|
||||
// Outline phase: show action whenever research exists and action handler is available
|
||||
// This allows users to create/regenerate outline after research, even if cached one exists
|
||||
const isOutlinePhase = phase.id === 'outline' && hasResearch && action.handler;
|
||||
// SEO phase: show action whenever prerequisites are met (action handler exists)
|
||||
// Similar to research/outline, show SEO actions whenever handler exists and phase is enabled
|
||||
const isSEOPhase = phase.id === 'seo' && action.handler;
|
||||
|
||||
// Debug logging for SEO phase (temporary - for troubleshooting)
|
||||
if (phase.id === 'seo' && !copilotKitAvailable && process.env.NODE_ENV === 'development') {
|
||||
console.log('[PhaseNavigation] SEO phase debug:', {
|
||||
phaseId: phase.id,
|
||||
isCurrent,
|
||||
isCompleted,
|
||||
isDisabled,
|
||||
hasContent,
|
||||
contentConfirmed,
|
||||
hasSEOAnalysis,
|
||||
seoRecommendationsApplied,
|
||||
hasSEOMetadata,
|
||||
actionLabel: action.label,
|
||||
actionHandler: !!action.handler,
|
||||
copilotKitAvailable,
|
||||
isSEOPhase,
|
||||
showActionWillBe: !copilotKitAvailable && action.handler && !isDisabled && (
|
||||
isCurrent ||
|
||||
(!isCompleted && !isDisabled) ||
|
||||
isResearchPhase ||
|
||||
isOutlinePhase ||
|
||||
isSEOPhase
|
||||
)
|
||||
});
|
||||
<>
|
||||
<style>{`
|
||||
/* Enterprise Phase Navigation Styles */
|
||||
.phase-nav-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
alignItems: center;
|
||||
padding: 12px 0;
|
||||
flexWrap: wrap;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 250, 251, 0.98) 100%);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
// Show action if: current phase, or phase is not completed and not disabled, or it's research/outline/SEO with available action
|
||||
// For SEO: show whenever action handler exists (prerequisites are met), even if phase is marked as disabled/completed
|
||||
// This is critical because SEO prerequisites (hasContent && contentConfirmed) are validated in getActionForPhase,
|
||||
// so if action.handler exists, we should show it regardless of phase navigation's disabled state
|
||||
const showAction = !copilotKitAvailable && action.handler && (
|
||||
isCurrent ||
|
||||
(!isCompleted && !isDisabled) ||
|
||||
isResearchPhase ||
|
||||
isOutlinePhase ||
|
||||
isSEOPhase // Show SEO actions when handler exists - handler existence means prerequisites are met, so ignore isDisabled
|
||||
);
|
||||
.phase-chip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 24px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={phase.id} style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => !isDisabled && onPhaseClick(phase.id)}
|
||||
disabled={isDisabled}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '20px',
|
||||
border: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
backgroundColor: isCurrent
|
||||
? '#1976d2'
|
||||
: isCompleted
|
||||
? '#4caf50'
|
||||
: isDisabled
|
||||
? '#f5f5f5'
|
||||
: '#e3f2fd',
|
||||
color: isCurrent
|
||||
? 'white'
|
||||
: isCompleted
|
||||
? 'white'
|
||||
: isDisabled
|
||||
? '#999'
|
||||
: '#1976d2',
|
||||
opacity: isDisabled ? 0.6 : 1,
|
||||
boxShadow: isCurrent ? '0 2px 4px rgba(25, 118, 210, 0.3)' : 'none',
|
||||
transform: isCurrent ? 'translateY(-1px)' : 'none'
|
||||
}}
|
||||
title={phase.disabled ? `Complete ${phase.name} first` : phase.description}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>
|
||||
{phase.icon}
|
||||
</span>
|
||||
<span>{phase.name}</span>
|
||||
{isCompleted && !isCurrent && (
|
||||
<span style={{ fontSize: '12px', marginLeft: '4px' }}>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showAction && (
|
||||
.phase-chip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.phase-chip:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* Current Phase - Active Gradient */
|
||||
.phase-chip.current {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
}
|
||||
|
||||
.phase-chip.current:hover {
|
||||
transform: translateY(-3px) scale(1.03);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.2) inset;
|
||||
}
|
||||
|
||||
.phase-chip.current:active {
|
||||
transform: translateY(-1px) scale(1.01);
|
||||
box-shadow: 0 3px 12px rgba(102, 126, 234, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.15) inset;
|
||||
}
|
||||
|
||||
/* Completed Phase - Success Gradient */
|
||||
.phase-chip.completed {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
box-shadow: 0 3px 12px rgba(16, 185, 129, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
}
|
||||
|
||||
.phase-chip.completed:hover {
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0 5px 16px rgba(16, 185, 129, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.15) inset;
|
||||
}
|
||||
|
||||
/* Pending Phase - Subtle Gradient */
|
||||
.phase-chip.pending {
|
||||
background: linear-gradient(135deg, #e0e7ff 0%, #dbeafe 100%);
|
||||
color: #4b5563;
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.phase-chip.pending:hover {
|
||||
background: linear-gradient(135deg, #c7d2fe 0%, #bfdbfe 100%);
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
/* Disabled Phase */
|
||||
.phase-chip.disabled {
|
||||
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
box-shadow: none;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.phase-chip.disabled:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Phase Icon */
|
||||
.phase-icon {
|
||||
font-size: 18px;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.phase-chip.current .phase-icon,
|
||||
.phase-chip.completed .phase-icon {
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.phase-chip:hover:not(.disabled) .phase-icon {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
/* Checkmark for completed */
|
||||
.phase-checkmark {
|
||||
font-size: 14px;
|
||||
margin-left: 4px;
|
||||
animation: checkmarkPop 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes checkmarkPop {
|
||||
0% { transform: scale(0); }
|
||||
50% { transform: scale(1.2); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Action Button - Enterprise Style */
|
||||
.phase-action-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.35),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
|
||||
0 1px 2px rgba(0, 0, 0, 0.1) inset;
|
||||
}
|
||||
|
||||
.phase-action-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.phase-action-btn:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.phase-action-btn:hover {
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
|
||||
0 2px 4px rgba(0, 0, 0, 0.15) inset;
|
||||
}
|
||||
|
||||
.phase-action-btn:active {
|
||||
transform: translateY(0) scale(1.02);
|
||||
box-shadow: 0 3px 10px rgba(102, 126, 234, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.15) inset;
|
||||
}
|
||||
|
||||
.phase-action-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 12px rgba(102, 126, 234, 0.35),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
}
|
||||
|
||||
.phase-action-icon {
|
||||
font-size: 12px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.phase-action-btn:hover .phase-action-icon {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="phase-nav-container">
|
||||
{phases.map((phase) => {
|
||||
const isCurrent = phase.current;
|
||||
const isCompleted = phase.completed;
|
||||
const isDisabled = phase.disabled;
|
||||
const action = getActionForPhase(phase.id);
|
||||
|
||||
// Show action button when:
|
||||
// 1. CopilotKit is unavailable
|
||||
// 2. Action handler exists
|
||||
// 3. Phase is not disabled
|
||||
// 4. Show for current phase OR next actionable phase (not completed) OR phases with available actions
|
||||
// For research phase: always show if no research exists
|
||||
// For outline phase: always show if research exists but no outline (like research phase)
|
||||
// For SEO phase: always show if action handler exists (prerequisites are met)
|
||||
const isResearchPhase = phase.id === 'research' && !hasResearch;
|
||||
// Outline phase: show action whenever research exists and action handler is available
|
||||
// This allows users to create/regenerate outline after research, even if cached one exists
|
||||
const isOutlinePhase = phase.id === 'outline' && hasResearch && action.handler;
|
||||
// SEO phase: show action whenever prerequisites are met (action handler exists)
|
||||
// Similar to research/outline, show SEO actions whenever handler exists and phase is enabled
|
||||
const isSEOPhase = phase.id === 'seo' && action.handler;
|
||||
|
||||
// Debug logging for SEO phase (temporary - for troubleshooting)
|
||||
if (phase.id === 'seo' && !copilotKitAvailable && process.env.NODE_ENV === 'development') {
|
||||
console.log('[PhaseNavigation] SEO phase debug:', {
|
||||
phaseId: phase.id,
|
||||
isCurrent,
|
||||
isCompleted,
|
||||
isDisabled,
|
||||
hasContent,
|
||||
contentConfirmed,
|
||||
hasSEOAnalysis,
|
||||
seoRecommendationsApplied,
|
||||
hasSEOMetadata,
|
||||
actionLabel: action.label,
|
||||
actionHandler: !!action.handler,
|
||||
copilotKitAvailable,
|
||||
isSEOPhase,
|
||||
showActionWillBe: !copilotKitAvailable && action.handler && !isDisabled && (
|
||||
isCurrent ||
|
||||
(!isCompleted && !isDisabled) ||
|
||||
isResearchPhase ||
|
||||
isOutlinePhase ||
|
||||
isSEOPhase
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Show action if: current phase, or phase is not completed and not disabled, or it's research/outline/SEO with available action
|
||||
// For SEO: show whenever action handler exists (prerequisites are met), even if phase is marked as disabled/completed
|
||||
// This is critical because SEO prerequisites (hasContent && contentConfirmed) are validated in getActionForPhase,
|
||||
// so if action.handler exists, we should show it regardless of phase navigation's disabled state
|
||||
// DUAL MODE: Show action buttons even when CopilotKit is available (users can use either method)
|
||||
const showAction = action.handler && (
|
||||
isCurrent ||
|
||||
(!isCompleted && !isDisabled) ||
|
||||
isResearchPhase ||
|
||||
isOutlinePhase ||
|
||||
isSEOPhase // Show SEO actions when handler exists - handler existence means prerequisites are met, so ignore isDisabled
|
||||
);
|
||||
|
||||
// Determine chip class
|
||||
const chipClass = [
|
||||
'phase-chip',
|
||||
isCurrent ? 'current' : '',
|
||||
isCompleted && !isCurrent ? 'completed' : '',
|
||||
!isCurrent && !isCompleted && !isDisabled ? 'pending' : '',
|
||||
isDisabled ? 'disabled' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div key={phase.id} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.handler?.();
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
border: '1px solid #1976d2',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 4px rgba(25, 118, 210, 0.2)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#1565c0';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#1976d2';
|
||||
e.currentTarget.style.transform = 'none';
|
||||
}}
|
||||
title={`${action.label} (Chat unavailable - click to proceed)`}
|
||||
onClick={() => !isDisabled && onPhaseClick(phase.id)}
|
||||
disabled={isDisabled}
|
||||
className={chipClass}
|
||||
title={phase.disabled ? `Complete ${phase.name} first` : phase.description}
|
||||
>
|
||||
<span style={{ fontSize: '12px' }}>▶</span>
|
||||
<span>{action.label}</span>
|
||||
<span className="phase-icon">
|
||||
{phase.icon}
|
||||
</span>
|
||||
<span>{phase.name}</span>
|
||||
{isCompleted && !isCurrent && (
|
||||
<span className="phase-checkmark">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showAction && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.handler?.();
|
||||
}}
|
||||
className="phase-action-btn"
|
||||
title={`${action.label}`}
|
||||
>
|
||||
<span className="phase-action-icon">▶</span>
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -62,8 +62,12 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
try {
|
||||
const status = await wordpressAPI.getStatus();
|
||||
setWordpressSites(status.sites || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to check WordPress connection status:', error);
|
||||
} catch (error: any) {
|
||||
// getStatus now handles 404 gracefully, so we should rarely hit this
|
||||
// Only log non-404 errors
|
||||
if (error?.response?.status !== 404) {
|
||||
console.error('Failed to check WordPress connection status:', error);
|
||||
}
|
||||
setWordpressSites([]);
|
||||
} finally {
|
||||
setCheckingWordPressStatus(false);
|
||||
|
||||
@@ -9,9 +9,10 @@ const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface ResearchActionProps {
|
||||
onResearchComplete?: (research: BlogResearchResponse) => void;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete }) => {
|
||||
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete, navigateToPhase }) => {
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
const [currentMessage, setCurrentMessage] = useState<string>('');
|
||||
const [showProgressModal, setShowProgressModal] = useState<boolean>(false);
|
||||
@@ -20,28 +21,36 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
// Refs for form inputs (uncontrolled, avoids typing issues inside Copilot render)
|
||||
const keywordsRef = useRef<HTMLInputElement | null>(null);
|
||||
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
|
||||
|
||||
// Track if we've navigated to research phase for this form display
|
||||
const hasNavigatedRef = useRef<boolean>(false);
|
||||
|
||||
const polling = useResearchPolling({
|
||||
onProgress: (message) => {
|
||||
setCurrentMessage(message);
|
||||
setForceUpdate(prev => prev + 1); // Force re-render
|
||||
},
|
||||
onComplete: (result) => {
|
||||
if (result && result.keywords) {
|
||||
researchCache.cacheResult(
|
||||
result.keywords,
|
||||
result.industry || 'General',
|
||||
result.target_audience || 'General',
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
onResearchComplete?.(result);
|
||||
setCurrentTaskId(null);
|
||||
setCurrentMessage('');
|
||||
setShowProgressModal(false);
|
||||
setForceUpdate(prev => prev + 1);
|
||||
},
|
||||
onComplete: (result) => {
|
||||
console.info('[ResearchAction] ✅ Research completed', { hasResult: !!result });
|
||||
|
||||
if (result && result.keywords) {
|
||||
researchCache.cacheResult(
|
||||
result.keywords,
|
||||
result.industry || 'General',
|
||||
result.target_audience || 'General',
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
// Reset navigation tracking when research completes
|
||||
hasNavigatedRef.current = false;
|
||||
|
||||
onResearchComplete?.(result);
|
||||
setCurrentTaskId(null);
|
||||
setCurrentMessage('');
|
||||
setShowProgressModal(false);
|
||||
setForceUpdate(prev => prev + 1);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Research polling error:', error);
|
||||
setCurrentTaskId(null);
|
||||
@@ -55,14 +64,40 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
name: 'showResearchForm',
|
||||
description: 'Show keyword input form for blog research',
|
||||
parameters: [],
|
||||
handler: async () => ({
|
||||
success: true,
|
||||
message: "🔍 Let's Research Your Blog Topic\n\nWhat keywords and information would you like to use for your research? Please also specify the desired length of the blog post.\n\nKeywords or Topic *\ne.g., artificial intelligence, machine learning, AI trends\n\nBlog Length (words)\n\n1000 words (Medium blog)\n\n🚀 Start Research",
|
||||
showForm: true
|
||||
}),
|
||||
handler: async () => {
|
||||
// Navigate to research phase when research form is shown
|
||||
// Reset navigation tracking so form render can navigate again if needed
|
||||
hasNavigatedRef.current = false;
|
||||
// Navigate immediately when handler is called
|
||||
if (navigateToPhase) {
|
||||
navigateToPhase('research');
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: "🔍 Let's Research Your Blog Topic\n\nWhat keywords and information would you like to use for your research? Please also specify the desired length of the blog post.\n\nKeywords or Topic *\ne.g., artificial intelligence, machine learning, AI trends\n\nBlog Length (words)\n\n1000 words (Medium blog)\n\n🚀 Start Research",
|
||||
showForm: true
|
||||
};
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
const _ = forceUpdate;
|
||||
|
||||
// Navigate to research phase when form is rendered (if not already navigated and form is shown)
|
||||
// This ensures phase navigation updates when CopilotKit shows the research form
|
||||
// Only navigate when showing the form (not progress or completion states)
|
||||
const isShowingForm = polling.currentStatus !== 'completed' &&
|
||||
polling.currentStatus !== 'in_progress' &&
|
||||
polling.currentStatus !== 'running';
|
||||
|
||||
if (isShowingForm && !hasNavigatedRef.current && navigateToPhase) {
|
||||
// Use setTimeout to avoid calling during render
|
||||
setTimeout(() => {
|
||||
if (!hasNavigatedRef.current) {
|
||||
navigateToPhase('research');
|
||||
hasNavigatedRef.current = true;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (polling.currentStatus === 'completed' && polling.progressMessages.length > 0) {
|
||||
const latestMessage = polling.progressMessages[polling.progressMessages.length - 1];
|
||||
return (
|
||||
@@ -135,6 +170,8 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
target_audience: 'General',
|
||||
word_count_target: parseInt(blogLength)
|
||||
};
|
||||
// Navigate to research phase when research starts
|
||||
navigateToPhase?.('research');
|
||||
const { task_id } = await blogWriterApi.startResearch(payload);
|
||||
setCurrentTaskId(task_id);
|
||||
setShowProgressModal(true);
|
||||
@@ -173,6 +210,8 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
const keywordList = trimmed.includes(',')
|
||||
? trimmed.split(',').map((k: string) => k.trim()).filter(Boolean)
|
||||
: [trimmed];
|
||||
// Navigate to research phase when research starts
|
||||
navigateToPhase?.('research');
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: keywordList,
|
||||
industry,
|
||||
@@ -191,6 +230,7 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{showProgressModal && (
|
||||
|
||||
@@ -8,12 +8,14 @@ interface ResearchDataActionsProps {
|
||||
research: BlogResearchResponse | null;
|
||||
onOutlineCreated: (outline: BlogOutlineSection[]) => void;
|
||||
onTitleOptionsSet: (titles: string[]) => void;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
export const ResearchDataActions: React.FC<ResearchDataActionsProps> = ({
|
||||
research,
|
||||
onOutlineCreated,
|
||||
onTitleOptionsSet
|
||||
onTitleOptionsSet,
|
||||
navigateToPhase
|
||||
}) => {
|
||||
// Chat with Research Data
|
||||
useCopilotActionTyped({
|
||||
@@ -110,6 +112,9 @@ export const ResearchDataActions: React.FC<ResearchDataActionsProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
// Navigate to outline phase when outline generation starts
|
||||
navigateToPhase?.('outline');
|
||||
|
||||
// Create a custom outline request with user instructions
|
||||
const customOutlineRequest = {
|
||||
research: research,
|
||||
|
||||
@@ -51,16 +51,13 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
|
||||
if (taskId) {
|
||||
polling.startPolling(taskId);
|
||||
} else {
|
||||
polling.stopPolling();
|
||||
// Only stop if actually polling (not on every render when taskId is null)
|
||||
if (polling.isPolling) {
|
||||
polling.stopPolling();
|
||||
}
|
||||
}
|
||||
}, [taskId, polling]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
polling.stopPolling();
|
||||
};
|
||||
}, [polling]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskId]); // Removed polling from dependencies - usePolling already handles cleanup
|
||||
|
||||
// Only log on meaningful changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -183,7 +183,12 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const [applyError, setApplyError] = useState<string | null>(null);
|
||||
|
||||
console.log('SEOAnalysisModal render:', { isOpen, blogContent: blogContent?.length, researchData: !!researchData });
|
||||
// Debug logging only in development and when modal state changes meaningfully
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'development' && isOpen) {
|
||||
console.log('SEOAnalysisModal render:', { isOpen, blogContent: blogContent?.length, researchData: !!researchData });
|
||||
}
|
||||
}, [isOpen, blogContent?.length, researchData]);
|
||||
|
||||
const runSEOAnalysis = useCallback(async (forceRefresh = false) => {
|
||||
try {
|
||||
@@ -318,7 +323,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
status,
|
||||
data: err?.response?.data
|
||||
});
|
||||
const handled = triggerSubscriptionError(err);
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
console.log('SEOAnalysisModal: Global subscription error handler triggered successfully');
|
||||
// Don't set local error - let the global modal handle it
|
||||
|
||||
@@ -125,15 +125,17 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
return unsub;
|
||||
}, [metadataResult]);
|
||||
|
||||
// Debug logging
|
||||
// Debug logging only in development and when modal state changes meaningfully
|
||||
useEffect(() => {
|
||||
console.log('🔍 SEOMetadataModal render:', {
|
||||
isOpen,
|
||||
blogContent: blogContent?.length,
|
||||
blogTitle,
|
||||
researchData: !!researchData
|
||||
});
|
||||
}, [isOpen, blogContent, blogTitle, researchData]);
|
||||
if (process.env.NODE_ENV === 'development' && isOpen) {
|
||||
console.log('🔍 SEOMetadataModal render:', {
|
||||
isOpen,
|
||||
blogContent: blogContent?.length,
|
||||
blogTitle,
|
||||
researchData: !!researchData
|
||||
});
|
||||
}
|
||||
}, [isOpen, blogContent?.length, blogTitle, researchData]);
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
@@ -229,7 +231,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
status,
|
||||
data: err?.response?.data
|
||||
});
|
||||
const handled = triggerSubscriptionError(err);
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
console.log('SEOMetadataModal: Global subscription error handler triggered successfully');
|
||||
// Don't set local error - let the global modal handle it
|
||||
|
||||
@@ -8,6 +8,7 @@ interface SectionGeneratorProps {
|
||||
genMode: 'draft' | 'polished';
|
||||
onSectionGenerated: (sectionId: string, markdown: string) => void;
|
||||
onContinuityRefresh: () => void;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
@@ -17,7 +18,8 @@ export const SectionGenerator: React.FC<SectionGeneratorProps> = ({
|
||||
research,
|
||||
genMode,
|
||||
onSectionGenerated,
|
||||
onContinuityRefresh
|
||||
onContinuityRefresh,
|
||||
navigateToPhase
|
||||
}) => {
|
||||
useCopilotActionTyped({
|
||||
name: 'generateSection',
|
||||
@@ -27,6 +29,9 @@ export const SectionGenerator: React.FC<SectionGeneratorProps> = ({
|
||||
const section = outline.find(s => s.id === sectionId);
|
||||
if (!section) return { success: false, message: 'Section not found. Please generate an outline first.' };
|
||||
|
||||
// Navigate to content phase when content generation starts
|
||||
navigateToPhase?.('content');
|
||||
|
||||
try {
|
||||
const res = await blogWriterApi.generateSection({ section, mode: genMode });
|
||||
if (res?.markdown) {
|
||||
@@ -98,6 +103,9 @@ export const SectionGenerator: React.FC<SectionGeneratorProps> = ({
|
||||
description: 'Generate content for every section in the outline',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
// Navigate to content phase when content generation starts
|
||||
navigateToPhase?.('content');
|
||||
|
||||
for (const s of outline) {
|
||||
const res = await blogWriterApi.generateSection({ section: s, mode: genMode });
|
||||
onSectionGenerated(s.id, res.markdown);
|
||||
|
||||
185
frontend/src/components/FacebookWriter/FacebookPersonaModal.tsx
Normal file
185
frontend/src/components/FacebookWriter/FacebookPersonaModal.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Facebook Persona Generation Modal
|
||||
*
|
||||
* Prompts user to generate Facebook persona if it doesn't exist.
|
||||
* Similar to ResearchPersonaModal but for Facebook-specific persona.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Facebook as FacebookIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Group as GroupIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Close as CloseIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface FacebookPersonaModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onGenerate: () => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const FacebookPersonaModal: React.FC<FacebookPersonaModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onGenerate,
|
||||
onCancel
|
||||
}) => {
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onGenerate();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate Facebook persona');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={!generating ? onClose : undefined}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
disableEscapeKeyDown={generating}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
background: 'linear-gradient(135deg, #fff 0%, #f8fafc 100%)',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ textAlign: 'center', pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2 }}>
|
||||
<FacebookIcon sx={{ fontSize: 32, color: '#1877F2' }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||
Generate Facebook Persona
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ px: 4, py: 2 }}>
|
||||
<Typography variant="body1" sx={{ mb: 3, textAlign: 'center', color: 'text.secondary' }}>
|
||||
Enhance your Facebook content with AI-powered personalization based on your brand voice and Facebook's algorithm.
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Why generate a Facebook persona?
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
Your Facebook persona learns from your onboarding data to provide personalized content that matches
|
||||
your brand voice and optimizes for Facebook's engagement algorithm.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5 }}>
|
||||
Benefits:
|
||||
</Typography>
|
||||
<List dense sx={{ py: 0 }}>
|
||||
<ListItem sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<AutoAwesomeIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Algorithm Optimization"
|
||||
secondary="Content optimized for Facebook's engagement algorithm and reach"
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<TrendingUpIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Platform-Specific Strategies"
|
||||
secondary="Facebook-specific engagement, timing, and community building strategies"
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<GroupIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Community Building"
|
||||
secondary="Strategies for building and engaging your Facebook community"
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<CheckCircleIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Brand Voice Alignment"
|
||||
secondary="Content that matches your brand voice and Facebook's best practices"
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
|
||||
Note: This process takes about 30-60 seconds and uses your AI provider.
|
||||
You can continue using generic persona if you skip this step.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 4, pb: 3, justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
disabled={generating}
|
||||
startIcon={<CloseIcon />}
|
||||
color="inherit"
|
||||
>
|
||||
Skip for Now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
variant="contained"
|
||||
startIcon={generating ? <CircularProgress size={16} /> : <FacebookIcon />}
|
||||
sx={{ minWidth: 150, bgcolor: '#1877F2', '&:hover': { bgcolor: '#1565C0' } }}
|
||||
>
|
||||
{generating ? 'Generating...' : 'Generate Persona'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,8 @@ import RegisterFacebookActions from './RegisterFacebookActions';
|
||||
import RegisterFacebookEditActions from './RegisterFacebookEditActions';
|
||||
import RegisterFacebookActionsEnhanced from './RegisterFacebookActionsEnhanced';
|
||||
import { PlatformPersonaProvider, usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider';
|
||||
import { generatePlatformPersona } from '../../api/persona';
|
||||
import { generatePlatformPersona, checkFacebookPersona } from '../../api/persona';
|
||||
import { FacebookPersonaModal } from './FacebookPersonaModal';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
@@ -168,6 +169,36 @@ const FacebookWriterContent: React.FC<FacebookWriterProps> = ({ className = '' }
|
||||
// State for generating persona
|
||||
const [isGeneratingPersona, setIsGeneratingPersona] = React.useState<boolean>(false);
|
||||
const [personaError, setPersonaError] = React.useState<string | null>(null);
|
||||
const [showPersonaModal, setShowPersonaModal] = React.useState<boolean>(false);
|
||||
const [personaChecked, setPersonaChecked] = React.useState<boolean>(false);
|
||||
|
||||
// Check for Facebook persona on component mount
|
||||
React.useEffect(() => {
|
||||
const checkPersona = async () => {
|
||||
if (personaChecked) return; // Already checked
|
||||
|
||||
try {
|
||||
const userId = localStorage.getItem('user_id');
|
||||
if (!userId) {
|
||||
setPersonaChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const personaStatus = await checkFacebookPersona(userId);
|
||||
|
||||
// Show modal if onboarding completed but persona missing
|
||||
if (personaStatus.onboarding_completed && !personaStatus.has_persona && personaStatus.has_core_persona) {
|
||||
setShowPersonaModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Facebook persona:', error);
|
||||
} finally {
|
||||
setPersonaChecked(true);
|
||||
}
|
||||
};
|
||||
|
||||
checkPersona();
|
||||
}, [personaChecked]);
|
||||
|
||||
// Handler to generate Facebook persona on-demand
|
||||
const handleGeneratePersona = async () => {
|
||||
@@ -192,6 +223,36 @@ const FacebookWriterContent: React.FC<FacebookWriterProps> = ({ className = '' }
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for modal generation
|
||||
const handleGenerateFacebookPersona = async () => {
|
||||
setIsGeneratingPersona(true);
|
||||
setPersonaError(null);
|
||||
|
||||
try {
|
||||
const result = await generatePlatformPersona('facebook');
|
||||
|
||||
if (result.success) {
|
||||
// Refresh the persona context to load the newly generated persona
|
||||
await refreshPersonas();
|
||||
console.log('✅ Facebook persona generated successfully');
|
||||
setShowPersonaModal(false);
|
||||
} else {
|
||||
throw new Error('Failed to generate persona');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error generating persona:', error);
|
||||
throw error; // Let modal handle error display
|
||||
} finally {
|
||||
setIsGeneratingPersona(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for modal cancel
|
||||
const handleCancelPersona = () => {
|
||||
setShowPersonaModal(false);
|
||||
// Continue with generic persona
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const onUpdate = (e: any) => {
|
||||
setPostDraft(String(e.detail || ''));
|
||||
@@ -790,6 +851,16 @@ Instead of generic content, you get:
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Facebook Persona Modal */}
|
||||
{showPersonaModal && (
|
||||
<FacebookPersonaModal
|
||||
open={showPersonaModal}
|
||||
onClose={() => setShowPersonaModal(false)}
|
||||
onGenerate={handleGenerateFacebookPersona}
|
||||
onCancel={handleCancelPersona}
|
||||
/>
|
||||
)}
|
||||
</CopilotSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* OAuth Token Status Panel
|
||||
* Displays OAuth token monitoring status for all platforms and allows manual refresh
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Collapse,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import {
|
||||
getOAuthTokenStatus,
|
||||
manualRefreshToken,
|
||||
OAuthTokenStatusResponse,
|
||||
ManualRefreshResponse,
|
||||
} from '../../api/oauthTokenMonitoring';
|
||||
|
||||
interface OAuthTokenStatusPanelProps {
|
||||
userId?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const OAuthTokenStatusPanel: React.FC<OAuthTokenStatusPanelProps> = ({
|
||||
userId,
|
||||
compact = false
|
||||
}) => {
|
||||
const { userId: clerkUserId } = useAuth();
|
||||
const actualUserId = userId || clerkUserId || '';
|
||||
|
||||
const [status, setStatus] = useState<OAuthTokenStatusResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedPlatform, setExpandedPlatform] = useState<string | null>(null);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!actualUserId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await getOAuthTokenStatus(actualUserId);
|
||||
setStatus(response);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch token status');
|
||||
console.error('Error fetching OAuth token status:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
|
||||
// Poll for status updates every 2 minutes
|
||||
const interval = setInterval(fetchStatus, 120000);
|
||||
return () => clearInterval(interval);
|
||||
}, [actualUserId]);
|
||||
|
||||
const handleRefresh = async (platform: string) => {
|
||||
if (!actualUserId) return;
|
||||
|
||||
try {
|
||||
setRefreshing(platform);
|
||||
setError(null);
|
||||
const response: ManualRefreshResponse = await manualRefreshToken(actualUserId, platform);
|
||||
|
||||
// Refresh status after manual refresh
|
||||
await fetchStatus();
|
||||
|
||||
// Show success message
|
||||
if (response.success) {
|
||||
console.log(`Token refresh successful for ${platform}`);
|
||||
} else {
|
||||
console.error(`Token refresh failed for ${platform}:`, response.data.execution_result.error_message);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || `Failed to refresh ${platform} token`);
|
||||
console.error(`Error refreshing ${platform} token:`, err);
|
||||
} finally {
|
||||
setRefreshing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (taskStatus: string | null, connected: boolean) => {
|
||||
if (!connected) {
|
||||
return <XCircle size={20} color="#ef4444" />;
|
||||
}
|
||||
|
||||
if (!taskStatus || taskStatus === 'not_created') {
|
||||
return <Info size={20} color="#3b82f6" />;
|
||||
}
|
||||
|
||||
switch (taskStatus) {
|
||||
case 'active':
|
||||
return <CheckCircle size={20} color="#10b981" />;
|
||||
case 'failed':
|
||||
return <XCircle size={20} color="#ef4444" />;
|
||||
case 'paused':
|
||||
return <AlertTriangle size={20} color="#f59e0b" />;
|
||||
default:
|
||||
return <Info size={20} color="#6b7280" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (taskStatus: string | null, connected: boolean) => {
|
||||
if (!connected) return 'error';
|
||||
if (!taskStatus || taskStatus === 'not_created') return 'info';
|
||||
if (taskStatus === 'active') return 'success';
|
||||
if (taskStatus === 'failed') return 'error';
|
||||
if (taskStatus === 'paused') return 'warning';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformDisplayName = (platform: string) => {
|
||||
const names: { [key: string]: string } = {
|
||||
gsc: 'Google Search Console',
|
||||
bing: 'Bing Webmaster Tools',
|
||||
wordpress: 'WordPress',
|
||||
wix: 'Wix',
|
||||
};
|
||||
return names[platform] || platform.toUpperCase();
|
||||
};
|
||||
|
||||
if (loading && !status) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" p={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !status) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ m: 2 }}>
|
||||
{error}
|
||||
<Button size="small" onClick={fetchStatus} sx={{ ml: 2 }}>
|
||||
Retry
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const platforms = ['gsc', 'bing', 'wordpress', 'wix'];
|
||||
|
||||
return (
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">OAuth Token Status</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<RefreshCw size={16} />}
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Platform</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Last Check</TableCell>
|
||||
<TableCell>Next Check</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{platforms.map((platform) => {
|
||||
const platformStatus = status.data.platform_status[platform];
|
||||
const task = platformStatus?.monitoring_task;
|
||||
|
||||
return (
|
||||
<React.Fragment key={platform}>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{getStatusIcon(task?.status || null, platformStatus?.connected || false)}
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{getPlatformDisplayName(platform)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={task?.status || (platformStatus?.connected ? 'Connected' : 'Not Connected')}
|
||||
size="small"
|
||||
color={getStatusColor(task?.status || null, platformStatus?.connected || false) as any}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatDate(task?.last_check || null)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatDate(task?.next_check || null)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Box display="flex" gap={1} justifyContent="flex-end">
|
||||
<Tooltip title="View details">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setExpandedPlatform(
|
||||
expandedPlatform === platform ? null : platform
|
||||
)}
|
||||
>
|
||||
{expandedPlatform === platform ? (
|
||||
<ChevronUp size={16} />
|
||||
) : (
|
||||
<ChevronDown size={16} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{platformStatus?.connected && (
|
||||
<Tooltip title="Manually refresh token">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleRefresh(platform)}
|
||||
disabled={refreshing === platform}
|
||||
>
|
||||
{refreshing === platform ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<RefreshCw size={16} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} sx={{ py: 0, border: 0 }}>
|
||||
<Collapse in={expandedPlatform === platform}>
|
||||
<Box p={2} bgcolor="grey.50">
|
||||
{task?.failure_reason && (
|
||||
<Alert severity="error" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
Last Failure:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{task.failure_reason}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(task.last_failure || null)}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
{task?.last_success && (
|
||||
<Alert severity="success" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2">
|
||||
Last successful check: {formatDate(task.last_success)}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
{!task && platformStatus?.connected && (
|
||||
<Alert severity="info">
|
||||
<Typography variant="body2">
|
||||
Platform is connected but no monitoring task exists.
|
||||
Monitoring tasks are created automatically after onboarding.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
{!platformStatus?.connected && (
|
||||
<Alert severity="warning">
|
||||
<Typography variant="body2">
|
||||
Platform is not connected. Connect it in onboarding step 5.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthTokenStatusPanel;
|
||||
|
||||
298
frontend/src/components/Research/ResearchPersonaModal.tsx
Normal file
298
frontend/src/components/Research/ResearchPersonaModal.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Research Persona Generation Modal
|
||||
*
|
||||
* Prompts user to generate research persona if it doesn't exist.
|
||||
* Explains benefits and allows user to generate or skip.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Psychology as PsychologyIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Search as SearchIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Close as CloseIcon
|
||||
} from '@mui/icons-material';
|
||||
import { refreshResearchPersona } from '../../api/researchConfig';
|
||||
import { triggerSubscriptionError } from '../../api/client';
|
||||
|
||||
interface ResearchPersonaModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onGenerate: () => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ResearchPersonaModal: React.FC<ResearchPersonaModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onGenerate,
|
||||
onCancel
|
||||
}) => {
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Debug: Track modal open state
|
||||
React.useEffect(() => {
|
||||
console.log('[ResearchPersonaModal] Modal open state:', open);
|
||||
if (open) {
|
||||
console.log('[ResearchPersonaModal] ✅ Modal is now OPEN');
|
||||
} else {
|
||||
console.log('[ResearchPersonaModal] Modal is CLOSED');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onGenerate();
|
||||
// Close modal on success
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
// Check if this is a subscription error (429/402)
|
||||
// The apiClient interceptor should have already handled it via the global handler
|
||||
// We just need to check if the global handler suppressed it (subscription is active)
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
console.log('[ResearchPersonaModal] Detected subscription error', {
|
||||
status,
|
||||
data: err?.response?.data
|
||||
});
|
||||
|
||||
// The global handler in apiClient interceptor should have already processed this
|
||||
// If subscription is active, the global handler suppresses the modal
|
||||
// If subscription is inactive, the global handler shows the modal
|
||||
// We just need to avoid showing a duplicate error message
|
||||
// Wait a moment to see if the global handler shows the modal
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// If the global handler showed the modal, it will handle it
|
||||
// We just stop here and don't show a local error
|
||||
setGenerating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// For non-subscription errors, show local error message
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate research persona');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!generating) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
disableEscapeKeyDown={generating}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
background: 'linear-gradient(135deg, #fff 0%, #f8fafc 100%)',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
// Force dark text colors for readability on light background
|
||||
color: '#1e293b',
|
||||
'& *': {
|
||||
color: 'inherit',
|
||||
},
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ textAlign: 'center', pb: 1, color: '#0f172a' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2 }}>
|
||||
<PsychologyIcon sx={{ fontSize: 32, color: 'primary.main' }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||
Generate Research Persona
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ px: 4, py: 2, color: '#1e293b' }}>
|
||||
<Typography variant="body1" sx={{ mb: 3, textAlign: 'center', color: '#475569' }}>
|
||||
Enhance your research experience with AI-powered personalization based on your business profile and preferences.
|
||||
</Typography>
|
||||
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
mb: 3,
|
||||
backgroundColor: '#e0f2fe',
|
||||
borderColor: '#7dd3fc',
|
||||
'& .MuiAlert-icon': {
|
||||
color: '#0284c7',
|
||||
},
|
||||
'& .MuiAlert-message': {
|
||||
color: '#0c4a6e',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5, color: '#0c4a6e' }}>
|
||||
Why generate a research persona?
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#075985', display: 'block' }}>
|
||||
Your research persona learns from your onboarding data to provide personalized research suggestions,
|
||||
keyword expansions, and research angles tailored to your industry and audience.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, color: '#0f172a' }}>
|
||||
Benefits:
|
||||
</Typography>
|
||||
<List dense sx={{ py: 0 }}>
|
||||
<ListItem sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<AutoAwesomeIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={<Typography sx={{ color: '#1e293b', fontWeight: 500 }}>Smart Keyword Expansion</Typography>}
|
||||
secondary={<Typography sx={{ color: '#64748b', fontSize: '0.875rem' }}>Automatically expand your keywords with industry-specific terms</Typography>}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<TrendingUpIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={<Typography sx={{ color: '#1e293b', fontWeight: 500 }}>Alternative Research Angles</Typography>}
|
||||
secondary={<Typography sx={{ color: '#64748b', fontSize: '0.875rem' }}>Discover new research directions based on your business context</Typography>}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<SearchIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={<Typography sx={{ color: '#1e293b', fontWeight: 500 }}>Personalized Research Presets</Typography>}
|
||||
secondary={<Typography sx={{ color: '#64748b', fontSize: '0.875rem' }}>Get recommended research configurations tailored to your needs</Typography>}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<CheckCircleIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={<Typography sx={{ color: '#1e293b', fontWeight: 500 }}>Better Search Results</Typography>}
|
||||
secondary={<Typography sx={{ color: '#64748b', fontSize: '0.875rem' }}>Improved query enhancement and domain suggestions for your industry</Typography>}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontStyle: 'italic' }}>
|
||||
Note: This process takes about 30-60 seconds and uses your AI provider.
|
||||
You can continue using rule-based suggestions if you skip this step.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 4, pb: 3, justifyContent: 'space-between', gap: 2 }}>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
disabled={generating}
|
||||
startIcon={<CloseIcon />}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#475569',
|
||||
borderColor: '#cbd5e1',
|
||||
'&:hover': {
|
||||
borderColor: '#94a3b8',
|
||||
backgroundColor: 'rgba(148, 163, 184, 0.08)',
|
||||
},
|
||||
px: 3,
|
||||
py: 1.25,
|
||||
}}
|
||||
>
|
||||
Skip for Now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
variant="contained"
|
||||
startIcon={generating ? <CircularProgress size={18} sx={{ color: 'white' }} /> : <PsychologyIcon />}
|
||||
sx={{
|
||||
minWidth: 180,
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
background: generating
|
||||
? 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)'
|
||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
boxShadow: generating
|
||||
? '0 4px 14px rgba(139, 92, 246, 0.3)'
|
||||
: '0 8px 20px rgba(102, 126, 234, 0.4), 0 0 0 1px rgba(102, 126, 234, 0.1) inset',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
|
||||
boxShadow: '0 12px 28px rgba(102, 126, 234, 0.5), 0 0 0 1px rgba(102, 126, 234, 0.2) inset',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0)',
|
||||
boxShadow: '0 4px 14px rgba(102, 126, 234, 0.4)',
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'linear-gradient(135deg, #cbd5e1 0%, #94a3b8 100%)',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': generating ? {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent)',
|
||||
animation: 'shimmer 2s infinite',
|
||||
} : {},
|
||||
'@keyframes shimmer': {
|
||||
'0%': { left: '-100%' },
|
||||
'100%': { left: '100%' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{generating ? 'Generating...' : 'Generate Persona'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,14 +5,24 @@ import { ResearchInput } from './steps/ResearchInput';
|
||||
import { StepProgress } from './steps/StepProgress';
|
||||
import { StepResults } from './steps/StepResults';
|
||||
import { ResearchWizardProps } from './types/research.types';
|
||||
import { addResearchHistory } from '../../utils/researchHistory';
|
||||
|
||||
export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
||||
onComplete,
|
||||
onCancel,
|
||||
initialKeywords,
|
||||
initialIndustry,
|
||||
initialTargetAudience,
|
||||
initialResearchMode,
|
||||
initialConfig,
|
||||
}) => {
|
||||
const wizard = useResearchWizard(initialKeywords, initialIndustry);
|
||||
const wizard = useResearchWizard(
|
||||
initialKeywords,
|
||||
initialIndustry,
|
||||
initialTargetAudience,
|
||||
initialResearchMode,
|
||||
initialConfig
|
||||
);
|
||||
const execution = useResearchExecution();
|
||||
|
||||
// Handle results from execution
|
||||
@@ -30,12 +40,28 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
||||
}
|
||||
}, [execution.result, execution.isExecuting]); // Don't depend on currentStep to avoid loops
|
||||
|
||||
// Handle completion callback
|
||||
// Handle completion callback and track history
|
||||
useEffect(() => {
|
||||
if (wizard.state.results && onComplete) {
|
||||
// Track in research history when results are available
|
||||
if (wizard.state.keywords.length > 0) {
|
||||
// Extract a summary from results if available
|
||||
const resultSummary = wizard.state.results.suggested_angles?.[0] ||
|
||||
wizard.state.results.keyword_analysis?.primary_keywords?.[0] ||
|
||||
wizard.state.results.sources?.[0]?.title;
|
||||
|
||||
addResearchHistory({
|
||||
keywords: wizard.state.keywords,
|
||||
industry: wizard.state.industry,
|
||||
targetAudience: wizard.state.targetAudience,
|
||||
researchMode: wizard.state.researchMode,
|
||||
resultSummary,
|
||||
});
|
||||
}
|
||||
|
||||
onComplete(wizard.state.results);
|
||||
}
|
||||
}, [wizard.state.results, onComplete]);
|
||||
}, [wizard.state.results, wizard.state.keywords, wizard.state.industry, wizard.state.targetAudience, wizard.state.researchMode, onComplete]);
|
||||
|
||||
const renderStep = () => {
|
||||
const stepProps = {
|
||||
|
||||
@@ -23,9 +23,28 @@ const defaultState: WizardState = {
|
||||
results: null,
|
||||
};
|
||||
|
||||
export const useResearchWizard = (initialKeywords?: string[], initialIndustry?: string) => {
|
||||
export const useResearchWizard = (
|
||||
initialKeywords?: string[],
|
||||
initialIndustry?: string,
|
||||
initialTargetAudience?: string,
|
||||
initialResearchMode?: ResearchMode,
|
||||
initialConfig?: ResearchConfig
|
||||
) => {
|
||||
const [state, setState] = useState<WizardState>(() => {
|
||||
// Try to load from localStorage first
|
||||
// If initial values are provided (preset clicked), clear localStorage and use them
|
||||
if (initialKeywords || initialIndustry || initialTargetAudience || initialResearchMode || initialConfig) {
|
||||
localStorage.removeItem(WIZARD_STATE_KEY);
|
||||
return {
|
||||
...defaultState,
|
||||
keywords: initialKeywords || [],
|
||||
industry: initialIndustry || defaultState.industry,
|
||||
targetAudience: initialTargetAudience || defaultState.targetAudience,
|
||||
researchMode: initialResearchMode || defaultState.researchMode,
|
||||
config: initialConfig || defaultState.config,
|
||||
};
|
||||
}
|
||||
|
||||
// Try to load from localStorage only if no initial values
|
||||
const saved = localStorage.getItem(WIZARD_STATE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
@@ -36,14 +55,26 @@ export const useResearchWizard = (initialKeywords?: string[], initialIndustry?:
|
||||
}
|
||||
}
|
||||
|
||||
// Use defaults or initial values
|
||||
return {
|
||||
...defaultState,
|
||||
keywords: initialKeywords || [],
|
||||
industry: initialIndustry || defaultState.industry,
|
||||
};
|
||||
// Use defaults
|
||||
return defaultState;
|
||||
});
|
||||
|
||||
// Update state when initial values change (preset clicked)
|
||||
useEffect(() => {
|
||||
if (initialKeywords || initialIndustry || initialTargetAudience || initialResearchMode || initialConfig) {
|
||||
localStorage.removeItem(WIZARD_STATE_KEY);
|
||||
setState({
|
||||
...defaultState,
|
||||
keywords: initialKeywords || [],
|
||||
industry: initialIndustry || defaultState.industry,
|
||||
targetAudience: initialTargetAudience || defaultState.targetAudience,
|
||||
researchMode: initialResearchMode || defaultState.researchMode,
|
||||
config: initialConfig || defaultState.config,
|
||||
results: null, // Clear any previous results
|
||||
});
|
||||
}
|
||||
}, [initialKeywords, initialIndustry, initialTargetAudience, initialResearchMode, initialConfig]);
|
||||
|
||||
// Persist state to localStorage
|
||||
useEffect(() => {
|
||||
if (state.currentStep > 1) {
|
||||
@@ -74,10 +105,13 @@ export const useResearchWizard = (initialKeywords?: string[], initialIndustry?:
|
||||
...defaultState,
|
||||
keywords: initialKeywords || [],
|
||||
industry: initialIndustry || defaultState.industry,
|
||||
targetAudience: initialTargetAudience || defaultState.targetAudience,
|
||||
researchMode: initialResearchMode || defaultState.researchMode,
|
||||
config: initialConfig || defaultState.config,
|
||||
};
|
||||
setState(resetState);
|
||||
localStorage.removeItem(WIZARD_STATE_KEY);
|
||||
}, [initialKeywords, initialIndustry]);
|
||||
}, [initialKeywords, initialIndustry, initialTargetAudience, initialResearchMode, initialConfig]);
|
||||
|
||||
const clearResults = useCallback(() => {
|
||||
setState(prev => ({ ...prev, results: null }));
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { WizardStepProps } from '../types/research.types';
|
||||
import { ResearchProvider } from '../../../services/blogWriterApi';
|
||||
import { ResearchProvider, ResearchMode } from '../../../services/blogWriterApi';
|
||||
import { getResearchConfig, ProviderAvailability } from '../../../api/researchConfig';
|
||||
import {
|
||||
getResearchHistory,
|
||||
clearResearchHistory,
|
||||
formatHistoryTimestamp,
|
||||
getHistorySummary,
|
||||
ResearchHistoryEntry
|
||||
} from '../../../utils/researchHistory';
|
||||
import {
|
||||
expandKeywords,
|
||||
formatKeyword,
|
||||
isOriginalKeyword
|
||||
} from '../../../utils/keywordExpansion';
|
||||
import {
|
||||
generateResearchAngles,
|
||||
formatAngle
|
||||
} from '../../../utils/researchAngles';
|
||||
|
||||
const industries = [
|
||||
'General',
|
||||
@@ -53,30 +70,365 @@ const exaSearchTypes = [
|
||||
{ value: 'neural', label: 'Neural - Semantic search' },
|
||||
];
|
||||
|
||||
// Dynamic placeholder examples showcasing research capabilities
|
||||
const placeholderExamples = [
|
||||
"AI-powered content marketing strategies for SaaS startups\n\nExplores:\n• Latest automation tools and platforms\n• ROI optimization techniques\n• Multi-channel campaign orchestration\n• Data-driven personalization strategies",
|
||||
"Sustainable supply chain management in manufacturing\n\nCovers:\n• Green logistics and carbon footprint reduction\n• Blockchain for transparency and traceability\n• Circular economy implementation frameworks\n• Real-time inventory optimization with AI",
|
||||
"Emerging trends in telemedicine and remote patient monitoring\n\nIncludes:\n• Wearable device integration and IoT sensors\n• HIPAA-compliant data transmission protocols\n• AI-assisted diagnostic accuracy improvements\n• Patient engagement and adherence strategies",
|
||||
"Cryptocurrency regulation and institutional adoption\n\nAnalyzes:\n• Global regulatory frameworks and compliance\n• Institutional investment trends (2024-2025)\n• DeFi integration with traditional finance\n• Risk management and security best practices",
|
||||
"Voice search optimization and conversational AI for e-commerce\n\nFeatures:\n• Natural language processing advancements\n• Smart speaker integration strategies\n• Voice-enabled checkout experiences\n• Personalization through voice analytics"
|
||||
];
|
||||
// Intelligent input parser - handles sentences, keywords, URLs
|
||||
const parseIntelligentInput = (value: string): string[] => {
|
||||
// If empty, return empty array
|
||||
if (!value.trim()) return [];
|
||||
|
||||
// Detect if input contains URLs
|
||||
const urlPattern = /(https?:\/\/[^\s,]+)/g;
|
||||
const urls = value.match(urlPattern) || [];
|
||||
|
||||
// Check if input looks like a sentence/paragraph (contains multiple words without commas)
|
||||
const hasCommas = value.includes(',');
|
||||
const wordCount = value.trim().split(/\s+/).length;
|
||||
|
||||
if (urls.length > 0) {
|
||||
// User provided URLs - extract them as separate keywords
|
||||
const textWithoutUrls = value.replace(urlPattern, '').trim();
|
||||
const textKeywords = textWithoutUrls ? [textWithoutUrls] : [];
|
||||
return [...urls, ...textKeywords];
|
||||
} else if (!hasCommas && wordCount > 5) {
|
||||
// Looks like a sentence/paragraph - treat entire input as single research topic
|
||||
return [value.trim()];
|
||||
} else if (hasCommas) {
|
||||
// Traditional comma-separated keywords
|
||||
return value.split(',').map(k => k.trim()).filter(Boolean);
|
||||
} else {
|
||||
// Short phrase or single keyword
|
||||
return [value.trim()];
|
||||
}
|
||||
};
|
||||
|
||||
// Industry-specific placeholder examples for personalized experience
|
||||
const getIndustryPlaceholders = (industry: string): string[] => {
|
||||
const industryExamples: Record<string, string[]> = {
|
||||
Healthcare: [
|
||||
"Research: AI-powered diagnostic tools in clinical practice\n\n💡 What you'll get:\n• FDA-approved AI medical devices\n• Clinical accuracy and patient outcomes\n• Implementation costs and ROI",
|
||||
"Analyze: Telemedicine adoption trends and patient satisfaction\n\n💡 Research includes:\n• Post-pandemic telehealth growth\n• Remote patient monitoring technologies\n• Insurance coverage and reimbursement",
|
||||
"Investigate: Personalized medicine and genomic testing advances\n\n💡 You'll discover:\n• Latest genomic sequencing technologies\n• Precision therapy success rates\n• Ethical considerations and regulations"
|
||||
],
|
||||
Technology: [
|
||||
"Investigate: Latest developments in edge computing and IoT\n\n💡 What you'll get:\n• Edge AI deployment strategies\n• 5G integration and performance\n• Industry use cases and benchmarks",
|
||||
"Compare: Cloud providers for enterprise SaaS applications\n\n💡 Research includes:\n• AWS vs Azure vs GCP feature comparison\n• Cost optimization strategies\n• Security and compliance certifications",
|
||||
"Analyze: Quantum computing breakthroughs and commercial applications\n\n💡 You'll discover:\n• Latest quantum hardware developments\n• Real-world problem solving examples\n• Investment landscape and timeline"
|
||||
],
|
||||
Finance: [
|
||||
"Research: DeFi regulatory landscape and compliance challenges\n\n💡 What you'll get:\n• Global regulatory frameworks\n• Compliance best practices\n• Risk management strategies",
|
||||
"Analyze: Digital banking customer retention strategies\n\n💡 Research includes:\n• Neobank growth and market share\n• Customer acquisition costs and LTV\n• Personalization and UX innovations",
|
||||
"Investigate: ESG investing trends and impact measurement\n\n💡 You'll discover:\n• ESG rating methodologies\n• Fund performance and returns\n• Regulatory requirements and reporting"
|
||||
],
|
||||
Marketing: [
|
||||
"Research: AI-powered marketing automation and personalization\n\n💡 What you'll get:\n• Top marketing AI platforms and features\n• ROI and conversion rate improvements\n• Implementation case studies",
|
||||
"Analyze: Influencer marketing ROI and authenticity trends\n\n💡 Research includes:\n• Micro vs macro influencer effectiveness\n• Platform-specific engagement rates\n• Brand partnership best practices",
|
||||
"Investigate: Privacy-first marketing in a cookieless world\n\n💡 You'll discover:\n• First-party data strategies\n• Contextual targeting innovations\n• Compliance with privacy regulations"
|
||||
],
|
||||
Business: [
|
||||
"Research: Remote work policies and hybrid workplace models\n\n💡 What you'll get:\n• Productivity metrics and employee satisfaction\n• Technology infrastructure requirements\n• Cultural impact and change management",
|
||||
"Analyze: Supply chain resilience and diversification strategies\n\n💡 Research includes:\n• Nearshoring and reshoring trends\n• Technology solutions for visibility\n• Risk mitigation frameworks",
|
||||
"Investigate: Sustainability initiatives and corporate ESG programs\n\n💡 You'll discover:\n• Industry-specific sustainability benchmarks\n• Cost-benefit analysis of green initiatives\n• Stakeholder communication strategies"
|
||||
],
|
||||
Education: [
|
||||
"Research: EdTech tools for personalized learning experiences\n\n💡 What you'll get:\n• Adaptive learning platform comparisons\n• Student engagement and outcomes data\n• Implementation costs and training needs",
|
||||
"Analyze: Microlearning and skill-based education trends\n\n💡 Research includes:\n• Corporate training effectiveness\n• Platform and content recommendations\n• ROI and completion rates",
|
||||
"Investigate: AI tutoring systems and student support tools\n\n💡 You'll discover:\n• Natural language processing advances\n• Student performance improvements\n• Accessibility and inclusion features"
|
||||
],
|
||||
'Real Estate': [
|
||||
"Research: PropTech innovations transforming property management\n\n💡 What you'll get:\n• Smart building technologies and IoT\n• Tenant experience platforms\n• Operational efficiency gains",
|
||||
"Analyze: Virtual staging and 3D property tours adoption\n\n💡 Research includes:\n• Technology provider comparisons\n• Impact on sales velocity and pricing\n• Cost vs traditional staging",
|
||||
"Investigate: Real estate tokenization and fractional ownership\n\n💡 You'll discover:\n• Blockchain platforms and regulations\n• Investor demographics and demand\n• Liquidity and exit strategies"
|
||||
],
|
||||
Travel: [
|
||||
"Research: Sustainable tourism trends and eco-travel preferences\n\n💡 What you'll get:\n• Green certification programs\n• Traveler willingness to pay premium\n• Destination best practices",
|
||||
"Analyze: AI-powered travel personalization and recommendations\n\n💡 Research includes:\n• Recommendation engine technologies\n• Booking conversion rate improvements\n• Customer lifetime value impact",
|
||||
"Investigate: Bleisure travel and workation destination trends\n\n💡 You'll discover:\n• Remote work-friendly destinations\n• Co-working and accommodation options\n• Digital nomad demographics"
|
||||
]
|
||||
};
|
||||
|
||||
return industryExamples[industry] || [
|
||||
"Research: Latest AI advancements in your industry\n\n💡 What you'll get:\n• Recent breakthroughs and innovations\n• Key companies and technologies\n• Expert insights and market trends",
|
||||
|
||||
"Write a blog on: Emerging trends shaping your industry in 2025\n\n💡 This will research:\n• Technology disruptions and innovations\n• Regulatory changes and compliance\n• Consumer behavior shifts",
|
||||
|
||||
"Analyze: Best practices and success stories in your field\n\n💡 Research includes:\n• Industry leader strategies\n• Implementation case studies\n• ROI and performance metrics",
|
||||
|
||||
"https://example.com/article\n\n💡 URL detected! Research will:\n• Extract key insights from the article\n• Find related sources and updates\n• Provide comprehensive context"
|
||||
];
|
||||
};
|
||||
|
||||
export const ResearchInput: React.FC<WizardStepProps> = ({ state, onUpdate }) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [currentPlaceholder, setCurrentPlaceholder] = useState(0);
|
||||
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability | null>(null);
|
||||
const [loadingConfig, setLoadingConfig] = useState(true);
|
||||
const [suggestedMode, setSuggestedMode] = useState<ResearchMode | null>(null);
|
||||
const [researchHistory, setResearchHistory] = useState<ResearchHistoryEntry[]>([]);
|
||||
const [keywordExpansion, setKeywordExpansion] = useState<{
|
||||
original: string[];
|
||||
expanded: string[];
|
||||
suggestions: string[];
|
||||
} | null>(null);
|
||||
const [researchAngles, setResearchAngles] = useState<string[]>([]);
|
||||
|
||||
// Load research history on mount and when component updates
|
||||
useEffect(() => {
|
||||
const history = getResearchHistory();
|
||||
setResearchHistory(history);
|
||||
}, []); // Load once on mount
|
||||
|
||||
// Reload history when keywords change (after research completes)
|
||||
useEffect(() => {
|
||||
const history = getResearchHistory();
|
||||
setResearchHistory(history);
|
||||
}, [state.keywords]);
|
||||
|
||||
// Load research configuration on mount
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const config = await getResearchConfig();
|
||||
|
||||
// Set provider availability with fallback
|
||||
setProviderAvailability(config?.provider_availability || {
|
||||
google_available: true, // Default to available, will be corrected by actual key status
|
||||
exa_available: false,
|
||||
gemini_key_status: 'missing',
|
||||
exa_key_status: 'missing'
|
||||
});
|
||||
|
||||
// Apply persona defaults if not already set (with null checks)
|
||||
if (config?.persona_defaults) {
|
||||
if (config.persona_defaults.industry && state.industry === 'General') {
|
||||
onUpdate({ industry: config.persona_defaults.industry });
|
||||
}
|
||||
if (config.persona_defaults.target_audience && state.targetAudience === 'General') {
|
||||
onUpdate({ targetAudience: config.persona_defaults.target_audience });
|
||||
}
|
||||
|
||||
// Apply suggested Exa domains if Exa is available and not already set
|
||||
if (config.provider_availability?.exa_available && config.persona_defaults.suggested_domains?.length > 0) {
|
||||
if (!state.config.exa_include_domains || state.config.exa_include_domains.length === 0) {
|
||||
onUpdate({
|
||||
config: {
|
||||
...state.config,
|
||||
exa_include_domains: config.persona_defaults.suggested_domains
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply suggested Exa category if available
|
||||
if (config.persona_defaults.suggested_exa_category && !state.config.exa_category) {
|
||||
onUpdate({
|
||||
config: {
|
||||
...state.config,
|
||||
exa_category: config.persona_defaults.suggested_exa_category
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('[ResearchInput] Failed to load research config:', errorMessage);
|
||||
|
||||
// Set default provider availability on error
|
||||
setProviderAvailability({
|
||||
google_available: true, // Optimistically assume available
|
||||
exa_available: false,
|
||||
gemini_key_status: 'missing',
|
||||
exa_key_status: 'missing'
|
||||
});
|
||||
|
||||
// Continue with defaults - don't block the UI
|
||||
} finally {
|
||||
setLoadingConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadConfig();
|
||||
}, []); // Only run once on mount
|
||||
|
||||
// Get industry-specific placeholders
|
||||
const placeholderExamples = getIndustryPlaceholders(state.industry);
|
||||
|
||||
// Rotate placeholder examples every 4 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentPlaceholder((prev) => (prev + 1) % placeholderExamples.length);
|
||||
}, 4000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [placeholderExamples.length]);
|
||||
|
||||
// Reset placeholder index when industry changes
|
||||
useEffect(() => {
|
||||
setCurrentPlaceholder(0);
|
||||
}, [state.industry]);
|
||||
|
||||
// Auto-set provider based on research mode
|
||||
useEffect(() => {
|
||||
if (!providerAvailability) return;
|
||||
|
||||
let newProvider: ResearchProvider = 'google';
|
||||
|
||||
switch (state.researchMode) {
|
||||
case 'basic':
|
||||
// Basic: Google only (fast, simple)
|
||||
newProvider = 'google';
|
||||
break;
|
||||
case 'comprehensive':
|
||||
// Comprehensive: Prefer Exa if available, fallback to Google
|
||||
newProvider = providerAvailability.exa_available ? 'exa' : 'google';
|
||||
break;
|
||||
case 'targeted':
|
||||
// Targeted: Prefer Exa if available, fallback to Google
|
||||
newProvider = providerAvailability.exa_available ? 'exa' : 'google';
|
||||
break;
|
||||
}
|
||||
|
||||
// Only update if provider changed
|
||||
if (state.config.provider !== newProvider) {
|
||||
onUpdate({ config: { ...state.config, provider: newProvider } });
|
||||
}
|
||||
}, [state.researchMode, providerAvailability]);
|
||||
|
||||
// Dynamic domain suggestions when industry changes
|
||||
useEffect(() => {
|
||||
if (!providerAvailability || state.industry === 'General') return;
|
||||
|
||||
// Get industry-specific domain suggestions (from backend logic)
|
||||
const domainMap: Record<string, string[]> = {
|
||||
'Healthcare': ['pubmed.gov', 'nejm.org', 'thelancet.com', 'nih.gov'],
|
||||
'Technology': ['techcrunch.com', 'wired.com', 'arstechnica.com', 'theverge.com'],
|
||||
'Finance': ['wsj.com', 'bloomberg.com', 'ft.com', 'reuters.com'],
|
||||
'Science': ['nature.com', 'sciencemag.org', 'cell.com', 'pnas.org'],
|
||||
'Business': ['hbr.org', 'forbes.com', 'businessinsider.com', 'mckinsey.com'],
|
||||
'Marketing': ['marketingland.com', 'adweek.com', 'hubspot.com', 'moz.com'],
|
||||
'Education': ['edutopia.org', 'chronicle.com', 'insidehighered.com'],
|
||||
'Real Estate': ['realtor.com', 'zillow.com', 'forbes.com'],
|
||||
'Entertainment': ['variety.com', 'hollywoodreporter.com', 'deadline.com'],
|
||||
'Travel': ['lonelyplanet.com', 'nationalgeographic.com', 'travelandleisure.com'],
|
||||
'Fashion': ['vogue.com', 'elle.com', 'wwd.com'],
|
||||
'Sports': ['espn.com', 'si.com', 'bleacherreport.com'],
|
||||
'Law': ['law.com', 'abajournal.com', 'scotusblog.com'],
|
||||
};
|
||||
|
||||
const newDomains = domainMap[state.industry] || [];
|
||||
|
||||
// Get industry-specific Exa category
|
||||
const categoryMap: Record<string, string> = {
|
||||
'Healthcare': 'research paper',
|
||||
'Science': 'research paper',
|
||||
'Finance': 'financial report',
|
||||
'Technology': 'company',
|
||||
'Business': 'company',
|
||||
'Marketing': 'company',
|
||||
'Education': 'research paper',
|
||||
'Law': 'pdf',
|
||||
};
|
||||
|
||||
const newCategory = categoryMap[state.industry];
|
||||
|
||||
// Only update if Exa is available and domains/category should change
|
||||
if (providerAvailability.exa_available && newDomains.length > 0) {
|
||||
const configUpdates: any = {};
|
||||
|
||||
// Update domains if different
|
||||
const currentDomains = state.config.exa_include_domains || [];
|
||||
if (JSON.stringify(currentDomains) !== JSON.stringify(newDomains)) {
|
||||
configUpdates.exa_include_domains = newDomains;
|
||||
}
|
||||
|
||||
// Update category if available and different
|
||||
if (newCategory && state.config.exa_category !== newCategory) {
|
||||
configUpdates.exa_category = newCategory;
|
||||
}
|
||||
|
||||
// Apply updates if any
|
||||
if (Object.keys(configUpdates).length > 0) {
|
||||
onUpdate({
|
||||
config: {
|
||||
...state.config,
|
||||
...configUpdates
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [state.industry, providerAvailability]);
|
||||
|
||||
// Smart mode suggestion based on query complexity
|
||||
const suggestResearchMode = (keywords: string[]): ResearchMode => {
|
||||
if (keywords.length === 0) return 'basic';
|
||||
|
||||
const totalText = keywords.join(' ');
|
||||
const totalWords = totalText.split(/\s+/).length;
|
||||
const hasURL = keywords.some(k => k.startsWith('http'));
|
||||
|
||||
// URL detected → comprehensive research
|
||||
if (hasURL) return 'comprehensive';
|
||||
|
||||
// Long detailed query → comprehensive
|
||||
if (totalWords > 20) return 'comprehensive';
|
||||
|
||||
// Medium complexity → targeted
|
||||
if (totalWords > 10 || keywords.length > 3) return 'targeted';
|
||||
|
||||
// Simple query → basic
|
||||
return 'basic';
|
||||
};
|
||||
|
||||
// Expand keywords when keywords or industry changes
|
||||
useEffect(() => {
|
||||
if (state.keywords.length > 0 && state.industry !== 'General') {
|
||||
const expansion = expandKeywords(state.keywords, state.industry);
|
||||
setKeywordExpansion(expansion);
|
||||
} else {
|
||||
setKeywordExpansion(null);
|
||||
}
|
||||
}, [state.keywords, state.industry]);
|
||||
|
||||
// Generate research angles when keywords change
|
||||
useEffect(() => {
|
||||
if (state.keywords.length > 0) {
|
||||
// Use the first keyword (or joined keywords) as the query
|
||||
const query = state.keywords.join(' ');
|
||||
const angles = generateResearchAngles(query, state.industry);
|
||||
setResearchAngles(angles);
|
||||
} else {
|
||||
setResearchAngles([]);
|
||||
}
|
||||
}, [state.keywords, state.industry]);
|
||||
|
||||
const handleKeywordsChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const keywords = value.split(',').map(k => k.trim()).filter(Boolean);
|
||||
const keywords = parseIntelligentInput(value);
|
||||
onUpdate({ keywords });
|
||||
|
||||
// Update suggested mode
|
||||
const suggested = suggestResearchMode(keywords);
|
||||
setSuggestedMode(suggested);
|
||||
};
|
||||
|
||||
// Handle clicking a keyword suggestion to add it
|
||||
const handleAddSuggestion = (suggestion: string) => {
|
||||
const currentKeywords = [...state.keywords];
|
||||
// Check if suggestion already exists (case-insensitive)
|
||||
const exists = currentKeywords.some(k => k.toLowerCase() === suggestion.toLowerCase());
|
||||
if (!exists) {
|
||||
currentKeywords.push(suggestion);
|
||||
onUpdate({ keywords: currentKeywords });
|
||||
}
|
||||
};
|
||||
|
||||
// Handle removing a keyword
|
||||
const handleRemoveKeyword = (keywordToRemove: string) => {
|
||||
const currentKeywords = state.keywords.filter(k => k.toLowerCase() !== keywordToRemove.toLowerCase());
|
||||
onUpdate({ keywords: currentKeywords });
|
||||
};
|
||||
|
||||
// Handle clicking a research angle to use it
|
||||
const handleUseAngle = (angle: string) => {
|
||||
// Parse the angle as a new research query
|
||||
const keywords = parseIntelligentInput(angle);
|
||||
onUpdate({ keywords });
|
||||
};
|
||||
|
||||
@@ -168,6 +520,129 @@ export const ResearchInput: React.FC<WizardStepProps> = ({ state, onUpdate }) =>
|
||||
Research Topic & Keywords
|
||||
</label>
|
||||
|
||||
{/* Research History */}
|
||||
{researchHistory.length > 0 && (
|
||||
<div style={{
|
||||
marginBottom: '12px',
|
||||
padding: '12px',
|
||||
background: 'rgba(14, 165, 233, 0.03)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.1)',
|
||||
borderRadius: '10px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#0369a1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}>
|
||||
<span>🕐</span>
|
||||
Recently Researched
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
clearResearchHistory();
|
||||
setResearchHistory([]);
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: '11px',
|
||||
color: '#64748b',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(100, 116, 139, 0.2)',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||
e.currentTarget.style.borderColor = 'rgba(239, 68, 68, 0.3)';
|
||||
e.currentTarget.style.color = '#dc2626';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.borderColor = 'rgba(100, 116, 139, 0.2)';
|
||||
e.currentTarget.style.color = '#64748b';
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
}}>
|
||||
{researchHistory.map((entry) => (
|
||||
<button
|
||||
key={entry.timestamp}
|
||||
onClick={() => {
|
||||
// Populate all fields from history entry
|
||||
onUpdate({
|
||||
keywords: entry.keywords,
|
||||
industry: entry.industry,
|
||||
targetAudience: entry.targetAudience,
|
||||
researchMode: entry.researchMode,
|
||||
});
|
||||
}}
|
||||
title={`Industry: ${entry.industry} | Audience: ${entry.targetAudience} | Mode: ${entry.researchMode} | ${formatHistoryTimestamp(entry.timestamp)}`}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
fontSize: '12px',
|
||||
color: '#0369a1',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
maxWidth: '100%',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(14, 165, 233, 0.1)';
|
||||
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.4)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(14, 165, 233, 0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.9)';
|
||||
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.2)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '14px' }}>🔍</span>
|
||||
<span style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '200px',
|
||||
}}>
|
||||
{getHistorySummary(entry)}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: '#64748b',
|
||||
marginLeft: '4px',
|
||||
}}>
|
||||
{formatHistoryTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<textarea
|
||||
value={state.keywords.join(', ')}
|
||||
@@ -239,13 +714,290 @@ export const ResearchInput: React.FC<WizardStepProps> = ({ state, onUpdate }) =>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Smart Input Detection Indicator */}
|
||||
{state.keywords.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: '10px',
|
||||
padding: '8px 12px',
|
||||
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.1) 0%, rgba(16, 185, 129, 0.1) 100%)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.2)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#059669',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}>
|
||||
<span>✓</span>
|
||||
{state.keywords[0]?.startsWith('http') ? (
|
||||
<span>URL detected - will extract and analyze content</span>
|
||||
) : state.keywords.length === 1 && state.keywords[0]?.split(/\s+/).length > 5 ? (
|
||||
<span>Research topic detected - will conduct comprehensive analysis</span>
|
||||
) : (
|
||||
<span>{state.keywords.length} keyword{state.keywords.length > 1 ? 's' : ''} identified</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyword Expansion Suggestions */}
|
||||
{keywordExpansion && keywordExpansion.suggestions.length > 0 && state.industry !== 'General' && (
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(147, 197, 253, 0.05) 100%)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.15)',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#1e40af',
|
||||
}}>
|
||||
<span>💡</span>
|
||||
<span>Suggested Keywords for {state.industry}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
}}>
|
||||
{keywordExpansion.suggestions.map((suggestion, idx) => {
|
||||
const isAlreadyAdded = state.keywords.some(k => k.toLowerCase() === suggestion.toLowerCase());
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => !isAlreadyAdded && handleAddSuggestion(suggestion)}
|
||||
disabled={isAlreadyAdded}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: isAlreadyAdded
|
||||
? 'rgba(203, 213, 225, 0.3)'
|
||||
: 'rgba(59, 130, 246, 0.1)',
|
||||
border: `1px solid ${isAlreadyAdded ? 'rgba(148, 163, 184, 0.3)' : 'rgba(59, 130, 246, 0.2)'}`,
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
color: isAlreadyAdded ? '#64748b' : '#1e40af',
|
||||
cursor: isAlreadyAdded ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isAlreadyAdded) {
|
||||
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.15)';
|
||||
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.3)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isAlreadyAdded) {
|
||||
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.1)';
|
||||
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.2)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isAlreadyAdded ? (
|
||||
<>
|
||||
<span>✓</span>
|
||||
<span>{formatKeyword(suggestion)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>+</span>
|
||||
<span>{formatKeyword(suggestion)}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
fontSize: '11px',
|
||||
color: '#64748b',
|
||||
fontStyle: 'italic',
|
||||
}}>
|
||||
Click to add suggested keywords to your research query
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Keywords Display (for removal) */}
|
||||
{state.keywords.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
padding: '10px',
|
||||
background: 'rgba(241, 245, 249, 0.5)',
|
||||
border: '1px solid rgba(203, 213, 225, 0.3)',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#475569',
|
||||
marginBottom: '8px',
|
||||
}}>
|
||||
Current Keywords ({state.keywords.length})
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6px',
|
||||
}}>
|
||||
{state.keywords.map((keyword, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
padding: '5px 10px',
|
||||
background: 'white',
|
||||
border: '1px solid rgba(203, 213, 225, 0.5)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#334155',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<span>{formatKeyword(keyword)}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveKeyword(keyword)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#ef4444',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
padding: '0',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'none';
|
||||
}}
|
||||
title="Remove keyword"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alternative Research Angles */}
|
||||
{researchAngles.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)',
|
||||
border: '1px solid rgba(168, 85, 247, 0.15)',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '16px',
|
||||
}}>💡</span>
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#7c3aed',
|
||||
}}>
|
||||
Explore Alternative Research Angles
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: '10px',
|
||||
}}>
|
||||
{researchAngles.map((angle, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleUseAngle(angle)}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
border: '1px solid rgba(168, 85, 247, 0.2)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
color: '#6b21a8',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
boxShadow: '0 1px 3px rgba(168, 85, 247, 0.1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(168, 85, 247, 0.1)';
|
||||
e.currentTarget.style.borderColor = 'rgba(168, 85, 247, 0.4)';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(168, 85, 247, 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.9)';
|
||||
e.currentTarget.style.borderColor = 'rgba(168, 85, 247, 0.2)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 1px 3px rgba(168, 85, 247, 0.1)';
|
||||
}}
|
||||
title={`Click to research: ${angle}`}
|
||||
>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}>
|
||||
<span style={{ fontSize: '14px' }}>🔍</span>
|
||||
<span>{formatAngle(angle)}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
fontSize: '11px',
|
||||
color: '#64748b',
|
||||
fontStyle: 'italic',
|
||||
}}>
|
||||
Click any angle to explore a different research focus
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: '10px',
|
||||
fontSize: '12px',
|
||||
color: '#64748b',
|
||||
lineHeight: '1.5',
|
||||
}}>
|
||||
💡 Tip: Describe your research topic in detail. Include specific keywords, questions, or aspects you want to explore. The AI will find relevant sources and insights.
|
||||
💡 Tip: Enter sentences, keywords, or URLs. The AI will intelligently parse your input and conduct comprehensive research.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -296,16 +1048,53 @@ export const ResearchInput: React.FC<WizardStepProps> = ({ state, onUpdate }) =>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Research Mode */}
|
||||
{/* Research Mode with Status Indicator */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#0c4a6e',
|
||||
}}>
|
||||
Research Depth
|
||||
<span>Research Depth</span>
|
||||
{providerAvailability && (
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '11px',
|
||||
color: '#64748b',
|
||||
background: 'rgba(255, 255, 255, 0.8)',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '20px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.15)',
|
||||
}}>
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: providerAvailability.google_available ? '#10b981' : '#ef4444',
|
||||
boxShadow: providerAvailability.google_available
|
||||
? '0 0 6px rgba(16, 185, 129, 0.5)'
|
||||
: '0 0 6px rgba(239, 68, 68, 0.5)',
|
||||
}} title={`Google: ${providerAvailability.gemini_key_status}`} />
|
||||
<span>Google</span>
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: providerAvailability.exa_available ? '#10b981' : '#ef4444',
|
||||
boxShadow: providerAvailability.exa_available
|
||||
? '0 0 6px rgba(16, 185, 129, 0.5)'
|
||||
: '0 0 6px rgba(239, 68, 68, 0.5)',
|
||||
marginLeft: '6px',
|
||||
}} title={`Exa: ${providerAvailability.exa_key_status}`} />
|
||||
<span>Exa</span>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<select
|
||||
value={state.researchMode}
|
||||
@@ -331,56 +1120,71 @@ export const ResearchInput: React.FC<WizardStepProps> = ({ state, onUpdate }) =>
|
||||
}}
|
||||
>
|
||||
{researchModes.map(mode => (
|
||||
<option key={mode.value} value={mode.value}>{mode.label}</option>
|
||||
<option key={mode.value} value={mode.value}>
|
||||
{mode.label}
|
||||
{mode.value === 'basic' && ' • Google Search'}
|
||||
{mode.value === 'comprehensive' && providerAvailability?.exa_available && ' • Exa Neural'}
|
||||
{mode.value === 'comprehensive' && !providerAvailability?.exa_available && ' • Google Search'}
|
||||
{mode.value === 'targeted' && providerAvailability?.exa_available && ' • Exa Neural'}
|
||||
{mode.value === 'targeted' && !providerAvailability?.exa_available && ' • Google Search'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Provider (only for Comprehensive/Targeted) */}
|
||||
{state.researchMode !== 'basic' && (
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#0c4a6e',
|
||||
}}>
|
||||
Search Provider
|
||||
</label>
|
||||
<select
|
||||
value={state.config.provider}
|
||||
onChange={handleProviderChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
fontSize: '13px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.5)';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(14, 165, 233, 0.1)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.2)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
{providers.map(prov => (
|
||||
<option key={prov.value} value={prov.value}>{prov.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{
|
||||
marginTop: '6px',
|
||||
fontSize: '11px',
|
||||
color: '#64748b',
|
||||
fontStyle: 'italic',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<span>
|
||||
{state.researchMode === 'basic' && '🔍 Fast research using Google Search'}
|
||||
{state.researchMode === 'comprehensive' && providerAvailability?.exa_available && '🧠 Deep research using Exa Neural Search'}
|
||||
{state.researchMode === 'comprehensive' && !providerAvailability?.exa_available && '🔍 In-depth research using Google Search'}
|
||||
{state.researchMode === 'targeted' && providerAvailability?.exa_available && '🎯 Focused research using Exa Neural Search'}
|
||||
{state.researchMode === 'targeted' && !providerAvailability?.exa_available && '🎯 Focused research using Google Search'}
|
||||
</span>
|
||||
{suggestedMode && suggestedMode !== state.researchMode && state.keywords.length > 0 && (
|
||||
<button
|
||||
onClick={() => onUpdate({ researchMode: suggestedMode })}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(16, 185, 129, 0.3)';
|
||||
}}
|
||||
title={`Switch to ${suggestedMode} mode for better results`}
|
||||
>
|
||||
<span>💡</span>
|
||||
<span>Try {suggestedMode}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exa-Specific Options */}
|
||||
{state.config.provider === 'exa' && state.researchMode !== 'basic' && (
|
||||
{/* Exa-Specific Options - Show when Exa is selected */}
|
||||
{state.config.provider === 'exa' && (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(139, 92, 246, 0.05) 0%, rgba(99, 102, 241, 0.05) 100%)',
|
||||
border: '1px solid rgba(139, 92, 246, 0.2)',
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface ResearchWizardProps {
|
||||
onCancel?: () => void;
|
||||
initialKeywords?: string[];
|
||||
initialIndustry?: string;
|
||||
initialTargetAudience?: string;
|
||||
initialResearchMode?: ResearchMode;
|
||||
initialConfig?: ResearchConfig;
|
||||
}
|
||||
|
||||
export interface ModeCardInfo {
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Execution Logs Table Component
|
||||
* Displays task execution logs in a table with pagination and filtering.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Visibility as VisibilityIcon
|
||||
} from '@mui/icons-material';
|
||||
import { getExecutionLogs, getRecentSchedulerLogs, ExecutionLog, ExecutionLogsResponse } from '../../api/schedulerDashboard';
|
||||
import {
|
||||
TerminalPaper,
|
||||
TerminalTypography,
|
||||
TerminalChipSuccess,
|
||||
TerminalChipError,
|
||||
TerminalChipWarning,
|
||||
TerminalTableCell,
|
||||
TerminalTableRow,
|
||||
TerminalAlert,
|
||||
terminalColors
|
||||
} from './terminalTheme';
|
||||
|
||||
interface ExecutionLogsTableProps {
|
||||
initialLimit?: number;
|
||||
}
|
||||
|
||||
const ExecutionLogsTable: React.FC<ExecutionLogsTableProps> = ({ initialLimit = 50 }) => {
|
||||
const [logs, setLogs] = useState<ExecutionLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(initialLimit);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [statusFilter, setStatusFilter] = useState<'success' | 'failed' | 'running' | 'skipped' | 'all'>('all');
|
||||
const [isShowingSchedulerLogs, setIsShowingSchedulerLogs] = useState(false);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// First, try to fetch actual execution logs
|
||||
const response = await getExecutionLogs(
|
||||
rowsPerPage,
|
||||
page * rowsPerPage,
|
||||
statusFilter === 'all' ? undefined : statusFilter
|
||||
);
|
||||
|
||||
console.log('📋 Execution Logs Response:', JSON.stringify({
|
||||
logsCount: response.logs?.length || 0,
|
||||
totalCount: response.total_count,
|
||||
hasLogs: !!(response.logs && response.logs.length > 0),
|
||||
isSchedulerLogs: response.is_scheduler_logs,
|
||||
firstLog: response.logs?.[0] || null
|
||||
}, null, 2));
|
||||
|
||||
// If we have actual execution logs, use them
|
||||
if (response.logs && response.logs.length > 0 && !response.is_scheduler_logs) {
|
||||
console.log('✅ Using execution logs:', response.logs.length);
|
||||
setLogs(response.logs);
|
||||
setTotalCount(response.total_count || 0);
|
||||
setIsShowingSchedulerLogs(false);
|
||||
} else {
|
||||
// No execution logs available, fetch scheduler logs as fallback (latest 5 only)
|
||||
console.log('📋 No execution logs found, fetching latest scheduler logs...');
|
||||
try {
|
||||
const schedulerLogsResponse = await getRecentSchedulerLogs();
|
||||
console.log('📋 Scheduler Logs Response:', JSON.stringify({
|
||||
logsCount: schedulerLogsResponse.logs?.length || 0,
|
||||
totalCount: schedulerLogsResponse.total_count,
|
||||
isSchedulerLogs: schedulerLogsResponse.is_scheduler_logs,
|
||||
allLogs: schedulerLogsResponse.logs || []
|
||||
}, null, 2));
|
||||
|
||||
if (schedulerLogsResponse.logs && schedulerLogsResponse.logs.length > 0) {
|
||||
console.log('✅ Setting scheduler logs:', schedulerLogsResponse.logs.length, 'logs');
|
||||
setLogs(schedulerLogsResponse.logs);
|
||||
setTotalCount(schedulerLogsResponse.total_count || 0);
|
||||
setIsShowingSchedulerLogs(true);
|
||||
} else {
|
||||
console.warn('⚠️ Scheduler logs response is empty');
|
||||
setLogs([]);
|
||||
setTotalCount(0);
|
||||
setIsShowingSchedulerLogs(false);
|
||||
}
|
||||
} catch (schedulerErr: any) {
|
||||
console.error('❌ Error fetching scheduler logs:', schedulerErr);
|
||||
setLogs([]);
|
||||
setTotalCount(0);
|
||||
setIsShowingSchedulerLogs(false);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch execution logs');
|
||||
console.error('❌ Error fetching execution logs:', err);
|
||||
|
||||
// Try to fetch scheduler logs as fallback even on error (latest 5 only)
|
||||
try {
|
||||
const schedulerLogsResponse = await getRecentSchedulerLogs();
|
||||
setLogs(schedulerLogsResponse.logs || []);
|
||||
setTotalCount(schedulerLogsResponse.total_count || 0);
|
||||
setIsShowingSchedulerLogs(true);
|
||||
} catch (schedulerErr: any) {
|
||||
console.error('❌ Error fetching scheduler logs:', schedulerErr);
|
||||
setLogs([]);
|
||||
setTotalCount(0);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, rowsPerPage, statusFilter]); // fetchLogs is stable, no need to include
|
||||
|
||||
const handleChangePage = (_event: unknown, newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircleIcon fontSize="small" color="success" />;
|
||||
case 'failed':
|
||||
return <ErrorIcon fontSize="small" color="error" />;
|
||||
case 'running':
|
||||
return <ScheduleIcon fontSize="small" color="primary" />;
|
||||
default:
|
||||
return <ScheduleIcon fontSize="small" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string): "success" | "error" | "warning" | "default" => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'success';
|
||||
case 'failed':
|
||||
return 'error';
|
||||
case 'running':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const formatExecutionTime = (ms: number | null) => {
|
||||
if (!ms) return 'N/A';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 3 }}>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<ScheduleIcon sx={{ color: terminalColors.primary }} />
|
||||
<TerminalTypography variant="h6" component="h2" sx={{ fontSize: '1.25rem', fontWeight: 'bold' }}>
|
||||
Execution Logs
|
||||
</TerminalTypography>
|
||||
{isShowingSchedulerLogs && (
|
||||
<TerminalChipWarning
|
||||
label="Showing Scheduler Logs"
|
||||
size="small"
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: 120,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
color: terminalColors.primary,
|
||||
'& fieldset': {
|
||||
borderColor: terminalColors.primary,
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: terminalColors.secondary,
|
||||
},
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: terminalColors.textSecondary,
|
||||
},
|
||||
'& .MuiSelect-icon': {
|
||||
color: terminalColors.primary,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
label="Status"
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value as any);
|
||||
setPage(0);
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
sx: {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
border: `1px solid ${terminalColors.primary}`,
|
||||
'& .MuiMenuItem-root': {
|
||||
color: terminalColors.primary,
|
||||
fontFamily: 'monospace',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.15)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="success">Success</MenuItem>
|
||||
<MenuItem value="failed">Failed</MenuItem>
|
||||
<MenuItem value="running">Running</MenuItem>
|
||||
<MenuItem value="skipped">Skipped</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Tooltip title="Refresh logs">
|
||||
<IconButton
|
||||
onClick={fetchLogs}
|
||||
size="small"
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
border: `1px solid ${terminalColors.primary}`,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<TerminalAlert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</TerminalAlert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" p={3}>
|
||||
<CircularProgress sx={{ color: terminalColors.primary }} />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{isShowingSchedulerLogs && (
|
||||
<TerminalAlert severity="info" sx={{ mb: 2 }}>
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.875rem' }}>
|
||||
Showing latest 5 scheduler activity logs (job scheduling, completion, failures).
|
||||
Historical execution logs are available in the Event History section below.
|
||||
</TerminalTypography>
|
||||
</TerminalAlert>
|
||||
)}
|
||||
<TableContainer
|
||||
sx={{
|
||||
backgroundColor: terminalColors.background,
|
||||
maxHeight: '600px',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
<Table size="small" sx={{ minWidth: 650 }}>
|
||||
<TableHead>
|
||||
<TerminalTableRow>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Task</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Status</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Execution Time</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Duration</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>User ID</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Date</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Error</TerminalTableCell>
|
||||
</TerminalTableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(() => {
|
||||
// Debug logging
|
||||
if (logs.length > 0) {
|
||||
console.log('🔍 Rendering logs table:', {
|
||||
logsCount: logs.length,
|
||||
loading,
|
||||
isShowingSchedulerLogs,
|
||||
firstLogId: logs[0]?.id,
|
||||
firstLogStatus: logs[0]?.status
|
||||
});
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
{logs.length === 0 && !loading ? (
|
||||
<TerminalTableRow>
|
||||
<TerminalTableCell colSpan={7} align="center">
|
||||
<Box sx={{ py: 4, textAlign: 'center' }}>
|
||||
<ScheduleIcon sx={{ color: terminalColors.textSecondary, fontSize: 48, mb: 2, opacity: 0.5 }} />
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.primary, mb: 1, fontWeight: 'bold' }}>
|
||||
{isShowingSchedulerLogs ? 'No Scheduler Logs Yet' : 'No Execution Logs Yet'}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary, mb: 1 }}>
|
||||
{isShowingSchedulerLogs
|
||||
? 'Scheduler activity logs (job scheduling, restoration, etc.) will appear here when the scheduler starts or schedules jobs.'
|
||||
: 'Execution logs will appear here once the scheduler runs and executes tasks.'}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem', fontStyle: 'italic', display: 'block' }}>
|
||||
{isShowingSchedulerLogs
|
||||
? 'These logs show scheduler activity (job restoration, scheduling) when actual task execution logs are not available.'
|
||||
: 'The scheduler checks for due tasks every 60 minutes (or based on active strategies).'}
|
||||
{!isShowingSchedulerLogs && totalCount === 0 && ' Currently, no tasks have been executed yet.'}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</TerminalTableCell>
|
||||
</TerminalTableRow>
|
||||
) : loading ? (
|
||||
<TerminalTableRow>
|
||||
<TerminalTableCell colSpan={7} align="center">
|
||||
<Box sx={{ py: 3, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 2 }}>
|
||||
<CircularProgress size={24} sx={{ color: terminalColors.primary }} />
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
|
||||
Loading execution logs...
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</TerminalTableCell>
|
||||
</TerminalTableRow>
|
||||
) : (
|
||||
logs.map((log) => {
|
||||
// Debug: log each row being rendered
|
||||
if (log.id === logs[0]?.id) {
|
||||
console.log('🎯 Rendering first log row:', log.id, log.status, log.task?.task_title);
|
||||
}
|
||||
return (
|
||||
<TerminalTableRow
|
||||
key={log.id}
|
||||
sx={{
|
||||
backgroundColor: terminalColors.background,
|
||||
color: terminalColors.primary,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TerminalTableCell sx={{ color: terminalColors.primary }}>
|
||||
<Box>
|
||||
<TerminalTypography variant="body2" fontWeight="medium" sx={{ fontSize: '0.875rem' }}>
|
||||
{log.is_scheduler_log
|
||||
? (log.task?.task_title || `Scheduler Event: ${log.event_type || 'unknown'}`)
|
||||
: (log.task?.task_title || `Task #${log.task_id}`)
|
||||
}
|
||||
</TerminalTypography>
|
||||
{log.is_scheduler_log && log.job_id && (
|
||||
<TerminalTypography variant="caption" sx={{ fontSize: '0.7rem', color: terminalColors.textSecondary, display: 'block', mt: 0.5 }}>
|
||||
Job ID: {log.job_id}
|
||||
</TerminalTypography>
|
||||
)}
|
||||
{!log.is_scheduler_log && log.task?.component_name && (
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
{log.task.component_name}
|
||||
</TerminalTypography>
|
||||
)}
|
||||
{log.is_scheduler_log && log.task?.metric && (
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Function: {log.task.metric}
|
||||
</TerminalTypography>
|
||||
)}
|
||||
</Box>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ color: terminalColors.primary }}>
|
||||
{log.status === 'success' ? (
|
||||
<TerminalChipSuccess
|
||||
icon={getStatusIcon(log.status)}
|
||||
label={log.status}
|
||||
size="small"
|
||||
/>
|
||||
) : log.status === 'failed' ? (
|
||||
<TerminalChipError
|
||||
icon={getStatusIcon(log.status)}
|
||||
label={log.status}
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<TerminalChipWarning
|
||||
icon={getStatusIcon(log.status)}
|
||||
label={log.status}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ color: terminalColors.primary }}>
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.875rem', color: terminalColors.primary }}>
|
||||
{formatExecutionTime(log.execution_time_ms)}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ color: terminalColors.primary }}>
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.875rem', color: terminalColors.primary }}>
|
||||
{log.execution_date ? formatDate(log.execution_date) : 'N/A'}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ color: terminalColors.primary }}>
|
||||
{log.user_id ? (
|
||||
<TerminalTypography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.875rem', color: terminalColors.primary }}>
|
||||
{String(log.user_id).substring(0, 12)}...
|
||||
</TerminalTypography>
|
||||
) : (
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }}>
|
||||
System
|
||||
</TerminalTypography>
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ color: terminalColors.primary }}>
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.875rem', color: terminalColors.primary }}>
|
||||
{formatDate(log.created_at)}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ color: terminalColors.primary }}>
|
||||
{log.error_message ? (
|
||||
<Tooltip title={log.error_message} arrow>
|
||||
<TerminalTypography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
color: terminalColors.error,
|
||||
maxWidth: 300,
|
||||
wordBreak: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
cursor: 'help'
|
||||
}}
|
||||
>
|
||||
{log.error_message}
|
||||
</TerminalTypography>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }}>
|
||||
-
|
||||
</TerminalTypography>
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
</TerminalTableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Only show pagination for actual execution logs, not scheduler logs */}
|
||||
{!isShowingSchedulerLogs && logs.length > 0 && (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
'& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': {
|
||||
color: terminalColors.textSecondary,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
'& .MuiTablePagination-select': {
|
||||
color: terminalColors.primary,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
'& .MuiIconButton-root': {
|
||||
color: terminalColors.primary,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
}
|
||||
},
|
||||
'& .MuiIconButton-root.Mui-disabled': {
|
||||
color: terminalColors.textSecondary,
|
||||
opacity: 0.3,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info message for scheduler logs */}
|
||||
{isShowingSchedulerLogs && logs.length > 0 && (
|
||||
<Box mt={2}>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem', fontStyle: 'italic' }}>
|
||||
Displaying latest 5 scheduler activity logs. Only the most recent logs are shown here.
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TerminalPaper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExecutionLogsTable;
|
||||
|
||||
297
frontend/src/components/SchedulerDashboard/FailuresInsights.tsx
Normal file
297
frontend/src/components/SchedulerDashboard/FailuresInsights.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Failures & Insights Component
|
||||
* Displays recent failures, error messages, and scheduler insights.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Divider,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon,
|
||||
Info as InfoIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
CheckCircle as CheckCircleIcon
|
||||
} from '@mui/icons-material';
|
||||
import { getExecutionLogs, getRecentSchedulerLogs, ExecutionLog } from '../../api/schedulerDashboard';
|
||||
import { SchedulerStats } from '../../api/schedulerDashboard';
|
||||
import {
|
||||
TerminalPaper,
|
||||
TerminalTypography,
|
||||
TerminalAlert,
|
||||
TerminalAccordion,
|
||||
terminalColors
|
||||
} from './terminalTheme';
|
||||
|
||||
interface FailuresInsightsProps {
|
||||
stats: SchedulerStats;
|
||||
}
|
||||
|
||||
const FailuresInsights: React.FC<FailuresInsightsProps> = ({ stats }) => {
|
||||
const [recentFailures, setRecentFailures] = useState<ExecutionLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFailures = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// First try to get execution logs with failed status
|
||||
const executionLogsResponse = await getExecutionLogs(10, 0, 'failed');
|
||||
|
||||
// Also get scheduler logs (which include job_failed events)
|
||||
const schedulerLogsResponse = await getRecentSchedulerLogs();
|
||||
|
||||
// Combine both, filtering for failed status
|
||||
const allFailures: ExecutionLog[] = [
|
||||
...executionLogsResponse.logs.filter(log => log.status === 'failed'),
|
||||
...(schedulerLogsResponse.logs || []).filter(log => log.status === 'failed')
|
||||
];
|
||||
|
||||
// Sort by execution_date descending (most recent first) and limit to 10
|
||||
allFailures.sort((a, b) => {
|
||||
const dateA = new Date(a.execution_date).getTime();
|
||||
const dateB = new Date(b.execution_date).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
setRecentFailures(allFailures.slice(0, 10));
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch failures');
|
||||
console.error('Error fetching failures:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFailures();
|
||||
}, []);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// Generate insights based on stats
|
||||
const generateInsights = () => {
|
||||
const insights: Array<{ type: 'info' | 'warning' | 'error' | 'success'; message: string }> = [];
|
||||
|
||||
// Scheduler status insight
|
||||
if (!stats.running) {
|
||||
insights.push({
|
||||
type: 'error',
|
||||
message: 'Scheduler is stopped. Tasks will not be executed until scheduler is restarted.'
|
||||
});
|
||||
} else {
|
||||
insights.push({
|
||||
type: 'success',
|
||||
message: 'Scheduler is running and processing tasks normally.'
|
||||
});
|
||||
}
|
||||
|
||||
// Active strategies insight
|
||||
if (stats.active_strategies_count === 0) {
|
||||
insights.push({
|
||||
type: 'info',
|
||||
message: `No active strategies detected. Using ${stats.max_check_interval_minutes}min check interval (idle mode).`
|
||||
});
|
||||
} else {
|
||||
insights.push({
|
||||
type: 'info',
|
||||
message: `${stats.active_strategies_count} active strategy(ies) with monitoring tasks. Using ${stats.min_check_interval_minutes}min check interval.`
|
||||
});
|
||||
}
|
||||
|
||||
// Failure rate insight
|
||||
const totalExecutions = stats.tasks_executed + stats.tasks_failed;
|
||||
if (totalExecutions > 0) {
|
||||
const failureRate = (stats.tasks_failed / totalExecutions) * 100;
|
||||
if (failureRate > 20) {
|
||||
insights.push({
|
||||
type: 'error',
|
||||
message: `High failure rate: ${failureRate.toFixed(1)}% of tasks are failing. Review error logs for details.`
|
||||
});
|
||||
} else if (failureRate > 10) {
|
||||
insights.push({
|
||||
type: 'warning',
|
||||
message: `Moderate failure rate: ${failureRate.toFixed(1)}% of tasks are failing. Monitor for patterns.`
|
||||
});
|
||||
} else if (stats.tasks_failed > 0) {
|
||||
insights.push({
|
||||
type: 'info',
|
||||
message: `Low failure rate: ${failureRate.toFixed(1)}% of tasks are failing. System is healthy.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check interval insight
|
||||
if (stats.intelligent_scheduling) {
|
||||
insights.push({
|
||||
type: 'success',
|
||||
message: `Intelligent scheduling enabled. Interval automatically adjusts based on active strategies (${stats.min_check_interval_minutes}-${stats.max_check_interval_minutes}min range).`
|
||||
});
|
||||
}
|
||||
|
||||
// Last check insight
|
||||
if (stats.last_check) {
|
||||
try {
|
||||
const lastCheck = new Date(stats.last_check);
|
||||
const now = new Date();
|
||||
const diffMins = Math.floor((now.getTime() - lastCheck.getTime()) / 60000);
|
||||
|
||||
if (diffMins > stats.check_interval_minutes * 2) {
|
||||
insights.push({
|
||||
type: 'warning',
|
||||
message: `Last check was ${diffMins} minutes ago. Expected interval is ${stats.check_interval_minutes} minutes. Scheduler may be delayed.`
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore date parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
return insights;
|
||||
};
|
||||
|
||||
const insights = generateInsights();
|
||||
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<InfoIcon sx={{ color: terminalColors.primary }} />
|
||||
<TerminalTypography variant="h6" component="h2" sx={{ fontSize: '1.25rem', fontWeight: 'bold' }}>
|
||||
Failures & Insights
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
|
||||
{/* Recent Failures */}
|
||||
<Box mb={3} sx={{ flexShrink: 0 }}>
|
||||
<TerminalTypography variant="subtitle1" fontWeight="medium" mb={1} sx={{ fontSize: '1rem', color: terminalColors.textSecondary }}>
|
||||
Recent Failures ({recentFailures.length})
|
||||
</TerminalTypography>
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" p={2}>
|
||||
<CircularProgress size={24} sx={{ color: terminalColors.primary }} />
|
||||
</Box>
|
||||
) : error ? (
|
||||
<TerminalAlert severity="error">{error}</TerminalAlert>
|
||||
) : recentFailures.length === 0 ? (
|
||||
<TerminalAlert severity="success" icon={<CheckCircleIcon />}>
|
||||
No recent failures. All tasks are executing successfully.
|
||||
</TerminalAlert>
|
||||
) : (
|
||||
<List>
|
||||
{recentFailures.map((log, index) => (
|
||||
<React.Fragment key={log.id}>
|
||||
<TerminalAccordion>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ color: terminalColors.primary }} />}
|
||||
sx={{
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.05)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={1} width="100%">
|
||||
<ErrorIcon sx={{ color: terminalColors.error }} fontSize="small" />
|
||||
<TerminalTypography variant="body2" sx={{ flexGrow: 1, fontSize: '0.875rem' }}>
|
||||
{log.task?.task_title || `Task #${log.task_id}`}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
{formatDate(log.execution_date)}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ backgroundColor: terminalColors.background }}>
|
||||
<Box>
|
||||
<TerminalTypography variant="body2" gutterBottom sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }}>
|
||||
<strong style={{ color: terminalColors.primary }}>Component:</strong> {log.task?.component_name || 'Unknown'}
|
||||
</TerminalTypography>
|
||||
{log.error_message && (
|
||||
<Box sx={{ mt: 1, p: 1, border: `1px solid ${terminalColors.error}`, borderRadius: 1, backgroundColor: terminalColors.backgroundLight }}>
|
||||
<TerminalTypography variant="body2" fontWeight="bold" gutterBottom sx={{ color: terminalColors.error, fontSize: '0.875rem' }}>
|
||||
Error Message
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.error, fontSize: '0.875rem', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
|
||||
{log.error_message}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
{log.execution_time_ms && (
|
||||
<TerminalTypography variant="caption" sx={{ mt: 1, display: 'block', color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Execution time: {log.execution_time_ms}ms
|
||||
</TerminalTypography>
|
||||
)}
|
||||
{log.user_id && (
|
||||
<TerminalTypography variant="caption" sx={{ mt: 0.5, display: 'block', color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
User ID: {log.user_id}
|
||||
</TerminalTypography>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</TerminalAccordion>
|
||||
{index < recentFailures.length - 1 && <Divider sx={{ borderColor: terminalColors.border }} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3, borderColor: terminalColors.border, flexShrink: 0 }} />
|
||||
|
||||
{/* Scheduler Insights */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', minHeight: 0, flexShrink: 1 }}>
|
||||
<TerminalTypography variant="subtitle1" fontWeight="medium" mb={1} sx={{ fontSize: '1rem', color: terminalColors.textSecondary }}>
|
||||
Scheduler Insights
|
||||
</TerminalTypography>
|
||||
<List>
|
||||
{insights.map((insight, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
{insight.type === 'error' && <ErrorIcon sx={{ color: terminalColors.error }} />}
|
||||
{insight.type === 'warning' && <WarningIcon sx={{ color: terminalColors.warning }} />}
|
||||
{insight.type === 'info' && <InfoIcon sx={{ color: terminalColors.info }} />}
|
||||
{insight.type === 'success' && <CheckCircleIcon sx={{ color: terminalColors.success }} />}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<TerminalTypography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
color: insight.type === 'error' ? terminalColors.error : terminalColors.text
|
||||
}}
|
||||
>
|
||||
{insight.message}
|
||||
</TerminalTypography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < insights.length - 1 && <Divider component="li" sx={{ borderColor: terminalColors.border }} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
};
|
||||
|
||||
export default FailuresInsights;
|
||||
|
||||
364
frontend/src/components/SchedulerDashboard/OAuthTokenStatus.tsx
Normal file
364
frontend/src/components/SchedulerDashboard/OAuthTokenStatus.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* OAuth Token Status Component
|
||||
* Compact terminal-themed component for displaying OAuth token monitoring status
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import {
|
||||
getOAuthTokenStatus,
|
||||
manualRefreshToken,
|
||||
OAuthTokenStatusResponse,
|
||||
ManualRefreshResponse,
|
||||
} from '../../api/oauthTokenMonitoring';
|
||||
import {
|
||||
TerminalPaper,
|
||||
TerminalTypography,
|
||||
TerminalChip,
|
||||
TerminalChipSuccess,
|
||||
TerminalChipError,
|
||||
TerminalChipWarning,
|
||||
TerminalAlert,
|
||||
terminalColors,
|
||||
} from './terminalTheme';
|
||||
|
||||
interface OAuthTokenStatusProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) => {
|
||||
const { userId } = useAuth();
|
||||
const [status, setStatus] = useState<OAuthTokenStatusResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedPlatform, setExpandedPlatform] = useState<string | null>(null);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await getOAuthTokenStatus(userId);
|
||||
setStatus(response);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch token status');
|
||||
console.error('Error fetching OAuth token status:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
|
||||
// Poll for status updates every 2 minutes
|
||||
const interval = setInterval(fetchStatus, 120000);
|
||||
return () => clearInterval(interval);
|
||||
}, [userId]);
|
||||
|
||||
const handleRefresh = async (platform: string) => {
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
setRefreshing(platform);
|
||||
setError(null);
|
||||
const response: ManualRefreshResponse = await manualRefreshToken(userId, platform);
|
||||
|
||||
// Refresh status after manual refresh
|
||||
await fetchStatus();
|
||||
|
||||
if (response.success) {
|
||||
console.log(`Token refresh successful for ${platform}`);
|
||||
} else {
|
||||
console.error(`Token refresh failed for ${platform}:`, response.data.execution_result.error_message);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || `Failed to refresh ${platform} token`);
|
||||
console.error(`Error refreshing ${platform} token:`, err);
|
||||
} finally {
|
||||
setRefreshing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (taskStatus: string | null, connected: boolean) => {
|
||||
if (!connected) {
|
||||
return <XCircle size={16} color={terminalColors.error} />;
|
||||
}
|
||||
|
||||
if (!taskStatus || taskStatus === 'not_created') {
|
||||
return <Info size={16} color={terminalColors.info} />;
|
||||
}
|
||||
|
||||
switch (taskStatus) {
|
||||
case 'active':
|
||||
return <CheckCircle size={16} color={terminalColors.success} />;
|
||||
case 'failed':
|
||||
return <XCircle size={16} color={terminalColors.error} />;
|
||||
case 'paused':
|
||||
return <AlertTriangle size={16} color={terminalColors.warning} />;
|
||||
default:
|
||||
return <Info size={16} color={terminalColors.primary} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusChip = (taskStatus: string | null, connected: boolean) => {
|
||||
if (!connected) {
|
||||
return <TerminalChipError label="Not Connected" size="small" />;
|
||||
}
|
||||
|
||||
if (!taskStatus || taskStatus === 'not_created') {
|
||||
return <TerminalChip label={taskStatus || 'Not Created'} size="small" />;
|
||||
}
|
||||
|
||||
switch (taskStatus) {
|
||||
case 'active':
|
||||
return <TerminalChipSuccess label="Active" size="small" />;
|
||||
case 'failed':
|
||||
return <TerminalChipError label="Failed" size="small" />;
|
||||
case 'paused':
|
||||
return <TerminalChipWarning label="Paused" size="small" />;
|
||||
default:
|
||||
return <TerminalChip label={taskStatus} size="small" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformDisplayName = (platform: string) => {
|
||||
const names: { [key: string]: string } = {
|
||||
gsc: 'GSC',
|
||||
bing: 'Bing',
|
||||
wordpress: 'WP',
|
||||
wix: 'Wix',
|
||||
};
|
||||
return names[platform] || platform.toUpperCase();
|
||||
};
|
||||
|
||||
if (loading && !status) {
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 2 }}>
|
||||
<Box display="flex" justifyContent="center" alignItems="center" p={2}>
|
||||
<CircularProgress size={20} sx={{ color: terminalColors.primary }} />
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const platforms = ['gsc', 'bing', 'wordpress', 'wix'];
|
||||
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<TerminalTypography variant="h6" component="h3">
|
||||
OAuth Token Status
|
||||
</TerminalTypography>
|
||||
<Tooltip title="Refresh status">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
border: `1px solid ${terminalColors.primary}`,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
},
|
||||
'&:disabled': {
|
||||
color: '#004400',
|
||||
borderColor: '#004400',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<TerminalAlert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</TerminalAlert>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||
<Table size="small" sx={{ '& .MuiTableCell-root': { color: terminalColors.primary, borderColor: terminalColors.primary + '40' } }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Platform</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Last Check</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{platforms.map((platform) => {
|
||||
const platformStatus = status.data.platform_status[platform];
|
||||
const task = platformStatus?.monitoring_task;
|
||||
const isExpanded = expandedPlatform === platform;
|
||||
|
||||
return (
|
||||
<React.Fragment key={platform}>
|
||||
<TableRow
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.05)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{getStatusIcon(task?.status || null, platformStatus?.connected || false)}
|
||||
<TerminalTypography variant="body2" fontWeight="medium">
|
||||
{getPlatformDisplayName(platform)}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusChip(task?.status || null, platformStatus?.connected || false)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TerminalTypography variant="caption" color={terminalColors.textSecondary}>
|
||||
{formatDate(task?.last_check || null)}
|
||||
</TerminalTypography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Box display="flex" gap={0.5} justifyContent="flex-end">
|
||||
<Tooltip title={isExpanded ? "Hide details" : "Show details"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setExpandedPlatform(isExpanded ? null : platform)}
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{platformStatus?.connected && (
|
||||
<Tooltip title="Manually refresh token">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleRefresh(platform)}
|
||||
disabled={refreshing === platform}
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
},
|
||||
'&:disabled': {
|
||||
color: '#004400',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{refreshing === platform ? (
|
||||
<CircularProgress size={14} sx={{ color: terminalColors.primary }} />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} sx={{ py: 0, border: 0 }}>
|
||||
<Collapse in={isExpanded}>
|
||||
<Box p={2} sx={{ backgroundColor: 'rgba(0, 255, 0, 0.05)', borderLeft: `2px solid ${terminalColors.primary}` }}>
|
||||
{task?.failure_reason && (
|
||||
<TerminalAlert severity="error" sx={{ mb: 1 }}>
|
||||
<TerminalTypography variant="body2" fontWeight="bold">
|
||||
Last Failure:
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="body2">
|
||||
{task.failure_reason}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" color={terminalColors.textSecondary}>
|
||||
{formatDate(task.last_failure || null)}
|
||||
</TerminalTypography>
|
||||
</TerminalAlert>
|
||||
)}
|
||||
{task?.last_success && (
|
||||
<TerminalAlert severity="success" sx={{ mb: 1 }}>
|
||||
<TerminalTypography variant="body2">
|
||||
Last successful: {formatDate(task.last_success)}
|
||||
</TerminalTypography>
|
||||
</TerminalAlert>
|
||||
)}
|
||||
{task?.next_check && (
|
||||
<Box mt={1}>
|
||||
<TerminalTypography variant="caption" color={terminalColors.textSecondary}>
|
||||
Next check: {formatDate(task.next_check)}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
{!task && platformStatus?.connected && (
|
||||
<TerminalAlert severity="info">
|
||||
<TerminalTypography variant="body2">
|
||||
Connected but no monitoring task. Create one manually or wait for onboarding completion.
|
||||
</TerminalTypography>
|
||||
</TerminalAlert>
|
||||
)}
|
||||
{!platformStatus?.connected && (
|
||||
<TerminalAlert severity="warning">
|
||||
<TerminalTypography variant="body2">
|
||||
Not connected. Connect in onboarding step 5.
|
||||
</TerminalTypography>
|
||||
</TerminalAlert>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthTokenStatus;
|
||||
|
||||
385
frontend/src/components/SchedulerDashboard/SchedulerCharts.tsx
Normal file
385
frontend/src/components/SchedulerDashboard/SchedulerCharts.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Scheduler Charts Component
|
||||
* Visualizes scheduler event history data using Recharts
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { Box, Paper, CircularProgress } from '@mui/material';
|
||||
import { TerminalTypography, TerminalPaper, terminalColors } from './terminalTheme';
|
||||
import { getSchedulerEventHistory, SchedulerEvent } from '../../api/schedulerDashboard';
|
||||
|
||||
interface SchedulerChartsProps {
|
||||
// Optional: can receive events as prop or fetch them internally
|
||||
events?: SchedulerEvent[];
|
||||
}
|
||||
|
||||
const SchedulerCharts: React.FC<SchedulerChartsProps> = ({ events: propEvents }) => {
|
||||
const [events, setEvents] = useState<SchedulerEvent[]>(propEvents || []);
|
||||
const [loading, setLoading] = useState(!propEvents);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch events if not provided as prop
|
||||
useEffect(() => {
|
||||
if (!propEvents) {
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Fetch all events for visualization (no pagination limit)
|
||||
// Pass undefined to get all event types
|
||||
console.log('📊 Charts - Fetching event history...');
|
||||
const response = await getSchedulerEventHistory(1000, 0, undefined);
|
||||
console.log('📊 Charts - Fetched events:', {
|
||||
totalEvents: response.events?.length || 0,
|
||||
totalCount: response.total_count,
|
||||
hasEvents: !!(response.events && response.events.length > 0),
|
||||
sampleEvent: response.events?.[0]
|
||||
});
|
||||
setEvents(response.events || []);
|
||||
} catch (err: any) {
|
||||
console.error('❌ Charts - Error fetching events:', err);
|
||||
console.error('❌ Charts - Error details:', {
|
||||
message: err?.message,
|
||||
response: err?.response,
|
||||
responseData: err?.response?.data,
|
||||
stack: err?.stack
|
||||
});
|
||||
const errorMessage = err?.response?.data?.detail || err?.response?.data?.message || err?.message || String(err) || 'Failed to fetch event history';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchEvents();
|
||||
}
|
||||
}, [propEvents]);
|
||||
|
||||
// Process events for charting
|
||||
const chartData = useMemo(() => {
|
||||
if (!events || events.length === 0) return [];
|
||||
|
||||
// Group events by date (day)
|
||||
const eventsByDate: Record<string, {
|
||||
date: string;
|
||||
check_cycles: number;
|
||||
tasks_found: number;
|
||||
tasks_executed: number;
|
||||
tasks_failed: number;
|
||||
job_scheduled: number;
|
||||
job_completed: number;
|
||||
job_failed: number;
|
||||
}> = {};
|
||||
|
||||
events.forEach(event => {
|
||||
const date = event.event_date ? new Date(event.event_date).toLocaleDateString() : 'Unknown';
|
||||
|
||||
if (!eventsByDate[date]) {
|
||||
eventsByDate[date] = {
|
||||
date,
|
||||
check_cycles: 0,
|
||||
tasks_found: 0,
|
||||
tasks_executed: 0,
|
||||
tasks_failed: 0,
|
||||
job_scheduled: 0,
|
||||
job_completed: 0,
|
||||
job_failed: 0,
|
||||
};
|
||||
}
|
||||
|
||||
switch (event.event_type) {
|
||||
case 'check_cycle':
|
||||
eventsByDate[date].check_cycles++;
|
||||
eventsByDate[date].tasks_found += event.tasks_found || 0;
|
||||
eventsByDate[date].tasks_executed += event.tasks_executed || 0;
|
||||
eventsByDate[date].tasks_failed += event.tasks_failed || 0;
|
||||
break;
|
||||
case 'job_scheduled':
|
||||
eventsByDate[date].job_scheduled++;
|
||||
break;
|
||||
case 'job_completed':
|
||||
eventsByDate[date].job_completed++;
|
||||
break;
|
||||
case 'job_failed':
|
||||
eventsByDate[date].job_failed++;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to array and sort by date
|
||||
return Object.values(eventsByDate).sort((a, b) => {
|
||||
return new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
}).slice(-30); // Last 30 days
|
||||
}, [events]);
|
||||
|
||||
// Calculate totals for summary
|
||||
const totals = useMemo(() => {
|
||||
return events.reduce((acc, event) => {
|
||||
switch (event.event_type) {
|
||||
case 'check_cycle':
|
||||
acc.check_cycles++;
|
||||
acc.tasks_found += event.tasks_found || 0;
|
||||
acc.tasks_executed += event.tasks_executed || 0;
|
||||
acc.tasks_failed += event.tasks_failed || 0;
|
||||
break;
|
||||
case 'job_scheduled':
|
||||
acc.job_scheduled++;
|
||||
break;
|
||||
case 'job_completed':
|
||||
acc.job_completed++;
|
||||
break;
|
||||
case 'job_failed':
|
||||
acc.job_failed++;
|
||||
break;
|
||||
}
|
||||
return acc;
|
||||
}, {
|
||||
check_cycles: 0,
|
||||
tasks_found: 0,
|
||||
tasks_executed: 0,
|
||||
tasks_failed: 0,
|
||||
job_scheduled: 0,
|
||||
job_completed: 0,
|
||||
job_failed: 0,
|
||||
});
|
||||
}, [events]);
|
||||
|
||||
// Custom tooltip with terminal theme
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
backgroundColor: terminalColors.background,
|
||||
border: `1px solid ${terminalColors.primary}`,
|
||||
padding: 1,
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.primary, fontWeight: 'bold', mb: 0.5 }}>
|
||||
{label}
|
||||
</TerminalTypography>
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<TerminalTypography
|
||||
key={index}
|
||||
variant="body2"
|
||||
sx={{ color: entry.color, fontSize: '0.75rem' }}
|
||||
>
|
||||
{entry.name}: {entry.value}
|
||||
</TerminalTypography>
|
||||
))}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 3, textAlign: 'center' }}>
|
||||
<CircularProgress sx={{ color: terminalColors.primary, mb: 2 }} />
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
|
||||
Loading chart data...
|
||||
</TerminalTypography>
|
||||
</TerminalPaper>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 3, textAlign: 'center' }}>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.error }}>
|
||||
Error loading charts: {error}
|
||||
</TerminalTypography>
|
||||
</TerminalPaper>
|
||||
);
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 3, textAlign: 'center' }}>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
|
||||
No event history data available yet. Charts will appear once scheduler events are logged.
|
||||
</TerminalTypography>
|
||||
</TerminalPaper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Summary Stats */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 2 }}>
|
||||
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<TerminalTypography variant="h6" sx={{ color: terminalColors.primary, fontSize: '1.5rem' }}>
|
||||
{totals.check_cycles}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Check Cycles
|
||||
</TerminalTypography>
|
||||
</TerminalPaper>
|
||||
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<TerminalTypography variant="h6" sx={{ color: terminalColors.primary, fontSize: '1.5rem' }}>
|
||||
{totals.tasks_executed}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Tasks Executed
|
||||
</TerminalTypography>
|
||||
</TerminalPaper>
|
||||
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<TerminalTypography variant="h6" sx={{ color: terminalColors.error, fontSize: '1.5rem' }}>
|
||||
{totals.tasks_failed}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Tasks Failed
|
||||
</TerminalTypography>
|
||||
</TerminalPaper>
|
||||
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<TerminalTypography variant="h6" sx={{ color: terminalColors.primary, fontSize: '1.5rem' }}>
|
||||
{totals.job_completed}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Jobs Completed
|
||||
</TerminalTypography>
|
||||
</TerminalPaper>
|
||||
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<TerminalTypography variant="h6" sx={{ color: terminalColors.error, fontSize: '1.5rem' }}>
|
||||
{totals.job_failed}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Jobs Failed
|
||||
</TerminalTypography>
|
||||
</TerminalPaper>
|
||||
</Box>
|
||||
|
||||
{/* Task Execution Trends */}
|
||||
<TerminalPaper sx={{ p: 3 }}>
|
||||
<TerminalTypography variant="h6" sx={{ mb: 2, color: terminalColors.primary }}>
|
||||
Task Execution Trends (Last 30 Days)
|
||||
</TerminalTypography>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
wrapperStyle={{ color: terminalColors.primary, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="tasks_found"
|
||||
stroke={terminalColors.info}
|
||||
strokeWidth={2}
|
||||
name="Tasks Found"
|
||||
dot={{ fill: terminalColors.info, r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="tasks_executed"
|
||||
stroke={terminalColors.success}
|
||||
strokeWidth={2}
|
||||
name="Tasks Executed"
|
||||
dot={{ fill: terminalColors.success, r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="tasks_failed"
|
||||
stroke={terminalColors.error}
|
||||
strokeWidth={2}
|
||||
name="Tasks Failed"
|
||||
dot={{ fill: terminalColors.error, r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</TerminalPaper>
|
||||
|
||||
{/* Job Status Distribution */}
|
||||
<TerminalPaper sx={{ p: 3 }}>
|
||||
<TerminalTypography variant="h6" sx={{ mb: 2, color: terminalColors.primary }}>
|
||||
Job Status Distribution (Last 30 Days)
|
||||
</TerminalTypography>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
wrapperStyle={{ color: terminalColors.primary, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="job_scheduled"
|
||||
fill={terminalColors.info}
|
||||
name="Scheduled"
|
||||
/>
|
||||
<Bar
|
||||
dataKey="job_completed"
|
||||
fill={terminalColors.success}
|
||||
name="Completed"
|
||||
/>
|
||||
<Bar
|
||||
dataKey="job_failed"
|
||||
fill={terminalColors.error}
|
||||
name="Failed"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</TerminalPaper>
|
||||
|
||||
{/* Check Cycles Over Time */}
|
||||
<TerminalPaper sx={{ p: 3 }}>
|
||||
<TerminalTypography variant="h6" sx={{ mb: 2, color: terminalColors.primary }}>
|
||||
Check Cycles Over Time (Last 30 Days)
|
||||
</TerminalTypography>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey="check_cycles"
|
||||
fill={terminalColors.primary}
|
||||
name="Check Cycles"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</TerminalPaper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchedulerCharts;
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Scheduler Event History Component
|
||||
* Displays historical scheduler events (check cycles, interval adjustments, etc.)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
Chip,
|
||||
Typography,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
TerminalPaper,
|
||||
TerminalTypography,
|
||||
TerminalChipSuccess,
|
||||
TerminalChipError,
|
||||
TerminalChipWarning,
|
||||
TerminalTableCell,
|
||||
TerminalTableRow,
|
||||
terminalColors
|
||||
} from './terminalTheme';
|
||||
import { getSchedulerEventHistory, SchedulerEvent } from '../../api/schedulerDashboard';
|
||||
|
||||
interface SchedulerEventHistoryProps {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const SchedulerEventHistory: React.FC<SchedulerEventHistoryProps> = ({ limit = 50 }) => {
|
||||
const [events, setEvents] = useState<SchedulerEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(limit);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [eventTypeFilter, setEventTypeFilter] = useState<string>('all');
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await getSchedulerEventHistory(
|
||||
rowsPerPage,
|
||||
page * rowsPerPage,
|
||||
eventTypeFilter !== 'all' ? eventTypeFilter as any : undefined
|
||||
);
|
||||
|
||||
setEvents(response.events);
|
||||
setTotalCount(response.total_count);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch scheduler event history');
|
||||
console.error('Error fetching scheduler event history:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, rowsPerPage, eventTypeFilter]); // fetchEvents is stable, no need to include
|
||||
|
||||
const handleChangePage = (_event: unknown, newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const getEventTypeColor = (eventType: string) => {
|
||||
switch (eventType) {
|
||||
case 'check_cycle':
|
||||
return terminalColors.success;
|
||||
case 'interval_adjustment':
|
||||
return terminalColors.warning;
|
||||
case 'start':
|
||||
return terminalColors.success;
|
||||
case 'stop':
|
||||
return terminalColors.error;
|
||||
case 'job_scheduled':
|
||||
return terminalColors.info;
|
||||
case 'job_completed':
|
||||
return terminalColors.success;
|
||||
case 'job_failed':
|
||||
return terminalColors.error;
|
||||
default:
|
||||
return terminalColors.info;
|
||||
}
|
||||
};
|
||||
|
||||
const formatEventDetails = (event: SchedulerEvent): string => {
|
||||
switch (event.event_type) {
|
||||
case 'check_cycle':
|
||||
return `Cycle #${event.check_cycle_number || 'N/A'} | ${event.tasks_found || 0} found, ${event.tasks_executed || 0} executed, ${event.tasks_failed || 0} failed | ${event.check_duration_seconds?.toFixed(2) || 'N/A'}s`;
|
||||
case 'interval_adjustment':
|
||||
return `${event.previous_interval_minutes || 'N/A'}min → ${event.new_interval_minutes || 'N/A'}min | ${event.active_strategies_count || 0} active strategies`;
|
||||
case 'start':
|
||||
return `Started with ${event.check_interval_minutes || 'N/A'}min interval | ${event.active_strategies_count || 0} active strategies`;
|
||||
case 'stop':
|
||||
return `Stopped gracefully | ${event.event_data?.total_checks || 0} total cycles`;
|
||||
case 'job_scheduled':
|
||||
const scheduledJob = event.event_data as any;
|
||||
return `Job: ${event.job_id || 'N/A'} | Function: ${scheduledJob?.function_name || 'N/A'} | User: ${event.user_id || 'system'}`;
|
||||
case 'job_completed':
|
||||
const completedJob = event.event_data as any;
|
||||
return `Job: ${event.job_id || 'N/A'} | Function: ${completedJob?.job_function || 'N/A'} | User: ${event.user_id || 'system'} | Time: ${completedJob?.execution_time_seconds?.toFixed(2) || 'N/A'}s`;
|
||||
case 'job_failed':
|
||||
const failedJob = event.event_data as any;
|
||||
const expensive = failedJob?.expensive_api_call ? '💰 Expensive API call wasted' : '';
|
||||
const errorMsg = event.error_message || failedJob?.exception_message || 'Unknown error';
|
||||
return `Job: ${event.job_id || 'N/A'} | Function: ${failedJob?.job_function || 'N/A'} | User: ${event.user_id || 'system'} | Error: ${errorMsg}${expensive ? ` | ${expensive}` : ''}`;
|
||||
default:
|
||||
return JSON.stringify(event.event_data || {});
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && events.length === 0) {
|
||||
return (
|
||||
<TerminalPaper>
|
||||
<Box p={3}>
|
||||
<TerminalTypography variant="h6" gutterBottom>
|
||||
📜 Scheduler Event History
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.info }}>
|
||||
Loading event history...
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<TerminalPaper>
|
||||
<Box p={3}>
|
||||
<TerminalTypography variant="h6" gutterBottom>
|
||||
📜 Scheduler Event History
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.error }}>
|
||||
Error: {error}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TerminalPaper>
|
||||
<Box p={2}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<TerminalTypography variant="h6">
|
||||
📜 Scheduler Event History
|
||||
</TerminalTypography>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel sx={{ color: terminalColors.primary }}>Event Type</InputLabel>
|
||||
<Select
|
||||
value={eventTypeFilter}
|
||||
onChange={(e) => {
|
||||
setEventTypeFilter(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: terminalColors.primary,
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
color: terminalColors.primary,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">All Events</MenuItem>
|
||||
<MenuItem value="check_cycle">Check Cycles</MenuItem>
|
||||
<MenuItem value="interval_adjustment">Interval Adjustments</MenuItem>
|
||||
<MenuItem value="start">Scheduler Start</MenuItem>
|
||||
<MenuItem value="stop">Scheduler Stop</MenuItem>
|
||||
<MenuItem value="job_scheduled">Job Scheduled</MenuItem>
|
||||
<MenuItem value="job_completed">Job Completed</MenuItem>
|
||||
<MenuItem value="job_failed">Job Failed</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{events.length === 0 ? (
|
||||
<Box p={3} textAlign="center">
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.info }}>
|
||||
No scheduler events found. Events will appear here as the scheduler runs.
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TerminalTableCell>Date</TerminalTableCell>
|
||||
<TerminalTableCell>Event Type</TerminalTableCell>
|
||||
<TerminalTableCell>Details</TerminalTableCell>
|
||||
{(events.some(e => e.event_type === 'job_failed' && e.error_message)) && (
|
||||
<TerminalTableCell>Error</TerminalTableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{events.map((event) => (
|
||||
<TerminalTableRow key={event.id}>
|
||||
<TerminalTableCell>
|
||||
<TerminalTypography variant="body2" fontSize="0.75rem">
|
||||
{formatDate(event.event_date)}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<Chip
|
||||
label={event.event_type}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: getEventTypeColor(event.event_type),
|
||||
color: '#000',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
/>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<TerminalTypography variant="body2" fontSize="0.75rem" sx={{
|
||||
color: getEventTypeColor(event.event_type),
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{formatEventDetails(event)}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
{event.event_type === 'job_failed' && event.error_message && (
|
||||
<TerminalTableCell>
|
||||
<Tooltip title={event.error_message} arrow>
|
||||
<TerminalTypography variant="body2" fontSize="0.7rem" sx={{
|
||||
color: terminalColors.error,
|
||||
fontFamily: 'monospace',
|
||||
maxWidth: '300px',
|
||||
wordBreak: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical'
|
||||
}}>
|
||||
{event.error_message}
|
||||
</TerminalTypography>
|
||||
</Tooltip>
|
||||
</TerminalTableCell>
|
||||
)}
|
||||
{event.event_type !== 'job_failed' && events.some(e => e.event_type === 'job_failed' && e.error_message) && (
|
||||
<TerminalTableCell></TerminalTableCell>
|
||||
)}
|
||||
</TerminalTableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
'& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': {
|
||||
color: terminalColors.primary,
|
||||
},
|
||||
'& .MuiIconButton-root': {
|
||||
color: terminalColors.primary,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchedulerEventHistory;
|
||||
|
||||
272
frontend/src/components/SchedulerDashboard/SchedulerJobsTree.tsx
Normal file
272
frontend/src/components/SchedulerDashboard/SchedulerJobsTree.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Scheduler Jobs Tree Component
|
||||
* Displays scheduled jobs in tree structure matching log format.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import {
|
||||
Schedule as ScheduleIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Event as EventIcon,
|
||||
Person as PersonIcon,
|
||||
Storage as StorageIcon
|
||||
} from '@mui/icons-material';
|
||||
import { SchedulerJob } from '../../api/schedulerDashboard';
|
||||
import { TerminalPaper, TerminalTypography, TerminalChip, terminalColors } from './terminalTheme';
|
||||
|
||||
interface SchedulerJobsTreeProps {
|
||||
jobs: SchedulerJob[];
|
||||
recurringJobs: number;
|
||||
oneTimeJobs: number;
|
||||
}
|
||||
|
||||
const SchedulerJobsTree: React.FC<SchedulerJobsTreeProps> = ({
|
||||
jobs,
|
||||
recurringJobs,
|
||||
oneTimeJobs
|
||||
}) => {
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Not scheduled';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const getJobTypeIcon = (jobId: string) => {
|
||||
if (jobId === 'check_due_tasks') {
|
||||
return <RefreshIcon fontSize="small" />;
|
||||
}
|
||||
return <EventIcon fontSize="small" />;
|
||||
};
|
||||
|
||||
const getJobTypeLabel = (jobId: string, job?: SchedulerJob) => {
|
||||
if (jobId === 'check_due_tasks') {
|
||||
return 'Recurring';
|
||||
}
|
||||
if (jobId.includes('research_persona')) {
|
||||
return 'Research Persona';
|
||||
}
|
||||
if (jobId.includes('facebook_persona')) {
|
||||
return 'Facebook Persona';
|
||||
}
|
||||
if (jobId.includes('oauth_token_monitoring')) {
|
||||
// Extract platform from job ID or use platform field
|
||||
const platform = job?.platform ||
|
||||
jobId.split('_')[2] ||
|
||||
'OAuth';
|
||||
const platformNames: { [key: string]: string } = {
|
||||
'gsc': 'GSC',
|
||||
'bing': 'Bing',
|
||||
'wordpress': 'WordPress',
|
||||
'wix': 'Wix'
|
||||
};
|
||||
return `OAuth ${platformNames[platform] || platform.toUpperCase()}`;
|
||||
}
|
||||
return 'One-Time';
|
||||
};
|
||||
|
||||
const getJobTypeColor = (jobId: string) => {
|
||||
if (jobId === 'check_due_tasks') {
|
||||
return 'primary';
|
||||
}
|
||||
return 'secondary';
|
||||
};
|
||||
|
||||
// Separate recurring and one-time jobs
|
||||
const recurringJob = jobs.find(j => j.id === 'check_due_tasks');
|
||||
const oneTimeJobsList = jobs.filter(j => j.id !== 'check_due_tasks');
|
||||
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<ScheduleIcon sx={{ color: terminalColors.primary }} />
|
||||
<TerminalTypography variant="h6" component="h2" sx={{ fontSize: '1.25rem', fontWeight: 'bold' }}>
|
||||
Scheduled Jobs
|
||||
</TerminalTypography>
|
||||
<TerminalChip
|
||||
label={`${jobs.length} total`}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ fontFamily: 'monospace', fontSize: '0.875rem', color: terminalColors.text, flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||
{/* Header */}
|
||||
<Box mb={2} sx={{ flexShrink: 0 }}>
|
||||
<TerminalTypography variant="body2" sx={{ mb: 1, color: terminalColors.textSecondary }}>
|
||||
Recurring Jobs: {recurringJobs} | One-Time Jobs: {oneTimeJobs}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
|
||||
{/* Jobs Tree */}
|
||||
{jobs.length > 0 ? (
|
||||
<Box sx={{ flex: 1 }}>
|
||||
{jobs.map((job, index) => {
|
||||
const isLast = index === jobs.length - 1;
|
||||
const prefix = isLast ? '└─' : '├─';
|
||||
const isRecurring = job.id === 'check_due_tasks';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={job.id}
|
||||
sx={{
|
||||
mb: 2,
|
||||
display: 'block',
|
||||
borderLeft: `2px solid ${terminalColors.border}`,
|
||||
pl: 2,
|
||||
py: 1
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="flex-start"
|
||||
gap={1.5}
|
||||
flexWrap="wrap"
|
||||
sx={{
|
||||
width: '100%',
|
||||
minHeight: '50px',
|
||||
}}
|
||||
>
|
||||
{/* Tree prefix and chip */}
|
||||
<Box display="flex" alignItems="center" gap={1} sx={{ flexShrink: 0 }}>
|
||||
<TerminalTypography component="span" sx={{ fontFamily: 'monospace', color: terminalColors.primary, fontSize: '1.2rem' }}>
|
||||
{prefix}
|
||||
</TerminalTypography>
|
||||
<TerminalChip
|
||||
icon={getJobTypeIcon(job.id)}
|
||||
label={getJobTypeLabel(job.id, job)}
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Job details */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center', mb: 0.5 }}>
|
||||
<TerminalTypography
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: terminalColors.primary,
|
||||
wordBreak: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
>
|
||||
{job.id}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, alignItems: 'center', mt: 0.5 }}>
|
||||
<TerminalTypography
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: terminalColors.textSecondary,
|
||||
fontSize: '0.8rem',
|
||||
wordBreak: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal'
|
||||
}}
|
||||
>
|
||||
Trigger: {job.trigger_type}
|
||||
</TerminalTypography>
|
||||
{job.next_run_time && (
|
||||
<TerminalTypography
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: terminalColors.textSecondary,
|
||||
fontSize: '0.8rem',
|
||||
wordBreak: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal'
|
||||
}}
|
||||
>
|
||||
Next Run: {formatDate(job.next_run_time)}
|
||||
</TerminalTypography>
|
||||
)}
|
||||
{job.user_id && (
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
<PersonIcon fontSize="small" sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }} />
|
||||
<TerminalTypography
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: terminalColors.textSecondary,
|
||||
fontSize: '0.8rem',
|
||||
wordBreak: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal'
|
||||
}}
|
||||
>
|
||||
User: {String(job.user_id)}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
{job.platform && (
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
<TerminalTypography
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: terminalColors.primary,
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 'bold',
|
||||
wordBreak: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal'
|
||||
}}
|
||||
>
|
||||
Platform: {job.platform.toUpperCase()}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
{job.user_job_store && job.user_job_store !== 'default' && (
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
<StorageIcon fontSize="small" sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }} />
|
||||
<TerminalTypography
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: terminalColors.textSecondary,
|
||||
fontSize: '0.8rem',
|
||||
wordBreak: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal'
|
||||
}}
|
||||
>
|
||||
Store: {job.user_job_store}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
) : (
|
||||
<TerminalTypography variant="body2" sx={{ fontStyle: 'italic', color: terminalColors.textSecondary }}>
|
||||
No jobs scheduled
|
||||
</TerminalTypography>
|
||||
)}
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchedulerJobsTree;
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Scheduler Stats Cards Component
|
||||
* Displays scheduler metrics in card format.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Grid, Typography, Box } from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
PlayArrow as PlayArrowIcon,
|
||||
Pause as PauseIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
AccessTime as AccessTimeIcon
|
||||
} from '@mui/icons-material';
|
||||
import { SchedulerStats } from '../../api/schedulerDashboard';
|
||||
import { TerminalCard, TerminalCardContent, TerminalTypography, TerminalChip, TerminalChipSuccess, TerminalChipError, terminalColors } from './terminalTheme';
|
||||
|
||||
interface SchedulerStatsCardsProps {
|
||||
stats: SchedulerStats;
|
||||
}
|
||||
|
||||
const SchedulerStatsCards: React.FC<SchedulerStatsCardsProps> = ({ stats }) => {
|
||||
// Debug: Only log if cumulative values are actually present (not just 0 from defaults)
|
||||
// Suppress logging when all cumulative values are 0 to reduce console noise
|
||||
if (stats.cumulative_total_check_cycles !== undefined) {
|
||||
const hasCumulativeData = stats.cumulative_total_check_cycles > 0 ||
|
||||
stats.cumulative_tasks_found > 0 ||
|
||||
stats.cumulative_tasks_executed > 0;
|
||||
|
||||
// Only log if there's actual cumulative data or if this is the first render
|
||||
if (hasCumulativeData || stats.total_checks > 0) {
|
||||
console.log('📊 StatsCards received stats:', {
|
||||
total_checks: stats.total_checks,
|
||||
cumulative_total_check_cycles: stats.cumulative_total_check_cycles,
|
||||
cumulative_tasks_found: stats.cumulative_tasks_found,
|
||||
cumulative_tasks_executed: stats.cumulative_tasks_executed,
|
||||
cumulative_tasks_failed: stats.cumulative_tasks_failed,
|
||||
has_cumulative_data: hasCumulativeData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (running: boolean) => {
|
||||
return running ? 'success' : 'error';
|
||||
};
|
||||
|
||||
const getStatusIcon = (running: boolean) => {
|
||||
return running ? <PlayArrowIcon /> : <PauseIcon />;
|
||||
};
|
||||
|
||||
const formatTime = (minutes: number) => {
|
||||
if (minutes >= 60) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: 'Scheduler Status',
|
||||
value: stats.running ? 'Running' : 'Stopped',
|
||||
icon: getStatusIcon(stats.running),
|
||||
color: getStatusColor(stats.running),
|
||||
subtitle: stats.running ? 'Active' : 'Inactive'
|
||||
},
|
||||
{
|
||||
title: 'Total Check Cycles',
|
||||
value: (stats.cumulative_total_check_cycles !== undefined && stats.cumulative_total_check_cycles !== null)
|
||||
? stats.cumulative_total_check_cycles.toLocaleString()
|
||||
: stats.total_checks.toLocaleString(),
|
||||
icon: <CheckCircleIcon />,
|
||||
color: 'primary' as const,
|
||||
subtitle: (stats.cumulative_total_check_cycles !== undefined && stats.cumulative_total_check_cycles !== null && stats.cumulative_total_check_cycles > 0)
|
||||
? `${stats.total_checks.toLocaleString()} this session (${stats.cumulative_total_check_cycles.toLocaleString()} total)`
|
||||
: stats.total_checks === 0
|
||||
? 'No cycles yet (scheduler waiting)'
|
||||
: 'Since startup'
|
||||
},
|
||||
{
|
||||
title: 'Tasks Executed',
|
||||
value: (stats.cumulative_tasks_executed !== undefined && stats.cumulative_tasks_executed !== null)
|
||||
? stats.cumulative_tasks_executed.toLocaleString()
|
||||
: stats.tasks_executed.toLocaleString(),
|
||||
icon: <TrendingUpIcon />,
|
||||
color: 'success' as const,
|
||||
subtitle: (stats.cumulative_tasks_executed !== undefined && stats.cumulative_tasks_executed !== null && stats.cumulative_tasks_executed > 0)
|
||||
? `${stats.tasks_executed.toLocaleString()} this session (${stats.cumulative_tasks_executed.toLocaleString()} total)`
|
||||
: stats.tasks_executed === 0
|
||||
? 'No tasks executed yet'
|
||||
: `${stats.tasks_failed > 0 ? `${stats.tasks_failed} failed` : 'All successful'}`
|
||||
},
|
||||
{
|
||||
title: 'Tasks Found',
|
||||
value: (stats.cumulative_tasks_found !== undefined && stats.cumulative_tasks_found !== null)
|
||||
? stats.cumulative_tasks_found.toLocaleString()
|
||||
: stats.tasks_found.toLocaleString(),
|
||||
icon: <ScheduleIcon />,
|
||||
color: 'info' as const,
|
||||
subtitle: (stats.cumulative_tasks_found !== undefined && stats.cumulative_tasks_found !== null && stats.cumulative_tasks_found > 0)
|
||||
? `${stats.tasks_found.toLocaleString()} this session (${stats.cumulative_tasks_found.toLocaleString()} total)`
|
||||
: stats.tasks_found === 0
|
||||
? 'No tasks scheduled yet'
|
||||
: `${stats.tasks_executed} executed, ${stats.tasks_failed} failed`
|
||||
},
|
||||
{
|
||||
title: 'Check Interval',
|
||||
value: formatTime(stats.check_interval_minutes),
|
||||
icon: <AccessTimeIcon />,
|
||||
color: 'secondary' as const,
|
||||
subtitle: stats.intelligent_scheduling
|
||||
? `Intelligent (${stats.active_strategies_count > 0 ? '15min' : '60min'} range)`
|
||||
: 'Fixed interval'
|
||||
},
|
||||
{
|
||||
title: 'Active Strategies',
|
||||
value: stats.active_strategies_count.toString(),
|
||||
icon: <TrendingUpIcon />,
|
||||
color: stats.active_strategies_count > 0 ? 'success' : 'default' as const,
|
||||
subtitle: stats.active_strategies_count > 0
|
||||
? 'With monitoring tasks'
|
||||
: 'No active strategies'
|
||||
}
|
||||
];
|
||||
|
||||
const getCardIconColor = (cardColor: string) => {
|
||||
switch (cardColor) {
|
||||
case 'success':
|
||||
return terminalColors.success;
|
||||
case 'error':
|
||||
return terminalColors.error;
|
||||
case 'primary':
|
||||
return terminalColors.primary;
|
||||
case 'info':
|
||||
return terminalColors.info;
|
||||
case 'secondary':
|
||||
return terminalColors.secondary;
|
||||
default:
|
||||
return terminalColors.text;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
{cards.map((card, index) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={index}>
|
||||
<TerminalCard sx={{ height: '100%' }}>
|
||||
<TerminalCardContent>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
borderRadius: '4px',
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
border: `1px solid ${getCardIconColor(card.color)}`,
|
||||
color: getCardIconColor(card.color),
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
{card.icon}
|
||||
</Box>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
|
||||
{card.title}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</Box>
|
||||
<TerminalTypography variant="h4" component="div" sx={{ fontWeight: 600, mb: 0.5, fontSize: '1.75rem', color: terminalColors.primary }}>
|
||||
{card.value}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }}>
|
||||
{card.subtitle}
|
||||
</TerminalTypography>
|
||||
{card.title === 'Scheduler Status' && stats.last_check && (
|
||||
<TerminalTypography variant="caption" sx={{ mt: 1, display: 'block', color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Last check: {formatDate(stats.last_check)}
|
||||
</TerminalTypography>
|
||||
)}
|
||||
</TerminalCardContent>
|
||||
</TerminalCard>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchedulerStatsCards;
|
||||
|
||||
187
frontend/src/components/SchedulerDashboard/terminalTheme.ts
Normal file
187
frontend/src/components/SchedulerDashboard/terminalTheme.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Terminal Theme Styling
|
||||
* Shared terminal-themed styles for scheduler dashboard components
|
||||
*/
|
||||
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { Box, Paper, Card, CardContent, Typography, Chip, TableCell, TableRow, Alert, Accordion } from '@mui/material';
|
||||
|
||||
export const TerminalPaper = styled(Paper)({
|
||||
backgroundColor: '#0a0a0a',
|
||||
border: '1px solid #00ff00',
|
||||
color: '#00ff00',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
padding: 16,
|
||||
minHeight: '200px', // Ensure minimum height for visibility
|
||||
'& *': {
|
||||
fontFamily: 'inherit',
|
||||
color: 'inherit', // Ensure all text inherits the green color
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalCard = styled(Card)({
|
||||
backgroundColor: '#0a0a0a',
|
||||
border: '1px solid #00ff00',
|
||||
color: '#00ff00',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
transition: 'all 0.2s',
|
||||
minHeight: '120px', // Ensure cards have minimum height
|
||||
'&:hover': {
|
||||
borderColor: '#00ff88',
|
||||
boxShadow: '0 0 15px rgba(0, 255, 0, 0.3)',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
'& *': {
|
||||
fontFamily: 'inherit',
|
||||
color: 'inherit', // Ensure all text inherits the green color
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalCardContent = styled(CardContent)({
|
||||
color: '#00ff00',
|
||||
'&:last-child': {
|
||||
paddingBottom: 16,
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalTypography = styled(Typography)<{ component?: React.ElementType }>(({ theme }) => ({
|
||||
color: '#00ff00',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
}));
|
||||
|
||||
export const TerminalChip = styled(Chip)({
|
||||
backgroundColor: '#1a1a1a',
|
||||
color: '#00ff00',
|
||||
border: '1px solid #00ff00',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
fontSize: '0.75rem',
|
||||
'& .MuiChip-label': {
|
||||
padding: '4px 8px',
|
||||
},
|
||||
'& .MuiChip-icon': {
|
||||
color: '#00ff00',
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalChipSuccess = styled(Chip)({
|
||||
backgroundColor: '#0a2a0a',
|
||||
color: '#00ff00',
|
||||
border: '1px solid #00ff00',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
fontSize: '0.75rem',
|
||||
'& .MuiChip-label': {
|
||||
padding: '4px 8px',
|
||||
},
|
||||
'& .MuiChip-icon': {
|
||||
color: '#00ff00',
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalChipError = styled(Chip)({
|
||||
backgroundColor: '#2a0a0a',
|
||||
color: '#ff4444',
|
||||
border: '1px solid #ff4444',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
fontSize: '0.75rem',
|
||||
'& .MuiChip-label': {
|
||||
padding: '4px 8px',
|
||||
},
|
||||
'& .MuiChip-icon': {
|
||||
color: '#ff4444',
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalChipWarning = styled(Chip)({
|
||||
backgroundColor: '#2a2a0a',
|
||||
color: '#ffd700',
|
||||
border: '1px solid #ffd700',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
fontSize: '0.75rem',
|
||||
'& .MuiChip-label': {
|
||||
padding: '4px 8px',
|
||||
},
|
||||
'& .MuiChip-icon': {
|
||||
color: '#ffd700',
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalTableCell = styled(TableCell)({
|
||||
color: '#00ff00',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
borderColor: '#004400',
|
||||
fontSize: '0.875rem',
|
||||
});
|
||||
|
||||
export const TerminalTableRow = styled(TableRow)({
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.05)',
|
||||
},
|
||||
'&:nth-of-type(even)': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.02)',
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalAlert = styled(Alert)({
|
||||
backgroundColor: '#1a1a1a',
|
||||
color: '#ff4444',
|
||||
border: '1px solid #ff4444',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
'& .MuiAlert-icon': {
|
||||
color: '#ff4444',
|
||||
},
|
||||
'&.MuiAlert-standardSuccess': {
|
||||
color: '#00ff00',
|
||||
borderColor: '#00ff00',
|
||||
'& .MuiAlert-icon': {
|
||||
color: '#00ff00',
|
||||
}
|
||||
},
|
||||
'&.MuiAlert-standardWarning': {
|
||||
color: '#ffd700',
|
||||
borderColor: '#ffd700',
|
||||
'& .MuiAlert-icon': {
|
||||
color: '#ffd700',
|
||||
}
|
||||
},
|
||||
'&.MuiAlert-standardInfo': {
|
||||
color: '#00ffff',
|
||||
borderColor: '#00ffff',
|
||||
'& .MuiAlert-icon': {
|
||||
color: '#00ffff',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalAccordion = styled(Accordion)({
|
||||
backgroundColor: '#1a1a1a',
|
||||
border: '1px solid #00ff00',
|
||||
color: '#00ff00',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
'&:before': {
|
||||
display: 'none',
|
||||
},
|
||||
'&.Mui-expanded': {
|
||||
margin: 0,
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalBox = styled(Box)({
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
color: '#00ff00',
|
||||
});
|
||||
|
||||
// Color constants
|
||||
export const terminalColors = {
|
||||
primary: '#00ff00',
|
||||
secondary: '#00ff88',
|
||||
error: '#ff4444',
|
||||
warning: '#ffd700',
|
||||
info: '#00ffff',
|
||||
success: '#00ff00',
|
||||
background: '#0a0a0a',
|
||||
backgroundLight: '#1a1a1a',
|
||||
text: '#00ff00',
|
||||
textSecondary: '#00ff88',
|
||||
border: '#00ff00',
|
||||
};
|
||||
|
||||
@@ -63,6 +63,12 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
const [planSignature, setPlanSignature] = useState<string>("");
|
||||
// Flag to track if current modal is a usage limit modal (should never be auto-closed)
|
||||
const [isUsageLimitModal, setIsUsageLimitModal] = useState<boolean>(false);
|
||||
|
||||
// Use ref to access latest subscription value in callbacks (avoid closure issues)
|
||||
const subscriptionRef = useRef<SubscriptionStatus | null>(null);
|
||||
useEffect(() => {
|
||||
subscriptionRef.current = subscription;
|
||||
}, [subscription]);
|
||||
|
||||
const checkSubscription = useCallback(async () => {
|
||||
// Throttle subscription checks to prevent excessive API calls
|
||||
@@ -99,6 +105,8 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
|
||||
console.log('SubscriptionContext: Received subscription data from backend:', subscriptionData);
|
||||
setSubscription(subscriptionData);
|
||||
// Update ref immediately so callbacks can access latest value
|
||||
subscriptionRef.current = subscriptionData;
|
||||
|
||||
// Detect plan/tier change and start a grace window (5 minutes)
|
||||
try {
|
||||
@@ -249,38 +257,24 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
}, []);
|
||||
|
||||
// Global subscription error handler for API client
|
||||
const globalSubscriptionErrorHandler = useCallback((error: any) => {
|
||||
console.log('SubscriptionContext: Global error handler triggered', error);
|
||||
|
||||
const globalSubscriptionErrorHandler = useCallback(async (error: any): Promise<boolean> => {
|
||||
// Check if it's a subscription-related error
|
||||
const status = error.response?.status;
|
||||
|
||||
if (status === 429 || status === 402) {
|
||||
console.log('SubscriptionContext: Subscription error detected');
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Check if this is a usage limit error (status 429) vs subscription expired (402)
|
||||
let errorData = error.response?.data || {};
|
||||
|
||||
// DEBUG: Log the raw error data structure
|
||||
console.log('SubscriptionContext: Raw error data', {
|
||||
type: typeof errorData,
|
||||
isArray: Array.isArray(errorData),
|
||||
data: errorData,
|
||||
stringified: JSON.stringify(errorData)
|
||||
});
|
||||
|
||||
// If errorData is an array, extract the first element (common FastAPI response format)
|
||||
if (Array.isArray(errorData)) {
|
||||
console.log('SubscriptionContext: errorData is array, extracting first element');
|
||||
errorData = errorData[0] || {};
|
||||
}
|
||||
|
||||
// CRITICAL: FastAPI wraps HTTPException detail in a 'detail' field
|
||||
// If errorData has a 'detail' field, extract it (this is the actual error data)
|
||||
if (errorData.detail && typeof errorData.detail === 'object') {
|
||||
console.log('SubscriptionContext: Found FastAPI detail wrapper, extracting detail field');
|
||||
errorData = errorData.detail;
|
||||
}
|
||||
|
||||
@@ -303,83 +297,82 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
const isUsageLimitError = status === 429 && hasUsageIndicators;
|
||||
const isSubscriptionExpired = status === 402 || (status === 429 && !isUsageLimitError);
|
||||
|
||||
console.log('SubscriptionContext: Error analysis', {
|
||||
status,
|
||||
isUsageLimitError,
|
||||
isSubscriptionExpired,
|
||||
hasUsageInfo: !!usageInfo,
|
||||
errorDataType: typeof errorData,
|
||||
errorDataKeys: typeof errorData === 'object' && !Array.isArray(errorData) ? Object.keys(errorData) : 'not-an-object',
|
||||
errorData: errorData
|
||||
});
|
||||
|
||||
// For usage limit errors (429 with usage_info), always show modal - even for active subscriptions
|
||||
// Ignore grace window and cooldown for usage limit errors (user needs to know immediately)
|
||||
// For usage limit errors (429 with usage_info), check subscription status first
|
||||
// User may have just renewed, so we need fresh subscription data
|
||||
if (isUsageLimitError) {
|
||||
// Build usage_info from various possible locations
|
||||
const finalUsageInfo = usageInfo ||
|
||||
(errorData.requested_tokens !== undefined ? {
|
||||
provider: errorData.provider,
|
||||
current_tokens: errorData.current_tokens,
|
||||
requested_tokens: errorData.requested_tokens,
|
||||
limit: errorData.limit,
|
||||
type: 'tokens',
|
||||
...errorData
|
||||
} : null) ||
|
||||
errorData;
|
||||
// CRITICAL: Check if subscription status is stale (older than 5 seconds)
|
||||
// If stale or if we don't have subscription data, refresh it before deciding
|
||||
const timeSinceLastCheck = now - lastCheckTime;
|
||||
const shouldRefresh = !subscription || timeSinceLastCheck > 5000;
|
||||
|
||||
const modalData = {
|
||||
provider: errorData.provider || usageInfo?.provider || 'unknown',
|
||||
usage_info: finalUsageInfo || errorData,
|
||||
message: errorData.message || errorData.error || 'You have reached your usage limit.'
|
||||
};
|
||||
if (shouldRefresh) {
|
||||
try {
|
||||
await checkSubscription();
|
||||
// Wait for state update (checkSubscription updates subscription state)
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
} catch (refreshError) {
|
||||
console.warn('SubscriptionContext: Failed to refresh subscription status:', refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('SubscriptionContext: Usage limit exceeded, showing modal (ignoring grace window/cooldown)', {
|
||||
modalData,
|
||||
errorData: Object.keys(errorData),
|
||||
usageInfo: usageInfo ? Object.keys(usageInfo) : null,
|
||||
currentShowModal: showModal,
|
||||
currentModalErrorData: modalErrorData
|
||||
});
|
||||
// Re-read subscription state after potential refresh using ref (to avoid closure issues)
|
||||
const currentSubscription = subscriptionRef.current;
|
||||
|
||||
// Set flag to mark this as a usage limit modal (should never be auto-closed)
|
||||
setIsUsageLimitModal(true);
|
||||
setModalErrorData(modalData);
|
||||
setShowModal(true);
|
||||
setLastModalShowTime(now);
|
||||
|
||||
console.log('SubscriptionContext: Modal state updated - showModal should be true, isUsageLimitModal = true', {
|
||||
showModal: true,
|
||||
isUsageLimitModal: true,
|
||||
modalErrorData: modalData
|
||||
});
|
||||
|
||||
// Force a re-render check
|
||||
setTimeout(() => {
|
||||
console.log('SubscriptionContext: State check after timeout - showModal:', showModal, 'modalErrorData:', modalErrorData);
|
||||
}, 100);
|
||||
|
||||
return true;
|
||||
// If subscription is inactive, treat as expired and fall through to expired handling
|
||||
if (!currentSubscription || !currentSubscription.active) {
|
||||
// Fall through to subscription expired handling below
|
||||
} else {
|
||||
// Subscription is active but usage limit exceeded - show usage limit modal
|
||||
|
||||
// Build usage_info from various possible locations
|
||||
const finalUsageInfo = usageInfo ||
|
||||
(errorData.requested_tokens !== undefined ? {
|
||||
provider: errorData.provider,
|
||||
current_tokens: errorData.current_tokens,
|
||||
requested_tokens: errorData.requested_tokens,
|
||||
limit: errorData.limit,
|
||||
type: 'tokens',
|
||||
...errorData
|
||||
} : null) ||
|
||||
errorData;
|
||||
|
||||
const modalData = {
|
||||
provider: errorData.provider || usageInfo?.provider || 'unknown',
|
||||
usage_info: finalUsageInfo || errorData,
|
||||
message: errorData.message || errorData.error || 'You have reached your usage limit.'
|
||||
};
|
||||
|
||||
// Set flag to mark this as a usage limit modal (should never be auto-closed)
|
||||
setIsUsageLimitModal(true);
|
||||
setModalErrorData(modalData);
|
||||
setShowModal(true);
|
||||
setLastModalShowTime(now);
|
||||
|
||||
console.log('SubscriptionContext: Showing usage limit modal', {
|
||||
provider: modalData.provider,
|
||||
message: modalData.message?.substring(0, 50)
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// For subscription expired errors, handle based on subscription status
|
||||
if (isSubscriptionExpired) {
|
||||
// If we have subscription data and it's active, this shouldn't happen but suppress anyway
|
||||
if (subscription && subscription.active) {
|
||||
console.log('SubscriptionContext: Active subscription but got expired error, suppressing modal');
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we don't have subscription data yet, defer the decision
|
||||
if (!subscription) {
|
||||
console.log('SubscriptionContext: No subscription data yet, deferring modal decision');
|
||||
setDeferredError(error);
|
||||
return true; // Handle the error but don't show modal yet
|
||||
}
|
||||
|
||||
// If subscription is not active, show modal immediately
|
||||
if (!subscription.active) {
|
||||
console.log('SubscriptionContext: Inactive subscription, showing modal immediately');
|
||||
console.log('SubscriptionContext: Showing subscription expired modal');
|
||||
setIsUsageLimitModal(false);
|
||||
setModalErrorData({
|
||||
provider: errorData.provider,
|
||||
@@ -394,7 +387,7 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
}
|
||||
|
||||
return false; // Not a subscription error
|
||||
}, [subscription]);
|
||||
}, [subscription, lastCheckTime, checkSubscription]);
|
||||
|
||||
// Register the global error handler with the API client
|
||||
// Use a ref to ensure the latest handler is always used
|
||||
|
||||
199
frontend/src/hooks/useOAuthTokenAlerts.ts
Normal file
199
frontend/src/hooks/useOAuthTokenAlerts.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Hook for polling OAuth token alerts and showing toast notifications
|
||||
*
|
||||
* This hook periodically checks for new OAuth token failure alerts
|
||||
* and displays toast notifications when detected.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { billingService } from '../services/billingService';
|
||||
import { UsageAlert } from '../types/billing';
|
||||
|
||||
interface UseOAuthTokenAlertsOptions {
|
||||
/**
|
||||
* Polling interval in milliseconds
|
||||
* @default 60000 (1 minute)
|
||||
*/
|
||||
interval?: number;
|
||||
|
||||
/**
|
||||
* Whether to enable polling
|
||||
* @default true
|
||||
*/
|
||||
enabled?: boolean;
|
||||
|
||||
/**
|
||||
* User ID - if not provided, will use localStorage or skip polling
|
||||
*/
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to poll for OAuth token alerts and show toast notifications
|
||||
*
|
||||
* Polls the UsageAlert API for new OAuth token alerts (oauth_token_failure, oauth_token_warning)
|
||||
* and displays toast notifications when new unread alerts are detected.
|
||||
*
|
||||
* @param options Polling configuration options
|
||||
* @returns Object with polling state and controls
|
||||
*/
|
||||
export function useOAuthTokenAlerts(options: UseOAuthTokenAlertsOptions = {}) {
|
||||
const {
|
||||
interval = 60000, // 1 minute default
|
||||
enabled = true,
|
||||
userId
|
||||
} = options;
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastAlertIdsRef = useRef<Set<number>>(new Set());
|
||||
const isPollingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actualUserId = userId || localStorage.getItem('user_id');
|
||||
if (!actualUserId) {
|
||||
console.debug('useOAuthTokenAlerts: No user ID available, skipping polling');
|
||||
return;
|
||||
}
|
||||
|
||||
const pollAlerts = async () => {
|
||||
// Prevent concurrent polls
|
||||
if (isPollingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isPollingRef.current = true;
|
||||
|
||||
// Fetch unread alerts only
|
||||
const alerts = await billingService.getUsageAlerts(actualUserId, true);
|
||||
|
||||
// Filter for OAuth token alerts
|
||||
const oauthAlerts = alerts.filter(
|
||||
(alert: UsageAlert) =>
|
||||
alert.type === 'oauth_token_failure' ||
|
||||
alert.type === 'oauth_token_warning'
|
||||
);
|
||||
|
||||
// Find new alerts (not in our tracked set)
|
||||
const newAlerts = oauthAlerts.filter(
|
||||
(alert: UsageAlert) => !lastAlertIdsRef.current.has(alert.id)
|
||||
);
|
||||
|
||||
// Show toast notifications for new alerts
|
||||
for (const alert of newAlerts) {
|
||||
// Map severity to notification type
|
||||
const notificationType =
|
||||
alert.severity === 'error' ? 'error' :
|
||||
alert.severity === 'warning' ? 'warning' :
|
||||
'info';
|
||||
|
||||
// Show toast notification
|
||||
showToastNotification(alert.message, notificationType);
|
||||
|
||||
// Track this alert ID
|
||||
lastAlertIdsRef.current.add(alert.id);
|
||||
|
||||
console.log(`OAuth token alert notification: ${alert.title}`, {
|
||||
type: alert.type,
|
||||
severity: alert.severity,
|
||||
platform: extractPlatformFromTitle(alert.title)
|
||||
});
|
||||
}
|
||||
|
||||
// Update tracked alert IDs (keep only current alerts to handle deletions)
|
||||
lastAlertIdsRef.current = new Set(oauthAlerts.map((a: UsageAlert) => a.id));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error polling OAuth token alerts:', error);
|
||||
// Don't show error to user - this is background polling
|
||||
} finally {
|
||||
isPollingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Poll immediately on mount
|
||||
pollAlerts();
|
||||
|
||||
// Set up periodic polling
|
||||
intervalRef.current = setInterval(pollAlerts, interval);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, interval, userId]);
|
||||
|
||||
return {
|
||||
isPolling: isPollingRef.current
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification using DOM-based approach
|
||||
* Works globally across the app, regardless of which component is mounted
|
||||
*/
|
||||
function showToastNotification(message: string, type: 'error' | 'warning' | 'info' = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
|
||||
// Determine background color based on type
|
||||
const bgColors = {
|
||||
error: '#f44336',
|
||||
warning: '#ff9800',
|
||||
info: '#2196f3',
|
||||
success: '#4caf50'
|
||||
};
|
||||
|
||||
toast.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 16px 24px;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
z-index: 10000;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
background-color: ${bgColors[type] || bgColors.info};
|
||||
word-wrap: break-word;
|
||||
`;
|
||||
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
// Animate in
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
|
||||
// Remove after 5 seconds (longer for important alerts)
|
||||
const duration = type === 'error' ? 7000 : 5000;
|
||||
setTimeout(() => {
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(toast)) {
|
||||
document.body.removeChild(toast);
|
||||
}
|
||||
}, 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract platform name from alert title
|
||||
* Used for logging/debugging
|
||||
*/
|
||||
function extractPlatformFromTitle(title: string): string {
|
||||
const match = title.match(/^(Google Search Console|Bing Webmaster Tools|WordPress|Wix)/);
|
||||
return match ? match[1] : 'Unknown';
|
||||
}
|
||||
|
||||
@@ -37,9 +37,20 @@ export function usePolling(
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Debug state changes
|
||||
// Debug state changes only in development and when state actually changes meaningfully
|
||||
const prevStateRef = useRef({ isPolling: false, currentStatus: 'idle', progressCount: 0 });
|
||||
useEffect(() => {
|
||||
console.log('Polling state changed:', { isPolling, currentStatus, progressCount: progressMessages.length });
|
||||
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]);
|
||||
|
||||
|
||||
@@ -48,26 +59,34 @@ export function usePolling(
|
||||
const currentTaskIdRef = useRef<string | null>(null);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
console.log('stopPolling called');
|
||||
// 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;
|
||||
}
|
||||
console.log('Setting isPolling to false');
|
||||
setIsPolling(false);
|
||||
attemptsRef.current = 0;
|
||||
currentTaskIdRef.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 startPolling = useCallback((taskId: string) => {
|
||||
console.log('startPolling called with taskId:', taskId);
|
||||
if (isPolling) {
|
||||
console.log('Already polling, stopping first');
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
currentTaskIdRef.current = taskId;
|
||||
console.log('Setting isPolling to true');
|
||||
setIsPolling(true);
|
||||
setCurrentStatus('pending');
|
||||
setProgressMessages([]);
|
||||
@@ -83,30 +102,25 @@ export function usePolling(
|
||||
|
||||
try {
|
||||
const status = await pollFunction(currentTaskIdRef.current);
|
||||
console.log('Polling status update:', status);
|
||||
setCurrentStatus(status.status);
|
||||
|
||||
// Update progress messages
|
||||
if (status.progress_messages && status.progress_messages.length > 0) {
|
||||
console.log('Progress messages received:', status.progress_messages);
|
||||
console.log('Previous progress messages count:', progressMessages.length);
|
||||
setProgressMessages(status.progress_messages);
|
||||
console.log('Progress messages state updated to:', status.progress_messages.length, 'messages');
|
||||
|
||||
// Call onProgress with the latest message for backward compatibility
|
||||
const latestMessage = status.progress_messages[status.progress_messages.length - 1];
|
||||
console.log('Latest progress message:', latestMessage.message);
|
||||
onProgress?.(latestMessage.message);
|
||||
}
|
||||
|
||||
if (status.status === 'completed') {
|
||||
console.log('✅ Task completed - stopping polling immediately');
|
||||
console.info('[usePolling] ✅ Task completed', { taskId: currentTaskIdRef.current });
|
||||
setResult(status.result);
|
||||
onComplete?.(status.result);
|
||||
stopPolling();
|
||||
return; // Exit early to prevent further processing
|
||||
} else if (status.status === 'failed') {
|
||||
console.log('❌ Task failed - stopping polling immediately');
|
||||
console.error('[usePolling] ❌ Task failed:', status.error);
|
||||
setError(status.error || 'Task failed');
|
||||
onError?.(status.error || 'Task failed');
|
||||
|
||||
@@ -139,7 +153,7 @@ export function usePolling(
|
||||
};
|
||||
|
||||
console.log('usePolling: Triggering subscription error handler with:', mockError);
|
||||
const handled = triggerSubscriptionError(mockError);
|
||||
const handled = await triggerSubscriptionError(mockError);
|
||||
|
||||
if (!handled) {
|
||||
console.warn('usePolling: Subscription error handler did not handle the error');
|
||||
@@ -159,19 +173,11 @@ export function usePolling(
|
||||
// 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) {
|
||||
console.log('usePolling: Detected subscription error in axios error response', {
|
||||
status: axiosError.response.status,
|
||||
data: axiosError.response.data,
|
||||
errorDataKeys: axiosError.response.data ? Object.keys(axiosError.response.data) : null
|
||||
});
|
||||
|
||||
// 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 = triggerSubscriptionError(axiosError);
|
||||
console.log('usePolling: triggerSubscriptionError returned', handled);
|
||||
const handled = await triggerSubscriptionError(axiosError);
|
||||
|
||||
if (handled) {
|
||||
console.log('usePolling: Subscription error handled, stopping polling - modal should be visible');
|
||||
const errorMsg = axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
'Subscription limit exceeded';
|
||||
@@ -180,7 +186,7 @@ export function usePolling(
|
||||
stopPolling();
|
||||
return; // Exit early - don't continue processing
|
||||
} else {
|
||||
console.warn('usePolling: Subscription error not handled by global handler, dispatching fallback event');
|
||||
console.warn('[usePolling] Subscription error not handled by global handler');
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('subscription-error', { detail: axiosError }));
|
||||
} catch (eventError) {
|
||||
@@ -210,10 +216,14 @@ export function usePolling(
|
||||
intervalRef.current = setInterval(poll, interval);
|
||||
}, [isPolling, interval, onProgress, onComplete, onError, pollFunction, stopPolling, progressMessages.length]);
|
||||
|
||||
// Cleanup on unmount
|
||||
// Cleanup on unmount - only if actually polling
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopPolling();
|
||||
// Only call stopPolling if we have an active interval (actually polling)
|
||||
// This prevents unnecessary cleanup calls when component unmounts while idle
|
||||
if (intervalRef.current) {
|
||||
stopPolling();
|
||||
}
|
||||
};
|
||||
}, [stopPolling]);
|
||||
|
||||
|
||||
@@ -49,11 +49,22 @@ export const useRealTimeData = (options: RealTimeDataOptions) => {
|
||||
setState(prev => ({ ...prev, isConnecting: true, error: null }));
|
||||
|
||||
try {
|
||||
// For development, use a mock WebSocket connection
|
||||
// In production, this would be the actual WebSocket URL
|
||||
const wsUrl = process.env.NODE_ENV === 'development'
|
||||
? `ws://localhost:8000/ws/strategy/${strategyId}/live`
|
||||
: `wss://api.alwrity.com/ws/strategy/${strategyId}/live`;
|
||||
// Build WebSocket URL from environment variables
|
||||
// Consistent with API URL pattern - no hardcoded localhost
|
||||
const apiUrl = process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL || '';
|
||||
|
||||
// In development, use proxy (empty string means use same origin)
|
||||
// In production, derive WebSocket URL from API URL
|
||||
let wsUrl: string;
|
||||
if (!apiUrl || apiUrl === '') {
|
||||
// Development: use proxy (same origin WebSocket)
|
||||
wsUrl = `ws://${window.location.host}/ws/strategy/${strategyId}/live`;
|
||||
} else {
|
||||
// Production: derive from API URL
|
||||
const wsProtocol = apiUrl.startsWith('https://') ? 'wss://' : 'ws://';
|
||||
const wsHost = apiUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
||||
wsUrl = `${wsProtocol}${wsHost}/ws/strategy/${strategyId}/live`;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
@@ -1,44 +1,368 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ResearchWizard } from '../components/Research';
|
||||
import { BlogResearchResponse } from '../services/blogWriterApi';
|
||||
import { getResearchConfig, PersonaDefaults, refreshResearchPersona, ResearchPersona } from '../api/researchConfig';
|
||||
import { ResearchPersonaModal } from '../components/Research/ResearchPersonaModal';
|
||||
|
||||
const samplePresets = [
|
||||
{
|
||||
name: 'AI Marketing Tools',
|
||||
keywords: 'AI in marketing, automation tools, customer engagement',
|
||||
keywords: 'Research latest AI-powered marketing automation tools and customer engagement platforms',
|
||||
industry: 'Technology',
|
||||
targetAudience: 'Marketing professionals and SaaS founders',
|
||||
researchMode: 'comprehensive' as const,
|
||||
icon: '🤖',
|
||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
config: {
|
||||
mode: 'comprehensive' as const,
|
||||
provider: 'google' as const,
|
||||
max_sources: 15,
|
||||
include_statistics: true,
|
||||
include_expert_quotes: true,
|
||||
include_competitors: true,
|
||||
include_trends: true,
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Small Business SEO',
|
||||
keywords: 'local SEO, small business, Google My Business',
|
||||
keywords: 'Write a blog on local SEO strategies for small businesses and Google My Business optimization',
|
||||
industry: 'Marketing',
|
||||
targetAudience: 'Small business owners and local entrepreneurs',
|
||||
researchMode: 'targeted' as const,
|
||||
icon: '📈',
|
||||
gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||
config: {
|
||||
mode: 'targeted' as const,
|
||||
provider: 'google' as const,
|
||||
max_sources: 12,
|
||||
include_statistics: true,
|
||||
include_expert_quotes: false,
|
||||
include_competitors: true,
|
||||
include_trends: true,
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Content Strategy',
|
||||
keywords: 'content planning, editorial calendar, content creation',
|
||||
keywords: 'Analyze content planning frameworks and editorial calendar best practices for B2B marketing',
|
||||
industry: 'Marketing',
|
||||
targetAudience: 'Content marketers and marketing managers',
|
||||
researchMode: 'comprehensive' as const,
|
||||
icon: '✍️',
|
||||
gradient: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
||||
config: {
|
||||
mode: 'comprehensive' as const,
|
||||
provider: 'exa' as const,
|
||||
max_sources: 20,
|
||||
include_statistics: true,
|
||||
include_expert_quotes: true,
|
||||
include_competitors: false,
|
||||
include_trends: true,
|
||||
exa_category: 'research paper',
|
||||
exa_search_type: 'neural' as const,
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Crypto Trends',
|
||||
keywords: 'Explore cryptocurrency market trends and blockchain adoption in enterprise',
|
||||
industry: 'Finance',
|
||||
targetAudience: 'Investors and blockchain developers',
|
||||
researchMode: 'comprehensive' as const,
|
||||
icon: '₿',
|
||||
gradient: 'linear-gradient(135deg, #f7931a 0%, #ffa94d 100%)',
|
||||
config: {
|
||||
mode: 'comprehensive' as const,
|
||||
provider: 'exa' as const,
|
||||
max_sources: 25,
|
||||
include_statistics: true,
|
||||
include_expert_quotes: true,
|
||||
include_competitors: true,
|
||||
include_trends: true,
|
||||
exa_category: 'news',
|
||||
exa_search_type: 'neural' as const,
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Healthcare Tech',
|
||||
keywords: 'Research telemedicine platforms and remote patient monitoring technologies',
|
||||
industry: 'Healthcare',
|
||||
targetAudience: 'Healthcare administrators and medical professionals',
|
||||
researchMode: 'comprehensive' as const,
|
||||
icon: '⚕️',
|
||||
gradient: 'linear-gradient(135deg, #06b6d4 0%, #3b82f6 100%)',
|
||||
config: {
|
||||
mode: 'comprehensive' as const,
|
||||
provider: 'exa' as const,
|
||||
max_sources: 20,
|
||||
include_statistics: true,
|
||||
include_expert_quotes: true,
|
||||
include_competitors: false,
|
||||
include_trends: true,
|
||||
exa_category: 'research paper',
|
||||
exa_search_type: 'neural' as const,
|
||||
exa_include_domains: ['pubmed.gov', 'nejm.org', 'thelancet.com'],
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
// Generate persona-specific presets dynamically
|
||||
const generatePersonaPresets = (persona: PersonaDefaults | null): typeof samplePresets => {
|
||||
if (!persona || !persona.industry || persona.industry === 'General') {
|
||||
return samplePresets;
|
||||
}
|
||||
|
||||
const industry = persona.industry;
|
||||
const audience = persona.target_audience || 'professionals';
|
||||
const exaCategory = persona.suggested_exa_category || '';
|
||||
const exaDomains = persona.suggested_domains || [];
|
||||
|
||||
// Build config objects conditionally based on whether we have Exa options
|
||||
const baseConfig1: any = {
|
||||
mode: 'comprehensive' as const,
|
||||
provider: 'exa' as const,
|
||||
max_sources: 20,
|
||||
include_statistics: true,
|
||||
include_expert_quotes: true,
|
||||
include_competitors: true,
|
||||
include_trends: true,
|
||||
exa_search_type: 'neural' as const,
|
||||
...(exaCategory ? { exa_category: exaCategory } : {}),
|
||||
...(exaDomains.length > 0 ? { exa_include_domains: exaDomains } : {}),
|
||||
};
|
||||
|
||||
const baseConfig2: any = {
|
||||
mode: 'targeted' as const,
|
||||
provider: 'exa' as const,
|
||||
max_sources: 15,
|
||||
include_statistics: true,
|
||||
include_expert_quotes: true,
|
||||
include_competitors: false,
|
||||
include_trends: true,
|
||||
exa_search_type: 'neural' as const,
|
||||
...(exaCategory ? { exa_category: exaCategory } : {}),
|
||||
...(exaDomains.length > 0 ? { exa_include_domains: exaDomains } : {}),
|
||||
};
|
||||
|
||||
const baseConfig3: any = {
|
||||
mode: 'comprehensive' as const,
|
||||
provider: 'exa' as const,
|
||||
max_sources: 18,
|
||||
include_statistics: true,
|
||||
include_expert_quotes: true,
|
||||
include_competitors: true,
|
||||
include_trends: false,
|
||||
exa_search_type: 'neural' as const,
|
||||
...(exaCategory ? { exa_category: exaCategory } : {}),
|
||||
...(exaDomains.length > 0 ? { exa_include_domains: exaDomains } : {}),
|
||||
};
|
||||
|
||||
const generatedPresets = [
|
||||
{
|
||||
name: `${industry} Trends`,
|
||||
keywords: `Research latest trends and innovations in ${industry}`,
|
||||
industry,
|
||||
targetAudience: audience,
|
||||
researchMode: 'comprehensive' as const,
|
||||
icon: '📊',
|
||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
config: baseConfig1,
|
||||
},
|
||||
{
|
||||
name: `${audience} Insights`,
|
||||
keywords: `Analyze ${audience} pain points and preferences in ${industry}`,
|
||||
industry,
|
||||
targetAudience: audience,
|
||||
researchMode: 'targeted' as const,
|
||||
icon: '🎯',
|
||||
gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||
config: baseConfig2,
|
||||
},
|
||||
{
|
||||
name: `${industry} Best Practices`,
|
||||
keywords: `Investigate best practices and success stories in ${industry}`,
|
||||
industry,
|
||||
targetAudience: audience,
|
||||
researchMode: 'comprehensive' as const,
|
||||
icon: '⭐',
|
||||
gradient: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
||||
config: baseConfig3,
|
||||
}
|
||||
];
|
||||
|
||||
return [...generatedPresets, ...samplePresets.slice(0, 2)] as typeof samplePresets;
|
||||
};
|
||||
|
||||
export const ResearchTest: React.FC = () => {
|
||||
const [results, setResults] = useState<BlogResearchResponse | null>(null);
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
const [presetKeywords, setPresetKeywords] = useState<string[] | undefined>();
|
||||
const [presetIndustry, setPresetIndustry] = useState<string | undefined>();
|
||||
const [presetTargetAudience, setPresetTargetAudience] = useState<string | undefined>();
|
||||
const [presetMode, setPresetMode] = useState<any>();
|
||||
const [presetConfig, setPresetConfig] = useState<any>();
|
||||
const [personaData, setPersonaData] = useState<PersonaDefaults | null>(null);
|
||||
const [displayPresets, setDisplayPresets] = useState(samplePresets);
|
||||
const [showPersonaModal, setShowPersonaModal] = useState(false);
|
||||
const [personaChecked, setPersonaChecked] = useState(false);
|
||||
const [researchPersona, setResearchPersona] = useState<ResearchPersona | null>(null);
|
||||
|
||||
// Debug: Track modal state changes
|
||||
useEffect(() => {
|
||||
console.log('[ResearchTest] 🔍 Modal state changed:', showPersonaModal);
|
||||
}, [showPersonaModal]);
|
||||
|
||||
// Check for research persona and load persona data
|
||||
useEffect(() => {
|
||||
const loadPersonaPresets = async () => {
|
||||
console.log('[ResearchTest] Starting persona check...');
|
||||
try {
|
||||
const config = await getResearchConfig();
|
||||
console.log('[ResearchTest] Config received:', {
|
||||
hasResearchPersona: !!config.research_persona,
|
||||
onboardingCompleted: config.onboarding_completed,
|
||||
personaScheduled: config.persona_scheduled,
|
||||
personaDefaults: config.persona_defaults
|
||||
});
|
||||
|
||||
setPersonaData(config.persona_defaults || null);
|
||||
|
||||
// CASE 1: Research persona exists in database
|
||||
if (config.research_persona) {
|
||||
console.log('[ResearchTest] ✅ CASE 1: Research persona found in database');
|
||||
console.log('[ResearchTest] Persona details:', {
|
||||
defaultIndustry: config.research_persona.default_industry,
|
||||
defaultTargetAudience: config.research_persona.default_target_audience,
|
||||
hasRecommendedPresets: !!config.research_persona.recommended_presets,
|
||||
presetCount: config.research_persona.recommended_presets?.length || 0
|
||||
});
|
||||
|
||||
setResearchPersona(config.research_persona);
|
||||
|
||||
// Use AI-generated presets if persona exists
|
||||
if (config.research_persona.recommended_presets && config.research_persona.recommended_presets.length > 0) {
|
||||
console.log('[ResearchTest] Using AI-generated presets from persona');
|
||||
// Convert AI presets to display format
|
||||
const aiPresets = config.research_persona.recommended_presets.map((preset: any) => ({
|
||||
name: preset.name,
|
||||
keywords: preset.keywords.join(', '),
|
||||
industry: config.persona_defaults?.industry || 'General',
|
||||
targetAudience: config.persona_defaults?.target_audience || 'General',
|
||||
researchMode: preset.config?.mode || 'comprehensive',
|
||||
icon: preset.icon || '🔍',
|
||||
gradient: preset.gradient || 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
config: preset.config || {}
|
||||
}));
|
||||
setDisplayPresets([...aiPresets, ...samplePresets.slice(0, 2)]);
|
||||
} else {
|
||||
console.log('[ResearchTest] Persona exists but no recommended presets, using rule-based presets');
|
||||
const dynamicPresets = generatePersonaPresets(config.persona_defaults || null);
|
||||
setDisplayPresets(dynamicPresets);
|
||||
}
|
||||
} else {
|
||||
// CASE 2 & 3: No research persona found
|
||||
console.log('[ResearchTest] ⚠️ CASE 2/3: Research persona NOT found in database');
|
||||
console.log('[ResearchTest] Onboarding status:', {
|
||||
onboardingCompleted: config.onboarding_completed,
|
||||
personaScheduled: config.persona_scheduled
|
||||
});
|
||||
|
||||
const dynamicPresets = generatePersonaPresets(config.persona_defaults || null);
|
||||
setDisplayPresets(dynamicPresets);
|
||||
|
||||
// Show modal only if onboarding is completed
|
||||
if (config.onboarding_completed) {
|
||||
console.log('[ResearchTest] ✅ CASE 2: Onboarding completed but persona missing - SHOWING MODAL');
|
||||
console.log('[ResearchTest] Setting showPersonaModal to true');
|
||||
setShowPersonaModal(true);
|
||||
|
||||
// Log if persona was scheduled
|
||||
if (config.persona_scheduled) {
|
||||
console.log('[ResearchTest] ℹ️ Research persona generation scheduled for 20 minutes from now');
|
||||
} else {
|
||||
console.log('[ResearchTest] ⚠️ Persona was not scheduled (may have failed or already scheduled)');
|
||||
}
|
||||
} else {
|
||||
console.log('[ResearchTest] ✅ CASE 3: Onboarding not completed yet - SKIPPING modal');
|
||||
console.log('[ResearchTest] User has not completed onboarding, will use rule-based suggestions');
|
||||
}
|
||||
}
|
||||
|
||||
setPersonaChecked(true);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('[ResearchTest] ❌ ERROR: Failed to load persona data:', error);
|
||||
console.error('[ResearchTest] Error details:', errorMessage);
|
||||
|
||||
// Use fallback presets on error
|
||||
setDisplayPresets(samplePresets);
|
||||
setPersonaChecked(true);
|
||||
|
||||
// Don't show modal on error - user can still use default presets
|
||||
// Error is already logged to console for debugging
|
||||
}
|
||||
};
|
||||
|
||||
loadPersonaPresets();
|
||||
}, []);
|
||||
|
||||
// Handle research persona generation
|
||||
const handleGeneratePersona = async () => {
|
||||
console.log('[ResearchTest] 🔄 User clicked "Generate Persona" - starting generation...');
|
||||
try {
|
||||
// Force refresh to generate new persona
|
||||
console.log('[ResearchTest] Calling refreshResearchPersona with force_refresh=true');
|
||||
const persona = await refreshResearchPersona(true);
|
||||
console.log('[ResearchTest] ✅ Persona generated successfully:', {
|
||||
defaultIndustry: persona.default_industry,
|
||||
hasRecommendedPresets: !!persona.recommended_presets
|
||||
});
|
||||
|
||||
setResearchPersona(persona);
|
||||
|
||||
// Reload config to get updated presets
|
||||
const config = await getResearchConfig();
|
||||
if (config.research_persona?.recommended_presets && config.research_persona.recommended_presets.length > 0) {
|
||||
console.log('[ResearchTest] Updating presets with AI-generated presets');
|
||||
const aiPresets = config.research_persona.recommended_presets.map((preset: any) => ({
|
||||
name: preset.name,
|
||||
keywords: preset.keywords.join(', '),
|
||||
industry: config.persona_defaults.industry || 'General',
|
||||
targetAudience: config.persona_defaults.target_audience || 'General',
|
||||
researchMode: preset.config?.mode || 'comprehensive',
|
||||
icon: preset.icon || '🔍',
|
||||
gradient: preset.gradient || 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
config: preset.config || {}
|
||||
}));
|
||||
setDisplayPresets([...aiPresets, ...samplePresets.slice(0, 2)]);
|
||||
}
|
||||
|
||||
console.log('[ResearchTest] ✅ Persona generation complete - closing modal');
|
||||
setShowPersonaModal(false);
|
||||
} catch (error) {
|
||||
console.error('[ResearchTest] ❌ Failed to generate research persona:', error);
|
||||
console.error('[ResearchTest] Error details:', error instanceof Error ? error.message : String(error));
|
||||
throw error; // Let modal handle the error display
|
||||
}
|
||||
};
|
||||
|
||||
// Handle cancel - user chooses to skip persona generation
|
||||
const handleCancelPersona = () => {
|
||||
console.log('[ResearchTest] ✅ CASE 3: User cancelled persona generation');
|
||||
console.log('[ResearchTest] Continuing with rule-based suggestions');
|
||||
setShowPersonaModal(false);
|
||||
// Continue with rule-based suggestions (already set as displayPresets)
|
||||
};
|
||||
|
||||
const handleComplete = (researchResults: BlogResearchResponse) => {
|
||||
setResults(researchResults);
|
||||
};
|
||||
|
||||
const handlePresetClick = (preset: typeof samplePresets[0]) => {
|
||||
setPresetKeywords(preset.keywords.split(',').map(k => k.trim()));
|
||||
// Pass full research query as single keyword for intelligent parsing
|
||||
setPresetKeywords([preset.keywords]);
|
||||
setPresetIndustry(preset.industry);
|
||||
setPresetTargetAudience(preset.targetAudience);
|
||||
setPresetMode(preset.researchMode);
|
||||
setPresetConfig(preset.config);
|
||||
setResults(null);
|
||||
};
|
||||
|
||||
@@ -212,7 +536,7 @@ export const ResearchTest: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{samplePresets.map((preset, idx) => (
|
||||
{displayPresets.map((preset, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
@@ -374,6 +698,9 @@ export const ResearchTest: React.FC = () => {
|
||||
<ResearchWizard
|
||||
initialKeywords={presetKeywords}
|
||||
initialIndustry={presetIndustry}
|
||||
initialTargetAudience={presetTargetAudience}
|
||||
initialResearchMode={presetMode}
|
||||
initialConfig={presetConfig}
|
||||
onComplete={handleComplete}
|
||||
/>
|
||||
</div>
|
||||
@@ -521,6 +848,17 @@ export const ResearchTest: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Research Persona Generation Modal */}
|
||||
<ResearchPersonaModal
|
||||
open={showPersonaModal}
|
||||
onClose={() => {
|
||||
console.log('[ResearchTest] Modal onClose called');
|
||||
setShowPersonaModal(false);
|
||||
}}
|
||||
onGenerate={handleGeneratePersona}
|
||||
onCancel={handleCancelPersona}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
701
frontend/src/pages/SchedulerDashboard.tsx
Normal file
701
frontend/src/pages/SchedulerDashboard.tsx
Normal file
@@ -0,0 +1,701 @@
|
||||
/**
|
||||
* Scheduler Dashboard Page
|
||||
* Main page displaying scheduler status, jobs, execution logs, and insights.
|
||||
* Terminal-themed UI with high readability.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Chip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Refresh as RefreshIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
PlayArrow as PlayArrowIcon,
|
||||
Pause as PauseIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
AccessTime as AccessTimeIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
import { getSchedulerDashboard, SchedulerDashboardData } from '../api/schedulerDashboard';
|
||||
// Removed SchedulerStatsCards - metrics moved to header
|
||||
import SchedulerJobsTree from '../components/SchedulerDashboard/SchedulerJobsTree';
|
||||
import ExecutionLogsTable from '../components/SchedulerDashboard/ExecutionLogsTable';
|
||||
import FailuresInsights from '../components/SchedulerDashboard/FailuresInsights';
|
||||
import SchedulerEventHistory from '../components/SchedulerDashboard/SchedulerEventHistory';
|
||||
import SchedulerCharts from '../components/SchedulerDashboard/SchedulerCharts';
|
||||
import OAuthTokenStatus from '../components/SchedulerDashboard/OAuthTokenStatus';
|
||||
import { TerminalTypography, terminalColors } from '../components/SchedulerDashboard/terminalTheme';
|
||||
|
||||
// Terminal-themed styled components
|
||||
const TerminalContainer = styled(Container)(({ theme }) => ({
|
||||
backgroundColor: '#0a0a0a',
|
||||
minHeight: '100vh',
|
||||
color: '#00ff00',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
padding: theme.spacing(3),
|
||||
'& *': {
|
||||
fontFamily: 'inherit',
|
||||
}
|
||||
}));
|
||||
|
||||
const TerminalHeader = styled(Box)({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 24,
|
||||
paddingBottom: 16,
|
||||
borderBottom: '2px solid #00ff00',
|
||||
});
|
||||
|
||||
const TerminalTitle = styled(Typography)<{ component?: React.ElementType }>(({ theme }) => ({
|
||||
color: '#00ff00',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 'bold',
|
||||
textShadow: '0 0 10px rgba(0, 255, 0, 0.5)',
|
||||
letterSpacing: '2px',
|
||||
}));
|
||||
|
||||
const TerminalSubtitle = styled(Typography)({
|
||||
color: '#00ff88',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.875rem',
|
||||
marginTop: 4,
|
||||
opacity: 0.8,
|
||||
});
|
||||
|
||||
const TerminalChip = styled(Chip)({
|
||||
backgroundColor: '#1a1a1a',
|
||||
color: '#00ff00',
|
||||
border: '1px solid #00ff00',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.75rem',
|
||||
'& .MuiChip-label': {
|
||||
padding: '4px 8px',
|
||||
}
|
||||
});
|
||||
|
||||
const TerminalIconButton = styled(IconButton)({
|
||||
color: '#00ff00',
|
||||
border: '1px solid #00ff00',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
boxShadow: '0 0 10px rgba(0, 255, 0, 0.3)',
|
||||
},
|
||||
'&:disabled': {
|
||||
color: '#004400',
|
||||
borderColor: '#004400',
|
||||
}
|
||||
});
|
||||
|
||||
// Metric bubble style for header - Ultra modern terminal aesthetic
|
||||
const MetricBubble = styled(Box)({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 14px',
|
||||
backgroundColor: 'rgba(10, 10, 10, 0.8)',
|
||||
border: '1px solid #00ff00',
|
||||
borderRadius: '20px',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
fontSize: '0.875rem',
|
||||
color: '#00ff00',
|
||||
cursor: 'default',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 0 0 rgba(0, 255, 0, 0)',
|
||||
textShadow: '0 0 5px rgba(0, 255, 0, 0.3)',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(0, 255, 0, 0.1), transparent)',
|
||||
transition: 'left 0.5s ease',
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.15)',
|
||||
borderColor: '#00ff88',
|
||||
boxShadow: '0 0 20px rgba(0, 255, 0, 0.4), inset 0 0 10px rgba(0, 255, 0, 0.1)',
|
||||
transform: 'translateY(-2px) scale(1.02)',
|
||||
textShadow: '0 0 8px rgba(0, 255, 0, 0.6)',
|
||||
'&::before': {
|
||||
left: '100%',
|
||||
},
|
||||
},
|
||||
'& .metric-icon': {
|
||||
fontSize: '18px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
filter: 'drop-shadow(0 0 3px rgba(0, 255, 0, 0.5))',
|
||||
transition: 'all 0.3s ease',
|
||||
},
|
||||
'&:hover .metric-icon': {
|
||||
transform: 'scale(1.1) rotate(5deg)',
|
||||
filter: 'drop-shadow(0 0 6px rgba(0, 255, 0, 0.8))',
|
||||
},
|
||||
'& .metric-value': {
|
||||
fontWeight: 700,
|
||||
fontSize: '0.9rem',
|
||||
letterSpacing: '0.5px',
|
||||
background: 'linear-gradient(135deg, #00ff00 0%, #00ff88 100%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
},
|
||||
'& .metric-label': {
|
||||
fontSize: '0.7rem',
|
||||
opacity: 0.7,
|
||||
marginLeft: '2px',
|
||||
letterSpacing: '0.3px',
|
||||
textTransform: 'uppercase',
|
||||
fontWeight: 500,
|
||||
}
|
||||
});
|
||||
|
||||
const TerminalAlert = styled(Alert)({
|
||||
backgroundColor: '#1a1a1a',
|
||||
color: '#ff4444',
|
||||
border: '1px solid #ff4444',
|
||||
fontFamily: 'inherit',
|
||||
'& .MuiAlert-icon': {
|
||||
color: '#ff4444',
|
||||
}
|
||||
});
|
||||
|
||||
const TerminalLoading = styled(Box)({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '400px',
|
||||
'& .MuiCircularProgress-root': {
|
||||
color: '#00ff00',
|
||||
}
|
||||
});
|
||||
|
||||
const SchedulerDashboard: React.FC = () => {
|
||||
const { isSignedIn, isLoaded } = useAuth();
|
||||
const [dashboardData, setDashboardData] = useState<SchedulerDashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [autoRefreshInterval, setAutoRefreshInterval] = useState<NodeJS.Timeout | null>(null);
|
||||
const [lastUpdateTimestamp, setLastUpdateTimestamp] = useState<string | null>(null);
|
||||
|
||||
// Use refs to track loading state without causing re-renders
|
||||
const loadingRef = useRef(false);
|
||||
const refreshingRef = useRef(false);
|
||||
|
||||
const fetchDashboardData = useCallback(async (isManualRefresh = false) => {
|
||||
// Prevent multiple simultaneous fetches using refs
|
||||
if (loadingRef.current || refreshingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loadingRef.current = !isManualRefresh;
|
||||
refreshingRef.current = isManualRefresh;
|
||||
|
||||
if (isManualRefresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
const data = await getSchedulerDashboard();
|
||||
|
||||
// Always update state to ensure metrics are updated
|
||||
// The comparison was preventing updates when cumulative stats changed
|
||||
setDashboardData(data);
|
||||
|
||||
setLastUpdated(new Date());
|
||||
setLastUpdateTimestamp(data.stats.last_update || null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch scheduler dashboard');
|
||||
console.error('Error fetching scheduler dashboard:', err);
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
refreshingRef.current = false;
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []); // Empty deps - function is stable
|
||||
|
||||
// Initial load - only once
|
||||
useEffect(() => {
|
||||
if (isLoaded && isSignedIn && !dashboardData) {
|
||||
fetchDashboardData();
|
||||
}
|
||||
}, [isLoaded, isSignedIn]); // Removed fetchDashboardData to prevent re-renders
|
||||
|
||||
// Smart auto-refresh: Poll based on scheduler's check interval or next job execution
|
||||
useEffect(() => {
|
||||
if (!isSignedIn || !isLoaded || !dashboardData) return;
|
||||
|
||||
// Calculate polling interval based on scheduler's check interval
|
||||
const checkIntervalMinutes = dashboardData.stats?.check_interval_minutes || 60;
|
||||
// Poll slightly before the scheduler's next check (at 90% of interval)
|
||||
// Convert to milliseconds and add some buffer
|
||||
const pollingIntervalMs = Math.max(
|
||||
(checkIntervalMinutes * 60 * 1000 * 0.9), // 90% of check interval
|
||||
60000 // Minimum 60 seconds
|
||||
);
|
||||
|
||||
// Alternatively, calculate based on next job execution time
|
||||
let nextJobTime: Date | null = null;
|
||||
if (dashboardData.jobs && dashboardData.jobs.length > 0) {
|
||||
// Find the earliest next run time (only future jobs)
|
||||
const now = Date.now();
|
||||
const nextRunTimes = dashboardData.jobs
|
||||
.map(job => {
|
||||
if (!job.next_run_time) return null;
|
||||
const jobTime = new Date(job.next_run_time);
|
||||
// Only include future jobs
|
||||
return jobTime.getTime() > now ? jobTime : null;
|
||||
})
|
||||
.filter((time): time is Date => time !== null && !isNaN(time.getTime()))
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
if (nextRunTimes.length > 0) {
|
||||
nextJobTime = nextRunTimes[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Use next job time if it's sooner than the check interval
|
||||
let finalIntervalMs = pollingIntervalMs;
|
||||
if (nextJobTime) {
|
||||
const msUntilNextJob = nextJobTime.getTime() - Date.now();
|
||||
// Poll slightly before next job (at 90% of time remaining, min 10s before, max 2min before)
|
||||
if (msUntilNextJob > 10000) { // At least 10 seconds in the future
|
||||
// Poll 10 seconds before job, or 10% of time remaining, whichever is smaller
|
||||
const pollBeforeJob = Math.min(
|
||||
Math.max(msUntilNextJob * 0.1, 10000), // 10% of time or 10s minimum
|
||||
msUntilNextJob - 10000 // But no more than 10s before
|
||||
);
|
||||
finalIntervalMs = Math.min(finalIntervalMs, pollBeforeJob);
|
||||
} else if (msUntilNextJob > 0) {
|
||||
// Job is very soon (< 10s), poll immediately (1 second)
|
||||
finalIntervalMs = 1000;
|
||||
}
|
||||
}
|
||||
|
||||
// Cap at reasonable maximum (10 minutes) and minimum (10 seconds)
|
||||
finalIntervalMs = Math.max(10000, Math.min(finalIntervalMs, 600000)); // 10s min, 10min max
|
||||
|
||||
const interval = setInterval(() => {
|
||||
// Only fetch if we're not already loading/refreshing (using refs)
|
||||
if (!loadingRef.current && !refreshingRef.current) {
|
||||
fetchDashboardData();
|
||||
}
|
||||
}, finalIntervalMs);
|
||||
|
||||
setAutoRefreshInterval(interval);
|
||||
|
||||
// Log the polling interval for debugging
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
`📊 Scheduler polling: ${Math.round(finalIntervalMs / 1000)}s ` +
|
||||
`(check interval: ${checkIntervalMinutes}min, next job: ${nextJobTime ? nextJobTime.toLocaleTimeString() : 'none'})`
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [isSignedIn, isLoaded, dashboardData, fetchDashboardData]); // Re-run when dashboard data changes
|
||||
|
||||
// Format time ago
|
||||
const formatTimeAgo = (date: Date | null) => {
|
||||
if (!date) return 'Never';
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
|
||||
if (diffSecs < 10) return 'Just now';
|
||||
if (diffSecs < 60) return `${diffSecs}s ago`;
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
return `${diffHours}h ago`;
|
||||
};
|
||||
|
||||
const handleManualRefresh = () => {
|
||||
if (!refreshing && !loading) {
|
||||
fetchDashboardData(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<TerminalContainer maxWidth="xl">
|
||||
<TerminalLoading>
|
||||
<CircularProgress />
|
||||
</TerminalLoading>
|
||||
</TerminalContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isSignedIn) {
|
||||
return (
|
||||
<TerminalContainer maxWidth="xl">
|
||||
<TerminalAlert severity="warning">
|
||||
Please sign in to view the scheduler dashboard.
|
||||
</TerminalAlert>
|
||||
</TerminalContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TerminalContainer maxWidth="xl">
|
||||
{/* Header */}
|
||||
<TerminalHeader>
|
||||
<Box display="flex" flexDirection="column" gap={2} flex={1}>
|
||||
{/* Title Row */}
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<ScheduleIcon sx={{ color: '#00ff00', fontSize: 32 }} />
|
||||
<Box>
|
||||
<TerminalTitle component="h1">
|
||||
SCHEDULER DASHBOARD
|
||||
</TerminalTitle>
|
||||
<TerminalSubtitle>
|
||||
Monitor task execution, jobs, and system status
|
||||
</TerminalSubtitle>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{lastUpdated && (
|
||||
<TerminalChip
|
||||
label={`Last updated: ${formatTimeAgo(lastUpdated)}`}
|
||||
size="small"
|
||||
icon={<CheckCircleIcon sx={{ color: '#00ff00', fontSize: 14 }} />}
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Dashboard Status
|
||||
</Typography>
|
||||
{dashboardData && (
|
||||
<>
|
||||
<Typography variant="caption" component="div" sx={{ mb: 0.5 }}>
|
||||
<strong>Jobs:</strong> {dashboardData.jobs?.length || 0} total
|
||||
(Recurring: {dashboardData.recurring_jobs || 0}, One-Time: {dashboardData.one_time_jobs || 0})
|
||||
</Typography>
|
||||
<Typography variant="caption" component="div" sx={{ mb: 0.5 }}>
|
||||
<strong>Check Cycles:</strong> {
|
||||
dashboardData.stats?.total_checks === 0
|
||||
? '0 (First check pending - scheduler waiting for interval)'
|
||||
: `${dashboardData.stats?.total_checks || 0} (${dashboardData.stats?.cumulative_total_check_cycles || 0} total)`
|
||||
}
|
||||
</Typography>
|
||||
<Typography variant="caption" component="div" sx={{ mb: 0.5 }}>
|
||||
<strong>Scheduler:</strong> {dashboardData.stats?.running ? 'Running' : 'Stopped'} |
|
||||
<strong> Interval:</strong> {dashboardData.stats?.check_interval_minutes || 0} min
|
||||
</Typography>
|
||||
{dashboardData.stats && dashboardData.stats.tasks_found > 0 && (
|
||||
<Typography variant="caption" component="div">
|
||||
<strong>Tasks:</strong> {dashboardData.stats.tasks_found} found, {dashboardData.stats.tasks_executed} executed, {dashboardData.stats.tasks_failed} failed
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!dashboardData && (
|
||||
<Typography variant="caption">
|
||||
Click to refresh dashboard data
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<span>
|
||||
<TerminalIconButton
|
||||
onClick={handleManualRefresh}
|
||||
disabled={refreshing || loading}
|
||||
>
|
||||
<RefreshIcon
|
||||
sx={{
|
||||
animation: refreshing ? 'spin 1s linear infinite' : 'none',
|
||||
'@keyframes spin': {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(360deg)' }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TerminalIconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Metrics Bubbles Row */}
|
||||
{dashboardData?.stats && (
|
||||
<Box display="flex" alignItems="center" gap={1.5} flexWrap="wrap">
|
||||
{/* Scheduler Status */}
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Scheduler Status
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
{dashboardData.stats.running
|
||||
? 'The scheduler is currently running and actively checking for due tasks.'
|
||||
: 'The scheduler is stopped and not processing any tasks.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<MetricBubble>
|
||||
<Box className="metric-icon" sx={{ color: dashboardData.stats.running ? '#00ff00' : '#ff4444' }}>
|
||||
{dashboardData.stats.running ? <PlayArrowIcon fontSize="small" /> : <PauseIcon fontSize="small" />}
|
||||
</Box>
|
||||
<Box className="metric-value">
|
||||
{dashboardData.stats.running ? 'Running' : 'Stopped'}
|
||||
</Box>
|
||||
</MetricBubble>
|
||||
</Tooltip>
|
||||
|
||||
{/* Total Check Cycles */}
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Total Check Cycles
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
{dashboardData.stats.cumulative_total_check_cycles > 0
|
||||
? `Total check cycles: ${dashboardData.stats.cumulative_total_check_cycles.toLocaleString()} (${dashboardData.stats.total_checks} this session). The scheduler periodically checks for due tasks.`
|
||||
: `No check cycles yet. The scheduler will run its first check cycle after the interval expires (${dashboardData.stats.check_interval_minutes} minutes).`}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<MetricBubble>
|
||||
<Box className="metric-icon">
|
||||
<CheckCircleIcon fontSize="small" />
|
||||
</Box>
|
||||
<Box className="metric-value">
|
||||
{(dashboardData.stats.cumulative_total_check_cycles !== undefined && dashboardData.stats.cumulative_total_check_cycles !== null)
|
||||
? dashboardData.stats.cumulative_total_check_cycles.toLocaleString()
|
||||
: dashboardData.stats.total_checks.toLocaleString()}
|
||||
</Box>
|
||||
<Box className="metric-label">Cycles</Box>
|
||||
</MetricBubble>
|
||||
</Tooltip>
|
||||
|
||||
{/* Tasks Executed */}
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Tasks Executed
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
{dashboardData.stats.cumulative_tasks_executed > 0
|
||||
? `Total tasks executed: ${dashboardData.stats.cumulative_tasks_executed.toLocaleString()} (${dashboardData.stats.tasks_executed} this session). ${dashboardData.stats.tasks_failed > 0 ? `${dashboardData.stats.tasks_failed} failed.` : 'All successful.'}`
|
||||
: 'No tasks have been executed yet. Tasks will appear here once the scheduler starts processing them.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<MetricBubble>
|
||||
<Box className="metric-icon">
|
||||
<TrendingUpIcon fontSize="small" />
|
||||
</Box>
|
||||
<Box className="metric-value">
|
||||
{(dashboardData.stats.cumulative_tasks_executed !== undefined && dashboardData.stats.cumulative_tasks_executed !== null)
|
||||
? dashboardData.stats.cumulative_tasks_executed.toLocaleString()
|
||||
: dashboardData.stats.tasks_executed.toLocaleString()}
|
||||
</Box>
|
||||
<Box className="metric-label">Executed</Box>
|
||||
</MetricBubble>
|
||||
</Tooltip>
|
||||
|
||||
{/* Tasks Found */}
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Tasks Found
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
{dashboardData.stats.cumulative_tasks_found > 0
|
||||
? `Total tasks found: ${dashboardData.stats.cumulative_tasks_found.toLocaleString()} (${dashboardData.stats.tasks_found} this session). ${dashboardData.stats.tasks_executed} executed, ${dashboardData.stats.tasks_failed} failed.`
|
||||
: 'No tasks have been found yet. Tasks will appear here once they are scheduled and due for execution.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<MetricBubble>
|
||||
<Box className="metric-icon">
|
||||
<ScheduleIcon fontSize="small" />
|
||||
</Box>
|
||||
<Box className="metric-value">
|
||||
{(dashboardData.stats.cumulative_tasks_found !== undefined && dashboardData.stats.cumulative_tasks_found !== null)
|
||||
? dashboardData.stats.cumulative_tasks_found.toLocaleString()
|
||||
: dashboardData.stats.tasks_found.toLocaleString()}
|
||||
</Box>
|
||||
<Box className="metric-label">Found</Box>
|
||||
</MetricBubble>
|
||||
</Tooltip>
|
||||
|
||||
{/* Check Interval */}
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Check Interval
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
{dashboardData.stats.intelligent_scheduling
|
||||
? `Intelligent scheduling is enabled. The scheduler adjusts its check interval based on active strategies: ${dashboardData.stats.active_strategies_count > 0 ? '15-30 minutes when strategies are active' : '60 minutes when no active strategies'}. Current interval: ${dashboardData.stats.check_interval_minutes} minutes.`
|
||||
: `Fixed check interval: ${dashboardData.stats.check_interval_minutes} minutes. The scheduler checks for due tasks at this interval.`}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<MetricBubble>
|
||||
<Box className="metric-icon">
|
||||
<AccessTimeIcon fontSize="small" />
|
||||
</Box>
|
||||
<Box className="metric-value">
|
||||
{dashboardData.stats.check_interval_minutes >= 60
|
||||
? `${Math.floor(dashboardData.stats.check_interval_minutes / 60)}h`
|
||||
: `${dashboardData.stats.check_interval_minutes}m`}
|
||||
</Box>
|
||||
<Box className="metric-label">Interval</Box>
|
||||
</MetricBubble>
|
||||
</Tooltip>
|
||||
|
||||
{/* Active Strategies */}
|
||||
<Tooltip
|
||||
title={
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Active Strategies
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
{dashboardData.stats.active_strategies_count > 0
|
||||
? `There are ${dashboardData.stats.active_strategies_count} active content strategy(ies) with monitoring tasks. The scheduler will check more frequently when strategies are active.`
|
||||
: 'No active content strategies with monitoring tasks. The scheduler will check less frequently (every 60 minutes) to conserve resources.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<MetricBubble>
|
||||
<Box className="metric-icon" sx={{ color: dashboardData.stats.active_strategies_count > 0 ? '#00ff00' : '#888' }}>
|
||||
<TrendingUpIcon fontSize="small" />
|
||||
</Box>
|
||||
<Box className="metric-value">
|
||||
{dashboardData.stats.active_strategies_count}
|
||||
</Box>
|
||||
<Box className="metric-label">Strategies</Box>
|
||||
</MetricBubble>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</TerminalHeader>
|
||||
|
||||
{/* Error Alert */}
|
||||
{error && (
|
||||
<TerminalAlert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</TerminalAlert>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && !dashboardData ? (
|
||||
<TerminalLoading>
|
||||
<CircularProgress />
|
||||
</TerminalLoading>
|
||||
) : dashboardData ? (
|
||||
<>
|
||||
{/* Debug Info removed - status moved to refresh icon tooltip */}
|
||||
|
||||
{/* Stats Cards removed - metrics moved to header as bubbles */}
|
||||
|
||||
{/* Jobs Tree and Failures/Insights Side by Side */}
|
||||
<Box display="flex" gap={3} flexDirection={{ xs: 'column', lg: 'row' }} mb={4} alignItems="stretch">
|
||||
<Box flex={2} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{dashboardData.jobs && dashboardData.jobs.length > 0 ? (
|
||||
<SchedulerJobsTree
|
||||
jobs={dashboardData.jobs}
|
||||
recurringJobs={dashboardData.recurring_jobs || 0}
|
||||
oneTimeJobs={dashboardData.one_time_jobs || 0}
|
||||
/>
|
||||
) : (
|
||||
<TerminalAlert severity="info">No jobs scheduled</TerminalAlert>
|
||||
)}
|
||||
</Box>
|
||||
<Box flex={1} sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<FailuresInsights stats={dashboardData.stats} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* OAuth Token Status */}
|
||||
<Box mb={4}>
|
||||
<OAuthTokenStatus compact={true} />
|
||||
</Box>
|
||||
|
||||
{/* Execution Logs */}
|
||||
<Box mb={4}>
|
||||
<ExecutionLogsTable initialLimit={50} />
|
||||
</Box>
|
||||
|
||||
{/* Scheduler Event History */}
|
||||
<Box mb={4}>
|
||||
<SchedulerEventHistory limit={50} />
|
||||
</Box>
|
||||
|
||||
{/* Scheduler Charts Visualization */}
|
||||
<Box mb={4}>
|
||||
<SchedulerCharts />
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<TerminalAlert severity="info">
|
||||
No scheduler data available. The scheduler may not be running.
|
||||
</TerminalAlert>
|
||||
)}
|
||||
|
||||
{/* Auto-refresh indicator */}
|
||||
{autoRefreshInterval && dashboardData?.stats && (
|
||||
<Box mt={2} display="flex" justifyContent="center">
|
||||
<TerminalChip
|
||||
icon={<CheckCircleIcon sx={{ color: '#00ff00', fontSize: 14 }} />}
|
||||
label={`Auto-refresh: ${dashboardData.stats.check_interval_minutes}min interval`}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</TerminalContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchedulerDashboard;
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
UsageStatsSchema,
|
||||
} from '../types/billing';
|
||||
|
||||
// API base configuration
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
// API base configuration - consistent with client.ts pattern
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL || '';
|
||||
|
||||
// Create axios instance with default config
|
||||
const billingAPI = axios.create({
|
||||
|
||||
@@ -308,9 +308,7 @@ export const blogWriterApi = {
|
||||
},
|
||||
|
||||
async pollResearchStatus(taskId: string): Promise<TaskStatusResponse> {
|
||||
console.log('Polling research status for task:', taskId);
|
||||
const { data } = await pollingApiClient.get(`/api/blog/research/status/${taskId}`);
|
||||
console.log('Research status response:', data);
|
||||
return data;
|
||||
},
|
||||
|
||||
|
||||
@@ -77,8 +77,8 @@ class HallucinationDetectorService {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor() {
|
||||
// Use environment variable or default to localhost
|
||||
this.baseUrl = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
// Consistent API URL pattern - no hardcoded localhost fallback
|
||||
this.baseUrl = process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL || '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
CacheStatsSchema,
|
||||
} from '../types/monitoring';
|
||||
|
||||
// API base configuration
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
// API base configuration - consistent with client.ts pattern
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL || '';
|
||||
|
||||
// Create axios instance for monitoring APIs
|
||||
const monitoringAPI = axios.create({
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
CopilotSuggestion
|
||||
} from '../types/seoCopilotTypes';
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:8000';
|
||||
// Consistent API URL pattern - use same env vars as other services
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL || '';
|
||||
|
||||
class SEOApiService {
|
||||
private baseUrl: string;
|
||||
|
||||
@@ -21,7 +21,8 @@ export interface WASuggestResponse {
|
||||
class WritingAssistantService {
|
||||
private baseUrl: string;
|
||||
constructor() {
|
||||
this.baseUrl = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
// Consistent API URL pattern - no hardcoded localhost fallback
|
||||
this.baseUrl = process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL || '';
|
||||
}
|
||||
|
||||
async suggest(text: string): Promise<WASuggestion[]> {
|
||||
|
||||
@@ -373,4 +373,48 @@ a:hover {
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Blog Writer - Light Theme Override for High Readability */
|
||||
body.blog-writer-page,
|
||||
html.blog-writer-page {
|
||||
background-color: #ffffff !important;
|
||||
color: #1a1a1a !important;
|
||||
}
|
||||
|
||||
/* Blog Writer Container - Light Background */
|
||||
.blog-writer-container {
|
||||
background-color: #ffffff !important;
|
||||
color: #1a1a1a !important;
|
||||
}
|
||||
|
||||
/* Override MUI dark theme for blog writer components */
|
||||
.blog-writer-container .MuiPaper-root,
|
||||
.blog-writer-container .MuiCard-root,
|
||||
.blog-writer-container .MuiDialog-root .MuiPaper-root,
|
||||
.blog-writer-container .MuiModal-root .MuiPaper-root {
|
||||
background-color: #ffffff !important;
|
||||
color: #1a1a1a !important;
|
||||
}
|
||||
|
||||
/* Ensure text is readable on light background */
|
||||
.blog-writer-container .MuiTypography-root,
|
||||
.blog-writer-container .MuiButton-root,
|
||||
.blog-writer-container .MuiInputBase-root {
|
||||
color: #1a1a1a !important;
|
||||
}
|
||||
|
||||
/* Ensure buttons and inputs are visible on light background */
|
||||
.blog-writer-container .MuiButton-outlined {
|
||||
border-color: rgba(26, 26, 26, 0.23) !important;
|
||||
color: #1a1a1a !important;
|
||||
}
|
||||
|
||||
.blog-writer-container .MuiTextField-root .MuiOutlinedInput-root {
|
||||
background-color: #ffffff !important;
|
||||
color: #1a1a1a !important;
|
||||
}
|
||||
|
||||
.blog-writer-container .MuiTextField-root .MuiOutlinedInput-root fieldset {
|
||||
border-color: rgba(26, 26, 26, 0.23) !important;
|
||||
}
|
||||
@@ -3,8 +3,12 @@ const testAIIntegration = async () => {
|
||||
try {
|
||||
console.log('Testing AI Integration...');
|
||||
|
||||
// Get API URL from environment variables (consistent with other services)
|
||||
const apiUrl = process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL || '';
|
||||
const baseUrl = apiUrl || 'http://localhost:8000'; // Fallback only for test utility
|
||||
|
||||
// Test the AI analytics endpoint
|
||||
const response = await fetch('http://localhost:8000/api/content-planning/ai-analytics/');
|
||||
const response = await fetch(`${baseUrl}/api/content-planning/ai-analytics/`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
|
||||
191
frontend/src/utils/keywordExpansion.ts
Normal file
191
frontend/src/utils/keywordExpansion.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Smart Keyword Expansion Utility
|
||||
* Expands user keywords with industry-specific related terms using rule-based logic
|
||||
*/
|
||||
|
||||
// Industry-specific keyword expansion maps
|
||||
// Format: { industry: { keyword: [expansions] } }
|
||||
const industryKeywordExpansions: Record<string, Record<string, string[]>> = {
|
||||
Healthcare: {
|
||||
'AI': ['medical AI', 'healthcare AI', 'clinical AI', 'diagnostic AI', 'healthcare automation'],
|
||||
'tools': ['medical devices', 'clinical tools', 'diagnostic systems', 'healthcare software'],
|
||||
'automation': ['healthcare automation', 'clinical automation', 'patient care automation', 'medical workflow automation'],
|
||||
'technology': ['healthtech', 'medical technology', 'clinical technology', 'digital health'],
|
||||
'data': ['health data', 'medical records', 'patient data', 'clinical data', 'healthcare analytics'],
|
||||
'research': ['medical research', 'clinical research', 'biomedical research', 'healthcare studies'],
|
||||
'management': ['patient management', 'care coordination', 'healthcare administration'],
|
||||
},
|
||||
Technology: {
|
||||
'AI': ['machine learning', 'deep learning', 'neural networks', 'artificial intelligence applications'],
|
||||
'cloud': ['AWS', 'Azure', 'GCP', 'cloud infrastructure', 'cloud computing'],
|
||||
'security': ['cybersecurity', 'data protection', 'privacy compliance', 'information security'],
|
||||
'automation': ['IT automation', 'devops automation', 'software automation', 'process automation'],
|
||||
'development': ['software development', 'web development', 'mobile development', 'app development'],
|
||||
'tools': ['development tools', 'software tools', 'developer tools', 'tech stack'],
|
||||
'platform': ['SaaS platform', 'cloud platform', 'development platform', 'tech platform'],
|
||||
},
|
||||
Finance: {
|
||||
'fintech': ['financial technology', 'digital banking', 'payment solutions', 'financial services tech'],
|
||||
'investing': ['investment strategies', 'portfolio management', 'trading platforms', 'wealth management'],
|
||||
'cryptocurrency': ['blockchain', 'digital assets', 'DeFi', 'crypto trading'],
|
||||
'banking': ['digital banking', 'online banking', 'mobile banking', 'banking technology'],
|
||||
'payment': ['payment processing', 'payment gateways', 'digital payments', 'payment solutions'],
|
||||
'analysis': ['financial analysis', 'market analysis', 'risk analysis', 'investment analysis'],
|
||||
'compliance': ['financial compliance', 'regulatory compliance', 'fintech regulations'],
|
||||
},
|
||||
Marketing: {
|
||||
'SEO': ['search engine optimization', 'SEO strategy', 'SEO tools', 'keyword research'],
|
||||
'content': ['content marketing', 'content strategy', 'content creation', 'content distribution'],
|
||||
'social media': ['social media marketing', 'social media strategy', 'social media advertising'],
|
||||
'advertising': ['digital advertising', 'online advertising', 'PPC', 'display advertising'],
|
||||
'analytics': ['marketing analytics', 'web analytics', 'campaign analytics', 'performance metrics'],
|
||||
'automation': ['marketing automation', 'email marketing', 'lead generation', 'CRM'],
|
||||
'strategy': ['marketing strategy', 'brand strategy', 'digital strategy', 'growth strategy'],
|
||||
},
|
||||
Business: {
|
||||
'management': ['business management', 'operations management', 'strategic management'],
|
||||
'strategy': ['business strategy', 'growth strategy', 'competitive strategy', 'market strategy'],
|
||||
'startup': ['startup funding', 'venture capital', 'startup ecosystem', 'entrepreneurship'],
|
||||
'operations': ['business operations', 'process optimization', 'operational efficiency'],
|
||||
'leadership': ['business leadership', 'executive leadership', 'management leadership'],
|
||||
'innovation': ['business innovation', 'digital transformation', 'business disruption'],
|
||||
'analytics': ['business analytics', 'data analytics', 'business intelligence', 'KPIs'],
|
||||
},
|
||||
Education: {
|
||||
'e-learning': ['online learning', 'distance education', 'digital learning', 'virtual classrooms'],
|
||||
'edtech': ['education technology', 'learning management systems', 'educational software'],
|
||||
'teaching': ['teaching methods', 'pedagogy', 'instructional design', 'curriculum development'],
|
||||
'student': ['student engagement', 'student success', 'student analytics', 'learning outcomes'],
|
||||
'training': ['professional training', 'skills development', 'corporate training', 'certification'],
|
||||
'assessment': ['educational assessment', 'learning assessment', 'student evaluation'],
|
||||
},
|
||||
Real_Estate: {
|
||||
'property': ['real estate', 'real estate market', 'property investment', 'property management'],
|
||||
'technology': ['proptech', 'real estate technology', 'property tech', 'real estate software'],
|
||||
'investment': ['real estate investment', 'property investment', 'real estate portfolio'],
|
||||
'market': ['housing market', 'real estate trends', 'market analysis', 'property values'],
|
||||
'management': ['property management', 'facility management', 'real estate operations'],
|
||||
},
|
||||
Travel: {
|
||||
'tourism': ['travel industry', 'hospitality', 'travel trends', 'tourism technology'],
|
||||
'booking': ['travel booking', 'online booking', 'travel platforms', 'reservation systems'],
|
||||
'technology': ['travel tech', 'travel technology', 'tourism tech', 'hospitality technology'],
|
||||
'experience': ['travel experience', 'customer experience', 'tourism experiences'],
|
||||
},
|
||||
Science: {
|
||||
'research': ['scientific research', 'academic research', 'research methods', 'research publications'],
|
||||
'technology': ['scientific technology', 'laboratory technology', 'research tools'],
|
||||
'data': ['scientific data', 'research data', 'experimental data', 'data analysis'],
|
||||
'innovation': ['scientific innovation', 'research innovation', 'scientific breakthroughs'],
|
||||
},
|
||||
Legal: {
|
||||
'technology': ['legal tech', 'legal technology', 'law tech', 'legal software'],
|
||||
'compliance': ['legal compliance', 'regulatory compliance', 'legal requirements'],
|
||||
'automation': ['legal automation', 'document automation', 'legal process automation'],
|
||||
'research': ['legal research', 'case research', 'legal analysis'],
|
||||
},
|
||||
Manufacturing: {
|
||||
'automation': ['manufacturing automation', 'industrial automation', 'factory automation', 'production automation'],
|
||||
'technology': ['industrial technology', 'manufacturing tech', 'Industry 4.0', 'smart manufacturing'],
|
||||
'quality': ['quality control', 'quality assurance', 'quality management', 'quality standards'],
|
||||
'efficiency': ['manufacturing efficiency', 'production efficiency', 'operational efficiency'],
|
||||
},
|
||||
Retail: {
|
||||
'e-commerce': ['online retail', 'digital commerce', 'ecommerce platform', 'online shopping'],
|
||||
'technology': ['retail tech', 'retail technology', 'retail innovation', 'retail software'],
|
||||
'customer': ['customer experience', 'customer engagement', 'customer service', 'customer analytics'],
|
||||
'inventory': ['inventory management', 'stock management', 'supply chain', 'warehouse management'],
|
||||
},
|
||||
Energy: {
|
||||
'renewable': ['solar energy', 'wind energy', 'renewable technology', 'clean energy'],
|
||||
'technology': ['energy technology', 'energy innovation', 'energy management systems'],
|
||||
'efficiency': ['energy efficiency', 'energy optimization', 'energy conservation'],
|
||||
},
|
||||
Agriculture: {
|
||||
'technology': ['agtech', 'agricultural technology', 'farm technology', 'precision agriculture'],
|
||||
'automation': ['farm automation', 'agricultural automation', 'precision farming'],
|
||||
'sustainability': ['sustainable farming', 'organic farming', 'agricultural sustainability'],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Expands keywords based on industry context
|
||||
* @param keywords - Array of user-entered keywords
|
||||
* @param industry - Selected industry (or 'General')
|
||||
* @returns Array of expanded keywords (originals + suggestions)
|
||||
*/
|
||||
export function expandKeywords(keywords: string[], industry: string): {
|
||||
original: string[];
|
||||
expanded: string[];
|
||||
suggestions: string[];
|
||||
} {
|
||||
if (!keywords || keywords.length === 0) {
|
||||
return { original: [], expanded: [], suggestions: [] };
|
||||
}
|
||||
|
||||
// Normalize industry name (handle spaces and case)
|
||||
const normalizedIndustry = industry.replace(/\s+/g, '_');
|
||||
|
||||
// Get expansion map for this industry, or empty object if not found
|
||||
const expansionMap = industryKeywordExpansions[normalizedIndustry] || {};
|
||||
|
||||
const originalKeywords = [...keywords];
|
||||
const suggestions: string[] = [];
|
||||
const expandedSet = new Set<string>();
|
||||
|
||||
// Add original keywords to expanded set
|
||||
originalKeywords.forEach(k => expandedSet.add(k.toLowerCase().trim()));
|
||||
|
||||
// For each keyword, find expansions
|
||||
originalKeywords.forEach(keyword => {
|
||||
const keywordLower = keyword.toLowerCase().trim();
|
||||
|
||||
// Direct match in expansion map
|
||||
if (expansionMap[keywordLower]) {
|
||||
expansionMap[keywordLower].forEach(expansion => {
|
||||
if (!expandedSet.has(expansion.toLowerCase())) {
|
||||
suggestions.push(expansion);
|
||||
expandedSet.add(expansion.toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Partial match: check if keyword contains any expansion key
|
||||
Object.keys(expansionMap).forEach(expansionKey => {
|
||||
if (keywordLower.includes(expansionKey) || expansionKey.includes(keywordLower)) {
|
||||
expansionMap[expansionKey].forEach(expansion => {
|
||||
if (!expandedSet.has(expansion.toLowerCase())) {
|
||||
suggestions.push(expansion);
|
||||
expandedSet.add(expansion.toLowerCase());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Return structure
|
||||
return {
|
||||
original: originalKeywords,
|
||||
expanded: Array.from(expandedSet).map(k => {
|
||||
// Preserve original casing if it exists in originals
|
||||
const originalMatch = originalKeywords.find(ok => ok.toLowerCase() === k);
|
||||
return originalMatch || k;
|
||||
}),
|
||||
suggestions: suggestions.slice(0, 8), // Limit to 8 suggestions to avoid overwhelming UI
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats keyword for display (capitalize first letter)
|
||||
*/
|
||||
export function formatKeyword(keyword: string): string {
|
||||
if (!keyword) return keyword;
|
||||
return keyword.charAt(0).toUpperCase() + keyword.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a keyword is an original (user-entered) or a suggestion
|
||||
*/
|
||||
export function isOriginalKeyword(keyword: string, originalKeywords: string[]): boolean {
|
||||
return originalKeywords.some(ok => ok.toLowerCase() === keyword.toLowerCase());
|
||||
}
|
||||
193
frontend/src/utils/researchAngles.ts
Normal file
193
frontend/src/utils/researchAngles.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Alternative Research Angles Utility
|
||||
* Generates related research angles based on user query intent using rule-based patterns
|
||||
*/
|
||||
|
||||
// Pattern-based angle generation templates
|
||||
const anglePatterns: Record<string, string[]> = {
|
||||
tools: [
|
||||
'Compare {topic}',
|
||||
'{topic} ROI analysis',
|
||||
'Best {topic} for {industry}',
|
||||
'{topic} implementation guide',
|
||||
'Top {topic} features and pricing',
|
||||
],
|
||||
trends: [
|
||||
'Latest {topic} trends',
|
||||
'{topic} market analysis',
|
||||
'{topic} future predictions',
|
||||
'{topic} adoption rates',
|
||||
'Emerging {topic} technologies',
|
||||
],
|
||||
strategies: [
|
||||
'{topic} implementation guide',
|
||||
'{topic} best practices',
|
||||
'{topic} case studies',
|
||||
'{topic} success strategies',
|
||||
'{topic} optimization techniques',
|
||||
],
|
||||
analysis: [
|
||||
'{topic} competitive analysis',
|
||||
'{topic} market share',
|
||||
'{topic} industry leaders',
|
||||
'{topic} SWOT analysis',
|
||||
'{topic} benchmarking',
|
||||
],
|
||||
guides: [
|
||||
'{topic} getting started guide',
|
||||
'{topic} for beginners',
|
||||
'{topic} step-by-step tutorial',
|
||||
'{topic} troubleshooting',
|
||||
'{topic} expert tips',
|
||||
],
|
||||
comparison: [
|
||||
'{topic} vs alternatives',
|
||||
'Best {topic} comparison',
|
||||
'{topic} feature comparison',
|
||||
'{topic} pricing comparison',
|
||||
'{topic} pros and cons',
|
||||
],
|
||||
general: [
|
||||
'What is {topic}',
|
||||
'How {topic} works',
|
||||
'{topic} benefits and challenges',
|
||||
'{topic} industry insights',
|
||||
'{topic} expert opinions',
|
||||
],
|
||||
};
|
||||
|
||||
// Keywords that indicate query intent
|
||||
const intentKeywords: Record<string, string[]> = {
|
||||
tools: ['tools', 'software', 'platform', 'system', 'solution', 'app', 'application', 'toolkit', 'suite'],
|
||||
trends: ['trends', 'future', 'emerging', 'latest', 'new', 'innovation', 'development', 'growth'],
|
||||
strategies: ['strategy', 'plan', 'approach', 'method', 'best practices', 'how to', 'guide', 'implementation'],
|
||||
analysis: ['analysis', 'compare', 'review', 'evaluate', 'assessment', 'study', 'research'],
|
||||
guides: ['guide', 'tutorial', 'how to', 'getting started', 'learn', 'tips', 'advice'],
|
||||
comparison: ['vs', 'versus', 'compare', 'comparison', 'alternative', 'difference'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Detects the primary intent of a query
|
||||
*/
|
||||
function detectQueryIntent(query: string): string {
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
// Check each intent category
|
||||
for (const [intent, keywords] of Object.entries(intentKeywords)) {
|
||||
if (keywords.some(keyword => queryLower.includes(keyword))) {
|
||||
return intent;
|
||||
}
|
||||
}
|
||||
|
||||
return 'general';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the main topic from a query
|
||||
*/
|
||||
function extractTopic(query: string, industry: string): string {
|
||||
// Remove common intent words to get the core topic
|
||||
const intentWords = Object.values(intentKeywords).flat();
|
||||
let topic = query.toLowerCase();
|
||||
|
||||
// Remove intent keywords
|
||||
for (const word of intentWords) {
|
||||
const regex = new RegExp(`\\b${word}\\b`, 'gi');
|
||||
topic = topic.replace(regex, '').trim();
|
||||
}
|
||||
|
||||
// Clean up extra whitespace and common stop words
|
||||
topic = topic
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\b(a|an|the|in|on|at|for|with|to|of|and|or|but)\b/g, '')
|
||||
.trim();
|
||||
|
||||
// If topic is too short or empty, use original query
|
||||
if (topic.length < 3 || topic.split(' ').length === 0) {
|
||||
topic = query.toLowerCase();
|
||||
}
|
||||
|
||||
// Capitalize first letter
|
||||
return topic.charAt(0).toUpperCase() + topic.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates alternative research angles based on user query
|
||||
* @param query - User's research query/keywords
|
||||
* @param industry - Selected industry (optional)
|
||||
* @returns Array of alternative research angle suggestions
|
||||
*/
|
||||
export function generateResearchAngles(query: string, industry: string = 'General'): string[] {
|
||||
if (!query || query.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Detect primary intent
|
||||
const intent = detectQueryIntent(query);
|
||||
|
||||
// Extract main topic
|
||||
const topic = extractTopic(query, industry);
|
||||
|
||||
// Get patterns for detected intent (fallback to general)
|
||||
const patterns = anglePatterns[intent] || anglePatterns.general;
|
||||
|
||||
// Generate angles using patterns
|
||||
const angles: string[] = [];
|
||||
|
||||
for (const pattern of patterns.slice(0, 5)) { // Limit to 5 angles
|
||||
let angle = pattern.replace('{topic}', topic);
|
||||
|
||||
// Replace industry placeholder if present
|
||||
if (industry && industry !== 'General') {
|
||||
angle = angle.replace('{industry}', industry);
|
||||
} else {
|
||||
// Remove industry-specific placeholder if no industry
|
||||
angle = angle.replace(' for {industry}', '');
|
||||
}
|
||||
|
||||
// Capitalize first letter
|
||||
angle = angle.charAt(0).toUpperCase() + angle.slice(1);
|
||||
|
||||
// Skip if angle is too similar to original query
|
||||
const angleLower = angle.toLowerCase();
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
if (angleLower !== queryLower && !queryLower.includes(angleLower) && !angleLower.includes(queryLower)) {
|
||||
angles.push(angle);
|
||||
}
|
||||
}
|
||||
|
||||
// Add industry-specific angle if industry is set
|
||||
if (industry && industry !== 'General' && angles.length < 5) {
|
||||
const industryAngle = `${topic} in ${industry} industry`;
|
||||
if (!angles.some(a => a.toLowerCase() === industryAngle.toLowerCase())) {
|
||||
angles.push(industryAngle);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have fewer than 3 angles, add some general ones
|
||||
if (angles.length < 3) {
|
||||
const generalPatterns = anglePatterns.general.slice(0, 3 - angles.length);
|
||||
for (const pattern of generalPatterns) {
|
||||
const angle = pattern.replace('{topic}', topic);
|
||||
if (!angles.some(a => a.toLowerCase() === angle.toLowerCase())) {
|
||||
angles.push(angle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and limit to 5
|
||||
const uniqueAngles = Array.from(new Set(angles.map(a => a.toLowerCase())))
|
||||
.slice(0, 5)
|
||||
.map(a => a.charAt(0).toUpperCase() + a.slice(1));
|
||||
|
||||
return uniqueAngles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an angle for display
|
||||
*/
|
||||
export function formatAngle(angle: string): string {
|
||||
if (!angle) return angle;
|
||||
return angle.charAt(0).toUpperCase() + angle.slice(1);
|
||||
}
|
||||
132
frontend/src/utils/researchHistory.ts
Normal file
132
frontend/src/utils/researchHistory.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { ResearchMode } from '../services/blogWriterApi';
|
||||
|
||||
export interface ResearchHistoryEntry {
|
||||
keywords: string[];
|
||||
industry: string;
|
||||
targetAudience: string;
|
||||
researchMode: ResearchMode;
|
||||
timestamp: number;
|
||||
resultSummary?: string; // Optional: show snippet from results
|
||||
}
|
||||
|
||||
const HISTORY_STORAGE_KEY = 'alwrity_research_history';
|
||||
const MAX_HISTORY_ENTRIES = 5;
|
||||
|
||||
/**
|
||||
* Get all research history entries, sorted by most recent first
|
||||
*/
|
||||
export function getResearchHistory(): ResearchHistoryEntry[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(HISTORY_STORAGE_KEY);
|
||||
if (!stored) return [];
|
||||
|
||||
const entries = JSON.parse(stored) as ResearchHistoryEntry[];
|
||||
if (!Array.isArray(entries)) return [];
|
||||
|
||||
// Sort by timestamp (most recent first) and limit to MAX_HISTORY_ENTRIES
|
||||
return entries
|
||||
.sort((a, b) => b.timestamp - a.timestamp)
|
||||
.slice(0, MAX_HISTORY_ENTRIES);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load research history:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new research entry to history
|
||||
*/
|
||||
export function addResearchHistory(entry: Omit<ResearchHistoryEntry, 'timestamp'>): void {
|
||||
try {
|
||||
const currentHistory = getResearchHistory();
|
||||
|
||||
// Create new entry with timestamp
|
||||
const newEntry: ResearchHistoryEntry = {
|
||||
...entry,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// Check if similar entry already exists (same keywords, industry, audience)
|
||||
const existingIndex = currentHistory.findIndex(
|
||||
(e) =>
|
||||
JSON.stringify(e.keywords.sort()) === JSON.stringify(entry.keywords.sort()) &&
|
||||
e.industry === entry.industry &&
|
||||
e.targetAudience === entry.targetAudience &&
|
||||
e.researchMode === entry.researchMode
|
||||
);
|
||||
|
||||
// If exists, remove it (we'll add it back at the top)
|
||||
const updatedHistory =
|
||||
existingIndex >= 0
|
||||
? currentHistory.filter((_, i) => i !== existingIndex)
|
||||
: currentHistory;
|
||||
|
||||
// Add new entry at the beginning and limit to MAX_HISTORY_ENTRIES
|
||||
const finalHistory = [newEntry, ...updatedHistory].slice(0, MAX_HISTORY_ENTRIES);
|
||||
|
||||
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(finalHistory));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save research history:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all research history
|
||||
*/
|
||||
export function clearResearchHistory(): void {
|
||||
try {
|
||||
localStorage.removeItem(HISTORY_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear research history:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific entry from history by timestamp
|
||||
*/
|
||||
export function removeResearchHistoryEntry(timestamp: number): void {
|
||||
try {
|
||||
const currentHistory = getResearchHistory();
|
||||
const updatedHistory = currentHistory.filter((e) => e.timestamp !== timestamp);
|
||||
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(updatedHistory));
|
||||
} catch (error) {
|
||||
console.warn('Failed to remove research history entry:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp for display (e.g., "2 hours ago", "Yesterday")
|
||||
*/
|
||||
export function formatHistoryTimestamp(timestamp: number): string {
|
||||
const now = Date.now();
|
||||
const diffMs = now - timestamp;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins} min${diffMins > 1 ? 's' : ''} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
if (diffDays === 1) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays} days ago`;
|
||||
|
||||
// For older entries, show date
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a short summary from keywords for display
|
||||
*/
|
||||
export function getHistorySummary(entry: ResearchHistoryEntry): string {
|
||||
if (entry.resultSummary) {
|
||||
return entry.resultSummary.length > 60
|
||||
? entry.resultSummary.substring(0, 60) + '...'
|
||||
: entry.resultSummary;
|
||||
}
|
||||
|
||||
// Fallback to first keyword or keywords joined
|
||||
if (entry.keywords.length === 0) return 'Research query';
|
||||
if (entry.keywords.length === 1) return entry.keywords[0];
|
||||
return entry.keywords.slice(0, 2).join(', ') + (entry.keywords.length > 2 ? '...' : '');
|
||||
}
|
||||
Reference in New Issue
Block a user