Scheduled research persona generation

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

View File

@@ -29,6 +29,16 @@ import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSec
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
export const BlogWriter: React.FC = () => {
// Add light theme class to body/html on mount, remove on unmount
React.useEffect(() => {
document.body.classList.add('blog-writer-page');
document.documentElement.classList.add('blog-writer-page');
return () => {
document.body.classList.remove('blog-writer-page');
document.documentElement.classList.remove('blog-writer-page');
};
}, []);
// Check CopilotKit health status
const { isAvailable: copilotKitAvailable } = useCopilotKitHealth({
enabled: true, // Enable health checking
@@ -313,6 +323,7 @@ export const BlogWriter: React.FC = () => {
sections,
research,
openSEOMetadata: () => setIsSEOMetadataModalOpen(true),
navigateToPhase,
});
@@ -320,7 +331,14 @@ export const BlogWriter: React.FC = () => {
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
<div style={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
backgroundColor: '#ffffff',
color: '#1a1a1a',
overflow: 'auto'
}} className="blog-writer-container">
{/* CopilotKit-dependent components - extracted to CopilotKitComponents */}
{copilotKitAvailable && (
<CopilotKitComponents
@@ -349,6 +367,7 @@ export const BlogWriter: React.FC = () => {
setFlowAnalysisResults={setFlowAnalysisResults}
setContinuityRefresh={setContinuityRefresh}
researchPolling={researchPolling}
navigateToPhase={navigateToPhase}
/>
)}
@@ -359,6 +378,14 @@ export const BlogWriter: React.FC = () => {
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
onModalShow={() => setShowOutlineModal(true)}
navigateToPhase={navigateToPhase}
onOutlineCreated={(outline, titleOptions) => {
// Handle cached outline from CopilotKit action (same as header button)
setOutline(outline);
if (titleOptions) {
setTitleOptions(titleOptions);
}
}}
/>
<OutlineRefiner
outline={outline}
@@ -381,31 +408,29 @@ export const BlogWriter: React.FC = () => {
seoMetadata={seoMetadata}
/>
{/* Always show HeaderBar when CopilotKit is unavailable, or when research exists */}
{(!copilotKitAvailable || research) && (
<HeaderBar
phases={phases}
currentPhase={currentPhase}
onPhaseClick={handlePhaseClick}
copilotKitAvailable={copilotKitAvailable}
actionHandlers={{
onResearchAction: handleResearchAction,
onOutlineAction: handleOutlineAction,
onContentAction: handleContentAction,
onSEOAction: handleSEOAction,
onApplySEORecommendations: handleApplySEORecommendations,
onPublishAction: handlePublishAction,
}}
hasResearch={!!research}
hasOutline={outline.length > 0}
outlineConfirmed={outlineConfirmed}
hasContent={Object.keys(sections).length > 0}
contentConfirmed={contentConfirmed}
hasSEOAnalysis={!!seoAnalysis}
seoRecommendationsApplied={seoRecommendationsApplied}
hasSEOMetadata={!!seoMetadata}
/>
)}
{/* Phase navigation header - always visible as default interface */}
<HeaderBar
phases={phases}
currentPhase={currentPhase}
onPhaseClick={handlePhaseClick}
copilotKitAvailable={copilotKitAvailable}
actionHandlers={{
onResearchAction: handleResearchAction,
onOutlineAction: handleOutlineAction,
onContentAction: handleContentAction,
onSEOAction: handleSEOAction,
onApplySEORecommendations: handleApplySEORecommendations,
onPublishAction: handlePublishAction,
}}
hasResearch={!!research}
hasOutline={outline.length > 0}
outlineConfirmed={outlineConfirmed}
hasContent={Object.keys(sections).length > 0}
contentConfirmed={contentConfirmed}
hasSEOAnalysis={!!seoAnalysis}
seoRecommendationsApplied={seoRecommendationsApplied}
hasSEOMetadata={!!seoMetadata}
/>
{/* Landing section - extracted to BlogWriterLandingSection */}
<BlogWriterLandingSection

View File

@@ -70,21 +70,12 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
backgroundSize: '56% auto',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
backgroundColor: 'transparent',
backgroundColor: '#ffffff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
}}>
{/* Animated overlay for subtle movement */}
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(156, 39, 176, 0.05) 100%)'
}} />
{/* Main content container */}
<div style={{
@@ -109,7 +100,7 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
textShadow: '0 4px 8px rgba(0,0,0,0.1)',
lineHeight: '1.2'
}}>
Step1- Research Your Blog Topic
AI-First, Contextual, Click through Blog Writer
</h1>
</div>

View File

@@ -17,27 +17,24 @@ export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> =
navigateToPhase,
onResearchComplete,
}) => {
// Only show landing/initial content when no research exists
// Phase navigation header is always visible, so this is just the initial content
if (!research) {
return (
<>
{/* Show manual research form when on research phase and CopilotKit unavailable */}
{!copilotKitAvailable && currentPhase === 'research' && (
<ManualResearchForm onResearchComplete={onResearchComplete} />
)}
{copilotKitAvailable && (
{/* Show landing page for CopilotKit flow or when not on research phase */}
{(!copilotKitAvailable && currentPhase !== 'research') || copilotKitAvailable ? (
<BlogWriterLanding
onStartWriting={() => {
// Trigger the copilot to start the research process
}}
/>
)}
{!copilotKitAvailable && currentPhase !== 'research' && (
<BlogWriterLanding
onStartWriting={() => {
// Navigate to research phase when CopilotKit unavailable
// Navigate to research phase to start the workflow
navigateToPhase('research');
}}
/>
)}
) : null}
</>
);
}

View File

@@ -27,6 +27,7 @@ interface CopilotKitComponentsProps {
setFlowAnalysisResults: (results: any) => void;
setContinuityRefresh: (refresh: number | ((prev: number) => number)) => void;
researchPolling: any;
navigateToPhase?: (phase: string) => void;
}
export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
@@ -49,6 +50,7 @@ export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
setFlowAnalysisResults,
setContinuityRefresh,
researchPolling,
navigateToPhase,
}) => {
return (
<>
@@ -57,12 +59,13 @@ export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
onTaskStart={(taskId) => researchPolling.startPolling(taskId)}
/>
<CustomOutlineForm onOutlineCreated={onOutlineCreated} />
<ResearchAction onResearchComplete={onResearchComplete} />
<ResearchAction onResearchComplete={onResearchComplete} navigateToPhase={navigateToPhase} />
<ResearchDataActions
research={research}
onOutlineCreated={onOutlineCreated}
onTitleOptionsSet={onTitleOptionsSet}
onTitleOptionsSet={onTitleOptionsSet}
navigateToPhase={navigateToPhase}
/>
<EnhancedOutlineActions
outline={outline}
@@ -77,6 +80,7 @@ export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
onMediumGenerationTriggered={onMediumGenerationTriggered}
sections={sections}
blogTitle={selectedTitle ?? undefined}
navigateToPhase={navigateToPhase}
onFlowAnalysisComplete={(analysis) => {
console.log('Flow analysis completed:', analysis);
setFlowAnalysisCompleted(true);

View File

@@ -16,14 +16,242 @@ export const WriterCopilotSidebar: React.FC<WriterCopilotSidebarProps> = ({
outlineConfirmed,
}) => {
return (
<CopilotSidebar
labels={{
title: 'ALwrity Co-Pilot',
initial: !research
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.',
}}
suggestions={suggestions}
<>
<style>{`
/* Enterprise CopilotKit Suggestion Styling */
/* All suggestion chips - base styling */
.copilotkit-suggestions button,
.copilot-suggestions button,
[class*="suggestion"] button,
[class*="Suggestion"] button {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-radius: 12px;
border: 1px solid rgba(99, 102, 241, 0.2);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 250, 251, 0.98) 100%);
color: #4b5563;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(255, 255, 255, 0.5) inset;
letter-spacing: 0.01em;
}
/* Shine effect on hover */
.copilotkit-suggestions button::before,
.copilot-suggestions button::before,
[class*="suggestion"] button::before,
[class*="Suggestion"] button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
transition: left 0.6s ease;
}
.copilotkit-suggestions button:hover::before,
.copilot-suggestions button:hover::before,
[class*="suggestion"] button:hover::before,
[class*="Suggestion"] button:hover::before {
left: 100%;
}
/* Regular suggestions - hover effects */
.copilotkit-suggestions button:hover,
.copilot-suggestions button:hover,
[class*="suggestion"] button:hover:not([class*="next-suggestion"]),
[class*="Suggestion"] button:hover:not([class*="next-suggestion"]) {
transform: translateY(-2px) scale(1.02);
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.6) inset;
border-color: rgba(99, 102, 241, 0.3);
background: linear-gradient(135deg, rgba(255, 255, 255, 1) 0%, rgba(249, 250, 251, 1) 100%);
}
/* "Next:" Suggestions - Premium Enterprise Style */
.copilotkit-suggestions button[data-is-next="true"],
.copilot-suggestions button[data-is-next="true"],
.copilotkit-suggestions button.next-suggestion,
.copilot-suggestions button.next-suggestion,
.copilotkit-suggestions button[aria-label*="Next:"],
.copilot-suggestions button[aria-label*="Next:"] {
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%) !important;
color: white !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
0 2px 4px rgba(0, 0, 0, 0.1) inset,
0 0 20px rgba(102, 126, 234, 0.3) !important;
font-weight: 700 !important;
text-transform: uppercase;
letter-spacing: 0.5px;
position: relative;
animation: nextSuggestionPulse 3s ease-in-out infinite;
}
/* Pulse animation for Next suggestions */
@keyframes nextSuggestionPulse {
0%, 100% {
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
0 2px 4px rgba(0, 0, 0, 0.1) inset,
0 0 20px rgba(102, 126, 234, 0.3);
}
50% {
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.25) inset,
0 2px 4px rgba(0, 0, 0, 0.1) inset,
0 0 30px rgba(102, 126, 234, 0.5);
}
}
/* Next suggestion hover - enhanced */
.copilotkit-suggestions button[data-is-next="true"]:hover,
.copilot-suggestions button[data-is-next="true"]:hover,
.copilotkit-suggestions button.next-suggestion:hover,
.copilot-suggestions button.next-suggestion:hover,
.copilotkit-suggestions button[aria-label*="Next:"]:hover,
.copilot-suggestions button[aria-label*="Next:"]:hover {
transform: translateY(-3px) scale(1.05) !important;
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.3) inset,
0 3px 6px rgba(0, 0, 0, 0.15) inset,
0 0 40px rgba(102, 126, 234, 0.6) !important;
background: linear-gradient(135deg, #667eea 0%, #764ba2 40%, #f093fb 60%, #4facfe 100%) !important;
animation: none;
}
/* Next suggestion active */
.copilotkit-suggestions button[data-is-next="true"]:active,
.copilot-suggestions button[data-is-next="true"]:active,
.copilotkit-suggestions button.next-suggestion:active,
.copilot-suggestions button.next-suggestion:active,
.copilotkit-suggestions button[aria-label*="Next:"]:active,
.copilot-suggestions button[aria-label*="Next:"]:active {
transform: translateY(-1px) scale(1.02) !important;
box-shadow: 0 3px 12px rgba(102, 126, 234, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.25) inset,
0 1px 3px rgba(0, 0, 0, 0.1) inset !important;
}
/* Next suggestion focus */
.copilotkit-suggestions button[data-is-next="true"]:focus-visible,
.copilot-suggestions button[data-is-next="true"]:focus-visible,
.copilotkit-suggestions button.next-suggestion:focus-visible,
.copilot-suggestions button.next-suggestion:focus-visible,
.copilotkit-suggestions button[aria-label*="Next:"]:focus-visible,
.copilot-suggestions button[aria-label*="Next:"]:focus-visible {
outline: none !important;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.4),
0 4px 16px rgba(102, 126, 234, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
0 0 30px rgba(102, 126, 234, 0.5) !important;
}
/* Match buttons by text content using data attributes or class */
/* We'll inject a data attribute via JS to identify Next suggestions */
/* Regular suggestion active state */
.copilotkit-suggestions button:active:not([data-is-next="true"]):not(.next-suggestion),
.copilot-suggestions button:active:not([data-is-next="true"]):not(.next-suggestion) {
transform: translateY(0) scale(0.98);
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.15);
}
/* Focus states for regular suggestions */
.copilotkit-suggestions button:focus-visible:not([data-is-next="true"]):not(.next-suggestion),
.copilot-suggestions button:focus-visible:not([data-is-next="true"]):not(.next-suggestion) {
outline: none;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3), 0 4px 12px rgba(99, 102, 241, 0.2);
}
/* Enhanced suggestion container */
.copilotkit-suggestions,
.copilot-suggestions {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
margin: 16px 0;
padding: 12px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4) 0%, rgba(249, 250, 251, 0.6) 100%);
border-radius: 12px;
backdrop-filter: blur(8px);
}
@media (min-width: 420px) {
.copilotkit-suggestions,
.copilot-suggestions {
grid-template-columns: 1fr 1fr;
gap: 12px;
}
}
/* Smooth transitions */
* {
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
`}</style>
{/* Inject data attributes to identify Next suggestions */}
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
const observer = new MutationObserver(() => {
const suggestionButtons = document.querySelectorAll(
'.copilotkit-suggestions button, .copilot-suggestions button, [class*="suggestion"] button'
);
suggestionButtons.forEach(btn => {
const text = btn.textContent || btn.innerText || '';
if (text.includes('Next:')) {
btn.setAttribute('data-is-next', 'true');
btn.classList.add('next-suggestion');
} else {
btn.removeAttribute('data-is-next');
btn.classList.remove('next-suggestion');
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// Initial run
setTimeout(() => {
const suggestionButtons = document.querySelectorAll(
'.copilotkit-suggestions button, .copilot-suggestions button, [class*="suggestion"] button'
);
suggestionButtons.forEach(btn => {
const text = btn.textContent || btn.innerText || '';
if (text.includes('Next:')) {
btn.setAttribute('data-is-next', 'true');
btn.classList.add('next-suggestion');
}
});
}, 100);
})();
`
}}
/>
<CopilotSidebar
labels={{
title: 'ALwrity Co-Pilot',
initial: !research
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.',
}}
suggestions={suggestions}
makeSystemMessage={(context: string, additional?: string) => {
const hasResearch = research !== null && research !== undefined;
const hasOutline = outline && outline.length > 0;
@@ -132,6 +360,7 @@ Available tools:
return [toolGuide, additional].filter(Boolean).join('\n\n');
}}
/>
</>
);
};

View File

@@ -14,6 +14,7 @@ interface UseBlogWriterCopilotActionsParams {
sections: Record<string, string>;
research: any;
openSEOMetadata: OpenMetadataCb;
navigateToPhase?: (phase: string) => void;
}
// Consolidates all Copilot actions used by BlogWriter
@@ -25,6 +26,7 @@ export function useBlogWriterCopilotActions({
sections,
research,
openSEOMetadata,
navigateToPhase,
}: UseBlogWriterCopilotActionsParams) {
// Maintain the same any-cast pattern for parity with component
const useCopilotActionTyped = useCopilotAction as any;
@@ -35,6 +37,8 @@ export function useBlogWriterCopilotActions({
description: 'Confirm that the blog content is ready and move to the next stage (SEO analysis)',
parameters: [],
handler: async () => {
// Navigate to SEO phase when content is confirmed
navigateToPhase?.('seo');
const msg = await confirmBlogContent();
return msg;
},
@@ -46,6 +50,9 @@ export function useBlogWriterCopilotActions({
description: 'Analyze the blog content for SEO optimization and provide detailed recommendations',
parameters: [],
handler: async () => {
// Navigate to SEO phase when SEO analysis starts
navigateToPhase?.('seo');
debug.log('[BlogWriter] SEO analysis action', {
modalOpen: isSEOAnalysisModalOpen,
hasSections: !!sections && Object.keys(sections).length > 0,
@@ -73,6 +80,9 @@ export function useBlogWriterCopilotActions({
},
],
handler: async ({ title }: { title?: string }) => {
// Navigate to SEO phase when SEO metadata generation starts
navigateToPhase?.('seo');
if (!sections || Object.keys(sections).length === 0) {
return 'Please generate blog content first before creating SEO metadata. Use the content generation features to create your blog post.';
}

View File

@@ -13,27 +13,15 @@ interface KeywordInputFormProps {
}
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete, onTaskStart }) => {
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
// This component now only provides polling functionality
// The keyword input form is handled by ResearchAction component
return (
<>
{/* Polling handler for research progress */}
<ResearchPollingHandler
taskId={currentTaskId}
onResearchComplete={(result) => {
onResearchComplete?.(result);
setCurrentTaskId(null);
}}
onError={(error) => {
console.error('Research error:', error);
setCurrentTaskId(null);
}}
/>
</>
);
// This component is now a lightweight wrapper
// The actual keyword input form is handled by ResearchAction component
// Polling is handled by ResearchPollingHandler in ResearchAction
// This component exists for backward compatibility but doesn't create unnecessary polling hooks
// Note: If onTaskStart is called, it should use the researchPolling from parent
// (passed via CopilotKitComponents), not create a new polling instance here
return null; // No UI needed - ResearchAction handles everything
};
export default KeywordInputForm;

View File

@@ -50,6 +50,7 @@ interface OutlineFeedbackFormProps {
sections?: Record<string, string>;
blogTitle?: string;
onFlowAnalysisComplete?: (analysis: any) => void;
navigateToPhase?: (phase: string) => void;
}
@@ -225,7 +226,8 @@ const FeedbackForm: React.FC<{
export const OutlineFeedbackForm: React.FC<OutlineFeedbackFormProps> = ({
outline,
research,
research,
navigateToPhase,
onOutlineConfirmed,
onOutlineRefined,
onMediumGenerationStarted,
@@ -352,6 +354,9 @@ export const OutlineFeedbackForm: React.FC<OutlineFeedbackFormProps> = ({
}
try {
// Navigate to content phase when outline is confirmed
navigateToPhase?.('content');
onOutlineConfirmed();
// If research specifies a short/medium blog (<=1000), kick off medium generation

View File

@@ -8,6 +8,8 @@ interface OutlineGeneratorProps {
onTaskStart: (taskId: string) => void;
onPollingStart: (taskId: string) => void;
onModalShow?: () => void; // Callback to show progress modal immediately
navigateToPhase?: (phase: string) => void;
onOutlineCreated?: (outline: any[], titleOptions?: any[]) => void; // Callback when outline is created/found (for cached outlines)
}
const useCopilotActionTyped = useCopilotAction as any;
@@ -16,7 +18,9 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
research,
onTaskStart,
onPollingStart,
onModalShow
onModalShow,
navigateToPhase,
onOutlineCreated
}, ref) => {
// Expose an imperative method to trigger outline generation directly (bypass LLM)
useImperativeHandle(ref, () => ({
@@ -67,6 +71,15 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
if (cachedOutline) {
console.log('[OutlineGenerator] Using cached outline from CopilotKit action', { sections: cachedOutline.outline.length });
// Navigate to outline phase when cached outline is found
navigateToPhase?.('outline');
// Update parent state with cached outline (same as header button does)
if (onOutlineCreated) {
onOutlineCreated(cachedOutline.outline, cachedOutline.title_options);
}
return {
success: true,
message: `✅ Outline already exists! ${cachedOutline.outline.length} sections loaded from cache.`,
@@ -77,6 +90,9 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
}
try {
// Navigate to outline phase when outline generation starts
navigateToPhase?.('outline');
// Show progress modal immediately when user clicks "Create outline"
onModalShow?.();

View File

@@ -51,9 +51,16 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
seoRecommendationsApplied = false,
hasSEOMetadata = false,
}) => {
// Phase Navigation: Default interface for blog writing workflow
// - Phase buttons are always clickable and functional (for both CopilotKit and manual flows)
// - Action buttons (▶) only appear when CopilotKit is unavailable (manual fallback)
// - When CopilotKit is available, users can use either phase buttons or CopilotKit suggestions
// Determine which action to show for each phase when CopilotKit is unavailable
const getActionForPhase = (phaseId: string): { label: string; handler: (() => void) | null } => {
if (copilotKitAvailable || !actionHandlers) {
// Show action buttons for both CopilotKit and manual flows (dual mode)
// Users can use either CopilotKit suggestions or phase navigation buttons
if (!actionHandlers) {
return { label: '', handler: null };
}
@@ -104,159 +111,317 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
};
return (
<div style={{
display: 'flex',
gap: '8px',
alignItems: 'center',
padding: '8px 0',
flexWrap: 'wrap'
}}>
{phases.map((phase) => {
const isCurrent = phase.current;
const isCompleted = phase.completed;
const isDisabled = phase.disabled;
const action = getActionForPhase(phase.id);
// Show action button when:
// 1. CopilotKit is unavailable
// 2. Action handler exists
// 3. Phase is not disabled
// 4. Show for current phase OR next actionable phase (not completed) OR phases with available actions
// For research phase: always show if no research exists
// For outline phase: always show if research exists but no outline (like research phase)
// For SEO phase: always show if action handler exists (prerequisites are met)
const isResearchPhase = phase.id === 'research' && !hasResearch;
// Outline phase: show action whenever research exists and action handler is available
// This allows users to create/regenerate outline after research, even if cached one exists
const isOutlinePhase = phase.id === 'outline' && hasResearch && action.handler;
// SEO phase: show action whenever prerequisites are met (action handler exists)
// Similar to research/outline, show SEO actions whenever handler exists and phase is enabled
const isSEOPhase = phase.id === 'seo' && action.handler;
// Debug logging for SEO phase (temporary - for troubleshooting)
if (phase.id === 'seo' && !copilotKitAvailable && process.env.NODE_ENV === 'development') {
console.log('[PhaseNavigation] SEO phase debug:', {
phaseId: phase.id,
isCurrent,
isCompleted,
isDisabled,
hasContent,
contentConfirmed,
hasSEOAnalysis,
seoRecommendationsApplied,
hasSEOMetadata,
actionLabel: action.label,
actionHandler: !!action.handler,
copilotKitAvailable,
isSEOPhase,
showActionWillBe: !copilotKitAvailable && action.handler && !isDisabled && (
isCurrent ||
(!isCompleted && !isDisabled) ||
isResearchPhase ||
isOutlinePhase ||
isSEOPhase
)
});
<>
<style>{`
/* Enterprise Phase Navigation Styles */
.phase-nav-container {
display: flex;
gap: 10px;
alignItems: center;
padding: 12px 0;
flexWrap: wrap;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 250, 251, 0.98) 100%);
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
// Show action if: current phase, or phase is not completed and not disabled, or it's research/outline/SEO with available action
// For SEO: show whenever action handler exists (prerequisites are met), even if phase is marked as disabled/completed
// This is critical because SEO prerequisites (hasContent && contentConfirmed) are validated in getActionForPhase,
// so if action.handler exists, we should show it regardless of phase navigation's disabled state
const showAction = !copilotKitAvailable && action.handler && (
isCurrent ||
(!isCompleted && !isDisabled) ||
isResearchPhase ||
isOutlinePhase ||
isSEOPhase // Show SEO actions when handler exists - handler existence means prerequisites are met, so ignore isDisabled
);
.phase-chip {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
border-radius: 24px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
letter-spacing: 0.01em;
}
return (
<div key={phase.id} style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<button
onClick={() => !isDisabled && onPhaseClick(phase.id)}
disabled={isDisabled}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
padding: '8px 12px',
borderRadius: '20px',
border: 'none',
fontSize: '14px',
fontWeight: '500',
cursor: isDisabled ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
backgroundColor: isCurrent
? '#1976d2'
: isCompleted
? '#4caf50'
: isDisabled
? '#f5f5f5'
: '#e3f2fd',
color: isCurrent
? 'white'
: isCompleted
? 'white'
: isDisabled
? '#999'
: '#1976d2',
opacity: isDisabled ? 0.6 : 1,
boxShadow: isCurrent ? '0 2px 4px rgba(25, 118, 210, 0.3)' : 'none',
transform: isCurrent ? 'translateY(-1px)' : 'none'
}}
title={phase.disabled ? `Complete ${phase.name} first` : phase.description}
>
<span style={{ fontSize: '16px' }}>
{phase.icon}
</span>
<span>{phase.name}</span>
{isCompleted && !isCurrent && (
<span style={{ fontSize: '12px', marginLeft: '4px' }}>
</span>
)}
</button>
{showAction && (
.phase-chip::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
transition: left 0.5s ease;
}
.phase-chip:hover::before {
left: 100%;
}
/* Current Phase - Active Gradient */
.phase-chip.current {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
transform: translateY(-2px) scale(1.02);
}
.phase-chip.current:hover {
transform: translateY(-3px) scale(1.03);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.2) inset;
}
.phase-chip.current:active {
transform: translateY(-1px) scale(1.01);
box-shadow: 0 3px 12px rgba(102, 126, 234, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.15) inset;
}
/* Completed Phase - Success Gradient */
.phase-chip.completed {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
box-shadow: 0 3px 12px rgba(16, 185, 129, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
}
.phase-chip.completed:hover {
transform: translateY(-2px) scale(1.02);
box-shadow: 0 5px 16px rgba(16, 185, 129, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.15) inset;
}
/* Pending Phase - Subtle Gradient */
.phase-chip.pending {
background: linear-gradient(135deg, #e0e7ff 0%, #dbeafe 100%);
color: #4b5563;
border: 1px solid rgba(99, 102, 241, 0.2);
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.1);
}
.phase-chip.pending:hover {
background: linear-gradient(135deg, #c7d2fe 0%, #bfdbfe 100%);
transform: translateY(-2px) scale(1.02);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
border-color: rgba(99, 102, 241, 0.3);
}
/* Disabled Phase */
.phase-chip.disabled {
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
color: #9ca3af;
cursor: not-allowed;
opacity: 0.6;
box-shadow: none;
border: 1px solid #e5e7eb;
}
.phase-chip.disabled:hover {
transform: none;
box-shadow: none;
}
/* Phase Icon */
.phase-icon {
font-size: 18px;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
transition: transform 0.3s ease;
}
.phase-chip.current .phase-icon,
.phase-chip.completed .phase-icon {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
.phase-chip:hover:not(.disabled) .phase-icon {
transform: scale(1.1) rotate(5deg);
}
/* Checkmark for completed */
.phase-checkmark {
font-size: 14px;
margin-left: 4px;
animation: checkmarkPop 0.3s ease;
}
@keyframes checkmarkPop {
0% { transform: scale(0); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
/* Action Button - Enterprise Style */
.phase-action-btn {
position: relative;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 20px;
border: none;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
text-transform: uppercase;
letter-spacing: 0.5px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.35),
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
0 1px 2px rgba(0, 0, 0, 0.1) inset;
}
.phase-action-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent);
transition: left 0.5s ease;
}
.phase-action-btn:hover::before {
left: 100%;
}
.phase-action-btn:hover {
transform: translateY(-2px) scale(1.05);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
0 2px 4px rgba(0, 0, 0, 0.15) inset;
}
.phase-action-btn:active {
transform: translateY(0) scale(1.02);
box-shadow: 0 3px 10px rgba(102, 126, 234, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.15) inset;
}
.phase-action-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.3),
0 4px 12px rgba(102, 126, 234, 0.35),
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
}
.phase-action-icon {
font-size: 12px;
transition: transform 0.3s ease;
}
.phase-action-btn:hover .phase-action-icon {
transform: translateX(2px);
}
`}</style>
<div className="phase-nav-container">
{phases.map((phase) => {
const isCurrent = phase.current;
const isCompleted = phase.completed;
const isDisabled = phase.disabled;
const action = getActionForPhase(phase.id);
// Show action button when:
// 1. CopilotKit is unavailable
// 2. Action handler exists
// 3. Phase is not disabled
// 4. Show for current phase OR next actionable phase (not completed) OR phases with available actions
// For research phase: always show if no research exists
// For outline phase: always show if research exists but no outline (like research phase)
// For SEO phase: always show if action handler exists (prerequisites are met)
const isResearchPhase = phase.id === 'research' && !hasResearch;
// Outline phase: show action whenever research exists and action handler is available
// This allows users to create/regenerate outline after research, even if cached one exists
const isOutlinePhase = phase.id === 'outline' && hasResearch && action.handler;
// SEO phase: show action whenever prerequisites are met (action handler exists)
// Similar to research/outline, show SEO actions whenever handler exists and phase is enabled
const isSEOPhase = phase.id === 'seo' && action.handler;
// Debug logging for SEO phase (temporary - for troubleshooting)
if (phase.id === 'seo' && !copilotKitAvailable && process.env.NODE_ENV === 'development') {
console.log('[PhaseNavigation] SEO phase debug:', {
phaseId: phase.id,
isCurrent,
isCompleted,
isDisabled,
hasContent,
contentConfirmed,
hasSEOAnalysis,
seoRecommendationsApplied,
hasSEOMetadata,
actionLabel: action.label,
actionHandler: !!action.handler,
copilotKitAvailable,
isSEOPhase,
showActionWillBe: !copilotKitAvailable && action.handler && !isDisabled && (
isCurrent ||
(!isCompleted && !isDisabled) ||
isResearchPhase ||
isOutlinePhase ||
isSEOPhase
)
});
}
// Show action if: current phase, or phase is not completed and not disabled, or it's research/outline/SEO with available action
// For SEO: show whenever action handler exists (prerequisites are met), even if phase is marked as disabled/completed
// This is critical because SEO prerequisites (hasContent && contentConfirmed) are validated in getActionForPhase,
// so if action.handler exists, we should show it regardless of phase navigation's disabled state
// DUAL MODE: Show action buttons even when CopilotKit is available (users can use either method)
const showAction = action.handler && (
isCurrent ||
(!isCompleted && !isDisabled) ||
isResearchPhase ||
isOutlinePhase ||
isSEOPhase // Show SEO actions when handler exists - handler existence means prerequisites are met, so ignore isDisabled
);
// Determine chip class
const chipClass = [
'phase-chip',
isCurrent ? 'current' : '',
isCompleted && !isCurrent ? 'completed' : '',
!isCurrent && !isCompleted && !isDisabled ? 'pending' : '',
isDisabled ? 'disabled' : ''
].filter(Boolean).join(' ');
return (
<div key={phase.id} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={(e) => {
e.stopPropagation();
action.handler?.();
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '4px',
padding: '6px 12px',
borderRadius: '16px',
border: '1px solid #1976d2',
fontSize: '12px',
fontWeight: '600',
cursor: 'pointer',
backgroundColor: '#1976d2',
color: 'white',
transition: 'all 0.2s ease',
boxShadow: '0 2px 4px rgba(25, 118, 210, 0.2)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#1565c0';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#1976d2';
e.currentTarget.style.transform = 'none';
}}
title={`${action.label} (Chat unavailable - click to proceed)`}
onClick={() => !isDisabled && onPhaseClick(phase.id)}
disabled={isDisabled}
className={chipClass}
title={phase.disabled ? `Complete ${phase.name} first` : phase.description}
>
<span style={{ fontSize: '12px' }}></span>
<span>{action.label}</span>
<span className="phase-icon">
{phase.icon}
</span>
<span>{phase.name}</span>
{isCompleted && !isCurrent && (
<span className="phase-checkmark">
</span>
)}
</button>
)}
</div>
);
})}
</div>
{showAction && (
<button
onClick={(e) => {
e.stopPropagation();
action.handler?.();
}}
className="phase-action-btn"
title={`${action.label}`}
>
<span className="phase-action-icon"></span>
<span>{action.label}</span>
</button>
)}
</div>
);
})}
</div>
</>
);
};

View File

@@ -62,8 +62,12 @@ export const Publisher: React.FC<PublisherProps> = ({
try {
const status = await wordpressAPI.getStatus();
setWordpressSites(status.sites || []);
} catch (error) {
console.error('Failed to check WordPress connection status:', error);
} catch (error: any) {
// getStatus now handles 404 gracefully, so we should rarely hit this
// Only log non-404 errors
if (error?.response?.status !== 404) {
console.error('Failed to check WordPress connection status:', error);
}
setWordpressSites([]);
} finally {
setCheckingWordPressStatus(false);

View File

@@ -9,9 +9,10 @@ const useCopilotActionTyped = useCopilotAction as any;
interface ResearchActionProps {
onResearchComplete?: (research: BlogResearchResponse) => void;
navigateToPhase?: (phase: string) => void;
}
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete }) => {
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete, navigateToPhase }) => {
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
const [currentMessage, setCurrentMessage] = useState<string>('');
const [showProgressModal, setShowProgressModal] = useState<boolean>(false);
@@ -20,28 +21,36 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
// Refs for form inputs (uncontrolled, avoids typing issues inside Copilot render)
const keywordsRef = useRef<HTMLInputElement | null>(null);
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
// Track if we've navigated to research phase for this form display
const hasNavigatedRef = useRef<boolean>(false);
const polling = useResearchPolling({
onProgress: (message) => {
setCurrentMessage(message);
setForceUpdate(prev => prev + 1); // Force re-render
},
onComplete: (result) => {
if (result && result.keywords) {
researchCache.cacheResult(
result.keywords,
result.industry || 'General',
result.target_audience || 'General',
result
);
}
onResearchComplete?.(result);
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
setForceUpdate(prev => prev + 1);
},
onComplete: (result) => {
console.info('[ResearchAction] ✅ Research completed', { hasResult: !!result });
if (result && result.keywords) {
researchCache.cacheResult(
result.keywords,
result.industry || 'General',
result.target_audience || 'General',
result
);
}
// Reset navigation tracking when research completes
hasNavigatedRef.current = false;
onResearchComplete?.(result);
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
setForceUpdate(prev => prev + 1);
},
onError: (error) => {
console.error('Research polling error:', error);
setCurrentTaskId(null);
@@ -55,14 +64,40 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
name: 'showResearchForm',
description: 'Show keyword input form for blog research',
parameters: [],
handler: async () => ({
success: true,
message: "🔍 Let's Research Your Blog Topic\n\nWhat keywords and information would you like to use for your research? Please also specify the desired length of the blog post.\n\nKeywords or Topic *\ne.g., artificial intelligence, machine learning, AI trends\n\nBlog Length (words)\n\n1000 words (Medium blog)\n\n🚀 Start Research",
showForm: true
}),
handler: async () => {
// Navigate to research phase when research form is shown
// Reset navigation tracking so form render can navigate again if needed
hasNavigatedRef.current = false;
// Navigate immediately when handler is called
if (navigateToPhase) {
navigateToPhase('research');
}
return {
success: true,
message: "🔍 Let's Research Your Blog Topic\n\nWhat keywords and information would you like to use for your research? Please also specify the desired length of the blog post.\n\nKeywords or Topic *\ne.g., artificial intelligence, machine learning, AI trends\n\nBlog Length (words)\n\n1000 words (Medium blog)\n\n🚀 Start Research",
showForm: true
};
},
render: ({ status }: any) => {
const _ = forceUpdate;
// Navigate to research phase when form is rendered (if not already navigated and form is shown)
// This ensures phase navigation updates when CopilotKit shows the research form
// Only navigate when showing the form (not progress or completion states)
const isShowingForm = polling.currentStatus !== 'completed' &&
polling.currentStatus !== 'in_progress' &&
polling.currentStatus !== 'running';
if (isShowingForm && !hasNavigatedRef.current && navigateToPhase) {
// Use setTimeout to avoid calling during render
setTimeout(() => {
if (!hasNavigatedRef.current) {
navigateToPhase('research');
hasNavigatedRef.current = true;
}
}, 0);
}
if (polling.currentStatus === 'completed' && polling.progressMessages.length > 0) {
const latestMessage = polling.progressMessages[polling.progressMessages.length - 1];
return (
@@ -135,6 +170,8 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
target_audience: 'General',
word_count_target: parseInt(blogLength)
};
// Navigate to research phase when research starts
navigateToPhase?.('research');
const { task_id } = await blogWriterApi.startResearch(payload);
setCurrentTaskId(task_id);
setShowProgressModal(true);
@@ -173,6 +210,8 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
const keywordList = trimmed.includes(',')
? trimmed.split(',').map((k: string) => k.trim()).filter(Boolean)
: [trimmed];
// Navigate to research phase when research starts
navigateToPhase?.('research');
const payload: BlogResearchRequest = {
keywords: keywordList,
industry,
@@ -191,6 +230,7 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
}
});
return (
<>
{showProgressModal && (

View File

@@ -8,12 +8,14 @@ interface ResearchDataActionsProps {
research: BlogResearchResponse | null;
onOutlineCreated: (outline: BlogOutlineSection[]) => void;
onTitleOptionsSet: (titles: string[]) => void;
navigateToPhase?: (phase: string) => void;
}
export const ResearchDataActions: React.FC<ResearchDataActionsProps> = ({
research,
onOutlineCreated,
onTitleOptionsSet
onTitleOptionsSet,
navigateToPhase
}) => {
// Chat with Research Data
useCopilotActionTyped({
@@ -110,6 +112,9 @@ export const ResearchDataActions: React.FC<ResearchDataActionsProps> = ({
}
try {
// Navigate to outline phase when outline generation starts
navigateToPhase?.('outline');
// Create a custom outline request with user instructions
const customOutlineRequest = {
research: research,

View File

@@ -51,16 +51,13 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
if (taskId) {
polling.startPolling(taskId);
} else {
polling.stopPolling();
// Only stop if actually polling (not on every render when taskId is null)
if (polling.isPolling) {
polling.stopPolling();
}
}
}, [taskId, polling]);
// Cleanup on unmount
useEffect(() => {
return () => {
polling.stopPolling();
};
}, [polling]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId]); // Removed polling from dependencies - usePolling already handles cleanup
// Only log on meaningful changes
useEffect(() => {

View File

@@ -183,7 +183,12 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
const [isApplying, setIsApplying] = useState(false);
const [applyError, setApplyError] = useState<string | null>(null);
console.log('SEOAnalysisModal render:', { isOpen, blogContent: blogContent?.length, researchData: !!researchData });
// Debug logging only in development and when modal state changes meaningfully
useEffect(() => {
if (process.env.NODE_ENV === 'development' && isOpen) {
console.log('SEOAnalysisModal render:', { isOpen, blogContent: blogContent?.length, researchData: !!researchData });
}
}, [isOpen, blogContent?.length, researchData]);
const runSEOAnalysis = useCallback(async (forceRefresh = false) => {
try {
@@ -318,7 +323,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
status,
data: err?.response?.data
});
const handled = triggerSubscriptionError(err);
const handled = await triggerSubscriptionError(err);
if (handled) {
console.log('SEOAnalysisModal: Global subscription error handler triggered successfully');
// Don't set local error - let the global modal handle it

View File

@@ -125,15 +125,17 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
return unsub;
}, [metadataResult]);
// Debug logging
// Debug logging only in development and when modal state changes meaningfully
useEffect(() => {
console.log('🔍 SEOMetadataModal render:', {
isOpen,
blogContent: blogContent?.length,
blogTitle,
researchData: !!researchData
});
}, [isOpen, blogContent, blogTitle, researchData]);
if (process.env.NODE_ENV === 'development' && isOpen) {
console.log('🔍 SEOMetadataModal render:', {
isOpen,
blogContent: blogContent?.length,
blogTitle,
researchData: !!researchData
});
}
}, [isOpen, blogContent?.length, blogTitle, researchData]);
// Reset state when modal closes
useEffect(() => {
@@ -229,7 +231,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
status,
data: err?.response?.data
});
const handled = triggerSubscriptionError(err);
const handled = await triggerSubscriptionError(err);
if (handled) {
console.log('SEOMetadataModal: Global subscription error handler triggered successfully');
// Don't set local error - let the global modal handle it

View File

@@ -8,6 +8,7 @@ interface SectionGeneratorProps {
genMode: 'draft' | 'polished';
onSectionGenerated: (sectionId: string, markdown: string) => void;
onContinuityRefresh: () => void;
navigateToPhase?: (phase: string) => void;
}
const useCopilotActionTyped = useCopilotAction as any;
@@ -17,7 +18,8 @@ export const SectionGenerator: React.FC<SectionGeneratorProps> = ({
research,
genMode,
onSectionGenerated,
onContinuityRefresh
onContinuityRefresh,
navigateToPhase
}) => {
useCopilotActionTyped({
name: 'generateSection',
@@ -27,6 +29,9 @@ export const SectionGenerator: React.FC<SectionGeneratorProps> = ({
const section = outline.find(s => s.id === sectionId);
if (!section) return { success: false, message: 'Section not found. Please generate an outline first.' };
// Navigate to content phase when content generation starts
navigateToPhase?.('content');
try {
const res = await blogWriterApi.generateSection({ section, mode: genMode });
if (res?.markdown) {
@@ -98,6 +103,9 @@ export const SectionGenerator: React.FC<SectionGeneratorProps> = ({
description: 'Generate content for every section in the outline',
parameters: [],
handler: async () => {
// Navigate to content phase when content generation starts
navigateToPhase?.('content');
for (const s of outline) {
const res = await blogWriterApi.generateSection({ section: s, mode: genMode });
onSectionGenerated(s.id, res.markdown);

View File

@@ -0,0 +1,185 @@
/**
* Facebook Persona Generation Modal
*
* Prompts user to generate Facebook persona if it doesn't exist.
* Similar to ResearchPersonaModal but for Facebook-specific persona.
*/
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
Alert,
CircularProgress,
List,
ListItem,
ListItemIcon,
ListItemText
} from '@mui/material';
import {
Facebook as FacebookIcon,
AutoAwesome as AutoAwesomeIcon,
TrendingUp as TrendingUpIcon,
Group as GroupIcon,
CheckCircle as CheckCircleIcon,
Close as CloseIcon
} from '@mui/icons-material';
interface FacebookPersonaModalProps {
open: boolean;
onClose: () => void;
onGenerate: () => Promise<void>;
onCancel: () => void;
}
export const FacebookPersonaModal: React.FC<FacebookPersonaModalProps> = ({
open,
onClose,
onGenerate,
onCancel
}) => {
const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleGenerate = async () => {
setGenerating(true);
setError(null);
try {
await onGenerate();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate Facebook persona');
} finally {
setGenerating(false);
}
};
const handleCancel = () => {
onCancel();
onClose();
};
return (
<Dialog
open={open}
onClose={!generating ? onClose : undefined}
maxWidth="md"
fullWidth
disableEscapeKeyDown={generating}
PaperProps={{
sx: {
borderRadius: 3,
background: 'linear-gradient(135deg, #fff 0%, #f8fafc 100%)',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
}
}}
>
<DialogTitle sx={{ textAlign: 'center', pb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2 }}>
<FacebookIcon sx={{ fontSize: 32, color: '#1877F2' }} />
<Typography variant="h5" sx={{ fontWeight: 600 }}>
Generate Facebook Persona
</Typography>
</Box>
</DialogTitle>
<DialogContent sx={{ px: 4, py: 2 }}>
<Typography variant="body1" sx={{ mb: 3, textAlign: 'center', color: 'text.secondary' }}>
Enhance your Facebook content with AI-powered personalization based on your brand voice and Facebook's algorithm.
</Typography>
<Alert severity="info" sx={{ mb: 3 }}>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Why generate a Facebook persona?
</Typography>
<Typography variant="caption">
Your Facebook persona learns from your onboarding data to provide personalized content that matches
your brand voice and optimizes for Facebook's engagement algorithm.
</Typography>
</Alert>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5 }}>
Benefits:
</Typography>
<List dense sx={{ py: 0 }}>
<ListItem sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
<AutoAwesomeIcon fontSize="small" color="primary" />
</ListItemIcon>
<ListItemText
primary="Algorithm Optimization"
secondary="Content optimized for Facebook's engagement algorithm and reach"
/>
</ListItem>
<ListItem sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
<TrendingUpIcon fontSize="small" color="primary" />
</ListItemIcon>
<ListItemText
primary="Platform-Specific Strategies"
secondary="Facebook-specific engagement, timing, and community building strategies"
/>
</ListItem>
<ListItem sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
<GroupIcon fontSize="small" color="primary" />
</ListItemIcon>
<ListItemText
primary="Community Building"
secondary="Strategies for building and engaging your Facebook community"
/>
</ListItem>
<ListItem sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
<CheckCircleIcon fontSize="small" color="primary" />
</ListItemIcon>
<ListItemText
primary="Brand Voice Alignment"
secondary="Content that matches your brand voice and Facebook's best practices"
/>
</ListItem>
</List>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Typography variant="caption" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
Note: This process takes about 30-60 seconds and uses your AI provider.
You can continue using generic persona if you skip this step.
</Typography>
</DialogContent>
<DialogActions sx={{ px: 4, pb: 3, justifyContent: 'space-between' }}>
<Button
onClick={handleCancel}
disabled={generating}
startIcon={<CloseIcon />}
color="inherit"
>
Skip for Now
</Button>
<Button
onClick={handleGenerate}
disabled={generating}
variant="contained"
startIcon={generating ? <CircularProgress size={16} /> : <FacebookIcon />}
sx={{ minWidth: 150, bgcolor: '#1877F2', '&:hover': { bgcolor: '#1565C0' } }}
>
{generating ? 'Generating...' : 'Generate Persona'}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -8,7 +8,8 @@ import RegisterFacebookActions from './RegisterFacebookActions';
import RegisterFacebookEditActions from './RegisterFacebookEditActions';
import RegisterFacebookActionsEnhanced from './RegisterFacebookActionsEnhanced';
import { PlatformPersonaProvider, usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider';
import { generatePlatformPersona } from '../../api/persona';
import { generatePlatformPersona, checkFacebookPersona } from '../../api/persona';
import { FacebookPersonaModal } from './FacebookPersonaModal';
const useCopilotActionTyped = useCopilotAction as any;
@@ -168,6 +169,36 @@ const FacebookWriterContent: React.FC<FacebookWriterProps> = ({ className = '' }
// State for generating persona
const [isGeneratingPersona, setIsGeneratingPersona] = React.useState<boolean>(false);
const [personaError, setPersonaError] = React.useState<string | null>(null);
const [showPersonaModal, setShowPersonaModal] = React.useState<boolean>(false);
const [personaChecked, setPersonaChecked] = React.useState<boolean>(false);
// Check for Facebook persona on component mount
React.useEffect(() => {
const checkPersona = async () => {
if (personaChecked) return; // Already checked
try {
const userId = localStorage.getItem('user_id');
if (!userId) {
setPersonaChecked(true);
return;
}
const personaStatus = await checkFacebookPersona(userId);
// Show modal if onboarding completed but persona missing
if (personaStatus.onboarding_completed && !personaStatus.has_persona && personaStatus.has_core_persona) {
setShowPersonaModal(true);
}
} catch (error) {
console.error('Error checking Facebook persona:', error);
} finally {
setPersonaChecked(true);
}
};
checkPersona();
}, [personaChecked]);
// Handler to generate Facebook persona on-demand
const handleGeneratePersona = async () => {
@@ -192,6 +223,36 @@ const FacebookWriterContent: React.FC<FacebookWriterProps> = ({ className = '' }
}
};
// Handler for modal generation
const handleGenerateFacebookPersona = async () => {
setIsGeneratingPersona(true);
setPersonaError(null);
try {
const result = await generatePlatformPersona('facebook');
if (result.success) {
// Refresh the persona context to load the newly generated persona
await refreshPersonas();
console.log('✅ Facebook persona generated successfully');
setShowPersonaModal(false);
} else {
throw new Error('Failed to generate persona');
}
} catch (error: any) {
console.error('Error generating persona:', error);
throw error; // Let modal handle error display
} finally {
setIsGeneratingPersona(false);
}
};
// Handler for modal cancel
const handleCancelPersona = () => {
setShowPersonaModal(false);
// Continue with generic persona
};
React.useEffect(() => {
const onUpdate = (e: any) => {
setPostDraft(String(e.detail || ''));
@@ -790,6 +851,16 @@ Instead of generic content, you get:
)}
</Container>
</Box>
{/* Facebook Persona Modal */}
{showPersonaModal && (
<FacebookPersonaModal
open={showPersonaModal}
onClose={() => setShowPersonaModal(false)}
onGenerate={handleGenerateFacebookPersona}
onCancel={handleCancelPersona}
/>
)}
</CopilotSidebar>
);
};

View File

@@ -0,0 +1,342 @@
/**
* OAuth Token Status Panel
* Displays OAuth token monitoring status for all platforms and allows manual refresh
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
Chip,
CircularProgress,
Alert,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
IconButton,
Tooltip,
Collapse,
} from '@mui/material';
import {
RefreshCw,
CheckCircle,
XCircle,
AlertTriangle,
Info,
ChevronDown,
ChevronUp,
Clock,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '@clerk/clerk-react';
import {
getOAuthTokenStatus,
manualRefreshToken,
OAuthTokenStatusResponse,
ManualRefreshResponse,
} from '../../api/oauthTokenMonitoring';
interface OAuthTokenStatusPanelProps {
userId?: string;
compact?: boolean;
}
const OAuthTokenStatusPanel: React.FC<OAuthTokenStatusPanelProps> = ({
userId,
compact = false
}) => {
const { userId: clerkUserId } = useAuth();
const actualUserId = userId || clerkUserId || '';
const [status, setStatus] = useState<OAuthTokenStatusResponse | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [expandedPlatform, setExpandedPlatform] = useState<string | null>(null);
const fetchStatus = async () => {
if (!actualUserId) return;
try {
setLoading(true);
setError(null);
const response = await getOAuthTokenStatus(actualUserId);
setStatus(response);
} catch (err: any) {
setError(err.message || 'Failed to fetch token status');
console.error('Error fetching OAuth token status:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStatus();
// Poll for status updates every 2 minutes
const interval = setInterval(fetchStatus, 120000);
return () => clearInterval(interval);
}, [actualUserId]);
const handleRefresh = async (platform: string) => {
if (!actualUserId) return;
try {
setRefreshing(platform);
setError(null);
const response: ManualRefreshResponse = await manualRefreshToken(actualUserId, platform);
// Refresh status after manual refresh
await fetchStatus();
// Show success message
if (response.success) {
console.log(`Token refresh successful for ${platform}`);
} else {
console.error(`Token refresh failed for ${platform}:`, response.data.execution_result.error_message);
}
} catch (err: any) {
setError(err.message || `Failed to refresh ${platform} token`);
console.error(`Error refreshing ${platform} token:`, err);
} finally {
setRefreshing(null);
}
};
const getStatusIcon = (taskStatus: string | null, connected: boolean) => {
if (!connected) {
return <XCircle size={20} color="#ef4444" />;
}
if (!taskStatus || taskStatus === 'not_created') {
return <Info size={20} color="#3b82f6" />;
}
switch (taskStatus) {
case 'active':
return <CheckCircle size={20} color="#10b981" />;
case 'failed':
return <XCircle size={20} color="#ef4444" />;
case 'paused':
return <AlertTriangle size={20} color="#f59e0b" />;
default:
return <Info size={20} color="#6b7280" />;
}
};
const getStatusColor = (taskStatus: string | null, connected: boolean) => {
if (!connected) return 'error';
if (!taskStatus || taskStatus === 'not_created') return 'info';
if (taskStatus === 'active') return 'success';
if (taskStatus === 'failed') return 'error';
if (taskStatus === 'paused') return 'warning';
return 'default';
};
const formatDate = (dateString: string | null) => {
if (!dateString) return 'Never';
try {
const date = new Date(dateString);
return date.toLocaleString();
} catch {
return dateString;
}
};
const getPlatformDisplayName = (platform: string) => {
const names: { [key: string]: string } = {
gsc: 'Google Search Console',
bing: 'Bing Webmaster Tools',
wordpress: 'WordPress',
wix: 'Wix',
};
return names[platform] || platform.toUpperCase();
};
if (loading && !status) {
return (
<Box display="flex" justifyContent="center" alignItems="center" p={4}>
<CircularProgress />
</Box>
);
}
if (error && !status) {
return (
<Alert severity="error" sx={{ m: 2 }}>
{error}
<Button size="small" onClick={fetchStatus} sx={{ ml: 2 }}>
Retry
</Button>
</Alert>
);
}
if (!status) {
return null;
}
const platforms = ['gsc', 'bing', 'wordpress', 'wix'];
return (
<Card sx={{ mb: 2 }}>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">OAuth Token Status</Typography>
<Button
size="small"
startIcon={<RefreshCw size={16} />}
onClick={fetchStatus}
disabled={loading}
>
Refresh
</Button>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Platform</TableCell>
<TableCell>Status</TableCell>
<TableCell>Last Check</TableCell>
<TableCell>Next Check</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{platforms.map((platform) => {
const platformStatus = status.data.platform_status[platform];
const task = platformStatus?.monitoring_task;
return (
<React.Fragment key={platform}>
<TableRow>
<TableCell>
<Box display="flex" alignItems="center" gap={1}>
{getStatusIcon(task?.status || null, platformStatus?.connected || false)}
<Typography variant="body2" fontWeight="medium">
{getPlatformDisplayName(platform)}
</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={task?.status || (platformStatus?.connected ? 'Connected' : 'Not Connected')}
size="small"
color={getStatusColor(task?.status || null, platformStatus?.connected || false) as any}
/>
</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">
{formatDate(task?.last_check || null)}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">
{formatDate(task?.next_check || null)}
</Typography>
</TableCell>
<TableCell align="right">
<Box display="flex" gap={1} justifyContent="flex-end">
<Tooltip title="View details">
<IconButton
size="small"
onClick={() => setExpandedPlatform(
expandedPlatform === platform ? null : platform
)}
>
{expandedPlatform === platform ? (
<ChevronUp size={16} />
) : (
<ChevronDown size={16} />
)}
</IconButton>
</Tooltip>
{platformStatus?.connected && (
<Tooltip title="Manually refresh token">
<IconButton
size="small"
onClick={() => handleRefresh(platform)}
disabled={refreshing === platform}
>
{refreshing === platform ? (
<CircularProgress size={16} />
) : (
<RefreshCw size={16} />
)}
</IconButton>
</Tooltip>
)}
</Box>
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={5} sx={{ py: 0, border: 0 }}>
<Collapse in={expandedPlatform === platform}>
<Box p={2} bgcolor="grey.50">
{task?.failure_reason && (
<Alert severity="error" sx={{ mb: 1 }}>
<Typography variant="body2" fontWeight="bold">
Last Failure:
</Typography>
<Typography variant="body2">
{task.failure_reason}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(task.last_failure || null)}
</Typography>
</Alert>
)}
{task?.last_success && (
<Alert severity="success" sx={{ mb: 1 }}>
<Typography variant="body2">
Last successful check: {formatDate(task.last_success)}
</Typography>
</Alert>
)}
{!task && platformStatus?.connected && (
<Alert severity="info">
<Typography variant="body2">
Platform is connected but no monitoring task exists.
Monitoring tasks are created automatically after onboarding.
</Typography>
</Alert>
)}
{!platformStatus?.connected && (
<Alert severity="warning">
<Typography variant="body2">
Platform is not connected. Connect it in onboarding step 5.
</Typography>
</Alert>
)}
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
})}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
);
};
export default OAuthTokenStatusPanel;

View File

@@ -0,0 +1,298 @@
/**
* Research Persona Generation Modal
*
* Prompts user to generate research persona if it doesn't exist.
* Explains benefits and allows user to generate or skip.
*/
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
Alert,
CircularProgress,
List,
ListItem,
ListItemIcon,
ListItemText
} from '@mui/material';
import {
Psychology as PsychologyIcon,
AutoAwesome as AutoAwesomeIcon,
TrendingUp as TrendingUpIcon,
Search as SearchIcon,
CheckCircle as CheckCircleIcon,
Close as CloseIcon
} from '@mui/icons-material';
import { refreshResearchPersona } from '../../api/researchConfig';
import { triggerSubscriptionError } from '../../api/client';
interface ResearchPersonaModalProps {
open: boolean;
onClose: () => void;
onGenerate: () => Promise<void>;
onCancel: () => void;
}
export const ResearchPersonaModal: React.FC<ResearchPersonaModalProps> = ({
open,
onClose,
onGenerate,
onCancel
}) => {
const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Debug: Track modal open state
React.useEffect(() => {
console.log('[ResearchPersonaModal] Modal open state:', open);
if (open) {
console.log('[ResearchPersonaModal] ✅ Modal is now OPEN');
} else {
console.log('[ResearchPersonaModal] Modal is CLOSED');
}
}, [open]);
const handleGenerate = async () => {
setGenerating(true);
setError(null);
try {
await onGenerate();
// Close modal on success
onClose();
} catch (err: any) {
// Check if this is a subscription error (429/402)
// The apiClient interceptor should have already handled it via the global handler
// We just need to check if the global handler suppressed it (subscription is active)
const status = err?.response?.status;
if (status === 429 || status === 402) {
console.log('[ResearchPersonaModal] Detected subscription error', {
status,
data: err?.response?.data
});
// The global handler in apiClient interceptor should have already processed this
// If subscription is active, the global handler suppresses the modal
// If subscription is inactive, the global handler shows the modal
// We just need to avoid showing a duplicate error message
// Wait a moment to see if the global handler shows the modal
await new Promise(resolve => setTimeout(resolve, 100));
// If the global handler showed the modal, it will handle it
// We just stop here and don't show a local error
setGenerating(false);
return;
}
// For non-subscription errors, show local error message
setError(err instanceof Error ? err.message : 'Failed to generate research persona');
} finally {
setGenerating(false);
}
};
const handleCancel = () => {
onCancel();
onClose();
};
const handleClose = () => {
if (!generating) {
onClose();
}
};
return (
<Dialog
open={open}
onClose={handleClose}
maxWidth="md"
fullWidth
disableEscapeKeyDown={generating}
PaperProps={{
sx: {
borderRadius: 3,
background: 'linear-gradient(135deg, #fff 0%, #f8fafc 100%)',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
// Force dark text colors for readability on light background
color: '#1e293b',
'& *': {
color: 'inherit',
},
}
}}
>
<DialogTitle sx={{ textAlign: 'center', pb: 1, color: '#0f172a' }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2 }}>
<PsychologyIcon sx={{ fontSize: 32, color: 'primary.main' }} />
<Typography variant="h5" sx={{ fontWeight: 600, color: '#0f172a' }}>
Generate Research Persona
</Typography>
</Box>
</DialogTitle>
<DialogContent sx={{ px: 4, py: 2, color: '#1e293b' }}>
<Typography variant="body1" sx={{ mb: 3, textAlign: 'center', color: '#475569' }}>
Enhance your research experience with AI-powered personalization based on your business profile and preferences.
</Typography>
<Alert
severity="info"
sx={{
mb: 3,
backgroundColor: '#e0f2fe',
borderColor: '#7dd3fc',
'& .MuiAlert-icon': {
color: '#0284c7',
},
'& .MuiAlert-message': {
color: '#0c4a6e',
},
}}
>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5, color: '#0c4a6e' }}>
Why generate a research persona?
</Typography>
<Typography variant="caption" sx={{ color: '#075985', display: 'block' }}>
Your research persona learns from your onboarding data to provide personalized research suggestions,
keyword expansions, and research angles tailored to your industry and audience.
</Typography>
</Alert>
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, color: '#0f172a' }}>
Benefits:
</Typography>
<List dense sx={{ py: 0 }}>
<ListItem sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
<AutoAwesomeIcon fontSize="small" color="primary" />
</ListItemIcon>
<ListItemText
primary={<Typography sx={{ color: '#1e293b', fontWeight: 500 }}>Smart Keyword Expansion</Typography>}
secondary={<Typography sx={{ color: '#64748b', fontSize: '0.875rem' }}>Automatically expand your keywords with industry-specific terms</Typography>}
/>
</ListItem>
<ListItem sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
<TrendingUpIcon fontSize="small" color="primary" />
</ListItemIcon>
<ListItemText
primary={<Typography sx={{ color: '#1e293b', fontWeight: 500 }}>Alternative Research Angles</Typography>}
secondary={<Typography sx={{ color: '#64748b', fontSize: '0.875rem' }}>Discover new research directions based on your business context</Typography>}
/>
</ListItem>
<ListItem sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
<SearchIcon fontSize="small" color="primary" />
</ListItemIcon>
<ListItemText
primary={<Typography sx={{ color: '#1e293b', fontWeight: 500 }}>Personalized Research Presets</Typography>}
secondary={<Typography sx={{ color: '#64748b', fontSize: '0.875rem' }}>Get recommended research configurations tailored to your needs</Typography>}
/>
</ListItem>
<ListItem sx={{ px: 0, py: 0.5 }}>
<ListItemIcon sx={{ minWidth: 36 }}>
<CheckCircleIcon fontSize="small" color="primary" />
</ListItemIcon>
<ListItemText
primary={<Typography sx={{ color: '#1e293b', fontWeight: 500 }}>Better Search Results</Typography>}
secondary={<Typography sx={{ color: '#64748b', fontSize: '0.875rem' }}>Improved query enhancement and domain suggestions for your industry</Typography>}
/>
</ListItem>
</List>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Typography variant="caption" sx={{ color: '#64748b', fontStyle: 'italic' }}>
Note: This process takes about 30-60 seconds and uses your AI provider.
You can continue using rule-based suggestions if you skip this step.
</Typography>
</DialogContent>
<DialogActions sx={{ px: 4, pb: 3, justifyContent: 'space-between', gap: 2 }}>
<Button
onClick={handleCancel}
disabled={generating}
startIcon={<CloseIcon />}
variant="outlined"
sx={{
color: '#475569',
borderColor: '#cbd5e1',
'&:hover': {
borderColor: '#94a3b8',
backgroundColor: 'rgba(148, 163, 184, 0.08)',
},
px: 3,
py: 1.25,
}}
>
Skip for Now
</Button>
<Button
onClick={handleGenerate}
disabled={generating}
variant="contained"
startIcon={generating ? <CircularProgress size={18} sx={{ color: 'white' }} /> : <PsychologyIcon />}
sx={{
minWidth: 180,
px: 4,
py: 1.5,
fontSize: '1rem',
fontWeight: 600,
background: generating
? 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)'
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
boxShadow: generating
? '0 4px 14px rgba(139, 92, 246, 0.3)'
: '0 8px 20px rgba(102, 126, 234, 0.4), 0 0 0 1px rgba(102, 126, 234, 0.1) inset',
'&:hover': {
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
boxShadow: '0 12px 28px rgba(102, 126, 234, 0.5), 0 0 0 1px rgba(102, 126, 234, 0.2) inset',
transform: 'translateY(-1px)',
},
'&:active': {
transform: 'translateY(0)',
boxShadow: '0 4px 14px rgba(102, 126, 234, 0.4)',
},
'&:disabled': {
background: 'linear-gradient(135deg, #cbd5e1 0%, #94a3b8 100%)',
boxShadow: 'none',
},
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
position: 'relative',
overflow: 'hidden',
'&::before': generating ? {
content: '""',
position: 'absolute',
top: 0,
left: '-100%',
width: '100%',
height: '100%',
background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent)',
animation: 'shimmer 2s infinite',
} : {},
'@keyframes shimmer': {
'0%': { left: '-100%' },
'100%': { left: '100%' },
},
}}
>
{generating ? 'Generating...' : 'Generate Persona'}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -5,14 +5,24 @@ import { ResearchInput } from './steps/ResearchInput';
import { StepProgress } from './steps/StepProgress';
import { StepResults } from './steps/StepResults';
import { ResearchWizardProps } from './types/research.types';
import { addResearchHistory } from '../../utils/researchHistory';
export const ResearchWizard: React.FC<ResearchWizardProps> = ({
onComplete,
onCancel,
initialKeywords,
initialIndustry,
initialTargetAudience,
initialResearchMode,
initialConfig,
}) => {
const wizard = useResearchWizard(initialKeywords, initialIndustry);
const wizard = useResearchWizard(
initialKeywords,
initialIndustry,
initialTargetAudience,
initialResearchMode,
initialConfig
);
const execution = useResearchExecution();
// Handle results from execution
@@ -30,12 +40,28 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
}
}, [execution.result, execution.isExecuting]); // Don't depend on currentStep to avoid loops
// Handle completion callback
// Handle completion callback and track history
useEffect(() => {
if (wizard.state.results && onComplete) {
// Track in research history when results are available
if (wizard.state.keywords.length > 0) {
// Extract a summary from results if available
const resultSummary = wizard.state.results.suggested_angles?.[0] ||
wizard.state.results.keyword_analysis?.primary_keywords?.[0] ||
wizard.state.results.sources?.[0]?.title;
addResearchHistory({
keywords: wizard.state.keywords,
industry: wizard.state.industry,
targetAudience: wizard.state.targetAudience,
researchMode: wizard.state.researchMode,
resultSummary,
});
}
onComplete(wizard.state.results);
}
}, [wizard.state.results, onComplete]);
}, [wizard.state.results, wizard.state.keywords, wizard.state.industry, wizard.state.targetAudience, wizard.state.researchMode, onComplete]);
const renderStep = () => {
const stepProps = {

View File

@@ -23,9 +23,28 @@ const defaultState: WizardState = {
results: null,
};
export const useResearchWizard = (initialKeywords?: string[], initialIndustry?: string) => {
export const useResearchWizard = (
initialKeywords?: string[],
initialIndustry?: string,
initialTargetAudience?: string,
initialResearchMode?: ResearchMode,
initialConfig?: ResearchConfig
) => {
const [state, setState] = useState<WizardState>(() => {
// Try to load from localStorage first
// If initial values are provided (preset clicked), clear localStorage and use them
if (initialKeywords || initialIndustry || initialTargetAudience || initialResearchMode || initialConfig) {
localStorage.removeItem(WIZARD_STATE_KEY);
return {
...defaultState,
keywords: initialKeywords || [],
industry: initialIndustry || defaultState.industry,
targetAudience: initialTargetAudience || defaultState.targetAudience,
researchMode: initialResearchMode || defaultState.researchMode,
config: initialConfig || defaultState.config,
};
}
// Try to load from localStorage only if no initial values
const saved = localStorage.getItem(WIZARD_STATE_KEY);
if (saved) {
try {
@@ -36,14 +55,26 @@ export const useResearchWizard = (initialKeywords?: string[], initialIndustry?:
}
}
// Use defaults or initial values
return {
...defaultState,
keywords: initialKeywords || [],
industry: initialIndustry || defaultState.industry,
};
// Use defaults
return defaultState;
});
// Update state when initial values change (preset clicked)
useEffect(() => {
if (initialKeywords || initialIndustry || initialTargetAudience || initialResearchMode || initialConfig) {
localStorage.removeItem(WIZARD_STATE_KEY);
setState({
...defaultState,
keywords: initialKeywords || [],
industry: initialIndustry || defaultState.industry,
targetAudience: initialTargetAudience || defaultState.targetAudience,
researchMode: initialResearchMode || defaultState.researchMode,
config: initialConfig || defaultState.config,
results: null, // Clear any previous results
});
}
}, [initialKeywords, initialIndustry, initialTargetAudience, initialResearchMode, initialConfig]);
// Persist state to localStorage
useEffect(() => {
if (state.currentStep > 1) {
@@ -74,10 +105,13 @@ export const useResearchWizard = (initialKeywords?: string[], initialIndustry?:
...defaultState,
keywords: initialKeywords || [],
industry: initialIndustry || defaultState.industry,
targetAudience: initialTargetAudience || defaultState.targetAudience,
researchMode: initialResearchMode || defaultState.researchMode,
config: initialConfig || defaultState.config,
};
setState(resetState);
localStorage.removeItem(WIZARD_STATE_KEY);
}, [initialKeywords, initialIndustry]);
}, [initialKeywords, initialIndustry, initialTargetAudience, initialResearchMode, initialConfig]);
const clearResults = useCallback(() => {
setState(prev => ({ ...prev, results: null }));

View File

@@ -1,6 +1,23 @@
import React, { useRef, useState, useEffect } from 'react';
import { WizardStepProps } from '../types/research.types';
import { ResearchProvider } from '../../../services/blogWriterApi';
import { ResearchProvider, ResearchMode } from '../../../services/blogWriterApi';
import { getResearchConfig, ProviderAvailability } from '../../../api/researchConfig';
import {
getResearchHistory,
clearResearchHistory,
formatHistoryTimestamp,
getHistorySummary,
ResearchHistoryEntry
} from '../../../utils/researchHistory';
import {
expandKeywords,
formatKeyword,
isOriginalKeyword
} from '../../../utils/keywordExpansion';
import {
generateResearchAngles,
formatAngle
} from '../../../utils/researchAngles';
const industries = [
'General',
@@ -53,30 +70,365 @@ const exaSearchTypes = [
{ value: 'neural', label: 'Neural - Semantic search' },
];
// Dynamic placeholder examples showcasing research capabilities
const placeholderExamples = [
"AI-powered content marketing strategies for SaaS startups\n\nExplores:\n• Latest automation tools and platforms\n• ROI optimization techniques\n• Multi-channel campaign orchestration\n• Data-driven personalization strategies",
"Sustainable supply chain management in manufacturing\n\nCovers:\n• Green logistics and carbon footprint reduction\n• Blockchain for transparency and traceability\n• Circular economy implementation frameworks\n• Real-time inventory optimization with AI",
"Emerging trends in telemedicine and remote patient monitoring\n\nIncludes:\n• Wearable device integration and IoT sensors\n• HIPAA-compliant data transmission protocols\n• AI-assisted diagnostic accuracy improvements\n• Patient engagement and adherence strategies",
"Cryptocurrency regulation and institutional adoption\n\nAnalyzes:\n• Global regulatory frameworks and compliance\n• Institutional investment trends (2024-2025)\n• DeFi integration with traditional finance\n• Risk management and security best practices",
"Voice search optimization and conversational AI for e-commerce\n\nFeatures:\n• Natural language processing advancements\n• Smart speaker integration strategies\n• Voice-enabled checkout experiences\n• Personalization through voice analytics"
];
// Intelligent input parser - handles sentences, keywords, URLs
const parseIntelligentInput = (value: string): string[] => {
// If empty, return empty array
if (!value.trim()) return [];
// Detect if input contains URLs
const urlPattern = /(https?:\/\/[^\s,]+)/g;
const urls = value.match(urlPattern) || [];
// Check if input looks like a sentence/paragraph (contains multiple words without commas)
const hasCommas = value.includes(',');
const wordCount = value.trim().split(/\s+/).length;
if (urls.length > 0) {
// User provided URLs - extract them as separate keywords
const textWithoutUrls = value.replace(urlPattern, '').trim();
const textKeywords = textWithoutUrls ? [textWithoutUrls] : [];
return [...urls, ...textKeywords];
} else if (!hasCommas && wordCount > 5) {
// Looks like a sentence/paragraph - treat entire input as single research topic
return [value.trim()];
} else if (hasCommas) {
// Traditional comma-separated keywords
return value.split(',').map(k => k.trim()).filter(Boolean);
} else {
// Short phrase or single keyword
return [value.trim()];
}
};
// Industry-specific placeholder examples for personalized experience
const getIndustryPlaceholders = (industry: string): string[] => {
const industryExamples: Record<string, string[]> = {
Healthcare: [
"Research: AI-powered diagnostic tools in clinical practice\n\n💡 What you'll get:\n• FDA-approved AI medical devices\n• Clinical accuracy and patient outcomes\n• Implementation costs and ROI",
"Analyze: Telemedicine adoption trends and patient satisfaction\n\n💡 Research includes:\n• Post-pandemic telehealth growth\n• Remote patient monitoring technologies\n• Insurance coverage and reimbursement",
"Investigate: Personalized medicine and genomic testing advances\n\n💡 You'll discover:\n• Latest genomic sequencing technologies\n• Precision therapy success rates\n• Ethical considerations and regulations"
],
Technology: [
"Investigate: Latest developments in edge computing and IoT\n\n💡 What you'll get:\n• Edge AI deployment strategies\n• 5G integration and performance\n• Industry use cases and benchmarks",
"Compare: Cloud providers for enterprise SaaS applications\n\n💡 Research includes:\n• AWS vs Azure vs GCP feature comparison\n• Cost optimization strategies\n• Security and compliance certifications",
"Analyze: Quantum computing breakthroughs and commercial applications\n\n💡 You'll discover:\n• Latest quantum hardware developments\n• Real-world problem solving examples\n• Investment landscape and timeline"
],
Finance: [
"Research: DeFi regulatory landscape and compliance challenges\n\n💡 What you'll get:\n• Global regulatory frameworks\n• Compliance best practices\n• Risk management strategies",
"Analyze: Digital banking customer retention strategies\n\n💡 Research includes:\n• Neobank growth and market share\n• Customer acquisition costs and LTV\n• Personalization and UX innovations",
"Investigate: ESG investing trends and impact measurement\n\n💡 You'll discover:\n• ESG rating methodologies\n• Fund performance and returns\n• Regulatory requirements and reporting"
],
Marketing: [
"Research: AI-powered marketing automation and personalization\n\n💡 What you'll get:\n• Top marketing AI platforms and features\n• ROI and conversion rate improvements\n• Implementation case studies",
"Analyze: Influencer marketing ROI and authenticity trends\n\n💡 Research includes:\n• Micro vs macro influencer effectiveness\n• Platform-specific engagement rates\n• Brand partnership best practices",
"Investigate: Privacy-first marketing in a cookieless world\n\n💡 You'll discover:\n• First-party data strategies\n• Contextual targeting innovations\n• Compliance with privacy regulations"
],
Business: [
"Research: Remote work policies and hybrid workplace models\n\n💡 What you'll get:\n• Productivity metrics and employee satisfaction\n• Technology infrastructure requirements\n• Cultural impact and change management",
"Analyze: Supply chain resilience and diversification strategies\n\n💡 Research includes:\n• Nearshoring and reshoring trends\n• Technology solutions for visibility\n• Risk mitigation frameworks",
"Investigate: Sustainability initiatives and corporate ESG programs\n\n💡 You'll discover:\n• Industry-specific sustainability benchmarks\n• Cost-benefit analysis of green initiatives\n• Stakeholder communication strategies"
],
Education: [
"Research: EdTech tools for personalized learning experiences\n\n💡 What you'll get:\n• Adaptive learning platform comparisons\n• Student engagement and outcomes data\n• Implementation costs and training needs",
"Analyze: Microlearning and skill-based education trends\n\n💡 Research includes:\n• Corporate training effectiveness\n• Platform and content recommendations\n• ROI and completion rates",
"Investigate: AI tutoring systems and student support tools\n\n💡 You'll discover:\n• Natural language processing advances\n• Student performance improvements\n• Accessibility and inclusion features"
],
'Real Estate': [
"Research: PropTech innovations transforming property management\n\n💡 What you'll get:\n• Smart building technologies and IoT\n• Tenant experience platforms\n• Operational efficiency gains",
"Analyze: Virtual staging and 3D property tours adoption\n\n💡 Research includes:\n• Technology provider comparisons\n• Impact on sales velocity and pricing\n• Cost vs traditional staging",
"Investigate: Real estate tokenization and fractional ownership\n\n💡 You'll discover:\n• Blockchain platforms and regulations\n• Investor demographics and demand\n• Liquidity and exit strategies"
],
Travel: [
"Research: Sustainable tourism trends and eco-travel preferences\n\n💡 What you'll get:\n• Green certification programs\n• Traveler willingness to pay premium\n• Destination best practices",
"Analyze: AI-powered travel personalization and recommendations\n\n💡 Research includes:\n• Recommendation engine technologies\n• Booking conversion rate improvements\n• Customer lifetime value impact",
"Investigate: Bleisure travel and workation destination trends\n\n💡 You'll discover:\n• Remote work-friendly destinations\n• Co-working and accommodation options\n• Digital nomad demographics"
]
};
return industryExamples[industry] || [
"Research: Latest AI advancements in your industry\n\n💡 What you'll get:\n• Recent breakthroughs and innovations\n• Key companies and technologies\n• Expert insights and market trends",
"Write a blog on: Emerging trends shaping your industry in 2025\n\n💡 This will research:\n• Technology disruptions and innovations\n• Regulatory changes and compliance\n• Consumer behavior shifts",
"Analyze: Best practices and success stories in your field\n\n💡 Research includes:\n• Industry leader strategies\n• Implementation case studies\n• ROI and performance metrics",
"https://example.com/article\n\n💡 URL detected! Research will:\n• Extract key insights from the article\n• Find related sources and updates\n• Provide comprehensive context"
];
};
export const ResearchInput: React.FC<WizardStepProps> = ({ state, onUpdate }) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [currentPlaceholder, setCurrentPlaceholder] = useState(0);
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability | null>(null);
const [loadingConfig, setLoadingConfig] = useState(true);
const [suggestedMode, setSuggestedMode] = useState<ResearchMode | null>(null);
const [researchHistory, setResearchHistory] = useState<ResearchHistoryEntry[]>([]);
const [keywordExpansion, setKeywordExpansion] = useState<{
original: string[];
expanded: string[];
suggestions: string[];
} | null>(null);
const [researchAngles, setResearchAngles] = useState<string[]>([]);
// Load research history on mount and when component updates
useEffect(() => {
const history = getResearchHistory();
setResearchHistory(history);
}, []); // Load once on mount
// Reload history when keywords change (after research completes)
useEffect(() => {
const history = getResearchHistory();
setResearchHistory(history);
}, [state.keywords]);
// Load research configuration on mount
useEffect(() => {
const loadConfig = async () => {
try {
const config = await getResearchConfig();
// Set provider availability with fallback
setProviderAvailability(config?.provider_availability || {
google_available: true, // Default to available, will be corrected by actual key status
exa_available: false,
gemini_key_status: 'missing',
exa_key_status: 'missing'
});
// Apply persona defaults if not already set (with null checks)
if (config?.persona_defaults) {
if (config.persona_defaults.industry && state.industry === 'General') {
onUpdate({ industry: config.persona_defaults.industry });
}
if (config.persona_defaults.target_audience && state.targetAudience === 'General') {
onUpdate({ targetAudience: config.persona_defaults.target_audience });
}
// Apply suggested Exa domains if Exa is available and not already set
if (config.provider_availability?.exa_available && config.persona_defaults.suggested_domains?.length > 0) {
if (!state.config.exa_include_domains || state.config.exa_include_domains.length === 0) {
onUpdate({
config: {
...state.config,
exa_include_domains: config.persona_defaults.suggested_domains
}
});
}
}
// Apply suggested Exa category if available
if (config.persona_defaults.suggested_exa_category && !state.config.exa_category) {
onUpdate({
config: {
...state.config,
exa_category: config.persona_defaults.suggested_exa_category
}
});
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[ResearchInput] Failed to load research config:', errorMessage);
// Set default provider availability on error
setProviderAvailability({
google_available: true, // Optimistically assume available
exa_available: false,
gemini_key_status: 'missing',
exa_key_status: 'missing'
});
// Continue with defaults - don't block the UI
} finally {
setLoadingConfig(false);
}
};
loadConfig();
}, []); // Only run once on mount
// Get industry-specific placeholders
const placeholderExamples = getIndustryPlaceholders(state.industry);
// Rotate placeholder examples every 4 seconds
useEffect(() => {
const interval = setInterval(() => {
setCurrentPlaceholder((prev) => (prev + 1) % placeholderExamples.length);
}, 4000);
return () => clearInterval(interval);
}, []);
}, [placeholderExamples.length]);
// Reset placeholder index when industry changes
useEffect(() => {
setCurrentPlaceholder(0);
}, [state.industry]);
// Auto-set provider based on research mode
useEffect(() => {
if (!providerAvailability) return;
let newProvider: ResearchProvider = 'google';
switch (state.researchMode) {
case 'basic':
// Basic: Google only (fast, simple)
newProvider = 'google';
break;
case 'comprehensive':
// Comprehensive: Prefer Exa if available, fallback to Google
newProvider = providerAvailability.exa_available ? 'exa' : 'google';
break;
case 'targeted':
// Targeted: Prefer Exa if available, fallback to Google
newProvider = providerAvailability.exa_available ? 'exa' : 'google';
break;
}
// Only update if provider changed
if (state.config.provider !== newProvider) {
onUpdate({ config: { ...state.config, provider: newProvider } });
}
}, [state.researchMode, providerAvailability]);
// Dynamic domain suggestions when industry changes
useEffect(() => {
if (!providerAvailability || state.industry === 'General') return;
// Get industry-specific domain suggestions (from backend logic)
const domainMap: Record<string, string[]> = {
'Healthcare': ['pubmed.gov', 'nejm.org', 'thelancet.com', 'nih.gov'],
'Technology': ['techcrunch.com', 'wired.com', 'arstechnica.com', 'theverge.com'],
'Finance': ['wsj.com', 'bloomberg.com', 'ft.com', 'reuters.com'],
'Science': ['nature.com', 'sciencemag.org', 'cell.com', 'pnas.org'],
'Business': ['hbr.org', 'forbes.com', 'businessinsider.com', 'mckinsey.com'],
'Marketing': ['marketingland.com', 'adweek.com', 'hubspot.com', 'moz.com'],
'Education': ['edutopia.org', 'chronicle.com', 'insidehighered.com'],
'Real Estate': ['realtor.com', 'zillow.com', 'forbes.com'],
'Entertainment': ['variety.com', 'hollywoodreporter.com', 'deadline.com'],
'Travel': ['lonelyplanet.com', 'nationalgeographic.com', 'travelandleisure.com'],
'Fashion': ['vogue.com', 'elle.com', 'wwd.com'],
'Sports': ['espn.com', 'si.com', 'bleacherreport.com'],
'Law': ['law.com', 'abajournal.com', 'scotusblog.com'],
};
const newDomains = domainMap[state.industry] || [];
// Get industry-specific Exa category
const categoryMap: Record<string, string> = {
'Healthcare': 'research paper',
'Science': 'research paper',
'Finance': 'financial report',
'Technology': 'company',
'Business': 'company',
'Marketing': 'company',
'Education': 'research paper',
'Law': 'pdf',
};
const newCategory = categoryMap[state.industry];
// Only update if Exa is available and domains/category should change
if (providerAvailability.exa_available && newDomains.length > 0) {
const configUpdates: any = {};
// Update domains if different
const currentDomains = state.config.exa_include_domains || [];
if (JSON.stringify(currentDomains) !== JSON.stringify(newDomains)) {
configUpdates.exa_include_domains = newDomains;
}
// Update category if available and different
if (newCategory && state.config.exa_category !== newCategory) {
configUpdates.exa_category = newCategory;
}
// Apply updates if any
if (Object.keys(configUpdates).length > 0) {
onUpdate({
config: {
...state.config,
...configUpdates
}
});
}
}
}, [state.industry, providerAvailability]);
// Smart mode suggestion based on query complexity
const suggestResearchMode = (keywords: string[]): ResearchMode => {
if (keywords.length === 0) return 'basic';
const totalText = keywords.join(' ');
const totalWords = totalText.split(/\s+/).length;
const hasURL = keywords.some(k => k.startsWith('http'));
// URL detected → comprehensive research
if (hasURL) return 'comprehensive';
// Long detailed query → comprehensive
if (totalWords > 20) return 'comprehensive';
// Medium complexity → targeted
if (totalWords > 10 || keywords.length > 3) return 'targeted';
// Simple query → basic
return 'basic';
};
// Expand keywords when keywords or industry changes
useEffect(() => {
if (state.keywords.length > 0 && state.industry !== 'General') {
const expansion = expandKeywords(state.keywords, state.industry);
setKeywordExpansion(expansion);
} else {
setKeywordExpansion(null);
}
}, [state.keywords, state.industry]);
// Generate research angles when keywords change
useEffect(() => {
if (state.keywords.length > 0) {
// Use the first keyword (or joined keywords) as the query
const query = state.keywords.join(' ');
const angles = generateResearchAngles(query, state.industry);
setResearchAngles(angles);
} else {
setResearchAngles([]);
}
}, [state.keywords, state.industry]);
const handleKeywordsChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const keywords = value.split(',').map(k => k.trim()).filter(Boolean);
const keywords = parseIntelligentInput(value);
onUpdate({ keywords });
// Update suggested mode
const suggested = suggestResearchMode(keywords);
setSuggestedMode(suggested);
};
// Handle clicking a keyword suggestion to add it
const handleAddSuggestion = (suggestion: string) => {
const currentKeywords = [...state.keywords];
// Check if suggestion already exists (case-insensitive)
const exists = currentKeywords.some(k => k.toLowerCase() === suggestion.toLowerCase());
if (!exists) {
currentKeywords.push(suggestion);
onUpdate({ keywords: currentKeywords });
}
};
// Handle removing a keyword
const handleRemoveKeyword = (keywordToRemove: string) => {
const currentKeywords = state.keywords.filter(k => k.toLowerCase() !== keywordToRemove.toLowerCase());
onUpdate({ keywords: currentKeywords });
};
// Handle clicking a research angle to use it
const handleUseAngle = (angle: string) => {
// Parse the angle as a new research query
const keywords = parseIntelligentInput(angle);
onUpdate({ keywords });
};
@@ -168,6 +520,129 @@ export const ResearchInput: React.FC<WizardStepProps> = ({ state, onUpdate }) =>
Research Topic & Keywords
</label>
{/* Research History */}
{researchHistory.length > 0 && (
<div style={{
marginBottom: '12px',
padding: '12px',
background: 'rgba(14, 165, 233, 0.03)',
border: '1px solid rgba(14, 165, 233, 0.1)',
borderRadius: '10px',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '10px',
}}>
<span style={{
fontSize: '12px',
fontWeight: '600',
color: '#0369a1',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}>
<span>🕐</span>
Recently Researched
</span>
<button
onClick={() => {
clearResearchHistory();
setResearchHistory([]);
}}
style={{
padding: '4px 10px',
fontSize: '11px',
color: '#64748b',
background: 'transparent',
border: '1px solid rgba(100, 116, 139, 0.2)',
borderRadius: '6px',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
e.currentTarget.style.borderColor = 'rgba(239, 68, 68, 0.3)';
e.currentTarget.style.color = '#dc2626';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.borderColor = 'rgba(100, 116, 139, 0.2)';
e.currentTarget.style.color = '#64748b';
}}
>
Clear
</button>
</div>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
}}>
{researchHistory.map((entry) => (
<button
key={entry.timestamp}
onClick={() => {
// Populate all fields from history entry
onUpdate({
keywords: entry.keywords,
industry: entry.industry,
targetAudience: entry.targetAudience,
researchMode: entry.researchMode,
});
}}
title={`Industry: ${entry.industry} | Audience: ${entry.targetAudience} | Mode: ${entry.researchMode} | ${formatHistoryTimestamp(entry.timestamp)}`}
style={{
padding: '8px 14px',
fontSize: '12px',
color: '#0369a1',
background: 'rgba(255, 255, 255, 0.9)',
border: '1px solid rgba(14, 165, 233, 0.2)',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '6px',
maxWidth: '100%',
textAlign: 'left',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(14, 165, 233, 0.1)';
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.4)';
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(14, 165, 233, 0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.9)';
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.2)';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<span style={{ fontSize: '14px' }}>🔍</span>
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '200px',
}}>
{getHistorySummary(entry)}
</span>
<span style={{
fontSize: '10px',
color: '#64748b',
marginLeft: '4px',
}}>
{formatHistoryTimestamp(entry.timestamp)}
</span>
</button>
))}
</div>
</div>
)}
<div style={{ position: 'relative' }}>
<textarea
value={state.keywords.join(', ')}
@@ -239,13 +714,290 @@ export const ResearchInput: React.FC<WizardStepProps> = ({ state, onUpdate }) =>
/>
</div>
{/* Smart Input Detection Indicator */}
{state.keywords.length > 0 && (
<div style={{
marginTop: '10px',
padding: '8px 12px',
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.1) 0%, rgba(16, 185, 129, 0.1) 100%)',
border: '1px solid rgba(34, 197, 94, 0.2)',
borderRadius: '8px',
fontSize: '12px',
color: '#059669',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}>
<span></span>
{state.keywords[0]?.startsWith('http') ? (
<span>URL detected - will extract and analyze content</span>
) : state.keywords.length === 1 && state.keywords[0]?.split(/\s+/).length > 5 ? (
<span>Research topic detected - will conduct comprehensive analysis</span>
) : (
<span>{state.keywords.length} keyword{state.keywords.length > 1 ? 's' : ''} identified</span>
)}
</div>
)}
{/* Keyword Expansion Suggestions */}
{keywordExpansion && keywordExpansion.suggestions.length > 0 && state.industry !== 'General' && (
<div style={{
marginTop: '12px',
padding: '12px',
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(147, 197, 253, 0.05) 100%)',
border: '1px solid rgba(59, 130, 246, 0.15)',
borderRadius: '8px',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '10px',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
fontWeight: '600',
color: '#1e40af',
}}>
<span>💡</span>
<span>Suggested Keywords for {state.industry}</span>
</div>
</div>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
}}>
{keywordExpansion.suggestions.map((suggestion, idx) => {
const isAlreadyAdded = state.keywords.some(k => k.toLowerCase() === suggestion.toLowerCase());
return (
<button
key={idx}
onClick={() => !isAlreadyAdded && handleAddSuggestion(suggestion)}
disabled={isAlreadyAdded}
style={{
padding: '6px 12px',
background: isAlreadyAdded
? 'rgba(203, 213, 225, 0.3)'
: 'rgba(59, 130, 246, 0.1)',
border: `1px solid ${isAlreadyAdded ? 'rgba(148, 163, 184, 0.3)' : 'rgba(59, 130, 246, 0.2)'}`,
borderRadius: '6px',
fontSize: '12px',
fontWeight: '500',
color: isAlreadyAdded ? '#64748b' : '#1e40af',
cursor: isAlreadyAdded ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
onMouseEnter={(e) => {
if (!isAlreadyAdded) {
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.15)';
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.3)';
}
}}
onMouseLeave={(e) => {
if (!isAlreadyAdded) {
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.1)';
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.2)';
}
}}
>
{isAlreadyAdded ? (
<>
<span></span>
<span>{formatKeyword(suggestion)}</span>
</>
) : (
<>
<span>+</span>
<span>{formatKeyword(suggestion)}</span>
</>
)}
</button>
);
})}
</div>
<div style={{
marginTop: '8px',
fontSize: '11px',
color: '#64748b',
fontStyle: 'italic',
}}>
Click to add suggested keywords to your research query
</div>
</div>
)}
{/* Current Keywords Display (for removal) */}
{state.keywords.length > 0 && (
<div style={{
marginTop: '12px',
padding: '10px',
background: 'rgba(241, 245, 249, 0.5)',
border: '1px solid rgba(203, 213, 225, 0.3)',
borderRadius: '8px',
}}>
<div style={{
fontSize: '12px',
fontWeight: '600',
color: '#475569',
marginBottom: '8px',
}}>
Current Keywords ({state.keywords.length})
</div>
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
}}>
{state.keywords.map((keyword, idx) => (
<div
key={idx}
style={{
padding: '5px 10px',
background: 'white',
border: '1px solid rgba(203, 213, 225, 0.5)',
borderRadius: '6px',
fontSize: '12px',
color: '#334155',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<span>{formatKeyword(keyword)}</span>
<button
onClick={() => handleRemoveKeyword(keyword)}
style={{
background: 'none',
border: 'none',
color: '#ef4444',
cursor: 'pointer',
fontSize: '14px',
padding: '0',
width: '16px',
height: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'none';
}}
title="Remove keyword"
>
×
</button>
</div>
))}
</div>
</div>
)}
{/* Alternative Research Angles */}
{researchAngles.length > 0 && (
<div style={{
marginTop: '12px',
padding: '12px',
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)',
border: '1px solid rgba(168, 85, 247, 0.15)',
borderRadius: '8px',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
marginBottom: '10px',
}}>
<span style={{
fontSize: '16px',
}}>💡</span>
<span style={{
fontSize: '13px',
fontWeight: '600',
color: '#7c3aed',
}}>
Explore Alternative Research Angles
</span>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '10px',
}}>
{researchAngles.map((angle, idx) => (
<button
key={idx}
onClick={() => handleUseAngle(angle)}
style={{
padding: '10px 14px',
background: 'rgba(255, 255, 255, 0.9)',
border: '1px solid rgba(168, 85, 247, 0.2)',
borderRadius: '8px',
fontSize: '12px',
fontWeight: '500',
color: '#6b21a8',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.2s ease',
display: 'flex',
flexDirection: 'column',
gap: '4px',
boxShadow: '0 1px 3px rgba(168, 85, 247, 0.1)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(168, 85, 247, 0.1)';
e.currentTarget.style.borderColor = 'rgba(168, 85, 247, 0.4)';
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(168, 85, 247, 0.2)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.9)';
e.currentTarget.style.borderColor = 'rgba(168, 85, 247, 0.2)';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 1px 3px rgba(168, 85, 247, 0.1)';
}}
title={`Click to research: ${angle}`}
>
<span style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
}}>
<span style={{ fontSize: '14px' }}>🔍</span>
<span>{formatAngle(angle)}</span>
</span>
</button>
))}
</div>
<div style={{
marginTop: '8px',
fontSize: '11px',
color: '#64748b',
fontStyle: 'italic',
}}>
Click any angle to explore a different research focus
</div>
</div>
)}
<div style={{
marginTop: '10px',
fontSize: '12px',
color: '#64748b',
lineHeight: '1.5',
}}>
💡 Tip: Describe your research topic in detail. Include specific keywords, questions, or aspects you want to explore. The AI will find relevant sources and insights.
💡 Tip: Enter sentences, keywords, or URLs. The AI will intelligently parse your input and conduct comprehensive research.
</div>
</div>
@@ -296,16 +1048,53 @@ export const ResearchInput: React.FC<WizardStepProps> = ({ state, onUpdate }) =>
</select>
</div>
{/* Research Mode */}
{/* Research Mode with Status Indicator */}
<div>
<label style={{
display: 'block',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
fontSize: '13px',
fontWeight: '600',
color: '#0c4a6e',
}}>
Research Depth
<span>Research Depth</span>
{providerAvailability && (
<span style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '11px',
color: '#64748b',
background: 'rgba(255, 255, 255, 0.8)',
padding: '4px 10px',
borderRadius: '20px',
border: '1px solid rgba(14, 165, 233, 0.15)',
}}>
<span style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: providerAvailability.google_available ? '#10b981' : '#ef4444',
boxShadow: providerAvailability.google_available
? '0 0 6px rgba(16, 185, 129, 0.5)'
: '0 0 6px rgba(239, 68, 68, 0.5)',
}} title={`Google: ${providerAvailability.gemini_key_status}`} />
<span>Google</span>
<span style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: providerAvailability.exa_available ? '#10b981' : '#ef4444',
boxShadow: providerAvailability.exa_available
? '0 0 6px rgba(16, 185, 129, 0.5)'
: '0 0 6px rgba(239, 68, 68, 0.5)',
marginLeft: '6px',
}} title={`Exa: ${providerAvailability.exa_key_status}`} />
<span>Exa</span>
</span>
)}
</label>
<select
value={state.researchMode}
@@ -331,56 +1120,71 @@ export const ResearchInput: React.FC<WizardStepProps> = ({ state, onUpdate }) =>
}}
>
{researchModes.map(mode => (
<option key={mode.value} value={mode.value}>{mode.label}</option>
<option key={mode.value} value={mode.value}>
{mode.label}
{mode.value === 'basic' && ' • Google Search'}
{mode.value === 'comprehensive' && providerAvailability?.exa_available && ' • Exa Neural'}
{mode.value === 'comprehensive' && !providerAvailability?.exa_available && ' • Google Search'}
{mode.value === 'targeted' && providerAvailability?.exa_available && ' • Exa Neural'}
{mode.value === 'targeted' && !providerAvailability?.exa_available && ' • Google Search'}
</option>
))}
</select>
</div>
{/* Provider (only for Comprehensive/Targeted) */}
{state.researchMode !== 'basic' && (
<div>
<label style={{
display: 'block',
marginBottom: '8px',
fontSize: '13px',
fontWeight: '600',
color: '#0c4a6e',
}}>
Search Provider
</label>
<select
value={state.config.provider}
onChange={handleProviderChange}
style={{
width: '100%',
padding: '10px 12px',
fontSize: '13px',
border: '1px solid rgba(14, 165, 233, 0.2)',
borderRadius: '10px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.5)';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(14, 165, 233, 0.1)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.2)';
e.currentTarget.style.boxShadow = 'none';
}}
>
{providers.map(prov => (
<option key={prov.value} value={prov.value}>{prov.label}</option>
))}
</select>
<div style={{
marginTop: '6px',
fontSize: '11px',
color: '#64748b',
fontStyle: 'italic',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
}}>
<span>
{state.researchMode === 'basic' && '🔍 Fast research using Google Search'}
{state.researchMode === 'comprehensive' && providerAvailability?.exa_available && '🧠 Deep research using Exa Neural Search'}
{state.researchMode === 'comprehensive' && !providerAvailability?.exa_available && '🔍 In-depth research using Google Search'}
{state.researchMode === 'targeted' && providerAvailability?.exa_available && '🎯 Focused research using Exa Neural Search'}
{state.researchMode === 'targeted' && !providerAvailability?.exa_available && '🎯 Focused research using Google Search'}
</span>
{suggestedMode && suggestedMode !== state.researchMode && state.keywords.length > 0 && (
<button
onClick={() => onUpdate({ researchMode: suggestedMode })}
style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: 'white',
border: 'none',
padding: '4px 10px',
borderRadius: '12px',
fontSize: '11px',
fontWeight: '600',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
transition: 'all 0.2s ease',
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(16, 185, 129, 0.3)';
}}
title={`Switch to ${suggestedMode} mode for better results`}
>
<span>💡</span>
<span>Try {suggestedMode}</span>
</button>
)}
</div>
)}
</div>
</div>
{/* Exa-Specific Options */}
{state.config.provider === 'exa' && state.researchMode !== 'basic' && (
{/* Exa-Specific Options - Show when Exa is selected */}
{state.config.provider === 'exa' && (
<div style={{
background: 'linear-gradient(135deg, rgba(139, 92, 246, 0.05) 0%, rgba(99, 102, 241, 0.05) 100%)',
border: '1px solid rgba(139, 92, 246, 0.2)',

View File

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

View File

@@ -0,0 +1,539 @@
/**
* Execution Logs Table Component
* Displays task execution logs in a table with pagination and filtering.
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Table,
TableBody,
TableContainer,
TableHead,
TableRow,
TablePagination,
IconButton,
Tooltip,
Select,
MenuItem,
FormControl,
InputLabel,
CircularProgress
} from '@mui/material';
import {
CheckCircle as CheckCircleIcon,
Error as ErrorIcon,
Schedule as ScheduleIcon,
Refresh as RefreshIcon,
Visibility as VisibilityIcon
} from '@mui/icons-material';
import { getExecutionLogs, getRecentSchedulerLogs, ExecutionLog, ExecutionLogsResponse } from '../../api/schedulerDashboard';
import {
TerminalPaper,
TerminalTypography,
TerminalChipSuccess,
TerminalChipError,
TerminalChipWarning,
TerminalTableCell,
TerminalTableRow,
TerminalAlert,
terminalColors
} from './terminalTheme';
interface ExecutionLogsTableProps {
initialLimit?: number;
}
const ExecutionLogsTable: React.FC<ExecutionLogsTableProps> = ({ initialLimit = 50 }) => {
const [logs, setLogs] = useState<ExecutionLog[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(initialLimit);
const [totalCount, setTotalCount] = useState(0);
const [statusFilter, setStatusFilter] = useState<'success' | 'failed' | 'running' | 'skipped' | 'all'>('all');
const [isShowingSchedulerLogs, setIsShowingSchedulerLogs] = useState(false);
const fetchLogs = async () => {
try {
setLoading(true);
setError(null);
// First, try to fetch actual execution logs
const response = await getExecutionLogs(
rowsPerPage,
page * rowsPerPage,
statusFilter === 'all' ? undefined : statusFilter
);
console.log('📋 Execution Logs Response:', JSON.stringify({
logsCount: response.logs?.length || 0,
totalCount: response.total_count,
hasLogs: !!(response.logs && response.logs.length > 0),
isSchedulerLogs: response.is_scheduler_logs,
firstLog: response.logs?.[0] || null
}, null, 2));
// If we have actual execution logs, use them
if (response.logs && response.logs.length > 0 && !response.is_scheduler_logs) {
console.log('✅ Using execution logs:', response.logs.length);
setLogs(response.logs);
setTotalCount(response.total_count || 0);
setIsShowingSchedulerLogs(false);
} else {
// No execution logs available, fetch scheduler logs as fallback (latest 5 only)
console.log('📋 No execution logs found, fetching latest scheduler logs...');
try {
const schedulerLogsResponse = await getRecentSchedulerLogs();
console.log('📋 Scheduler Logs Response:', JSON.stringify({
logsCount: schedulerLogsResponse.logs?.length || 0,
totalCount: schedulerLogsResponse.total_count,
isSchedulerLogs: schedulerLogsResponse.is_scheduler_logs,
allLogs: schedulerLogsResponse.logs || []
}, null, 2));
if (schedulerLogsResponse.logs && schedulerLogsResponse.logs.length > 0) {
console.log('✅ Setting scheduler logs:', schedulerLogsResponse.logs.length, 'logs');
setLogs(schedulerLogsResponse.logs);
setTotalCount(schedulerLogsResponse.total_count || 0);
setIsShowingSchedulerLogs(true);
} else {
console.warn('⚠️ Scheduler logs response is empty');
setLogs([]);
setTotalCount(0);
setIsShowingSchedulerLogs(false);
}
} catch (schedulerErr: any) {
console.error('❌ Error fetching scheduler logs:', schedulerErr);
setLogs([]);
setTotalCount(0);
setIsShowingSchedulerLogs(false);
}
}
} catch (err: any) {
setError(err.message || 'Failed to fetch execution logs');
console.error('❌ Error fetching execution logs:', err);
// Try to fetch scheduler logs as fallback even on error (latest 5 only)
try {
const schedulerLogsResponse = await getRecentSchedulerLogs();
setLogs(schedulerLogsResponse.logs || []);
setTotalCount(schedulerLogsResponse.total_count || 0);
setIsShowingSchedulerLogs(true);
} catch (schedulerErr: any) {
console.error('❌ Error fetching scheduler logs:', schedulerErr);
setLogs([]);
setTotalCount(0);
}
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchLogs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, rowsPerPage, statusFilter]); // fetchLogs is stable, no need to include
const handleChangePage = (_event: unknown, newPage: number) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'success':
return <CheckCircleIcon fontSize="small" color="success" />;
case 'failed':
return <ErrorIcon fontSize="small" color="error" />;
case 'running':
return <ScheduleIcon fontSize="small" color="primary" />;
default:
return <ScheduleIcon fontSize="small" />;
}
};
const getStatusColor = (status: string): "success" | "error" | "warning" | "default" => {
switch (status) {
case 'success':
return 'success';
case 'failed':
return 'error';
case 'running':
return 'warning';
default:
return 'default';
}
};
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleString();
} catch {
return dateString;
}
};
const formatExecutionTime = (ms: number | null) => {
if (!ms) return 'N/A';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
};
return (
<TerminalPaper sx={{ p: 3 }}>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Box display="flex" alignItems="center" gap={1}>
<ScheduleIcon sx={{ color: terminalColors.primary }} />
<TerminalTypography variant="h6" component="h2" sx={{ fontSize: '1.25rem', fontWeight: 'bold' }}>
Execution Logs
</TerminalTypography>
{isShowingSchedulerLogs && (
<TerminalChipWarning
label="Showing Scheduler Logs"
size="small"
sx={{ ml: 1 }}
/>
)}
</Box>
<Box display="flex" alignItems="center" gap={2}>
<FormControl
size="small"
sx={{
minWidth: 120,
'& .MuiOutlinedInput-root': {
color: terminalColors.primary,
'& fieldset': {
borderColor: terminalColors.primary,
},
'&:hover fieldset': {
borderColor: terminalColors.secondary,
},
},
'& .MuiInputLabel-root': {
color: terminalColors.textSecondary,
},
'& .MuiSelect-icon': {
color: terminalColors.primary,
}
}}
>
<InputLabel>Status</InputLabel>
<Select
value={statusFilter}
label="Status"
onChange={(e) => {
setStatusFilter(e.target.value as any);
setPage(0);
}}
MenuProps={{
PaperProps: {
sx: {
backgroundColor: terminalColors.backgroundLight,
border: `1px solid ${terminalColors.primary}`,
'& .MuiMenuItem-root': {
color: terminalColors.primary,
fontFamily: 'monospace',
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.1)',
},
'&.Mui-selected': {
backgroundColor: 'rgba(0, 255, 0, 0.15)',
}
}
}
}
}}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="success">Success</MenuItem>
<MenuItem value="failed">Failed</MenuItem>
<MenuItem value="running">Running</MenuItem>
<MenuItem value="skipped">Skipped</MenuItem>
</Select>
</FormControl>
<Tooltip title="Refresh logs">
<IconButton
onClick={fetchLogs}
size="small"
sx={{
color: terminalColors.primary,
border: `1px solid ${terminalColors.primary}`,
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.1)',
}
}}
>
<RefreshIcon />
</IconButton>
</Tooltip>
</Box>
</Box>
{error && (
<TerminalAlert severity="error" sx={{ mb: 2 }}>
{error}
</TerminalAlert>
)}
{loading ? (
<Box display="flex" justifyContent="center" p={3}>
<CircularProgress sx={{ color: terminalColors.primary }} />
</Box>
) : (
<>
{isShowingSchedulerLogs && (
<TerminalAlert severity="info" sx={{ mb: 2 }}>
<TerminalTypography variant="body2" sx={{ fontSize: '0.875rem' }}>
Showing latest 5 scheduler activity logs (job scheduling, completion, failures).
Historical execution logs are available in the Event History section below.
</TerminalTypography>
</TerminalAlert>
)}
<TableContainer
sx={{
backgroundColor: terminalColors.background,
maxHeight: '600px',
overflow: 'auto'
}}
>
<Table size="small" sx={{ minWidth: 650 }}>
<TableHead>
<TerminalTableRow>
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Task</TerminalTableCell>
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Status</TerminalTableCell>
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Execution Time</TerminalTableCell>
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Duration</TerminalTableCell>
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>User ID</TerminalTableCell>
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Date</TerminalTableCell>
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Error</TerminalTableCell>
</TerminalTableRow>
</TableHead>
<TableBody>
{(() => {
// Debug logging
if (logs.length > 0) {
console.log('🔍 Rendering logs table:', {
logsCount: logs.length,
loading,
isShowingSchedulerLogs,
firstLogId: logs[0]?.id,
firstLogStatus: logs[0]?.status
});
}
return null;
})()}
{logs.length === 0 && !loading ? (
<TerminalTableRow>
<TerminalTableCell colSpan={7} align="center">
<Box sx={{ py: 4, textAlign: 'center' }}>
<ScheduleIcon sx={{ color: terminalColors.textSecondary, fontSize: 48, mb: 2, opacity: 0.5 }} />
<TerminalTypography variant="body2" sx={{ color: terminalColors.primary, mb: 1, fontWeight: 'bold' }}>
{isShowingSchedulerLogs ? 'No Scheduler Logs Yet' : 'No Execution Logs Yet'}
</TerminalTypography>
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary, mb: 1 }}>
{isShowingSchedulerLogs
? 'Scheduler activity logs (job scheduling, restoration, etc.) will appear here when the scheduler starts or schedules jobs.'
: 'Execution logs will appear here once the scheduler runs and executes tasks.'}
</TerminalTypography>
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem', fontStyle: 'italic', display: 'block' }}>
{isShowingSchedulerLogs
? 'These logs show scheduler activity (job restoration, scheduling) when actual task execution logs are not available.'
: 'The scheduler checks for due tasks every 60 minutes (or based on active strategies).'}
{!isShowingSchedulerLogs && totalCount === 0 && ' Currently, no tasks have been executed yet.'}
</TerminalTypography>
</Box>
</TerminalTableCell>
</TerminalTableRow>
) : loading ? (
<TerminalTableRow>
<TerminalTableCell colSpan={7} align="center">
<Box sx={{ py: 3, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 2 }}>
<CircularProgress size={24} sx={{ color: terminalColors.primary }} />
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
Loading execution logs...
</TerminalTypography>
</Box>
</TerminalTableCell>
</TerminalTableRow>
) : (
logs.map((log) => {
// Debug: log each row being rendered
if (log.id === logs[0]?.id) {
console.log('🎯 Rendering first log row:', log.id, log.status, log.task?.task_title);
}
return (
<TerminalTableRow
key={log.id}
sx={{
backgroundColor: terminalColors.background,
color: terminalColors.primary,
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.1)',
}
}}
>
<TerminalTableCell sx={{ color: terminalColors.primary }}>
<Box>
<TerminalTypography variant="body2" fontWeight="medium" sx={{ fontSize: '0.875rem' }}>
{log.is_scheduler_log
? (log.task?.task_title || `Scheduler Event: ${log.event_type || 'unknown'}`)
: (log.task?.task_title || `Task #${log.task_id}`)
}
</TerminalTypography>
{log.is_scheduler_log && log.job_id && (
<TerminalTypography variant="caption" sx={{ fontSize: '0.7rem', color: terminalColors.textSecondary, display: 'block', mt: 0.5 }}>
Job ID: {log.job_id}
</TerminalTypography>
)}
{!log.is_scheduler_log && log.task?.component_name && (
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
{log.task.component_name}
</TerminalTypography>
)}
{log.is_scheduler_log && log.task?.metric && (
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
Function: {log.task.metric}
</TerminalTypography>
)}
</Box>
</TerminalTableCell>
<TerminalTableCell sx={{ color: terminalColors.primary }}>
{log.status === 'success' ? (
<TerminalChipSuccess
icon={getStatusIcon(log.status)}
label={log.status}
size="small"
/>
) : log.status === 'failed' ? (
<TerminalChipError
icon={getStatusIcon(log.status)}
label={log.status}
size="small"
/>
) : (
<TerminalChipWarning
icon={getStatusIcon(log.status)}
label={log.status}
size="small"
/>
)}
</TerminalTableCell>
<TerminalTableCell sx={{ color: terminalColors.primary }}>
<TerminalTypography variant="body2" sx={{ fontSize: '0.875rem', color: terminalColors.primary }}>
{formatExecutionTime(log.execution_time_ms)}
</TerminalTypography>
</TerminalTableCell>
<TerminalTableCell sx={{ color: terminalColors.primary }}>
<TerminalTypography variant="body2" sx={{ fontSize: '0.875rem', color: terminalColors.primary }}>
{log.execution_date ? formatDate(log.execution_date) : 'N/A'}
</TerminalTypography>
</TerminalTableCell>
<TerminalTableCell sx={{ color: terminalColors.primary }}>
{log.user_id ? (
<TerminalTypography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.875rem', color: terminalColors.primary }}>
{String(log.user_id).substring(0, 12)}...
</TerminalTypography>
) : (
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }}>
System
</TerminalTypography>
)}
</TerminalTableCell>
<TerminalTableCell sx={{ color: terminalColors.primary }}>
<TerminalTypography variant="body2" sx={{ fontSize: '0.875rem', color: terminalColors.primary }}>
{formatDate(log.created_at)}
</TerminalTypography>
</TerminalTableCell>
<TerminalTableCell sx={{ color: terminalColors.primary }}>
{log.error_message ? (
<Tooltip title={log.error_message} arrow>
<TerminalTypography
variant="body2"
sx={{
fontSize: '0.875rem',
color: terminalColors.error,
maxWidth: 300,
wordBreak: 'break-word',
wordWrap: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
cursor: 'help'
}}
>
{log.error_message}
</TerminalTypography>
</Tooltip>
) : (
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }}>
-
</TerminalTypography>
)}
</TerminalTableCell>
</TerminalTableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
{/* Only show pagination for actual execution logs, not scheduler logs */}
{!isShowingSchedulerLogs && logs.length > 0 && (
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 25, 50, 100]}
sx={{
color: terminalColors.primary,
'& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': {
color: terminalColors.textSecondary,
fontFamily: 'monospace',
},
'& .MuiTablePagination-select': {
color: terminalColors.primary,
fontFamily: 'monospace',
},
'& .MuiIconButton-root': {
color: terminalColors.primary,
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.1)',
}
},
'& .MuiIconButton-root.Mui-disabled': {
color: terminalColors.textSecondary,
opacity: 0.3,
}
}}
/>
)}
{/* Info message for scheduler logs */}
{isShowingSchedulerLogs && logs.length > 0 && (
<Box mt={2}>
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem', fontStyle: 'italic' }}>
Displaying latest 5 scheduler activity logs. Only the most recent logs are shown here.
</TerminalTypography>
</Box>
)}
</>
)}
</TerminalPaper>
);
};
export default ExecutionLogsTable;

View File

@@ -0,0 +1,297 @@
/**
* Failures & Insights Component
* Displays recent failures, error messages, and scheduler insights.
*/
import React, { useState, useEffect } from 'react';
import {
Box,
List,
ListItem,
ListItemIcon,
ListItemText,
AccordionSummary,
AccordionDetails,
Divider,
CircularProgress
} from '@mui/material';
import {
Error as ErrorIcon,
Warning as WarningIcon,
Info as InfoIcon,
ExpandMore as ExpandMoreIcon,
CheckCircle as CheckCircleIcon
} from '@mui/icons-material';
import { getExecutionLogs, getRecentSchedulerLogs, ExecutionLog } from '../../api/schedulerDashboard';
import { SchedulerStats } from '../../api/schedulerDashboard';
import {
TerminalPaper,
TerminalTypography,
TerminalAlert,
TerminalAccordion,
terminalColors
} from './terminalTheme';
interface FailuresInsightsProps {
stats: SchedulerStats;
}
const FailuresInsights: React.FC<FailuresInsightsProps> = ({ stats }) => {
const [recentFailures, setRecentFailures] = useState<ExecutionLog[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchFailures = async () => {
try {
setLoading(true);
// First try to get execution logs with failed status
const executionLogsResponse = await getExecutionLogs(10, 0, 'failed');
// Also get scheduler logs (which include job_failed events)
const schedulerLogsResponse = await getRecentSchedulerLogs();
// Combine both, filtering for failed status
const allFailures: ExecutionLog[] = [
...executionLogsResponse.logs.filter(log => log.status === 'failed'),
...(schedulerLogsResponse.logs || []).filter(log => log.status === 'failed')
];
// Sort by execution_date descending (most recent first) and limit to 10
allFailures.sort((a, b) => {
const dateA = new Date(a.execution_date).getTime();
const dateB = new Date(b.execution_date).getTime();
return dateB - dateA;
});
setRecentFailures(allFailures.slice(0, 10));
} catch (err: any) {
setError(err.message || 'Failed to fetch failures');
console.error('Error fetching failures:', err);
} finally {
setLoading(false);
}
};
fetchFailures();
}, []);
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleString();
} catch {
return dateString;
}
};
// Generate insights based on stats
const generateInsights = () => {
const insights: Array<{ type: 'info' | 'warning' | 'error' | 'success'; message: string }> = [];
// Scheduler status insight
if (!stats.running) {
insights.push({
type: 'error',
message: 'Scheduler is stopped. Tasks will not be executed until scheduler is restarted.'
});
} else {
insights.push({
type: 'success',
message: 'Scheduler is running and processing tasks normally.'
});
}
// Active strategies insight
if (stats.active_strategies_count === 0) {
insights.push({
type: 'info',
message: `No active strategies detected. Using ${stats.max_check_interval_minutes}min check interval (idle mode).`
});
} else {
insights.push({
type: 'info',
message: `${stats.active_strategies_count} active strategy(ies) with monitoring tasks. Using ${stats.min_check_interval_minutes}min check interval.`
});
}
// Failure rate insight
const totalExecutions = stats.tasks_executed + stats.tasks_failed;
if (totalExecutions > 0) {
const failureRate = (stats.tasks_failed / totalExecutions) * 100;
if (failureRate > 20) {
insights.push({
type: 'error',
message: `High failure rate: ${failureRate.toFixed(1)}% of tasks are failing. Review error logs for details.`
});
} else if (failureRate > 10) {
insights.push({
type: 'warning',
message: `Moderate failure rate: ${failureRate.toFixed(1)}% of tasks are failing. Monitor for patterns.`
});
} else if (stats.tasks_failed > 0) {
insights.push({
type: 'info',
message: `Low failure rate: ${failureRate.toFixed(1)}% of tasks are failing. System is healthy.`
});
}
}
// Check interval insight
if (stats.intelligent_scheduling) {
insights.push({
type: 'success',
message: `Intelligent scheduling enabled. Interval automatically adjusts based on active strategies (${stats.min_check_interval_minutes}-${stats.max_check_interval_minutes}min range).`
});
}
// Last check insight
if (stats.last_check) {
try {
const lastCheck = new Date(stats.last_check);
const now = new Date();
const diffMins = Math.floor((now.getTime() - lastCheck.getTime()) / 60000);
if (diffMins > stats.check_interval_minutes * 2) {
insights.push({
type: 'warning',
message: `Last check was ${diffMins} minutes ago. Expected interval is ${stats.check_interval_minutes} minutes. Scheduler may be delayed.`
});
}
} catch {
// Ignore date parsing errors
}
}
return insights;
};
const insights = generateInsights();
return (
<TerminalPaper sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box display="flex" alignItems="center" gap={1} mb={2}>
<InfoIcon sx={{ color: terminalColors.primary }} />
<TerminalTypography variant="h6" component="h2" sx={{ fontSize: '1.25rem', fontWeight: 'bold' }}>
Failures & Insights
</TerminalTypography>
</Box>
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Recent Failures */}
<Box mb={3} sx={{ flexShrink: 0 }}>
<TerminalTypography variant="subtitle1" fontWeight="medium" mb={1} sx={{ fontSize: '1rem', color: terminalColors.textSecondary }}>
Recent Failures ({recentFailures.length})
</TerminalTypography>
{loading ? (
<Box display="flex" justifyContent="center" p={2}>
<CircularProgress size={24} sx={{ color: terminalColors.primary }} />
</Box>
) : error ? (
<TerminalAlert severity="error">{error}</TerminalAlert>
) : recentFailures.length === 0 ? (
<TerminalAlert severity="success" icon={<CheckCircleIcon />}>
No recent failures. All tasks are executing successfully.
</TerminalAlert>
) : (
<List>
{recentFailures.map((log, index) => (
<React.Fragment key={log.id}>
<TerminalAccordion>
<AccordionSummary
expandIcon={<ExpandMoreIcon sx={{ color: terminalColors.primary }} />}
sx={{
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.05)',
}
}}
>
<Box display="flex" alignItems="center" gap={1} width="100%">
<ErrorIcon sx={{ color: terminalColors.error }} fontSize="small" />
<TerminalTypography variant="body2" sx={{ flexGrow: 1, fontSize: '0.875rem' }}>
{log.task?.task_title || `Task #${log.task_id}`}
</TerminalTypography>
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
{formatDate(log.execution_date)}
</TerminalTypography>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ backgroundColor: terminalColors.background }}>
<Box>
<TerminalTypography variant="body2" gutterBottom sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }}>
<strong style={{ color: terminalColors.primary }}>Component:</strong> {log.task?.component_name || 'Unknown'}
</TerminalTypography>
{log.error_message && (
<Box sx={{ mt: 1, p: 1, border: `1px solid ${terminalColors.error}`, borderRadius: 1, backgroundColor: terminalColors.backgroundLight }}>
<TerminalTypography variant="body2" fontWeight="bold" gutterBottom sx={{ color: terminalColors.error, fontSize: '0.875rem' }}>
Error Message
</TerminalTypography>
<TerminalTypography variant="body2" sx={{ color: terminalColors.error, fontSize: '0.875rem', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
{log.error_message}
</TerminalTypography>
</Box>
)}
{log.execution_time_ms && (
<TerminalTypography variant="caption" sx={{ mt: 1, display: 'block', color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
Execution time: {log.execution_time_ms}ms
</TerminalTypography>
)}
{log.user_id && (
<TerminalTypography variant="caption" sx={{ mt: 0.5, display: 'block', color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
User ID: {log.user_id}
</TerminalTypography>
)}
</Box>
</AccordionDetails>
</TerminalAccordion>
{index < recentFailures.length - 1 && <Divider sx={{ borderColor: terminalColors.border }} />}
</React.Fragment>
))}
</List>
)}
</Box>
<Divider sx={{ my: 3, borderColor: terminalColors.border, flexShrink: 0 }} />
{/* Scheduler Insights */}
<Box sx={{ flex: 1, overflow: 'auto', minHeight: 0, flexShrink: 1 }}>
<TerminalTypography variant="subtitle1" fontWeight="medium" mb={1} sx={{ fontSize: '1rem', color: terminalColors.textSecondary }}>
Scheduler Insights
</TerminalTypography>
<List>
{insights.map((insight, index) => (
<React.Fragment key={index}>
<ListItem>
<ListItemIcon>
{insight.type === 'error' && <ErrorIcon sx={{ color: terminalColors.error }} />}
{insight.type === 'warning' && <WarningIcon sx={{ color: terminalColors.warning }} />}
{insight.type === 'info' && <InfoIcon sx={{ color: terminalColors.info }} />}
{insight.type === 'success' && <CheckCircleIcon sx={{ color: terminalColors.success }} />}
</ListItemIcon>
<ListItemText
primary={
<TerminalTypography
variant="body2"
sx={{
fontSize: '0.875rem',
color: insight.type === 'error' ? terminalColors.error : terminalColors.text
}}
>
{insight.message}
</TerminalTypography>
}
/>
</ListItem>
{index < insights.length - 1 && <Divider component="li" sx={{ borderColor: terminalColors.border }} />}
</React.Fragment>
))}
</List>
</Box>
</Box>
</TerminalPaper>
);
};
export default FailuresInsights;

View File

@@ -0,0 +1,364 @@
/**
* OAuth Token Status Component
* Compact terminal-themed component for displaying OAuth token monitoring status
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
IconButton,
Tooltip,
CircularProgress,
Collapse,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from '@mui/material';
import {
RefreshCw,
CheckCircle,
XCircle,
AlertTriangle,
Info,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { useAuth } from '@clerk/clerk-react';
import {
getOAuthTokenStatus,
manualRefreshToken,
OAuthTokenStatusResponse,
ManualRefreshResponse,
} from '../../api/oauthTokenMonitoring';
import {
TerminalPaper,
TerminalTypography,
TerminalChip,
TerminalChipSuccess,
TerminalChipError,
TerminalChipWarning,
TerminalAlert,
terminalColors,
} from './terminalTheme';
interface OAuthTokenStatusProps {
compact?: boolean;
}
const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) => {
const { userId } = useAuth();
const [status, setStatus] = useState<OAuthTokenStatusResponse | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [expandedPlatform, setExpandedPlatform] = useState<string | null>(null);
const fetchStatus = async () => {
if (!userId) return;
try {
setLoading(true);
setError(null);
const response = await getOAuthTokenStatus(userId);
setStatus(response);
} catch (err: any) {
setError(err.message || 'Failed to fetch token status');
console.error('Error fetching OAuth token status:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStatus();
// Poll for status updates every 2 minutes
const interval = setInterval(fetchStatus, 120000);
return () => clearInterval(interval);
}, [userId]);
const handleRefresh = async (platform: string) => {
if (!userId) return;
try {
setRefreshing(platform);
setError(null);
const response: ManualRefreshResponse = await manualRefreshToken(userId, platform);
// Refresh status after manual refresh
await fetchStatus();
if (response.success) {
console.log(`Token refresh successful for ${platform}`);
} else {
console.error(`Token refresh failed for ${platform}:`, response.data.execution_result.error_message);
}
} catch (err: any) {
setError(err.message || `Failed to refresh ${platform} token`);
console.error(`Error refreshing ${platform} token:`, err);
} finally {
setRefreshing(null);
}
};
const getStatusIcon = (taskStatus: string | null, connected: boolean) => {
if (!connected) {
return <XCircle size={16} color={terminalColors.error} />;
}
if (!taskStatus || taskStatus === 'not_created') {
return <Info size={16} color={terminalColors.info} />;
}
switch (taskStatus) {
case 'active':
return <CheckCircle size={16} color={terminalColors.success} />;
case 'failed':
return <XCircle size={16} color={terminalColors.error} />;
case 'paused':
return <AlertTriangle size={16} color={terminalColors.warning} />;
default:
return <Info size={16} color={terminalColors.primary} />;
}
};
const getStatusChip = (taskStatus: string | null, connected: boolean) => {
if (!connected) {
return <TerminalChipError label="Not Connected" size="small" />;
}
if (!taskStatus || taskStatus === 'not_created') {
return <TerminalChip label={taskStatus || 'Not Created'} size="small" />;
}
switch (taskStatus) {
case 'active':
return <TerminalChipSuccess label="Active" size="small" />;
case 'failed':
return <TerminalChipError label="Failed" size="small" />;
case 'paused':
return <TerminalChipWarning label="Paused" size="small" />;
default:
return <TerminalChip label={taskStatus} size="small" />;
}
};
const formatDate = (dateString: string | null) => {
if (!dateString) return 'Never';
try {
const date = new Date(dateString);
return date.toLocaleString();
} catch {
return dateString;
}
};
const getPlatformDisplayName = (platform: string) => {
const names: { [key: string]: string } = {
gsc: 'GSC',
bing: 'Bing',
wordpress: 'WP',
wix: 'Wix',
};
return names[platform] || platform.toUpperCase();
};
if (loading && !status) {
return (
<TerminalPaper sx={{ p: 2 }}>
<Box display="flex" justifyContent="center" alignItems="center" p={2}>
<CircularProgress size={20} sx={{ color: terminalColors.primary }} />
</Box>
</TerminalPaper>
);
}
if (!status) {
return null;
}
const platforms = ['gsc', 'bing', 'wordpress', 'wix'];
return (
<TerminalPaper sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<TerminalTypography variant="h6" component="h3">
OAuth Token Status
</TerminalTypography>
<Tooltip title="Refresh status">
<IconButton
size="small"
onClick={fetchStatus}
disabled={loading}
sx={{
color: terminalColors.primary,
border: `1px solid ${terminalColors.primary}`,
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.1)',
},
'&:disabled': {
color: '#004400',
borderColor: '#004400',
}
}}
>
<RefreshCw size={16} />
</IconButton>
</Tooltip>
</Box>
{error && (
<TerminalAlert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</TerminalAlert>
)}
<Box sx={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
<Table size="small" sx={{ '& .MuiTableCell-root': { color: terminalColors.primary, borderColor: terminalColors.primary + '40' } }}>
<TableHead>
<TableRow>
<TableCell>Platform</TableCell>
<TableCell>Status</TableCell>
<TableCell>Last Check</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{platforms.map((platform) => {
const platformStatus = status.data.platform_status[platform];
const task = platformStatus?.monitoring_task;
const isExpanded = expandedPlatform === platform;
return (
<React.Fragment key={platform}>
<TableRow
sx={{
cursor: 'pointer',
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.05)',
}
}}
>
<TableCell>
<Box display="flex" alignItems="center" gap={1}>
{getStatusIcon(task?.status || null, platformStatus?.connected || false)}
<TerminalTypography variant="body2" fontWeight="medium">
{getPlatformDisplayName(platform)}
</TerminalTypography>
</Box>
</TableCell>
<TableCell>
{getStatusChip(task?.status || null, platformStatus?.connected || false)}
</TableCell>
<TableCell>
<TerminalTypography variant="caption" color={terminalColors.textSecondary}>
{formatDate(task?.last_check || null)}
</TerminalTypography>
</TableCell>
<TableCell align="right">
<Box display="flex" gap={0.5} justifyContent="flex-end">
<Tooltip title={isExpanded ? "Hide details" : "Show details"}>
<IconButton
size="small"
onClick={() => setExpandedPlatform(isExpanded ? null : platform)}
sx={{
color: terminalColors.primary,
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.1)',
}
}}
>
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</IconButton>
</Tooltip>
{platformStatus?.connected && (
<Tooltip title="Manually refresh token">
<IconButton
size="small"
onClick={() => handleRefresh(platform)}
disabled={refreshing === platform}
sx={{
color: terminalColors.primary,
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.1)',
},
'&:disabled': {
color: '#004400',
}
}}
>
{refreshing === platform ? (
<CircularProgress size={14} sx={{ color: terminalColors.primary }} />
) : (
<RefreshCw size={14} />
)}
</IconButton>
</Tooltip>
)}
</Box>
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={4} sx={{ py: 0, border: 0 }}>
<Collapse in={isExpanded}>
<Box p={2} sx={{ backgroundColor: 'rgba(0, 255, 0, 0.05)', borderLeft: `2px solid ${terminalColors.primary}` }}>
{task?.failure_reason && (
<TerminalAlert severity="error" sx={{ mb: 1 }}>
<TerminalTypography variant="body2" fontWeight="bold">
Last Failure:
</TerminalTypography>
<TerminalTypography variant="body2">
{task.failure_reason}
</TerminalTypography>
<TerminalTypography variant="caption" color={terminalColors.textSecondary}>
{formatDate(task.last_failure || null)}
</TerminalTypography>
</TerminalAlert>
)}
{task?.last_success && (
<TerminalAlert severity="success" sx={{ mb: 1 }}>
<TerminalTypography variant="body2">
Last successful: {formatDate(task.last_success)}
</TerminalTypography>
</TerminalAlert>
)}
{task?.next_check && (
<Box mt={1}>
<TerminalTypography variant="caption" color={terminalColors.textSecondary}>
Next check: {formatDate(task.next_check)}
</TerminalTypography>
</Box>
)}
{!task && platformStatus?.connected && (
<TerminalAlert severity="info">
<TerminalTypography variant="body2">
Connected but no monitoring task. Create one manually or wait for onboarding completion.
</TerminalTypography>
</TerminalAlert>
)}
{!platformStatus?.connected && (
<TerminalAlert severity="warning">
<TerminalTypography variant="body2">
Not connected. Connect in onboarding step 5.
</TerminalTypography>
</TerminalAlert>
)}
</Box>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
})}
</TableBody>
</Table>
</Box>
</TerminalPaper>
);
};
export default OAuthTokenStatus;

View File

@@ -0,0 +1,385 @@
/**
* Scheduler Charts Component
* Visualizes scheduler event history data using Recharts
*/
import React, { useMemo, useState, useEffect } from 'react';
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer
} from 'recharts';
import { Box, Paper, CircularProgress } from '@mui/material';
import { TerminalTypography, TerminalPaper, terminalColors } from './terminalTheme';
import { getSchedulerEventHistory, SchedulerEvent } from '../../api/schedulerDashboard';
interface SchedulerChartsProps {
// Optional: can receive events as prop or fetch them internally
events?: SchedulerEvent[];
}
const SchedulerCharts: React.FC<SchedulerChartsProps> = ({ events: propEvents }) => {
const [events, setEvents] = useState<SchedulerEvent[]>(propEvents || []);
const [loading, setLoading] = useState(!propEvents);
const [error, setError] = useState<string | null>(null);
// Fetch events if not provided as prop
useEffect(() => {
if (!propEvents) {
const fetchEvents = async () => {
try {
setLoading(true);
setError(null);
// Fetch all events for visualization (no pagination limit)
// Pass undefined to get all event types
console.log('📊 Charts - Fetching event history...');
const response = await getSchedulerEventHistory(1000, 0, undefined);
console.log('📊 Charts - Fetched events:', {
totalEvents: response.events?.length || 0,
totalCount: response.total_count,
hasEvents: !!(response.events && response.events.length > 0),
sampleEvent: response.events?.[0]
});
setEvents(response.events || []);
} catch (err: any) {
console.error('❌ Charts - Error fetching events:', err);
console.error('❌ Charts - Error details:', {
message: err?.message,
response: err?.response,
responseData: err?.response?.data,
stack: err?.stack
});
const errorMessage = err?.response?.data?.detail || err?.response?.data?.message || err?.message || String(err) || 'Failed to fetch event history';
setError(errorMessage);
} finally {
setLoading(false);
}
};
fetchEvents();
}
}, [propEvents]);
// Process events for charting
const chartData = useMemo(() => {
if (!events || events.length === 0) return [];
// Group events by date (day)
const eventsByDate: Record<string, {
date: string;
check_cycles: number;
tasks_found: number;
tasks_executed: number;
tasks_failed: number;
job_scheduled: number;
job_completed: number;
job_failed: number;
}> = {};
events.forEach(event => {
const date = event.event_date ? new Date(event.event_date).toLocaleDateString() : 'Unknown';
if (!eventsByDate[date]) {
eventsByDate[date] = {
date,
check_cycles: 0,
tasks_found: 0,
tasks_executed: 0,
tasks_failed: 0,
job_scheduled: 0,
job_completed: 0,
job_failed: 0,
};
}
switch (event.event_type) {
case 'check_cycle':
eventsByDate[date].check_cycles++;
eventsByDate[date].tasks_found += event.tasks_found || 0;
eventsByDate[date].tasks_executed += event.tasks_executed || 0;
eventsByDate[date].tasks_failed += event.tasks_failed || 0;
break;
case 'job_scheduled':
eventsByDate[date].job_scheduled++;
break;
case 'job_completed':
eventsByDate[date].job_completed++;
break;
case 'job_failed':
eventsByDate[date].job_failed++;
break;
}
});
// Convert to array and sort by date
return Object.values(eventsByDate).sort((a, b) => {
return new Date(a.date).getTime() - new Date(b.date).getTime();
}).slice(-30); // Last 30 days
}, [events]);
// Calculate totals for summary
const totals = useMemo(() => {
return events.reduce((acc, event) => {
switch (event.event_type) {
case 'check_cycle':
acc.check_cycles++;
acc.tasks_found += event.tasks_found || 0;
acc.tasks_executed += event.tasks_executed || 0;
acc.tasks_failed += event.tasks_failed || 0;
break;
case 'job_scheduled':
acc.job_scheduled++;
break;
case 'job_completed':
acc.job_completed++;
break;
case 'job_failed':
acc.job_failed++;
break;
}
return acc;
}, {
check_cycles: 0,
tasks_found: 0,
tasks_executed: 0,
tasks_failed: 0,
job_scheduled: 0,
job_completed: 0,
job_failed: 0,
});
}, [events]);
// Custom tooltip with terminal theme
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<Paper
sx={{
backgroundColor: terminalColors.background,
border: `1px solid ${terminalColors.primary}`,
padding: 1,
fontFamily: 'monospace'
}}
>
<TerminalTypography variant="body2" sx={{ color: terminalColors.primary, fontWeight: 'bold', mb: 0.5 }}>
{label}
</TerminalTypography>
{payload.map((entry: any, index: number) => (
<TerminalTypography
key={index}
variant="body2"
sx={{ color: entry.color, fontSize: '0.75rem' }}
>
{entry.name}: {entry.value}
</TerminalTypography>
))}
</Paper>
);
}
return null;
};
if (loading) {
return (
<TerminalPaper sx={{ p: 3, textAlign: 'center' }}>
<CircularProgress sx={{ color: terminalColors.primary, mb: 2 }} />
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
Loading chart data...
</TerminalTypography>
</TerminalPaper>
);
}
if (error) {
return (
<TerminalPaper sx={{ p: 3, textAlign: 'center' }}>
<TerminalTypography variant="body2" sx={{ color: terminalColors.error }}>
Error loading charts: {error}
</TerminalTypography>
</TerminalPaper>
);
}
if (events.length === 0) {
return (
<TerminalPaper sx={{ p: 3, textAlign: 'center' }}>
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
No event history data available yet. Charts will appear once scheduler events are logged.
</TerminalTypography>
</TerminalPaper>
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Summary Stats */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 2 }}>
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
<TerminalTypography variant="h6" sx={{ color: terminalColors.primary, fontSize: '1.5rem' }}>
{totals.check_cycles}
</TerminalTypography>
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
Check Cycles
</TerminalTypography>
</TerminalPaper>
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
<TerminalTypography variant="h6" sx={{ color: terminalColors.primary, fontSize: '1.5rem' }}>
{totals.tasks_executed}
</TerminalTypography>
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
Tasks Executed
</TerminalTypography>
</TerminalPaper>
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
<TerminalTypography variant="h6" sx={{ color: terminalColors.error, fontSize: '1.5rem' }}>
{totals.tasks_failed}
</TerminalTypography>
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
Tasks Failed
</TerminalTypography>
</TerminalPaper>
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
<TerminalTypography variant="h6" sx={{ color: terminalColors.primary, fontSize: '1.5rem' }}>
{totals.job_completed}
</TerminalTypography>
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
Jobs Completed
</TerminalTypography>
</TerminalPaper>
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
<TerminalTypography variant="h6" sx={{ color: terminalColors.error, fontSize: '1.5rem' }}>
{totals.job_failed}
</TerminalTypography>
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
Jobs Failed
</TerminalTypography>
</TerminalPaper>
</Box>
{/* Task Execution Trends */}
<TerminalPaper sx={{ p: 3 }}>
<TerminalTypography variant="h6" sx={{ mb: 2, color: terminalColors.primary }}>
Task Execution Trends (Last 30 Days)
</TerminalTypography>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
<XAxis
dataKey="date"
stroke={terminalColors.primary}
tick={{ fill: terminalColors.primary, fontSize: 12 }}
/>
<YAxis
stroke={terminalColors.primary}
tick={{ fill: terminalColors.primary, fontSize: 12 }}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{ color: terminalColors.primary, fontFamily: 'monospace' }}
/>
<Line
type="monotone"
dataKey="tasks_found"
stroke={terminalColors.info}
strokeWidth={2}
name="Tasks Found"
dot={{ fill: terminalColors.info, r: 4 }}
/>
<Line
type="monotone"
dataKey="tasks_executed"
stroke={terminalColors.success}
strokeWidth={2}
name="Tasks Executed"
dot={{ fill: terminalColors.success, r: 4 }}
/>
<Line
type="monotone"
dataKey="tasks_failed"
stroke={terminalColors.error}
strokeWidth={2}
name="Tasks Failed"
dot={{ fill: terminalColors.error, r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
</TerminalPaper>
{/* Job Status Distribution */}
<TerminalPaper sx={{ p: 3 }}>
<TerminalTypography variant="h6" sx={{ mb: 2, color: terminalColors.primary }}>
Job Status Distribution (Last 30 Days)
</TerminalTypography>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
<XAxis
dataKey="date"
stroke={terminalColors.primary}
tick={{ fill: terminalColors.primary, fontSize: 12 }}
/>
<YAxis
stroke={terminalColors.primary}
tick={{ fill: terminalColors.primary, fontSize: 12 }}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{ color: terminalColors.primary, fontFamily: 'monospace' }}
/>
<Bar
dataKey="job_scheduled"
fill={terminalColors.info}
name="Scheduled"
/>
<Bar
dataKey="job_completed"
fill={terminalColors.success}
name="Completed"
/>
<Bar
dataKey="job_failed"
fill={terminalColors.error}
name="Failed"
/>
</BarChart>
</ResponsiveContainer>
</TerminalPaper>
{/* Check Cycles Over Time */}
<TerminalPaper sx={{ p: 3 }}>
<TerminalTypography variant="h6" sx={{ mb: 2, color: terminalColors.primary }}>
Check Cycles Over Time (Last 30 Days)
</TerminalTypography>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
<XAxis
dataKey="date"
stroke={terminalColors.primary}
tick={{ fill: terminalColors.primary, fontSize: 12 }}
/>
<YAxis
stroke={terminalColors.primary}
tick={{ fill: terminalColors.primary, fontSize: 12 }}
/>
<Tooltip content={<CustomTooltip />} />
<Bar
dataKey="check_cycles"
fill={terminalColors.primary}
name="Check Cycles"
/>
</BarChart>
</ResponsiveContainer>
</TerminalPaper>
</Box>
);
};
export default SchedulerCharts;

View File

@@ -0,0 +1,313 @@
/**
* Scheduler Event History Component
* Displays historical scheduler events (check cycles, interval adjustments, etc.)
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
Chip,
Typography,
Select,
MenuItem,
FormControl,
InputLabel,
Tooltip
} from '@mui/material';
import {
TerminalPaper,
TerminalTypography,
TerminalChipSuccess,
TerminalChipError,
TerminalChipWarning,
TerminalTableCell,
TerminalTableRow,
terminalColors
} from './terminalTheme';
import { getSchedulerEventHistory, SchedulerEvent } from '../../api/schedulerDashboard';
interface SchedulerEventHistoryProps {
limit?: number;
}
const SchedulerEventHistory: React.FC<SchedulerEventHistoryProps> = ({ limit = 50 }) => {
const [events, setEvents] = useState<SchedulerEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(limit);
const [totalCount, setTotalCount] = useState(0);
const [eventTypeFilter, setEventTypeFilter] = useState<string>('all');
const fetchEvents = async () => {
try {
setLoading(true);
setError(null);
const response = await getSchedulerEventHistory(
rowsPerPage,
page * rowsPerPage,
eventTypeFilter !== 'all' ? eventTypeFilter as any : undefined
);
setEvents(response.events);
setTotalCount(response.total_count);
} catch (err: any) {
setError(err.message || 'Failed to fetch scheduler event history');
console.error('Error fetching scheduler event history:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchEvents();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, rowsPerPage, eventTypeFilter]); // fetchEvents is stable, no need to include
const handleChangePage = (_event: unknown, newPage: number) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
const getEventTypeColor = (eventType: string) => {
switch (eventType) {
case 'check_cycle':
return terminalColors.success;
case 'interval_adjustment':
return terminalColors.warning;
case 'start':
return terminalColors.success;
case 'stop':
return terminalColors.error;
case 'job_scheduled':
return terminalColors.info;
case 'job_completed':
return terminalColors.success;
case 'job_failed':
return terminalColors.error;
default:
return terminalColors.info;
}
};
const formatEventDetails = (event: SchedulerEvent): string => {
switch (event.event_type) {
case 'check_cycle':
return `Cycle #${event.check_cycle_number || 'N/A'} | ${event.tasks_found || 0} found, ${event.tasks_executed || 0} executed, ${event.tasks_failed || 0} failed | ${event.check_duration_seconds?.toFixed(2) || 'N/A'}s`;
case 'interval_adjustment':
return `${event.previous_interval_minutes || 'N/A'}min → ${event.new_interval_minutes || 'N/A'}min | ${event.active_strategies_count || 0} active strategies`;
case 'start':
return `Started with ${event.check_interval_minutes || 'N/A'}min interval | ${event.active_strategies_count || 0} active strategies`;
case 'stop':
return `Stopped gracefully | ${event.event_data?.total_checks || 0} total cycles`;
case 'job_scheduled':
const scheduledJob = event.event_data as any;
return `Job: ${event.job_id || 'N/A'} | Function: ${scheduledJob?.function_name || 'N/A'} | User: ${event.user_id || 'system'}`;
case 'job_completed':
const completedJob = event.event_data as any;
return `Job: ${event.job_id || 'N/A'} | Function: ${completedJob?.job_function || 'N/A'} | User: ${event.user_id || 'system'} | Time: ${completedJob?.execution_time_seconds?.toFixed(2) || 'N/A'}s`;
case 'job_failed':
const failedJob = event.event_data as any;
const expensive = failedJob?.expensive_api_call ? '💰 Expensive API call wasted' : '';
const errorMsg = event.error_message || failedJob?.exception_message || 'Unknown error';
return `Job: ${event.job_id || 'N/A'} | Function: ${failedJob?.job_function || 'N/A'} | User: ${event.user_id || 'system'} | Error: ${errorMsg}${expensive ? ` | ${expensive}` : ''}`;
default:
return JSON.stringify(event.event_data || {});
}
};
const formatDate = (dateString: string | null) => {
if (!dateString) return 'N/A';
try {
const date = new Date(dateString);
return date.toLocaleString();
} catch {
return dateString;
}
};
if (loading && events.length === 0) {
return (
<TerminalPaper>
<Box p={3}>
<TerminalTypography variant="h6" gutterBottom>
📜 Scheduler Event History
</TerminalTypography>
<TerminalTypography variant="body2" sx={{ color: terminalColors.info }}>
Loading event history...
</TerminalTypography>
</Box>
</TerminalPaper>
);
}
if (error) {
return (
<TerminalPaper>
<Box p={3}>
<TerminalTypography variant="h6" gutterBottom>
📜 Scheduler Event History
</TerminalTypography>
<TerminalTypography variant="body2" sx={{ color: terminalColors.error }}>
Error: {error}
</TerminalTypography>
</Box>
</TerminalPaper>
);
}
return (
<TerminalPaper>
<Box p={2}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<TerminalTypography variant="h6">
📜 Scheduler Event History
</TerminalTypography>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel sx={{ color: terminalColors.primary }}>Event Type</InputLabel>
<Select
value={eventTypeFilter}
onChange={(e) => {
setEventTypeFilter(e.target.value);
setPage(0);
}}
sx={{
color: terminalColors.primary,
'& .MuiOutlinedInput-notchedOutline': {
borderColor: terminalColors.primary,
},
'& .MuiSvgIcon-root': {
color: terminalColors.primary,
}
}}
>
<MenuItem value="all">All Events</MenuItem>
<MenuItem value="check_cycle">Check Cycles</MenuItem>
<MenuItem value="interval_adjustment">Interval Adjustments</MenuItem>
<MenuItem value="start">Scheduler Start</MenuItem>
<MenuItem value="stop">Scheduler Stop</MenuItem>
<MenuItem value="job_scheduled">Job Scheduled</MenuItem>
<MenuItem value="job_completed">Job Completed</MenuItem>
<MenuItem value="job_failed">Job Failed</MenuItem>
</Select>
</FormControl>
</Box>
{events.length === 0 ? (
<Box p={3} textAlign="center">
<TerminalTypography variant="body2" sx={{ color: terminalColors.info }}>
No scheduler events found. Events will appear here as the scheduler runs.
</TerminalTypography>
</Box>
) : (
<>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TerminalTableCell>Date</TerminalTableCell>
<TerminalTableCell>Event Type</TerminalTableCell>
<TerminalTableCell>Details</TerminalTableCell>
{(events.some(e => e.event_type === 'job_failed' && e.error_message)) && (
<TerminalTableCell>Error</TerminalTableCell>
)}
</TableRow>
</TableHead>
<TableBody>
{events.map((event) => (
<TerminalTableRow key={event.id}>
<TerminalTableCell>
<TerminalTypography variant="body2" fontSize="0.75rem">
{formatDate(event.event_date)}
</TerminalTypography>
</TerminalTableCell>
<TerminalTableCell>
<Chip
label={event.event_type}
size="small"
sx={{
backgroundColor: getEventTypeColor(event.event_type),
color: '#000',
fontFamily: 'inherit',
fontSize: '0.7rem',
fontWeight: 'bold'
}}
/>
</TerminalTableCell>
<TerminalTableCell>
<TerminalTypography variant="body2" fontSize="0.75rem" sx={{
color: getEventTypeColor(event.event_type),
fontFamily: 'monospace'
}}>
{formatEventDetails(event)}
</TerminalTypography>
</TerminalTableCell>
{event.event_type === 'job_failed' && event.error_message && (
<TerminalTableCell>
<Tooltip title={event.error_message} arrow>
<TerminalTypography variant="body2" fontSize="0.7rem" sx={{
color: terminalColors.error,
fontFamily: 'monospace',
maxWidth: '300px',
wordBreak: 'break-word',
wordWrap: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical'
}}>
{event.error_message}
</TerminalTypography>
</Tooltip>
</TerminalTableCell>
)}
{event.event_type !== 'job_failed' && events.some(e => e.event_type === 'job_failed' && e.error_message) && (
<TerminalTableCell></TerminalTableCell>
)}
</TerminalTableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 25, 50, 100]}
sx={{
color: terminalColors.primary,
'& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': {
color: terminalColors.primary,
},
'& .MuiIconButton-root': {
color: terminalColors.primary,
}
}}
/>
</>
)}
</Box>
</TerminalPaper>
);
};
export default SchedulerEventHistory;

View File

@@ -0,0 +1,272 @@
/**
* Scheduler Jobs Tree Component
* Displays scheduled jobs in tree structure matching log format.
*/
import React from 'react';
import { Box } from '@mui/material';
import {
Schedule as ScheduleIcon,
Refresh as RefreshIcon,
Event as EventIcon,
Person as PersonIcon,
Storage as StorageIcon
} from '@mui/icons-material';
import { SchedulerJob } from '../../api/schedulerDashboard';
import { TerminalPaper, TerminalTypography, TerminalChip, terminalColors } from './terminalTheme';
interface SchedulerJobsTreeProps {
jobs: SchedulerJob[];
recurringJobs: number;
oneTimeJobs: number;
}
const SchedulerJobsTree: React.FC<SchedulerJobsTreeProps> = ({
jobs,
recurringJobs,
oneTimeJobs
}) => {
const formatDate = (dateString: string | null) => {
if (!dateString) return 'Not scheduled';
try {
const date = new Date(dateString);
return date.toLocaleString();
} catch {
return dateString;
}
};
const getJobTypeIcon = (jobId: string) => {
if (jobId === 'check_due_tasks') {
return <RefreshIcon fontSize="small" />;
}
return <EventIcon fontSize="small" />;
};
const getJobTypeLabel = (jobId: string, job?: SchedulerJob) => {
if (jobId === 'check_due_tasks') {
return 'Recurring';
}
if (jobId.includes('research_persona')) {
return 'Research Persona';
}
if (jobId.includes('facebook_persona')) {
return 'Facebook Persona';
}
if (jobId.includes('oauth_token_monitoring')) {
// Extract platform from job ID or use platform field
const platform = job?.platform ||
jobId.split('_')[2] ||
'OAuth';
const platformNames: { [key: string]: string } = {
'gsc': 'GSC',
'bing': 'Bing',
'wordpress': 'WordPress',
'wix': 'Wix'
};
return `OAuth ${platformNames[platform] || platform.toUpperCase()}`;
}
return 'One-Time';
};
const getJobTypeColor = (jobId: string) => {
if (jobId === 'check_due_tasks') {
return 'primary';
}
return 'secondary';
};
// Separate recurring and one-time jobs
const recurringJob = jobs.find(j => j.id === 'check_due_tasks');
const oneTimeJobsList = jobs.filter(j => j.id !== 'check_due_tasks');
return (
<TerminalPaper sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box display="flex" alignItems="center" gap={1} mb={2}>
<ScheduleIcon sx={{ color: terminalColors.primary }} />
<TerminalTypography variant="h6" component="h2" sx={{ fontSize: '1.25rem', fontWeight: 'bold' }}>
Scheduled Jobs
</TerminalTypography>
<TerminalChip
label={`${jobs.length} total`}
size="small"
/>
</Box>
<Box sx={{ fontFamily: 'monospace', fontSize: '0.875rem', color: terminalColors.text, flex: 1, overflow: 'auto', minHeight: 0 }}>
{/* Header */}
<Box mb={2} sx={{ flexShrink: 0 }}>
<TerminalTypography variant="body2" sx={{ mb: 1, color: terminalColors.textSecondary }}>
Recurring Jobs: {recurringJobs} | One-Time Jobs: {oneTimeJobs}
</TerminalTypography>
</Box>
{/* Jobs Tree */}
{jobs.length > 0 ? (
<Box sx={{ flex: 1 }}>
{jobs.map((job, index) => {
const isLast = index === jobs.length - 1;
const prefix = isLast ? '└─' : '├─';
const isRecurring = job.id === 'check_due_tasks';
return (
<Box
key={job.id}
sx={{
mb: 2,
display: 'block',
borderLeft: `2px solid ${terminalColors.border}`,
pl: 2,
py: 1
}}
>
<Box
display="flex"
alignItems="flex-start"
gap={1.5}
flexWrap="wrap"
sx={{
width: '100%',
minHeight: '50px',
}}
>
{/* Tree prefix and chip */}
<Box display="flex" alignItems="center" gap={1} sx={{ flexShrink: 0 }}>
<TerminalTypography component="span" sx={{ fontFamily: 'monospace', color: terminalColors.primary, fontSize: '1.2rem' }}>
{prefix}
</TerminalTypography>
<TerminalChip
icon={getJobTypeIcon(job.id)}
label={getJobTypeLabel(job.id, job)}
size="small"
sx={{ flexShrink: 0 }}
/>
</Box>
{/* Job details */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center', mb: 0.5 }}>
<TerminalTypography
component="span"
sx={{
fontFamily: 'monospace',
color: terminalColors.primary,
wordBreak: 'break-word',
wordWrap: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
fontSize: '0.875rem',
fontWeight: 'bold',
maxWidth: '100%'
}}
>
{job.id}
</TerminalTypography>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, alignItems: 'center', mt: 0.5 }}>
<TerminalTypography
component="span"
sx={{
fontFamily: 'monospace',
color: terminalColors.textSecondary,
fontSize: '0.8rem',
wordBreak: 'break-word',
wordWrap: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal'
}}
>
Trigger: {job.trigger_type}
</TerminalTypography>
{job.next_run_time && (
<TerminalTypography
component="span"
sx={{
fontFamily: 'monospace',
color: terminalColors.textSecondary,
fontSize: '0.8rem',
wordBreak: 'break-word',
wordWrap: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal'
}}
>
Next Run: {formatDate(job.next_run_time)}
</TerminalTypography>
)}
{job.user_id && (
<Box display="flex" alignItems="center" gap={0.5}>
<PersonIcon fontSize="small" sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }} />
<TerminalTypography
component="span"
sx={{
fontFamily: 'monospace',
color: terminalColors.textSecondary,
fontSize: '0.8rem',
wordBreak: 'break-word',
wordWrap: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal'
}}
>
User: {String(job.user_id)}
</TerminalTypography>
</Box>
)}
{job.platform && (
<Box display="flex" alignItems="center" gap={0.5}>
<TerminalTypography
component="span"
sx={{
fontFamily: 'monospace',
color: terminalColors.primary,
fontSize: '0.8rem',
fontWeight: 'bold',
wordBreak: 'break-word',
wordWrap: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal'
}}
>
Platform: {job.platform.toUpperCase()}
</TerminalTypography>
</Box>
)}
{job.user_job_store && job.user_job_store !== 'default' && (
<Box display="flex" alignItems="center" gap={0.5}>
<StorageIcon fontSize="small" sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }} />
<TerminalTypography
component="span"
sx={{
fontFamily: 'monospace',
color: terminalColors.textSecondary,
fontSize: '0.8rem',
wordBreak: 'break-word',
wordWrap: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal'
}}
>
Store: {job.user_job_store}
</TerminalTypography>
</Box>
)}
</Box>
</Box>
</Box>
</Box>
);
})}
</Box>
) : (
<TerminalTypography variant="body2" sx={{ fontStyle: 'italic', color: terminalColors.textSecondary }}>
No jobs scheduled
</TerminalTypography>
)}
</Box>
</TerminalPaper>
);
};
export default SchedulerJobsTree;

View File

@@ -0,0 +1,211 @@
/**
* Scheduler Stats Cards Component
* Displays scheduler metrics in card format.
*/
import React from 'react';
import { Grid, Typography, Box } from '@mui/material';
import {
CheckCircle as CheckCircleIcon,
Error as ErrorIcon,
Schedule as ScheduleIcon,
PlayArrow as PlayArrowIcon,
Pause as PauseIcon,
TrendingUp as TrendingUpIcon,
AccessTime as AccessTimeIcon
} from '@mui/icons-material';
import { SchedulerStats } from '../../api/schedulerDashboard';
import { TerminalCard, TerminalCardContent, TerminalTypography, TerminalChip, TerminalChipSuccess, TerminalChipError, terminalColors } from './terminalTheme';
interface SchedulerStatsCardsProps {
stats: SchedulerStats;
}
const SchedulerStatsCards: React.FC<SchedulerStatsCardsProps> = ({ stats }) => {
// Debug: Only log if cumulative values are actually present (not just 0 from defaults)
// Suppress logging when all cumulative values are 0 to reduce console noise
if (stats.cumulative_total_check_cycles !== undefined) {
const hasCumulativeData = stats.cumulative_total_check_cycles > 0 ||
stats.cumulative_tasks_found > 0 ||
stats.cumulative_tasks_executed > 0;
// Only log if there's actual cumulative data or if this is the first render
if (hasCumulativeData || stats.total_checks > 0) {
console.log('📊 StatsCards received stats:', {
total_checks: stats.total_checks,
cumulative_total_check_cycles: stats.cumulative_total_check_cycles,
cumulative_tasks_found: stats.cumulative_tasks_found,
cumulative_tasks_executed: stats.cumulative_tasks_executed,
cumulative_tasks_failed: stats.cumulative_tasks_failed,
has_cumulative_data: hasCumulativeData
});
}
}
const getStatusColor = (running: boolean) => {
return running ? 'success' : 'error';
};
const getStatusIcon = (running: boolean) => {
return running ? <PlayArrowIcon /> : <PauseIcon />;
};
const formatTime = (minutes: number) => {
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
}
return `${minutes}m`;
};
const formatDate = (dateString: string | null) => {
if (!dateString) return 'Never';
try {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
} catch {
return dateString;
}
};
const cards = [
{
title: 'Scheduler Status',
value: stats.running ? 'Running' : 'Stopped',
icon: getStatusIcon(stats.running),
color: getStatusColor(stats.running),
subtitle: stats.running ? 'Active' : 'Inactive'
},
{
title: 'Total Check Cycles',
value: (stats.cumulative_total_check_cycles !== undefined && stats.cumulative_total_check_cycles !== null)
? stats.cumulative_total_check_cycles.toLocaleString()
: stats.total_checks.toLocaleString(),
icon: <CheckCircleIcon />,
color: 'primary' as const,
subtitle: (stats.cumulative_total_check_cycles !== undefined && stats.cumulative_total_check_cycles !== null && stats.cumulative_total_check_cycles > 0)
? `${stats.total_checks.toLocaleString()} this session (${stats.cumulative_total_check_cycles.toLocaleString()} total)`
: stats.total_checks === 0
? 'No cycles yet (scheduler waiting)'
: 'Since startup'
},
{
title: 'Tasks Executed',
value: (stats.cumulative_tasks_executed !== undefined && stats.cumulative_tasks_executed !== null)
? stats.cumulative_tasks_executed.toLocaleString()
: stats.tasks_executed.toLocaleString(),
icon: <TrendingUpIcon />,
color: 'success' as const,
subtitle: (stats.cumulative_tasks_executed !== undefined && stats.cumulative_tasks_executed !== null && stats.cumulative_tasks_executed > 0)
? `${stats.tasks_executed.toLocaleString()} this session (${stats.cumulative_tasks_executed.toLocaleString()} total)`
: stats.tasks_executed === 0
? 'No tasks executed yet'
: `${stats.tasks_failed > 0 ? `${stats.tasks_failed} failed` : 'All successful'}`
},
{
title: 'Tasks Found',
value: (stats.cumulative_tasks_found !== undefined && stats.cumulative_tasks_found !== null)
? stats.cumulative_tasks_found.toLocaleString()
: stats.tasks_found.toLocaleString(),
icon: <ScheduleIcon />,
color: 'info' as const,
subtitle: (stats.cumulative_tasks_found !== undefined && stats.cumulative_tasks_found !== null && stats.cumulative_tasks_found > 0)
? `${stats.tasks_found.toLocaleString()} this session (${stats.cumulative_tasks_found.toLocaleString()} total)`
: stats.tasks_found === 0
? 'No tasks scheduled yet'
: `${stats.tasks_executed} executed, ${stats.tasks_failed} failed`
},
{
title: 'Check Interval',
value: formatTime(stats.check_interval_minutes),
icon: <AccessTimeIcon />,
color: 'secondary' as const,
subtitle: stats.intelligent_scheduling
? `Intelligent (${stats.active_strategies_count > 0 ? '15min' : '60min'} range)`
: 'Fixed interval'
},
{
title: 'Active Strategies',
value: stats.active_strategies_count.toString(),
icon: <TrendingUpIcon />,
color: stats.active_strategies_count > 0 ? 'success' : 'default' as const,
subtitle: stats.active_strategies_count > 0
? 'With monitoring tasks'
: 'No active strategies'
}
];
const getCardIconColor = (cardColor: string) => {
switch (cardColor) {
case 'success':
return terminalColors.success;
case 'error':
return terminalColors.error;
case 'primary':
return terminalColors.primary;
case 'info':
return terminalColors.info;
case 'secondary':
return terminalColors.secondary;
default:
return terminalColors.text;
}
};
return (
<Grid container spacing={2}>
{cards.map((card, index) => (
<Grid item xs={12} sm={6} md={4} key={index}>
<TerminalCard sx={{ height: '100%' }}>
<TerminalCardContent>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
<Box display="flex" alignItems="center" gap={1}>
<Box
sx={{
p: 1,
borderRadius: '4px',
backgroundColor: terminalColors.backgroundLight,
border: `1px solid ${getCardIconColor(card.color)}`,
color: getCardIconColor(card.color),
display: 'flex',
alignItems: 'center'
}}
>
{card.icon}
</Box>
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
{card.title}
</TerminalTypography>
</Box>
</Box>
<TerminalTypography variant="h4" component="div" sx={{ fontWeight: 600, mb: 0.5, fontSize: '1.75rem', color: terminalColors.primary }}>
{card.value}
</TerminalTypography>
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }}>
{card.subtitle}
</TerminalTypography>
{card.title === 'Scheduler Status' && stats.last_check && (
<TerminalTypography variant="caption" sx={{ mt: 1, display: 'block', color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
Last check: {formatDate(stats.last_check)}
</TerminalTypography>
)}
</TerminalCardContent>
</TerminalCard>
</Grid>
))}
</Grid>
);
};
export default SchedulerStatsCards;

View File

@@ -0,0 +1,187 @@
/**
* Terminal Theme Styling
* Shared terminal-themed styles for scheduler dashboard components
*/
import { styled } from '@mui/material/styles';
import { Box, Paper, Card, CardContent, Typography, Chip, TableCell, TableRow, Alert, Accordion } from '@mui/material';
export const TerminalPaper = styled(Paper)({
backgroundColor: '#0a0a0a',
border: '1px solid #00ff00',
color: '#00ff00',
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
padding: 16,
minHeight: '200px', // Ensure minimum height for visibility
'& *': {
fontFamily: 'inherit',
color: 'inherit', // Ensure all text inherits the green color
}
});
export const TerminalCard = styled(Card)({
backgroundColor: '#0a0a0a',
border: '1px solid #00ff00',
color: '#00ff00',
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
transition: 'all 0.2s',
minHeight: '120px', // Ensure cards have minimum height
'&:hover': {
borderColor: '#00ff88',
boxShadow: '0 0 15px rgba(0, 255, 0, 0.3)',
transform: 'translateY(-2px)',
},
'& *': {
fontFamily: 'inherit',
color: 'inherit', // Ensure all text inherits the green color
}
});
export const TerminalCardContent = styled(CardContent)({
color: '#00ff00',
'&:last-child': {
paddingBottom: 16,
}
});
export const TerminalTypography = styled(Typography)<{ component?: React.ElementType }>(({ theme }) => ({
color: '#00ff00',
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
}));
export const TerminalChip = styled(Chip)({
backgroundColor: '#1a1a1a',
color: '#00ff00',
border: '1px solid #00ff00',
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
fontSize: '0.75rem',
'& .MuiChip-label': {
padding: '4px 8px',
},
'& .MuiChip-icon': {
color: '#00ff00',
}
});
export const TerminalChipSuccess = styled(Chip)({
backgroundColor: '#0a2a0a',
color: '#00ff00',
border: '1px solid #00ff00',
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
fontSize: '0.75rem',
'& .MuiChip-label': {
padding: '4px 8px',
},
'& .MuiChip-icon': {
color: '#00ff00',
}
});
export const TerminalChipError = styled(Chip)({
backgroundColor: '#2a0a0a',
color: '#ff4444',
border: '1px solid #ff4444',
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
fontSize: '0.75rem',
'& .MuiChip-label': {
padding: '4px 8px',
},
'& .MuiChip-icon': {
color: '#ff4444',
}
});
export const TerminalChipWarning = styled(Chip)({
backgroundColor: '#2a2a0a',
color: '#ffd700',
border: '1px solid #ffd700',
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
fontSize: '0.75rem',
'& .MuiChip-label': {
padding: '4px 8px',
},
'& .MuiChip-icon': {
color: '#ffd700',
}
});
export const TerminalTableCell = styled(TableCell)({
color: '#00ff00',
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
borderColor: '#004400',
fontSize: '0.875rem',
});
export const TerminalTableRow = styled(TableRow)({
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.05)',
},
'&:nth-of-type(even)': {
backgroundColor: 'rgba(0, 255, 0, 0.02)',
}
});
export const TerminalAlert = styled(Alert)({
backgroundColor: '#1a1a1a',
color: '#ff4444',
border: '1px solid #ff4444',
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
'& .MuiAlert-icon': {
color: '#ff4444',
},
'&.MuiAlert-standardSuccess': {
color: '#00ff00',
borderColor: '#00ff00',
'& .MuiAlert-icon': {
color: '#00ff00',
}
},
'&.MuiAlert-standardWarning': {
color: '#ffd700',
borderColor: '#ffd700',
'& .MuiAlert-icon': {
color: '#ffd700',
}
},
'&.MuiAlert-standardInfo': {
color: '#00ffff',
borderColor: '#00ffff',
'& .MuiAlert-icon': {
color: '#00ffff',
}
}
});
export const TerminalAccordion = styled(Accordion)({
backgroundColor: '#1a1a1a',
border: '1px solid #00ff00',
color: '#00ff00',
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
'&:before': {
display: 'none',
},
'&.Mui-expanded': {
margin: 0,
}
});
export const TerminalBox = styled(Box)({
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
color: '#00ff00',
});
// Color constants
export const terminalColors = {
primary: '#00ff00',
secondary: '#00ff88',
error: '#ff4444',
warning: '#ffd700',
info: '#00ffff',
success: '#00ff00',
background: '#0a0a0a',
backgroundLight: '#1a1a1a',
text: '#00ff00',
textSecondary: '#00ff88',
border: '#00ff00',
};