Research component integration, Copilotkit implementation, SEO copilotkit implementation, Wix SEO metadata complete, Wix SEO metadata review
This commit is contained in:
216
frontend/src/components/Research/ResearchWizard.tsx
Normal file
216
frontend/src/components/Research/ResearchWizard.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
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 { StepProgress } from './steps/StepProgress';
|
||||
import { StepResults } from './steps/StepResults';
|
||||
import { ResearchWizardProps } from './types/research.types';
|
||||
|
||||
export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
||||
onComplete,
|
||||
onCancel,
|
||||
initialKeywords,
|
||||
initialIndustry,
|
||||
}) => {
|
||||
const wizard = useResearchWizard(initialKeywords, initialIndustry);
|
||||
const execution = useResearchExecution();
|
||||
|
||||
// Handle results from execution
|
||||
useEffect(() => {
|
||||
if (execution.result && !execution.isExecuting) {
|
||||
wizard.updateState({ results: execution.result });
|
||||
if (wizard.state.currentStep === 3) {
|
||||
wizard.nextStep();
|
||||
}
|
||||
}
|
||||
}, [execution.result, execution.isExecuting]);
|
||||
|
||||
// Handle completion callback
|
||||
useEffect(() => {
|
||||
if (wizard.state.results && onComplete) {
|
||||
onComplete(wizard.state.results);
|
||||
}
|
||||
}, [wizard.state.results, onComplete]);
|
||||
|
||||
const renderStep = () => {
|
||||
const stepProps = {
|
||||
state: wizard.state,
|
||||
onUpdate: wizard.updateState,
|
||||
onNext: wizard.nextStep,
|
||||
onBack: wizard.prevStep,
|
||||
};
|
||||
|
||||
switch (wizard.state.currentStep) {
|
||||
case 1:
|
||||
return <StepKeyword {...stepProps} />;
|
||||
case 2:
|
||||
return <StepOptions {...stepProps} />;
|
||||
case 3:
|
||||
return <StepProgress {...stepProps} />;
|
||||
case 4:
|
||||
return <StepResults {...stepProps} />;
|
||||
default:
|
||||
return <StepKeyword {...stepProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: '20px',
|
||||
}}>
|
||||
{/* Wizard Container */}
|
||||
<div style={{
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
padding: '24px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
}}>
|
||||
<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}
|
||||
</p>
|
||||
</div>
|
||||
{onCancel && (
|
||||
<button
|
||||
onClick={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',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div style={{
|
||||
backgroundColor: '#f0f0f0',
|
||||
height: '4px',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#1976d2',
|
||||
height: '100%',
|
||||
width: `${(wizard.state.currentStep / wizard.maxSteps) * 100}%`,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step Indicators */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
padding: '20px 40px',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
}}>
|
||||
{[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}
|
||||
</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' }}>
|
||||
{renderStep()}
|
||||
</div>
|
||||
|
||||
{/* Navigation Footer */}
|
||||
{wizard.state.currentStep <= 2 && (
|
||||
<div style={{
|
||||
padding: '20px 24px',
|
||||
borderTop: '1px solid #e0e0e0',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#fafafa',
|
||||
}}>
|
||||
<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',
|
||||
cursor: wizard.isFirstStep ? 'not-allowed' : 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={wizard.nextStep}
|
||||
disabled={!wizard.canGoNext()}
|
||||
style={{
|
||||
padding: '10px 24px',
|
||||
backgroundColor: wizard.canGoNext() ? '#1976d2' : '#e0e0e0',
|
||||
color: wizard.canGoNext() ? 'white' : '#999',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: wizard.canGoNext() ? 'pointer' : 'not-allowed',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
}}
|
||||
>
|
||||
{wizard.isLastStep ? 'Finish' : 'Next →'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResearchWizard;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { blogWriterApi, BlogResearchRequest, BlogResearchResponse } from '../../../services/blogWriterApi';
|
||||
import { useResearchPolling } from '../../../hooks/usePolling';
|
||||
import { researchCache } from '../../../services/researchCache';
|
||||
import { WizardState } from '../types/research.types';
|
||||
|
||||
export const useResearchExecution = () => {
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const polling = useResearchPolling({
|
||||
onComplete: (result) => {
|
||||
if (result && result.keywords) {
|
||||
researchCache.cacheResult(
|
||||
result.keywords,
|
||||
'General',
|
||||
'General',
|
||||
result
|
||||
);
|
||||
}
|
||||
setIsExecuting(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Research polling error:', error);
|
||||
setError(error);
|
||||
setIsExecuting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const executeResearch = useCallback(async (state: WizardState): Promise<string | null> => {
|
||||
setIsExecuting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Check cache first
|
||||
const cachedResult = researchCache.getCachedResult(
|
||||
state.keywords,
|
||||
state.industry,
|
||||
state.targetAudience
|
||||
);
|
||||
|
||||
if (cachedResult) {
|
||||
setIsExecuting(false);
|
||||
return 'cached';
|
||||
}
|
||||
|
||||
const payload: BlogResearchRequest = {
|
||||
keywords: state.keywords,
|
||||
industry: state.industry,
|
||||
target_audience: state.targetAudience,
|
||||
research_mode: state.researchMode,
|
||||
config: state.config,
|
||||
};
|
||||
|
||||
const { task_id } = await blogWriterApi.startResearch(payload);
|
||||
polling.startPolling(task_id);
|
||||
return task_id;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(errorMessage);
|
||||
setIsExecuting(false);
|
||||
return null;
|
||||
}
|
||||
}, [polling]);
|
||||
|
||||
const stopExecution = useCallback(() => {
|
||||
polling.stopPolling();
|
||||
setIsExecuting(false);
|
||||
setError(null);
|
||||
}, [polling]);
|
||||
|
||||
return {
|
||||
executeResearch,
|
||||
stopExecution,
|
||||
isExecuting,
|
||||
error,
|
||||
progressMessages: polling.progressMessages,
|
||||
currentStatus: polling.currentStatus,
|
||||
result: polling.result,
|
||||
};
|
||||
};
|
||||
|
||||
116
frontend/src/components/Research/hooks/useResearchWizard.ts
Normal file
116
frontend/src/components/Research/hooks/useResearchWizard.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
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 defaultState: WizardState = {
|
||||
currentStep: 1,
|
||||
keywords: [],
|
||||
industry: 'General',
|
||||
targetAudience: 'General',
|
||||
researchMode: 'basic' as ResearchMode,
|
||||
config: {
|
||||
mode: 'basic',
|
||||
provider: 'google',
|
||||
max_sources: 10,
|
||||
include_statistics: true,
|
||||
include_expert_quotes: true,
|
||||
include_competitors: true,
|
||||
include_trends: true,
|
||||
},
|
||||
results: null,
|
||||
};
|
||||
|
||||
export const useResearchWizard = (initialKeywords?: string[], initialIndustry?: string) => {
|
||||
const [state, setState] = useState<WizardState>(() => {
|
||||
// Try to load from localStorage first
|
||||
const saved = localStorage.getItem(WIZARD_STATE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse saved wizard state', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Use defaults or initial values
|
||||
return {
|
||||
...defaultState,
|
||||
keywords: initialKeywords || [],
|
||||
industry: initialIndustry || defaultState.industry,
|
||||
};
|
||||
});
|
||||
|
||||
// Persist state to localStorage
|
||||
useEffect(() => {
|
||||
if (state.currentStep > 1) {
|
||||
localStorage.setItem(WIZARD_STATE_KEY, JSON.stringify(state));
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
const updateState = useCallback((updates: Partial<WizardState>) => {
|
||||
setState(prev => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
setState(prev => {
|
||||
if (prev.currentStep >= MAX_STEPS) return prev;
|
||||
return { ...prev, currentStep: prev.currentStep + 1 };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
setState(prev => {
|
||||
if (prev.currentStep <= 1) return prev;
|
||||
return { ...prev, currentStep: prev.currentStep - 1 };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
const resetState = {
|
||||
...defaultState,
|
||||
keywords: initialKeywords || [],
|
||||
industry: initialIndustry || defaultState.industry,
|
||||
};
|
||||
setState(resetState);
|
||||
localStorage.removeItem(WIZARD_STATE_KEY);
|
||||
}, [initialKeywords, initialIndustry]);
|
||||
|
||||
const clearResults = useCallback(() => {
|
||||
setState(prev => ({ ...prev, results: null }));
|
||||
}, []);
|
||||
|
||||
const canGoNext = useCallback((): boolean => {
|
||||
switch (state.currentStep) {
|
||||
case 1:
|
||||
return state.keywords.length > 0 && state.keywords.every(k => k.trim().length > 0);
|
||||
case 2:
|
||||
return true; // Mode selection always allowed
|
||||
case 3:
|
||||
return false; // Progress can't be skipped
|
||||
case 4:
|
||||
return false; // Results can't be skipped
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
state,
|
||||
updateState,
|
||||
nextStep,
|
||||
prevStep,
|
||||
reset,
|
||||
clearResults,
|
||||
canGoNext,
|
||||
isFirstStep: state.currentStep === 1,
|
||||
isLastStep: state.currentStep === MAX_STEPS,
|
||||
maxSteps: MAX_STEPS,
|
||||
};
|
||||
};
|
||||
|
||||
export type UseResearchWizardReturn = ReturnType<typeof useResearchWizard>;
|
||||
|
||||
5
frontend/src/components/Research/index.tsx
Normal file
5
frontend/src/components/Research/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export { ResearchWizard } from './ResearchWizard';
|
||||
export { useResearchWizard } from './hooks/useResearchWizard';
|
||||
export { useResearchExecution } from './hooks/useResearchExecution';
|
||||
export * from './types/research.types';
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Blog Writer Integration Adapter for Research Component
|
||||
*
|
||||
* This adapter provides a simple way to integrate the ResearchWizard
|
||||
* into the BlogWriter's research phase.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ResearchWizard } from '../ResearchWizard';
|
||||
import { BlogResearchResponse } from '../../../services/blogWriterApi';
|
||||
|
||||
interface BlogWriterResearchAdapterProps {
|
||||
onResearchComplete: (research: BlogResearchResponse) => void;
|
||||
onCancel?: () => void;
|
||||
initialKeywords?: string[];
|
||||
initialIndustry?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter component that wraps ResearchWizard for BlogWriter integration.
|
||||
* Provides a clean interface for switching between CopilotKit and wizard-based research.
|
||||
*/
|
||||
export const BlogWriterResearchAdapter: React.FC<BlogWriterResearchAdapterProps> = ({
|
||||
onResearchComplete,
|
||||
onCancel,
|
||||
initialKeywords,
|
||||
initialIndustry,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: 'white',
|
||||
}}>
|
||||
<ResearchWizard
|
||||
onComplete={onResearchComplete}
|
||||
onCancel={onCancel}
|
||||
initialKeywords={initialKeywords}
|
||||
initialIndustry={initialIndustry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlogWriterResearchAdapter;
|
||||
|
||||
/**
|
||||
* USAGE EXAMPLE:
|
||||
*
|
||||
* In BlogWriter.tsx, replace the research phase content with:
|
||||
*
|
||||
* {currentPhase === 'research' && !research && (
|
||||
* <BlogWriterResearchAdapter
|
||||
* onResearchComplete={(res) => {
|
||||
* handleResearchComplete(res);
|
||||
* // Optionally auto-advance to outline phase
|
||||
* navigateToPhase('outline');
|
||||
* }}
|
||||
* onCancel={() => {
|
||||
* // Navigate back to dashboard
|
||||
* navigateToPhase('research');
|
||||
* }}
|
||||
* initialKeywords={[]}
|
||||
* initialIndustry="General"
|
||||
* />
|
||||
* )}
|
||||
*
|
||||
* Note: This maintains backward compatibility. The existing CopilotKit/manual
|
||||
* research flow continues to work. This provides an alternative UI option.
|
||||
*/
|
||||
|
||||
133
frontend/src/components/Research/steps/StepKeyword.tsx
Normal file
133
frontend/src/components/Research/steps/StepKeyword.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { WizardStepProps } from '../types/research.types';
|
||||
|
||||
const industries = [
|
||||
'General',
|
||||
'Technology',
|
||||
'Business',
|
||||
'Marketing',
|
||||
'Finance',
|
||||
'Healthcare',
|
||||
'Education',
|
||||
'Real Estate',
|
||||
'Entertainment',
|
||||
'Food & Beverage',
|
||||
'Travel',
|
||||
'Fashion',
|
||||
'Sports',
|
||||
'Science',
|
||||
'Law',
|
||||
'Other',
|
||||
];
|
||||
|
||||
export const StepKeyword: React.FC<WizardStepProps> = ({ state, onUpdate }) => {
|
||||
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 keywordText = state.keywords.join(', ');
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h2 style={{ marginBottom: '8px', color: '#333' }}>🔍 Research Setup</h2>
|
||||
<p style={{ marginBottom: '24px', color: '#666', fontSize: '15px' }}>
|
||||
Enter your keywords, industry, and target audience to start research.
|
||||
</p>
|
||||
|
||||
{/* Keywords Input */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600', color: '#555' }}>
|
||||
Keywords *
|
||||
</label>
|
||||
<textarea
|
||||
value={keywordText}
|
||||
onChange={handleKeywordsChange}
|
||||
placeholder="e.g., AI in marketing, automation tools, customer engagement"
|
||||
rows={4}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
resize: 'vertical',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<p style={{ marginTop: '4px', fontSize: '12px', color: '#888' }}>
|
||||
Separate multiple keywords with commas
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Industry Selection */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600', color: '#555' }}>
|
||||
Industry
|
||||
</label>
|
||||
<select
|
||||
value={state.industry}
|
||||
onChange={handleIndustryChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
backgroundColor: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{industries.map(ind => (
|
||||
<option key={ind} value={ind}>{ind}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Target Audience */}
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: '600', color: '#555' }}>
|
||||
Target Audience
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={state.targetAudience}
|
||||
onChange={handleAudienceChange}
|
||||
placeholder="e.g., Digital marketers, Small business owners"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#f0f7ff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #b3d9ff',
|
||||
fontSize: '13px',
|
||||
color: '#004085',
|
||||
}}>
|
||||
💡 <strong>Tip:</strong> Be specific with your keywords. The more precise your keywords, the better your research results.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
182
frontend/src/components/Research/steps/StepOptions.tsx
Normal file
182
frontend/src/components/Research/steps/StepOptions.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React from 'react';
|
||||
import { WizardStepProps, ModeCardInfo } from '../types/research.types';
|
||||
import { ResearchProvider } from '../../../services/blogWriterApi';
|
||||
|
||||
const modeCards: ModeCardInfo[] = [
|
||||
{
|
||||
mode: 'basic',
|
||||
title: 'Basic Research',
|
||||
description: 'Quick keyword-focused analysis for fast results',
|
||||
features: [
|
||||
'Primary & secondary keywords',
|
||||
'Current trends overview',
|
||||
'Top 5 content angles',
|
||||
'Key statistics',
|
||||
],
|
||||
icon: '⚡',
|
||||
},
|
||||
{
|
||||
mode: 'comprehensive',
|
||||
title: 'Comprehensive Research',
|
||||
description: 'Deep analysis with full competitive intelligence',
|
||||
features: [
|
||||
'All basic features',
|
||||
'Expert quotes & opinions',
|
||||
'Competitor analysis',
|
||||
'Market forecasts',
|
||||
'Best practices & case studies',
|
||||
'Content gaps identification',
|
||||
],
|
||||
icon: '📊',
|
||||
},
|
||||
{
|
||||
mode: 'targeted',
|
||||
title: 'Targeted Research',
|
||||
description: 'Customize what you need most',
|
||||
features: [
|
||||
'Select specific components',
|
||||
'Choose date ranges',
|
||||
'Filter source types',
|
||||
'Control depth & scope',
|
||||
],
|
||||
icon: '🎯',
|
||||
},
|
||||
];
|
||||
|
||||
export const StepOptions: React.FC<WizardStepProps> = ({ state, onUpdate }) => {
|
||||
const handleModeChange = (mode: any) => {
|
||||
onUpdate({ researchMode: mode });
|
||||
};
|
||||
|
||||
const handleProviderChange = (provider: ResearchProvider) => {
|
||||
onUpdate({ config: { ...state.config, provider } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: '1000px', margin: '0 auto' }}>
|
||||
<h2 style={{ marginBottom: '8px', color: '#333' }}>Choose Research Mode</h2>
|
||||
<p style={{ marginBottom: '24px', color: '#666', fontSize: '15px' }}>
|
||||
Select the type of research that best fits your needs.
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||
gap: '20px',
|
||||
marginBottom: '24px',
|
||||
}}>
|
||||
{modeCards.map(card => (
|
||||
<div
|
||||
key={card.mode}
|
||||
onClick={() => handleModeChange(card.mode)}
|
||||
style={{
|
||||
border: state.researchMode === card.mode ? '2px solid #1976d2' : '2px solid #e0e0e0',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
backgroundColor: state.researchMode === card.mode ? '#f0f7ff' : 'white',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (state.researchMode !== card.mode) {
|
||||
e.currentTarget.style.borderColor = '#90caf9';
|
||||
e.currentTarget.style.backgroundColor = '#fafafa';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (state.researchMode !== card.mode) {
|
||||
e.currentTarget.style.borderColor = '#e0e0e0';
|
||||
e.currentTarget.style.backgroundColor = 'white';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<span style={{ fontSize: '32px', marginRight: '12px' }}>{card.icon}</span>
|
||||
<h3 style={{ margin: 0, color: '#333', fontSize: '18px' }}>{card.title}</h3>
|
||||
</div>
|
||||
<p style={{ marginBottom: '16px', color: '#666', fontSize: '14px' }}>
|
||||
{card.description}
|
||||
</p>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', fontSize: '13px', color: '#555' }}>
|
||||
{card.features.map((feature, idx) => (
|
||||
<li key={idx} style={{ marginBottom: '4px' }}>{feature}</li>
|
||||
))}
|
||||
</ul>
|
||||
{state.researchMode === card.mode && (
|
||||
<div style={{
|
||||
marginTop: '16px',
|
||||
padding: '8px',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
borderRadius: '6px',
|
||||
textAlign: 'center',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
}}>
|
||||
✓ Selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{state.researchMode !== 'basic' && (
|
||||
<div style={{ marginBottom: '24px', border: '1px solid #e0e0e0', borderRadius: '8px', padding: '15px', backgroundColor: '#f9f9f9' }}>
|
||||
<h3 style={{ marginTop: 0, marginBottom: '12px', color: '#555', fontSize: '16px' }}>
|
||||
🔍 Research Provider
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<div
|
||||
onClick={() => handleProviderChange('google')}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
border: '2px solid',
|
||||
borderColor: (state.config.provider === 'google' || !state.config.provider) ? '#1976d2' : '#ddd',
|
||||
backgroundColor: (state.config.provider === 'google' || !state.config.provider) ? '#e3f2fd' : 'white',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: '600', marginBottom: '4px' }}>Google Search</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>
|
||||
Fast, broad coverage, trending topics
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => handleProviderChange('exa')}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
border: '2px solid',
|
||||
borderColor: state.config.provider === 'exa' ? '#7c3aed' : '#ddd',
|
||||
backgroundColor: state.config.provider === 'exa' ? '#f3e8ff' : 'white',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: '600', marginBottom: '4px' }}>Exa Neural</div>
|
||||
<div style={{ fontSize: '11px', color: '#666' }}>
|
||||
Deep research, rich citations, semantic search
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#fff3e0',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #ffb74d',
|
||||
fontSize: '13px',
|
||||
color: '#e65100',
|
||||
}}>
|
||||
ℹ️ <strong>Note:</strong> You can always run additional research if you need more information later.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
153
frontend/src/components/Research/steps/StepProgress.tsx
Normal file
153
frontend/src/components/Research/steps/StepProgress.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
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();
|
||||
|
||||
useEffect(() => {
|
||||
// Start research when this step is reached
|
||||
const startResearch = async () => {
|
||||
const taskId = await executeResearch(state);
|
||||
if (taskId === 'cached') {
|
||||
// If cached, move to results immediately
|
||||
// The parent will handle this
|
||||
}
|
||||
};
|
||||
|
||||
startResearch();
|
||||
|
||||
return () => {
|
||||
if (isExecuting) {
|
||||
stopExecution();
|
||||
}
|
||||
};
|
||||
}, []); // Run once on mount
|
||||
|
||||
// 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]);
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (error) return '❌';
|
||||
if (!isExecuting && progressMessages.length > 0) return '✅';
|
||||
if (currentStatus === 'completed') return '✅';
|
||||
return '🔄';
|
||||
};
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (error) return '#f44336';
|
||||
if (!isExecuting && progressMessages.length > 0) return '#4caf50';
|
||||
return '#1976d2';
|
||||
};
|
||||
|
||||
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
|
||||
</p>
|
||||
|
||||
{/* Status Display */}
|
||||
<div style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: '12px',
|
||||
padding: '24px',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{getStatusIcon()}</div>
|
||||
|
||||
{error ? (
|
||||
<>
|
||||
<h3 style={{ color: getStatusColor(), marginBottom: '8px' }}>Error</h3>
|
||||
<p style={{ color: '#666', fontSize: '14px' }}>{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
marginTop: '16px',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 style={{ color: getStatusColor(), marginBottom: '8px' }}>
|
||||
{currentStatus === 'completed' ? 'Complete!' : 'In Progress'}
|
||||
</h3>
|
||||
<p style={{ color: '#666', fontSize: '14px' }}>
|
||||
{isExecuting ? 'Analyzing sources and generating insights...' : 'Finalizing results...'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Messages */}
|
||||
{progressMessages.length > 0 && (
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
maxHeight: '300px',
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
<div style={{ padding: '16px', borderBottom: '1px solid #e0e0e0' }}>
|
||||
<strong style={{ fontSize: '14px', color: '#333' }}>Progress Updates</strong>
|
||||
</div>
|
||||
{progressMessages.map((msg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: idx < progressMessages.length - 1 ? '1px solid #f0f0f0' : 'none',
|
||||
fontSize: '13px',
|
||||
color: '#555',
|
||||
}}
|
||||
>
|
||||
{idx === progressMessages.length - 1 && isExecuting && (
|
||||
<span style={{ marginRight: '8px' }}>🔄</span>
|
||||
)}
|
||||
{msg.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancel Button */}
|
||||
{isExecuting && (
|
||||
<div style={{ marginTop: '24px', textAlign: 'center' }}>
|
||||
<button
|
||||
onClick={stopExecution}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#f44336',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Cancel Research
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
103
frontend/src/components/Research/steps/StepResults.tsx
Normal file
103
frontend/src/components/Research/steps/StepResults.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { WizardStepProps } from '../types/research.types';
|
||||
import { ResearchResults } from '../../BlogWriter/ResearchResults';
|
||||
import { BlogResearchResponse } from '../../../services/blogWriterApi';
|
||||
|
||||
export const StepResults: React.FC<WizardStepProps> = ({ state, onBack }) => {
|
||||
if (!state.results) {
|
||||
return (
|
||||
<div style={{ padding: '24px', textAlign: 'center' }}>
|
||||
<p style={{ color: '#666' }}>No results available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
const dataStr = JSON.stringify(state.results, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `research-${state.keywords.join('-')}-${Date.now()}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '24px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '24px',
|
||||
flexWrap: 'wrap',
|
||||
gap: '16px',
|
||||
}}>
|
||||
<h2 style={{ margin: 0, color: '#333' }}>Research Results</h2>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1976d2',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
📥 Export JSON
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onBack}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#333',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
← Start New Research
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Display */}
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e0e0e0',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<ResearchResults research={state.results} />
|
||||
</div>
|
||||
|
||||
{/* Action Section */}
|
||||
<div style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px',
|
||||
backgroundColor: '#f0f7ff',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #b3d9ff',
|
||||
}}>
|
||||
<h4 style={{ marginBottom: '8px', color: '#004085' }}>Next Steps</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: '#004085', fontSize: '14px' }}>
|
||||
<li>Review the research insights and sources</li>
|
||||
<li>Explore content angles and competitor analysis</li>
|
||||
<li>Use this research to create your blog outline</li>
|
||||
<li>Export the data for reference</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
34
frontend/src/components/Research/types/research.types.ts
Normal file
34
frontend/src/components/Research/types/research.types.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { BlogResearchResponse, ResearchMode, ResearchConfig } from '../../../services/blogWriterApi';
|
||||
|
||||
export interface WizardState {
|
||||
currentStep: number;
|
||||
keywords: string[];
|
||||
industry: string;
|
||||
targetAudience: string;
|
||||
researchMode: ResearchMode;
|
||||
config: ResearchConfig;
|
||||
results: BlogResearchResponse | null;
|
||||
}
|
||||
|
||||
export interface WizardStepProps {
|
||||
state: WizardState;
|
||||
onUpdate: (updates: Partial<WizardState>) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export interface ResearchWizardProps {
|
||||
onComplete?: (results: BlogResearchResponse) => void;
|
||||
onCancel?: () => void;
|
||||
initialKeywords?: string[];
|
||||
initialIndustry?: string;
|
||||
}
|
||||
|
||||
export interface ModeCardInfo {
|
||||
mode: ResearchMode;
|
||||
title: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
icon: string;
|
||||
}
|
||||
|
||||
17
frontend/src/components/Research/utils/researchUtils.ts
Normal file
17
frontend/src/components/Research/utils/researchUtils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Utility functions for research component
|
||||
|
||||
export const formatKeywords = (keywords: string[]): string => {
|
||||
return keywords.join(', ');
|
||||
};
|
||||
|
||||
export const parseKeywords = (keywordsString: string): string[] => {
|
||||
return keywordsString
|
||||
.split(',')
|
||||
.map(k => k.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
export const validateKeywords = (keywords: string[]): boolean => {
|
||||
return keywords.length > 0 && keywords.every(k => k.trim().length > 0);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user