Scheduled research persona generation
This commit is contained in:
@@ -29,6 +29,16 @@ import { BlogWriterLandingSection } from './BlogWriterUtils/BlogWriterLandingSec
|
||||
import { CopilotKitComponents } from './BlogWriterUtils/CopilotKitComponents';
|
||||
|
||||
export const BlogWriter: React.FC = () => {
|
||||
// Add light theme class to body/html on mount, remove on unmount
|
||||
React.useEffect(() => {
|
||||
document.body.classList.add('blog-writer-page');
|
||||
document.documentElement.classList.add('blog-writer-page');
|
||||
return () => {
|
||||
document.body.classList.remove('blog-writer-page');
|
||||
document.documentElement.classList.remove('blog-writer-page');
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check CopilotKit health status
|
||||
const { isAvailable: copilotKitAvailable } = useCopilotKitHealth({
|
||||
enabled: true, // Enable health checking
|
||||
@@ -313,6 +323,7 @@ export const BlogWriter: React.FC = () => {
|
||||
sections,
|
||||
research,
|
||||
openSEOMetadata: () => setIsSEOMetadataModalOpen(true),
|
||||
navigateToPhase,
|
||||
});
|
||||
|
||||
|
||||
@@ -320,7 +331,14 @@ export const BlogWriter: React.FC = () => {
|
||||
|
||||
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: '#ffffff',
|
||||
color: '#1a1a1a',
|
||||
overflow: 'auto'
|
||||
}} className="blog-writer-container">
|
||||
{/* CopilotKit-dependent components - extracted to CopilotKitComponents */}
|
||||
{copilotKitAvailable && (
|
||||
<CopilotKitComponents
|
||||
@@ -349,6 +367,7 @@ export const BlogWriter: React.FC = () => {
|
||||
setFlowAnalysisResults={setFlowAnalysisResults}
|
||||
setContinuityRefresh={setContinuityRefresh}
|
||||
researchPolling={researchPolling}
|
||||
navigateToPhase={navigateToPhase}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -359,6 +378,14 @@ export const BlogWriter: React.FC = () => {
|
||||
onTaskStart={(taskId) => setOutlineTaskId(taskId)}
|
||||
onPollingStart={(taskId) => outlinePolling.startPolling(taskId)}
|
||||
onModalShow={() => setShowOutlineModal(true)}
|
||||
navigateToPhase={navigateToPhase}
|
||||
onOutlineCreated={(outline, titleOptions) => {
|
||||
// Handle cached outline from CopilotKit action (same as header button)
|
||||
setOutline(outline);
|
||||
if (titleOptions) {
|
||||
setTitleOptions(titleOptions);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<OutlineRefiner
|
||||
outline={outline}
|
||||
@@ -381,31 +408,29 @@ export const BlogWriter: React.FC = () => {
|
||||
seoMetadata={seoMetadata}
|
||||
/>
|
||||
|
||||
{/* Always show HeaderBar when CopilotKit is unavailable, or when research exists */}
|
||||
{(!copilotKitAvailable || research) && (
|
||||
<HeaderBar
|
||||
phases={phases}
|
||||
currentPhase={currentPhase}
|
||||
onPhaseClick={handlePhaseClick}
|
||||
copilotKitAvailable={copilotKitAvailable}
|
||||
actionHandlers={{
|
||||
onResearchAction: handleResearchAction,
|
||||
onOutlineAction: handleOutlineAction,
|
||||
onContentAction: handleContentAction,
|
||||
onSEOAction: handleSEOAction,
|
||||
onApplySEORecommendations: handleApplySEORecommendations,
|
||||
onPublishAction: handlePublishAction,
|
||||
}}
|
||||
hasResearch={!!research}
|
||||
hasOutline={outline.length > 0}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
hasContent={Object.keys(sections).length > 0}
|
||||
contentConfirmed={contentConfirmed}
|
||||
hasSEOAnalysis={!!seoAnalysis}
|
||||
seoRecommendationsApplied={seoRecommendationsApplied}
|
||||
hasSEOMetadata={!!seoMetadata}
|
||||
/>
|
||||
)}
|
||||
{/* Phase navigation header - always visible as default interface */}
|
||||
<HeaderBar
|
||||
phases={phases}
|
||||
currentPhase={currentPhase}
|
||||
onPhaseClick={handlePhaseClick}
|
||||
copilotKitAvailable={copilotKitAvailable}
|
||||
actionHandlers={{
|
||||
onResearchAction: handleResearchAction,
|
||||
onOutlineAction: handleOutlineAction,
|
||||
onContentAction: handleContentAction,
|
||||
onSEOAction: handleSEOAction,
|
||||
onApplySEORecommendations: handleApplySEORecommendations,
|
||||
onPublishAction: handlePublishAction,
|
||||
}}
|
||||
hasResearch={!!research}
|
||||
hasOutline={outline.length > 0}
|
||||
outlineConfirmed={outlineConfirmed}
|
||||
hasContent={Object.keys(sections).length > 0}
|
||||
contentConfirmed={contentConfirmed}
|
||||
hasSEOAnalysis={!!seoAnalysis}
|
||||
seoRecommendationsApplied={seoRecommendationsApplied}
|
||||
hasSEOMetadata={!!seoMetadata}
|
||||
/>
|
||||
|
||||
{/* Landing section - extracted to BlogWriterLandingSection */}
|
||||
<BlogWriterLandingSection
|
||||
|
||||
@@ -70,21 +70,12 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
|
||||
backgroundSize: '56% auto',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundColor: 'transparent',
|
||||
backgroundColor: '#ffffff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{/* Animated overlay for subtle movement */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'linear-gradient(135deg, rgba(25, 118, 210, 0.05) 0%, rgba(156, 39, 176, 0.05) 100%)'
|
||||
}} />
|
||||
|
||||
{/* Main content container */}
|
||||
<div style={{
|
||||
@@ -109,7 +100,7 @@ const BlogWriterLanding: React.FC<BlogWriterLandingProps> = ({ onStartWriting })
|
||||
textShadow: '0 4px 8px rgba(0,0,0,0.1)',
|
||||
lineHeight: '1.2'
|
||||
}}>
|
||||
Step1- Research Your Blog Topic
|
||||
AI-First, Contextual, Click through Blog Writer
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,27 +17,24 @@ export const BlogWriterLandingSection: React.FC<BlogWriterLandingSectionProps> =
|
||||
navigateToPhase,
|
||||
onResearchComplete,
|
||||
}) => {
|
||||
// Only show landing/initial content when no research exists
|
||||
// Phase navigation header is always visible, so this is just the initial content
|
||||
if (!research) {
|
||||
return (
|
||||
<>
|
||||
{/* Show manual research form when on research phase and CopilotKit unavailable */}
|
||||
{!copilotKitAvailable && currentPhase === 'research' && (
|
||||
<ManualResearchForm onResearchComplete={onResearchComplete} />
|
||||
)}
|
||||
{copilotKitAvailable && (
|
||||
{/* Show landing page for CopilotKit flow or when not on research phase */}
|
||||
{(!copilotKitAvailable && currentPhase !== 'research') || copilotKitAvailable ? (
|
||||
<BlogWriterLanding
|
||||
onStartWriting={() => {
|
||||
// Trigger the copilot to start the research process
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!copilotKitAvailable && currentPhase !== 'research' && (
|
||||
<BlogWriterLanding
|
||||
onStartWriting={() => {
|
||||
// Navigate to research phase when CopilotKit unavailable
|
||||
// Navigate to research phase to start the workflow
|
||||
navigateToPhase('research');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ interface CopilotKitComponentsProps {
|
||||
setFlowAnalysisResults: (results: any) => void;
|
||||
setContinuityRefresh: (refresh: number | ((prev: number) => number)) => void;
|
||||
researchPolling: any;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
|
||||
@@ -49,6 +50,7 @@ export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
|
||||
setFlowAnalysisResults,
|
||||
setContinuityRefresh,
|
||||
researchPolling,
|
||||
navigateToPhase,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
@@ -57,12 +59,13 @@ export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
|
||||
onTaskStart={(taskId) => researchPolling.startPolling(taskId)}
|
||||
/>
|
||||
<CustomOutlineForm onOutlineCreated={onOutlineCreated} />
|
||||
<ResearchAction onResearchComplete={onResearchComplete} />
|
||||
<ResearchAction onResearchComplete={onResearchComplete} navigateToPhase={navigateToPhase} />
|
||||
|
||||
<ResearchDataActions
|
||||
research={research}
|
||||
onOutlineCreated={onOutlineCreated}
|
||||
onTitleOptionsSet={onTitleOptionsSet}
|
||||
onTitleOptionsSet={onTitleOptionsSet}
|
||||
navigateToPhase={navigateToPhase}
|
||||
/>
|
||||
<EnhancedOutlineActions
|
||||
outline={outline}
|
||||
@@ -77,6 +80,7 @@ export const CopilotKitComponents: React.FC<CopilotKitComponentsProps> = ({
|
||||
onMediumGenerationTriggered={onMediumGenerationTriggered}
|
||||
sections={sections}
|
||||
blogTitle={selectedTitle ?? undefined}
|
||||
navigateToPhase={navigateToPhase}
|
||||
onFlowAnalysisComplete={(analysis) => {
|
||||
console.log('Flow analysis completed:', analysis);
|
||||
setFlowAnalysisCompleted(true);
|
||||
|
||||
@@ -16,14 +16,242 @@ export const WriterCopilotSidebar: React.FC<WriterCopilotSidebarProps> = ({
|
||||
outlineConfirmed,
|
||||
}) => {
|
||||
return (
|
||||
<CopilotSidebar
|
||||
labels={{
|
||||
title: 'ALwrity Co-Pilot',
|
||||
initial: !research
|
||||
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
|
||||
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.',
|
||||
}}
|
||||
suggestions={suggestions}
|
||||
<>
|
||||
<style>{`
|
||||
/* Enterprise CopilotKit Suggestion Styling */
|
||||
|
||||
/* All suggestion chips - base styling */
|
||||
.copilotkit-suggestions button,
|
||||
.copilot-suggestions button,
|
||||
[class*="suggestion"] button,
|
||||
[class*="Suggestion"] button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 250, 251, 0.98) 100%);
|
||||
color: #4b5563;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 0 0 1px rgba(255, 255, 255, 0.5) inset;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
/* Shine effect on hover */
|
||||
.copilotkit-suggestions button::before,
|
||||
.copilot-suggestions button::before,
|
||||
[class*="suggestion"] button::before,
|
||||
[class*="Suggestion"] button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
transition: left 0.6s ease;
|
||||
}
|
||||
|
||||
.copilotkit-suggestions button:hover::before,
|
||||
.copilot-suggestions button:hover::before,
|
||||
[class*="suggestion"] button:hover::before,
|
||||
[class*="Suggestion"] button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* Regular suggestions - hover effects */
|
||||
.copilotkit-suggestions button:hover,
|
||||
.copilot-suggestions button:hover,
|
||||
[class*="suggestion"] button:hover:not([class*="next-suggestion"]),
|
||||
[class*="Suggestion"] button:hover:not([class*="next-suggestion"]) {
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.2), 0 0 0 1px rgba(255, 255, 255, 0.6) inset;
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 1) 0%, rgba(249, 250, 251, 1) 100%);
|
||||
}
|
||||
|
||||
/* "Next:" Suggestions - Premium Enterprise Style */
|
||||
.copilotkit-suggestions button[data-is-next="true"],
|
||||
.copilot-suggestions button[data-is-next="true"],
|
||||
.copilotkit-suggestions button.next-suggestion,
|
||||
.copilot-suggestions button.next-suggestion,
|
||||
.copilotkit-suggestions button[aria-label*="Next:"],
|
||||
.copilot-suggestions button[aria-label*="Next:"] {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%) !important;
|
||||
color: white !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3) !important;
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
|
||||
0 2px 4px rgba(0, 0, 0, 0.1) inset,
|
||||
0 0 20px rgba(102, 126, 234, 0.3) !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
position: relative;
|
||||
animation: nextSuggestionPulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Pulse animation for Next suggestions */
|
||||
@keyframes nextSuggestionPulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
|
||||
0 2px 4px rgba(0, 0, 0, 0.1) inset,
|
||||
0 0 20px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.25) inset,
|
||||
0 2px 4px rgba(0, 0, 0, 0.1) inset,
|
||||
0 0 30px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Next suggestion hover - enhanced */
|
||||
.copilotkit-suggestions button[data-is-next="true"]:hover,
|
||||
.copilot-suggestions button[data-is-next="true"]:hover,
|
||||
.copilotkit-suggestions button.next-suggestion:hover,
|
||||
.copilot-suggestions button.next-suggestion:hover,
|
||||
.copilotkit-suggestions button[aria-label*="Next:"]:hover,
|
||||
.copilot-suggestions button[aria-label*="Next:"]:hover {
|
||||
transform: translateY(-3px) scale(1.05) !important;
|
||||
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.6),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
||||
0 3px 6px rgba(0, 0, 0, 0.15) inset,
|
||||
0 0 40px rgba(102, 126, 234, 0.6) !important;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 40%, #f093fb 60%, #4facfe 100%) !important;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Next suggestion active */
|
||||
.copilotkit-suggestions button[data-is-next="true"]:active,
|
||||
.copilot-suggestions button[data-is-next="true"]:active,
|
||||
.copilotkit-suggestions button.next-suggestion:active,
|
||||
.copilot-suggestions button.next-suggestion:active,
|
||||
.copilotkit-suggestions button[aria-label*="Next:"]:active,
|
||||
.copilot-suggestions button[aria-label*="Next:"]:active {
|
||||
transform: translateY(-1px) scale(1.02) !important;
|
||||
box-shadow: 0 3px 12px rgba(102, 126, 234, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.25) inset,
|
||||
0 1px 3px rgba(0, 0, 0, 0.1) inset !important;
|
||||
}
|
||||
|
||||
/* Next suggestion focus */
|
||||
.copilotkit-suggestions button[data-is-next="true"]:focus-visible,
|
||||
.copilot-suggestions button[data-is-next="true"]:focus-visible,
|
||||
.copilotkit-suggestions button.next-suggestion:focus-visible,
|
||||
.copilot-suggestions button.next-suggestion:focus-visible,
|
||||
.copilotkit-suggestions button[aria-label*="Next:"]:focus-visible,
|
||||
.copilot-suggestions button[aria-label*="Next:"]:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.4),
|
||||
0 4px 16px rgba(102, 126, 234, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
|
||||
0 0 30px rgba(102, 126, 234, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Match buttons by text content using data attributes or class */
|
||||
/* We'll inject a data attribute via JS to identify Next suggestions */
|
||||
|
||||
/* Regular suggestion active state */
|
||||
.copilotkit-suggestions button:active:not([data-is-next="true"]):not(.next-suggestion),
|
||||
.copilot-suggestions button:active:not([data-is-next="true"]):not(.next-suggestion) {
|
||||
transform: translateY(0) scale(0.98);
|
||||
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
/* Focus states for regular suggestions */
|
||||
.copilotkit-suggestions button:focus-visible:not([data-is-next="true"]):not(.next-suggestion),
|
||||
.copilot-suggestions button:focus-visible:not([data-is-next="true"]):not(.next-suggestion) {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3), 0 4px 12px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
/* Enhanced suggestion container */
|
||||
.copilotkit-suggestions,
|
||||
.copilot-suggestions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4) 0%, rgba(249, 250, 251, 0.6) 100%);
|
||||
border-radius: 12px;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
@media (min-width: 420px) {
|
||||
.copilotkit-suggestions,
|
||||
.copilot-suggestions {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Inject data attributes to identify Next suggestions */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
const observer = new MutationObserver(() => {
|
||||
const suggestionButtons = document.querySelectorAll(
|
||||
'.copilotkit-suggestions button, .copilot-suggestions button, [class*="suggestion"] button'
|
||||
);
|
||||
suggestionButtons.forEach(btn => {
|
||||
const text = btn.textContent || btn.innerText || '';
|
||||
if (text.includes('Next:')) {
|
||||
btn.setAttribute('data-is-next', 'true');
|
||||
btn.classList.add('next-suggestion');
|
||||
} else {
|
||||
btn.removeAttribute('data-is-next');
|
||||
btn.classList.remove('next-suggestion');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
|
||||
// Initial run
|
||||
setTimeout(() => {
|
||||
const suggestionButtons = document.querySelectorAll(
|
||||
'.copilotkit-suggestions button, .copilot-suggestions button, [class*="suggestion"] button'
|
||||
);
|
||||
suggestionButtons.forEach(btn => {
|
||||
const text = btn.textContent || btn.innerText || '';
|
||||
if (text.includes('Next:')) {
|
||||
btn.setAttribute('data-is-next', 'true');
|
||||
btn.classList.add('next-suggestion');
|
||||
}
|
||||
});
|
||||
}, 100);
|
||||
})();
|
||||
`
|
||||
}}
|
||||
/>
|
||||
|
||||
<CopilotSidebar
|
||||
labels={{
|
||||
title: 'ALwrity Co-Pilot',
|
||||
initial: !research
|
||||
? 'Hi! I can help you research, outline, and draft your blog. Just tell me what topic you want to write about and I\'ll get started!'
|
||||
: 'Great! I can see you have research data. Let me help you create an outline and generate content for your blog.',
|
||||
}}
|
||||
suggestions={suggestions}
|
||||
makeSystemMessage={(context: string, additional?: string) => {
|
||||
const hasResearch = research !== null && research !== undefined;
|
||||
const hasOutline = outline && outline.length > 0;
|
||||
@@ -132,6 +360,7 @@ Available tools:
|
||||
return [toolGuide, additional].filter(Boolean).join('\n\n');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ interface UseBlogWriterCopilotActionsParams {
|
||||
sections: Record<string, string>;
|
||||
research: any;
|
||||
openSEOMetadata: OpenMetadataCb;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
// Consolidates all Copilot actions used by BlogWriter
|
||||
@@ -25,6 +26,7 @@ export function useBlogWriterCopilotActions({
|
||||
sections,
|
||||
research,
|
||||
openSEOMetadata,
|
||||
navigateToPhase,
|
||||
}: UseBlogWriterCopilotActionsParams) {
|
||||
// Maintain the same any-cast pattern for parity with component
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
@@ -35,6 +37,8 @@ export function useBlogWriterCopilotActions({
|
||||
description: 'Confirm that the blog content is ready and move to the next stage (SEO analysis)',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
// Navigate to SEO phase when content is confirmed
|
||||
navigateToPhase?.('seo');
|
||||
const msg = await confirmBlogContent();
|
||||
return msg;
|
||||
},
|
||||
@@ -46,6 +50,9 @@ export function useBlogWriterCopilotActions({
|
||||
description: 'Analyze the blog content for SEO optimization and provide detailed recommendations',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
// Navigate to SEO phase when SEO analysis starts
|
||||
navigateToPhase?.('seo');
|
||||
|
||||
debug.log('[BlogWriter] SEO analysis action', {
|
||||
modalOpen: isSEOAnalysisModalOpen,
|
||||
hasSections: !!sections && Object.keys(sections).length > 0,
|
||||
@@ -73,6 +80,9 @@ export function useBlogWriterCopilotActions({
|
||||
},
|
||||
],
|
||||
handler: async ({ title }: { title?: string }) => {
|
||||
// Navigate to SEO phase when SEO metadata generation starts
|
||||
navigateToPhase?.('seo');
|
||||
|
||||
if (!sections || Object.keys(sections).length === 0) {
|
||||
return 'Please generate blog content first before creating SEO metadata. Use the content generation features to create your blog post.';
|
||||
}
|
||||
|
||||
@@ -13,27 +13,15 @@ interface KeywordInputFormProps {
|
||||
}
|
||||
|
||||
export const KeywordInputForm: React.FC<KeywordInputFormProps> = ({ onKeywordsReceived, onResearchComplete, onTaskStart }) => {
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
|
||||
// This component now only provides polling functionality
|
||||
// The keyword input form is handled by ResearchAction component
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Polling handler for research progress */}
|
||||
<ResearchPollingHandler
|
||||
taskId={currentTaskId}
|
||||
onResearchComplete={(result) => {
|
||||
onResearchComplete?.(result);
|
||||
setCurrentTaskId(null);
|
||||
}}
|
||||
onError={(error) => {
|
||||
console.error('Research error:', error);
|
||||
setCurrentTaskId(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
// This component is now a lightweight wrapper
|
||||
// The actual keyword input form is handled by ResearchAction component
|
||||
// Polling is handled by ResearchPollingHandler in ResearchAction
|
||||
// This component exists for backward compatibility but doesn't create unnecessary polling hooks
|
||||
|
||||
// Note: If onTaskStart is called, it should use the researchPolling from parent
|
||||
// (passed via CopilotKitComponents), not create a new polling instance here
|
||||
|
||||
return null; // No UI needed - ResearchAction handles everything
|
||||
};
|
||||
|
||||
export default KeywordInputForm;
|
||||
@@ -50,6 +50,7 @@ interface OutlineFeedbackFormProps {
|
||||
sections?: Record<string, string>;
|
||||
blogTitle?: string;
|
||||
onFlowAnalysisComplete?: (analysis: any) => void;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -225,7 +226,8 @@ const FeedbackForm: React.FC<{
|
||||
|
||||
export const OutlineFeedbackForm: React.FC<OutlineFeedbackFormProps> = ({
|
||||
outline,
|
||||
research,
|
||||
research,
|
||||
navigateToPhase,
|
||||
onOutlineConfirmed,
|
||||
onOutlineRefined,
|
||||
onMediumGenerationStarted,
|
||||
@@ -352,6 +354,9 @@ export const OutlineFeedbackForm: React.FC<OutlineFeedbackFormProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
// Navigate to content phase when outline is confirmed
|
||||
navigateToPhase?.('content');
|
||||
|
||||
onOutlineConfirmed();
|
||||
|
||||
// If research specifies a short/medium blog (<=1000), kick off medium generation
|
||||
|
||||
@@ -8,6 +8,8 @@ interface OutlineGeneratorProps {
|
||||
onTaskStart: (taskId: string) => void;
|
||||
onPollingStart: (taskId: string) => void;
|
||||
onModalShow?: () => void; // Callback to show progress modal immediately
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
onOutlineCreated?: (outline: any[], titleOptions?: any[]) => void; // Callback when outline is created/found (for cached outlines)
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
@@ -16,7 +18,9 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
research,
|
||||
onTaskStart,
|
||||
onPollingStart,
|
||||
onModalShow
|
||||
onModalShow,
|
||||
navigateToPhase,
|
||||
onOutlineCreated
|
||||
}, ref) => {
|
||||
// Expose an imperative method to trigger outline generation directly (bypass LLM)
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -67,6 +71,15 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
|
||||
if (cachedOutline) {
|
||||
console.log('[OutlineGenerator] Using cached outline from CopilotKit action', { sections: cachedOutline.outline.length });
|
||||
|
||||
// Navigate to outline phase when cached outline is found
|
||||
navigateToPhase?.('outline');
|
||||
|
||||
// Update parent state with cached outline (same as header button does)
|
||||
if (onOutlineCreated) {
|
||||
onOutlineCreated(cachedOutline.outline, cachedOutline.title_options);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `✅ Outline already exists! ${cachedOutline.outline.length} sections loaded from cache.`,
|
||||
@@ -77,6 +90,9 @@ export const OutlineGenerator = forwardRef<any, OutlineGeneratorProps>(({
|
||||
}
|
||||
|
||||
try {
|
||||
// Navigate to outline phase when outline generation starts
|
||||
navigateToPhase?.('outline');
|
||||
|
||||
// Show progress modal immediately when user clicks "Create outline"
|
||||
onModalShow?.();
|
||||
|
||||
|
||||
@@ -51,9 +51,16 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
seoRecommendationsApplied = false,
|
||||
hasSEOMetadata = false,
|
||||
}) => {
|
||||
// Phase Navigation: Default interface for blog writing workflow
|
||||
// - Phase buttons are always clickable and functional (for both CopilotKit and manual flows)
|
||||
// - Action buttons (▶) only appear when CopilotKit is unavailable (manual fallback)
|
||||
// - When CopilotKit is available, users can use either phase buttons or CopilotKit suggestions
|
||||
|
||||
// Determine which action to show for each phase when CopilotKit is unavailable
|
||||
const getActionForPhase = (phaseId: string): { label: string; handler: (() => void) | null } => {
|
||||
if (copilotKitAvailable || !actionHandlers) {
|
||||
// Show action buttons for both CopilotKit and manual flows (dual mode)
|
||||
// Users can use either CopilotKit suggestions or phase navigation buttons
|
||||
if (!actionHandlers) {
|
||||
return { label: '', handler: null };
|
||||
}
|
||||
|
||||
@@ -104,159 +111,317 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
padding: '8px 0',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
{phases.map((phase) => {
|
||||
const isCurrent = phase.current;
|
||||
const isCompleted = phase.completed;
|
||||
const isDisabled = phase.disabled;
|
||||
const action = getActionForPhase(phase.id);
|
||||
|
||||
// Show action button when:
|
||||
// 1. CopilotKit is unavailable
|
||||
// 2. Action handler exists
|
||||
// 3. Phase is not disabled
|
||||
// 4. Show for current phase OR next actionable phase (not completed) OR phases with available actions
|
||||
// For research phase: always show if no research exists
|
||||
// For outline phase: always show if research exists but no outline (like research phase)
|
||||
// For SEO phase: always show if action handler exists (prerequisites are met)
|
||||
const isResearchPhase = phase.id === 'research' && !hasResearch;
|
||||
// Outline phase: show action whenever research exists and action handler is available
|
||||
// This allows users to create/regenerate outline after research, even if cached one exists
|
||||
const isOutlinePhase = phase.id === 'outline' && hasResearch && action.handler;
|
||||
// SEO phase: show action whenever prerequisites are met (action handler exists)
|
||||
// Similar to research/outline, show SEO actions whenever handler exists and phase is enabled
|
||||
const isSEOPhase = phase.id === 'seo' && action.handler;
|
||||
|
||||
// Debug logging for SEO phase (temporary - for troubleshooting)
|
||||
if (phase.id === 'seo' && !copilotKitAvailable && process.env.NODE_ENV === 'development') {
|
||||
console.log('[PhaseNavigation] SEO phase debug:', {
|
||||
phaseId: phase.id,
|
||||
isCurrent,
|
||||
isCompleted,
|
||||
isDisabled,
|
||||
hasContent,
|
||||
contentConfirmed,
|
||||
hasSEOAnalysis,
|
||||
seoRecommendationsApplied,
|
||||
hasSEOMetadata,
|
||||
actionLabel: action.label,
|
||||
actionHandler: !!action.handler,
|
||||
copilotKitAvailable,
|
||||
isSEOPhase,
|
||||
showActionWillBe: !copilotKitAvailable && action.handler && !isDisabled && (
|
||||
isCurrent ||
|
||||
(!isCompleted && !isDisabled) ||
|
||||
isResearchPhase ||
|
||||
isOutlinePhase ||
|
||||
isSEOPhase
|
||||
)
|
||||
});
|
||||
<>
|
||||
<style>{`
|
||||
/* Enterprise Phase Navigation Styles */
|
||||
.phase-nav-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
alignItems: center;
|
||||
padding: 12px 0;
|
||||
flexWrap: wrap;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(249, 250, 251, 0.98) 100%);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
// Show action if: current phase, or phase is not completed and not disabled, or it's research/outline/SEO with available action
|
||||
// For SEO: show whenever action handler exists (prerequisites are met), even if phase is marked as disabled/completed
|
||||
// This is critical because SEO prerequisites (hasContent && contentConfirmed) are validated in getActionForPhase,
|
||||
// so if action.handler exists, we should show it regardless of phase navigation's disabled state
|
||||
const showAction = !copilotKitAvailable && action.handler && (
|
||||
isCurrent ||
|
||||
(!isCompleted && !isDisabled) ||
|
||||
isResearchPhase ||
|
||||
isOutlinePhase ||
|
||||
isSEOPhase // Show SEO actions when handler exists - handler existence means prerequisites are met, so ignore isDisabled
|
||||
);
|
||||
.phase-chip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 24px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={phase.id} style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<button
|
||||
onClick={() => !isDisabled && onPhaseClick(phase.id)}
|
||||
disabled={isDisabled}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '20px',
|
||||
border: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
backgroundColor: isCurrent
|
||||
? '#1976d2'
|
||||
: isCompleted
|
||||
? '#4caf50'
|
||||
: isDisabled
|
||||
? '#f5f5f5'
|
||||
: '#e3f2fd',
|
||||
color: isCurrent
|
||||
? 'white'
|
||||
: isCompleted
|
||||
? 'white'
|
||||
: isDisabled
|
||||
? '#999'
|
||||
: '#1976d2',
|
||||
opacity: isDisabled ? 0.6 : 1,
|
||||
boxShadow: isCurrent ? '0 2px 4px rgba(25, 118, 210, 0.3)' : 'none',
|
||||
transform: isCurrent ? 'translateY(-1px)' : 'none'
|
||||
}}
|
||||
title={phase.disabled ? `Complete ${phase.name} first` : phase.description}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>
|
||||
{phase.icon}
|
||||
</span>
|
||||
<span>{phase.name}</span>
|
||||
{isCompleted && !isCurrent && (
|
||||
<span style={{ fontSize: '12px', marginLeft: '4px' }}>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{showAction && (
|
||||
.phase-chip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.phase-chip:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
/* Current Phase - Active Gradient */
|
||||
.phase-chip.current {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
}
|
||||
|
||||
.phase-chip.current:hover {
|
||||
transform: translateY(-3px) scale(1.03);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.2) inset;
|
||||
}
|
||||
|
||||
.phase-chip.current:active {
|
||||
transform: translateY(-1px) scale(1.01);
|
||||
box-shadow: 0 3px 12px rgba(102, 126, 234, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.15) inset;
|
||||
}
|
||||
|
||||
/* Completed Phase - Success Gradient */
|
||||
.phase-chip.completed {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
box-shadow: 0 3px 12px rgba(16, 185, 129, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
}
|
||||
|
||||
.phase-chip.completed:hover {
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0 5px 16px rgba(16, 185, 129, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.15) inset;
|
||||
}
|
||||
|
||||
/* Pending Phase - Subtle Gradient */
|
||||
.phase-chip.pending {
|
||||
background: linear-gradient(135deg, #e0e7ff 0%, #dbeafe 100%);
|
||||
color: #4b5563;
|
||||
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.1);
|
||||
}
|
||||
|
||||
.phase-chip.pending:hover {
|
||||
background: linear-gradient(135deg, #c7d2fe 0%, #bfdbfe 100%);
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
/* Disabled Phase */
|
||||
.phase-chip.disabled {
|
||||
background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
box-shadow: none;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.phase-chip.disabled:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Phase Icon */
|
||||
.phase-icon {
|
||||
font-size: 18px;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.phase-chip.current .phase-icon,
|
||||
.phase-chip.completed .phase-icon {
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
||||
}
|
||||
|
||||
.phase-chip:hover:not(.disabled) .phase-icon {
|
||||
transform: scale(1.1) rotate(5deg);
|
||||
}
|
||||
|
||||
/* Checkmark for completed */
|
||||
.phase-checkmark {
|
||||
font-size: 14px;
|
||||
margin-left: 4px;
|
||||
animation: checkmarkPop 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes checkmarkPop {
|
||||
0% { transform: scale(0); }
|
||||
50% { transform: scale(1.2); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Action Button - Enterprise Style */
|
||||
.phase-action-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.35),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
|
||||
0 1px 2px rgba(0, 0, 0, 0.1) inset;
|
||||
}
|
||||
|
||||
.phase-action-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.25), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.phase-action-btn:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.phase-action-btn:hover {
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2) inset,
|
||||
0 2px 4px rgba(0, 0, 0, 0.15) inset;
|
||||
}
|
||||
|
||||
.phase-action-btn:active {
|
||||
transform: translateY(0) scale(1.02);
|
||||
box-shadow: 0 3px 10px rgba(102, 126, 234, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.15) inset;
|
||||
}
|
||||
|
||||
.phase-action-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.3),
|
||||
0 4px 12px rgba(102, 126, 234, 0.35),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
}
|
||||
|
||||
.phase-action-icon {
|
||||
font-size: 12px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.phase-action-btn:hover .phase-action-icon {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="phase-nav-container">
|
||||
{phases.map((phase) => {
|
||||
const isCurrent = phase.current;
|
||||
const isCompleted = phase.completed;
|
||||
const isDisabled = phase.disabled;
|
||||
const action = getActionForPhase(phase.id);
|
||||
|
||||
// Show action button when:
|
||||
// 1. CopilotKit is unavailable
|
||||
// 2. Action handler exists
|
||||
// 3. Phase is not disabled
|
||||
// 4. Show for current phase OR next actionable phase (not completed) OR phases with available actions
|
||||
// For research phase: always show if no research exists
|
||||
// For outline phase: always show if research exists but no outline (like research phase)
|
||||
// For SEO phase: always show if action handler exists (prerequisites are met)
|
||||
const isResearchPhase = phase.id === 'research' && !hasResearch;
|
||||
// Outline phase: show action whenever research exists and action handler is available
|
||||
// This allows users to create/regenerate outline after research, even if cached one exists
|
||||
const isOutlinePhase = phase.id === 'outline' && hasResearch && action.handler;
|
||||
// SEO phase: show action whenever prerequisites are met (action handler exists)
|
||||
// Similar to research/outline, show SEO actions whenever handler exists and phase is enabled
|
||||
const isSEOPhase = phase.id === 'seo' && action.handler;
|
||||
|
||||
// Debug logging for SEO phase (temporary - for troubleshooting)
|
||||
if (phase.id === 'seo' && !copilotKitAvailable && process.env.NODE_ENV === 'development') {
|
||||
console.log('[PhaseNavigation] SEO phase debug:', {
|
||||
phaseId: phase.id,
|
||||
isCurrent,
|
||||
isCompleted,
|
||||
isDisabled,
|
||||
hasContent,
|
||||
contentConfirmed,
|
||||
hasSEOAnalysis,
|
||||
seoRecommendationsApplied,
|
||||
hasSEOMetadata,
|
||||
actionLabel: action.label,
|
||||
actionHandler: !!action.handler,
|
||||
copilotKitAvailable,
|
||||
isSEOPhase,
|
||||
showActionWillBe: !copilotKitAvailable && action.handler && !isDisabled && (
|
||||
isCurrent ||
|
||||
(!isCompleted && !isDisabled) ||
|
||||
isResearchPhase ||
|
||||
isOutlinePhase ||
|
||||
isSEOPhase
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// Show action if: current phase, or phase is not completed and not disabled, or it's research/outline/SEO with available action
|
||||
// For SEO: show whenever action handler exists (prerequisites are met), even if phase is marked as disabled/completed
|
||||
// This is critical because SEO prerequisites (hasContent && contentConfirmed) are validated in getActionForPhase,
|
||||
// so if action.handler exists, we should show it regardless of phase navigation's disabled state
|
||||
// DUAL MODE: Show action buttons even when CopilotKit is available (users can use either method)
|
||||
const showAction = action.handler && (
|
||||
isCurrent ||
|
||||
(!isCompleted && !isDisabled) ||
|
||||
isResearchPhase ||
|
||||
isOutlinePhase ||
|
||||
isSEOPhase // Show SEO actions when handler exists - handler existence means prerequisites are met, so ignore isDisabled
|
||||
);
|
||||
|
||||
// Determine chip class
|
||||
const chipClass = [
|
||||
'phase-chip',
|
||||
isCurrent ? 'current' : '',
|
||||
isCompleted && !isCurrent ? 'completed' : '',
|
||||
!isCurrent && !isCompleted && !isDisabled ? 'pending' : '',
|
||||
isDisabled ? 'disabled' : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div key={phase.id} style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.handler?.();
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '16px',
|
||||
border: '1px solid #1976d2',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 4px rgba(25, 118, 210, 0.2)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#1565c0';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = '#1976d2';
|
||||
e.currentTarget.style.transform = 'none';
|
||||
}}
|
||||
title={`${action.label} (Chat unavailable - click to proceed)`}
|
||||
onClick={() => !isDisabled && onPhaseClick(phase.id)}
|
||||
disabled={isDisabled}
|
||||
className={chipClass}
|
||||
title={phase.disabled ? `Complete ${phase.name} first` : phase.description}
|
||||
>
|
||||
<span style={{ fontSize: '12px' }}>▶</span>
|
||||
<span>{action.label}</span>
|
||||
<span className="phase-icon">
|
||||
{phase.icon}
|
||||
</span>
|
||||
<span>{phase.name}</span>
|
||||
{isCompleted && !isCurrent && (
|
||||
<span className="phase-checkmark">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showAction && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.handler?.();
|
||||
}}
|
||||
className="phase-action-btn"
|
||||
title={`${action.label}`}
|
||||
>
|
||||
<span className="phase-action-icon">▶</span>
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -62,8 +62,12 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
try {
|
||||
const status = await wordpressAPI.getStatus();
|
||||
setWordpressSites(status.sites || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to check WordPress connection status:', error);
|
||||
} catch (error: any) {
|
||||
// getStatus now handles 404 gracefully, so we should rarely hit this
|
||||
// Only log non-404 errors
|
||||
if (error?.response?.status !== 404) {
|
||||
console.error('Failed to check WordPress connection status:', error);
|
||||
}
|
||||
setWordpressSites([]);
|
||||
} finally {
|
||||
setCheckingWordPressStatus(false);
|
||||
|
||||
@@ -9,9 +9,10 @@ const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
interface ResearchActionProps {
|
||||
onResearchComplete?: (research: BlogResearchResponse) => void;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete }) => {
|
||||
export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComplete, navigateToPhase }) => {
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
const [currentMessage, setCurrentMessage] = useState<string>('');
|
||||
const [showProgressModal, setShowProgressModal] = useState<boolean>(false);
|
||||
@@ -20,28 +21,36 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
// Refs for form inputs (uncontrolled, avoids typing issues inside Copilot render)
|
||||
const keywordsRef = useRef<HTMLInputElement | null>(null);
|
||||
const blogLengthRef = useRef<HTMLSelectElement | null>(null);
|
||||
|
||||
// Track if we've navigated to research phase for this form display
|
||||
const hasNavigatedRef = useRef<boolean>(false);
|
||||
|
||||
const polling = useResearchPolling({
|
||||
onProgress: (message) => {
|
||||
setCurrentMessage(message);
|
||||
setForceUpdate(prev => prev + 1); // Force re-render
|
||||
},
|
||||
onComplete: (result) => {
|
||||
if (result && result.keywords) {
|
||||
researchCache.cacheResult(
|
||||
result.keywords,
|
||||
result.industry || 'General',
|
||||
result.target_audience || 'General',
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
onResearchComplete?.(result);
|
||||
setCurrentTaskId(null);
|
||||
setCurrentMessage('');
|
||||
setShowProgressModal(false);
|
||||
setForceUpdate(prev => prev + 1);
|
||||
},
|
||||
onComplete: (result) => {
|
||||
console.info('[ResearchAction] ✅ Research completed', { hasResult: !!result });
|
||||
|
||||
if (result && result.keywords) {
|
||||
researchCache.cacheResult(
|
||||
result.keywords,
|
||||
result.industry || 'General',
|
||||
result.target_audience || 'General',
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
// Reset navigation tracking when research completes
|
||||
hasNavigatedRef.current = false;
|
||||
|
||||
onResearchComplete?.(result);
|
||||
setCurrentTaskId(null);
|
||||
setCurrentMessage('');
|
||||
setShowProgressModal(false);
|
||||
setForceUpdate(prev => prev + 1);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Research polling error:', error);
|
||||
setCurrentTaskId(null);
|
||||
@@ -55,14 +64,40 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
name: 'showResearchForm',
|
||||
description: 'Show keyword input form for blog research',
|
||||
parameters: [],
|
||||
handler: async () => ({
|
||||
success: true,
|
||||
message: "🔍 Let's Research Your Blog Topic\n\nWhat keywords and information would you like to use for your research? Please also specify the desired length of the blog post.\n\nKeywords or Topic *\ne.g., artificial intelligence, machine learning, AI trends\n\nBlog Length (words)\n\n1000 words (Medium blog)\n\n🚀 Start Research",
|
||||
showForm: true
|
||||
}),
|
||||
handler: async () => {
|
||||
// Navigate to research phase when research form is shown
|
||||
// Reset navigation tracking so form render can navigate again if needed
|
||||
hasNavigatedRef.current = false;
|
||||
// Navigate immediately when handler is called
|
||||
if (navigateToPhase) {
|
||||
navigateToPhase('research');
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: "🔍 Let's Research Your Blog Topic\n\nWhat keywords and information would you like to use for your research? Please also specify the desired length of the blog post.\n\nKeywords or Topic *\ne.g., artificial intelligence, machine learning, AI trends\n\nBlog Length (words)\n\n1000 words (Medium blog)\n\n🚀 Start Research",
|
||||
showForm: true
|
||||
};
|
||||
},
|
||||
render: ({ status }: any) => {
|
||||
const _ = forceUpdate;
|
||||
|
||||
// Navigate to research phase when form is rendered (if not already navigated and form is shown)
|
||||
// This ensures phase navigation updates when CopilotKit shows the research form
|
||||
// Only navigate when showing the form (not progress or completion states)
|
||||
const isShowingForm = polling.currentStatus !== 'completed' &&
|
||||
polling.currentStatus !== 'in_progress' &&
|
||||
polling.currentStatus !== 'running';
|
||||
|
||||
if (isShowingForm && !hasNavigatedRef.current && navigateToPhase) {
|
||||
// Use setTimeout to avoid calling during render
|
||||
setTimeout(() => {
|
||||
if (!hasNavigatedRef.current) {
|
||||
navigateToPhase('research');
|
||||
hasNavigatedRef.current = true;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (polling.currentStatus === 'completed' && polling.progressMessages.length > 0) {
|
||||
const latestMessage = polling.progressMessages[polling.progressMessages.length - 1];
|
||||
return (
|
||||
@@ -135,6 +170,8 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
target_audience: 'General',
|
||||
word_count_target: parseInt(blogLength)
|
||||
};
|
||||
// Navigate to research phase when research starts
|
||||
navigateToPhase?.('research');
|
||||
const { task_id } = await blogWriterApi.startResearch(payload);
|
||||
setCurrentTaskId(task_id);
|
||||
setShowProgressModal(true);
|
||||
@@ -173,6 +210,8 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
const keywordList = trimmed.includes(',')
|
||||
? trimmed.split(',').map((k: string) => k.trim()).filter(Boolean)
|
||||
: [trimmed];
|
||||
// Navigate to research phase when research starts
|
||||
navigateToPhase?.('research');
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: keywordList,
|
||||
industry,
|
||||
@@ -191,6 +230,7 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{showProgressModal && (
|
||||
|
||||
@@ -8,12 +8,14 @@ interface ResearchDataActionsProps {
|
||||
research: BlogResearchResponse | null;
|
||||
onOutlineCreated: (outline: BlogOutlineSection[]) => void;
|
||||
onTitleOptionsSet: (titles: string[]) => void;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
export const ResearchDataActions: React.FC<ResearchDataActionsProps> = ({
|
||||
research,
|
||||
onOutlineCreated,
|
||||
onTitleOptionsSet
|
||||
onTitleOptionsSet,
|
||||
navigateToPhase
|
||||
}) => {
|
||||
// Chat with Research Data
|
||||
useCopilotActionTyped({
|
||||
@@ -110,6 +112,9 @@ export const ResearchDataActions: React.FC<ResearchDataActionsProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
// Navigate to outline phase when outline generation starts
|
||||
navigateToPhase?.('outline');
|
||||
|
||||
// Create a custom outline request with user instructions
|
||||
const customOutlineRequest = {
|
||||
research: research,
|
||||
|
||||
@@ -51,16 +51,13 @@ export const ResearchPollingHandler: React.FC<ResearchPollingHandlerProps> = ({
|
||||
if (taskId) {
|
||||
polling.startPolling(taskId);
|
||||
} else {
|
||||
polling.stopPolling();
|
||||
// Only stop if actually polling (not on every render when taskId is null)
|
||||
if (polling.isPolling) {
|
||||
polling.stopPolling();
|
||||
}
|
||||
}
|
||||
}, [taskId, polling]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
polling.stopPolling();
|
||||
};
|
||||
}, [polling]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskId]); // Removed polling from dependencies - usePolling already handles cleanup
|
||||
|
||||
// Only log on meaningful changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -183,7 +183,12 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const [applyError, setApplyError] = useState<string | null>(null);
|
||||
|
||||
console.log('SEOAnalysisModal render:', { isOpen, blogContent: blogContent?.length, researchData: !!researchData });
|
||||
// Debug logging only in development and when modal state changes meaningfully
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'development' && isOpen) {
|
||||
console.log('SEOAnalysisModal render:', { isOpen, blogContent: blogContent?.length, researchData: !!researchData });
|
||||
}
|
||||
}, [isOpen, blogContent?.length, researchData]);
|
||||
|
||||
const runSEOAnalysis = useCallback(async (forceRefresh = false) => {
|
||||
try {
|
||||
@@ -318,7 +323,7 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
|
||||
status,
|
||||
data: err?.response?.data
|
||||
});
|
||||
const handled = triggerSubscriptionError(err);
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
console.log('SEOAnalysisModal: Global subscription error handler triggered successfully');
|
||||
// Don't set local error - let the global modal handle it
|
||||
|
||||
@@ -125,15 +125,17 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
return unsub;
|
||||
}, [metadataResult]);
|
||||
|
||||
// Debug logging
|
||||
// Debug logging only in development and when modal state changes meaningfully
|
||||
useEffect(() => {
|
||||
console.log('🔍 SEOMetadataModal render:', {
|
||||
isOpen,
|
||||
blogContent: blogContent?.length,
|
||||
blogTitle,
|
||||
researchData: !!researchData
|
||||
});
|
||||
}, [isOpen, blogContent, blogTitle, researchData]);
|
||||
if (process.env.NODE_ENV === 'development' && isOpen) {
|
||||
console.log('🔍 SEOMetadataModal render:', {
|
||||
isOpen,
|
||||
blogContent: blogContent?.length,
|
||||
blogTitle,
|
||||
researchData: !!researchData
|
||||
});
|
||||
}
|
||||
}, [isOpen, blogContent?.length, blogTitle, researchData]);
|
||||
|
||||
// Reset state when modal closes
|
||||
useEffect(() => {
|
||||
@@ -229,7 +231,7 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
|
||||
status,
|
||||
data: err?.response?.data
|
||||
});
|
||||
const handled = triggerSubscriptionError(err);
|
||||
const handled = await triggerSubscriptionError(err);
|
||||
if (handled) {
|
||||
console.log('SEOMetadataModal: Global subscription error handler triggered successfully');
|
||||
// Don't set local error - let the global modal handle it
|
||||
|
||||
@@ -8,6 +8,7 @@ interface SectionGeneratorProps {
|
||||
genMode: 'draft' | 'polished';
|
||||
onSectionGenerated: (sectionId: string, markdown: string) => void;
|
||||
onContinuityRefresh: () => void;
|
||||
navigateToPhase?: (phase: string) => void;
|
||||
}
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
@@ -17,7 +18,8 @@ export const SectionGenerator: React.FC<SectionGeneratorProps> = ({
|
||||
research,
|
||||
genMode,
|
||||
onSectionGenerated,
|
||||
onContinuityRefresh
|
||||
onContinuityRefresh,
|
||||
navigateToPhase
|
||||
}) => {
|
||||
useCopilotActionTyped({
|
||||
name: 'generateSection',
|
||||
@@ -27,6 +29,9 @@ export const SectionGenerator: React.FC<SectionGeneratorProps> = ({
|
||||
const section = outline.find(s => s.id === sectionId);
|
||||
if (!section) return { success: false, message: 'Section not found. Please generate an outline first.' };
|
||||
|
||||
// Navigate to content phase when content generation starts
|
||||
navigateToPhase?.('content');
|
||||
|
||||
try {
|
||||
const res = await blogWriterApi.generateSection({ section, mode: genMode });
|
||||
if (res?.markdown) {
|
||||
@@ -98,6 +103,9 @@ export const SectionGenerator: React.FC<SectionGeneratorProps> = ({
|
||||
description: 'Generate content for every section in the outline',
|
||||
parameters: [],
|
||||
handler: async () => {
|
||||
// Navigate to content phase when content generation starts
|
||||
navigateToPhase?.('content');
|
||||
|
||||
for (const s of outline) {
|
||||
const res = await blogWriterApi.generateSection({ section: s, mode: genMode });
|
||||
onSectionGenerated(s.id, res.markdown);
|
||||
|
||||
185
frontend/src/components/FacebookWriter/FacebookPersonaModal.tsx
Normal file
185
frontend/src/components/FacebookWriter/FacebookPersonaModal.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Facebook Persona Generation Modal
|
||||
*
|
||||
* Prompts user to generate Facebook persona if it doesn't exist.
|
||||
* Similar to ResearchPersonaModal but for Facebook-specific persona.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Facebook as FacebookIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Group as GroupIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Close as CloseIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface FacebookPersonaModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onGenerate: () => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const FacebookPersonaModal: React.FC<FacebookPersonaModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onGenerate,
|
||||
onCancel
|
||||
}) => {
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onGenerate();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate Facebook persona');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={!generating ? onClose : undefined}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
disableEscapeKeyDown={generating}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
background: 'linear-gradient(135deg, #fff 0%, #f8fafc 100%)',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ textAlign: 'center', pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2 }}>
|
||||
<FacebookIcon sx={{ fontSize: 32, color: '#1877F2' }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 600 }}>
|
||||
Generate Facebook Persona
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ px: 4, py: 2 }}>
|
||||
<Typography variant="body1" sx={{ mb: 3, textAlign: 'center', color: 'text.secondary' }}>
|
||||
Enhance your Facebook content with AI-powered personalization based on your brand voice and Facebook's algorithm.
|
||||
</Typography>
|
||||
|
||||
<Alert severity="info" sx={{ mb: 3 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Why generate a Facebook persona?
|
||||
</Typography>
|
||||
<Typography variant="caption">
|
||||
Your Facebook persona learns from your onboarding data to provide personalized content that matches
|
||||
your brand voice and optimizes for Facebook's engagement algorithm.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5 }}>
|
||||
Benefits:
|
||||
</Typography>
|
||||
<List dense sx={{ py: 0 }}>
|
||||
<ListItem sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<AutoAwesomeIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Algorithm Optimization"
|
||||
secondary="Content optimized for Facebook's engagement algorithm and reach"
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<TrendingUpIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Platform-Specific Strategies"
|
||||
secondary="Facebook-specific engagement, timing, and community building strategies"
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<GroupIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Community Building"
|
||||
secondary="Strategies for building and engaging your Facebook community"
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<CheckCircleIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Brand Voice Alignment"
|
||||
secondary="Content that matches your brand voice and Facebook's best practices"
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontStyle: 'italic' }}>
|
||||
Note: This process takes about 30-60 seconds and uses your AI provider.
|
||||
You can continue using generic persona if you skip this step.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 4, pb: 3, justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
disabled={generating}
|
||||
startIcon={<CloseIcon />}
|
||||
color="inherit"
|
||||
>
|
||||
Skip for Now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
variant="contained"
|
||||
startIcon={generating ? <CircularProgress size={16} /> : <FacebookIcon />}
|
||||
sx={{ minWidth: 150, bgcolor: '#1877F2', '&:hover': { bgcolor: '#1565C0' } }}
|
||||
>
|
||||
{generating ? 'Generating...' : 'Generate Persona'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,8 @@ import RegisterFacebookActions from './RegisterFacebookActions';
|
||||
import RegisterFacebookEditActions from './RegisterFacebookEditActions';
|
||||
import RegisterFacebookActionsEnhanced from './RegisterFacebookActionsEnhanced';
|
||||
import { PlatformPersonaProvider, usePlatformPersonaContext } from '../shared/PersonaContext/PlatformPersonaProvider';
|
||||
import { generatePlatformPersona } from '../../api/persona';
|
||||
import { generatePlatformPersona, checkFacebookPersona } from '../../api/persona';
|
||||
import { FacebookPersonaModal } from './FacebookPersonaModal';
|
||||
|
||||
const useCopilotActionTyped = useCopilotAction as any;
|
||||
|
||||
@@ -168,6 +169,36 @@ const FacebookWriterContent: React.FC<FacebookWriterProps> = ({ className = '' }
|
||||
// State for generating persona
|
||||
const [isGeneratingPersona, setIsGeneratingPersona] = React.useState<boolean>(false);
|
||||
const [personaError, setPersonaError] = React.useState<string | null>(null);
|
||||
const [showPersonaModal, setShowPersonaModal] = React.useState<boolean>(false);
|
||||
const [personaChecked, setPersonaChecked] = React.useState<boolean>(false);
|
||||
|
||||
// Check for Facebook persona on component mount
|
||||
React.useEffect(() => {
|
||||
const checkPersona = async () => {
|
||||
if (personaChecked) return; // Already checked
|
||||
|
||||
try {
|
||||
const userId = localStorage.getItem('user_id');
|
||||
if (!userId) {
|
||||
setPersonaChecked(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const personaStatus = await checkFacebookPersona(userId);
|
||||
|
||||
// Show modal if onboarding completed but persona missing
|
||||
if (personaStatus.onboarding_completed && !personaStatus.has_persona && personaStatus.has_core_persona) {
|
||||
setShowPersonaModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Facebook persona:', error);
|
||||
} finally {
|
||||
setPersonaChecked(true);
|
||||
}
|
||||
};
|
||||
|
||||
checkPersona();
|
||||
}, [personaChecked]);
|
||||
|
||||
// Handler to generate Facebook persona on-demand
|
||||
const handleGeneratePersona = async () => {
|
||||
@@ -192,6 +223,36 @@ const FacebookWriterContent: React.FC<FacebookWriterProps> = ({ className = '' }
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for modal generation
|
||||
const handleGenerateFacebookPersona = async () => {
|
||||
setIsGeneratingPersona(true);
|
||||
setPersonaError(null);
|
||||
|
||||
try {
|
||||
const result = await generatePlatformPersona('facebook');
|
||||
|
||||
if (result.success) {
|
||||
// Refresh the persona context to load the newly generated persona
|
||||
await refreshPersonas();
|
||||
console.log('✅ Facebook persona generated successfully');
|
||||
setShowPersonaModal(false);
|
||||
} else {
|
||||
throw new Error('Failed to generate persona');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error generating persona:', error);
|
||||
throw error; // Let modal handle error display
|
||||
} finally {
|
||||
setIsGeneratingPersona(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for modal cancel
|
||||
const handleCancelPersona = () => {
|
||||
setShowPersonaModal(false);
|
||||
// Continue with generic persona
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const onUpdate = (e: any) => {
|
||||
setPostDraft(String(e.detail || ''));
|
||||
@@ -790,6 +851,16 @@ Instead of generic content, you get:
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Facebook Persona Modal */}
|
||||
{showPersonaModal && (
|
||||
<FacebookPersonaModal
|
||||
open={showPersonaModal}
|
||||
onClose={() => setShowPersonaModal(false)}
|
||||
onGenerate={handleGenerateFacebookPersona}
|
||||
onCancel={handleCancelPersona}
|
||||
/>
|
||||
)}
|
||||
</CopilotSidebar>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* OAuth Token Status Panel
|
||||
* Displays OAuth token monitoring status for all platforms and allows manual refresh
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Collapse,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import {
|
||||
getOAuthTokenStatus,
|
||||
manualRefreshToken,
|
||||
OAuthTokenStatusResponse,
|
||||
ManualRefreshResponse,
|
||||
} from '../../api/oauthTokenMonitoring';
|
||||
|
||||
interface OAuthTokenStatusPanelProps {
|
||||
userId?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const OAuthTokenStatusPanel: React.FC<OAuthTokenStatusPanelProps> = ({
|
||||
userId,
|
||||
compact = false
|
||||
}) => {
|
||||
const { userId: clerkUserId } = useAuth();
|
||||
const actualUserId = userId || clerkUserId || '';
|
||||
|
||||
const [status, setStatus] = useState<OAuthTokenStatusResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedPlatform, setExpandedPlatform] = useState<string | null>(null);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!actualUserId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await getOAuthTokenStatus(actualUserId);
|
||||
setStatus(response);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch token status');
|
||||
console.error('Error fetching OAuth token status:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
|
||||
// Poll for status updates every 2 minutes
|
||||
const interval = setInterval(fetchStatus, 120000);
|
||||
return () => clearInterval(interval);
|
||||
}, [actualUserId]);
|
||||
|
||||
const handleRefresh = async (platform: string) => {
|
||||
if (!actualUserId) return;
|
||||
|
||||
try {
|
||||
setRefreshing(platform);
|
||||
setError(null);
|
||||
const response: ManualRefreshResponse = await manualRefreshToken(actualUserId, platform);
|
||||
|
||||
// Refresh status after manual refresh
|
||||
await fetchStatus();
|
||||
|
||||
// Show success message
|
||||
if (response.success) {
|
||||
console.log(`Token refresh successful for ${platform}`);
|
||||
} else {
|
||||
console.error(`Token refresh failed for ${platform}:`, response.data.execution_result.error_message);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || `Failed to refresh ${platform} token`);
|
||||
console.error(`Error refreshing ${platform} token:`, err);
|
||||
} finally {
|
||||
setRefreshing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (taskStatus: string | null, connected: boolean) => {
|
||||
if (!connected) {
|
||||
return <XCircle size={20} color="#ef4444" />;
|
||||
}
|
||||
|
||||
if (!taskStatus || taskStatus === 'not_created') {
|
||||
return <Info size={20} color="#3b82f6" />;
|
||||
}
|
||||
|
||||
switch (taskStatus) {
|
||||
case 'active':
|
||||
return <CheckCircle size={20} color="#10b981" />;
|
||||
case 'failed':
|
||||
return <XCircle size={20} color="#ef4444" />;
|
||||
case 'paused':
|
||||
return <AlertTriangle size={20} color="#f59e0b" />;
|
||||
default:
|
||||
return <Info size={20} color="#6b7280" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (taskStatus: string | null, connected: boolean) => {
|
||||
if (!connected) return 'error';
|
||||
if (!taskStatus || taskStatus === 'not_created') return 'info';
|
||||
if (taskStatus === 'active') return 'success';
|
||||
if (taskStatus === 'failed') return 'error';
|
||||
if (taskStatus === 'paused') return 'warning';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformDisplayName = (platform: string) => {
|
||||
const names: { [key: string]: string } = {
|
||||
gsc: 'Google Search Console',
|
||||
bing: 'Bing Webmaster Tools',
|
||||
wordpress: 'WordPress',
|
||||
wix: 'Wix',
|
||||
};
|
||||
return names[platform] || platform.toUpperCase();
|
||||
};
|
||||
|
||||
if (loading && !status) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" p={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !status) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ m: 2 }}>
|
||||
{error}
|
||||
<Button size="small" onClick={fetchStatus} sx={{ ml: 2 }}>
|
||||
Retry
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const platforms = ['gsc', 'bing', 'wordpress', 'wix'];
|
||||
|
||||
return (
|
||||
<Card sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6">OAuth Token Status</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<RefreshCw size={16} />}
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Platform</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Last Check</TableCell>
|
||||
<TableCell>Next Check</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{platforms.map((platform) => {
|
||||
const platformStatus = status.data.platform_status[platform];
|
||||
const task = platformStatus?.monitoring_task;
|
||||
|
||||
return (
|
||||
<React.Fragment key={platform}>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{getStatusIcon(task?.status || null, platformStatus?.connected || false)}
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{getPlatformDisplayName(platform)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={task?.status || (platformStatus?.connected ? 'Connected' : 'Not Connected')}
|
||||
size="small"
|
||||
color={getStatusColor(task?.status || null, platformStatus?.connected || false) as any}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatDate(task?.last_check || null)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatDate(task?.next_check || null)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Box display="flex" gap={1} justifyContent="flex-end">
|
||||
<Tooltip title="View details">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setExpandedPlatform(
|
||||
expandedPlatform === platform ? null : platform
|
||||
)}
|
||||
>
|
||||
{expandedPlatform === platform ? (
|
||||
<ChevronUp size={16} />
|
||||
) : (
|
||||
<ChevronDown size={16} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{platformStatus?.connected && (
|
||||
<Tooltip title="Manually refresh token">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleRefresh(platform)}
|
||||
disabled={refreshing === platform}
|
||||
>
|
||||
{refreshing === platform ? (
|
||||
<CircularProgress size={16} />
|
||||
) : (
|
||||
<RefreshCw size={16} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} sx={{ py: 0, border: 0 }}>
|
||||
<Collapse in={expandedPlatform === platform}>
|
||||
<Box p={2} bgcolor="grey.50">
|
||||
{task?.failure_reason && (
|
||||
<Alert severity="error" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
Last Failure:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{task.failure_reason}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(task.last_failure || null)}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
{task?.last_success && (
|
||||
<Alert severity="success" sx={{ mb: 1 }}>
|
||||
<Typography variant="body2">
|
||||
Last successful check: {formatDate(task.last_success)}
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
{!task && platformStatus?.connected && (
|
||||
<Alert severity="info">
|
||||
<Typography variant="body2">
|
||||
Platform is connected but no monitoring task exists.
|
||||
Monitoring tasks are created automatically after onboarding.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
{!platformStatus?.connected && (
|
||||
<Alert severity="warning">
|
||||
<Typography variant="body2">
|
||||
Platform is not connected. Connect it in onboarding step 5.
|
||||
</Typography>
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthTokenStatusPanel;
|
||||
|
||||
298
frontend/src/components/Research/ResearchPersonaModal.tsx
Normal file
298
frontend/src/components/Research/ResearchPersonaModal.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Research Persona Generation Modal
|
||||
*
|
||||
* Prompts user to generate research persona if it doesn't exist.
|
||||
* Explains benefits and allows user to generate or skip.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Psychology as PsychologyIcon,
|
||||
AutoAwesome as AutoAwesomeIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
Search as SearchIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Close as CloseIcon
|
||||
} from '@mui/icons-material';
|
||||
import { refreshResearchPersona } from '../../api/researchConfig';
|
||||
import { triggerSubscriptionError } from '../../api/client';
|
||||
|
||||
interface ResearchPersonaModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onGenerate: () => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ResearchPersonaModal: React.FC<ResearchPersonaModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onGenerate,
|
||||
onCancel
|
||||
}) => {
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Debug: Track modal open state
|
||||
React.useEffect(() => {
|
||||
console.log('[ResearchPersonaModal] Modal open state:', open);
|
||||
if (open) {
|
||||
console.log('[ResearchPersonaModal] ✅ Modal is now OPEN');
|
||||
} else {
|
||||
console.log('[ResearchPersonaModal] Modal is CLOSED');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onGenerate();
|
||||
// Close modal on success
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
// Check if this is a subscription error (429/402)
|
||||
// The apiClient interceptor should have already handled it via the global handler
|
||||
// We just need to check if the global handler suppressed it (subscription is active)
|
||||
const status = err?.response?.status;
|
||||
if (status === 429 || status === 402) {
|
||||
console.log('[ResearchPersonaModal] Detected subscription error', {
|
||||
status,
|
||||
data: err?.response?.data
|
||||
});
|
||||
|
||||
// The global handler in apiClient interceptor should have already processed this
|
||||
// If subscription is active, the global handler suppresses the modal
|
||||
// If subscription is inactive, the global handler shows the modal
|
||||
// We just need to avoid showing a duplicate error message
|
||||
// Wait a moment to see if the global handler shows the modal
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// If the global handler showed the modal, it will handle it
|
||||
// We just stop here and don't show a local error
|
||||
setGenerating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// For non-subscription errors, show local error message
|
||||
setError(err instanceof Error ? err.message : 'Failed to generate research persona');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!generating) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
disableEscapeKeyDown={generating}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
background: 'linear-gradient(135deg, #fff 0%, #f8fafc 100%)',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
// Force dark text colors for readability on light background
|
||||
color: '#1e293b',
|
||||
'& *': {
|
||||
color: 'inherit',
|
||||
},
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ textAlign: 'center', pb: 1, color: '#0f172a' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2 }}>
|
||||
<PsychologyIcon sx={{ fontSize: 32, color: 'primary.main' }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, color: '#0f172a' }}>
|
||||
Generate Research Persona
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ px: 4, py: 2, color: '#1e293b' }}>
|
||||
<Typography variant="body1" sx={{ mb: 3, textAlign: 'center', color: '#475569' }}>
|
||||
Enhance your research experience with AI-powered personalization based on your business profile and preferences.
|
||||
</Typography>
|
||||
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{
|
||||
mb: 3,
|
||||
backgroundColor: '#e0f2fe',
|
||||
borderColor: '#7dd3fc',
|
||||
'& .MuiAlert-icon': {
|
||||
color: '#0284c7',
|
||||
},
|
||||
'& .MuiAlert-message': {
|
||||
color: '#0c4a6e',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5, color: '#0c4a6e' }}>
|
||||
Why generate a research persona?
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#075985', display: 'block' }}>
|
||||
Your research persona learns from your onboarding data to provide personalized research suggestions,
|
||||
keyword expansions, and research angles tailored to your industry and audience.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1.5, color: '#0f172a' }}>
|
||||
Benefits:
|
||||
</Typography>
|
||||
<List dense sx={{ py: 0 }}>
|
||||
<ListItem sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<AutoAwesomeIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={<Typography sx={{ color: '#1e293b', fontWeight: 500 }}>Smart Keyword Expansion</Typography>}
|
||||
secondary={<Typography sx={{ color: '#64748b', fontSize: '0.875rem' }}>Automatically expand your keywords with industry-specific terms</Typography>}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<TrendingUpIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={<Typography sx={{ color: '#1e293b', fontWeight: 500 }}>Alternative Research Angles</Typography>}
|
||||
secondary={<Typography sx={{ color: '#64748b', fontSize: '0.875rem' }}>Discover new research directions based on your business context</Typography>}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<SearchIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={<Typography sx={{ color: '#1e293b', fontWeight: 500 }}>Personalized Research Presets</Typography>}
|
||||
secondary={<Typography sx={{ color: '#64748b', fontSize: '0.875rem' }}>Get recommended research configurations tailored to your needs</Typography>}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem sx={{ px: 0, py: 0.5 }}>
|
||||
<ListItemIcon sx={{ minWidth: 36 }}>
|
||||
<CheckCircleIcon fontSize="small" color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={<Typography sx={{ color: '#1e293b', fontWeight: 500 }}>Better Search Results</Typography>}
|
||||
secondary={<Typography sx={{ color: '#64748b', fontSize: '0.875rem' }}>Improved query enhancement and domain suggestions for your industry</Typography>}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="caption" sx={{ color: '#64748b', fontStyle: 'italic' }}>
|
||||
Note: This process takes about 30-60 seconds and uses your AI provider.
|
||||
You can continue using rule-based suggestions if you skip this step.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 4, pb: 3, justifyContent: 'space-between', gap: 2 }}>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
disabled={generating}
|
||||
startIcon={<CloseIcon />}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
color: '#475569',
|
||||
borderColor: '#cbd5e1',
|
||||
'&:hover': {
|
||||
borderColor: '#94a3b8',
|
||||
backgroundColor: 'rgba(148, 163, 184, 0.08)',
|
||||
},
|
||||
px: 3,
|
||||
py: 1.25,
|
||||
}}
|
||||
>
|
||||
Skip for Now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
variant="contained"
|
||||
startIcon={generating ? <CircularProgress size={18} sx={{ color: 'white' }} /> : <PsychologyIcon />}
|
||||
sx={{
|
||||
minWidth: 180,
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
background: generating
|
||||
? 'linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%)'
|
||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
boxShadow: generating
|
||||
? '0 4px 14px rgba(139, 92, 246, 0.3)'
|
||||
: '0 8px 20px rgba(102, 126, 234, 0.4), 0 0 0 1px rgba(102, 126, 234, 0.1) inset',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
|
||||
boxShadow: '0 12px 28px rgba(102, 126, 234, 0.5), 0 0 0 1px rgba(102, 126, 234, 0.2) inset',
|
||||
transform: 'translateY(-1px)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'translateY(0)',
|
||||
boxShadow: '0 4px 14px rgba(102, 126, 234, 0.4)',
|
||||
},
|
||||
'&:disabled': {
|
||||
background: 'linear-gradient(135deg, #cbd5e1 0%, #94a3b8 100%)',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': generating ? {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '-100%',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent)',
|
||||
animation: 'shimmer 2s infinite',
|
||||
} : {},
|
||||
'@keyframes shimmer': {
|
||||
'0%': { left: '-100%' },
|
||||
'100%': { left: '100%' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{generating ? 'Generating...' : 'Generate Persona'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,14 +5,24 @@ import { ResearchInput } from './steps/ResearchInput';
|
||||
import { StepProgress } from './steps/StepProgress';
|
||||
import { StepResults } from './steps/StepResults';
|
||||
import { ResearchWizardProps } from './types/research.types';
|
||||
import { addResearchHistory } from '../../utils/researchHistory';
|
||||
|
||||
export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
||||
onComplete,
|
||||
onCancel,
|
||||
initialKeywords,
|
||||
initialIndustry,
|
||||
initialTargetAudience,
|
||||
initialResearchMode,
|
||||
initialConfig,
|
||||
}) => {
|
||||
const wizard = useResearchWizard(initialKeywords, initialIndustry);
|
||||
const wizard = useResearchWizard(
|
||||
initialKeywords,
|
||||
initialIndustry,
|
||||
initialTargetAudience,
|
||||
initialResearchMode,
|
||||
initialConfig
|
||||
);
|
||||
const execution = useResearchExecution();
|
||||
|
||||
// Handle results from execution
|
||||
@@ -30,12 +40,28 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
||||
}
|
||||
}, [execution.result, execution.isExecuting]); // Don't depend on currentStep to avoid loops
|
||||
|
||||
// Handle completion callback
|
||||
// Handle completion callback and track history
|
||||
useEffect(() => {
|
||||
if (wizard.state.results && onComplete) {
|
||||
// Track in research history when results are available
|
||||
if (wizard.state.keywords.length > 0) {
|
||||
// Extract a summary from results if available
|
||||
const resultSummary = wizard.state.results.suggested_angles?.[0] ||
|
||||
wizard.state.results.keyword_analysis?.primary_keywords?.[0] ||
|
||||
wizard.state.results.sources?.[0]?.title;
|
||||
|
||||
addResearchHistory({
|
||||
keywords: wizard.state.keywords,
|
||||
industry: wizard.state.industry,
|
||||
targetAudience: wizard.state.targetAudience,
|
||||
researchMode: wizard.state.researchMode,
|
||||
resultSummary,
|
||||
});
|
||||
}
|
||||
|
||||
onComplete(wizard.state.results);
|
||||
}
|
||||
}, [wizard.state.results, onComplete]);
|
||||
}, [wizard.state.results, wizard.state.keywords, wizard.state.industry, wizard.state.targetAudience, wizard.state.researchMode, onComplete]);
|
||||
|
||||
const renderStep = () => {
|
||||
const stepProps = {
|
||||
|
||||
@@ -23,9 +23,28 @@ const defaultState: WizardState = {
|
||||
results: null,
|
||||
};
|
||||
|
||||
export const useResearchWizard = (initialKeywords?: string[], initialIndustry?: string) => {
|
||||
export const useResearchWizard = (
|
||||
initialKeywords?: string[],
|
||||
initialIndustry?: string,
|
||||
initialTargetAudience?: string,
|
||||
initialResearchMode?: ResearchMode,
|
||||
initialConfig?: ResearchConfig
|
||||
) => {
|
||||
const [state, setState] = useState<WizardState>(() => {
|
||||
// Try to load from localStorage first
|
||||
// If initial values are provided (preset clicked), clear localStorage and use them
|
||||
if (initialKeywords || initialIndustry || initialTargetAudience || initialResearchMode || initialConfig) {
|
||||
localStorage.removeItem(WIZARD_STATE_KEY);
|
||||
return {
|
||||
...defaultState,
|
||||
keywords: initialKeywords || [],
|
||||
industry: initialIndustry || defaultState.industry,
|
||||
targetAudience: initialTargetAudience || defaultState.targetAudience,
|
||||
researchMode: initialResearchMode || defaultState.researchMode,
|
||||
config: initialConfig || defaultState.config,
|
||||
};
|
||||
}
|
||||
|
||||
// Try to load from localStorage only if no initial values
|
||||
const saved = localStorage.getItem(WIZARD_STATE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
@@ -36,14 +55,26 @@ export const useResearchWizard = (initialKeywords?: string[], initialIndustry?:
|
||||
}
|
||||
}
|
||||
|
||||
// Use defaults or initial values
|
||||
return {
|
||||
...defaultState,
|
||||
keywords: initialKeywords || [],
|
||||
industry: initialIndustry || defaultState.industry,
|
||||
};
|
||||
// Use defaults
|
||||
return defaultState;
|
||||
});
|
||||
|
||||
// Update state when initial values change (preset clicked)
|
||||
useEffect(() => {
|
||||
if (initialKeywords || initialIndustry || initialTargetAudience || initialResearchMode || initialConfig) {
|
||||
localStorage.removeItem(WIZARD_STATE_KEY);
|
||||
setState({
|
||||
...defaultState,
|
||||
keywords: initialKeywords || [],
|
||||
industry: initialIndustry || defaultState.industry,
|
||||
targetAudience: initialTargetAudience || defaultState.targetAudience,
|
||||
researchMode: initialResearchMode || defaultState.researchMode,
|
||||
config: initialConfig || defaultState.config,
|
||||
results: null, // Clear any previous results
|
||||
});
|
||||
}
|
||||
}, [initialKeywords, initialIndustry, initialTargetAudience, initialResearchMode, initialConfig]);
|
||||
|
||||
// Persist state to localStorage
|
||||
useEffect(() => {
|
||||
if (state.currentStep > 1) {
|
||||
@@ -74,10 +105,13 @@ export const useResearchWizard = (initialKeywords?: string[], initialIndustry?:
|
||||
...defaultState,
|
||||
keywords: initialKeywords || [],
|
||||
industry: initialIndustry || defaultState.industry,
|
||||
targetAudience: initialTargetAudience || defaultState.targetAudience,
|
||||
researchMode: initialResearchMode || defaultState.researchMode,
|
||||
config: initialConfig || defaultState.config,
|
||||
};
|
||||
setState(resetState);
|
||||
localStorage.removeItem(WIZARD_STATE_KEY);
|
||||
}, [initialKeywords, initialIndustry]);
|
||||
}, [initialKeywords, initialIndustry, initialTargetAudience, initialResearchMode, initialConfig]);
|
||||
|
||||
const clearResults = useCallback(() => {
|
||||
setState(prev => ({ ...prev, results: null }));
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { WizardStepProps } from '../types/research.types';
|
||||
import { ResearchProvider } from '../../../services/blogWriterApi';
|
||||
import { ResearchProvider, ResearchMode } from '../../../services/blogWriterApi';
|
||||
import { getResearchConfig, ProviderAvailability } from '../../../api/researchConfig';
|
||||
import {
|
||||
getResearchHistory,
|
||||
clearResearchHistory,
|
||||
formatHistoryTimestamp,
|
||||
getHistorySummary,
|
||||
ResearchHistoryEntry
|
||||
} from '../../../utils/researchHistory';
|
||||
import {
|
||||
expandKeywords,
|
||||
formatKeyword,
|
||||
isOriginalKeyword
|
||||
} from '../../../utils/keywordExpansion';
|
||||
import {
|
||||
generateResearchAngles,
|
||||
formatAngle
|
||||
} from '../../../utils/researchAngles';
|
||||
|
||||
const industries = [
|
||||
'General',
|
||||
@@ -53,30 +70,365 @@ const exaSearchTypes = [
|
||||
{ value: 'neural', label: 'Neural - Semantic search' },
|
||||
];
|
||||
|
||||
// Dynamic placeholder examples showcasing research capabilities
|
||||
const placeholderExamples = [
|
||||
"AI-powered content marketing strategies for SaaS startups\n\nExplores:\n• Latest automation tools and platforms\n• ROI optimization techniques\n• Multi-channel campaign orchestration\n• Data-driven personalization strategies",
|
||||
"Sustainable supply chain management in manufacturing\n\nCovers:\n• Green logistics and carbon footprint reduction\n• Blockchain for transparency and traceability\n• Circular economy implementation frameworks\n• Real-time inventory optimization with AI",
|
||||
"Emerging trends in telemedicine and remote patient monitoring\n\nIncludes:\n• Wearable device integration and IoT sensors\n• HIPAA-compliant data transmission protocols\n• AI-assisted diagnostic accuracy improvements\n• Patient engagement and adherence strategies",
|
||||
"Cryptocurrency regulation and institutional adoption\n\nAnalyzes:\n• Global regulatory frameworks and compliance\n• Institutional investment trends (2024-2025)\n• DeFi integration with traditional finance\n• Risk management and security best practices",
|
||||
"Voice search optimization and conversational AI for e-commerce\n\nFeatures:\n• Natural language processing advancements\n• Smart speaker integration strategies\n• Voice-enabled checkout experiences\n• Personalization through voice analytics"
|
||||
];
|
||||
// Intelligent input parser - handles sentences, keywords, URLs
|
||||
const parseIntelligentInput = (value: string): string[] => {
|
||||
// If empty, return empty array
|
||||
if (!value.trim()) return [];
|
||||
|
||||
// Detect if input contains URLs
|
||||
const urlPattern = /(https?:\/\/[^\s,]+)/g;
|
||||
const urls = value.match(urlPattern) || [];
|
||||
|
||||
// Check if input looks like a sentence/paragraph (contains multiple words without commas)
|
||||
const hasCommas = value.includes(',');
|
||||
const wordCount = value.trim().split(/\s+/).length;
|
||||
|
||||
if (urls.length > 0) {
|
||||
// User provided URLs - extract them as separate keywords
|
||||
const textWithoutUrls = value.replace(urlPattern, '').trim();
|
||||
const textKeywords = textWithoutUrls ? [textWithoutUrls] : [];
|
||||
return [...urls, ...textKeywords];
|
||||
} else if (!hasCommas && wordCount > 5) {
|
||||
// Looks like a sentence/paragraph - treat entire input as single research topic
|
||||
return [value.trim()];
|
||||
} else if (hasCommas) {
|
||||
// Traditional comma-separated keywords
|
||||
return value.split(',').map(k => k.trim()).filter(Boolean);
|
||||
} else {
|
||||
// Short phrase or single keyword
|
||||
return [value.trim()];
|
||||
}
|
||||
};
|
||||
|
||||
// Industry-specific placeholder examples for personalized experience
|
||||
const getIndustryPlaceholders = (industry: string): string[] => {
|
||||
const industryExamples: Record<string, string[]> = {
|
||||
Healthcare: [
|
||||
"Research: AI-powered diagnostic tools in clinical practice\n\n💡 What you'll get:\n• FDA-approved AI medical devices\n• Clinical accuracy and patient outcomes\n• Implementation costs and ROI",
|
||||
"Analyze: Telemedicine adoption trends and patient satisfaction\n\n💡 Research includes:\n• Post-pandemic telehealth growth\n• Remote patient monitoring technologies\n• Insurance coverage and reimbursement",
|
||||
"Investigate: Personalized medicine and genomic testing advances\n\n💡 You'll discover:\n• Latest genomic sequencing technologies\n• Precision therapy success rates\n• Ethical considerations and regulations"
|
||||
],
|
||||
Technology: [
|
||||
"Investigate: Latest developments in edge computing and IoT\n\n💡 What you'll get:\n• Edge AI deployment strategies\n• 5G integration and performance\n• Industry use cases and benchmarks",
|
||||
"Compare: Cloud providers for enterprise SaaS applications\n\n💡 Research includes:\n• AWS vs Azure vs GCP feature comparison\n• Cost optimization strategies\n• Security and compliance certifications",
|
||||
"Analyze: Quantum computing breakthroughs and commercial applications\n\n💡 You'll discover:\n• Latest quantum hardware developments\n• Real-world problem solving examples\n• Investment landscape and timeline"
|
||||
],
|
||||
Finance: [
|
||||
"Research: DeFi regulatory landscape and compliance challenges\n\n💡 What you'll get:\n• Global regulatory frameworks\n• Compliance best practices\n• Risk management strategies",
|
||||
"Analyze: Digital banking customer retention strategies\n\n💡 Research includes:\n• Neobank growth and market share\n• Customer acquisition costs and LTV\n• Personalization and UX innovations",
|
||||
"Investigate: ESG investing trends and impact measurement\n\n💡 You'll discover:\n• ESG rating methodologies\n• Fund performance and returns\n• Regulatory requirements and reporting"
|
||||
],
|
||||
Marketing: [
|
||||
"Research: AI-powered marketing automation and personalization\n\n💡 What you'll get:\n• Top marketing AI platforms and features\n• ROI and conversion rate improvements\n• Implementation case studies",
|
||||
"Analyze: Influencer marketing ROI and authenticity trends\n\n💡 Research includes:\n• Micro vs macro influencer effectiveness\n• Platform-specific engagement rates\n• Brand partnership best practices",
|
||||
"Investigate: Privacy-first marketing in a cookieless world\n\n💡 You'll discover:\n• First-party data strategies\n• Contextual targeting innovations\n• Compliance with privacy regulations"
|
||||
],
|
||||
Business: [
|
||||
"Research: Remote work policies and hybrid workplace models\n\n💡 What you'll get:\n• Productivity metrics and employee satisfaction\n• Technology infrastructure requirements\n• Cultural impact and change management",
|
||||
"Analyze: Supply chain resilience and diversification strategies\n\n💡 Research includes:\n• Nearshoring and reshoring trends\n• Technology solutions for visibility\n• Risk mitigation frameworks",
|
||||
"Investigate: Sustainability initiatives and corporate ESG programs\n\n💡 You'll discover:\n• Industry-specific sustainability benchmarks\n• Cost-benefit analysis of green initiatives\n• Stakeholder communication strategies"
|
||||
],
|
||||
Education: [
|
||||
"Research: EdTech tools for personalized learning experiences\n\n💡 What you'll get:\n• Adaptive learning platform comparisons\n• Student engagement and outcomes data\n• Implementation costs and training needs",
|
||||
"Analyze: Microlearning and skill-based education trends\n\n💡 Research includes:\n• Corporate training effectiveness\n• Platform and content recommendations\n• ROI and completion rates",
|
||||
"Investigate: AI tutoring systems and student support tools\n\n💡 You'll discover:\n• Natural language processing advances\n• Student performance improvements\n• Accessibility and inclusion features"
|
||||
],
|
||||
'Real Estate': [
|
||||
"Research: PropTech innovations transforming property management\n\n💡 What you'll get:\n• Smart building technologies and IoT\n• Tenant experience platforms\n• Operational efficiency gains",
|
||||
"Analyze: Virtual staging and 3D property tours adoption\n\n💡 Research includes:\n• Technology provider comparisons\n• Impact on sales velocity and pricing\n• Cost vs traditional staging",
|
||||
"Investigate: Real estate tokenization and fractional ownership\n\n💡 You'll discover:\n• Blockchain platforms and regulations\n• Investor demographics and demand\n• Liquidity and exit strategies"
|
||||
],
|
||||
Travel: [
|
||||
"Research: Sustainable tourism trends and eco-travel preferences\n\n💡 What you'll get:\n• Green certification programs\n• Traveler willingness to pay premium\n• Destination best practices",
|
||||
"Analyze: AI-powered travel personalization and recommendations\n\n💡 Research includes:\n• Recommendation engine technologies\n• Booking conversion rate improvements\n• Customer lifetime value impact",
|
||||
"Investigate: Bleisure travel and workation destination trends\n\n💡 You'll discover:\n• Remote work-friendly destinations\n• Co-working and accommodation options\n• Digital nomad demographics"
|
||||
]
|
||||
};
|
||||
|
||||
return industryExamples[industry] || [
|
||||
"Research: Latest AI advancements in your industry\n\n💡 What you'll get:\n• Recent breakthroughs and innovations\n• Key companies and technologies\n• Expert insights and market trends",
|
||||
|
||||
"Write a blog on: Emerging trends shaping your industry in 2025\n\n💡 This will research:\n• Technology disruptions and innovations\n• Regulatory changes and compliance\n• Consumer behavior shifts",
|
||||
|
||||
"Analyze: Best practices and success stories in your field\n\n💡 Research includes:\n• Industry leader strategies\n• Implementation case studies\n• ROI and performance metrics",
|
||||
|
||||
"https://example.com/article\n\n💡 URL detected! Research will:\n• Extract key insights from the article\n• Find related sources and updates\n• Provide comprehensive context"
|
||||
];
|
||||
};
|
||||
|
||||
export const ResearchInput: React.FC<WizardStepProps> = ({ state, onUpdate }) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [currentPlaceholder, setCurrentPlaceholder] = useState(0);
|
||||
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability | null>(null);
|
||||
const [loadingConfig, setLoadingConfig] = useState(true);
|
||||
const [suggestedMode, setSuggestedMode] = useState<ResearchMode | null>(null);
|
||||
const [researchHistory, setResearchHistory] = useState<ResearchHistoryEntry[]>([]);
|
||||
const [keywordExpansion, setKeywordExpansion] = useState<{
|
||||
original: string[];
|
||||
expanded: string[];
|
||||
suggestions: string[];
|
||||
} | null>(null);
|
||||
const [researchAngles, setResearchAngles] = useState<string[]>([]);
|
||||
|
||||
// Load research history on mount and when component updates
|
||||
useEffect(() => {
|
||||
const history = getResearchHistory();
|
||||
setResearchHistory(history);
|
||||
}, []); // Load once on mount
|
||||
|
||||
// Reload history when keywords change (after research completes)
|
||||
useEffect(() => {
|
||||
const history = getResearchHistory();
|
||||
setResearchHistory(history);
|
||||
}, [state.keywords]);
|
||||
|
||||
// Load research configuration on mount
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const config = await getResearchConfig();
|
||||
|
||||
// Set provider availability with fallback
|
||||
setProviderAvailability(config?.provider_availability || {
|
||||
google_available: true, // Default to available, will be corrected by actual key status
|
||||
exa_available: false,
|
||||
gemini_key_status: 'missing',
|
||||
exa_key_status: 'missing'
|
||||
});
|
||||
|
||||
// Apply persona defaults if not already set (with null checks)
|
||||
if (config?.persona_defaults) {
|
||||
if (config.persona_defaults.industry && state.industry === 'General') {
|
||||
onUpdate({ industry: config.persona_defaults.industry });
|
||||
}
|
||||
if (config.persona_defaults.target_audience && state.targetAudience === 'General') {
|
||||
onUpdate({ targetAudience: config.persona_defaults.target_audience });
|
||||
}
|
||||
|
||||
// Apply suggested Exa domains if Exa is available and not already set
|
||||
if (config.provider_availability?.exa_available && config.persona_defaults.suggested_domains?.length > 0) {
|
||||
if (!state.config.exa_include_domains || state.config.exa_include_domains.length === 0) {
|
||||
onUpdate({
|
||||
config: {
|
||||
...state.config,
|
||||
exa_include_domains: config.persona_defaults.suggested_domains
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply suggested Exa category if available
|
||||
if (config.persona_defaults.suggested_exa_category && !state.config.exa_category) {
|
||||
onUpdate({
|
||||
config: {
|
||||
...state.config,
|
||||
exa_category: config.persona_defaults.suggested_exa_category
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
console.error('[ResearchInput] Failed to load research config:', errorMessage);
|
||||
|
||||
// Set default provider availability on error
|
||||
setProviderAvailability({
|
||||
google_available: true, // Optimistically assume available
|
||||
exa_available: false,
|
||||
gemini_key_status: 'missing',
|
||||
exa_key_status: 'missing'
|
||||
});
|
||||
|
||||
// Continue with defaults - don't block the UI
|
||||
} finally {
|
||||
setLoadingConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadConfig();
|
||||
}, []); // Only run once on mount
|
||||
|
||||
// Get industry-specific placeholders
|
||||
const placeholderExamples = getIndustryPlaceholders(state.industry);
|
||||
|
||||
// Rotate placeholder examples every 4 seconds
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentPlaceholder((prev) => (prev + 1) % placeholderExamples.length);
|
||||
}, 4000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [placeholderExamples.length]);
|
||||
|
||||
// Reset placeholder index when industry changes
|
||||
useEffect(() => {
|
||||
setCurrentPlaceholder(0);
|
||||
}, [state.industry]);
|
||||
|
||||
// Auto-set provider based on research mode
|
||||
useEffect(() => {
|
||||
if (!providerAvailability) return;
|
||||
|
||||
let newProvider: ResearchProvider = 'google';
|
||||
|
||||
switch (state.researchMode) {
|
||||
case 'basic':
|
||||
// Basic: Google only (fast, simple)
|
||||
newProvider = 'google';
|
||||
break;
|
||||
case 'comprehensive':
|
||||
// Comprehensive: Prefer Exa if available, fallback to Google
|
||||
newProvider = providerAvailability.exa_available ? 'exa' : 'google';
|
||||
break;
|
||||
case 'targeted':
|
||||
// Targeted: Prefer Exa if available, fallback to Google
|
||||
newProvider = providerAvailability.exa_available ? 'exa' : 'google';
|
||||
break;
|
||||
}
|
||||
|
||||
// Only update if provider changed
|
||||
if (state.config.provider !== newProvider) {
|
||||
onUpdate({ config: { ...state.config, provider: newProvider } });
|
||||
}
|
||||
}, [state.researchMode, providerAvailability]);
|
||||
|
||||
// Dynamic domain suggestions when industry changes
|
||||
useEffect(() => {
|
||||
if (!providerAvailability || state.industry === 'General') return;
|
||||
|
||||
// Get industry-specific domain suggestions (from backend logic)
|
||||
const domainMap: Record<string, string[]> = {
|
||||
'Healthcare': ['pubmed.gov', 'nejm.org', 'thelancet.com', 'nih.gov'],
|
||||
'Technology': ['techcrunch.com', 'wired.com', 'arstechnica.com', 'theverge.com'],
|
||||
'Finance': ['wsj.com', 'bloomberg.com', 'ft.com', 'reuters.com'],
|
||||
'Science': ['nature.com', 'sciencemag.org', 'cell.com', 'pnas.org'],
|
||||
'Business': ['hbr.org', 'forbes.com', 'businessinsider.com', 'mckinsey.com'],
|
||||
'Marketing': ['marketingland.com', 'adweek.com', 'hubspot.com', 'moz.com'],
|
||||
'Education': ['edutopia.org', 'chronicle.com', 'insidehighered.com'],
|
||||
'Real Estate': ['realtor.com', 'zillow.com', 'forbes.com'],
|
||||
'Entertainment': ['variety.com', 'hollywoodreporter.com', 'deadline.com'],
|
||||
'Travel': ['lonelyplanet.com', 'nationalgeographic.com', 'travelandleisure.com'],
|
||||
'Fashion': ['vogue.com', 'elle.com', 'wwd.com'],
|
||||
'Sports': ['espn.com', 'si.com', 'bleacherreport.com'],
|
||||
'Law': ['law.com', 'abajournal.com', 'scotusblog.com'],
|
||||
};
|
||||
|
||||
const newDomains = domainMap[state.industry] || [];
|
||||
|
||||
// Get industry-specific Exa category
|
||||
const categoryMap: Record<string, string> = {
|
||||
'Healthcare': 'research paper',
|
||||
'Science': 'research paper',
|
||||
'Finance': 'financial report',
|
||||
'Technology': 'company',
|
||||
'Business': 'company',
|
||||
'Marketing': 'company',
|
||||
'Education': 'research paper',
|
||||
'Law': 'pdf',
|
||||
};
|
||||
|
||||
const newCategory = categoryMap[state.industry];
|
||||
|
||||
// Only update if Exa is available and domains/category should change
|
||||
if (providerAvailability.exa_available && newDomains.length > 0) {
|
||||
const configUpdates: any = {};
|
||||
|
||||
// Update domains if different
|
||||
const currentDomains = state.config.exa_include_domains || [];
|
||||
if (JSON.stringify(currentDomains) !== JSON.stringify(newDomains)) {
|
||||
configUpdates.exa_include_domains = newDomains;
|
||||
}
|
||||
|
||||
// Update category if available and different
|
||||
if (newCategory && state.config.exa_category !== newCategory) {
|
||||
configUpdates.exa_category = newCategory;
|
||||
}
|
||||
|
||||
// Apply updates if any
|
||||
if (Object.keys(configUpdates).length > 0) {
|
||||
onUpdate({
|
||||
config: {
|
||||
...state.config,
|
||||
...configUpdates
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [state.industry, providerAvailability]);
|
||||
|
||||
// Smart mode suggestion based on query complexity
|
||||
const suggestResearchMode = (keywords: string[]): ResearchMode => {
|
||||
if (keywords.length === 0) return 'basic';
|
||||
|
||||
const totalText = keywords.join(' ');
|
||||
const totalWords = totalText.split(/\s+/).length;
|
||||
const hasURL = keywords.some(k => k.startsWith('http'));
|
||||
|
||||
// URL detected → comprehensive research
|
||||
if (hasURL) return 'comprehensive';
|
||||
|
||||
// Long detailed query → comprehensive
|
||||
if (totalWords > 20) return 'comprehensive';
|
||||
|
||||
// Medium complexity → targeted
|
||||
if (totalWords > 10 || keywords.length > 3) return 'targeted';
|
||||
|
||||
// Simple query → basic
|
||||
return 'basic';
|
||||
};
|
||||
|
||||
// Expand keywords when keywords or industry changes
|
||||
useEffect(() => {
|
||||
if (state.keywords.length > 0 && state.industry !== 'General') {
|
||||
const expansion = expandKeywords(state.keywords, state.industry);
|
||||
setKeywordExpansion(expansion);
|
||||
} else {
|
||||
setKeywordExpansion(null);
|
||||
}
|
||||
}, [state.keywords, state.industry]);
|
||||
|
||||
// Generate research angles when keywords change
|
||||
useEffect(() => {
|
||||
if (state.keywords.length > 0) {
|
||||
// Use the first keyword (or joined keywords) as the query
|
||||
const query = state.keywords.join(' ');
|
||||
const angles = generateResearchAngles(query, state.industry);
|
||||
setResearchAngles(angles);
|
||||
} else {
|
||||
setResearchAngles([]);
|
||||
}
|
||||
}, [state.keywords, state.industry]);
|
||||
|
||||
const handleKeywordsChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const keywords = value.split(',').map(k => k.trim()).filter(Boolean);
|
||||
const keywords = parseIntelligentInput(value);
|
||||
onUpdate({ keywords });
|
||||
|
||||
// Update suggested mode
|
||||
const suggested = suggestResearchMode(keywords);
|
||||
setSuggestedMode(suggested);
|
||||
};
|
||||
|
||||
// Handle clicking a keyword suggestion to add it
|
||||
const handleAddSuggestion = (suggestion: string) => {
|
||||
const currentKeywords = [...state.keywords];
|
||||
// Check if suggestion already exists (case-insensitive)
|
||||
const exists = currentKeywords.some(k => k.toLowerCase() === suggestion.toLowerCase());
|
||||
if (!exists) {
|
||||
currentKeywords.push(suggestion);
|
||||
onUpdate({ keywords: currentKeywords });
|
||||
}
|
||||
};
|
||||
|
||||
// Handle removing a keyword
|
||||
const handleRemoveKeyword = (keywordToRemove: string) => {
|
||||
const currentKeywords = state.keywords.filter(k => k.toLowerCase() !== keywordToRemove.toLowerCase());
|
||||
onUpdate({ keywords: currentKeywords });
|
||||
};
|
||||
|
||||
// Handle clicking a research angle to use it
|
||||
const handleUseAngle = (angle: string) => {
|
||||
// Parse the angle as a new research query
|
||||
const keywords = parseIntelligentInput(angle);
|
||||
onUpdate({ keywords });
|
||||
};
|
||||
|
||||
@@ -168,6 +520,129 @@ export const ResearchInput: React.FC<WizardStepProps> = ({ state, onUpdate }) =>
|
||||
Research Topic & Keywords
|
||||
</label>
|
||||
|
||||
{/* Research History */}
|
||||
{researchHistory.length > 0 && (
|
||||
<div style={{
|
||||
marginBottom: '12px',
|
||||
padding: '12px',
|
||||
background: 'rgba(14, 165, 233, 0.03)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.1)',
|
||||
borderRadius: '10px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#0369a1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}>
|
||||
<span>🕐</span>
|
||||
Recently Researched
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
clearResearchHistory();
|
||||
setResearchHistory([]);
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
fontSize: '11px',
|
||||
color: '#64748b',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(100, 116, 139, 0.2)',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||
e.currentTarget.style.borderColor = 'rgba(239, 68, 68, 0.3)';
|
||||
e.currentTarget.style.color = '#dc2626';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.borderColor = 'rgba(100, 116, 139, 0.2)';
|
||||
e.currentTarget.style.color = '#64748b';
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
}}>
|
||||
{researchHistory.map((entry) => (
|
||||
<button
|
||||
key={entry.timestamp}
|
||||
onClick={() => {
|
||||
// Populate all fields from history entry
|
||||
onUpdate({
|
||||
keywords: entry.keywords,
|
||||
industry: entry.industry,
|
||||
targetAudience: entry.targetAudience,
|
||||
researchMode: entry.researchMode,
|
||||
});
|
||||
}}
|
||||
title={`Industry: ${entry.industry} | Audience: ${entry.targetAudience} | Mode: ${entry.researchMode} | ${formatHistoryTimestamp(entry.timestamp)}`}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
fontSize: '12px',
|
||||
color: '#0369a1',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
maxWidth: '100%',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(14, 165, 233, 0.1)';
|
||||
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.4)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(14, 165, 233, 0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.9)';
|
||||
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.2)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '14px' }}>🔍</span>
|
||||
<span style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '200px',
|
||||
}}>
|
||||
{getHistorySummary(entry)}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: '10px',
|
||||
color: '#64748b',
|
||||
marginLeft: '4px',
|
||||
}}>
|
||||
{formatHistoryTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ position: 'relative' }}>
|
||||
<textarea
|
||||
value={state.keywords.join(', ')}
|
||||
@@ -239,13 +714,290 @@ export const ResearchInput: React.FC<WizardStepProps> = ({ state, onUpdate }) =>
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Smart Input Detection Indicator */}
|
||||
{state.keywords.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: '10px',
|
||||
padding: '8px 12px',
|
||||
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.1) 0%, rgba(16, 185, 129, 0.1) 100%)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.2)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#059669',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}>
|
||||
<span>✓</span>
|
||||
{state.keywords[0]?.startsWith('http') ? (
|
||||
<span>URL detected - will extract and analyze content</span>
|
||||
) : state.keywords.length === 1 && state.keywords[0]?.split(/\s+/).length > 5 ? (
|
||||
<span>Research topic detected - will conduct comprehensive analysis</span>
|
||||
) : (
|
||||
<span>{state.keywords.length} keyword{state.keywords.length > 1 ? 's' : ''} identified</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyword Expansion Suggestions */}
|
||||
{keywordExpansion && keywordExpansion.suggestions.length > 0 && state.industry !== 'General' && (
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.05) 0%, rgba(147, 197, 253, 0.05) 100%)',
|
||||
border: '1px solid rgba(59, 130, 246, 0.15)',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#1e40af',
|
||||
}}>
|
||||
<span>💡</span>
|
||||
<span>Suggested Keywords for {state.industry}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
}}>
|
||||
{keywordExpansion.suggestions.map((suggestion, idx) => {
|
||||
const isAlreadyAdded = state.keywords.some(k => k.toLowerCase() === suggestion.toLowerCase());
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => !isAlreadyAdded && handleAddSuggestion(suggestion)}
|
||||
disabled={isAlreadyAdded}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
background: isAlreadyAdded
|
||||
? 'rgba(203, 213, 225, 0.3)'
|
||||
: 'rgba(59, 130, 246, 0.1)',
|
||||
border: `1px solid ${isAlreadyAdded ? 'rgba(148, 163, 184, 0.3)' : 'rgba(59, 130, 246, 0.2)'}`,
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
color: isAlreadyAdded ? '#64748b' : '#1e40af',
|
||||
cursor: isAlreadyAdded ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isAlreadyAdded) {
|
||||
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.15)';
|
||||
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.3)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isAlreadyAdded) {
|
||||
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.1)';
|
||||
e.currentTarget.style.borderColor = 'rgba(59, 130, 246, 0.2)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isAlreadyAdded ? (
|
||||
<>
|
||||
<span>✓</span>
|
||||
<span>{formatKeyword(suggestion)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>+</span>
|
||||
<span>{formatKeyword(suggestion)}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
fontSize: '11px',
|
||||
color: '#64748b',
|
||||
fontStyle: 'italic',
|
||||
}}>
|
||||
Click to add suggested keywords to your research query
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Keywords Display (for removal) */}
|
||||
{state.keywords.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
padding: '10px',
|
||||
background: 'rgba(241, 245, 249, 0.5)',
|
||||
border: '1px solid rgba(203, 213, 225, 0.3)',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#475569',
|
||||
marginBottom: '8px',
|
||||
}}>
|
||||
Current Keywords ({state.keywords.length})
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6px',
|
||||
}}>
|
||||
{state.keywords.map((keyword, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
padding: '5px 10px',
|
||||
background: 'white',
|
||||
border: '1px solid rgba(203, 213, 225, 0.5)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#334155',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
<span>{formatKeyword(keyword)}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveKeyword(keyword)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#ef4444',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
padding: '0',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'none';
|
||||
}}
|
||||
title="Remove keyword"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alternative Research Angles */}
|
||||
{researchAngles.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
padding: '12px',
|
||||
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.05) 0%, rgba(139, 92, 246, 0.05) 100%)',
|
||||
border: '1px solid rgba(168, 85, 247, 0.15)',
|
||||
borderRadius: '8px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
marginBottom: '10px',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '16px',
|
||||
}}>💡</span>
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#7c3aed',
|
||||
}}>
|
||||
Explore Alternative Research Angles
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: '10px',
|
||||
}}>
|
||||
{researchAngles.map((angle, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleUseAngle(angle)}
|
||||
style={{
|
||||
padding: '10px 14px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
border: '1px solid rgba(168, 85, 247, 0.2)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
fontWeight: '500',
|
||||
color: '#6b21a8',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '4px',
|
||||
boxShadow: '0 1px 3px rgba(168, 85, 247, 0.1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(168, 85, 247, 0.1)';
|
||||
e.currentTarget.style.borderColor = 'rgba(168, 85, 247, 0.4)';
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(168, 85, 247, 0.2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.9)';
|
||||
e.currentTarget.style.borderColor = 'rgba(168, 85, 247, 0.2)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 1px 3px rgba(168, 85, 247, 0.1)';
|
||||
}}
|
||||
title={`Click to research: ${angle}`}
|
||||
>
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}>
|
||||
<span style={{ fontSize: '14px' }}>🔍</span>
|
||||
<span>{formatAngle(angle)}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
fontSize: '11px',
|
||||
color: '#64748b',
|
||||
fontStyle: 'italic',
|
||||
}}>
|
||||
Click any angle to explore a different research focus
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: '10px',
|
||||
fontSize: '12px',
|
||||
color: '#64748b',
|
||||
lineHeight: '1.5',
|
||||
}}>
|
||||
💡 Tip: Describe your research topic in detail. Include specific keywords, questions, or aspects you want to explore. The AI will find relevant sources and insights.
|
||||
💡 Tip: Enter sentences, keywords, or URLs. The AI will intelligently parse your input and conduct comprehensive research.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -296,16 +1048,53 @@ export const ResearchInput: React.FC<WizardStepProps> = ({ state, onUpdate }) =>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Research Mode */}
|
||||
{/* Research Mode with Status Indicator */}
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#0c4a6e',
|
||||
}}>
|
||||
Research Depth
|
||||
<span>Research Depth</span>
|
||||
{providerAvailability && (
|
||||
<span style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '11px',
|
||||
color: '#64748b',
|
||||
background: 'rgba(255, 255, 255, 0.8)',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '20px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.15)',
|
||||
}}>
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: providerAvailability.google_available ? '#10b981' : '#ef4444',
|
||||
boxShadow: providerAvailability.google_available
|
||||
? '0 0 6px rgba(16, 185, 129, 0.5)'
|
||||
: '0 0 6px rgba(239, 68, 68, 0.5)',
|
||||
}} title={`Google: ${providerAvailability.gemini_key_status}`} />
|
||||
<span>Google</span>
|
||||
<span style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: providerAvailability.exa_available ? '#10b981' : '#ef4444',
|
||||
boxShadow: providerAvailability.exa_available
|
||||
? '0 0 6px rgba(16, 185, 129, 0.5)'
|
||||
: '0 0 6px rgba(239, 68, 68, 0.5)',
|
||||
marginLeft: '6px',
|
||||
}} title={`Exa: ${providerAvailability.exa_key_status}`} />
|
||||
<span>Exa</span>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<select
|
||||
value={state.researchMode}
|
||||
@@ -331,56 +1120,71 @@ export const ResearchInput: React.FC<WizardStepProps> = ({ state, onUpdate }) =>
|
||||
}}
|
||||
>
|
||||
{researchModes.map(mode => (
|
||||
<option key={mode.value} value={mode.value}>{mode.label}</option>
|
||||
<option key={mode.value} value={mode.value}>
|
||||
{mode.label}
|
||||
{mode.value === 'basic' && ' • Google Search'}
|
||||
{mode.value === 'comprehensive' && providerAvailability?.exa_available && ' • Exa Neural'}
|
||||
{mode.value === 'comprehensive' && !providerAvailability?.exa_available && ' • Google Search'}
|
||||
{mode.value === 'targeted' && providerAvailability?.exa_available && ' • Exa Neural'}
|
||||
{mode.value === 'targeted' && !providerAvailability?.exa_available && ' • Google Search'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Provider (only for Comprehensive/Targeted) */}
|
||||
{state.researchMode !== 'basic' && (
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
color: '#0c4a6e',
|
||||
}}>
|
||||
Search Provider
|
||||
</label>
|
||||
<select
|
||||
value={state.config.provider}
|
||||
onChange={handleProviderChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
fontSize: '13px',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
color: '#0f172a',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.5)';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(14, 165, 233, 0.1)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.2)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
{providers.map(prov => (
|
||||
<option key={prov.value} value={prov.value}>{prov.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{
|
||||
marginTop: '6px',
|
||||
fontSize: '11px',
|
||||
color: '#64748b',
|
||||
fontStyle: 'italic',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<span>
|
||||
{state.researchMode === 'basic' && '🔍 Fast research using Google Search'}
|
||||
{state.researchMode === 'comprehensive' && providerAvailability?.exa_available && '🧠 Deep research using Exa Neural Search'}
|
||||
{state.researchMode === 'comprehensive' && !providerAvailability?.exa_available && '🔍 In-depth research using Google Search'}
|
||||
{state.researchMode === 'targeted' && providerAvailability?.exa_available && '🎯 Focused research using Exa Neural Search'}
|
||||
{state.researchMode === 'targeted' && !providerAvailability?.exa_available && '🎯 Focused research using Google Search'}
|
||||
</span>
|
||||
{suggestedMode && suggestedMode !== state.researchMode && state.keywords.length > 0 && (
|
||||
<button
|
||||
onClick={() => onUpdate({ researchMode: suggestedMode })}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '4px 10px',
|
||||
borderRadius: '12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(16, 185, 129, 0.3)';
|
||||
}}
|
||||
title={`Switch to ${suggestedMode} mode for better results`}
|
||||
>
|
||||
<span>💡</span>
|
||||
<span>Try {suggestedMode}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exa-Specific Options */}
|
||||
{state.config.provider === 'exa' && state.researchMode !== 'basic' && (
|
||||
{/* Exa-Specific Options - Show when Exa is selected */}
|
||||
{state.config.provider === 'exa' && (
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, rgba(139, 92, 246, 0.05) 0%, rgba(99, 102, 241, 0.05) 100%)',
|
||||
border: '1px solid rgba(139, 92, 246, 0.2)',
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface ResearchWizardProps {
|
||||
onCancel?: () => void;
|
||||
initialKeywords?: string[];
|
||||
initialIndustry?: string;
|
||||
initialTargetAudience?: string;
|
||||
initialResearchMode?: ResearchMode;
|
||||
initialConfig?: ResearchConfig;
|
||||
}
|
||||
|
||||
export interface ModeCardInfo {
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Execution Logs Table Component
|
||||
* Displays task execution logs in a table with pagination and filtering.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Visibility as VisibilityIcon
|
||||
} from '@mui/icons-material';
|
||||
import { getExecutionLogs, getRecentSchedulerLogs, ExecutionLog, ExecutionLogsResponse } from '../../api/schedulerDashboard';
|
||||
import {
|
||||
TerminalPaper,
|
||||
TerminalTypography,
|
||||
TerminalChipSuccess,
|
||||
TerminalChipError,
|
||||
TerminalChipWarning,
|
||||
TerminalTableCell,
|
||||
TerminalTableRow,
|
||||
TerminalAlert,
|
||||
terminalColors
|
||||
} from './terminalTheme';
|
||||
|
||||
interface ExecutionLogsTableProps {
|
||||
initialLimit?: number;
|
||||
}
|
||||
|
||||
const ExecutionLogsTable: React.FC<ExecutionLogsTableProps> = ({ initialLimit = 50 }) => {
|
||||
const [logs, setLogs] = useState<ExecutionLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(initialLimit);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [statusFilter, setStatusFilter] = useState<'success' | 'failed' | 'running' | 'skipped' | 'all'>('all');
|
||||
const [isShowingSchedulerLogs, setIsShowingSchedulerLogs] = useState(false);
|
||||
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// First, try to fetch actual execution logs
|
||||
const response = await getExecutionLogs(
|
||||
rowsPerPage,
|
||||
page * rowsPerPage,
|
||||
statusFilter === 'all' ? undefined : statusFilter
|
||||
);
|
||||
|
||||
console.log('📋 Execution Logs Response:', JSON.stringify({
|
||||
logsCount: response.logs?.length || 0,
|
||||
totalCount: response.total_count,
|
||||
hasLogs: !!(response.logs && response.logs.length > 0),
|
||||
isSchedulerLogs: response.is_scheduler_logs,
|
||||
firstLog: response.logs?.[0] || null
|
||||
}, null, 2));
|
||||
|
||||
// If we have actual execution logs, use them
|
||||
if (response.logs && response.logs.length > 0 && !response.is_scheduler_logs) {
|
||||
console.log('✅ Using execution logs:', response.logs.length);
|
||||
setLogs(response.logs);
|
||||
setTotalCount(response.total_count || 0);
|
||||
setIsShowingSchedulerLogs(false);
|
||||
} else {
|
||||
// No execution logs available, fetch scheduler logs as fallback (latest 5 only)
|
||||
console.log('📋 No execution logs found, fetching latest scheduler logs...');
|
||||
try {
|
||||
const schedulerLogsResponse = await getRecentSchedulerLogs();
|
||||
console.log('📋 Scheduler Logs Response:', JSON.stringify({
|
||||
logsCount: schedulerLogsResponse.logs?.length || 0,
|
||||
totalCount: schedulerLogsResponse.total_count,
|
||||
isSchedulerLogs: schedulerLogsResponse.is_scheduler_logs,
|
||||
allLogs: schedulerLogsResponse.logs || []
|
||||
}, null, 2));
|
||||
|
||||
if (schedulerLogsResponse.logs && schedulerLogsResponse.logs.length > 0) {
|
||||
console.log('✅ Setting scheduler logs:', schedulerLogsResponse.logs.length, 'logs');
|
||||
setLogs(schedulerLogsResponse.logs);
|
||||
setTotalCount(schedulerLogsResponse.total_count || 0);
|
||||
setIsShowingSchedulerLogs(true);
|
||||
} else {
|
||||
console.warn('⚠️ Scheduler logs response is empty');
|
||||
setLogs([]);
|
||||
setTotalCount(0);
|
||||
setIsShowingSchedulerLogs(false);
|
||||
}
|
||||
} catch (schedulerErr: any) {
|
||||
console.error('❌ Error fetching scheduler logs:', schedulerErr);
|
||||
setLogs([]);
|
||||
setTotalCount(0);
|
||||
setIsShowingSchedulerLogs(false);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch execution logs');
|
||||
console.error('❌ Error fetching execution logs:', err);
|
||||
|
||||
// Try to fetch scheduler logs as fallback even on error (latest 5 only)
|
||||
try {
|
||||
const schedulerLogsResponse = await getRecentSchedulerLogs();
|
||||
setLogs(schedulerLogsResponse.logs || []);
|
||||
setTotalCount(schedulerLogsResponse.total_count || 0);
|
||||
setIsShowingSchedulerLogs(true);
|
||||
} catch (schedulerErr: any) {
|
||||
console.error('❌ Error fetching scheduler logs:', schedulerErr);
|
||||
setLogs([]);
|
||||
setTotalCount(0);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, rowsPerPage, statusFilter]); // fetchLogs is stable, no need to include
|
||||
|
||||
const handleChangePage = (_event: unknown, newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircleIcon fontSize="small" color="success" />;
|
||||
case 'failed':
|
||||
return <ErrorIcon fontSize="small" color="error" />;
|
||||
case 'running':
|
||||
return <ScheduleIcon fontSize="small" color="primary" />;
|
||||
default:
|
||||
return <ScheduleIcon fontSize="small" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string): "success" | "error" | "warning" | "default" => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'success';
|
||||
case 'failed':
|
||||
return 'error';
|
||||
case 'running':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const formatExecutionTime = (ms: number | null) => {
|
||||
if (!ms) return 'N/A';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 3 }}>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<ScheduleIcon sx={{ color: terminalColors.primary }} />
|
||||
<TerminalTypography variant="h6" component="h2" sx={{ fontSize: '1.25rem', fontWeight: 'bold' }}>
|
||||
Execution Logs
|
||||
</TerminalTypography>
|
||||
{isShowingSchedulerLogs && (
|
||||
<TerminalChipWarning
|
||||
label="Showing Scheduler Logs"
|
||||
size="small"
|
||||
sx={{ ml: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<FormControl
|
||||
size="small"
|
||||
sx={{
|
||||
minWidth: 120,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
color: terminalColors.primary,
|
||||
'& fieldset': {
|
||||
borderColor: terminalColors.primary,
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: terminalColors.secondary,
|
||||
},
|
||||
},
|
||||
'& .MuiInputLabel-root': {
|
||||
color: terminalColors.textSecondary,
|
||||
},
|
||||
'& .MuiSelect-icon': {
|
||||
color: terminalColors.primary,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
label="Status"
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value as any);
|
||||
setPage(0);
|
||||
}}
|
||||
MenuProps={{
|
||||
PaperProps: {
|
||||
sx: {
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
border: `1px solid ${terminalColors.primary}`,
|
||||
'& .MuiMenuItem-root': {
|
||||
color: terminalColors.primary,
|
||||
fontFamily: 'monospace',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.15)',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="success">Success</MenuItem>
|
||||
<MenuItem value="failed">Failed</MenuItem>
|
||||
<MenuItem value="running">Running</MenuItem>
|
||||
<MenuItem value="skipped">Skipped</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Tooltip title="Refresh logs">
|
||||
<IconButton
|
||||
onClick={fetchLogs}
|
||||
size="small"
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
border: `1px solid ${terminalColors.primary}`,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<TerminalAlert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</TerminalAlert>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" p={3}>
|
||||
<CircularProgress sx={{ color: terminalColors.primary }} />
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
{isShowingSchedulerLogs && (
|
||||
<TerminalAlert severity="info" sx={{ mb: 2 }}>
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.875rem' }}>
|
||||
Showing latest 5 scheduler activity logs (job scheduling, completion, failures).
|
||||
Historical execution logs are available in the Event History section below.
|
||||
</TerminalTypography>
|
||||
</TerminalAlert>
|
||||
)}
|
||||
<TableContainer
|
||||
sx={{
|
||||
backgroundColor: terminalColors.background,
|
||||
maxHeight: '600px',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
<Table size="small" sx={{ minWidth: 650 }}>
|
||||
<TableHead>
|
||||
<TerminalTableRow>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Task</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Status</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Execution Time</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Duration</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>User ID</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Date</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Error</TerminalTableCell>
|
||||
</TerminalTableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{(() => {
|
||||
// Debug logging
|
||||
if (logs.length > 0) {
|
||||
console.log('🔍 Rendering logs table:', {
|
||||
logsCount: logs.length,
|
||||
loading,
|
||||
isShowingSchedulerLogs,
|
||||
firstLogId: logs[0]?.id,
|
||||
firstLogStatus: logs[0]?.status
|
||||
});
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
{logs.length === 0 && !loading ? (
|
||||
<TerminalTableRow>
|
||||
<TerminalTableCell colSpan={7} align="center">
|
||||
<Box sx={{ py: 4, textAlign: 'center' }}>
|
||||
<ScheduleIcon sx={{ color: terminalColors.textSecondary, fontSize: 48, mb: 2, opacity: 0.5 }} />
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.primary, mb: 1, fontWeight: 'bold' }}>
|
||||
{isShowingSchedulerLogs ? 'No Scheduler Logs Yet' : 'No Execution Logs Yet'}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary, mb: 1 }}>
|
||||
{isShowingSchedulerLogs
|
||||
? 'Scheduler activity logs (job scheduling, restoration, etc.) will appear here when the scheduler starts or schedules jobs.'
|
||||
: 'Execution logs will appear here once the scheduler runs and executes tasks.'}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem', fontStyle: 'italic', display: 'block' }}>
|
||||
{isShowingSchedulerLogs
|
||||
? 'These logs show scheduler activity (job restoration, scheduling) when actual task execution logs are not available.'
|
||||
: 'The scheduler checks for due tasks every 60 minutes (or based on active strategies).'}
|
||||
{!isShowingSchedulerLogs && totalCount === 0 && ' Currently, no tasks have been executed yet.'}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</TerminalTableCell>
|
||||
</TerminalTableRow>
|
||||
) : loading ? (
|
||||
<TerminalTableRow>
|
||||
<TerminalTableCell colSpan={7} align="center">
|
||||
<Box sx={{ py: 3, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 2 }}>
|
||||
<CircularProgress size={24} sx={{ color: terminalColors.primary }} />
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
|
||||
Loading execution logs...
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</TerminalTableCell>
|
||||
</TerminalTableRow>
|
||||
) : (
|
||||
logs.map((log) => {
|
||||
// Debug: log each row being rendered
|
||||
if (log.id === logs[0]?.id) {
|
||||
console.log('🎯 Rendering first log row:', log.id, log.status, log.task?.task_title);
|
||||
}
|
||||
return (
|
||||
<TerminalTableRow
|
||||
key={log.id}
|
||||
sx={{
|
||||
backgroundColor: terminalColors.background,
|
||||
color: terminalColors.primary,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TerminalTableCell sx={{ color: terminalColors.primary }}>
|
||||
<Box>
|
||||
<TerminalTypography variant="body2" fontWeight="medium" sx={{ fontSize: '0.875rem' }}>
|
||||
{log.is_scheduler_log
|
||||
? (log.task?.task_title || `Scheduler Event: ${log.event_type || 'unknown'}`)
|
||||
: (log.task?.task_title || `Task #${log.task_id}`)
|
||||
}
|
||||
</TerminalTypography>
|
||||
{log.is_scheduler_log && log.job_id && (
|
||||
<TerminalTypography variant="caption" sx={{ fontSize: '0.7rem', color: terminalColors.textSecondary, display: 'block', mt: 0.5 }}>
|
||||
Job ID: {log.job_id}
|
||||
</TerminalTypography>
|
||||
)}
|
||||
{!log.is_scheduler_log && log.task?.component_name && (
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
{log.task.component_name}
|
||||
</TerminalTypography>
|
||||
)}
|
||||
{log.is_scheduler_log && log.task?.metric && (
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Function: {log.task.metric}
|
||||
</TerminalTypography>
|
||||
)}
|
||||
</Box>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ color: terminalColors.primary }}>
|
||||
{log.status === 'success' ? (
|
||||
<TerminalChipSuccess
|
||||
icon={getStatusIcon(log.status)}
|
||||
label={log.status}
|
||||
size="small"
|
||||
/>
|
||||
) : log.status === 'failed' ? (
|
||||
<TerminalChipError
|
||||
icon={getStatusIcon(log.status)}
|
||||
label={log.status}
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<TerminalChipWarning
|
||||
icon={getStatusIcon(log.status)}
|
||||
label={log.status}
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ color: terminalColors.primary }}>
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.875rem', color: terminalColors.primary }}>
|
||||
{formatExecutionTime(log.execution_time_ms)}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ color: terminalColors.primary }}>
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.875rem', color: terminalColors.primary }}>
|
||||
{log.execution_date ? formatDate(log.execution_date) : 'N/A'}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ color: terminalColors.primary }}>
|
||||
{log.user_id ? (
|
||||
<TerminalTypography variant="body2" sx={{ fontFamily: 'monospace', fontSize: '0.875rem', color: terminalColors.primary }}>
|
||||
{String(log.user_id).substring(0, 12)}...
|
||||
</TerminalTypography>
|
||||
) : (
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }}>
|
||||
System
|
||||
</TerminalTypography>
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ color: terminalColors.primary }}>
|
||||
<TerminalTypography variant="body2" sx={{ fontSize: '0.875rem', color: terminalColors.primary }}>
|
||||
{formatDate(log.created_at)}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell sx={{ color: terminalColors.primary }}>
|
||||
{log.error_message ? (
|
||||
<Tooltip title={log.error_message} arrow>
|
||||
<TerminalTypography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
color: terminalColors.error,
|
||||
maxWidth: 300,
|
||||
wordBreak: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
cursor: 'help'
|
||||
}}
|
||||
>
|
||||
{log.error_message}
|
||||
</TerminalTypography>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }}>
|
||||
-
|
||||
</TerminalTypography>
|
||||
)}
|
||||
</TerminalTableCell>
|
||||
</TerminalTableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{/* Only show pagination for actual execution logs, not scheduler logs */}
|
||||
{!isShowingSchedulerLogs && logs.length > 0 && (
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
'& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': {
|
||||
color: terminalColors.textSecondary,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
'& .MuiTablePagination-select': {
|
||||
color: terminalColors.primary,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
'& .MuiIconButton-root': {
|
||||
color: terminalColors.primary,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
}
|
||||
},
|
||||
'& .MuiIconButton-root.Mui-disabled': {
|
||||
color: terminalColors.textSecondary,
|
||||
opacity: 0.3,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Info message for scheduler logs */}
|
||||
{isShowingSchedulerLogs && logs.length > 0 && (
|
||||
<Box mt={2}>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem', fontStyle: 'italic' }}>
|
||||
Displaying latest 5 scheduler activity logs. Only the most recent logs are shown here.
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TerminalPaper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExecutionLogsTable;
|
||||
|
||||
297
frontend/src/components/SchedulerDashboard/FailuresInsights.tsx
Normal file
297
frontend/src/components/SchedulerDashboard/FailuresInsights.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Failures & Insights Component
|
||||
* Displays recent failures, error messages, and scheduler insights.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Divider,
|
||||
CircularProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon,
|
||||
Info as InfoIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
CheckCircle as CheckCircleIcon
|
||||
} from '@mui/icons-material';
|
||||
import { getExecutionLogs, getRecentSchedulerLogs, ExecutionLog } from '../../api/schedulerDashboard';
|
||||
import { SchedulerStats } from '../../api/schedulerDashboard';
|
||||
import {
|
||||
TerminalPaper,
|
||||
TerminalTypography,
|
||||
TerminalAlert,
|
||||
TerminalAccordion,
|
||||
terminalColors
|
||||
} from './terminalTheme';
|
||||
|
||||
interface FailuresInsightsProps {
|
||||
stats: SchedulerStats;
|
||||
}
|
||||
|
||||
const FailuresInsights: React.FC<FailuresInsightsProps> = ({ stats }) => {
|
||||
const [recentFailures, setRecentFailures] = useState<ExecutionLog[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchFailures = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// First try to get execution logs with failed status
|
||||
const executionLogsResponse = await getExecutionLogs(10, 0, 'failed');
|
||||
|
||||
// Also get scheduler logs (which include job_failed events)
|
||||
const schedulerLogsResponse = await getRecentSchedulerLogs();
|
||||
|
||||
// Combine both, filtering for failed status
|
||||
const allFailures: ExecutionLog[] = [
|
||||
...executionLogsResponse.logs.filter(log => log.status === 'failed'),
|
||||
...(schedulerLogsResponse.logs || []).filter(log => log.status === 'failed')
|
||||
];
|
||||
|
||||
// Sort by execution_date descending (most recent first) and limit to 10
|
||||
allFailures.sort((a, b) => {
|
||||
const dateA = new Date(a.execution_date).getTime();
|
||||
const dateB = new Date(b.execution_date).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
setRecentFailures(allFailures.slice(0, 10));
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch failures');
|
||||
console.error('Error fetching failures:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFailures();
|
||||
}, []);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// Generate insights based on stats
|
||||
const generateInsights = () => {
|
||||
const insights: Array<{ type: 'info' | 'warning' | 'error' | 'success'; message: string }> = [];
|
||||
|
||||
// Scheduler status insight
|
||||
if (!stats.running) {
|
||||
insights.push({
|
||||
type: 'error',
|
||||
message: 'Scheduler is stopped. Tasks will not be executed until scheduler is restarted.'
|
||||
});
|
||||
} else {
|
||||
insights.push({
|
||||
type: 'success',
|
||||
message: 'Scheduler is running and processing tasks normally.'
|
||||
});
|
||||
}
|
||||
|
||||
// Active strategies insight
|
||||
if (stats.active_strategies_count === 0) {
|
||||
insights.push({
|
||||
type: 'info',
|
||||
message: `No active strategies detected. Using ${stats.max_check_interval_minutes}min check interval (idle mode).`
|
||||
});
|
||||
} else {
|
||||
insights.push({
|
||||
type: 'info',
|
||||
message: `${stats.active_strategies_count} active strategy(ies) with monitoring tasks. Using ${stats.min_check_interval_minutes}min check interval.`
|
||||
});
|
||||
}
|
||||
|
||||
// Failure rate insight
|
||||
const totalExecutions = stats.tasks_executed + stats.tasks_failed;
|
||||
if (totalExecutions > 0) {
|
||||
const failureRate = (stats.tasks_failed / totalExecutions) * 100;
|
||||
if (failureRate > 20) {
|
||||
insights.push({
|
||||
type: 'error',
|
||||
message: `High failure rate: ${failureRate.toFixed(1)}% of tasks are failing. Review error logs for details.`
|
||||
});
|
||||
} else if (failureRate > 10) {
|
||||
insights.push({
|
||||
type: 'warning',
|
||||
message: `Moderate failure rate: ${failureRate.toFixed(1)}% of tasks are failing. Monitor for patterns.`
|
||||
});
|
||||
} else if (stats.tasks_failed > 0) {
|
||||
insights.push({
|
||||
type: 'info',
|
||||
message: `Low failure rate: ${failureRate.toFixed(1)}% of tasks are failing. System is healthy.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check interval insight
|
||||
if (stats.intelligent_scheduling) {
|
||||
insights.push({
|
||||
type: 'success',
|
||||
message: `Intelligent scheduling enabled. Interval automatically adjusts based on active strategies (${stats.min_check_interval_minutes}-${stats.max_check_interval_minutes}min range).`
|
||||
});
|
||||
}
|
||||
|
||||
// Last check insight
|
||||
if (stats.last_check) {
|
||||
try {
|
||||
const lastCheck = new Date(stats.last_check);
|
||||
const now = new Date();
|
||||
const diffMins = Math.floor((now.getTime() - lastCheck.getTime()) / 60000);
|
||||
|
||||
if (diffMins > stats.check_interval_minutes * 2) {
|
||||
insights.push({
|
||||
type: 'warning',
|
||||
message: `Last check was ${diffMins} minutes ago. Expected interval is ${stats.check_interval_minutes} minutes. Scheduler may be delayed.`
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore date parsing errors
|
||||
}
|
||||
}
|
||||
|
||||
return insights;
|
||||
};
|
||||
|
||||
const insights = generateInsights();
|
||||
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<InfoIcon sx={{ color: terminalColors.primary }} />
|
||||
<TerminalTypography variant="h6" component="h2" sx={{ fontSize: '1.25rem', fontWeight: 'bold' }}>
|
||||
Failures & Insights
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
|
||||
{/* Recent Failures */}
|
||||
<Box mb={3} sx={{ flexShrink: 0 }}>
|
||||
<TerminalTypography variant="subtitle1" fontWeight="medium" mb={1} sx={{ fontSize: '1rem', color: terminalColors.textSecondary }}>
|
||||
Recent Failures ({recentFailures.length})
|
||||
</TerminalTypography>
|
||||
{loading ? (
|
||||
<Box display="flex" justifyContent="center" p={2}>
|
||||
<CircularProgress size={24} sx={{ color: terminalColors.primary }} />
|
||||
</Box>
|
||||
) : error ? (
|
||||
<TerminalAlert severity="error">{error}</TerminalAlert>
|
||||
) : recentFailures.length === 0 ? (
|
||||
<TerminalAlert severity="success" icon={<CheckCircleIcon />}>
|
||||
No recent failures. All tasks are executing successfully.
|
||||
</TerminalAlert>
|
||||
) : (
|
||||
<List>
|
||||
{recentFailures.map((log, index) => (
|
||||
<React.Fragment key={log.id}>
|
||||
<TerminalAccordion>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ color: terminalColors.primary }} />}
|
||||
sx={{
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.05)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={1} width="100%">
|
||||
<ErrorIcon sx={{ color: terminalColors.error }} fontSize="small" />
|
||||
<TerminalTypography variant="body2" sx={{ flexGrow: 1, fontSize: '0.875rem' }}>
|
||||
{log.task?.task_title || `Task #${log.task_id}`}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
{formatDate(log.execution_date)}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ backgroundColor: terminalColors.background }}>
|
||||
<Box>
|
||||
<TerminalTypography variant="body2" gutterBottom sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }}>
|
||||
<strong style={{ color: terminalColors.primary }}>Component:</strong> {log.task?.component_name || 'Unknown'}
|
||||
</TerminalTypography>
|
||||
{log.error_message && (
|
||||
<Box sx={{ mt: 1, p: 1, border: `1px solid ${terminalColors.error}`, borderRadius: 1, backgroundColor: terminalColors.backgroundLight }}>
|
||||
<TerminalTypography variant="body2" fontWeight="bold" gutterBottom sx={{ color: terminalColors.error, fontSize: '0.875rem' }}>
|
||||
Error Message
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.error, fontSize: '0.875rem', fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
|
||||
{log.error_message}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
{log.execution_time_ms && (
|
||||
<TerminalTypography variant="caption" sx={{ mt: 1, display: 'block', color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Execution time: {log.execution_time_ms}ms
|
||||
</TerminalTypography>
|
||||
)}
|
||||
{log.user_id && (
|
||||
<TerminalTypography variant="caption" sx={{ mt: 0.5, display: 'block', color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
User ID: {log.user_id}
|
||||
</TerminalTypography>
|
||||
)}
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</TerminalAccordion>
|
||||
{index < recentFailures.length - 1 && <Divider sx={{ borderColor: terminalColors.border }} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3, borderColor: terminalColors.border, flexShrink: 0 }} />
|
||||
|
||||
{/* Scheduler Insights */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', minHeight: 0, flexShrink: 1 }}>
|
||||
<TerminalTypography variant="subtitle1" fontWeight="medium" mb={1} sx={{ fontSize: '1rem', color: terminalColors.textSecondary }}>
|
||||
Scheduler Insights
|
||||
</TerminalTypography>
|
||||
<List>
|
||||
{insights.map((insight, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
{insight.type === 'error' && <ErrorIcon sx={{ color: terminalColors.error }} />}
|
||||
{insight.type === 'warning' && <WarningIcon sx={{ color: terminalColors.warning }} />}
|
||||
{insight.type === 'info' && <InfoIcon sx={{ color: terminalColors.info }} />}
|
||||
{insight.type === 'success' && <CheckCircleIcon sx={{ color: terminalColors.success }} />}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={
|
||||
<TerminalTypography
|
||||
variant="body2"
|
||||
sx={{
|
||||
fontSize: '0.875rem',
|
||||
color: insight.type === 'error' ? terminalColors.error : terminalColors.text
|
||||
}}
|
||||
>
|
||||
{insight.message}
|
||||
</TerminalTypography>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
{index < insights.length - 1 && <Divider component="li" sx={{ borderColor: terminalColors.border }} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
};
|
||||
|
||||
export default FailuresInsights;
|
||||
|
||||
364
frontend/src/components/SchedulerDashboard/OAuthTokenStatus.tsx
Normal file
364
frontend/src/components/SchedulerDashboard/OAuthTokenStatus.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* OAuth Token Status Component
|
||||
* Compact terminal-themed component for displaying OAuth token monitoring status
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
import {
|
||||
getOAuthTokenStatus,
|
||||
manualRefreshToken,
|
||||
OAuthTokenStatusResponse,
|
||||
ManualRefreshResponse,
|
||||
} from '../../api/oauthTokenMonitoring';
|
||||
import {
|
||||
TerminalPaper,
|
||||
TerminalTypography,
|
||||
TerminalChip,
|
||||
TerminalChipSuccess,
|
||||
TerminalChipError,
|
||||
TerminalChipWarning,
|
||||
TerminalAlert,
|
||||
terminalColors,
|
||||
} from './terminalTheme';
|
||||
|
||||
interface OAuthTokenStatusProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const OAuthTokenStatus: React.FC<OAuthTokenStatusProps> = ({ compact = true }) => {
|
||||
const { userId } = useAuth();
|
||||
const [status, setStatus] = useState<OAuthTokenStatusResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expandedPlatform, setExpandedPlatform] = useState<string | null>(null);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await getOAuthTokenStatus(userId);
|
||||
setStatus(response);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch token status');
|
||||
console.error('Error fetching OAuth token status:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
|
||||
// Poll for status updates every 2 minutes
|
||||
const interval = setInterval(fetchStatus, 120000);
|
||||
return () => clearInterval(interval);
|
||||
}, [userId]);
|
||||
|
||||
const handleRefresh = async (platform: string) => {
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
setRefreshing(platform);
|
||||
setError(null);
|
||||
const response: ManualRefreshResponse = await manualRefreshToken(userId, platform);
|
||||
|
||||
// Refresh status after manual refresh
|
||||
await fetchStatus();
|
||||
|
||||
if (response.success) {
|
||||
console.log(`Token refresh successful for ${platform}`);
|
||||
} else {
|
||||
console.error(`Token refresh failed for ${platform}:`, response.data.execution_result.error_message);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || `Failed to refresh ${platform} token`);
|
||||
console.error(`Error refreshing ${platform} token:`, err);
|
||||
} finally {
|
||||
setRefreshing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (taskStatus: string | null, connected: boolean) => {
|
||||
if (!connected) {
|
||||
return <XCircle size={16} color={terminalColors.error} />;
|
||||
}
|
||||
|
||||
if (!taskStatus || taskStatus === 'not_created') {
|
||||
return <Info size={16} color={terminalColors.info} />;
|
||||
}
|
||||
|
||||
switch (taskStatus) {
|
||||
case 'active':
|
||||
return <CheckCircle size={16} color={terminalColors.success} />;
|
||||
case 'failed':
|
||||
return <XCircle size={16} color={terminalColors.error} />;
|
||||
case 'paused':
|
||||
return <AlertTriangle size={16} color={terminalColors.warning} />;
|
||||
default:
|
||||
return <Info size={16} color={terminalColors.primary} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusChip = (taskStatus: string | null, connected: boolean) => {
|
||||
if (!connected) {
|
||||
return <TerminalChipError label="Not Connected" size="small" />;
|
||||
}
|
||||
|
||||
if (!taskStatus || taskStatus === 'not_created') {
|
||||
return <TerminalChip label={taskStatus || 'Not Created'} size="small" />;
|
||||
}
|
||||
|
||||
switch (taskStatus) {
|
||||
case 'active':
|
||||
return <TerminalChipSuccess label="Active" size="small" />;
|
||||
case 'failed':
|
||||
return <TerminalChipError label="Failed" size="small" />;
|
||||
case 'paused':
|
||||
return <TerminalChipWarning label="Paused" size="small" />;
|
||||
default:
|
||||
return <TerminalChip label={taskStatus} size="small" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformDisplayName = (platform: string) => {
|
||||
const names: { [key: string]: string } = {
|
||||
gsc: 'GSC',
|
||||
bing: 'Bing',
|
||||
wordpress: 'WP',
|
||||
wix: 'Wix',
|
||||
};
|
||||
return names[platform] || platform.toUpperCase();
|
||||
};
|
||||
|
||||
if (loading && !status) {
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 2 }}>
|
||||
<Box display="flex" justifyContent="center" alignItems="center" p={2}>
|
||||
<CircularProgress size={20} sx={{ color: terminalColors.primary }} />
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const platforms = ['gsc', 'bing', 'wordpress', 'wix'];
|
||||
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 2, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<TerminalTypography variant="h6" component="h3">
|
||||
OAuth Token Status
|
||||
</TerminalTypography>
|
||||
<Tooltip title="Refresh status">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
border: `1px solid ${terminalColors.primary}`,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
},
|
||||
'&:disabled': {
|
||||
color: '#004400',
|
||||
borderColor: '#004400',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<TerminalAlert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</TerminalAlert>
|
||||
)}
|
||||
|
||||
<Box sx={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||
<Table size="small" sx={{ '& .MuiTableCell-root': { color: terminalColors.primary, borderColor: terminalColors.primary + '40' } }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Platform</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Last Check</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{platforms.map((platform) => {
|
||||
const platformStatus = status.data.platform_status[platform];
|
||||
const task = platformStatus?.monitoring_task;
|
||||
const isExpanded = expandedPlatform === platform;
|
||||
|
||||
return (
|
||||
<React.Fragment key={platform}>
|
||||
<TableRow
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.05)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{getStatusIcon(task?.status || null, platformStatus?.connected || false)}
|
||||
<TerminalTypography variant="body2" fontWeight="medium">
|
||||
{getPlatformDisplayName(platform)}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusChip(task?.status || null, platformStatus?.connected || false)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TerminalTypography variant="caption" color={terminalColors.textSecondary}>
|
||||
{formatDate(task?.last_check || null)}
|
||||
</TerminalTypography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Box display="flex" gap={0.5} justifyContent="flex-end">
|
||||
<Tooltip title={isExpanded ? "Hide details" : "Show details"}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setExpandedPlatform(isExpanded ? null : platform)}
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{platformStatus?.connected && (
|
||||
<Tooltip title="Manually refresh token">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleRefresh(platform)}
|
||||
disabled={refreshing === platform}
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.1)',
|
||||
},
|
||||
'&:disabled': {
|
||||
color: '#004400',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{refreshing === platform ? (
|
||||
<CircularProgress size={14} sx={{ color: terminalColors.primary }} />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} sx={{ py: 0, border: 0 }}>
|
||||
<Collapse in={isExpanded}>
|
||||
<Box p={2} sx={{ backgroundColor: 'rgba(0, 255, 0, 0.05)', borderLeft: `2px solid ${terminalColors.primary}` }}>
|
||||
{task?.failure_reason && (
|
||||
<TerminalAlert severity="error" sx={{ mb: 1 }}>
|
||||
<TerminalTypography variant="body2" fontWeight="bold">
|
||||
Last Failure:
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="body2">
|
||||
{task.failure_reason}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" color={terminalColors.textSecondary}>
|
||||
{formatDate(task.last_failure || null)}
|
||||
</TerminalTypography>
|
||||
</TerminalAlert>
|
||||
)}
|
||||
{task?.last_success && (
|
||||
<TerminalAlert severity="success" sx={{ mb: 1 }}>
|
||||
<TerminalTypography variant="body2">
|
||||
Last successful: {formatDate(task.last_success)}
|
||||
</TerminalTypography>
|
||||
</TerminalAlert>
|
||||
)}
|
||||
{task?.next_check && (
|
||||
<Box mt={1}>
|
||||
<TerminalTypography variant="caption" color={terminalColors.textSecondary}>
|
||||
Next check: {formatDate(task.next_check)}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
{!task && platformStatus?.connected && (
|
||||
<TerminalAlert severity="info">
|
||||
<TerminalTypography variant="body2">
|
||||
Connected but no monitoring task. Create one manually or wait for onboarding completion.
|
||||
</TerminalTypography>
|
||||
</TerminalAlert>
|
||||
)}
|
||||
{!platformStatus?.connected && (
|
||||
<TerminalAlert severity="warning">
|
||||
<TerminalTypography variant="body2">
|
||||
Not connected. Connect in onboarding step 5.
|
||||
</TerminalTypography>
|
||||
</TerminalAlert>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthTokenStatus;
|
||||
|
||||
385
frontend/src/components/SchedulerDashboard/SchedulerCharts.tsx
Normal file
385
frontend/src/components/SchedulerDashboard/SchedulerCharts.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Scheduler Charts Component
|
||||
* Visualizes scheduler event history data using Recharts
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
import { Box, Paper, CircularProgress } from '@mui/material';
|
||||
import { TerminalTypography, TerminalPaper, terminalColors } from './terminalTheme';
|
||||
import { getSchedulerEventHistory, SchedulerEvent } from '../../api/schedulerDashboard';
|
||||
|
||||
interface SchedulerChartsProps {
|
||||
// Optional: can receive events as prop or fetch them internally
|
||||
events?: SchedulerEvent[];
|
||||
}
|
||||
|
||||
const SchedulerCharts: React.FC<SchedulerChartsProps> = ({ events: propEvents }) => {
|
||||
const [events, setEvents] = useState<SchedulerEvent[]>(propEvents || []);
|
||||
const [loading, setLoading] = useState(!propEvents);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch events if not provided as prop
|
||||
useEffect(() => {
|
||||
if (!propEvents) {
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Fetch all events for visualization (no pagination limit)
|
||||
// Pass undefined to get all event types
|
||||
console.log('📊 Charts - Fetching event history...');
|
||||
const response = await getSchedulerEventHistory(1000, 0, undefined);
|
||||
console.log('📊 Charts - Fetched events:', {
|
||||
totalEvents: response.events?.length || 0,
|
||||
totalCount: response.total_count,
|
||||
hasEvents: !!(response.events && response.events.length > 0),
|
||||
sampleEvent: response.events?.[0]
|
||||
});
|
||||
setEvents(response.events || []);
|
||||
} catch (err: any) {
|
||||
console.error('❌ Charts - Error fetching events:', err);
|
||||
console.error('❌ Charts - Error details:', {
|
||||
message: err?.message,
|
||||
response: err?.response,
|
||||
responseData: err?.response?.data,
|
||||
stack: err?.stack
|
||||
});
|
||||
const errorMessage = err?.response?.data?.detail || err?.response?.data?.message || err?.message || String(err) || 'Failed to fetch event history';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchEvents();
|
||||
}
|
||||
}, [propEvents]);
|
||||
|
||||
// Process events for charting
|
||||
const chartData = useMemo(() => {
|
||||
if (!events || events.length === 0) return [];
|
||||
|
||||
// Group events by date (day)
|
||||
const eventsByDate: Record<string, {
|
||||
date: string;
|
||||
check_cycles: number;
|
||||
tasks_found: number;
|
||||
tasks_executed: number;
|
||||
tasks_failed: number;
|
||||
job_scheduled: number;
|
||||
job_completed: number;
|
||||
job_failed: number;
|
||||
}> = {};
|
||||
|
||||
events.forEach(event => {
|
||||
const date = event.event_date ? new Date(event.event_date).toLocaleDateString() : 'Unknown';
|
||||
|
||||
if (!eventsByDate[date]) {
|
||||
eventsByDate[date] = {
|
||||
date,
|
||||
check_cycles: 0,
|
||||
tasks_found: 0,
|
||||
tasks_executed: 0,
|
||||
tasks_failed: 0,
|
||||
job_scheduled: 0,
|
||||
job_completed: 0,
|
||||
job_failed: 0,
|
||||
};
|
||||
}
|
||||
|
||||
switch (event.event_type) {
|
||||
case 'check_cycle':
|
||||
eventsByDate[date].check_cycles++;
|
||||
eventsByDate[date].tasks_found += event.tasks_found || 0;
|
||||
eventsByDate[date].tasks_executed += event.tasks_executed || 0;
|
||||
eventsByDate[date].tasks_failed += event.tasks_failed || 0;
|
||||
break;
|
||||
case 'job_scheduled':
|
||||
eventsByDate[date].job_scheduled++;
|
||||
break;
|
||||
case 'job_completed':
|
||||
eventsByDate[date].job_completed++;
|
||||
break;
|
||||
case 'job_failed':
|
||||
eventsByDate[date].job_failed++;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to array and sort by date
|
||||
return Object.values(eventsByDate).sort((a, b) => {
|
||||
return new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
}).slice(-30); // Last 30 days
|
||||
}, [events]);
|
||||
|
||||
// Calculate totals for summary
|
||||
const totals = useMemo(() => {
|
||||
return events.reduce((acc, event) => {
|
||||
switch (event.event_type) {
|
||||
case 'check_cycle':
|
||||
acc.check_cycles++;
|
||||
acc.tasks_found += event.tasks_found || 0;
|
||||
acc.tasks_executed += event.tasks_executed || 0;
|
||||
acc.tasks_failed += event.tasks_failed || 0;
|
||||
break;
|
||||
case 'job_scheduled':
|
||||
acc.job_scheduled++;
|
||||
break;
|
||||
case 'job_completed':
|
||||
acc.job_completed++;
|
||||
break;
|
||||
case 'job_failed':
|
||||
acc.job_failed++;
|
||||
break;
|
||||
}
|
||||
return acc;
|
||||
}, {
|
||||
check_cycles: 0,
|
||||
tasks_found: 0,
|
||||
tasks_executed: 0,
|
||||
tasks_failed: 0,
|
||||
job_scheduled: 0,
|
||||
job_completed: 0,
|
||||
job_failed: 0,
|
||||
});
|
||||
}, [events]);
|
||||
|
||||
// Custom tooltip with terminal theme
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
backgroundColor: terminalColors.background,
|
||||
border: `1px solid ${terminalColors.primary}`,
|
||||
padding: 1,
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.primary, fontWeight: 'bold', mb: 0.5 }}>
|
||||
{label}
|
||||
</TerminalTypography>
|
||||
{payload.map((entry: any, index: number) => (
|
||||
<TerminalTypography
|
||||
key={index}
|
||||
variant="body2"
|
||||
sx={{ color: entry.color, fontSize: '0.75rem' }}
|
||||
>
|
||||
{entry.name}: {entry.value}
|
||||
</TerminalTypography>
|
||||
))}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 3, textAlign: 'center' }}>
|
||||
<CircularProgress sx={{ color: terminalColors.primary, mb: 2 }} />
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
|
||||
Loading chart data...
|
||||
</TerminalTypography>
|
||||
</TerminalPaper>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 3, textAlign: 'center' }}>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.error }}>
|
||||
Error loading charts: {error}
|
||||
</TerminalTypography>
|
||||
</TerminalPaper>
|
||||
);
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 3, textAlign: 'center' }}>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
|
||||
No event history data available yet. Charts will appear once scheduler events are logged.
|
||||
</TerminalTypography>
|
||||
</TerminalPaper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Summary Stats */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 2 }}>
|
||||
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<TerminalTypography variant="h6" sx={{ color: terminalColors.primary, fontSize: '1.5rem' }}>
|
||||
{totals.check_cycles}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Check Cycles
|
||||
</TerminalTypography>
|
||||
</TerminalPaper>
|
||||
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<TerminalTypography variant="h6" sx={{ color: terminalColors.primary, fontSize: '1.5rem' }}>
|
||||
{totals.tasks_executed}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Tasks Executed
|
||||
</TerminalTypography>
|
||||
</TerminalPaper>
|
||||
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<TerminalTypography variant="h6" sx={{ color: terminalColors.error, fontSize: '1.5rem' }}>
|
||||
{totals.tasks_failed}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Tasks Failed
|
||||
</TerminalTypography>
|
||||
</TerminalPaper>
|
||||
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<TerminalTypography variant="h6" sx={{ color: terminalColors.primary, fontSize: '1.5rem' }}>
|
||||
{totals.job_completed}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Jobs Completed
|
||||
</TerminalTypography>
|
||||
</TerminalPaper>
|
||||
<TerminalPaper sx={{ p: 2, textAlign: 'center' }}>
|
||||
<TerminalTypography variant="h6" sx={{ color: terminalColors.error, fontSize: '1.5rem' }}>
|
||||
{totals.job_failed}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="caption" sx={{ color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Jobs Failed
|
||||
</TerminalTypography>
|
||||
</TerminalPaper>
|
||||
</Box>
|
||||
|
||||
{/* Task Execution Trends */}
|
||||
<TerminalPaper sx={{ p: 3 }}>
|
||||
<TerminalTypography variant="h6" sx={{ mb: 2, color: terminalColors.primary }}>
|
||||
Task Execution Trends (Last 30 Days)
|
||||
</TerminalTypography>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
wrapperStyle={{ color: terminalColors.primary, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="tasks_found"
|
||||
stroke={terminalColors.info}
|
||||
strokeWidth={2}
|
||||
name="Tasks Found"
|
||||
dot={{ fill: terminalColors.info, r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="tasks_executed"
|
||||
stroke={terminalColors.success}
|
||||
strokeWidth={2}
|
||||
name="Tasks Executed"
|
||||
dot={{ fill: terminalColors.success, r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="tasks_failed"
|
||||
stroke={terminalColors.error}
|
||||
strokeWidth={2}
|
||||
name="Tasks Failed"
|
||||
dot={{ fill: terminalColors.error, r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</TerminalPaper>
|
||||
|
||||
{/* Job Status Distribution */}
|
||||
<TerminalPaper sx={{ p: 3 }}>
|
||||
<TerminalTypography variant="h6" sx={{ mb: 2, color: terminalColors.primary }}>
|
||||
Job Status Distribution (Last 30 Days)
|
||||
</TerminalTypography>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend
|
||||
wrapperStyle={{ color: terminalColors.primary, fontFamily: 'monospace' }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="job_scheduled"
|
||||
fill={terminalColors.info}
|
||||
name="Scheduled"
|
||||
/>
|
||||
<Bar
|
||||
dataKey="job_completed"
|
||||
fill={terminalColors.success}
|
||||
name="Completed"
|
||||
/>
|
||||
<Bar
|
||||
dataKey="job_failed"
|
||||
fill={terminalColors.error}
|
||||
name="Failed"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</TerminalPaper>
|
||||
|
||||
{/* Check Cycles Over Time */}
|
||||
<TerminalPaper sx={{ p: 3 }}>
|
||||
<TerminalTypography variant="h6" sx={{ mb: 2, color: terminalColors.primary }}>
|
||||
Check Cycles Over Time (Last 30 Days)
|
||||
</TerminalTypography>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={terminalColors.border} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 12 }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={terminalColors.primary}
|
||||
tick={{ fill: terminalColors.primary, fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey="check_cycles"
|
||||
fill={terminalColors.primary}
|
||||
name="Check Cycles"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</TerminalPaper>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchedulerCharts;
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Scheduler Event History Component
|
||||
* Displays historical scheduler events (check cycles, interval adjustments, etc.)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
Chip,
|
||||
Typography,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Tooltip
|
||||
} from '@mui/material';
|
||||
import {
|
||||
TerminalPaper,
|
||||
TerminalTypography,
|
||||
TerminalChipSuccess,
|
||||
TerminalChipError,
|
||||
TerminalChipWarning,
|
||||
TerminalTableCell,
|
||||
TerminalTableRow,
|
||||
terminalColors
|
||||
} from './terminalTheme';
|
||||
import { getSchedulerEventHistory, SchedulerEvent } from '../../api/schedulerDashboard';
|
||||
|
||||
interface SchedulerEventHistoryProps {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const SchedulerEventHistory: React.FC<SchedulerEventHistoryProps> = ({ limit = 50 }) => {
|
||||
const [events, setEvents] = useState<SchedulerEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(limit);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [eventTypeFilter, setEventTypeFilter] = useState<string>('all');
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await getSchedulerEventHistory(
|
||||
rowsPerPage,
|
||||
page * rowsPerPage,
|
||||
eventTypeFilter !== 'all' ? eventTypeFilter as any : undefined
|
||||
);
|
||||
|
||||
setEvents(response.events);
|
||||
setTotalCount(response.total_count);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Failed to fetch scheduler event history');
|
||||
console.error('Error fetching scheduler event history:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, rowsPerPage, eventTypeFilter]); // fetchEvents is stable, no need to include
|
||||
|
||||
const handleChangePage = (_event: unknown, newPage: number) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setRowsPerPage(parseInt(event.target.value, 10));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const getEventTypeColor = (eventType: string) => {
|
||||
switch (eventType) {
|
||||
case 'check_cycle':
|
||||
return terminalColors.success;
|
||||
case 'interval_adjustment':
|
||||
return terminalColors.warning;
|
||||
case 'start':
|
||||
return terminalColors.success;
|
||||
case 'stop':
|
||||
return terminalColors.error;
|
||||
case 'job_scheduled':
|
||||
return terminalColors.info;
|
||||
case 'job_completed':
|
||||
return terminalColors.success;
|
||||
case 'job_failed':
|
||||
return terminalColors.error;
|
||||
default:
|
||||
return terminalColors.info;
|
||||
}
|
||||
};
|
||||
|
||||
const formatEventDetails = (event: SchedulerEvent): string => {
|
||||
switch (event.event_type) {
|
||||
case 'check_cycle':
|
||||
return `Cycle #${event.check_cycle_number || 'N/A'} | ${event.tasks_found || 0} found, ${event.tasks_executed || 0} executed, ${event.tasks_failed || 0} failed | ${event.check_duration_seconds?.toFixed(2) || 'N/A'}s`;
|
||||
case 'interval_adjustment':
|
||||
return `${event.previous_interval_minutes || 'N/A'}min → ${event.new_interval_minutes || 'N/A'}min | ${event.active_strategies_count || 0} active strategies`;
|
||||
case 'start':
|
||||
return `Started with ${event.check_interval_minutes || 'N/A'}min interval | ${event.active_strategies_count || 0} active strategies`;
|
||||
case 'stop':
|
||||
return `Stopped gracefully | ${event.event_data?.total_checks || 0} total cycles`;
|
||||
case 'job_scheduled':
|
||||
const scheduledJob = event.event_data as any;
|
||||
return `Job: ${event.job_id || 'N/A'} | Function: ${scheduledJob?.function_name || 'N/A'} | User: ${event.user_id || 'system'}`;
|
||||
case 'job_completed':
|
||||
const completedJob = event.event_data as any;
|
||||
return `Job: ${event.job_id || 'N/A'} | Function: ${completedJob?.job_function || 'N/A'} | User: ${event.user_id || 'system'} | Time: ${completedJob?.execution_time_seconds?.toFixed(2) || 'N/A'}s`;
|
||||
case 'job_failed':
|
||||
const failedJob = event.event_data as any;
|
||||
const expensive = failedJob?.expensive_api_call ? '💰 Expensive API call wasted' : '';
|
||||
const errorMsg = event.error_message || failedJob?.exception_message || 'Unknown error';
|
||||
return `Job: ${event.job_id || 'N/A'} | Function: ${failedJob?.job_function || 'N/A'} | User: ${event.user_id || 'system'} | Error: ${errorMsg}${expensive ? ` | ${expensive}` : ''}`;
|
||||
default:
|
||||
return JSON.stringify(event.event_data || {});
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'N/A';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && events.length === 0) {
|
||||
return (
|
||||
<TerminalPaper>
|
||||
<Box p={3}>
|
||||
<TerminalTypography variant="h6" gutterBottom>
|
||||
📜 Scheduler Event History
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.info }}>
|
||||
Loading event history...
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<TerminalPaper>
|
||||
<Box p={3}>
|
||||
<TerminalTypography variant="h6" gutterBottom>
|
||||
📜 Scheduler Event History
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.error }}>
|
||||
Error: {error}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TerminalPaper>
|
||||
<Box p={2}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<TerminalTypography variant="h6">
|
||||
📜 Scheduler Event History
|
||||
</TerminalTypography>
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel sx={{ color: terminalColors.primary }}>Event Type</InputLabel>
|
||||
<Select
|
||||
value={eventTypeFilter}
|
||||
onChange={(e) => {
|
||||
setEventTypeFilter(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
'& .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: terminalColors.primary,
|
||||
},
|
||||
'& .MuiSvgIcon-root': {
|
||||
color: terminalColors.primary,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem value="all">All Events</MenuItem>
|
||||
<MenuItem value="check_cycle">Check Cycles</MenuItem>
|
||||
<MenuItem value="interval_adjustment">Interval Adjustments</MenuItem>
|
||||
<MenuItem value="start">Scheduler Start</MenuItem>
|
||||
<MenuItem value="stop">Scheduler Stop</MenuItem>
|
||||
<MenuItem value="job_scheduled">Job Scheduled</MenuItem>
|
||||
<MenuItem value="job_completed">Job Completed</MenuItem>
|
||||
<MenuItem value="job_failed">Job Failed</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{events.length === 0 ? (
|
||||
<Box p={3} textAlign="center">
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.info }}>
|
||||
No scheduler events found. Events will appear here as the scheduler runs.
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TerminalTableCell>Date</TerminalTableCell>
|
||||
<TerminalTableCell>Event Type</TerminalTableCell>
|
||||
<TerminalTableCell>Details</TerminalTableCell>
|
||||
{(events.some(e => e.event_type === 'job_failed' && e.error_message)) && (
|
||||
<TerminalTableCell>Error</TerminalTableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{events.map((event) => (
|
||||
<TerminalTableRow key={event.id}>
|
||||
<TerminalTableCell>
|
||||
<TerminalTypography variant="body2" fontSize="0.75rem">
|
||||
{formatDate(event.event_date)}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<Chip
|
||||
label={event.event_type}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: getEventTypeColor(event.event_type),
|
||||
color: '#000',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
/>
|
||||
</TerminalTableCell>
|
||||
<TerminalTableCell>
|
||||
<TerminalTypography variant="body2" fontSize="0.75rem" sx={{
|
||||
color: getEventTypeColor(event.event_type),
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
{formatEventDetails(event)}
|
||||
</TerminalTypography>
|
||||
</TerminalTableCell>
|
||||
{event.event_type === 'job_failed' && event.error_message && (
|
||||
<TerminalTableCell>
|
||||
<Tooltip title={event.error_message} arrow>
|
||||
<TerminalTypography variant="body2" fontSize="0.7rem" sx={{
|
||||
color: terminalColors.error,
|
||||
fontFamily: 'monospace',
|
||||
maxWidth: '300px',
|
||||
wordBreak: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical'
|
||||
}}>
|
||||
{event.error_message}
|
||||
</TerminalTypography>
|
||||
</Tooltip>
|
||||
</TerminalTableCell>
|
||||
)}
|
||||
{event.event_type !== 'job_failed' && events.some(e => e.event_type === 'job_failed' && e.error_message) && (
|
||||
<TerminalTableCell></TerminalTableCell>
|
||||
)}
|
||||
</TerminalTableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
sx={{
|
||||
color: terminalColors.primary,
|
||||
'& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': {
|
||||
color: terminalColors.primary,
|
||||
},
|
||||
'& .MuiIconButton-root': {
|
||||
color: terminalColors.primary,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchedulerEventHistory;
|
||||
|
||||
272
frontend/src/components/SchedulerDashboard/SchedulerJobsTree.tsx
Normal file
272
frontend/src/components/SchedulerDashboard/SchedulerJobsTree.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* Scheduler Jobs Tree Component
|
||||
* Displays scheduled jobs in tree structure matching log format.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import {
|
||||
Schedule as ScheduleIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Event as EventIcon,
|
||||
Person as PersonIcon,
|
||||
Storage as StorageIcon
|
||||
} from '@mui/icons-material';
|
||||
import { SchedulerJob } from '../../api/schedulerDashboard';
|
||||
import { TerminalPaper, TerminalTypography, TerminalChip, terminalColors } from './terminalTheme';
|
||||
|
||||
interface SchedulerJobsTreeProps {
|
||||
jobs: SchedulerJob[];
|
||||
recurringJobs: number;
|
||||
oneTimeJobs: number;
|
||||
}
|
||||
|
||||
const SchedulerJobsTree: React.FC<SchedulerJobsTreeProps> = ({
|
||||
jobs,
|
||||
recurringJobs,
|
||||
oneTimeJobs
|
||||
}) => {
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Not scheduled';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const getJobTypeIcon = (jobId: string) => {
|
||||
if (jobId === 'check_due_tasks') {
|
||||
return <RefreshIcon fontSize="small" />;
|
||||
}
|
||||
return <EventIcon fontSize="small" />;
|
||||
};
|
||||
|
||||
const getJobTypeLabel = (jobId: string, job?: SchedulerJob) => {
|
||||
if (jobId === 'check_due_tasks') {
|
||||
return 'Recurring';
|
||||
}
|
||||
if (jobId.includes('research_persona')) {
|
||||
return 'Research Persona';
|
||||
}
|
||||
if (jobId.includes('facebook_persona')) {
|
||||
return 'Facebook Persona';
|
||||
}
|
||||
if (jobId.includes('oauth_token_monitoring')) {
|
||||
// Extract platform from job ID or use platform field
|
||||
const platform = job?.platform ||
|
||||
jobId.split('_')[2] ||
|
||||
'OAuth';
|
||||
const platformNames: { [key: string]: string } = {
|
||||
'gsc': 'GSC',
|
||||
'bing': 'Bing',
|
||||
'wordpress': 'WordPress',
|
||||
'wix': 'Wix'
|
||||
};
|
||||
return `OAuth ${platformNames[platform] || platform.toUpperCase()}`;
|
||||
}
|
||||
return 'One-Time';
|
||||
};
|
||||
|
||||
const getJobTypeColor = (jobId: string) => {
|
||||
if (jobId === 'check_due_tasks') {
|
||||
return 'primary';
|
||||
}
|
||||
return 'secondary';
|
||||
};
|
||||
|
||||
// Separate recurring and one-time jobs
|
||||
const recurringJob = jobs.find(j => j.id === 'check_due_tasks');
|
||||
const oneTimeJobsList = jobs.filter(j => j.id !== 'check_due_tasks');
|
||||
|
||||
return (
|
||||
<TerminalPaper sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={2}>
|
||||
<ScheduleIcon sx={{ color: terminalColors.primary }} />
|
||||
<TerminalTypography variant="h6" component="h2" sx={{ fontSize: '1.25rem', fontWeight: 'bold' }}>
|
||||
Scheduled Jobs
|
||||
</TerminalTypography>
|
||||
<TerminalChip
|
||||
label={`${jobs.length} total`}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ fontFamily: 'monospace', fontSize: '0.875rem', color: terminalColors.text, flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||
{/* Header */}
|
||||
<Box mb={2} sx={{ flexShrink: 0 }}>
|
||||
<TerminalTypography variant="body2" sx={{ mb: 1, color: terminalColors.textSecondary }}>
|
||||
Recurring Jobs: {recurringJobs} | One-Time Jobs: {oneTimeJobs}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
|
||||
{/* Jobs Tree */}
|
||||
{jobs.length > 0 ? (
|
||||
<Box sx={{ flex: 1 }}>
|
||||
{jobs.map((job, index) => {
|
||||
const isLast = index === jobs.length - 1;
|
||||
const prefix = isLast ? '└─' : '├─';
|
||||
const isRecurring = job.id === 'check_due_tasks';
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={job.id}
|
||||
sx={{
|
||||
mb: 2,
|
||||
display: 'block',
|
||||
borderLeft: `2px solid ${terminalColors.border}`,
|
||||
pl: 2,
|
||||
py: 1
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="flex-start"
|
||||
gap={1.5}
|
||||
flexWrap="wrap"
|
||||
sx={{
|
||||
width: '100%',
|
||||
minHeight: '50px',
|
||||
}}
|
||||
>
|
||||
{/* Tree prefix and chip */}
|
||||
<Box display="flex" alignItems="center" gap={1} sx={{ flexShrink: 0 }}>
|
||||
<TerminalTypography component="span" sx={{ fontFamily: 'monospace', color: terminalColors.primary, fontSize: '1.2rem' }}>
|
||||
{prefix}
|
||||
</TerminalTypography>
|
||||
<TerminalChip
|
||||
icon={getJobTypeIcon(job.id)}
|
||||
label={getJobTypeLabel(job.id, job)}
|
||||
size="small"
|
||||
sx={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Job details */}
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center', mb: 0.5 }}>
|
||||
<TerminalTypography
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: terminalColors.primary,
|
||||
wordBreak: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 'bold',
|
||||
maxWidth: '100%'
|
||||
}}
|
||||
>
|
||||
{job.id}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, alignItems: 'center', mt: 0.5 }}>
|
||||
<TerminalTypography
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: terminalColors.textSecondary,
|
||||
fontSize: '0.8rem',
|
||||
wordBreak: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal'
|
||||
}}
|
||||
>
|
||||
Trigger: {job.trigger_type}
|
||||
</TerminalTypography>
|
||||
{job.next_run_time && (
|
||||
<TerminalTypography
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: terminalColors.textSecondary,
|
||||
fontSize: '0.8rem',
|
||||
wordBreak: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal'
|
||||
}}
|
||||
>
|
||||
Next Run: {formatDate(job.next_run_time)}
|
||||
</TerminalTypography>
|
||||
)}
|
||||
{job.user_id && (
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
<PersonIcon fontSize="small" sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }} />
|
||||
<TerminalTypography
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: terminalColors.textSecondary,
|
||||
fontSize: '0.8rem',
|
||||
wordBreak: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal'
|
||||
}}
|
||||
>
|
||||
User: {String(job.user_id)}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
{job.platform && (
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
<TerminalTypography
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: terminalColors.primary,
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 'bold',
|
||||
wordBreak: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal'
|
||||
}}
|
||||
>
|
||||
Platform: {job.platform.toUpperCase()}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
{job.user_job_store && job.user_job_store !== 'default' && (
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
<StorageIcon fontSize="small" sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }} />
|
||||
<TerminalTypography
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: 'monospace',
|
||||
color: terminalColors.textSecondary,
|
||||
fontSize: '0.8rem',
|
||||
wordBreak: 'break-word',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal'
|
||||
}}
|
||||
>
|
||||
Store: {job.user_job_store}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
) : (
|
||||
<TerminalTypography variant="body2" sx={{ fontStyle: 'italic', color: terminalColors.textSecondary }}>
|
||||
No jobs scheduled
|
||||
</TerminalTypography>
|
||||
)}
|
||||
</Box>
|
||||
</TerminalPaper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchedulerJobsTree;
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Scheduler Stats Cards Component
|
||||
* Displays scheduler metrics in card format.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Grid, Typography, Box } from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
PlayArrow as PlayArrowIcon,
|
||||
Pause as PauseIcon,
|
||||
TrendingUp as TrendingUpIcon,
|
||||
AccessTime as AccessTimeIcon
|
||||
} from '@mui/icons-material';
|
||||
import { SchedulerStats } from '../../api/schedulerDashboard';
|
||||
import { TerminalCard, TerminalCardContent, TerminalTypography, TerminalChip, TerminalChipSuccess, TerminalChipError, terminalColors } from './terminalTheme';
|
||||
|
||||
interface SchedulerStatsCardsProps {
|
||||
stats: SchedulerStats;
|
||||
}
|
||||
|
||||
const SchedulerStatsCards: React.FC<SchedulerStatsCardsProps> = ({ stats }) => {
|
||||
// Debug: Only log if cumulative values are actually present (not just 0 from defaults)
|
||||
// Suppress logging when all cumulative values are 0 to reduce console noise
|
||||
if (stats.cumulative_total_check_cycles !== undefined) {
|
||||
const hasCumulativeData = stats.cumulative_total_check_cycles > 0 ||
|
||||
stats.cumulative_tasks_found > 0 ||
|
||||
stats.cumulative_tasks_executed > 0;
|
||||
|
||||
// Only log if there's actual cumulative data or if this is the first render
|
||||
if (hasCumulativeData || stats.total_checks > 0) {
|
||||
console.log('📊 StatsCards received stats:', {
|
||||
total_checks: stats.total_checks,
|
||||
cumulative_total_check_cycles: stats.cumulative_total_check_cycles,
|
||||
cumulative_tasks_found: stats.cumulative_tasks_found,
|
||||
cumulative_tasks_executed: stats.cumulative_tasks_executed,
|
||||
cumulative_tasks_failed: stats.cumulative_tasks_failed,
|
||||
has_cumulative_data: hasCumulativeData
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (running: boolean) => {
|
||||
return running ? 'success' : 'error';
|
||||
};
|
||||
|
||||
const getStatusIcon = (running: boolean) => {
|
||||
return running ? <PlayArrowIcon /> : <PauseIcon />;
|
||||
};
|
||||
|
||||
const formatTime = (minutes: number) => {
|
||||
if (minutes >= 60) {
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: 'Scheduler Status',
|
||||
value: stats.running ? 'Running' : 'Stopped',
|
||||
icon: getStatusIcon(stats.running),
|
||||
color: getStatusColor(stats.running),
|
||||
subtitle: stats.running ? 'Active' : 'Inactive'
|
||||
},
|
||||
{
|
||||
title: 'Total Check Cycles',
|
||||
value: (stats.cumulative_total_check_cycles !== undefined && stats.cumulative_total_check_cycles !== null)
|
||||
? stats.cumulative_total_check_cycles.toLocaleString()
|
||||
: stats.total_checks.toLocaleString(),
|
||||
icon: <CheckCircleIcon />,
|
||||
color: 'primary' as const,
|
||||
subtitle: (stats.cumulative_total_check_cycles !== undefined && stats.cumulative_total_check_cycles !== null && stats.cumulative_total_check_cycles > 0)
|
||||
? `${stats.total_checks.toLocaleString()} this session (${stats.cumulative_total_check_cycles.toLocaleString()} total)`
|
||||
: stats.total_checks === 0
|
||||
? 'No cycles yet (scheduler waiting)'
|
||||
: 'Since startup'
|
||||
},
|
||||
{
|
||||
title: 'Tasks Executed',
|
||||
value: (stats.cumulative_tasks_executed !== undefined && stats.cumulative_tasks_executed !== null)
|
||||
? stats.cumulative_tasks_executed.toLocaleString()
|
||||
: stats.tasks_executed.toLocaleString(),
|
||||
icon: <TrendingUpIcon />,
|
||||
color: 'success' as const,
|
||||
subtitle: (stats.cumulative_tasks_executed !== undefined && stats.cumulative_tasks_executed !== null && stats.cumulative_tasks_executed > 0)
|
||||
? `${stats.tasks_executed.toLocaleString()} this session (${stats.cumulative_tasks_executed.toLocaleString()} total)`
|
||||
: stats.tasks_executed === 0
|
||||
? 'No tasks executed yet'
|
||||
: `${stats.tasks_failed > 0 ? `${stats.tasks_failed} failed` : 'All successful'}`
|
||||
},
|
||||
{
|
||||
title: 'Tasks Found',
|
||||
value: (stats.cumulative_tasks_found !== undefined && stats.cumulative_tasks_found !== null)
|
||||
? stats.cumulative_tasks_found.toLocaleString()
|
||||
: stats.tasks_found.toLocaleString(),
|
||||
icon: <ScheduleIcon />,
|
||||
color: 'info' as const,
|
||||
subtitle: (stats.cumulative_tasks_found !== undefined && stats.cumulative_tasks_found !== null && stats.cumulative_tasks_found > 0)
|
||||
? `${stats.tasks_found.toLocaleString()} this session (${stats.cumulative_tasks_found.toLocaleString()} total)`
|
||||
: stats.tasks_found === 0
|
||||
? 'No tasks scheduled yet'
|
||||
: `${stats.tasks_executed} executed, ${stats.tasks_failed} failed`
|
||||
},
|
||||
{
|
||||
title: 'Check Interval',
|
||||
value: formatTime(stats.check_interval_minutes),
|
||||
icon: <AccessTimeIcon />,
|
||||
color: 'secondary' as const,
|
||||
subtitle: stats.intelligent_scheduling
|
||||
? `Intelligent (${stats.active_strategies_count > 0 ? '15min' : '60min'} range)`
|
||||
: 'Fixed interval'
|
||||
},
|
||||
{
|
||||
title: 'Active Strategies',
|
||||
value: stats.active_strategies_count.toString(),
|
||||
icon: <TrendingUpIcon />,
|
||||
color: stats.active_strategies_count > 0 ? 'success' : 'default' as const,
|
||||
subtitle: stats.active_strategies_count > 0
|
||||
? 'With monitoring tasks'
|
||||
: 'No active strategies'
|
||||
}
|
||||
];
|
||||
|
||||
const getCardIconColor = (cardColor: string) => {
|
||||
switch (cardColor) {
|
||||
case 'success':
|
||||
return terminalColors.success;
|
||||
case 'error':
|
||||
return terminalColors.error;
|
||||
case 'primary':
|
||||
return terminalColors.primary;
|
||||
case 'info':
|
||||
return terminalColors.info;
|
||||
case 'secondary':
|
||||
return terminalColors.secondary;
|
||||
default:
|
||||
return terminalColors.text;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
{cards.map((card, index) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={index}>
|
||||
<TerminalCard sx={{ height: '100%' }}>
|
||||
<TerminalCardContent>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
borderRadius: '4px',
|
||||
backgroundColor: terminalColors.backgroundLight,
|
||||
border: `1px solid ${getCardIconColor(card.color)}`,
|
||||
color: getCardIconColor(card.color),
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
{card.icon}
|
||||
</Box>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
|
||||
{card.title}
|
||||
</TerminalTypography>
|
||||
</Box>
|
||||
</Box>
|
||||
<TerminalTypography variant="h4" component="div" sx={{ fontWeight: 600, mb: 0.5, fontSize: '1.75rem', color: terminalColors.primary }}>
|
||||
{card.value}
|
||||
</TerminalTypography>
|
||||
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary, fontSize: '0.875rem' }}>
|
||||
{card.subtitle}
|
||||
</TerminalTypography>
|
||||
{card.title === 'Scheduler Status' && stats.last_check && (
|
||||
<TerminalTypography variant="caption" sx={{ mt: 1, display: 'block', color: terminalColors.textSecondary, fontSize: '0.75rem' }}>
|
||||
Last check: {formatDate(stats.last_check)}
|
||||
</TerminalTypography>
|
||||
)}
|
||||
</TerminalCardContent>
|
||||
</TerminalCard>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchedulerStatsCards;
|
||||
|
||||
187
frontend/src/components/SchedulerDashboard/terminalTheme.ts
Normal file
187
frontend/src/components/SchedulerDashboard/terminalTheme.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Terminal Theme Styling
|
||||
* Shared terminal-themed styles for scheduler dashboard components
|
||||
*/
|
||||
|
||||
import { styled } from '@mui/material/styles';
|
||||
import { Box, Paper, Card, CardContent, Typography, Chip, TableCell, TableRow, Alert, Accordion } from '@mui/material';
|
||||
|
||||
export const TerminalPaper = styled(Paper)({
|
||||
backgroundColor: '#0a0a0a',
|
||||
border: '1px solid #00ff00',
|
||||
color: '#00ff00',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
padding: 16,
|
||||
minHeight: '200px', // Ensure minimum height for visibility
|
||||
'& *': {
|
||||
fontFamily: 'inherit',
|
||||
color: 'inherit', // Ensure all text inherits the green color
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalCard = styled(Card)({
|
||||
backgroundColor: '#0a0a0a',
|
||||
border: '1px solid #00ff00',
|
||||
color: '#00ff00',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
transition: 'all 0.2s',
|
||||
minHeight: '120px', // Ensure cards have minimum height
|
||||
'&:hover': {
|
||||
borderColor: '#00ff88',
|
||||
boxShadow: '0 0 15px rgba(0, 255, 0, 0.3)',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
'& *': {
|
||||
fontFamily: 'inherit',
|
||||
color: 'inherit', // Ensure all text inherits the green color
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalCardContent = styled(CardContent)({
|
||||
color: '#00ff00',
|
||||
'&:last-child': {
|
||||
paddingBottom: 16,
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalTypography = styled(Typography)<{ component?: React.ElementType }>(({ theme }) => ({
|
||||
color: '#00ff00',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
}));
|
||||
|
||||
export const TerminalChip = styled(Chip)({
|
||||
backgroundColor: '#1a1a1a',
|
||||
color: '#00ff00',
|
||||
border: '1px solid #00ff00',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
fontSize: '0.75rem',
|
||||
'& .MuiChip-label': {
|
||||
padding: '4px 8px',
|
||||
},
|
||||
'& .MuiChip-icon': {
|
||||
color: '#00ff00',
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalChipSuccess = styled(Chip)({
|
||||
backgroundColor: '#0a2a0a',
|
||||
color: '#00ff00',
|
||||
border: '1px solid #00ff00',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
fontSize: '0.75rem',
|
||||
'& .MuiChip-label': {
|
||||
padding: '4px 8px',
|
||||
},
|
||||
'& .MuiChip-icon': {
|
||||
color: '#00ff00',
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalChipError = styled(Chip)({
|
||||
backgroundColor: '#2a0a0a',
|
||||
color: '#ff4444',
|
||||
border: '1px solid #ff4444',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
fontSize: '0.75rem',
|
||||
'& .MuiChip-label': {
|
||||
padding: '4px 8px',
|
||||
},
|
||||
'& .MuiChip-icon': {
|
||||
color: '#ff4444',
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalChipWarning = styled(Chip)({
|
||||
backgroundColor: '#2a2a0a',
|
||||
color: '#ffd700',
|
||||
border: '1px solid #ffd700',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
fontSize: '0.75rem',
|
||||
'& .MuiChip-label': {
|
||||
padding: '4px 8px',
|
||||
},
|
||||
'& .MuiChip-icon': {
|
||||
color: '#ffd700',
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalTableCell = styled(TableCell)({
|
||||
color: '#00ff00',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
borderColor: '#004400',
|
||||
fontSize: '0.875rem',
|
||||
});
|
||||
|
||||
export const TerminalTableRow = styled(TableRow)({
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.05)',
|
||||
},
|
||||
'&:nth-of-type(even)': {
|
||||
backgroundColor: 'rgba(0, 255, 0, 0.02)',
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalAlert = styled(Alert)({
|
||||
backgroundColor: '#1a1a1a',
|
||||
color: '#ff4444',
|
||||
border: '1px solid #ff4444',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
'& .MuiAlert-icon': {
|
||||
color: '#ff4444',
|
||||
},
|
||||
'&.MuiAlert-standardSuccess': {
|
||||
color: '#00ff00',
|
||||
borderColor: '#00ff00',
|
||||
'& .MuiAlert-icon': {
|
||||
color: '#00ff00',
|
||||
}
|
||||
},
|
||||
'&.MuiAlert-standardWarning': {
|
||||
color: '#ffd700',
|
||||
borderColor: '#ffd700',
|
||||
'& .MuiAlert-icon': {
|
||||
color: '#ffd700',
|
||||
}
|
||||
},
|
||||
'&.MuiAlert-standardInfo': {
|
||||
color: '#00ffff',
|
||||
borderColor: '#00ffff',
|
||||
'& .MuiAlert-icon': {
|
||||
color: '#00ffff',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalAccordion = styled(Accordion)({
|
||||
backgroundColor: '#1a1a1a',
|
||||
border: '1px solid #00ff00',
|
||||
color: '#00ff00',
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
'&:before': {
|
||||
display: 'none',
|
||||
},
|
||||
'&.Mui-expanded': {
|
||||
margin: 0,
|
||||
}
|
||||
});
|
||||
|
||||
export const TerminalBox = styled(Box)({
|
||||
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
|
||||
color: '#00ff00',
|
||||
});
|
||||
|
||||
// Color constants
|
||||
export const terminalColors = {
|
||||
primary: '#00ff00',
|
||||
secondary: '#00ff88',
|
||||
error: '#ff4444',
|
||||
warning: '#ffd700',
|
||||
info: '#00ffff',
|
||||
success: '#00ff00',
|
||||
background: '#0a0a0a',
|
||||
backgroundLight: '#1a1a1a',
|
||||
text: '#00ff00',
|
||||
textSecondary: '#00ff88',
|
||||
border: '#00ff00',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user