Research Wizard and CopilotKit mitigation review

This commit is contained in:
ajaysi
2025-11-04 08:11:57 +05:30
parent e69107b07c
commit 55087c4f37
27 changed files with 2167 additions and 277 deletions

View File

@@ -270,6 +270,7 @@ export const BlogWriter: React.FC = () => {
handleOutlineAction,
handleContentAction,
handleSEOAction,
handleApplySEORecommendations,
handlePublishAction,
} = usePhaseActionHandlers({
research,
@@ -284,6 +285,7 @@ export const BlogWriter: React.FC = () => {
outlineGenRef,
setOutline,
setContentConfirmed,
setIsSEOAnalysisModalOpen,
setIsSEOMetadataModalOpen,
runSEOAnalysisDirect,
onOutlineComplete: handleCachedOutlineComplete,
@@ -391,6 +393,7 @@ export const BlogWriter: React.FC = () => {
onOutlineAction: handleOutlineAction,
onContentAction: handleContentAction,
onSEOAction: handleSEOAction,
onApplySEORecommendations: handleApplySEORecommendations,
onPublishAction: handlePublishAction,
}}
hasResearch={!!research}
@@ -399,6 +402,7 @@ export const BlogWriter: React.FC = () => {
hasContent={Object.keys(sections).length > 0}
contentConfirmed={contentConfirmed}
hasSEOAnalysis={!!seoAnalysis}
seoRecommendationsApplied={seoRecommendationsApplied}
hasSEOMetadata={!!seoMetadata}
/>
)}

View File

@@ -13,6 +13,7 @@ interface HeaderBarProps {
hasContent?: boolean;
contentConfirmed?: boolean;
hasSEOAnalysis?: boolean;
seoRecommendationsApplied?: boolean;
hasSEOMetadata?: boolean;
}
@@ -28,6 +29,7 @@ export const HeaderBar: React.FC<HeaderBarProps> = ({
hasContent = false,
contentConfirmed = false,
hasSEOAnalysis = false,
seoRecommendationsApplied = false,
hasSEOMetadata = false,
}) => {
return (
@@ -61,6 +63,7 @@ export const HeaderBar: React.FC<HeaderBarProps> = ({
hasContent={hasContent}
contentConfirmed={contentConfirmed}
hasSEOAnalysis={hasSEOAnalysis}
seoRecommendationsApplied={seoRecommendationsApplied}
hasSEOMetadata={hasSEOMetadata}
/>
</div>

View File

@@ -25,28 +25,28 @@ export const WriterCopilotSidebar: React.FC<WriterCopilotSidebarProps> = ({
}}
suggestions={suggestions}
makeSystemMessage={(context: string, additional?: string) => {
const hasResearch = research !== null;
const hasOutline = outline.length > 0;
const hasResearch = research !== null && research !== undefined;
const hasOutline = outline && outline.length > 0;
const isOutlineConfirmed = outlineConfirmed;
const researchInfo = hasResearch
const researchInfo = hasResearch && research
? {
sources: research.sources?.length || 0,
queries: research.search_queries?.length || 0,
angles: research.suggested_angles?.length || 0,
primaryKeywords: research.keyword_analysis?.primary || [],
searchIntent: research.keyword_analysis?.search_intent || 'informational',
sources: research?.sources?.length || 0,
queries: research?.search_queries?.length || 0,
angles: research?.suggested_angles?.length || 0,
primaryKeywords: research?.keyword_analysis?.primary || [],
searchIntent: research?.keyword_analysis?.search_intent || 'informational',
}
: null;
const outlineContext = hasOutline
const outlineContext = hasOutline && outline
? `
OUTLINE DETAILS:
- Total sections: ${outline.length}
- Section headings: ${outline.map((s: any) => s.heading).join(', ')}
- Total target words: ${outline.reduce((sum: number, s: any) => sum + (s.target_words || 0), 0)}
- Section breakdown: ${outline
- Section headings: ${(outline || []).map((s: any) => s?.heading || 'Untitled').join(', ')}
- Total target words: ${(outline || []).reduce((sum: number, s: any) => sum + (s?.target_words || 0), 0)}
- Section breakdown: ${(outline || [])
.map(
(s: any) => `${s.heading} (${s.target_words || 0} words, ${s.subheadings?.length || 0} subheadings, ${s.key_points?.length || 0} key points)`
(s: any) => `${s?.heading || 'Untitled'} (${s?.target_words || 0} words, ${s?.subheadings?.length || 0} subheadings, ${s?.key_points?.length || 0} key points)`
)
.join('; ')}
`
@@ -65,7 +65,7 @@ ${hasResearch && researchInfo ? `
- Search intent: ${researchInfo.searchIntent}
` : '❌ No research completed yet'}
${hasOutline ? `✅ OUTLINE GENERATED: ${outline.length} sections created${isOutlineConfirmed ? ' (CONFIRMED)' : ' (PENDING CONFIRMATION)'}` : '❌ No outline generated yet'}
${hasOutline && outline ? `✅ OUTLINE GENERATED: ${outline.length} sections created${isOutlineConfirmed ? ' (CONFIRMED)' : ' (PENDING CONFIRMATION)'}` : '❌ No outline generated yet'}
${outlineContext}
Available tools:

View File

@@ -17,6 +17,7 @@ interface UsePhaseActionHandlersProps {
outlineGenRef: React.RefObject<any>;
setOutline: (outline: any[]) => void;
setContentConfirmed: (confirmed: boolean) => void;
setIsSEOAnalysisModalOpen: (open: boolean) => void;
setIsSEOMetadataModalOpen: (open: boolean) => void;
runSEOAnalysisDirect: () => string;
onOutlineComplete?: (outline: any) => void;
@@ -36,6 +37,7 @@ export const usePhaseActionHandlers = ({
outlineGenRef,
setOutline,
setContentConfirmed,
setIsSEOAnalysisModalOpen,
setIsSEOMetadataModalOpen,
runSEOAnalysisDirect,
onOutlineComplete,
@@ -162,13 +164,20 @@ export const usePhaseActionHandlers = ({
}
navigateToPhase('seo');
runSEOAnalysisDirect();
debug.log('[BlogWriter] SEO action triggered');
debug.log('[BlogWriter] SEO action triggered - running SEO analysis');
}, [contentConfirmed, setContentConfirmed, navigateToPhase, runSEOAnalysisDirect]);
const handleApplySEORecommendations = useCallback(() => {
navigateToPhase('seo');
setIsSEOAnalysisModalOpen(true);
debug.log('[BlogWriter] Apply SEO Recommendations action triggered - opening SEO analysis modal');
}, [navigateToPhase, setIsSEOAnalysisModalOpen]);
const handlePublishAction = useCallback(() => {
navigateToPhase('publish');
// Can be called from SEO phase (after recommendations applied) or publish phase
navigateToPhase('seo'); // Stay in SEO phase if called from there
setIsSEOMetadataModalOpen(true);
debug.log('[BlogWriter] Publish action triggered - opening SEO metadata modal');
debug.log('[BlogWriter] Generate SEO Metadata action triggered - opening SEO metadata modal');
}, [navigateToPhase, setIsSEOMetadataModalOpen]);
return {
@@ -176,6 +185,7 @@ export const usePhaseActionHandlers = ({
handleOutlineAction,
handleContentAction,
handleSEOAction,
handleApplySEORecommendations,
handlePublishAction,
};
};

View File

@@ -15,6 +15,7 @@ export interface PhaseActionHandlers {
onOutlineAction?: () => void; // Generate outline
onContentAction?: () => void; // Confirm outline + generate content
onSEOAction?: () => void; // Run SEO analysis
onApplySEORecommendations?: () => void; // Apply SEO recommendations
onPublishAction?: () => void; // Generate SEO metadata or publish
}
@@ -31,6 +32,7 @@ interface PhaseNavigationProps {
hasContent?: boolean;
contentConfirmed?: boolean;
hasSEOAnalysis?: boolean;
seoRecommendationsApplied?: boolean;
hasSEOMetadata?: boolean;
}
@@ -46,6 +48,7 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
hasContent = false,
contentConfirmed = false,
hasSEOAnalysis = false,
seoRecommendationsApplied = false,
hasSEOMetadata = false,
}) => {
// Determine which action to show for each phase when CopilotKit is unavailable
@@ -61,7 +64,10 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
}
break;
case 'outline':
if (hasResearch && !hasOutline) {
// Show "Create Outline" if research exists and outline is not yet confirmed
// This ensures users can create/regenerate outline after research, even if cached one exists
// Once outline is confirmed, we hide the button to avoid confusion during content generation
if (hasResearch && !outlineConfirmed) {
return { label: 'Create Outline', handler: actionHandlers.onOutlineAction || null };
}
break;
@@ -71,13 +77,26 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
}
break;
case 'seo':
if (hasContent && contentConfirmed && !hasSEOAnalysis) {
// Priority order matching CopilotKit suggestions:
// 1. No SEO analysis yet - Run SEO Analysis
// Note: We check hasContent (sections exist) - contentConfirmed is checked but not strictly required
// This allows users to run SEO analysis even if contentConfirmed hasn't been explicitly set
if (hasContent && !hasSEOAnalysis) {
return { label: 'Run SEO Analysis', handler: actionHandlers.onSEOAction || null };
}
// 2. SEO analysis exists but recommendations not applied - Apply SEO Recommendations
if (hasSEOAnalysis && !seoRecommendationsApplied) {
return { label: 'Apply SEO Recommendations', handler: actionHandlers.onApplySEORecommendations || null };
}
// 3. SEO analysis exists and recommendations applied but no metadata - Generate SEO Metadata
if (hasSEOAnalysis && seoRecommendationsApplied && !hasSEOMetadata) {
return { label: 'Generate SEO Metadata', handler: actionHandlers.onPublishAction || null };
}
break;
case 'publish':
if (hasSEOAnalysis && !hasSEOMetadata) {
return { label: 'Generate SEO Metadata', handler: actionHandlers.onPublishAction || null };
// Only show if SEO metadata exists (ready to publish)
if (hasSEOAnalysis && seoRecommendationsApplied && hasSEOMetadata) {
return { label: 'Ready to Publish', handler: null }; // Publish handled separately
}
break;
}
@@ -97,17 +116,59 @@ export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
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)
// For research phase specifically, always show if no research exists
// 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;
const showAction = !copilotKitAvailable && action.handler && !isDisabled && (
// 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
const showAction = !copilotKitAvailable && action.handler && (
isCurrent ||
(!isCompleted && !isDisabled) ||
isResearchPhase
isResearchPhase ||
isOutlinePhase ||
isSEOPhase // Show SEO actions when handler exists - handler existence means prerequisites are met, so ignore isDisabled
);
return (

View File

@@ -0,0 +1,238 @@
import React from 'react';
import { BlogResearchResponse } from '../../../services/blogWriterApi';
interface GoogleSearchModalProps {
research: BlogResearchResponse;
onClose: () => void;
}
export const GoogleSearchModal: React.FC<GoogleSearchModalProps> = ({ research, onClose }) => {
if (!research.search_widget && !research.search_queries?.length) {
return null;
}
const handleSearchClick = (query: string) => {
// Open Google Search in new tab per Google requirements
const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`;
window.open(searchUrl, '_blank', 'noopener,noreferrer');
};
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
backdropFilter: 'blur(4px)'
}}
onClick={onClose}>
<div style={{
backgroundColor: 'white',
borderRadius: '16px',
padding: '32px',
maxWidth: '800px',
width: '90%',
maxHeight: '90vh',
overflow: 'auto',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid #e5e7eb',
position: 'relative'
}}
onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '24px',
borderBottom: '2px solid #f3f4f6',
paddingBottom: '16px'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{
width: '48px',
height: '48px',
backgroundColor: '#f8fafc',
borderRadius: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid #e2e8f0'
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M21 21L15 15M17 10C17 13.866 13.866 17 10 17C6.13401 17 3 13.866 3 10C3 6.13401 6.13401 3 10 3C13.866 3 17 6.13401 17 10Z" stroke="#4285F4" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<div>
<h3 style={{
margin: 0,
color: '#1f2937',
fontSize: '24px',
fontWeight: '700'
}}>
Google Search Suggestions
</h3>
<p style={{
margin: '4px 0 0 0',
color: '#6b7280',
fontSize: '14px'
}}>
Explore related searches and sources
</p>
</div>
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
fontSize: '28px',
cursor: 'pointer',
color: '#6b7280',
padding: '8px',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease',
width: '40px',
height: '40px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#f3f4f6';
e.currentTarget.style.color = '#374151';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = '#6b7280';
}}
>
×
</button>
</div>
{/* Google Search Widget - Display exactly as provided per Google requirements */}
{research.search_widget && (
<div style={{
marginBottom: '32px',
width: '100%',
padding: '20px',
backgroundColor: '#f8fafc',
borderRadius: '12px',
border: '1px solid #e2e8f0'
}}>
<div style={{
marginBottom: '12px',
fontSize: '13px',
fontWeight: '600',
color: '#475569',
display: 'flex',
alignItems: 'center',
gap: '6px'
}}>
<span>🔍</span>
<span>Search Suggestions (Click to open in Google)</span>
</div>
{/* Render Google's HTML exactly as provided - no modifications */}
<div
dangerouslySetInnerHTML={{ __html: research.search_widget }}
/>
</div>
)}
{/* Search Queries List */}
{research.search_queries && research.search_queries.length > 0 && (
<div style={{ marginTop: '32px' }}>
<h4 style={{
margin: '0 0 16px 0',
color: '#1f2937',
fontSize: '18px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<span>📋</span>
Additional Search Queries
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{research.search_queries.map((query, index) => (
<button
key={index}
onClick={() => handleSearchClick(query)}
style={{
width: '100%',
padding: '12px 16px',
backgroundColor: 'white',
border: '1px solid #d1d5db',
borderRadius: '8px',
cursor: 'pointer',
textAlign: 'left',
fontSize: '14px',
color: '#374151',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#f9fafb';
e.currentTarget.style.borderColor = '#4285F4';
e.currentTarget.style.transform = 'translateX(4px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'white';
e.currentTarget.style.borderColor = '#d1d5db';
e.currentTarget.style.transform = 'translateX(0)';
}}
>
<span style={{ flex: 1 }}>{query}</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
<path d="M7 17L17 7M17 7H7M17 7V17" stroke="#4285F4" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
))}
</div>
</div>
)}
{/* Info Footer */}
<div style={{
marginTop: '24px',
padding: '16px',
backgroundColor: '#eff6ff',
borderRadius: '8px',
border: '1px solid #bfdbfe'
}}>
<div style={{
display: 'flex',
alignItems: 'flex-start',
gap: '8px',
fontSize: '13px',
color: '#1e40af'
}}>
<span style={{ fontSize: '16px', lineHeight: '1.5' }}></span>
<div>
<div style={{ fontWeight: '600', marginBottom: '4px' }}>
About These Suggestions
</div>
<div style={{ lineHeight: '1.6' }}>
These search suggestions are generated by Google's AI to help you explore related topics.
Clicking any suggestion will open Google Search in a new tab to find the latest and most relevant information.
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default GoogleSearchModal;

View File

@@ -436,20 +436,7 @@ export const ResearchSources: React.FC<ResearchSourcesProps> = ({ research }) =>
</div>
)}
{/* Google Search Suggestions - Per Google Display Requirements */}
{research.search_widget && (
<div style={{
marginBottom: '24px',
width: '100%',
position: 'relative'
}}>
{/* Google Search Widget - Display exactly as provided without modifications */}
<div
dangerouslySetInnerHTML={{ __html: research.search_widget }}
/>
</div>
)}
{/* Note: Google Search Widget is shown in GoogleSearchModal instead */}
<div style={{
display: 'grid',
gap: '12px',

View File

@@ -1,2 +1,3 @@
export { ResearchSources } from './ResearchSources';
export { ResearchGrounding } from './ResearchGrounding';
export { GoogleSearchModal } from './GoogleSearchModal';

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { BlogResearchResponse } from '../../services/blogWriterApi';
import { ResearchSources, ResearchGrounding } from './ResearchComponents';
import { ResearchSources, ResearchGrounding, GoogleSearchModal } from './ResearchComponents';
interface ResearchResultsProps {
research: BlogResearchResponse;
@@ -10,6 +10,7 @@ export const ResearchResults: React.FC<ResearchResultsProps> = ({ research }) =>
const [showAnglesModal, setShowAnglesModal] = useState(false);
const [showCompetitorModal, setShowCompetitorModal] = useState(false);
const [showGroundingModal, setShowGroundingModal] = useState(false);
const [showSearchModal, setShowSearchModal] = useState(false);
const [showToast, setShowToast] = useState(false);
// Show toast message on component mount
@@ -501,6 +502,38 @@ export const ResearchResults: React.FC<ResearchResultsProps> = ({ research }) =>
>
📝 Use Research Blog Topics
</div>
{/* Google Search Suggestions Chip - Only show when we have search data */}
{(research.search_widget || (research.search_queries && research.search_queries.length > 0)) && (
<div
onClick={() => setShowSearchModal(true)}
style={{
backgroundColor: '#fff8e1',
color: '#f57c00',
border: '1px solid #ffb74d',
borderRadius: '20px',
padding: '6px 16px',
fontSize: '13px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '6px',
cursor: 'pointer',
transition: 'all 0.2s ease',
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.1)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#ffe082';
e.currentTarget.style.transform = 'scale(1.05)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#fff8e1';
e.currentTarget.style.transform = 'scale(1)';
}}
>
🔍 Google Search
</div>
)}
</div>
</div>
@@ -539,6 +572,14 @@ export const ResearchResults: React.FC<ResearchResultsProps> = ({ research }) =>
{renderAnglesModal()}
{renderCompetitorModal()}
{renderGroundingModal()}
{/* Google Search Modal */}
{showSearchModal && (
<GoogleSearchModal
research={research}
onClose={() => setShowSearchModal(false)}
/>
)}
</div>
</>
);

View File

@@ -1,8 +1,7 @@
import React, { useEffect } from 'react';
import { useResearchWizard } from './hooks/useResearchWizard';
import { useResearchExecution } from './hooks/useResearchExecution';
import { StepKeyword } from './steps/StepKeyword';
import { StepOptions } from './steps/StepOptions';
import { ResearchInput } from './steps/ResearchInput';
import { StepProgress } from './steps/StepProgress';
import { StepResults } from './steps/StepResults';
import { ResearchWizardProps } from './types/research.types';
@@ -19,12 +18,17 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
// Handle results from execution
useEffect(() => {
if (execution.result && !execution.isExecuting) {
console.log('[ResearchWizard] Results received, updating state and navigating:', {
hasResults: !!execution.result,
currentStep: wizard.state.currentStep,
shouldNavigate: wizard.state.currentStep === 2
});
wizard.updateState({ results: execution.result });
if (wizard.state.currentStep === 3) {
if (wizard.state.currentStep === 2) {
wizard.nextStep();
}
}
}, [execution.result, execution.isExecuting]);
}, [execution.result, execution.isExecuting]); // Don't depend on currentStep to avoid loops
// Handle completion callback
useEffect(() => {
@@ -43,61 +47,79 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
switch (wizard.state.currentStep) {
case 1:
return <StepKeyword {...stepProps} />;
return <ResearchInput {...stepProps} />;
case 2:
return <StepOptions {...stepProps} />;
return <StepProgress {...stepProps} execution={execution} />;
case 3:
return <StepProgress {...stepProps} />;
case 4:
return <StepResults {...stepProps} />;
default:
return <StepKeyword {...stepProps} />;
return <ResearchInput {...stepProps} />;
}
};
return (
<div style={{
minHeight: '100vh',
backgroundColor: '#f5f5f5',
padding: '20px',
}}>
<div>
{/* Wizard Container */}
<div style={{
maxWidth: '1200px',
margin: '0 auto',
backgroundColor: 'white',
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
background: 'rgba(255, 255, 255, 0.8)',
backdropFilter: 'blur(12px)',
border: '1px solid rgba(14, 165, 233, 0.2)',
borderRadius: '20px',
boxShadow: '0 4px 16px rgba(14, 165, 233, 0.1)',
overflow: 'hidden',
}}>
{/* Header */}
<div style={{
backgroundColor: '#1976d2',
color: 'white',
padding: '24px',
borderBottom: '1px solid #e0e0e0',
background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.08) 0%, rgba(56, 189, 248, 0.08) 100%)',
borderBottom: '1px solid rgba(14, 165, 233, 0.15)',
padding: '20px 28px',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 style={{ margin: 0, fontSize: '24px' }}>Research Wizard</h1>
<p style={{ margin: '8px 0 0 0', fontSize: '14px', opacity: 0.9 }}>
Step {wizard.state.currentStep} of {wizard.maxSteps}
<h1 style={{
margin: 0,
fontSize: '24px',
fontWeight: '700',
color: '#0c4a6e',
}}>
Research Wizard
</h1>
<p style={{
margin: '4px 0 0 0',
fontSize: '13px',
color: '#0369a1',
fontWeight: '400',
}}>
Phase {wizard.state.currentStep} of {wizard.maxSteps} AI-Powered Intelligence
</p>
</div>
{onCancel && (
<button
onClick={onCancel}
onClick={() => {
wizard.reset();
onCancel();
}}
style={{
padding: '8px 16px',
backgroundColor: 'rgba(255,255,255,0.2)',
color: 'white',
border: '1px solid rgba(255,255,255,0.3)',
borderRadius: '6px',
background: 'rgba(239, 68, 68, 0.1)',
color: '#dc2626',
border: '1px solid rgba(239, 68, 68, 0.25)',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '14px',
fontSize: '13px',
fontWeight: '500',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.15)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.1)';
}}
>
Cancel
Cancel
</button>
)}
</div>
@@ -105,16 +127,18 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
{/* Progress Bar */}
<div style={{
backgroundColor: '#f0f0f0',
height: '4px',
background: 'rgba(14, 165, 233, 0.1)',
height: '5px',
position: 'relative',
overflow: 'hidden',
}}>
<div
style={{
backgroundColor: '#1976d2',
background: 'linear-gradient(90deg, #0ea5e9 0%, #38bdf8 100%)',
height: '100%',
width: `${(wizard.state.currentStep / wizard.maxSteps) * 100}%`,
transition: 'width 0.3s ease',
transition: 'width 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: '0 0 8px rgba(14, 165, 233, 0.4)',
}}
/>
</div>
@@ -123,67 +147,123 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
<div style={{
display: 'flex',
justifyContent: 'space-around',
padding: '20px 40px',
borderBottom: '1px solid #e0e0e0',
padding: '24px 40px',
borderBottom: '1px solid rgba(14, 165, 233, 0.15)',
background: 'rgba(14, 165, 233, 0.03)',
}}>
{[1, 2, 3, 4].map(step => (
<div key={step} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', position: 'relative' }}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '50%',
backgroundColor: step <= wizard.state.currentStep ? '#1976d2' : '#e0e0e0',
color: step <= wizard.state.currentStep ? 'white' : '#999',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 'bold',
fontSize: '16px',
marginBottom: '8px',
transition: 'all 0.3s ease',
}}>
{step < wizard.state.currentStep ? '✓' : step}
{[1, 2, 3].map(step => {
const isActive = step === wizard.state.currentStep;
const isCompleted = step < wizard.state.currentStep;
const isClickable = step <= wizard.state.currentStep;
return (
<div
key={step}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
position: 'relative',
cursor: isClickable ? 'pointer' : 'default',
transition: 'all 0.2s ease',
}}
onClick={() => {
if (isClickable) {
wizard.updateState({ currentStep: step });
}
}}
onMouseEnter={(e) => {
if (isClickable) {
e.currentTarget.style.transform = 'scale(1.05)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
>
<div style={{
width: '48px',
height: '48px',
borderRadius: '50%',
background: isActive
? 'linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%)'
: isCompleted
? 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)'
: 'rgba(14, 165, 233, 0.1)',
color: (isActive || isCompleted) ? 'white' : '#64748b',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: '700',
fontSize: '18px',
marginBottom: '10px',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
border: isActive ? '2px solid rgba(14, 165, 233, 0.3)' : '2px solid rgba(14, 165, 233, 0.1)',
boxShadow: isActive
? '0 4px 16px rgba(14, 165, 233, 0.3)'
: isCompleted
? '0 2px 8px rgba(34, 197, 94, 0.2)'
: 'none',
}}>
{isCompleted ? '✓' : step}
</div>
<span style={{
fontSize: '13px',
color: (isActive || isCompleted) ? '#0c4a6e' : '#64748b',
fontWeight: isActive ? '600' : '400',
letterSpacing: '0.01em',
}}>
{step === 1 && 'Configure'}
{step === 2 && 'Execute'}
{step === 3 && 'Analyze'}
</span>
</div>
<span style={{
fontSize: '12px',
color: step <= wizard.state.currentStep ? '#1976d2' : '#999',
fontWeight: step === wizard.state.currentStep ? '600' : 'normal',
}}>
{step === 1 && 'Setup'}
{step === 2 && 'Options'}
{step === 3 && 'Research'}
{step === 4 && 'Results'}
</span>
</div>
))}
);
})}
</div>
{/* Content */}
<div style={{ padding: '24px' }}>
<div style={{ padding: '20px' }}>
{renderStep()}
</div>
{/* Navigation Footer */}
{wizard.state.currentStep <= 2 && (
{wizard.state.currentStep < 3 && (
<div style={{
padding: '20px 24px',
borderTop: '1px solid #e0e0e0',
padding: '20px 28px',
borderTop: '1px solid rgba(14, 165, 233, 0.15)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#fafafa',
background: 'rgba(14, 165, 233, 0.03)',
}}>
<button
onClick={wizard.prevStep}
disabled={wizard.isFirstStep}
style={{
padding: '10px 20px',
backgroundColor: wizard.isFirstStep ? '#f0f0f0' : 'white',
color: wizard.isFirstStep ? '#999' : '#333',
border: wizard.isFirstStep ? '1px solid #e0e0e0' : '1px solid #ddd',
borderRadius: '6px',
padding: '10px 24px',
background: wizard.isFirstStep ? 'rgba(100, 116, 139, 0.1)' : 'rgba(255, 255, 255, 0.8)',
color: wizard.isFirstStep ? '#94a3b8' : '#0c4a6e',
border: `1px solid ${wizard.isFirstStep ? 'rgba(100, 116, 139, 0.2)' : 'rgba(14, 165, 233, 0.2)'}`,
borderRadius: '10px',
cursor: wizard.isFirstStep ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontSize: '13px',
fontWeight: '500',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!wizard.isFirstStep) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 1)';
e.currentTarget.style.transform = 'translateX(-4px)';
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.4)';
}
}}
onMouseLeave={(e) => {
if (!wizard.isFirstStep) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.8)';
e.currentTarget.style.transform = 'translateX(0)';
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.2)';
}
}}
>
Back
@@ -194,16 +274,32 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
disabled={!wizard.canGoNext()}
style={{
padding: '10px 24px',
backgroundColor: wizard.canGoNext() ? '#1976d2' : '#e0e0e0',
color: wizard.canGoNext() ? 'white' : '#999',
border: 'none',
borderRadius: '6px',
background: wizard.canGoNext()
? 'linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%)'
: 'rgba(100, 116, 139, 0.2)',
color: wizard.canGoNext() ? 'white' : '#94a3b8',
border: wizard.canGoNext() ? 'none' : '1px solid rgba(100, 116, 139, 0.2)',
borderRadius: '10px',
cursor: wizard.canGoNext() ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontSize: '13px',
fontWeight: '600',
transition: 'all 0.2s ease',
boxShadow: wizard.canGoNext() ? '0 2px 8px rgba(14, 165, 233, 0.3)' : 'none',
}}
onMouseEnter={(e) => {
if (wizard.canGoNext()) {
e.currentTarget.style.transform = 'translateX(4px)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(14, 165, 233, 0.4)';
}
}}
onMouseLeave={(e) => {
if (wizard.canGoNext()) {
e.currentTarget.style.transform = 'translateX(0)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(14, 165, 233, 0.3)';
}
}}
>
{wizard.isLastStep ? 'Finish' : 'Next →'}
{wizard.isLastStep ? 'Finish' : 'Continue →'}
</button>
</div>
)}

View File

@@ -3,7 +3,7 @@ import { WizardState, WizardStepProps } from '../types/research.types';
import { ResearchMode, ResearchConfig, BlogResearchResponse } from '../../../services/blogWriterApi';
const WIZARD_STATE_KEY = 'alwrity_research_wizard_state';
const MAX_STEPS = 4;
const MAX_STEPS = 3; // Input (combined) -> Progress -> Results
const defaultState: WizardState = {
currentStep: 1,
@@ -88,11 +88,9 @@ export const useResearchWizard = (initialKeywords?: string[], initialIndustry?:
case 1:
return state.keywords.length > 0 && state.keywords.every(k => k.trim().length > 0);
case 2:
return true; // Mode selection always allowed
return !!state.results; // Can proceed if we have results
case 3:
return false; // Progress can't be skipped
case 4:
return false; // Results can't be skipped
return false; // Results is the last step
default:
return false;
}

View File

@@ -0,0 +1,571 @@
import React, { useRef, useState, useEffect } from 'react';
import { WizardStepProps } from '../types/research.types';
import { ResearchProvider } from '../../../services/blogWriterApi';
const industries = [
'General',
'Technology',
'Business',
'Marketing',
'Finance',
'Healthcare',
'Education',
'Real Estate',
'Entertainment',
'Food & Beverage',
'Travel',
'Fashion',
'Sports',
'Science',
'Law',
'Other',
];
const researchModes = [
{ value: 'basic', label: 'Basic - Quick insights' },
{ value: 'comprehensive', label: 'Comprehensive - In-depth analysis' },
{ value: 'targeted', label: 'Targeted - Specific focus' },
];
const providers = [
{ value: 'google', label: '🔍 Google Search' },
{ value: 'exa', label: '🧠 Exa Neural Search' },
];
const exaCategories = [
{ value: '', label: 'All Categories' },
{ value: 'company', label: 'Company Profiles' },
{ value: 'research paper', label: 'Research Papers' },
{ value: 'news', label: 'News Articles' },
{ value: 'linkedin profile', label: 'LinkedIn Profiles' },
{ value: 'github', label: 'GitHub Repos' },
{ value: 'tweet', label: 'Tweets' },
{ value: 'movie', label: 'Movies' },
{ value: 'song', label: 'Songs' },
{ value: 'personal site', label: 'Personal Sites' },
{ value: 'pdf', label: 'PDF Documents' },
{ value: 'financial report', label: 'Financial Reports' },
];
const exaSearchTypes = [
{ value: 'auto', label: 'Auto - Let AI decide' },
{ value: 'keyword', label: 'Keyword - Precise matching' },
{ 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"
];
export const ResearchInput: React.FC<WizardStepProps> = ({ state, onUpdate }) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [currentPlaceholder, setCurrentPlaceholder] = useState(0);
// Rotate placeholder examples every 4 seconds
useEffect(() => {
const interval = setInterval(() => {
setCurrentPlaceholder((prev) => (prev + 1) % placeholderExamples.length);
}, 4000);
return () => clearInterval(interval);
}, []);
const handleKeywordsChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
const keywords = value.split(',').map(k => k.trim()).filter(Boolean);
onUpdate({ keywords });
};
const handleIndustryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onUpdate({ industry: e.target.value });
};
const handleAudienceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onUpdate({ targetAudience: e.target.value });
};
const handleModeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const mode = e.target.value as any;
onUpdate({ researchMode: mode });
};
const handleProviderChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const provider = e.target.value as ResearchProvider;
onUpdate({ config: { ...state.config, provider } });
};
const handleExaCategoryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
onUpdate({ config: { ...state.config, exa_category: value || undefined } });
};
const handleExaSearchTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value as 'auto' | 'keyword' | 'neural';
onUpdate({ config: { ...state.config, exa_search_type: value } });
};
const handleIncludeDomainsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const domains = value.split(',').map(d => d.trim()).filter(Boolean);
onUpdate({ config: { ...state.config, exa_include_domains: domains } });
};
const handleExcludeDomainsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
const domains = value.split(',').map(d => d.trim()).filter(Boolean);
onUpdate({ config: { ...state.config, exa_exclude_domains: domains } });
};
const handleFileUpload = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
console.log('File selected:', file.name);
// TODO: Implement file upload logic
}
};
return (
<div style={{ maxWidth: '100%' }}>
{/* Main Input Area */}
<div style={{
background: 'rgba(255, 255, 255, 0.6)',
backdropFilter: 'blur(8px)',
border: '2px solid rgba(14, 165, 233, 0.2)',
borderRadius: '16px',
padding: '20px',
marginBottom: '20px',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.4)';
e.currentTarget.style.boxShadow = '0 4px 20px rgba(14, 165, 233, 0.12)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.2)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<label style={{
marginBottom: '12px',
fontSize: '15px',
fontWeight: '600',
color: '#0c4a6e',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<span style={{
fontSize: '20px',
}}>🔍</span>
Research Topic & Keywords
</label>
<div style={{ position: 'relative' }}>
<textarea
value={state.keywords.join(', ')}
onChange={handleKeywordsChange}
placeholder={placeholderExamples[currentPlaceholder]}
style={{
width: '100%',
minHeight: '160px',
padding: '16px',
fontSize: '14px',
lineHeight: '1.6',
border: '1px solid rgba(14, 165, 233, 0.15)',
borderRadius: '12px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
resize: 'vertical',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
transition: 'all 0.2s ease',
boxShadow: 'inset 0 1px 3px rgba(14, 165, 233, 0.05)',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.5)';
e.currentTarget.style.boxShadow = 'inset 0 1px 3px rgba(14, 165, 233, 0.05), 0 0 0 3px rgba(14, 165, 233, 0.1)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.15)';
e.currentTarget.style.boxShadow = 'inset 0 1px 3px rgba(14, 165, 233, 0.05)';
}}
/>
{/* File Upload Button */}
<button
onClick={handleFileUpload}
type="button"
style={{
position: 'absolute',
bottom: '12px',
right: '12px',
padding: '8px 12px',
background: 'rgba(14, 165, 233, 0.1)',
border: '1px solid rgba(14, 165, 233, 0.2)',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '500',
color: '#0369a1',
display: 'flex',
alignItems: 'center',
gap: '6px',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(14, 165, 233, 0.15)';
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(14, 165, 233, 0.1)';
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.2)';
}}
>
📎 Upload Document
</button>
<input
ref={fileInputRef}
type="file"
onChange={handleFileChange}
accept=".txt,.doc,.docx,.pdf"
style={{ display: 'none' }}
/>
</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.
</div>
</div>
{/* Configuration Options */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '16px',
marginBottom: '20px',
}}>
{/* Industry */}
<div>
<label style={{
display: 'block',
marginBottom: '8px',
fontSize: '13px',
fontWeight: '600',
color: '#0c4a6e',
}}>
Industry
</label>
<select
value={state.industry}
onChange={handleIndustryChange}
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';
}}
>
{industries.map(ind => (
<option key={ind} value={ind}>{ind}</option>
))}
</select>
</div>
{/* Research Mode */}
<div>
<label style={{
display: 'block',
marginBottom: '8px',
fontSize: '13px',
fontWeight: '600',
color: '#0c4a6e',
}}>
Research Depth
</label>
<select
value={state.researchMode}
onChange={handleModeChange}
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';
}}
>
{researchModes.map(mode => (
<option key={mode.value} value={mode.value}>{mode.label}</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>
)}
</div>
{/* Exa-Specific Options */}
{state.config.provider === 'exa' && state.researchMode !== 'basic' && (
<div style={{
background: 'linear-gradient(135deg, rgba(139, 92, 246, 0.05) 0%, rgba(99, 102, 241, 0.05) 100%)',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '14px',
padding: '16px',
marginBottom: '20px',
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '14px',
}}>
<span style={{ fontSize: '18px' }}>🧠</span>
<strong style={{ color: '#6b21a8', fontSize: '13px' }}>Exa Neural Search Options</strong>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
gap: '12px',
marginBottom: '12px',
}}>
{/* Exa Category */}
<div>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Content Category
</label>
<select
value={state.config.exa_category || ''}
onChange={handleExaCategoryChange}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
cursor: 'pointer',
}}
>
{exaCategories.map(cat => (
<option key={cat.value} value={cat.value}>{cat.label}</option>
))}
</select>
</div>
{/* Exa Search Type */}
<div>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Search Algorithm
</label>
<select
value={state.config.exa_search_type || 'auto'}
onChange={handleExaSearchTypeChange}
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
cursor: 'pointer',
}}
>
{exaSearchTypes.map(type => (
<option key={type.value} value={type.value}>{type.label}</option>
))}
</select>
</div>
</div>
{/* Domain Filters */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '12px',
}}>
<div>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Include Domains (optional)
</label>
<input
type="text"
value={state.config.exa_include_domains?.join(', ') || ''}
onChange={handleIncludeDomainsChange}
placeholder="e.g., nature.com, arxiv.org"
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
}}
/>
</div>
<div>
<label style={{
display: 'block',
marginBottom: '6px',
fontSize: '12px',
fontWeight: '600',
color: '#6b21a8',
}}>
Exclude Domains (optional)
</label>
<input
type="text"
value={state.config.exa_exclude_domains?.join(', ') || ''}
onChange={handleExcludeDomainsChange}
placeholder="e.g., spam.com, ads.com"
style={{
width: '100%',
padding: '8px 10px',
fontSize: '12px',
border: '1px solid rgba(139, 92, 246, 0.2)',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
}}
/>
</div>
</div>
</div>
)}
{/* Target Audience (Optional) */}
<div>
<label style={{
display: 'block',
marginBottom: '8px',
fontSize: '13px',
fontWeight: '600',
color: '#0c4a6e',
}}>
Target Audience (Optional)
</label>
<input
type="text"
value={state.targetAudience}
onChange={handleAudienceChange}
placeholder="e.g., Marketing professionals, Tech enthusiasts, Business owners"
style={{
width: '100%',
padding: '10px 12px',
fontSize: '13px',
border: '1px solid rgba(14, 165, 233, 0.2)',
borderRadius: '10px',
background: 'rgba(255, 255, 255, 0.9)',
color: '#0f172a',
transition: 'all 0.2s ease',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.5)';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(14, 165, 233, 0.1)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.2)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
</div>
);
};

View File

@@ -1,11 +1,20 @@
import React, { useEffect } from 'react';
import { WizardStepProps } from '../types/research.types';
import { useResearchExecution } from '../hooks/useResearchExecution';
export const StepProgress: React.FC<WizardStepProps> = ({ state, onNext, onUpdate }) => {
const { executeResearch, stopExecution, isExecuting, error, progressMessages, currentStatus } = useResearchExecution();
export const StepProgress: React.FC<WizardStepProps> = ({ state, onNext, onUpdate, execution }) => {
const { executeResearch, stopExecution, isExecuting, error, progressMessages, currentStatus } = execution || {
executeResearch: async () => null,
stopExecution: () => {},
isExecuting: false,
error: 'No execution provided',
progressMessages: [],
currentStatus: 'idle'
};
useEffect(() => {
// Only start research if execution is available
if (!execution) return;
// Start research when this step is reached
const startResearch = async () => {
const taskId = await executeResearch(state);
@@ -22,18 +31,19 @@ export const StepProgress: React.FC<WizardStepProps> = ({ state, onNext, onUpdat
stopExecution();
}
};
}, []); // Run once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run once on mount - stable references
// Move to next step when research completes
useEffect(() => {
if (!isExecuting && progressMessages.length > 0) {
// Small delay to show final message
const timer = setTimeout(() => {
onNext();
}, 1000);
return () => clearTimeout(timer);
}
}, [isExecuting, progressMessages.length, onNext]);
// Note: Navigation to next step is handled by ResearchWizard when results are received
// Handle missing execution gracefully
if (!execution) {
return (
<div style={{ padding: '24px', textAlign: 'center' }}>
<p style={{ color: '#666' }}>Loading execution...</p>
</div>
);
}
const getStatusIcon = () => {
if (error) return '❌';
@@ -48,11 +58,14 @@ export const StepProgress: React.FC<WizardStepProps> = ({ state, onNext, onUpdat
return '#1976d2';
};
const providerName = state.config.provider === 'exa' ? 'Exa Neural' : 'Google Search';
const modeName = state.researchMode === 'basic' ? 'Basic' : state.researchMode === 'comprehensive' ? 'Comprehensive' : 'Targeted';
return (
<div style={{ padding: '24px', maxWidth: '800px', margin: '0 auto' }}>
<h2 style={{ marginBottom: '8px', color: '#333' }}>Researching...</h2>
<p style={{ marginBottom: '24px', color: '#666', fontSize: '15px' }}>
Gathering insights from Google Search grounding
{modeName} research with {providerName}
</p>
{/* Status Display */}

View File

@@ -3,7 +3,7 @@ import { WizardStepProps } from '../types/research.types';
import { ResearchResults } from '../../BlogWriter/ResearchResults';
import { BlogResearchResponse } from '../../../services/blogWriterApi';
export const StepResults: React.FC<WizardStepProps> = ({ state, onBack }) => {
export const StepResults: React.FC<WizardStepProps> = ({ state, onUpdate, onBack }) => {
if (!state.results) {
return (
<div style={{ padding: '24px', textAlign: 'center' }}>
@@ -23,6 +23,14 @@ export const StepResults: React.FC<WizardStepProps> = ({ state, onBack }) => {
URL.revokeObjectURL(url);
};
const handleStartNew = () => {
// Reset to step 1 and clear results
onUpdate({
currentStep: 1,
results: null
});
};
return (
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '24px' }}>
<div style={{
@@ -36,6 +44,21 @@ export const StepResults: React.FC<WizardStepProps> = ({ state, onBack }) => {
<h2 style={{ margin: 0, color: '#333' }}>Research Results</h2>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
onClick={onBack}
style={{
padding: '8px 16px',
backgroundColor: '#f5f5f5',
color: '#333',
border: '1px solid #ddd',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
}}
>
Back
</button>
<button
onClick={handleExport}
style={{
@@ -55,7 +78,7 @@ export const StepResults: React.FC<WizardStepProps> = ({ state, onBack }) => {
</button>
<button
onClick={onBack}
onClick={handleStartNew}
style={{
padding: '8px 16px',
backgroundColor: '#f5f5f5',
@@ -66,7 +89,7 @@ export const StepResults: React.FC<WizardStepProps> = ({ state, onBack }) => {
fontSize: '14px',
}}
>
Start New Research
🔄 Start New Research
</button>
</div>
</div>

View File

@@ -10,11 +10,22 @@ export interface WizardState {
results: BlogResearchResponse | null;
}
export interface ResearchExecution {
executeResearch: (state: WizardState) => Promise<string | null>;
stopExecution: () => void;
isExecuting: boolean;
error: string | null;
progressMessages: Array<{ timestamp: string; message: string }>;
currentStatus: string;
result: any;
}
export interface WizardStepProps {
state: WizardState;
onUpdate: (updates: Partial<WizardState>) => void;
onNext: () => void;
onBack: () => void;
execution?: ResearchExecution;
}
export interface ResearchWizardProps {

View File

@@ -26,8 +26,22 @@ const SEOCopilotKitProvider: React.FC<SEOCopilotKitProviderProps> = ({
} = useSEOCopilotStore();
const { analysisData } = useSEOCopilotStore();
// Get the CopilotKit API key from environment variables
const publicApiKey = process.env.REACT_APP_COPILOTKIT_API_KEY;
// Get the CopilotKit API key from the same sources as App.tsx
// Check localStorage first, then fall back to environment variable
const publicApiKey = useMemo(() => {
const savedKey = typeof window !== 'undefined'
? localStorage.getItem('copilotkit_api_key')
: null;
const envKey = process.env.REACT_APP_COPILOTKIT_API_KEY || '';
const key = (savedKey || envKey).trim();
// Validate key format if present
if (key && !key.startsWith('ck_pub_')) {
console.warn('SEOCopilotKitProvider: CopilotKit API key format invalid - must start with ck_pub_');
}
return key;
}, []);
// Derive a friendly site/brand name from the URL for personalization
const domainRootName = useMemo(() => {