Scheduled research persona generation

This commit is contained in:
ajaysi
2025-11-05 08:51:00 +05:30
parent 55087c4f37
commit d99c7c83a7
98 changed files with 14518 additions and 828 deletions

View File

@@ -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 />} />

View File

@@ -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);

View 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'
);
}
};

View File

@@ -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
*/

View 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;
}
};

View 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'
);
}
};

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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}
</>
);
}

View File

@@ -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);

View File

@@ -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');
}}
/>
</>
);
};

View File

@@ -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.';
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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?.();

View File

@@ -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>
</>
);
};

View File

@@ -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);

View File

@@ -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 && (

View File

@@ -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,

View File

@@ -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(() => {

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;

View 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>
);
};

View File

@@ -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 = {

View File

@@ -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 }));

View File

@@ -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)',

View File

@@ -33,6 +33,9 @@ export interface ResearchWizardProps {
onCancel?: () => void;
initialKeywords?: string[];
initialIndustry?: string;
initialTargetAudience?: string;
initialResearchMode?: ResearchMode;
initialConfig?: ResearchConfig;
}
export interface ModeCardInfo {

View File

@@ -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;

View 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;

View 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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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;

View 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',
};

View File

@@ -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

View 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';
}

View File

@@ -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]);

View File

@@ -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;

View File

@@ -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>
);
};

View 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;

View File

@@ -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({

View File

@@ -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;
},

View File

@@ -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 || '';
}
/**

View File

@@ -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({

View File

@@ -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;

View File

@@ -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[]> {

View File

@@ -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;
}

View File

@@ -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}`);

View 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());
}

View 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);
}

View 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 ? '...' : '');
}