AI platform insights monitoring and website analysis monitoring services added
This commit is contained in:
86
frontend/src/api/platformInsightsMonitoring.ts
Normal file
86
frontend/src/api/platformInsightsMonitoring.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Platform Insights Monitoring API Client
|
||||
* Provides typed functions for fetching platform insights (GSC/Bing) monitoring data.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
// TypeScript interfaces
|
||||
export interface PlatformInsightsTask {
|
||||
id: number;
|
||||
platform: 'gsc' | 'bing';
|
||||
site_url: string | null;
|
||||
status: 'active' | 'failed' | 'paused';
|
||||
last_check: string | null;
|
||||
last_success: string | null;
|
||||
last_failure: string | null;
|
||||
failure_reason: string | null;
|
||||
next_check: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PlatformInsightsStatusResponse {
|
||||
success: boolean;
|
||||
user_id: string;
|
||||
gsc_tasks: PlatformInsightsTask[];
|
||||
bing_tasks: PlatformInsightsTask[];
|
||||
total_tasks: number;
|
||||
}
|
||||
|
||||
export interface PlatformInsightsExecutionLog {
|
||||
id: number;
|
||||
task_id: number;
|
||||
execution_date: string;
|
||||
status: 'success' | 'failed' | 'running' | 'skipped';
|
||||
result_data: any;
|
||||
error_message: string | null;
|
||||
execution_time_ms: number | null;
|
||||
data_source: 'cached' | 'api' | 'onboarding' | 'storage' | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PlatformInsightsLogsResponse {
|
||||
success: boolean;
|
||||
logs: PlatformInsightsExecutionLog[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform insights status for a user
|
||||
*/
|
||||
export const getPlatformInsightsStatus = async (
|
||||
userId: string
|
||||
): Promise<PlatformInsightsStatusResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/scheduler/platform-insights/status/${userId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching platform insights status:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to fetch platform insights status');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get execution logs for platform insights tasks
|
||||
*/
|
||||
export const getPlatformInsightsLogs = async (
|
||||
userId: string,
|
||||
limit: number = 10,
|
||||
taskId?: number
|
||||
): Promise<PlatformInsightsLogsResponse> => {
|
||||
try {
|
||||
const params: any = { limit };
|
||||
if (taskId) {
|
||||
params.task_id = taskId;
|
||||
}
|
||||
const response = await apiClient.get(`/api/scheduler/platform-insights/logs/${userId}`, {
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching platform insights logs:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to fetch platform insights logs');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,8 +9,10 @@ import { apiClient } from './client';
|
||||
export interface ProviderAvailability {
|
||||
google_available: boolean;
|
||||
exa_available: boolean;
|
||||
tavily_available: boolean;
|
||||
gemini_key_status: 'configured' | 'missing';
|
||||
exa_key_status: 'configured' | 'missing';
|
||||
tavily_key_status: 'configured' | 'missing';
|
||||
}
|
||||
|
||||
export interface PersonaDefaults {
|
||||
@@ -140,18 +142,85 @@ export const getResearchConfig = async (): Promise<ResearchConfigResponse> => {
|
||||
|
||||
/**
|
||||
* Get or refresh research persona
|
||||
* @param forceRefresh - If true, regenerate persona even if cache is valid
|
||||
* @param forceRefresh - If true, regenerate persona even if cache is valid
|
||||
*/
|
||||
export const refreshResearchPersona = async (forceRefresh: boolean = false): Promise<ResearchPersona> => {
|
||||
export const refreshResearchPersona = async (forceRefresh: boolean = false): Promise<ResearchPersona> => {
|
||||
try {
|
||||
const url = `/api/research/research-persona${forceRefresh ? '?force_refresh=true' : ''}`;
|
||||
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
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Competitor Analysis Response Interface
|
||||
*/
|
||||
export interface CompetitorAnalysisResponse {
|
||||
success: boolean;
|
||||
competitors?: Array<{
|
||||
name?: string;
|
||||
url?: string;
|
||||
domain?: string;
|
||||
description?: string;
|
||||
similarity_score?: number;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
social_media_accounts?: Record<string, string>;
|
||||
social_media_citations?: Array<{
|
||||
platform?: string;
|
||||
account?: string;
|
||||
url?: string;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
research_summary?: {
|
||||
total_competitors?: number;
|
||||
industry_insights?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
analysis_timestamp?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get competitor analysis data from onboarding
|
||||
*/
|
||||
export const getCompetitorAnalysis = async (): Promise<CompetitorAnalysisResponse> => {
|
||||
console.log('[getCompetitorAnalysis] ===== START: Fetching competitor analysis =====');
|
||||
try {
|
||||
console.log('[getCompetitorAnalysis] Making GET request to /api/research/competitor-analysis');
|
||||
const response = await apiClient.get('/api/research/competitor-analysis');
|
||||
console.log('[getCompetitorAnalysis] ✅ Response received:', {
|
||||
success: response.data?.success,
|
||||
competitorsCount: response.data?.competitors?.length || 0,
|
||||
error: response.data?.error,
|
||||
fullResponse: response.data
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const statusCode = error?.response?.status;
|
||||
const errorMessage = error?.response?.data?.detail || error?.response?.data?.error || error?.message || 'Unknown error';
|
||||
|
||||
console.error('[getCompetitorAnalysis] ❌ ERROR:', {
|
||||
status: statusCode,
|
||||
message: errorMessage,
|
||||
fullError: error,
|
||||
responseData: error?.response?.data
|
||||
});
|
||||
|
||||
// Return error response instead of throwing
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
error: errorMessage
|
||||
};
|
||||
console.log('[getCompetitorAnalysis] Returning error response:', errorResponse);
|
||||
return errorResponse;
|
||||
} finally {
|
||||
console.log('[getCompetitorAnalysis] ===== END: Fetching competitor analysis =====');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -38,10 +38,14 @@ export interface SchedulerJob {
|
||||
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
|
||||
platform?: string; // For OAuth token monitoring tasks and platform insights
|
||||
task_id?: number; // For OAuth token monitoring tasks, website analysis, and platform insights
|
||||
is_database_task?: boolean; // Flag to indicate DB task vs APScheduler job
|
||||
frequency?: string; // For OAuth tasks (e.g., 'Weekly')
|
||||
task_type?: string; // For website analysis tasks ('user_website' or 'competitor')
|
||||
task_category?: string; // 'website_analysis', 'platform_insights', 'oauth_token_monitoring'
|
||||
website_url?: string | null; // For website analysis tasks
|
||||
competitor_id?: number | null; // For competitor website analysis tasks
|
||||
}
|
||||
|
||||
export interface UserIsolation {
|
||||
@@ -128,6 +132,11 @@ export interface SchedulerEventHistoryResponse {
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
date_filter?: {
|
||||
days: number;
|
||||
cutoff_date: string;
|
||||
showing_events_since: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,17 +208,19 @@ export const getSchedulerJobs = async (): Promise<SchedulerJobsResponse> => {
|
||||
/**
|
||||
* Get scheduler event history from database.
|
||||
*
|
||||
* @param limit - Number of events to return (1-1000, default: 100)
|
||||
* @param limit - Number of events to return (1-500, default: 5 for initial load, expand to 50 on hover)
|
||||
* @param offset - Pagination offset (default: 0)
|
||||
* @param eventType - Filter by event type (check_cycle, interval_adjustment, start, stop, etc.)
|
||||
* @param days - Number of days to look back (1-90, default: 7 days)
|
||||
*/
|
||||
export const getSchedulerEventHistory = async (
|
||||
limit: number = 100,
|
||||
limit: number = 5,
|
||||
offset: number = 0,
|
||||
eventType?: 'check_cycle' | 'interval_adjustment' | 'start' | 'stop' | 'job_scheduled' | 'job_cancelled' | 'job_completed' | 'job_failed'
|
||||
eventType?: 'check_cycle' | 'interval_adjustment' | 'start' | 'stop' | 'job_scheduled' | 'job_cancelled' | 'job_completed' | 'job_failed',
|
||||
days: number = 7
|
||||
): Promise<SchedulerEventHistoryResponse> => {
|
||||
try {
|
||||
const params: any = { limit, offset };
|
||||
const params: any = { limit, offset, days };
|
||||
if (eventType) {
|
||||
params.event_type = eventType;
|
||||
}
|
||||
|
||||
122
frontend/src/api/websiteAnalysisMonitoring.ts
Normal file
122
frontend/src/api/websiteAnalysisMonitoring.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Website Analysis Monitoring API Client
|
||||
* Provides typed functions for fetching website analysis monitoring data.
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
// TypeScript interfaces
|
||||
export interface WebsiteAnalysisTask {
|
||||
id: number;
|
||||
website_url: string;
|
||||
task_type: 'user_website' | 'competitor';
|
||||
competitor_id: string | null;
|
||||
status: 'active' | 'failed' | 'paused';
|
||||
last_check: string | null;
|
||||
last_success: string | null;
|
||||
last_failure: string | null;
|
||||
failure_reason: string | null;
|
||||
next_check: string | null;
|
||||
frequency_days: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WebsiteAnalysisStatusResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
user_id: string;
|
||||
user_website_tasks: WebsiteAnalysisTask[];
|
||||
competitor_tasks: WebsiteAnalysisTask[];
|
||||
total_tasks: number;
|
||||
active_tasks: number;
|
||||
failed_tasks: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WebsiteAnalysisExecutionLog {
|
||||
id: number;
|
||||
task_id: number;
|
||||
website_url: string;
|
||||
task_type: 'user_website' | 'competitor';
|
||||
execution_date: string;
|
||||
status: 'success' | 'failed' | 'running' | 'skipped';
|
||||
result_data: any;
|
||||
error_message: string | null;
|
||||
execution_time_ms: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WebsiteAnalysisLogsResponse {
|
||||
logs: WebsiteAnalysisExecutionLog[];
|
||||
total_count: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface RetryWebsiteAnalysisResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
task: {
|
||||
id: number;
|
||||
website_url: string;
|
||||
status: string;
|
||||
next_check: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get website analysis status for a user
|
||||
*/
|
||||
export const getWebsiteAnalysisStatus = async (
|
||||
userId: string
|
||||
): Promise<WebsiteAnalysisStatusResponse> => {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/scheduler/website-analysis/status/${userId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching website analysis status:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to fetch website analysis status');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get execution logs for website analysis tasks
|
||||
*/
|
||||
export const getWebsiteAnalysisLogs = async (
|
||||
userId: string,
|
||||
limit: number = 10,
|
||||
offset: number = 0,
|
||||
taskId?: number
|
||||
): Promise<WebsiteAnalysisLogsResponse> => {
|
||||
try {
|
||||
const params: any = { limit, offset };
|
||||
if (taskId) {
|
||||
params.task_id = taskId;
|
||||
}
|
||||
const response = await apiClient.get(`/api/scheduler/website-analysis/logs/${userId}`, {
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching website analysis logs:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to fetch website analysis logs');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Manually retry a failed website analysis task
|
||||
*/
|
||||
export const retryWebsiteAnalysis = async (
|
||||
taskId: number
|
||||
): Promise<RetryWebsiteAnalysisResponse> => {
|
||||
try {
|
||||
const response = await apiClient.post(`/api/scheduler/website-analysis/retry/${taskId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error('Error retrying website analysis:', error);
|
||||
throw new Error(error.response?.data?.detail || 'Failed to retry website analysis');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -160,6 +160,11 @@ export const BlogWriter: React.FC = () => {
|
||||
seoRecommendationsApplied
|
||||
);
|
||||
|
||||
// Update ref when navigateToPhase changes
|
||||
React.useEffect(() => {
|
||||
navigateToPhaseRef.current = navigateToPhase;
|
||||
}, [navigateToPhase]);
|
||||
|
||||
// Phase restoration logic
|
||||
usePhaseRestoration({
|
||||
copilotKitAvailable,
|
||||
@@ -184,6 +189,9 @@ export const BlogWriter: React.FC = () => {
|
||||
sections
|
||||
);
|
||||
|
||||
// Store navigateToPhase in a ref for use in polling callbacks
|
||||
const navigateToPhaseRef = React.useRef<((phase: string) => void) | null>(null);
|
||||
|
||||
// Polling hooks - extracted to useBlogWriterPolling
|
||||
const {
|
||||
researchPolling,
|
||||
@@ -198,6 +206,19 @@ export const BlogWriter: React.FC = () => {
|
||||
onOutlineComplete: handleOutlineComplete,
|
||||
onOutlineError: handleOutlineError,
|
||||
onSectionsUpdate: setSections,
|
||||
onContentConfirmed: () => {
|
||||
debug.log('[BlogWriter] Content generation completed - auto-confirming content');
|
||||
setContentConfirmed(true);
|
||||
},
|
||||
navigateToPhase: (phase) => {
|
||||
debug.log('[BlogWriter] Navigating to phase after content generation', { phase });
|
||||
// Use ref to access navigateToPhase (defined later in component)
|
||||
if (navigateToPhaseRef.current) {
|
||||
setTimeout(() => {
|
||||
navigateToPhaseRef.current?.(phase);
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Modal visibility management - extracted to useModalVisibility
|
||||
|
||||
@@ -12,6 +12,8 @@ interface UseBlogWriterPollingProps {
|
||||
onOutlineComplete: (outline: any) => void;
|
||||
onOutlineError: (error: any) => void;
|
||||
onSectionsUpdate: (sections: Record<string, string>) => void;
|
||||
onContentConfirmed?: () => void; // Callback when content generation completes
|
||||
navigateToPhase?: (phase: string) => void; // Phase navigation function
|
||||
}
|
||||
|
||||
export const useBlogWriterPolling = ({
|
||||
@@ -19,6 +21,8 @@ export const useBlogWriterPolling = ({
|
||||
onOutlineComplete,
|
||||
onOutlineError,
|
||||
onSectionsUpdate,
|
||||
onContentConfirmed,
|
||||
navigateToPhase,
|
||||
}: UseBlogWriterPollingProps) => {
|
||||
// Research polling hook (for context awareness)
|
||||
const researchPolling = useResearchPolling({
|
||||
@@ -47,6 +51,15 @@ export const useBlogWriterPolling = ({
|
||||
if (Object.keys(newSections).length > 0) {
|
||||
const sectionIds = Object.keys(newSections);
|
||||
blogWriterCache.cacheContent(newSections, sectionIds);
|
||||
|
||||
// Auto-confirm content and navigate to SEO phase when content generation completes
|
||||
// This happens when user clicks "Next:Confirm and generate content"
|
||||
if (onContentConfirmed) {
|
||||
onContentConfirmed();
|
||||
}
|
||||
if (navigateToPhase) {
|
||||
navigateToPhase('seo');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../services/blogWriterApi';
|
||||
import { useResearchPolling } from '../../hooks/usePolling';
|
||||
@@ -60,6 +60,27 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal when research completes (status becomes 'completed' or polling stops with result)
|
||||
useEffect(() => {
|
||||
if (showProgressModal && (
|
||||
polling.currentStatus === 'completed' ||
|
||||
(!polling.isPolling && polling.result && polling.currentStatus !== 'failed')
|
||||
)) {
|
||||
console.info('[ResearchAction] Closing modal - research completed', {
|
||||
status: polling.currentStatus,
|
||||
isPolling: polling.isPolling,
|
||||
hasResult: !!polling.result
|
||||
});
|
||||
// Small delay to show completion message before closing
|
||||
const timer = setTimeout(() => {
|
||||
setShowProgressModal(false);
|
||||
setCurrentTaskId(null);
|
||||
setCurrentMessage('');
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [polling.currentStatus, polling.isPolling, polling.result, showProgressModal]);
|
||||
|
||||
useCopilotActionTyped({
|
||||
name: 'showResearchForm',
|
||||
description: 'Show keyword input form for blog research',
|
||||
@@ -235,12 +256,16 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
<>
|
||||
{showProgressModal && (
|
||||
<ResearchProgressModal
|
||||
open={showProgressModal}
|
||||
open={showProgressModal && polling.currentStatus !== 'completed'}
|
||||
title={"Research in progress"}
|
||||
status={polling.currentStatus}
|
||||
messages={polling.progressMessages}
|
||||
error={polling.error}
|
||||
onClose={() => setShowProgressModal(false)}
|
||||
onClose={() => {
|
||||
console.info('[ResearchAction] Modal closed manually');
|
||||
setShowProgressModal(false);
|
||||
setCurrentTaskId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -190,8 +190,21 @@ export const useSuggestions = ({
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No content yet, show generation option
|
||||
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
|
||||
// No content yet, but outline is confirmed - show content generation options
|
||||
if (hasContent) {
|
||||
// Content exists but not confirmed - show confirmation and SEO options
|
||||
items.push({
|
||||
title: 'Next: Run SEO Analysis',
|
||||
message: 'Please analyze the blog content for SEO. Run the analyzeSEO action right away and do not ask for confirmation.'
|
||||
});
|
||||
items.push({
|
||||
title: '📊 Content Analysis',
|
||||
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
|
||||
});
|
||||
} else {
|
||||
// No content at all - show generation option (only if no content exists)
|
||||
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
317
frontend/src/components/Research/OnboardingCompetitorModal.tsx
Normal file
317
frontend/src/components/Research/OnboardingCompetitorModal.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Chip,
|
||||
Avatar,
|
||||
Divider,
|
||||
Alert,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Close as CloseIcon,
|
||||
Business as BusinessIcon,
|
||||
Assessment as AssessmentIcon,
|
||||
OpenInNew as OpenInNewIcon,
|
||||
Link as LinkIcon
|
||||
} from '@mui/icons-material';
|
||||
import { CompetitorAnalysisResponse } from '../../api/researchConfig';
|
||||
|
||||
interface OnboardingCompetitorModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
data: CompetitorAnalysisResponse | null;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export const OnboardingCompetitorModal: React.FC<OnboardingCompetitorModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
data,
|
||||
loading = false,
|
||||
error = null
|
||||
}) => {
|
||||
if (!data && !loading && !error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const competitors = data?.competitors || [];
|
||||
const socialMediaAccounts = data?.social_media_accounts || {};
|
||||
const researchSummary = data?.research_summary || {};
|
||||
|
||||
const avgScore = competitors.length > 0
|
||||
? competitors.reduce((sum, c) => sum + (c.similarity_score || 0), 0) / competitors.length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="lg"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
background: 'linear-gradient(135deg, #fff 0%, #f8fafc 100%)',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
maxHeight: '90vh'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
pb: 2,
|
||||
borderBottom: '2px solid #e5e7eb'
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<BusinessIcon sx={{ fontSize: 32, color: '#0ea5e9' }} />
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||
Competitive Analysis from Onboarding
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#64748b', mt: 0.5 }}>
|
||||
{loading ? 'Loading...' : `${competitors.length} competitors analyzed`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Button onClick={onClose} size="small" sx={{ minWidth: 'auto', p: 1 }}>
|
||||
<CloseIcon />
|
||||
</Button>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ py: 3, overflowY: 'auto' }}>
|
||||
{loading && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" sx={{ ml: 2, color: '#64748b' }}>
|
||||
Loading competitor data...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
<Typography variant="body2">{error}</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !error && data && (
|
||||
<>
|
||||
{researchSummary.industry_insights && (
|
||||
<Alert
|
||||
severity="info"
|
||||
icon={<AssessmentIcon />}
|
||||
sx={{ mb: 3, bgcolor: '#e0f2fe', borderLeft: '4px solid #0ea5e9' }}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Market Insights
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#1e293b' }}>
|
||||
{researchSummary.industry_insights}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card sx={{
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||
borderLeft: '4px solid #0ea5e9'
|
||||
}}>
|
||||
<CardContent>
|
||||
<Typography variant="caption" sx={{ color: '#0369a1', fontWeight: 600 }}>
|
||||
Total Competitors
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ color: '#0c4a6e', fontWeight: 700 }}>
|
||||
{competitors.length}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card sx={{
|
||||
background: 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)',
|
||||
borderLeft: '4px solid #22c55e'
|
||||
}}>
|
||||
<CardContent>
|
||||
<Typography variant="caption" sx={{ color: '#15803d', fontWeight: 600 }}>
|
||||
Avg Similarity
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ color: '#166534', fontWeight: 700 }}>
|
||||
{Math.round(avgScore * 100)}%
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Card sx={{
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
borderLeft: '4px solid #f59e0b'
|
||||
}}>
|
||||
<CardContent>
|
||||
<Typography variant="caption" sx={{ color: '#d97706', fontWeight: 600 }}>
|
||||
Social Accounts Found
|
||||
</Typography>
|
||||
<Typography variant="h4" sx={{ color: '#92400e', fontWeight: 700 }}>
|
||||
{Object.keys(socialMediaAccounts).length}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{Object.keys(socialMediaAccounts).length > 0 && (
|
||||
<>
|
||||
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600, color: '#0f172a' }}>
|
||||
Social Media Accounts
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 3 }}>
|
||||
{Object.entries(socialMediaAccounts).map(([platform, url]) => (
|
||||
<Chip
|
||||
key={platform}
|
||||
icon={<LinkIcon />}
|
||||
label={`${platform}: ${url}`}
|
||||
clickable
|
||||
onClick={() => window.open(url, '_blank')}
|
||||
sx={{
|
||||
bgcolor: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
'&:hover': {
|
||||
bgcolor: '#f1f5f9',
|
||||
borderColor: '#cbd5e1'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Divider sx={{ my: 3 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{competitors.length > 0 ? (
|
||||
<>
|
||||
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600, color: '#0f172a' }}>
|
||||
Competitors ({competitors.length})
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{competitors.map((competitor, index) => (
|
||||
<Grid item xs={12} md={6} key={index}>
|
||||
<Card sx={{
|
||||
height: '100%',
|
||||
'&:hover': { boxShadow: 4 },
|
||||
transition: 'box-shadow 0.3s'
|
||||
}}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, mb: 2 }}>
|
||||
<Avatar sx={{ width: 40, height: 40, bgcolor: '#0ea5e9' }}>
|
||||
<BusinessIcon />
|
||||
</Avatar>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: '#0f172a',
|
||||
mb: 0.5,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{competitor.name || competitor.domain || 'Unknown Competitor'}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, flexWrap: 'wrap' }}>
|
||||
{competitor.similarity_score !== undefined && (
|
||||
<Chip
|
||||
label={`Similarity: ${Math.round(competitor.similarity_score * 100)}%`}
|
||||
size="small"
|
||||
sx={{
|
||||
bgcolor: competitor.similarity_score > 0.7
|
||||
? '#dcfce7'
|
||||
: competitor.similarity_score > 0.5
|
||||
? '#fef3c7'
|
||||
: '#fee2e2',
|
||||
color: competitor.similarity_score > 0.7
|
||||
? '#166534'
|
||||
: competitor.similarity_score > 0.5
|
||||
? '#92400e'
|
||||
: '#991b1b',
|
||||
fontWeight: 600
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{competitor.url && (
|
||||
<Button
|
||||
size="small"
|
||||
endIcon={<OpenInNewIcon />}
|
||||
href={competitor.url}
|
||||
target="_blank"
|
||||
sx={{ textTransform: 'none', fontSize: '0.75rem' }}
|
||||
>
|
||||
Visit
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{competitor.description && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: '#64748b',
|
||||
mb: 2,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{competitor.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{competitor.domain && (
|
||||
<Typography variant="caption" sx={{ color: '#94a3b8', display: 'block' }}>
|
||||
{competitor.domain}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
) : (
|
||||
!loading && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
<Typography variant="body2">
|
||||
No competitor data available. Please complete onboarding step 3 to analyze competitors.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 4, py: 2, borderTop: '1px solid #e5e7eb' }}>
|
||||
<Button onClick={onClose} variant="contained" sx={{ minWidth: 120 }}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useResearchWizard } from './hooks/useResearchWizard';
|
||||
import { useResearchExecution } from './hooks/useResearchExecution';
|
||||
import { ResearchInput } from './steps/ResearchInput';
|
||||
@@ -6,6 +6,9 @@ import { StepProgress } from './steps/StepProgress';
|
||||
import { StepResults } from './steps/StepResults';
|
||||
import { ResearchWizardProps } from './types/research.types';
|
||||
import { addResearchHistory } from '../../utils/researchHistory';
|
||||
import { getResearchConfig, ProviderAvailability } from '../../api/researchConfig';
|
||||
import { ProviderChips } from './steps/components/ProviderChips';
|
||||
import { AdvancedChip } from './steps/components/AdvancedChip';
|
||||
|
||||
export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
||||
onComplete,
|
||||
@@ -24,6 +27,30 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
||||
initialConfig
|
||||
);
|
||||
const execution = useResearchExecution();
|
||||
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability | null>(null);
|
||||
const [advanced, setAdvanced] = useState<boolean>(false);
|
||||
|
||||
// Load provider availability on mount
|
||||
useEffect(() => {
|
||||
const loadProviderAvailability = async () => {
|
||||
try {
|
||||
const config = await getResearchConfig();
|
||||
setProviderAvailability(config?.provider_availability || null);
|
||||
} catch (error) {
|
||||
console.error('[ResearchWizard] Failed to load provider availability:', error);
|
||||
// Set default availability on error
|
||||
setProviderAvailability({
|
||||
google_available: true,
|
||||
exa_available: false,
|
||||
tavily_available: false,
|
||||
gemini_key_status: 'missing',
|
||||
exa_key_status: 'missing',
|
||||
tavily_key_status: 'missing',
|
||||
});
|
||||
}
|
||||
};
|
||||
loadProviderAvailability();
|
||||
}, []);
|
||||
|
||||
// Handle results from execution
|
||||
useEffect(() => {
|
||||
@@ -73,13 +100,13 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
||||
|
||||
switch (wizard.state.currentStep) {
|
||||
case 1:
|
||||
return <ResearchInput {...stepProps} />;
|
||||
return <ResearchInput {...stepProps} advanced={advanced} onAdvancedChange={setAdvanced} />;
|
||||
case 2:
|
||||
return <StepProgress {...stepProps} execution={execution} />;
|
||||
case 3:
|
||||
return <StepResults {...stepProps} />;
|
||||
default:
|
||||
return <ResearchInput {...stepProps} />;
|
||||
return <ResearchInput {...stepProps} advanced={advanced} onAdvancedChange={setAdvanced} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,31 +123,124 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
||||
boxShadow: '0 4px 16px rgba(14, 165, 233, 0.1)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header */}
|
||||
{/* Header with Compact Steps */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.08) 0%, rgba(56, 189, 248, 0.08) 100%)',
|
||||
borderBottom: '1px solid rgba(14, 165, 233, 0.15)',
|
||||
padding: '20px 28px',
|
||||
padding: '14px 24px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h1 style={{
|
||||
margin: 0,
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0c4a6e',
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '24px' }}>
|
||||
{/* Title Section */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', flex: '1', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<h1 style={{
|
||||
margin: 0,
|
||||
fontSize: '20px',
|
||||
fontWeight: '700',
|
||||
color: '#0c4a6e',
|
||||
letterSpacing: '-0.01em',
|
||||
}}>
|
||||
Research Wizard
|
||||
</h1>
|
||||
|
||||
{/* Provider Status Chips */}
|
||||
<ProviderChips providerAvailability={providerAvailability} advanced={advanced} />
|
||||
|
||||
{/* Advanced Chip */}
|
||||
<AdvancedChip advanced={advanced} />
|
||||
</div>
|
||||
|
||||
{/* Compact Step Indicators */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginLeft: '8px',
|
||||
}}>
|
||||
Research Wizard
|
||||
</h1>
|
||||
<p style={{
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '13px',
|
||||
color: '#0369a1',
|
||||
fontWeight: '400',
|
||||
}}>
|
||||
Phase {wizard.state.currentStep} of {wizard.maxSteps} • AI-Powered Intelligence
|
||||
</p>
|
||||
{[1, 2, 3].map((step, index) => {
|
||||
const isActive = step === wizard.state.currentStep;
|
||||
const isCompleted = step < wizard.state.currentStep;
|
||||
const isClickable = step <= wizard.state.currentStep;
|
||||
|
||||
return (
|
||||
<React.Fragment key={step}>
|
||||
{index > 0 && (
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '2px',
|
||||
background: isCompleted || (step === wizard.state.currentStep)
|
||||
? 'linear-gradient(90deg, #22c55e 0%, #16a34a 100%)'
|
||||
: 'rgba(14, 165, 233, 0.2)',
|
||||
transition: 'all 0.3s ease',
|
||||
}} />
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
cursor: isClickable ? 'pointer' : 'default',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isClickable) {
|
||||
wizard.updateState({ currentStep: step });
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isClickable) {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '50%',
|
||||
background: isActive
|
||||
? 'linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%)'
|
||||
: isCompleted
|
||||
? 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)'
|
||||
: 'rgba(14, 165, 233, 0.1)',
|
||||
color: (isActive || isCompleted) ? 'white' : '#64748b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: '700',
|
||||
fontSize: '13px',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
border: isActive ? '2px solid rgba(14, 165, 233, 0.3)' : '2px solid rgba(14, 165, 233, 0.1)',
|
||||
boxShadow: isActive
|
||||
? '0 2px 8px rgba(14, 165, 233, 0.25)'
|
||||
: isCompleted
|
||||
? '0 1px 4px rgba(34, 197, 94, 0.2)'
|
||||
: 'none',
|
||||
}}>
|
||||
{isCompleted ? '✓' : step}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '11px',
|
||||
color: (isActive || isCompleted) ? '#0c4a6e' : '#64748b',
|
||||
fontWeight: isActive ? '600' : '400',
|
||||
letterSpacing: '0.01em',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{step === 1 && 'Configure'}
|
||||
{step === 2 && 'Execute'}
|
||||
{step === 3 && 'Analyze'}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cancel Button */}
|
||||
{onCancel && (
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -128,13 +248,13 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
||||
onCancel();
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
padding: '6px 12px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
color: '#dc2626',
|
||||
border: '1px solid rgba(239, 68, 68, 0.25)',
|
||||
borderRadius: '10px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
@@ -154,7 +274,7 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
||||
{/* Progress Bar */}
|
||||
<div style={{
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
height: '5px',
|
||||
height: '3px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
@@ -164,90 +284,11 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
||||
height: '100%',
|
||||
width: `${(wizard.state.currentStep / wizard.maxSteps) * 100}%`,
|
||||
transition: 'width 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 0 8px rgba(14, 165, 233, 0.4)',
|
||||
boxShadow: '0 0 6px rgba(14, 165, 233, 0.4)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step Indicators */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
padding: '24px 40px',
|
||||
borderBottom: '1px solid rgba(14, 165, 233, 0.15)',
|
||||
background: 'rgba(14, 165, 233, 0.03)',
|
||||
}}>
|
||||
{[1, 2, 3].map(step => {
|
||||
const isActive = step === wizard.state.currentStep;
|
||||
const isCompleted = step < wizard.state.currentStep;
|
||||
const isClickable = step <= wizard.state.currentStep;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
cursor: isClickable ? 'pointer' : 'default',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isClickable) {
|
||||
wizard.updateState({ currentStep: step });
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (isClickable) {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: '48px',
|
||||
height: '48px',
|
||||
borderRadius: '50%',
|
||||
background: isActive
|
||||
? 'linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%)'
|
||||
: isCompleted
|
||||
? 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)'
|
||||
: 'rgba(14, 165, 233, 0.1)',
|
||||
color: (isActive || isCompleted) ? 'white' : '#64748b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: '700',
|
||||
fontSize: '18px',
|
||||
marginBottom: '10px',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
border: isActive ? '2px solid rgba(14, 165, 233, 0.3)' : '2px solid rgba(14, 165, 233, 0.1)',
|
||||
boxShadow: isActive
|
||||
? '0 4px 16px rgba(14, 165, 233, 0.3)'
|
||||
: isCompleted
|
||||
? '0 2px 8px rgba(34, 197, 94, 0.2)'
|
||||
: 'none',
|
||||
}}>
|
||||
{isCompleted ? '✓' : step}
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
color: (isActive || isCompleted) ? '#0c4a6e' : '#64748b',
|
||||
fontWeight: isActive ? '600' : '400',
|
||||
letterSpacing: '0.01em',
|
||||
}}>
|
||||
{step === 1 && 'Configure'}
|
||||
{step === 2 && 'Execute'}
|
||||
{step === 3 && 'Analyze'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ padding: '20px' }}>
|
||||
{renderStep()}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface AdvancedChipProps {
|
||||
advanced: boolean;
|
||||
}
|
||||
|
||||
export const AdvancedChip: React.FC<AdvancedChipProps> = ({ advanced }) => {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Chip */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '4px 10px',
|
||||
background: advanced
|
||||
? 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.15) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%)',
|
||||
border: `1px solid ${advanced ? 'rgba(16, 185, 129, 0.3)' : 'rgba(239, 68, 68, 0.2)'}`,
|
||||
borderRadius: '12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: advanced ? '#10b981' : '#ef4444',
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
cursor: 'default',
|
||||
boxShadow: hovered
|
||||
? '0 2px 8px rgba(0, 0, 0, 0.12)'
|
||||
: '0 1px 3px rgba(0, 0, 0, 0.08)',
|
||||
transform: hovered ? 'translateY(-1px)' : 'translateY(0)',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '13px' }}>⚙️</span>
|
||||
<span>Advanced</span>
|
||||
<span style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: advanced ? '#10b981' : '#ef4444',
|
||||
boxShadow: advanced
|
||||
? '0 0 4px rgba(16, 185, 129, 0.4)'
|
||||
: '0 0 4px rgba(239, 68, 68, 0.4)',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
{hovered && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginTop: '8px',
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(15, 23, 42, 0.95)',
|
||||
color: '#f8fafc',
|
||||
fontSize: '11px',
|
||||
lineHeight: '1.5',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '240px',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.25)',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'normal',
|
||||
wordWrap: 'break-word',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
{advanced
|
||||
? 'Advanced mode is ON. Exa and Tavily configuration options are available.'
|
||||
: 'Advanced mode is OFF. Enable to access Exa and Tavily configuration options.'}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%) rotate(45deg)',
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
background: 'rgba(15, 23, 42, 0.95)',
|
||||
borderLeft: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { formatKeyword } from '../../../../utils/keywordExpansion';
|
||||
|
||||
interface CurrentKeywordsProps {
|
||||
keywords: string[];
|
||||
onRemoveKeyword: (keyword: string) => void;
|
||||
}
|
||||
|
||||
export const CurrentKeywords: React.FC<CurrentKeywordsProps> = ({ keywords, onRemoveKeyword }) => {
|
||||
if (keywords.length === 0) return null;
|
||||
|
||||
return (
|
||||
<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 ({keywords.length})
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6px',
|
||||
}}>
|
||||
{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={() => onRemoveKeyword(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>
|
||||
);
|
||||
};
|
||||
|
||||
183
frontend/src/components/Research/steps/components/ExaOptions.tsx
Normal file
183
frontend/src/components/Research/steps/components/ExaOptions.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React from 'react';
|
||||
import { ResearchConfig } from '../../../../services/blogWriterApi';
|
||||
import { exaCategories, exaSearchTypes } from '../utils/constants';
|
||||
|
||||
interface ExaOptionsProps {
|
||||
config: ResearchConfig;
|
||||
onConfigUpdate: (updates: Partial<ResearchConfig>) => void;
|
||||
}
|
||||
|
||||
export const ExaOptions: React.FC<ExaOptionsProps> = ({ config, onConfigUpdate }) => {
|
||||
const handleCategoryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
onConfigUpdate({ exa_category: value || undefined });
|
||||
};
|
||||
|
||||
const handleSearchTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value as 'auto' | 'keyword' | 'neural';
|
||||
onConfigUpdate({ exa_search_type: value });
|
||||
};
|
||||
|
||||
const handleIncludeDomainsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
const domains = value.split(',').map(d => d.trim()).filter(Boolean);
|
||||
onConfigUpdate({ exa_include_domains: domains });
|
||||
};
|
||||
|
||||
const handleExcludeDomainsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
const domains = value.split(',').map(d => d.trim()).filter(Boolean);
|
||||
onConfigUpdate({ exa_exclude_domains: domains });
|
||||
};
|
||||
|
||||
return (
|
||||
<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)',
|
||||
borderRadius: '14px',
|
||||
padding: '16px',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '14px',
|
||||
}}>
|
||||
<span style={{ fontSize: '18px' }}>🧠</span>
|
||||
<strong style={{ color: '#6b21a8', fontSize: '13px' }}>Exa Neural Search Options</strong>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: '12px',
|
||||
marginBottom: '12px',
|
||||
}}>
|
||||
{/* Exa Category */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#6b21a8',
|
||||
}}>
|
||||
Content Category
|
||||
</label>
|
||||
<select
|
||||
value={config.exa_category || ''}
|
||||
onChange={handleCategoryChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(139, 92, 246, 0.2)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{exaCategories.map(cat => (
|
||||
<option key={cat.value} value={cat.value}>{cat.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Exa Search Type */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#6b21a8',
|
||||
}}>
|
||||
Search Algorithm
|
||||
</label>
|
||||
<select
|
||||
value={config.exa_search_type || 'auto'}
|
||||
onChange={handleSearchTypeChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(139, 92, 246, 0.2)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{exaSearchTypes.map(type => (
|
||||
<option key={type.value} value={type.value}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domain Filters */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#6b21a8',
|
||||
}}>
|
||||
Include Domains (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.exa_include_domains?.join(', ') || ''}
|
||||
onChange={handleIncludeDomainsChange}
|
||||
placeholder="e.g., nature.com, arxiv.org"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(139, 92, 246, 0.2)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#6b21a8',
|
||||
}}>
|
||||
Exclude Domains (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.exa_exclude_domains?.join(', ') || ''}
|
||||
onChange={handleExcludeDomainsChange}
|
||||
placeholder="e.g., spam.com, ads.com"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(139, 92, 246, 0.2)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { formatKeyword } from '../../../../utils/keywordExpansion';
|
||||
|
||||
interface KeywordExpansionProps {
|
||||
suggestions: string[];
|
||||
currentKeywords: string[];
|
||||
industry: string;
|
||||
onAddSuggestion: (suggestion: string) => void;
|
||||
}
|
||||
|
||||
export const KeywordExpansion: React.FC<KeywordExpansionProps> = ({
|
||||
suggestions,
|
||||
currentKeywords,
|
||||
industry,
|
||||
onAddSuggestion,
|
||||
}) => {
|
||||
if (suggestions.length === 0 || industry === 'General') return null;
|
||||
|
||||
return (
|
||||
<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 {industry}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
}}>
|
||||
{suggestions.map((suggestion, idx) => {
|
||||
const isAlreadyAdded = currentKeywords.some(k => k.toLowerCase() === suggestion.toLowerCase());
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => !isAlreadyAdded && onAddSuggestion(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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ProviderAvailability } from '../../../../api/researchConfig';
|
||||
|
||||
interface ProviderChipsProps {
|
||||
providerAvailability: ProviderAvailability | null;
|
||||
advanced?: boolean;
|
||||
}
|
||||
|
||||
export const ProviderChips: React.FC<ProviderChipsProps> = ({ providerAvailability, advanced = false }) => {
|
||||
const [hoveredChip, setHoveredChip] = useState<string | null>(null);
|
||||
|
||||
if (!providerAvailability) return null;
|
||||
|
||||
const providers = [
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
available: providerAvailability.google_available,
|
||||
status: providerAvailability.gemini_key_status,
|
||||
icon: '🔍',
|
||||
tooltip: 'Google Search powered by Gemini AI. Provides comprehensive web search results with semantic understanding and real-time information from across the web.',
|
||||
color: providerAvailability.google_available
|
||||
? 'linear-gradient(135deg, rgba(66, 133, 244, 0.15) 0%, rgba(52, 168, 83, 0.15) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%)',
|
||||
borderColor: providerAvailability.google_available
|
||||
? 'rgba(66, 133, 244, 0.3)'
|
||||
: 'rgba(239, 68, 68, 0.2)',
|
||||
textColor: providerAvailability.google_available ? '#4285f4' : '#ef4444',
|
||||
},
|
||||
{
|
||||
id: 'exa',
|
||||
name: 'Exa',
|
||||
available: providerAvailability.exa_available,
|
||||
status: providerAvailability.exa_key_status,
|
||||
icon: '🧠',
|
||||
tooltip: 'Exa Neural Search. Advanced semantic search engine that understands context and meaning, providing highly relevant results through neural network-powered query understanding.',
|
||||
// Show green when advanced is ON and available, red when advanced is OFF or not available
|
||||
isAdvanced: true,
|
||||
color: (advanced && providerAvailability.exa_available)
|
||||
? 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.15) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%)',
|
||||
borderColor: (advanced && providerAvailability.exa_available)
|
||||
? 'rgba(16, 185, 129, 0.3)'
|
||||
: 'rgba(239, 68, 68, 0.2)',
|
||||
textColor: (advanced && providerAvailability.exa_available) ? '#10b981' : '#ef4444',
|
||||
chipStatus: (advanced && providerAvailability.exa_available) ? '#10b981' : '#ef4444',
|
||||
},
|
||||
{
|
||||
id: 'tavily',
|
||||
name: 'Tavily',
|
||||
available: providerAvailability.tavily_available,
|
||||
status: providerAvailability.tavily_key_status,
|
||||
icon: '🤖',
|
||||
tooltip: 'Tavily AI Research Engine. Specialized AI-powered research tool designed for comprehensive content discovery, providing deep insights and structured research data from multiple sources.',
|
||||
// Show green when advanced is ON and available, red when advanced is OFF or not available
|
||||
isAdvanced: true,
|
||||
color: (advanced && providerAvailability.tavily_available)
|
||||
? 'linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(5, 150, 105, 0.15) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.1) 0%, rgba(220, 38, 38, 0.1) 100%)',
|
||||
borderColor: (advanced && providerAvailability.tavily_available)
|
||||
? 'rgba(16, 185, 129, 0.3)'
|
||||
: 'rgba(239, 68, 68, 0.2)',
|
||||
textColor: (advanced && providerAvailability.tavily_available) ? '#10b981' : '#ef4444',
|
||||
chipStatus: (advanced && providerAvailability.tavily_available) ? '#10b981' : '#ef4444',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginLeft: '16px',
|
||||
}}>
|
||||
{providers.map((provider) => {
|
||||
const isHovered = hoveredChip === provider.id;
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
style={{
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={() => setHoveredChip(provider.id)}
|
||||
onMouseLeave={() => setHoveredChip(null)}
|
||||
>
|
||||
{/* Chip */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '4px 10px',
|
||||
background: provider.color,
|
||||
border: `1px solid ${provider.borderColor}`,
|
||||
borderRadius: '12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: provider.textColor,
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
cursor: 'default',
|
||||
boxShadow: isHovered
|
||||
? '0 2px 8px rgba(0, 0, 0, 0.12)'
|
||||
: '0 1px 3px rgba(0, 0, 0, 0.08)',
|
||||
transform: isHovered ? 'translateY(-1px)' : 'translateY(0)',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '13px' }}>{provider.icon}</span>
|
||||
<span>{provider.name}</span>
|
||||
<span style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: (provider as any).chipStatus || (provider.available ? '#10b981' : '#ef4444'),
|
||||
boxShadow: ((provider as any).chipStatus === '#10b981') || (provider.available && !(provider as any).isAdvanced)
|
||||
? '0 0 4px rgba(16, 185, 129, 0.4)'
|
||||
: '0 0 4px rgba(239, 68, 68, 0.4)',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Tooltip */}
|
||||
{isHovered && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginTop: '8px',
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(15, 23, 42, 0.95)',
|
||||
color: '#f8fafc',
|
||||
fontSize: '11px',
|
||||
lineHeight: '1.5',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '280px',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.25)',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'normal',
|
||||
wordWrap: 'break-word',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
>
|
||||
{provider.tooltip}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%) rotate(45deg)',
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
background: 'rgba(15, 23, 42, 0.95)',
|
||||
borderLeft: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { ProviderAvailability } from '../../../../api/researchConfig';
|
||||
|
||||
interface ProviderStatusProps {
|
||||
providerAvailability: ProviderAvailability | null;
|
||||
}
|
||||
|
||||
export const ProviderStatus: React.FC<ProviderStatusProps> = ({ providerAvailability }) => {
|
||||
if (!providerAvailability) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: '20px',
|
||||
padding: '10px 14px',
|
||||
background: 'rgba(241, 245, 249, 0.5)',
|
||||
border: '1px solid rgba(203, 213, 225, 0.3)',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '16px',
|
||||
fontSize: '11px',
|
||||
color: '#64748b',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
<span style={{ fontWeight: '600', color: '#475569' }}>Provider Status:</span>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}>
|
||||
<span style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: providerAvailability.google_available ? '#10b981' : '#ef4444',
|
||||
}} />
|
||||
<span>Google: {providerAvailability.gemini_key_status}</span>
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}>
|
||||
<span style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: providerAvailability.exa_available ? '#10b981' : '#ef4444',
|
||||
}} />
|
||||
<span>Exa: {providerAvailability.exa_key_status}</span>
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}>
|
||||
<span style={{
|
||||
width: '6px',
|
||||
height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: providerAvailability.tavily_available ? '#10b981' : '#ef4444',
|
||||
}} />
|
||||
<span>Tavily: {providerAvailability.tavily_key_status}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { formatAngle } from '../../../../utils/researchAngles';
|
||||
|
||||
interface ResearchAnglesProps {
|
||||
angles: string[];
|
||||
onUseAngle: (angle: string) => void;
|
||||
}
|
||||
|
||||
export const ResearchAngles: React.FC<ResearchAnglesProps> = ({ angles, onUseAngle }) => {
|
||||
if (angles.length === 0) return null;
|
||||
|
||||
return (
|
||||
<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',
|
||||
}}>
|
||||
{angles.map((angle, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onUseAngle(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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { ProviderAvailability } from '../../../../api/researchConfig';
|
||||
import { industries } from '../utils/constants';
|
||||
|
||||
interface ResearchControlsBarProps {
|
||||
industry: string;
|
||||
providerAvailability: ProviderAvailability | null;
|
||||
onIndustryChange: (industry: string) => void;
|
||||
}
|
||||
|
||||
export const ResearchControlsBar: React.FC<ResearchControlsBarProps> = ({
|
||||
industry,
|
||||
providerAvailability,
|
||||
onIndustryChange,
|
||||
}) => {
|
||||
const dropdownStyle = {
|
||||
minWidth: '130px',
|
||||
padding: '7px 28px 7px 10px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(15, 23, 42, 0.1)',
|
||||
borderRadius: '8px',
|
||||
background: '#ffffff',
|
||||
color: '#0f172a',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", sans-serif',
|
||||
fontWeight: '500',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.04)',
|
||||
appearance: 'none' as const,
|
||||
WebkitAppearance: 'none' as const,
|
||||
MozAppearance: 'none' as const,
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23475569' d='M5 7L1 3h8z'/%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'no-repeat' as const,
|
||||
backgroundPosition: 'right 9px center',
|
||||
backgroundSize: '10px 10px',
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLSelectElement>) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.4)';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 2px rgba(14, 165, 233, 0.08), 0 1px 3px rgba(0, 0, 0, 0.08)';
|
||||
e.currentTarget.style.background = '#ffffff';
|
||||
e.currentTarget.style.backgroundImage = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%230ea5e9' d='M5 7L1 3h8z'/%3E%3C/svg%3E")`;
|
||||
e.currentTarget.style.backgroundSize = '10px 10px';
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLSelectElement>) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(15, 23, 42, 0.1)';
|
||||
e.currentTarget.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.04)';
|
||||
e.currentTarget.style.background = '#ffffff';
|
||||
e.currentTarget.style.backgroundImage = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23475569' d='M5 7L1 3h8z'/%3E%3C/svg%3E")`;
|
||||
e.currentTarget.style.backgroundSize = '10px 10px';
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent<HTMLSelectElement>) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(15, 23, 42, 0.15)';
|
||||
e.currentTarget.style.boxShadow = '0 1px 3px rgba(0, 0, 0, 0.06)';
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: React.MouseEvent<HTMLSelectElement>) => {
|
||||
if (document.activeElement !== e.currentTarget) {
|
||||
e.currentTarget.style.borderColor = 'rgba(15, 23, 42, 0.1)';
|
||||
e.currentTarget.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.04)';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '10px',
|
||||
paddingTop: '16px',
|
||||
marginTop: '16px',
|
||||
borderTop: '1px solid rgba(14, 165, 233, 0.15)',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{/* Compact Dropdowns - Stacked Horizontally */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '10px',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}>
|
||||
{/* Industry Dropdown */}
|
||||
<select
|
||||
value={industry}
|
||||
onChange={(e) => onIndustryChange(e.target.value)}
|
||||
title="Select industry for targeted research"
|
||||
style={dropdownStyle}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{industries.map(ind => (
|
||||
<option key={ind} value={ind}>{ind}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
clearResearchHistory,
|
||||
formatHistoryTimestamp,
|
||||
getHistorySummary,
|
||||
ResearchHistoryEntry
|
||||
} from '../../../../utils/researchHistory';
|
||||
import { WizardState } from '../../types/research.types';
|
||||
|
||||
interface ResearchHistoryProps {
|
||||
history: ResearchHistoryEntry[];
|
||||
onLoadHistory: (entry: Partial<WizardState>) => void;
|
||||
onHistoryCleared: () => void;
|
||||
}
|
||||
|
||||
export const ResearchHistory: React.FC<ResearchHistoryProps> = ({
|
||||
history,
|
||||
onLoadHistory,
|
||||
onHistoryCleared
|
||||
}) => {
|
||||
if (history.length === 0) return null;
|
||||
|
||||
const handleClear = () => {
|
||||
clearResearchHistory();
|
||||
onHistoryCleared();
|
||||
};
|
||||
|
||||
return (
|
||||
<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={handleClear}
|
||||
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',
|
||||
}}>
|
||||
{history.map((entry) => (
|
||||
<button
|
||||
key={entry.timestamp}
|
||||
onClick={() => {
|
||||
onLoadHistory({
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface ResearchInputContainerProps {
|
||||
keywords: string[];
|
||||
placeholder: string;
|
||||
onKeywordsChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
}
|
||||
|
||||
export const ResearchInputContainer: React.FC<ResearchInputContainerProps> = ({
|
||||
keywords,
|
||||
placeholder,
|
||||
onKeywordsChange,
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [wordCount, setWordCount] = useState(0);
|
||||
const MAX_WORDS = 1000;
|
||||
|
||||
// Initialize input value from keywords only on mount or when keywords are cleared
|
||||
useEffect(() => {
|
||||
const keywordValue = keywords.length > 0 ? keywords.join(', ') : '';
|
||||
// Only update if the input is empty or if keywords were cleared
|
||||
if (inputValue === '' || (keywords.length === 0 && inputValue !== '')) {
|
||||
setInputValue(keywordValue);
|
||||
const words = keywordValue.trim().split(/\s+/).filter(w => w.length > 0);
|
||||
setWordCount(words.length);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [keywords.length]); // Only reinitialize if keywords array length changes
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const words = value.trim().split(/\s+/).filter(w => w.length > 0);
|
||||
const currentWordCount = words.length;
|
||||
|
||||
// Only update if within word limit
|
||||
if (currentWordCount <= MAX_WORDS) {
|
||||
setInputValue(value);
|
||||
setWordCount(currentWordCount);
|
||||
// Create a new event with the current value for the parent handler
|
||||
const syntheticEvent = {
|
||||
...e,
|
||||
target: {
|
||||
...e.target,
|
||||
value: value,
|
||||
},
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>;
|
||||
onKeywordsChange(syntheticEvent);
|
||||
} else {
|
||||
// Truncate to last valid word boundary
|
||||
const truncatedWords = words.slice(0, MAX_WORDS);
|
||||
const truncatedValue = truncatedWords.join(' ');
|
||||
setInputValue(truncatedValue);
|
||||
setWordCount(MAX_WORDS);
|
||||
// Create synthetic event with truncated value
|
||||
const syntheticEvent = {
|
||||
...e,
|
||||
target: {
|
||||
...e.target,
|
||||
value: truncatedValue,
|
||||
},
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>;
|
||||
onKeywordsChange(syntheticEvent);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
minHeight: '227px', // Reduced by 35% from 350px
|
||||
width: '65%', // Reduced by 35% from 100%
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '16px',
|
||||
background: 'linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 250, 251, 0.95) 100%)',
|
||||
boxShadow: 'inset 0 2px 8px rgba(14, 165, 233, 0.06), 0 1px 2px rgba(0, 0, 0, 0.05)',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.3)';
|
||||
e.currentTarget.style.boxShadow = 'inset 0 2px 8px rgba(14, 165, 233, 0.08), 0 2px 4px rgba(0, 0, 0, 0.08)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.2)';
|
||||
e.currentTarget.style.boxShadow = 'inset 0 2px 8px rgba(14, 165, 233, 0.06), 0 1px 2px rgba(0, 0, 0, 0.05)';
|
||||
}}
|
||||
>
|
||||
{/* Textarea for input - takes full space */}
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
style={{
|
||||
width: '100%',
|
||||
flex: '1',
|
||||
minHeight: '195px', // Reduced by 35% from 300px
|
||||
padding: '12px',
|
||||
fontSize: '15px',
|
||||
lineHeight: '1.7',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: '#1e293b',
|
||||
resize: 'vertical',
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", sans-serif',
|
||||
outline: 'none',
|
||||
fontWeight: '400',
|
||||
letterSpacing: '-0.01em',
|
||||
overflowWrap: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Word count indicator */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
paddingTop: '8px',
|
||||
fontSize: '12px',
|
||||
color: wordCount >= MAX_WORDS ? '#ef4444' : '#64748b',
|
||||
fontWeight: wordCount >= MAX_WORDS ? '600' : '400',
|
||||
}}>
|
||||
{wordCount} / {MAX_WORDS} words
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
|
||||
interface SmartInputIndicatorProps {
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
export const SmartInputIndicator: React.FC<SmartInputIndicatorProps> = ({ keywords }) => {
|
||||
if (keywords.length === 0) return null;
|
||||
|
||||
return (
|
||||
<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>
|
||||
{keywords[0]?.startsWith('http') ? (
|
||||
<span>URL detected - will extract and analyze content</span>
|
||||
) : keywords.length === 1 && keywords[0]?.split(/\s+/).length > 5 ? (
|
||||
<span>Research topic detected - will conduct comprehensive analysis</span>
|
||||
) : (
|
||||
<span>{keywords.length} keyword{keywords.length > 1 ? 's' : ''} identified</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TargetAudienceProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export const TargetAudience: React.FC<TargetAudienceProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#0c4a6e',
|
||||
}}>
|
||||
Target Audience (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="e.g., Marketing professionals, Tech enthusiasts, Business owners"
|
||||
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',
|
||||
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';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
import React from 'react';
|
||||
import { ResearchConfig } from '../../../../services/blogWriterApi';
|
||||
import {
|
||||
tavilyTopics,
|
||||
tavilySearchDepths,
|
||||
tavilyTimeRanges,
|
||||
tavilyAnswerOptions,
|
||||
tavilyRawContentOptions
|
||||
} from '../utils/constants';
|
||||
|
||||
interface TavilyOptionsProps {
|
||||
config: ResearchConfig;
|
||||
onConfigUpdate: (updates: Partial<ResearchConfig>) => void;
|
||||
}
|
||||
|
||||
export const TavilyOptions: React.FC<TavilyOptionsProps> = ({ config, onConfigUpdate }) => {
|
||||
const handleTopicChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value as 'general' | 'news' | 'finance';
|
||||
onConfigUpdate({ tavily_topic: value || 'general' });
|
||||
};
|
||||
|
||||
const handleSearchDepthChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value as 'basic' | 'advanced';
|
||||
onConfigUpdate({ tavily_search_depth: value });
|
||||
};
|
||||
|
||||
const handleIncludeDomainsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
const domains = value.split(',').map(d => d.trim()).filter(Boolean);
|
||||
onConfigUpdate({ tavily_include_domains: domains });
|
||||
};
|
||||
|
||||
const handleExcludeDomainsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
const domains = value.split(',').map(d => d.trim()).filter(Boolean);
|
||||
onConfigUpdate({ tavily_exclude_domains: domains });
|
||||
};
|
||||
|
||||
const handleIncludeAnswerChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
let answerValue: boolean | 'basic' | 'advanced';
|
||||
if (value === 'true') {
|
||||
answerValue = true;
|
||||
} else if (value === 'false') {
|
||||
answerValue = false;
|
||||
} else {
|
||||
answerValue = value as 'basic' | 'advanced';
|
||||
}
|
||||
onConfigUpdate({ tavily_include_answer: answerValue });
|
||||
};
|
||||
|
||||
const handleTimeRangeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
const timeRangeValue = value ? (value as 'day' | 'week' | 'month' | 'year' | 'd' | 'w' | 'm' | 'y') : undefined;
|
||||
onConfigUpdate({ tavily_time_range: timeRangeValue });
|
||||
};
|
||||
|
||||
const handleIncludeRawContentChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
let rawContentValue: boolean | 'markdown' | 'text';
|
||||
if (value === 'true') {
|
||||
rawContentValue = true;
|
||||
} else if (value === 'false') {
|
||||
rawContentValue = false;
|
||||
} else {
|
||||
rawContentValue = value as 'markdown' | 'text';
|
||||
}
|
||||
onConfigUpdate({ tavily_include_raw_content: rawContentValue });
|
||||
};
|
||||
|
||||
const handleIncludeImagesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onConfigUpdate({ tavily_include_images: e.target.checked });
|
||||
};
|
||||
|
||||
const handleIncludeImageDescriptionsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onConfigUpdate({ tavily_include_image_descriptions: e.target.checked });
|
||||
};
|
||||
|
||||
const handleIncludeFaviconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onConfigUpdate({ tavily_include_favicon: e.target.checked });
|
||||
};
|
||||
|
||||
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onConfigUpdate({ tavily_start_date: e.target.value || undefined });
|
||||
};
|
||||
|
||||
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onConfigUpdate({ tavily_end_date: e.target.value || undefined });
|
||||
};
|
||||
|
||||
const handleCountryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onConfigUpdate({ tavily_country: e.target.value || undefined });
|
||||
};
|
||||
|
||||
const handleChunksPerSourceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (!isNaN(value) && value >= 1 && value <= 3) {
|
||||
onConfigUpdate({ tavily_chunks_per_source: value });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoParametersChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onConfigUpdate({ tavily_auto_parameters: e.target.checked });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
|
||||
border: '2px solid rgba(14, 165, 233, 0.3)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
marginBottom: '14px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '14px',
|
||||
}}>
|
||||
<span style={{ fontSize: '18px' }}>🤖</span>
|
||||
<strong style={{ color: '#0ea5e9', fontSize: '13px' }}>Tavily AI Search Options</strong>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: '12px',
|
||||
marginBottom: '12px',
|
||||
}}>
|
||||
{/* Tavily Topic */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#0ea5e9',
|
||||
}}>
|
||||
Search Topic
|
||||
</label>
|
||||
<select
|
||||
value={config.tavily_topic || 'general'}
|
||||
onChange={handleTopicChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{tavilyTopics.map(topic => (
|
||||
<option key={topic.value} value={topic.value}>{topic.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Tavily Search Depth */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#0ea5e9',
|
||||
}}>
|
||||
Search Depth
|
||||
</label>
|
||||
<select
|
||||
value={config.tavily_search_depth || 'basic'}
|
||||
onChange={handleSearchDepthChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{tavilySearchDepths.map(depth => (
|
||||
<option key={depth.value} value={depth.value}>{depth.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Tavily Include Answer */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#0ea5e9',
|
||||
}}>
|
||||
AI Answer
|
||||
</label>
|
||||
<select
|
||||
value={config.tavily_include_answer === true ? 'true' : typeof config.tavily_include_answer === 'string' ? config.tavily_include_answer : 'false'}
|
||||
onChange={handleIncludeAnswerChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{tavilyAnswerOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Tavily Time Range */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#0ea5e9',
|
||||
}}>
|
||||
Time Range
|
||||
</label>
|
||||
<select
|
||||
value={config.tavily_time_range || ''}
|
||||
onChange={handleTimeRangeChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{tavilyTimeRanges.map(range => (
|
||||
<option key={range.value} value={range.value}>{range.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domain Filters */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#0ea5e9',
|
||||
}}>
|
||||
Include Domains (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.tavily_include_domains?.join(', ') || ''}
|
||||
onChange={handleIncludeDomainsChange}
|
||||
placeholder="e.g., nature.com, arxiv.org"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#0ea5e9',
|
||||
}}>
|
||||
Exclude Domains (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.tavily_exclude_domains?.join(', ') || ''}
|
||||
onChange={handleExcludeDomainsChange}
|
||||
placeholder="e.g., spam.com, ads.com"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Tavily Options */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '12px',
|
||||
marginTop: '12px',
|
||||
}}>
|
||||
{/* Include Raw Content */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#0ea5e9',
|
||||
}}>
|
||||
Raw Content Format
|
||||
</label>
|
||||
<select
|
||||
value={config.tavily_include_raw_content === true ? 'true' : typeof config.tavily_include_raw_content === 'string' ? config.tavily_include_raw_content : 'false'}
|
||||
onChange={handleIncludeRawContentChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{tavilyRawContentOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Country */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#0ea5e9',
|
||||
}}>
|
||||
Country Code (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.tavily_country || ''}
|
||||
onChange={handleCountryChange}
|
||||
placeholder="e.g., US, GB, IN"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chunks Per Source (only for advanced) */}
|
||||
{config.tavily_search_depth === 'advanced' && (
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#0ea5e9',
|
||||
}}>
|
||||
Chunks Per Source (1-3)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="3"
|
||||
value={config.tavily_chunks_per_source || 3}
|
||||
onChange={handleChunksPerSourceChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date Range */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '12px',
|
||||
marginTop: '12px',
|
||||
}}>
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#0ea5e9',
|
||||
}}>
|
||||
Start Date (YYYY-MM-DD)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={config.tavily_start_date || ''}
|
||||
onChange={handleStartDateChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#0ea5e9',
|
||||
}}>
|
||||
End Date (YYYY-MM-DD)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={config.tavily_end_date || ''}
|
||||
onChange={handleEndDateChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
fontSize: '12px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkboxes */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '12px',
|
||||
marginTop: '12px',
|
||||
}}>
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#0ea5e9',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.tavily_include_images || false}
|
||||
onChange={handleIncludeImagesChange}
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<span>Include Images</span>
|
||||
</label>
|
||||
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#0ea5e9',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.tavily_include_image_descriptions || false}
|
||||
onChange={handleIncludeImageDescriptionsChange}
|
||||
disabled={!config.tavily_include_images}
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
cursor: config.tavily_include_images ? 'pointer' : 'not-allowed',
|
||||
opacity: config.tavily_include_images ? 1 : 0.5,
|
||||
}}
|
||||
/>
|
||||
<span>Include Image Descriptions</span>
|
||||
</label>
|
||||
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#0ea5e9',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.tavily_include_favicon || false}
|
||||
onChange={handleIncludeFaviconChange}
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<span>Include Favicon URLs</span>
|
||||
</label>
|
||||
|
||||
<label style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#0ea5e9',
|
||||
cursor: 'pointer',
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.tavily_auto_parameters || false}
|
||||
onChange={handleAutoParametersChange}
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
<span>Auto-Configure Parameters</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
83
frontend/src/components/Research/steps/utils/constants.ts
Normal file
83
frontend/src/components/Research/steps/utils/constants.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export const industries = [
|
||||
'General',
|
||||
'Technology',
|
||||
'Business',
|
||||
'Marketing',
|
||||
'Finance',
|
||||
'Healthcare',
|
||||
'Education',
|
||||
'Real Estate',
|
||||
'Entertainment',
|
||||
'Food & Beverage',
|
||||
'Travel',
|
||||
'Fashion',
|
||||
'Sports',
|
||||
'Science',
|
||||
'Law',
|
||||
'Other',
|
||||
];
|
||||
|
||||
export const researchModes = [
|
||||
{ value: 'basic', label: 'Basic - Quick insights' },
|
||||
{ value: 'comprehensive', label: 'Comprehensive - In-depth analysis' },
|
||||
{ value: 'targeted', label: 'Targeted - Specific focus' },
|
||||
];
|
||||
|
||||
export const providers = [
|
||||
{ value: 'google', label: '🔍 Google Search' },
|
||||
{ value: 'exa', label: '🧠 Exa Neural Search' },
|
||||
{ value: 'tavily', label: '🤖 Tavily AI Search' },
|
||||
];
|
||||
|
||||
export const exaCategories = [
|
||||
{ value: '', label: 'All Categories' },
|
||||
{ value: 'company', label: 'Company Profiles' },
|
||||
{ value: 'research paper', label: 'Research Papers' },
|
||||
{ value: 'news', label: 'News Articles' },
|
||||
{ value: 'linkedin profile', label: 'LinkedIn Profiles' },
|
||||
{ value: 'github', label: 'GitHub Repos' },
|
||||
{ value: 'tweet', label: 'Tweets' },
|
||||
{ value: 'movie', label: 'Movies' },
|
||||
{ value: 'song', label: 'Songs' },
|
||||
{ value: 'personal site', label: 'Personal Sites' },
|
||||
{ value: 'pdf', label: 'PDF Documents' },
|
||||
{ value: 'financial report', label: 'Financial Reports' },
|
||||
];
|
||||
|
||||
export const exaSearchTypes = [
|
||||
{ value: 'auto', label: 'Auto - Let AI decide' },
|
||||
{ value: 'keyword', label: 'Keyword - Precise matching' },
|
||||
{ value: 'neural', label: 'Neural - Semantic search' },
|
||||
];
|
||||
|
||||
export const tavilyTopics = [
|
||||
{ value: 'general', label: 'General' },
|
||||
{ value: 'news', label: 'News' },
|
||||
{ value: 'finance', label: 'Finance' },
|
||||
];
|
||||
|
||||
export const tavilySearchDepths = [
|
||||
{ value: 'basic', label: 'Basic (1 credit) - Fast search' },
|
||||
{ value: 'advanced', label: 'Advanced (2 credits) - Deep analysis' },
|
||||
];
|
||||
|
||||
export const tavilyTimeRanges = [
|
||||
{ value: '', label: 'No time filter' },
|
||||
{ value: 'day', label: 'Last 24 hours' },
|
||||
{ value: 'week', label: 'Last week' },
|
||||
{ value: 'month', label: 'Last month' },
|
||||
{ value: 'year', label: 'Last year' },
|
||||
];
|
||||
|
||||
export const tavilyAnswerOptions = [
|
||||
{ value: 'false', label: 'No answer' },
|
||||
{ value: 'basic', label: 'Basic answer' },
|
||||
{ value: 'advanced', label: 'Advanced answer' },
|
||||
];
|
||||
|
||||
export const tavilyRawContentOptions = [
|
||||
{ value: 'false', label: 'No raw content' },
|
||||
{ value: 'markdown', label: 'Markdown format' },
|
||||
{ value: 'text', label: 'Plain text' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Industry-specific domain suggestions and Exa category mappings
|
||||
*/
|
||||
export const getIndustryDomainSuggestions = (industry: string): string[] => {
|
||||
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'],
|
||||
};
|
||||
|
||||
return domainMap[industry] || [];
|
||||
};
|
||||
|
||||
export const getIndustryExaCategory = (industry: string): string | undefined => {
|
||||
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',
|
||||
};
|
||||
|
||||
return categoryMap[industry];
|
||||
};
|
||||
|
||||
32
frontend/src/components/Research/steps/utils/inputParser.ts
Normal file
32
frontend/src/components/Research/steps/utils/inputParser.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Intelligent input parser - handles sentences, keywords, URLs
|
||||
*/
|
||||
export 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()];
|
||||
}
|
||||
};
|
||||
|
||||
58
frontend/src/components/Research/steps/utils/placeholders.ts
Normal file
58
frontend/src/components/Research/steps/utils/placeholders.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Industry-specific placeholder examples for personalized experience
|
||||
*/
|
||||
export 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"
|
||||
];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ResearchMode } from '../../../../services/blogWriterApi';
|
||||
|
||||
/**
|
||||
* Smart mode suggestion based on query complexity
|
||||
*/
|
||||
export 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';
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* OAuth Token Status Component
|
||||
* Compact terminal-themed component for displaying OAuth token monitoring status
|
||||
* with platform-specific execution logs in expanded sections
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Chip,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
RefreshCw,
|
||||
@@ -30,8 +33,11 @@ import { useAuth } from '@clerk/clerk-react';
|
||||
import {
|
||||
getOAuthTokenStatus,
|
||||
manualRefreshToken,
|
||||
getOAuthTokenExecutionLogs,
|
||||
OAuthTokenStatusResponse,
|
||||
ManualRefreshResponse,
|
||||
ExecutionLog,
|
||||
ExecutionLogsResponse,
|
||||
} from '../../api/oauthTokenMonitoring';
|
||||
import {
|
||||
TerminalPaper,
|
||||
@@ -41,6 +47,8 @@ import {
|
||||
TerminalChipError,
|
||||
TerminalChipWarning,
|
||||
TerminalAlert,
|
||||
TerminalTableCell,
|
||||
TerminalTableRow,
|
||||
terminalColors,
|
||||
} from './terminalTheme';
|
||||
|
||||
@@ -48,6 +56,14 @@ interface OAuthTokenStatusProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface PlatformLogs {
|
||||
[platform: string]: {
|
||||
logs: ExecutionLog[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) => {
|
||||
const { userId } = useAuth();
|
||||
const [status, setStatus] = useState<OAuthTokenStatusResponse | null>(null);
|
||||
@@ -55,6 +71,8 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
||||
const [refreshing, setRefreshing] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedPlatform, setExpandedPlatform] = useState<string | null>(null);
|
||||
const [platformLogs, setPlatformLogs] = useState<PlatformLogs>({});
|
||||
const [hoveredLogId, setHoveredLogId] = useState<number | null>(null);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!userId) return;
|
||||
@@ -72,6 +90,48 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPlatformLogs = async (platform: string) => {
|
||||
if (!userId) return;
|
||||
|
||||
// Initialize platform logs state if not exists
|
||||
if (!platformLogs[platform]) {
|
||||
setPlatformLogs(prev => ({
|
||||
...prev,
|
||||
[platform]: { logs: [], loading: false, error: null }
|
||||
}));
|
||||
}
|
||||
|
||||
setPlatformLogs(prev => ({
|
||||
...prev,
|
||||
[platform]: { ...prev[platform], loading: true, error: null }
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await getOAuthTokenExecutionLogs(userId, platform, 10, 0); // Get latest 10 logs
|
||||
|
||||
if (response.success && response.data) {
|
||||
setPlatformLogs(prev => ({
|
||||
...prev,
|
||||
[platform]: {
|
||||
logs: response.data.logs || [],
|
||||
loading: false,
|
||||
error: null
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setPlatformLogs(prev => ({
|
||||
...prev,
|
||||
[platform]: {
|
||||
...prev[platform],
|
||||
loading: false,
|
||||
error: err.message || 'Failed to fetch logs'
|
||||
}
|
||||
}));
|
||||
console.error(`Error fetching logs for ${platform}:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
|
||||
@@ -79,6 +139,13 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
||||
const interval = setInterval(fetchStatus, 120000);
|
||||
return () => clearInterval(interval);
|
||||
}, [userId]);
|
||||
|
||||
// Fetch logs when platform is expanded
|
||||
useEffect(() => {
|
||||
if (expandedPlatform && userId) {
|
||||
fetchPlatformLogs(expandedPlatform);
|
||||
}
|
||||
}, [expandedPlatform, userId]);
|
||||
|
||||
const handleRefresh = async (platform: string) => {
|
||||
if (!userId) return;
|
||||
@@ -91,6 +158,11 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
||||
// Refresh status after manual refresh
|
||||
await fetchStatus();
|
||||
|
||||
// Refresh logs if platform is expanded
|
||||
if (expandedPlatform === platform) {
|
||||
await fetchPlatformLogs(platform);
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
console.log(`Token refresh successful for ${platform}`);
|
||||
} else {
|
||||
@@ -103,6 +175,14 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
||||
setRefreshing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpandPlatform = (platform: string) => {
|
||||
if (expandedPlatform === platform) {
|
||||
setExpandedPlatform(null);
|
||||
} else {
|
||||
setExpandedPlatform(platform);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (taskStatus: string | null, connected: boolean) => {
|
||||
if (!connected) {
|
||||
@@ -165,6 +245,39 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
||||
};
|
||||
return names[platform] || platform.toUpperCase();
|
||||
};
|
||||
|
||||
const getLogStatusChip = (logStatus: string) => {
|
||||
switch (logStatus) {
|
||||
case 'success':
|
||||
return <TerminalChipSuccess label="Success" size="small" />;
|
||||
case 'failed':
|
||||
return <TerminalChipError label="Failed" size="small" />;
|
||||
case 'running':
|
||||
return <TerminalChipWarning label="Running" size="small" />;
|
||||
default:
|
||||
return <Chip label={logStatus} size="small" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatLogResult = (resultData: any): string => {
|
||||
if (!resultData) return 'N/A';
|
||||
if (typeof resultData === 'string') {
|
||||
try {
|
||||
resultData = JSON.parse(resultData);
|
||||
} catch {
|
||||
return resultData.substring(0, 50);
|
||||
}
|
||||
}
|
||||
|
||||
if (resultData.token_status) {
|
||||
return `Token: ${resultData.token_status}`;
|
||||
}
|
||||
if (resultData.platform) {
|
||||
return `Platform: ${resultData.platform}`;
|
||||
}
|
||||
const str = JSON.stringify(resultData);
|
||||
return str.length > 60 ? str.substring(0, 60) + '...' : str;
|
||||
};
|
||||
|
||||
if (loading && !status) {
|
||||
return (
|
||||
@@ -231,6 +344,7 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
||||
const platformStatus = status.data.platform_status[platform];
|
||||
const task = platformStatus?.monitoring_task;
|
||||
const isExpanded = expandedPlatform === platform;
|
||||
const logs = platformLogs[platform];
|
||||
|
||||
return (
|
||||
<React.Fragment key={platform}>
|
||||
@@ -251,7 +365,47 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusChip(task?.status || null, platformStatus?.connected || false)}
|
||||
<Box display="flex" alignItems="center" gap={1} flexWrap="wrap">
|
||||
{getStatusChip(task?.status || null, platformStatus?.connected || false)}
|
||||
{task?.last_success && (
|
||||
<Tooltip title={`Last successful: ${formatDate(task.last_success)}`}>
|
||||
<Chip
|
||||
label={`✓ ${formatDate(task.last_success).split(',')[0].trim()}`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: terminalColors.success + '40',
|
||||
color: terminalColors.success,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.65rem',
|
||||
height: '20px',
|
||||
border: `1px solid ${terminalColors.success}40`,
|
||||
'& .MuiChip-label': {
|
||||
padding: '0 6px'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{task?.next_check && (
|
||||
<Tooltip title={`Next check: ${formatDate(task.next_check)}`}>
|
||||
<Chip
|
||||
label={`⏱ ${formatDate(task.next_check).split(',')[0].trim()}`}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: terminalColors.info + '40',
|
||||
color: terminalColors.info,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.65rem',
|
||||
height: '20px',
|
||||
border: `1px solid ${terminalColors.info}40`,
|
||||
'& .MuiChip-label': {
|
||||
padding: '0 6px'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TerminalTypography variant="caption" color={terminalColors.textSecondary}>
|
||||
@@ -263,7 +417,7 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
||||
<Tooltip title={isExpanded ? "Hide details" : "Show details"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setExpandedPlatform(isExpanded ? null : platform)}
|
||||
onClick={() => handleExpandPlatform(platform)}
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
'&:hover': {
|
||||
@@ -318,20 +472,162 @@ const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) =
|
||||
</TerminalTypography>
|
||||
</TerminalAlert>
|
||||
)}
|
||||
{task?.last_success && (
|
||||
<TerminalAlert severity="success" sx={{ mb: 1 }}>
|
||||
<TerminalTypography variant="body2">
|
||||
Last successful: {formatDate(task.last_success)}
|
||||
{/* OAuth Monitoring Logs Section */}
|
||||
{platformStatus?.connected && (
|
||||
<>
|
||||
<Divider sx={{ my: 1.5, borderColor: terminalColors.primary + '40' }} />
|
||||
<TerminalTypography variant="subtitle2" fontWeight="bold" mb={1}>
|
||||
🔐 Monitoring Logs
|
||||
</TerminalTypography>
|
||||
</TerminalAlert>
|
||||
)}
|
||||
{task?.next_check && (
|
||||
<Box mt={1}>
|
||||
<TerminalTypography variant="caption" color={terminalColors.textSecondary}>
|
||||
Next check: {formatDate(task.next_check)}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
|
||||
{logs?.loading ? (
|
||||
<Box display="flex" alignItems="center" gap={1} p={1}>
|
||||
<CircularProgress size={16} sx={{ color: terminalColors.primary }} />
|
||||
<TerminalTypography variant="caption" color={terminalColors.textSecondary}>
|
||||
Loading logs...
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
) : logs?.error ? (
|
||||
<TerminalAlert severity="error" sx={{ mb: 1 }}>
|
||||
<TerminalTypography variant="caption">
|
||||
{logs.error}
|
||||
</TerminalTypography>
|
||||
</TerminalAlert>
|
||||
) : logs?.logs && logs.logs.length > 0 ? (
|
||||
<Box sx={{
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.05)',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: terminalColors.primary + '80',
|
||||
borderRadius: '4px',
|
||||
'&:hover': {
|
||||
backgroundColor: terminalColors.primary,
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<Table size="small" sx={{
|
||||
'& .MuiTableCell-root': {
|
||||
color: terminalColors.primary,
|
||||
borderColor: terminalColors.primary + '30',
|
||||
fontSize: '0.7rem',
|
||||
py: 0.5
|
||||
}
|
||||
}}>
|
||||
<TableHead sx={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'rgba(0, 0, 0, 0.8)' }}>
|
||||
<TableRow>
|
||||
<TerminalTableCell>Date</TerminalTableCell>
|
||||
<TerminalTableCell>Status</TerminalTableCell>
|
||||
<TerminalTableCell>Result</TerminalTableCell>
|
||||
<TerminalTableCell>Duration</TerminalTableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{logs.logs.map((log) => (
|
||||
<React.Fragment key={log.id}>
|
||||
<TerminalTableRow
|
||||
onMouseEnter={() => setHoveredLogId(log.id)}
|
||||
onMouseLeave={() => setHoveredLogId(null)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TerminalTableCell>
|
||||
<TerminalTypography variant="caption" fontSize="0.65rem">
|
||||
{formatDate(log.execution_date)}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
{getLogStatusChip(log.status)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<TerminalTypography variant="caption" fontSize="0.65rem" sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: terminalColors.info,
|
||||
maxWidth: '200px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{formatLogResult(log.result_data)}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<TerminalTypography variant="caption" fontSize="0.65rem">
|
||||
{log.execution_time_ms ? `${log.execution_time_ms}ms` : 'N/A'}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
</TerminalTableRow>
|
||||
{hoveredLogId === log.id && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} sx={{ py: 1, backgroundColor: 'rgba(0, 255, 0, 0.08)', borderLeft: `3px solid ${terminalColors.primary}` }}>
|
||||
<Box pl={2}>
|
||||
<TerminalTypography variant="caption" fontWeight="bold" mb={0.5} display="block">
|
||||
Full Details:
|
||||
</TerminalTypography>
|
||||
{log.error_message && (
|
||||
<Box mb={1}>
|
||||
<TerminalTypography variant="caption" fontWeight="bold" color={terminalColors.error} display="block" mb={0.5}>
|
||||
Error:
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" fontSize="0.6rem" sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: terminalColors.error,
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
{log.error_message}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
{log.result_data && (
|
||||
<Box>
|
||||
<TerminalTypography variant="caption" fontWeight="bold" color={terminalColors.info} display="block" mb={0.5}>
|
||||
Result Data:
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" fontSize="0.6rem" sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: terminalColors.info,
|
||||
wordBreak: 'break-word',
|
||||
whiteSpace: 'pre-wrap'
|
||||
}}>
|
||||
{typeof log.result_data === 'string' ? log.result_data : JSON.stringify(log.result_data, null, 2)}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{logs.logs.length >= 10 && (
|
||||
<Box mt={1} textAlign="center">
|
||||
<TerminalTypography variant="caption" color={terminalColors.textSecondary} sx={{ fontStyle: 'italic' }}>
|
||||
Showing latest 10 logs. View all logs in OAuth Monitoring section.
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<TerminalTypography variant="caption" color={terminalColors.textSecondary} sx={{ fontStyle: 'italic' }}>
|
||||
No monitoring logs available yet. Logs will appear after the first scheduled check.
|
||||
</TerminalTypography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Existing connection status messages */}
|
||||
{!task && platformStatus?.connected && (
|
||||
<TerminalAlert severity="info">
|
||||
<TerminalTypography variant="body2">
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* Platform Insights Status Component
|
||||
* Compact terminal-themed component for displaying platform insights (GSC/Bing) task status
|
||||
* with execution logs in expanded sections
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Chip,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Search,
|
||||
Globe,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import {
|
||||
getPlatformInsightsStatus,
|
||||
getPlatformInsightsLogs,
|
||||
PlatformInsightsStatusResponse,
|
||||
PlatformInsightsTask,
|
||||
PlatformInsightsExecutionLog,
|
||||
PlatformInsightsLogsResponse,
|
||||
} from '../../api/platformInsightsMonitoring';
|
||||
import {
|
||||
TerminalPaper,
|
||||
TerminalTypography,
|
||||
TerminalChip,
|
||||
TerminalChipSuccess,
|
||||
TerminalChipError,
|
||||
TerminalChipWarning,
|
||||
TerminalAlert,
|
||||
TerminalTableCell,
|
||||
TerminalTableRow,
|
||||
terminalColors,
|
||||
} from './terminalTheme';
|
||||
|
||||
interface PlatformInsightsStatusProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface TaskLogs {
|
||||
[taskId: number]: {
|
||||
logs: PlatformInsightsExecutionLog[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const PlatformInsightsStatus: React.FC<PlatformInsightsStatusProps> = ({ compact = true }) => {
|
||||
const { userId } = useAuth();
|
||||
const [status, setStatus] = useState<PlatformInsightsStatusResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedTaskId, setExpandedTaskId] = useState<number | null>(null);
|
||||
const [taskLogs, setTaskLogs] = useState<TaskLogs>({});
|
||||
const [hoveredLogId, setHoveredLogId] = useState<number | null>(null);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await getPlatformInsightsStatus(userId);
|
||||
setStatus(response);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch platform insights status');
|
||||
console.error('Error fetching platform insights status:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTaskLogs = async (taskId: number) => {
|
||||
if (!userId) return;
|
||||
|
||||
// Initialize task logs state if not exists
|
||||
if (!taskLogs[taskId]) {
|
||||
setTaskLogs(prev => ({
|
||||
...prev,
|
||||
[taskId]: { logs: [], loading: true, error: null }
|
||||
}));
|
||||
} else {
|
||||
setTaskLogs(prev => ({
|
||||
...prev,
|
||||
[taskId]: { ...prev[taskId], loading: true, error: null }
|
||||
}));
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[PlatformInsights] Fetching logs for task ${taskId}...`);
|
||||
const response = await getPlatformInsightsLogs(userId, 10, taskId);
|
||||
console.log(`[PlatformInsights] Received logs response:`, {
|
||||
success: response.success,
|
||||
logsCount: response.logs?.length || 0,
|
||||
totalCount: response.total_count,
|
||||
hasLogs: !!(response.logs && response.logs.length > 0),
|
||||
firstLog: response.logs?.[0] || null
|
||||
});
|
||||
|
||||
if (response.success && response.logs && Array.isArray(response.logs)) {
|
||||
setTaskLogs(prev => ({
|
||||
...prev,
|
||||
[taskId]: {
|
||||
logs: response.logs,
|
||||
loading: false,
|
||||
error: null
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
console.warn(`[PlatformInsights] Invalid logs response structure:`, response);
|
||||
setTaskLogs(prev => ({
|
||||
...prev,
|
||||
[taskId]: {
|
||||
logs: prev[taskId]?.logs || [],
|
||||
loading: false,
|
||||
error: response.success === false ? 'Failed to fetch logs' : 'Invalid response structure'
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[PlatformInsights] Error fetching logs for task ${taskId}:`, err);
|
||||
setTaskLogs(prev => ({
|
||||
...prev,
|
||||
[taskId]: {
|
||||
logs: prev[taskId]?.logs || [],
|
||||
loading: false,
|
||||
error: err.message || 'Failed to fetch logs'
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleExpand = (taskId: number) => {
|
||||
if (expandedTaskId === taskId) {
|
||||
setExpandedTaskId(null);
|
||||
} else {
|
||||
setExpandedTaskId(taskId);
|
||||
// Always fetch logs when expanding to get latest data
|
||||
fetchTaskLogs(taskId);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
// Refresh every 5 minutes (same as other dashboard components)
|
||||
// Tasks only run weekly, so frequent polling is unnecessary
|
||||
const interval = setInterval(fetchStatus, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [userId]);
|
||||
|
||||
// Fetch logs when task is expanded (similar to OAuth pattern)
|
||||
useEffect(() => {
|
||||
if (expandedTaskId && userId) {
|
||||
fetchTaskLogs(expandedTaskId);
|
||||
}
|
||||
}, [expandedTaskId, userId]);
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number | null) => {
|
||||
if (!ms) return 'N/A';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
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.info} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusChip = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <TerminalChipSuccess label="Active" />;
|
||||
case 'failed':
|
||||
return <TerminalChipError label="Failed" />;
|
||||
case 'paused':
|
||||
return <TerminalChipWarning label="Paused" />;
|
||||
default:
|
||||
return <TerminalChip label={status} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformIcon = (platform: string) => {
|
||||
switch (platform) {
|
||||
case 'gsc':
|
||||
return <Search size={16} />;
|
||||
case 'bing':
|
||||
return <Globe size={16} />;
|
||||
default:
|
||||
return <Info size={16} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformName = (platform: string) => {
|
||||
switch (platform) {
|
||||
case 'gsc':
|
||||
return 'Google Search Console';
|
||||
case 'bing':
|
||||
return 'Bing Webmaster Tools';
|
||||
default:
|
||||
return platform.toUpperCase();
|
||||
}
|
||||
};
|
||||
|
||||
const allTasks = [
|
||||
...(status?.gsc_tasks || []).map(t => ({ ...t, platform: 'gsc' as const })),
|
||||
...(status?.bing_tasks || []).map(t => ({ ...t, platform: 'bing' as const }))
|
||||
];
|
||||
|
||||
if (loading && !status) {
|
||||
return (
|
||||
<TerminalPaper>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
|
||||
<CircularProgress size={20} sx={{ color: terminalColors.success }} />
|
||||
<TerminalTypography>Loading platform insights tasks...</TerminalTypography>
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<TerminalPaper>
|
||||
<TerminalAlert severity="error" sx={{ m: 2 }}>
|
||||
{error}
|
||||
</TerminalAlert>
|
||||
</TerminalPaper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status || allTasks.length === 0) {
|
||||
return (
|
||||
<TerminalPaper>
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<TerminalTypography variant="body1" sx={{ mb: 1 }}>
|
||||
No platform insights tasks found.
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
|
||||
Connect GSC or Bing in onboarding Step 5 to create insights tasks.
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TerminalPaper>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Search size={20} color={terminalColors.primary} />
|
||||
<TerminalTypography variant="h6">
|
||||
Platform Insights Tasks
|
||||
</TerminalTypography>
|
||||
<TerminalChip label={`${allTasks.length} tasks`} />
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
'&:hover': {
|
||||
backgroundColor: terminalColors.backgroundHover,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ borderColor: terminalColors.border, mb: 2 }} />
|
||||
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TerminalTableCell sx={{ width: '5%', fontSize: '0.75rem' }} />
|
||||
<TerminalTableCell sx={{ width: '15%', fontSize: '0.75rem' }}>Platform</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ width: '30%', fontSize: '0.75rem' }}>Site URL</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ width: '15%', fontSize: '0.75rem' }}>Status</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ width: '35%', fontSize: '0.75rem' }}>Timing</TerminalTableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{allTasks.map((task) => {
|
||||
const isExpanded = expandedTaskId === task.id;
|
||||
const logs = taskLogs[task.id];
|
||||
|
||||
return (
|
||||
<React.Fragment key={task.id}>
|
||||
<TerminalTableRow
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: terminalColors.backgroundHover,
|
||||
}
|
||||
}}
|
||||
onClick={() => handleToggleExpand(task.id)}
|
||||
>
|
||||
<TerminalTableCell sx={{ width: '5%' }}>
|
||||
<IconButton size="small" sx={{ color: terminalColors.primary }}>
|
||||
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</IconButton>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ width: '15%' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{getPlatformIcon(task.platform)}
|
||||
<Typography sx={{ fontFamily: 'inherit', color: terminalColors.text, fontSize: '0.875rem' }}>
|
||||
{getPlatformName(task.platform)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ width: '30%' }}>
|
||||
{task.site_url ? (
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'inherit',
|
||||
color: terminalColors.textSecondary,
|
||||
fontSize: '0.75rem',
|
||||
maxWidth: 200,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{task.site_url}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography sx={{ fontFamily: 'inherit', color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Default site
|
||||
</Typography>
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ width: '15%' }}>
|
||||
{getStatusChip(task.status)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ width: '35%' }}>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{task.last_success && (
|
||||
<Tooltip title={`Last successful: ${formatDate(task.last_success)}`}>
|
||||
<Chip
|
||||
label="Success"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: terminalColors.background,
|
||||
color: terminalColors.success,
|
||||
border: `1px solid ${terminalColors.success}`,
|
||||
fontSize: '0.65rem',
|
||||
height: 20,
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{task.next_check && (
|
||||
<Tooltip title={`Next check: ${formatDate(task.next_check)}`}>
|
||||
<Chip
|
||||
label="Scheduled"
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: terminalColors.background,
|
||||
color: terminalColors.info,
|
||||
border: `1px solid ${terminalColors.info}`,
|
||||
fontSize: '0.65rem',
|
||||
height: 20,
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</TerminalTableCell>
|
||||
</TerminalTableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} sx={{ py: 0, border: 0 }}>
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ p: 2, backgroundColor: terminalColors.backgroundSecondary }}>
|
||||
{task.failure_reason && (
|
||||
<TerminalAlert severity="error" sx={{ mb: 2 }}>
|
||||
Error: {task.failure_reason}
|
||||
</TerminalAlert>
|
||||
)}
|
||||
|
||||
<TerminalTypography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Execution Logs
|
||||
</TerminalTypography>
|
||||
|
||||
{logs?.loading ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 2 }}>
|
||||
<CircularProgress size={16} sx={{ color: terminalColors.success }} />
|
||||
<TerminalTypography variant="body2">Loading logs...</TerminalTypography>
|
||||
</Box>
|
||||
) : logs?.error ? (
|
||||
<TerminalAlert severity="error" sx={{ mb: 2 }}>
|
||||
{logs.error}
|
||||
</TerminalAlert>
|
||||
) : logs?.logs && logs.logs.length > 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto',
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TerminalTableCell>Date</TerminalTableCell>
|
||||
<TerminalTableCell>Status</TerminalTableCell>
|
||||
<TerminalTableCell>Source</TerminalTableCell>
|
||||
<TerminalTableCell>Duration</TerminalTableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{logs.logs.map((log) => (
|
||||
<React.Fragment key={log.id}>
|
||||
<TerminalTableRow
|
||||
sx={{
|
||||
'&:hover': {
|
||||
backgroundColor: terminalColors.backgroundHover,
|
||||
},
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={() => setHoveredLogId(log.id)}
|
||||
onMouseLeave={() => setHoveredLogId(null)}
|
||||
>
|
||||
<TerminalTableCell>
|
||||
{formatDate(log.execution_date)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
{log.status === 'success' ? (
|
||||
<TerminalChipSuccess label="Success" />
|
||||
) : log.status === 'failed' ? (
|
||||
<TerminalChipError label="Failed" />
|
||||
) : (
|
||||
<TerminalChip label={log.status} />
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<Chip
|
||||
label={log.data_source || 'N/A'}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: terminalColors.background,
|
||||
color: terminalColors.textSecondary,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
fontSize: '0.65rem',
|
||||
height: 18,
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
/>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
{formatDuration(log.execution_time_ms)}
|
||||
</TerminalTableCell>
|
||||
</TerminalTableRow>
|
||||
{hoveredLogId === log.id && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} sx={{ py: 1, border: 0, backgroundColor: terminalColors.backgroundSecondary }}>
|
||||
{log.error_message && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.error, fontWeight: 'bold' }}>
|
||||
Error:
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.text, ml: 1 }}>
|
||||
{log.error_message}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
{log.result_data && (
|
||||
<Box>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.info, fontWeight: 'bold' }}>
|
||||
Result:
|
||||
</TerminalTypography>
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.7rem',
|
||||
color: terminalColors.textSecondary,
|
||||
backgroundColor: terminalColors.background,
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
overflow: 'auto',
|
||||
maxHeight: 150,
|
||||
mt: 0.5,
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(log.result_data, null, 2)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
) : (
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary, p: 2 }}>
|
||||
No execution logs yet.
|
||||
</TerminalTypography>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlatformInsightsStatus;
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { Box, Paper, CircularProgress } from '@mui/material';
|
||||
import { Box, Paper, CircularProgress, Modal, IconButton } from '@mui/material';
|
||||
import { Close as CloseIcon, OpenInFull as MaximizeIcon } from '@mui/icons-material';
|
||||
import { TerminalTypography, TerminalPaper, terminalColors } from './terminalTheme';
|
||||
import { getSchedulerEventHistory, SchedulerEvent } from '../../api/schedulerDashboard';
|
||||
|
||||
@@ -25,10 +26,54 @@ interface SchedulerChartsProps {
|
||||
events?: SchedulerEvent[];
|
||||
}
|
||||
|
||||
interface ChartModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ChartModal: React.FC<ChartModalProps> = ({ open, onClose, title, children }) => {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
}}
|
||||
>
|
||||
<TerminalPaper
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: '90%',
|
||||
maxWidth: '1200px',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<TerminalTypography variant="h5" sx={{ color: terminalColors.primary }}>
|
||||
{title}
|
||||
</TerminalTypography>
|
||||
<IconButton onClick={onClose} sx={{ color: terminalColors.primary }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{children}
|
||||
</TerminalPaper>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
const [modalOpen, setModalOpen] = useState<string | null>(null);
|
||||
|
||||
// Fetch events if not provided as prop
|
||||
useEffect(() => {
|
||||
@@ -37,10 +82,10 @@ const SchedulerCharts: React.FC<SchedulerChartsProps> = ({ events: propEvents })
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Fetch all events for visualization (no pagination limit)
|
||||
// Pass undefined to get all event types
|
||||
// Fetch events for visualization (max 500 per backend API limit)
|
||||
// Pass undefined to get all event types, use 30 days for charts
|
||||
console.log('📊 Charts - Fetching event history...');
|
||||
const response = await getSchedulerEventHistory(1000, 0, undefined);
|
||||
const response = await getSchedulerEventHistory(500, 0, undefined, 30);
|
||||
console.log('📊 Charts - Fetched events:', {
|
||||
totalEvents: response.events?.length || 0,
|
||||
totalCount: response.total_count,
|
||||
@@ -216,58 +261,172 @@ const SchedulerCharts: React.FC<SchedulerChartsProps> = ({ events: propEvents })
|
||||
);
|
||||
}
|
||||
|
||||
const handleChartClick = (chartId: string) => {
|
||||
setModalOpen(chartId);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setModalOpen(null);
|
||||
};
|
||||
|
||||
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>
|
||||
{/* Compact Charts in Single Row */}
|
||||
<Box sx={{ display: 'flex', gap: 2, overflowX: 'auto', pb: 2 }}>
|
||||
{/* Task Execution Trends - Compact */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: '0 0 300px',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
}}
|
||||
onClick={() => handleChartClick('task-execution')}
|
||||
>
|
||||
<TerminalPaper sx={{ p: 2, position: 'relative' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.primary, fontSize: '0.875rem' }}>
|
||||
Task Execution Trends
|
||||
</TerminalTypography>
|
||||
<MaximizeIcon sx={{ fontSize: 16, color: terminalColors.primary }} />
|
||||
</Box>
|
||||
<ResponsiveContainer width="100%" height={150}>
|
||||
<LineChart data={chartData.slice(-7)}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 10 }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 10 }}
|
||||
width={30}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="tasks_executed"
|
||||
stroke={terminalColors.success}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="tasks_failed"
|
||||
stroke={terminalColors.error}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</TerminalPaper>
|
||||
</Box>
|
||||
|
||||
{/* Job Status Distribution - Compact */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: '0 0 300px',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
}}
|
||||
onClick={() => handleChartClick('job-status')}
|
||||
>
|
||||
<TerminalPaper sx={{ p: 2, position: 'relative' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.primary, fontSize: '0.875rem' }}>
|
||||
Job Status Distribution
|
||||
</TerminalTypography>
|
||||
<MaximizeIcon sx={{ fontSize: 16, color: terminalColors.primary }} />
|
||||
</Box>
|
||||
<ResponsiveContainer width="100%" height={150}>
|
||||
<BarChart data={chartData.slice(-7)}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 10 }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 10 }}
|
||||
width={30}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey="job_completed"
|
||||
fill={terminalColors.success}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="job_failed"
|
||||
fill={terminalColors.error}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</TerminalPaper>
|
||||
</Box>
|
||||
|
||||
{/* Check Cycles - Compact */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: '0 0 300px',
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
}}
|
||||
onClick={() => handleChartClick('check-cycles')}
|
||||
>
|
||||
<TerminalPaper sx={{ p: 2, position: 'relative' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.primary, fontSize: '0.875rem' }}>
|
||||
Check Cycles Over Time
|
||||
</TerminalTypography>
|
||||
<MaximizeIcon sx={{ fontSize: 16, color: terminalColors.primary }} />
|
||||
</Box>
|
||||
<ResponsiveContainer width="100%" height={150}>
|
||||
<BarChart data={chartData.slice(-7)}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 10 }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 10 }}
|
||||
width={30}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey="check_cycles"
|
||||
fill={terminalColors.primary}
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</TerminalPaper>
|
||||
</Box>
|
||||
</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}>
|
||||
{/* Modals for Expanded Charts */}
|
||||
<ChartModal
|
||||
open={modalOpen === 'task-execution'}
|
||||
onClose={handleModalClose}
|
||||
title="Task Execution Trends (Last 30 Days)"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||
<XAxis
|
||||
@@ -309,14 +468,14 @@ const SchedulerCharts: React.FC<SchedulerChartsProps> = ({ events: propEvents })
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</TerminalPaper>
|
||||
</ChartModal>
|
||||
|
||||
{/* 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}>
|
||||
<ChartModal
|
||||
open={modalOpen === 'job-status'}
|
||||
onClose={handleModalClose}
|
||||
title="Job Status Distribution (Last 30 Days)"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||
<XAxis
|
||||
@@ -349,14 +508,14 @@ const SchedulerCharts: React.FC<SchedulerChartsProps> = ({ events: propEvents })
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</TerminalPaper>
|
||||
</ChartModal>
|
||||
|
||||
{/* 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}>
|
||||
<ChartModal
|
||||
open={modalOpen === 'check-cycles'}
|
||||
onClose={handleModalClose}
|
||||
title="Check Cycles Over Time (Last 30 Days)"
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||
<XAxis
|
||||
@@ -376,7 +535,51 @@ const SchedulerCharts: React.FC<SchedulerChartsProps> = ({ events: propEvents })
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</TerminalPaper>
|
||||
</ChartModal>
|
||||
|
||||
{/* Summary Stats - Compact */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))', gap: 2, mt: 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>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,14 +37,16 @@ interface SchedulerEventHistoryProps {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const SchedulerEventHistory: React.FC<SchedulerEventHistoryProps> = ({ limit = 50 }) => {
|
||||
const SchedulerEventHistory: React.FC<SchedulerEventHistoryProps> = () => {
|
||||
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 [isExpanded, setIsExpanded] = useState(false);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(5); // Start with 5, expand to 50 on hover
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [eventTypeFilter, setEventTypeFilter] = useState<string>('all');
|
||||
const [daysFilter, setDaysFilter] = useState<number>(7);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
@@ -54,7 +56,8 @@ const SchedulerEventHistory: React.FC<SchedulerEventHistoryProps> = ({ limit = 5
|
||||
const response = await getSchedulerEventHistory(
|
||||
rowsPerPage,
|
||||
page * rowsPerPage,
|
||||
eventTypeFilter !== 'all' ? eventTypeFilter as any : undefined
|
||||
eventTypeFilter !== 'all' ? eventTypeFilter as any : undefined,
|
||||
daysFilter
|
||||
);
|
||||
|
||||
setEvents(response.events);
|
||||
@@ -70,7 +73,16 @@ const SchedulerEventHistory: React.FC<SchedulerEventHistoryProps> = ({ limit = 5
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, rowsPerPage, eventTypeFilter]); // fetchEvents is stable, no need to include
|
||||
}, [page, rowsPerPage, eventTypeFilter, daysFilter]); // fetchEvents is stable, no need to include
|
||||
|
||||
// Expand to 50 rows on hover
|
||||
const handleMouseEnter = () => {
|
||||
if (!isExpanded) {
|
||||
setIsExpanded(true);
|
||||
setRowsPerPage(50);
|
||||
setPage(0); // Reset to first page when expanding
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePage = (_event: unknown, newPage: number) => {
|
||||
setPage(newPage);
|
||||
@@ -169,40 +181,92 @@ const SchedulerEventHistory: React.FC<SchedulerEventHistoryProps> = ({ limit = 5
|
||||
}
|
||||
|
||||
return (
|
||||
<TerminalPaper>
|
||||
<TerminalPaper
|
||||
onMouseEnter={handleMouseEnter}
|
||||
sx={{
|
||||
cursor: isExpanded ? 'default' : 'pointer',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
boxShadow: isExpanded ? undefined : '0 4px 8px rgba(0,0,0,0.2)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box p={2}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2} flexWrap="wrap" gap={2}>
|
||||
<TerminalTypography variant="h6">
|
||||
📜 Scheduler Event History
|
||||
{!isExpanded && (
|
||||
<Tooltip title="Hover to expand and see more events with pagination">
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
color: terminalColors.info,
|
||||
ml: 1,
|
||||
fontStyle: 'italic'
|
||||
}}
|
||||
>
|
||||
(Hover to expand)
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)}
|
||||
</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': {
|
||||
<Box display="flex" gap={2} flexWrap="wrap">
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel sx={{ color: terminalColors.primary }}>Days</InputLabel>
|
||||
<Select
|
||||
value={daysFilter}
|
||||
onChange={(e) => {
|
||||
setDaysFilter(e.target.value as number);
|
||||
setPage(0);
|
||||
}}
|
||||
sx={{
|
||||
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>
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: terminalColors.primary,
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
color: terminalColors.primary,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value={1}>Last 1 day</MenuItem>
|
||||
<MenuItem value={3}>Last 3 days</MenuItem>
|
||||
<MenuItem value={7}>Last 7 days</MenuItem>
|
||||
<MenuItem value={14}>Last 14 days</MenuItem>
|
||||
<MenuItem value={30}>Last 30 days</MenuItem>
|
||||
<MenuItem value={90}>Last 90 days</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<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>
|
||||
</Box>
|
||||
|
||||
{events.length === 0 ? (
|
||||
@@ -284,24 +348,33 @@ const SchedulerEventHistory: React.FC<SchedulerEventHistoryProps> = ({ limit = 5
|
||||
</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': {
|
||||
{isExpanded && (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
},
|
||||
'& .MuiIconButton-root': {
|
||||
color: terminalColors.primary,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
'& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': {
|
||||
color: terminalColors.primary,
|
||||
},
|
||||
'& .MuiIconButton-root': {
|
||||
color: terminalColors.primary,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isExpanded && totalCount > events.length && (
|
||||
<Box p={2} textAlign="center">
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.info, fontStyle: 'italic' }}>
|
||||
Showing {events.length} of {totalCount} events. Hover to expand and see more with pagination.
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Displays scheduled jobs in tree structure matching log format.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import {
|
||||
Schedule as ScheduleIcon,
|
||||
@@ -26,6 +26,34 @@ const SchedulerJobsTree: React.FC<SchedulerJobsTreeProps> = ({
|
||||
recurringJobs,
|
||||
oneTimeJobs
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const DEFAULT_DISPLAY_COUNT = 3; // Show only 3 jobs by default
|
||||
const COLLAPSE_DELAY = 2000; // 2 seconds delay before collapsing
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
setIsExpanded(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setIsExpanded(false);
|
||||
}, COLLAPSE_DELAY);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const displayedJobs = isExpanded ? jobs : jobs.slice(0, DEFAULT_DISPLAY_COUNT);
|
||||
const hasMoreJobs = jobs.length > DEFAULT_DISPLAY_COUNT;
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Not scheduled';
|
||||
try {
|
||||
@@ -66,6 +94,26 @@ const SchedulerJobsTree: React.FC<SchedulerJobsTreeProps> = ({
|
||||
};
|
||||
return `OAuth ${platformNames[platform] || platform.toUpperCase()}`;
|
||||
}
|
||||
if (jobId.includes('website_analysis')) {
|
||||
// Extract task type from job
|
||||
const taskType = job?.task_type || 'Website';
|
||||
const taskTypeNames: { [key: string]: string } = {
|
||||
'user_website': 'User Website',
|
||||
'competitor': 'Competitor'
|
||||
};
|
||||
return `Website Analysis - ${taskTypeNames[taskType] || taskType}`;
|
||||
}
|
||||
if (jobId.includes('platform_insights')) {
|
||||
// Extract platform from job ID or use platform field
|
||||
const platform = job?.platform ||
|
||||
jobId.split('_')[2] ||
|
||||
'Platform';
|
||||
const platformNames: { [key: string]: string } = {
|
||||
'gsc': 'GSC Insights',
|
||||
'bing': 'Bing Insights'
|
||||
};
|
||||
return platformNames[platform] || `${platform.toUpperCase()} Insights`;
|
||||
}
|
||||
return 'One-Time';
|
||||
};
|
||||
|
||||
@@ -93,19 +141,38 @@ const SchedulerJobsTree: React.FC<SchedulerJobsTreeProps> = ({
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ fontFamily: 'monospace', fontSize: '0.875rem', color: terminalColors.text, flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem',
|
||||
color: terminalColors.text,
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
minHeight: 0
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box mb={2} sx={{ flexShrink: 0 }}>
|
||||
<TerminalTypography variant="body2" sx={{ mb: 1, color: terminalColors.textSecondary }}>
|
||||
Recurring Jobs: {recurringJobs} | One-Time Jobs: {oneTimeJobs}
|
||||
{hasMoreJobs && !isExpanded && (
|
||||
<TerminalTypography
|
||||
component="span"
|
||||
sx={{ ml: 1, color: terminalColors.primary, fontStyle: 'italic', fontSize: '0.75rem' }}
|
||||
>
|
||||
(Hover to see all {jobs.length} jobs)
|
||||
</TerminalTypography>
|
||||
)}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
|
||||
{/* Jobs Tree */}
|
||||
{jobs.length > 0 ? (
|
||||
{displayedJobs.length > 0 ? (
|
||||
<Box sx={{ flex: 1 }}>
|
||||
{jobs.map((job, index) => {
|
||||
const isLast = index === jobs.length - 1;
|
||||
{displayedJobs.map((job, index) => {
|
||||
const isLast = index === displayedJobs.length - 1 && (!hasMoreJobs || isExpanded);
|
||||
const prefix = isLast ? '└─' : '├─';
|
||||
const isRecurring = job.id === 'check_due_tasks';
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Task Monitoring Tabs Component
|
||||
* Organizes OAuth Token Status, Website Analysis Status, and Platform Insights in tabs
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Box, Tabs, Tab } from '@mui/material';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import OAuthTokenStatus from './OAuthTokenStatus';
|
||||
import WebsiteAnalysisStatus from './WebsiteAnalysisStatus';
|
||||
import PlatformInsightsStatus from './PlatformInsightsStatus';
|
||||
import { TerminalPaper, terminalColors } from './terminalTheme';
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`task-monitoring-tabpanel-${index}`}
|
||||
aria-labelledby={`task-monitoring-tab-${index}`}
|
||||
>
|
||||
<Box sx={{ pt: 3, display: value === index ? 'block' : 'none' }}>{children}</Box>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Terminal-themed button-like tab styling
|
||||
const TerminalTab = styled(Tab)({
|
||||
minHeight: 48,
|
||||
padding: '8px 16px',
|
||||
textTransform: 'none',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 400,
|
||||
color: terminalColors.textSecondary,
|
||||
backgroundColor: 'transparent',
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
borderBottom: 'none',
|
||||
borderRadius: '4px 4px 0 0',
|
||||
marginRight: '4px',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
backgroundColor: terminalColors.backgroundHover,
|
||||
color: terminalColors.primary,
|
||||
borderColor: terminalColors.primary,
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
color: terminalColors.primary,
|
||||
backgroundColor: terminalColors.background,
|
||||
borderColor: terminalColors.primary,
|
||||
fontWeight: 600,
|
||||
},
|
||||
'&:focus': {
|
||||
outline: `2px solid ${terminalColors.primary}`,
|
||||
outlineOffset: '-2px',
|
||||
},
|
||||
});
|
||||
|
||||
const TaskMonitoringTabs: React.FC = () => {
|
||||
const [value, setValue] = useState(0);
|
||||
|
||||
const handleChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 0 }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: terminalColors.border, px: 2, pt: 2 }}>
|
||||
<Tabs
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
aria-label="task monitoring tabs"
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
'& .MuiTabs-indicator': {
|
||||
display: 'none', // Hide default indicator, we use border styling instead
|
||||
},
|
||||
'& .MuiTabs-flexContainer': {
|
||||
gap: 1,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TerminalTab
|
||||
label="OAuth Token Status"
|
||||
id="task-monitoring-tab-0"
|
||||
aria-controls="task-monitoring-tabpanel-0"
|
||||
/>
|
||||
<TerminalTab
|
||||
label="Website Analysis"
|
||||
id="task-monitoring-tab-1"
|
||||
aria-controls="task-monitoring-tabpanel-1"
|
||||
/>
|
||||
<TerminalTab
|
||||
label="Platform Insights"
|
||||
id="task-monitoring-tab-2"
|
||||
aria-controls="task-monitoring-tabpanel-2"
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
<TabPanel value={value} index={0}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<OAuthTokenStatus compact={true} />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel value={value} index={1}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<WebsiteAnalysisStatus compact={true} />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<TabPanel value={value} index={2}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<PlatformInsightsStatus compact={true} />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</TerminalPaper>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskMonitoringTabs;
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
/**
|
||||
* Website Analysis Status Component
|
||||
* Compact terminal-themed component for displaying website analysis task status
|
||||
* with execution logs in expanded sections
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Chip,
|
||||
Divider,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Globe,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import {
|
||||
getWebsiteAnalysisStatus,
|
||||
retryWebsiteAnalysis,
|
||||
getWebsiteAnalysisLogs,
|
||||
WebsiteAnalysisStatusResponse,
|
||||
WebsiteAnalysisTask,
|
||||
WebsiteAnalysisExecutionLog,
|
||||
WebsiteAnalysisLogsResponse,
|
||||
} from '../../api/websiteAnalysisMonitoring';
|
||||
import {
|
||||
TerminalPaper,
|
||||
TerminalTypography,
|
||||
TerminalChip,
|
||||
TerminalChipSuccess,
|
||||
TerminalChipError,
|
||||
TerminalChipWarning,
|
||||
TerminalAlert,
|
||||
TerminalTableCell,
|
||||
TerminalTableRow,
|
||||
terminalColors,
|
||||
} from './terminalTheme';
|
||||
|
||||
interface WebsiteAnalysisStatusProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
interface TaskLogs {
|
||||
[taskId: number]: {
|
||||
logs: WebsiteAnalysisExecutionLog[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const WebsiteAnalysisStatus: React.FC<WebsiteAnalysisStatusProps> = ({ compact = true }) => {
|
||||
const { userId } = useAuth();
|
||||
const [status, setStatus] = useState<WebsiteAnalysisStatusResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedTaskId, setExpandedTaskId] = useState<number | null>(null);
|
||||
const [taskLogs, setTaskLogs] = useState<TaskLogs>({});
|
||||
const [hoveredLogId, setHoveredLogId] = useState<number | null>(null);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await getWebsiteAnalysisStatus(userId);
|
||||
setStatus(response);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch website analysis status');
|
||||
console.error('Error fetching website analysis status:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTaskLogs = async (taskId: number) => {
|
||||
if (!userId) return;
|
||||
|
||||
// Initialize task logs state if not exists
|
||||
if (!taskLogs[taskId]) {
|
||||
setTaskLogs(prev => ({
|
||||
...prev,
|
||||
[taskId]: { logs: [], loading: false, error: null }
|
||||
}));
|
||||
}
|
||||
|
||||
// Check if already loading
|
||||
if (taskLogs[taskId]?.loading) return;
|
||||
|
||||
setTaskLogs(prev => ({
|
||||
...prev,
|
||||
[taskId]: { ...prev[taskId], loading: true, error: null }
|
||||
}));
|
||||
|
||||
try {
|
||||
console.log(`[WebsiteAnalysis] Fetching logs for task ${taskId}...`);
|
||||
const response = await getWebsiteAnalysisLogs(userId, 10, 0, taskId);
|
||||
console.log(`[WebsiteAnalysis] Received logs response:`, {
|
||||
logsCount: response.logs?.length || 0,
|
||||
totalCount: response.total_count,
|
||||
hasLogs: !!(response.logs && response.logs.length > 0),
|
||||
firstLog: response.logs?.[0] || null
|
||||
});
|
||||
|
||||
if (response.logs && Array.isArray(response.logs)) {
|
||||
setTaskLogs(prev => ({
|
||||
...prev,
|
||||
[taskId]: { logs: response.logs, loading: false, error: null }
|
||||
}));
|
||||
} else {
|
||||
console.warn(`[WebsiteAnalysis] Invalid logs response structure:`, response);
|
||||
setTaskLogs(prev => ({
|
||||
...prev,
|
||||
[taskId]: { logs: [], loading: false, error: 'Invalid response structure' }
|
||||
}));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[WebsiteAnalysis] Error fetching logs for task ${taskId}:`, err);
|
||||
setTaskLogs(prev => ({
|
||||
...prev,
|
||||
[taskId]: { ...prev[taskId], loading: false, error: err.message || 'Failed to fetch logs' }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = async (taskId: number) => {
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
setRefreshing(taskId);
|
||||
await retryWebsiteAnalysis(taskId);
|
||||
await fetchStatus(); // Refresh status
|
||||
} catch (err: any) {
|
||||
console.error('Error retrying website analysis:', err);
|
||||
alert(err.message || 'Failed to retry website analysis');
|
||||
} finally {
|
||||
setRefreshing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleExpand = (taskId: number) => {
|
||||
if (expandedTaskId === taskId) {
|
||||
setExpandedTaskId(null);
|
||||
} else {
|
||||
setExpandedTaskId(taskId);
|
||||
// Always fetch logs when expanding to get latest data
|
||||
fetchTaskLogs(taskId);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
// Refresh every 5 minutes (same as other dashboard components)
|
||||
// Tasks run on schedule (every 10 days for competitors, etc.), so frequent polling is unnecessary
|
||||
const interval = setInterval(fetchStatus, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [userId]);
|
||||
|
||||
// Fetch logs when task is expanded (similar to OAuth pattern)
|
||||
useEffect(() => {
|
||||
if (expandedTaskId && userId) {
|
||||
fetchTaskLogs(expandedTaskId);
|
||||
}
|
||||
}, [expandedTaskId, userId]);
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
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) => {
|
||||
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 getLogStatusChip = (logStatus: string) => {
|
||||
switch (logStatus) {
|
||||
case 'success':
|
||||
return <TerminalChipSuccess label="Success" size="small" />;
|
||||
case 'failed':
|
||||
return <TerminalChipError label="Failed" size="small" />;
|
||||
case 'running':
|
||||
return <TerminalChipWarning label="Running" size="small" />;
|
||||
default:
|
||||
return <Chip label={logStatus} size="small" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatLogResult = (resultData: any): string => {
|
||||
if (!resultData) return 'N/A';
|
||||
if (typeof resultData === 'string') {
|
||||
try {
|
||||
resultData = JSON.parse(resultData);
|
||||
} catch {
|
||||
return resultData.substring(0, 50);
|
||||
}
|
||||
}
|
||||
|
||||
if (resultData.style_analysis) {
|
||||
return 'Analysis completed';
|
||||
}
|
||||
if (resultData.crawl_result) {
|
||||
return 'Crawl completed';
|
||||
}
|
||||
const str = JSON.stringify(resultData);
|
||||
return str.length > 60 ? str.substring(0, 60) + '...' : str;
|
||||
};
|
||||
|
||||
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 allTasks = [
|
||||
...status.data.user_website_tasks,
|
||||
...status.data.competitor_tasks
|
||||
];
|
||||
|
||||
const renderTaskRow = (task: WebsiteAnalysisTask) => {
|
||||
const isExpanded = expandedTaskId === task.id;
|
||||
const logs = taskLogs[task.id]?.logs || [];
|
||||
const logsLoading = taskLogs[task.id]?.loading || false;
|
||||
const logsError = taskLogs[task.id]?.error;
|
||||
|
||||
return (
|
||||
<React.Fragment key={task.id}>
|
||||
<TerminalTableRow
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': { backgroundColor: terminalColors.backgroundHover }
|
||||
}}
|
||||
onClick={() => handleToggleExpand(task.id)}
|
||||
>
|
||||
<TerminalTableCell>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{task.task_type === 'user_website' ? (
|
||||
<Globe size={16} color={terminalColors.primary} />
|
||||
) : (
|
||||
<Users size={16} color={terminalColors.secondary} />
|
||||
)}
|
||||
<TerminalTypography variant="body2" sx={{ fontWeight: 500 }}>
|
||||
{task.website_url}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{getStatusIcon(task.status)}
|
||||
{getStatusChip(task.status)}
|
||||
</Box>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<Box display="flex" alignItems="center" gap={0.5} flexWrap="wrap">
|
||||
{task.last_success && (
|
||||
<Tooltip title={`Last successful: ${formatDate(task.last_success)}`}>
|
||||
<Chip
|
||||
label={`Last: ${formatDate(task.last_success).split(',')[0]}`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.7rem',
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
backgroundColor: terminalColors.background,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{task.next_check && (
|
||||
<Tooltip title={`Next check: ${formatDate(task.next_check)}`}>
|
||||
<Chip
|
||||
label={`Next: ${formatDate(task.next_check).split(',')[0]}`}
|
||||
size="small"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.7rem',
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
backgroundColor: terminalColors.background,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{task.status === 'failed' && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRetry(task.id);
|
||||
}}
|
||||
disabled={refreshing === task.id}
|
||||
sx={{
|
||||
minWidth: 'auto',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
fontSize: '0.7rem',
|
||||
borderColor: terminalColors.border,
|
||||
color: terminalColors.text,
|
||||
'&:hover': {
|
||||
borderColor: terminalColors.primary,
|
||||
backgroundColor: terminalColors.backgroundHover,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{refreshing === task.id ? <CircularProgress size={12} /> : 'Retry'}
|
||||
</Button>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleExpand(task.id);
|
||||
}}
|
||||
sx={{ color: terminalColors.text }}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</TerminalTableCell>
|
||||
</TerminalTableRow>
|
||||
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} sx={{ py: 0, border: 0 }}>
|
||||
<Collapse in={isExpanded} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ p: 2, backgroundColor: terminalColors.backgroundSecondary }}>
|
||||
{task.failure_reason && (
|
||||
<TerminalAlert severity="error" sx={{ mb: 2 }}>
|
||||
Error: {task.failure_reason}
|
||||
</TerminalAlert>
|
||||
)}
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 1, color: terminalColors.text, fontSize: '0.9rem' }}>
|
||||
Monitoring Logs
|
||||
</Typography>
|
||||
|
||||
{logsLoading ? (
|
||||
<Box display="flex" justifyContent="center" p={2}>
|
||||
<CircularProgress size={16} sx={{ color: terminalColors.primary }} />
|
||||
</Box>
|
||||
) : logsError ? (
|
||||
<TerminalAlert severity="error">{logsError}</TerminalAlert>
|
||||
) : logs.length === 0 ? (
|
||||
<Typography variant="body2" sx={{ color: terminalColors.textSecondary }}>
|
||||
No execution logs yet
|
||||
</Typography>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
border: `1px solid ${terminalColors.border}`,
|
||||
borderRadius: 1,
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
backgroundColor: terminalColors.background,
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
backgroundColor: terminalColors.border,
|
||||
borderRadius: '4px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ backgroundColor: terminalColors.background, color: terminalColors.text, fontSize: '0.75rem', py: 1 }}>
|
||||
Date
|
||||
</TableCell>
|
||||
<TableCell sx={{ backgroundColor: terminalColors.background, color: terminalColors.text, fontSize: '0.75rem', py: 1 }}>
|
||||
Status
|
||||
</TableCell>
|
||||
<TableCell sx={{ backgroundColor: terminalColors.background, color: terminalColors.text, fontSize: '0.75rem', py: 1 }}>
|
||||
Result
|
||||
</TableCell>
|
||||
<TableCell sx={{ backgroundColor: terminalColors.background, color: terminalColors.text, fontSize: '0.75rem', py: 1 }}>
|
||||
Duration
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{logs.map((log) => (
|
||||
<React.Fragment key={log.id}>
|
||||
<TableRow
|
||||
sx={{
|
||||
'&:hover': { backgroundColor: terminalColors.backgroundHover },
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={() => setHoveredLogId(log.id)}
|
||||
onMouseLeave={() => setHoveredLogId(null)}
|
||||
>
|
||||
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>
|
||||
{formatDate(log.execution_date)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>
|
||||
{getLogStatusChip(log.status)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>
|
||||
{formatLogResult(log.result_data)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>
|
||||
{log.execution_time_ms ? `${log.execution_time_ms}ms` : 'N/A'}
|
||||
</TerminalTableCell>
|
||||
</TableRow>
|
||||
{hoveredLogId === log.id && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} sx={{ py: 1, border: 0, backgroundColor: terminalColors.backgroundSecondary }}>
|
||||
{log.error_message && (
|
||||
<Box sx={{ mb: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: terminalColors.error, fontWeight: 'bold' }}>
|
||||
Error:
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: terminalColors.text, display: 'block', ml: 1 }}>
|
||||
{log.error_message}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{log.result_data && (
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: terminalColors.textSecondary, fontWeight: 'bold' }}>
|
||||
Result Data:
|
||||
</Typography>
|
||||
<pre style={{
|
||||
fontSize: '0.7rem',
|
||||
color: terminalColors.text,
|
||||
margin: '4px 0 0 0',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word'
|
||||
}}>
|
||||
{JSON.stringify(log.result_data, null, 2)}
|
||||
</pre>
|
||||
</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<TerminalTypography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Globe size={20} />
|
||||
Website Analysis Status
|
||||
</TerminalTypography>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{status && (
|
||||
<TerminalChip
|
||||
label={`${status.data.active_tasks} Active`}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
{status && status.data.failed_tasks > 0 && (
|
||||
<TerminalChipError
|
||||
label={`${status.data.failed_tasks} Failed`}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
sx={{ color: terminalColors.text }}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<TerminalAlert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</TerminalAlert>
|
||||
)}
|
||||
|
||||
{status && (
|
||||
<>
|
||||
{status.data.user_website_tasks.length > 0 && (
|
||||
<Box mb={2}>
|
||||
<TerminalTypography variant="subtitle2" sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Globe size={14} />
|
||||
User Website ({status.data.user_website_tasks.length})
|
||||
</TerminalTypography>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>Website</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>Status</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>Timing</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>Actions</TerminalTableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{status.data.user_website_tasks.map(renderTaskRow)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{status.data.competitor_tasks.length > 0 && (
|
||||
<Box>
|
||||
<TerminalTypography variant="subtitle2" sx={{ mb: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Users size={14} />
|
||||
Competitors ({status.data.competitor_tasks.length})
|
||||
</TerminalTypography>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>Website</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>Status</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>Timing</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontSize: '0.75rem' }}>Actions</TerminalTableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{status.data.competitor_tasks.map(renderTaskRow)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{allTasks.length === 0 && (
|
||||
<Box p={2} textAlign="center">
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
|
||||
No website analysis tasks found. Complete onboarding to create tasks.
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TerminalPaper>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebsiteAnalysisStatus;
|
||||
|
||||
@@ -180,6 +180,8 @@ export const terminalColors = {
|
||||
success: '#00ff00',
|
||||
background: '#0a0a0a',
|
||||
backgroundLight: '#1a1a1a',
|
||||
backgroundHover: 'rgba(0, 255, 0, 0.05)',
|
||||
backgroundSecondary: 'rgba(0, 255, 0, 0.05)',
|
||||
text: '#00ff00',
|
||||
textSecondary: '#00ff88',
|
||||
border: '#00ff00',
|
||||
|
||||
@@ -229,10 +229,15 @@ const SubscriptionExpiredModal: React.FC<SubscriptionExpiredModalProps> = ({
|
||||
{errorData.usage_info.current_calls?.toLocaleString() || 0}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#7f1d1d' }}>
|
||||
/ {errorData.usage_info.call_limit?.toLocaleString() || 0}
|
||||
/ {(errorData.usage_info.limit || errorData.usage_info.call_limit || 0)?.toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#7f1d1d', ml: 'auto' }}>
|
||||
({((errorData.usage_info.current_calls / errorData.usage_info.call_limit) * 100).toFixed(1)}% used)
|
||||
{(() => {
|
||||
const limit = errorData.usage_info.limit || errorData.usage_info.call_limit || 0;
|
||||
const current = errorData.usage_info.current_calls || 0;
|
||||
const percentage = limit > 0 ? ((current / limit) * 100).toFixed(1) : '0.0';
|
||||
return `(${percentage}% used)`;
|
||||
})()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { getResearchConfig, PersonaDefaults, refreshResearchPersona, ResearchPersona, getCompetitorAnalysis, CompetitorAnalysisResponse } from '../api/researchConfig';
|
||||
import { ResearchPersonaModal } from '../components/Research/ResearchPersonaModal';
|
||||
import { OnboardingCompetitorModal } from '../components/Research/OnboardingCompetitorModal';
|
||||
|
||||
const samplePresets = [
|
||||
{
|
||||
@@ -204,6 +205,13 @@ export const ResearchTest: React.FC = () => {
|
||||
const [showPersonaModal, setShowPersonaModal] = useState(false);
|
||||
const [personaChecked, setPersonaChecked] = useState(false);
|
||||
const [researchPersona, setResearchPersona] = useState<ResearchPersona | null>(null);
|
||||
const [showCompetitorModal, setShowCompetitorModal] = useState(false);
|
||||
const [competitorData, setCompetitorData] = useState<CompetitorAnalysisResponse | null>(null);
|
||||
const [loadingCompetitors, setLoadingCompetitors] = useState(false);
|
||||
const [competitorError, setCompetitorError] = useState<string | null>(null);
|
||||
const [showPersonaDetailsModal, setShowPersonaDetailsModal] = useState(false);
|
||||
const [personaExists, setPersonaExists] = useState(false);
|
||||
const [loadingPersonaDetails, setLoadingPersonaDetails] = useState(false);
|
||||
|
||||
// Debug: Track modal state changes
|
||||
useEffect(() => {
|
||||
@@ -236,6 +244,7 @@ export const ResearchTest: React.FC = () => {
|
||||
});
|
||||
|
||||
setResearchPersona(config.research_persona);
|
||||
setPersonaExists(true);
|
||||
|
||||
// Use AI-generated presets if persona exists
|
||||
if (config.research_persona.recommended_presets && config.research_persona.recommended_presets.length > 0) {
|
||||
@@ -243,7 +252,11 @@ export const ResearchTest: React.FC = () => {
|
||||
// Convert AI presets to display format
|
||||
const aiPresets = config.research_persona.recommended_presets.map((preset: any) => ({
|
||||
name: preset.name,
|
||||
keywords: preset.keywords.join(', '),
|
||||
keywords: typeof preset.keywords === 'string'
|
||||
? preset.keywords
|
||||
: Array.isArray(preset.keywords)
|
||||
? preset.keywords.join(', ')
|
||||
: 'N/A',
|
||||
industry: config.persona_defaults?.industry || 'General',
|
||||
targetAudience: config.persona_defaults?.target_audience || 'General',
|
||||
researchMode: preset.config?.mode || 'comprehensive',
|
||||
@@ -268,21 +281,23 @@ export const ResearchTest: React.FC = () => {
|
||||
const dynamicPresets = generatePersonaPresets(config.persona_defaults || null);
|
||||
setDisplayPresets(dynamicPresets);
|
||||
|
||||
// Show modal only if onboarding is completed
|
||||
// Show modal when research persona is missing
|
||||
// This allows users to generate a research persona even if onboarding isn't completed yet
|
||||
// or if the cached persona has expired
|
||||
console.log('[ResearchTest] ✅ Research persona missing - SHOWING MODAL');
|
||||
console.log('[ResearchTest] Setting showPersonaModal to true');
|
||||
setShowPersonaModal(true);
|
||||
setPersonaExists(false);
|
||||
|
||||
// Log onboarding and scheduling status for context
|
||||
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)');
|
||||
console.log('[ResearchTest] ℹ️ Onboarding completed - user can generate persona now or wait for scheduled generation');
|
||||
}
|
||||
} 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');
|
||||
console.log('[ResearchTest] ℹ️ Onboarding not completed yet - user can still generate research persona');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +332,7 @@ export const ResearchTest: React.FC = () => {
|
||||
});
|
||||
|
||||
setResearchPersona(persona);
|
||||
setPersonaExists(true);
|
||||
|
||||
// Reload config to get updated presets
|
||||
const config = await getResearchConfig();
|
||||
@@ -324,7 +340,11 @@ export const ResearchTest: React.FC = () => {
|
||||
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(', '),
|
||||
keywords: typeof preset.keywords === 'string'
|
||||
? preset.keywords
|
||||
: Array.isArray(preset.keywords)
|
||||
? preset.keywords.join(', ')
|
||||
: 'N/A',
|
||||
industry: config.persona_defaults.industry || 'General',
|
||||
targetAudience: config.persona_defaults.target_audience || 'General',
|
||||
researchMode: preset.config?.mode || 'comprehensive',
|
||||
@@ -372,6 +392,58 @@ export const ResearchTest: React.FC = () => {
|
||||
setResults(null);
|
||||
};
|
||||
|
||||
const handleOpenCompetitorModal = async () => {
|
||||
console.log('[handleOpenCompetitorModal] ===== START: Opening competitor analysis modal =====');
|
||||
setShowCompetitorModal(true);
|
||||
setLoadingCompetitors(true);
|
||||
setCompetitorError(null);
|
||||
|
||||
try {
|
||||
console.log('[handleOpenCompetitorModal] Calling getCompetitorAnalysis()...');
|
||||
const data = await getCompetitorAnalysis();
|
||||
console.log('[handleOpenCompetitorModal] Received data:', {
|
||||
success: data.success,
|
||||
competitorsCount: data.competitors?.length || 0,
|
||||
error: data.error,
|
||||
hasCompetitors: !!data.competitors && data.competitors.length > 0
|
||||
});
|
||||
|
||||
setCompetitorData(data);
|
||||
if (!data.success) {
|
||||
const errorMsg = data.error || 'Failed to load competitor data';
|
||||
console.error('[handleOpenCompetitorModal] ❌ Failed to load competitor data:', errorMsg);
|
||||
setCompetitorError(errorMsg);
|
||||
} else {
|
||||
console.log('[handleOpenCompetitorModal] ✅ Successfully loaded competitor data');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to load competitor data';
|
||||
console.error('[handleOpenCompetitorModal] ❌ EXCEPTION:', error);
|
||||
setCompetitorError(errorMsg);
|
||||
setCompetitorData(null);
|
||||
} finally {
|
||||
setLoadingCompetitors(false);
|
||||
console.log('[handleOpenCompetitorModal] ===== END: Opening competitor analysis modal =====');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenPersonaDetails = async () => {
|
||||
setShowPersonaDetailsModal(true);
|
||||
setLoadingPersonaDetails(true);
|
||||
|
||||
try {
|
||||
// Fetch fresh persona data
|
||||
const config = await getResearchConfig();
|
||||
if (config.research_persona) {
|
||||
setResearchPersona(config.research_persona);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ResearchTest] Error loading persona details:', error);
|
||||
} finally {
|
||||
setLoadingPersonaDetails(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
@@ -403,33 +475,45 @@ export const ResearchTest: React.FC = () => {
|
||||
animation: 'float 15s ease-in-out infinite reverse',
|
||||
}} />
|
||||
|
||||
<style>{`
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(20px, 20px); }
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -1000px 0; }
|
||||
100% { background-position: 1000px 0; }
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.card-hover {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
`}</style>
|
||||
<style>{`
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0); }
|
||||
50% { transform: translate(20px, 20px); }
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -1000px 0; }
|
||||
100% { background-position: 1000px 0; }
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes glow-green {
|
||||
0%, 100% { box-shadow: 0 0 20px rgba(34, 197, 94, 0.5), 0 2px 8px rgba(34, 197, 94, 0.3); }
|
||||
50% { box-shadow: 0 0 30px rgba(34, 197, 94, 0.8), 0 2px 12px rgba(34, 197, 94, 0.5); }
|
||||
}
|
||||
@keyframes glow-red {
|
||||
0%, 100% { box-shadow: 0 0 20px rgba(239, 68, 68, 0.5), 0 2px 8px rgba(239, 68, 68, 0.3); }
|
||||
50% { box-shadow: 0 0 30px rgba(239, 68, 68, 0.8), 0 2px 12px rgba(239, 68, 68, 0.5); }
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
.card-hover {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
`}</style>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.7)',
|
||||
@@ -456,25 +540,105 @@ export const ResearchTest: React.FC = () => {
|
||||
}}>
|
||||
🔬
|
||||
</div>
|
||||
<div>
|
||||
<h1 style={{
|
||||
margin: 0,
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0c4a6e',
|
||||
letterSpacing: '-0.01em',
|
||||
}}>
|
||||
AI-Powered Research Lab
|
||||
</h1>
|
||||
<p style={{
|
||||
margin: '2px 0 0 0',
|
||||
fontSize: '13px',
|
||||
color: '#0369a1',
|
||||
fontWeight: '400',
|
||||
}}>
|
||||
Enterprise-grade research intelligence at your fingertips
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<h1 style={{
|
||||
margin: 0,
|
||||
fontSize: '24px',
|
||||
fontWeight: '700',
|
||||
color: '#0c4a6e',
|
||||
letterSpacing: '-0.01em',
|
||||
}}>
|
||||
AI-Powered Research Lab
|
||||
</h1>
|
||||
<p style={{
|
||||
margin: '2px 0 0 0',
|
||||
fontSize: '13px',
|
||||
color: '#0369a1',
|
||||
fontWeight: '400',
|
||||
}}>
|
||||
Enterprise-grade research intelligence at your fingertips
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenCompetitorModal}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#0284c7',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
boxShadow: '0 2px 8px rgba(2, 132, 199, 0.2)',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#0369a1';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(2, 132, 199, 0.3)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#0284c7';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(2, 132, 199, 0.2)';
|
||||
}}
|
||||
>
|
||||
<span>📊</span>
|
||||
<span>View Competitor Analysis</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenPersonaDetails}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: personaExists ? '#22c55e' : '#ef4444',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
boxShadow: personaExists
|
||||
? '0 0 20px rgba(34, 197, 94, 0.5), 0 2px 8px rgba(34, 197, 94, 0.3)'
|
||||
: '0 0 20px rgba(239, 68, 68, 0.5), 0 2px 8px rgba(239, 68, 68, 0.3)',
|
||||
transition: 'all 0.2s ease',
|
||||
animation: personaExists ? 'glow-green 2s ease-in-out infinite' : 'glow-red 2s ease-in-out infinite',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
if (personaExists) {
|
||||
e.currentTarget.style.boxShadow = '0 0 30px rgba(34, 197, 94, 0.7), 0 4px 12px rgba(34, 197, 94, 0.4)';
|
||||
} else {
|
||||
e.currentTarget.style.boxShadow = '0 0 30px rgba(239, 68, 68, 0.7), 0 4px 12px rgba(239, 68, 68, 0.4)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
if (personaExists) {
|
||||
e.currentTarget.style.boxShadow = '0 0 20px rgba(34, 197, 94, 0.5), 0 2px 8px rgba(34, 197, 94, 0.3)';
|
||||
} else {
|
||||
e.currentTarget.style.boxShadow = '0 0 20px rgba(239, 68, 68, 0.5), 0 2px 8px rgba(239, 68, 68, 0.3)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: 'white',
|
||||
boxShadow: personaExists
|
||||
? '0 0 8px rgba(255, 255, 255, 0.8)'
|
||||
: '0 0 8px rgba(255, 255, 255, 0.8)',
|
||||
animation: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
}} />
|
||||
<span>{personaExists ? '✓ Research Persona' : '✗ No Persona'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Badge - Moved to Header */}
|
||||
@@ -859,9 +1023,284 @@ export const ResearchTest: React.FC = () => {
|
||||
onGenerate={handleGeneratePersona}
|
||||
onCancel={handleCancelPersona}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
{/* Competitor Analysis Modal */}
|
||||
<OnboardingCompetitorModal
|
||||
open={showCompetitorModal}
|
||||
onClose={() => setShowCompetitorModal(false)}
|
||||
data={competitorData}
|
||||
loading={loadingCompetitors}
|
||||
error={competitorError}
|
||||
/>
|
||||
|
||||
{/* Research Persona Details Modal */}
|
||||
{showPersonaDetailsModal && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 9999,
|
||||
padding: '20px',
|
||||
}}
|
||||
onClick={() => setShowPersonaDetailsModal(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #fff 0%, #f8fafc 100%)',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
maxHeight: '90vh',
|
||||
overflow: 'auto',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
|
||||
<h2 style={{ margin: 0, fontSize: '24px', fontWeight: '700', color: '#0f172a' }}>
|
||||
Research Persona Details
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowPersonaDetailsModal(false)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: '#64748b',
|
||||
padding: '4px 8px',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loadingPersonaDetails ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div style={{ fontSize: '18px', color: '#64748b' }}>Loading persona details...</div>
|
||||
</div>
|
||||
) : researchPersona ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
{/* Status Badge */}
|
||||
<div style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 16px',
|
||||
background: 'rgba(34, 197, 94, 0.1)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.25)',
|
||||
borderRadius: '20px',
|
||||
fontSize: '14px',
|
||||
color: '#16a34a',
|
||||
fontWeight: '600',
|
||||
width: 'fit-content',
|
||||
}}>
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: '#22c55e',
|
||||
boxShadow: '0 0 8px rgba(34, 197, 94, 0.6)',
|
||||
}} />
|
||||
Persona Active
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#0f172a' }}>
|
||||
Default Settings
|
||||
</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: '4px' }}>Industry</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '600', color: '#0f172a' }}>
|
||||
{researchPersona.default_industry || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: '4px' }}>Target Audience</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '600', color: '#0f172a' }}>
|
||||
{researchPersona.default_target_audience || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: '4px' }}>Research Mode</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '600', color: '#0f172a' }}>
|
||||
{researchPersona.default_research_mode || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: '#64748b', marginBottom: '4px' }}>Provider</div>
|
||||
<div style={{ fontSize: '16px', fontWeight: '600', color: '#0f172a' }}>
|
||||
{researchPersona.default_provider || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggested Keywords */}
|
||||
{researchPersona.suggested_keywords && researchPersona.suggested_keywords.length > 0 && (
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#0f172a' }}>
|
||||
Suggested Keywords ({researchPersona.suggested_keywords.length})
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
{researchPersona.suggested_keywords.map((keyword, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
borderRadius: '16px',
|
||||
fontSize: '14px',
|
||||
color: '#0369a1',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
{keyword}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Research Angles */}
|
||||
{researchPersona.research_angles && researchPersona.research_angles.length > 0 && (
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#0f172a' }}>
|
||||
Research Angles ({researchPersona.research_angles.length})
|
||||
</h3>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{researchPersona.research_angles.map((angle, idx) => (
|
||||
<li key={idx} style={{ fontSize: '14px', color: '#475569', lineHeight: '1.6' }}>
|
||||
{angle}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommended Presets */}
|
||||
{researchPersona.recommended_presets && researchPersona.recommended_presets.length > 0 && (
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#0f172a' }}>
|
||||
Recommended Presets ({researchPersona.recommended_presets.length})
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{researchPersona.recommended_presets.map((preset, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
padding: '12px',
|
||||
background: 'rgba(14, 165, 233, 0.05)',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.1)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '16px', fontWeight: '600', color: '#0f172a', marginBottom: '4px' }}>
|
||||
{preset.name || `Preset ${idx + 1}`}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#64748b' }}>
|
||||
{typeof preset.keywords === 'string'
|
||||
? preset.keywords
|
||||
: Array.isArray(preset.keywords)
|
||||
? (preset.keywords as string[]).join(', ')
|
||||
: 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div style={{
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
padding: '20px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px 0', fontSize: '18px', fontWeight: '600', color: '#0f172a' }}>
|
||||
Metadata
|
||||
</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', fontSize: '14px' }}>
|
||||
{researchPersona.generated_at && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#64748b' }}>Generated At:</span>
|
||||
<span style={{ color: '#0f172a', fontWeight: '500' }}>
|
||||
{new Date(researchPersona.generated_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{researchPersona.confidence_score !== undefined && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#64748b' }}>Confidence Score:</span>
|
||||
<span style={{ color: '#0f172a', fontWeight: '500' }}>
|
||||
{(researchPersona.confidence_score * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{researchPersona.version && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#64748b' }}>Version:</span>
|
||||
<span style={{ color: '#0f172a', fontWeight: '500' }}>{researchPersona.version}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px',
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>⚠️</div>
|
||||
<div style={{ fontSize: '18px', fontWeight: '600', color: '#dc2626', marginBottom: '8px' }}>
|
||||
No Research Persona Found
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#64748b' }}>
|
||||
Generate a research persona to get personalized research suggestions and presets.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchTest;
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import ExecutionLogsTable from '../components/SchedulerDashboard/ExecutionLogsTa
|
||||
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 TaskMonitoringTabs from '../components/SchedulerDashboard/TaskMonitoringTabs';
|
||||
import { TerminalTypography, terminalColors } from '../components/SchedulerDashboard/terminalTheme';
|
||||
|
||||
// Terminal-themed styled components
|
||||
@@ -658,9 +658,9 @@ const SchedulerDashboard: React.FC = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* OAuth Token Status */}
|
||||
{/* Task Monitoring Tabs */}
|
||||
<Box mb={4}>
|
||||
<OAuthTokenStatus compact={true} />
|
||||
<TaskMonitoringTabs />
|
||||
</Box>
|
||||
|
||||
{/* Execution Logs */}
|
||||
@@ -670,7 +670,7 @@ const SchedulerDashboard: React.FC = () => {
|
||||
|
||||
{/* Scheduler Event History */}
|
||||
<Box mb={4}>
|
||||
<SchedulerEventHistory limit={50} />
|
||||
<SchedulerEventHistory />
|
||||
</Box>
|
||||
|
||||
{/* Scheduler Charts Visualization */}
|
||||
|
||||
@@ -18,7 +18,7 @@ export interface ResearchSource {
|
||||
}
|
||||
|
||||
export type ResearchMode = 'basic' | 'comprehensive' | 'targeted';
|
||||
export type ResearchProvider = 'google' | 'exa';
|
||||
export type ResearchProvider = 'google' | 'exa' | 'tavily';
|
||||
export type SourceType = 'web' | 'academic' | 'news' | 'industry' | 'expert';
|
||||
export type DateRange = 'last_week' | 'last_month' | 'last_3_months' | 'last_6_months' | 'last_year' | 'all_time';
|
||||
|
||||
@@ -37,6 +37,22 @@ export interface ResearchConfig {
|
||||
exa_include_domains?: string[];
|
||||
exa_exclude_domains?: string[];
|
||||
exa_search_type?: 'auto' | 'keyword' | 'neural';
|
||||
// Tavily-specific options
|
||||
tavily_topic?: 'general' | 'news' | 'finance';
|
||||
tavily_search_depth?: 'basic' | 'advanced';
|
||||
tavily_include_domains?: string[];
|
||||
tavily_exclude_domains?: string[];
|
||||
tavily_include_answer?: boolean | 'basic' | 'advanced';
|
||||
tavily_include_raw_content?: boolean | 'markdown' | 'text';
|
||||
tavily_include_images?: boolean;
|
||||
tavily_include_image_descriptions?: boolean;
|
||||
tavily_include_favicon?: boolean;
|
||||
tavily_time_range?: 'day' | 'week' | 'month' | 'year' | 'd' | 'w' | 'm' | 'y';
|
||||
tavily_start_date?: string; // YYYY-MM-DD
|
||||
tavily_end_date?: string; // YYYY-MM-DD
|
||||
tavily_country?: string;
|
||||
tavily_chunks_per_source?: number; // 1-3
|
||||
tavily_auto_parameters?: boolean;
|
||||
}
|
||||
|
||||
export interface BlogResearchRequest {
|
||||
|
||||
Reference in New Issue
Block a user